Compare commits

...

28 Commits
v0.9.4 ... dev

Author SHA1 Message Date
null 652669920b docs: record the v0.9.8 deploy and what was checked after it
deploy.sh reported "0.9.6 -> 0.9.6" and an unchanged digest, which is #236
doing exactly what it is filed for: it reads both before the container is
replaced. What actually happened, checked independently: qn-website-dev is
healthy on v0.9.8 with 0 restarts, and its image id 62d076e3800d is the id of
the image release.sh built. Both front doors answer ok, the crawler audit is
clean across 18 pages as all five agents, 18 sitemap entries carry a lastmod,
and the device sweep against production reports zero findings across ten
profiles and 190 page loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 19:32:32 -05:00
null 0e4718334b chore(release): v0.9.8 2026-09-10 19:21:51 -05:00
null 12f1fd54df docs: no em dashes in what ae966e8 added
The rule is none in replies, commits, issues, docs or site copy, and the last
commit put 40 of them into five documents, two scripts and two comments. Nothing
reached the site: dist/ has none, and the content validator refuses U+2014 in
src/data. Rewritten by sentence rather than substituted, so the punctuation
carries the same meaning it did.

scripts/lib/css-audit.js is a copy of another repository's file and its 20 were
repunctuated with it. Its header now says so, because "lifted verbatim" would
otherwise be a claim a byte diff disproves. The logic is untouched, and the
sweep still reports zero findings on all ten devices.

The four Batch 19 issue bodies were edited in the tracker for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 19:12:23 -05:00
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
59 changed files with 4356 additions and 314 deletions

View File

@ -26,6 +26,27 @@ db
logs logs
*.log *.log
# Client material and operator credentials. None of it belongs in the build
# context, which is sent to the Docker daemon in full: .drop/ holds the original
# site drop and the owner's copy sheets, zoho.md holds the live reCAPTCHA secret
# and the Zoho tokens, and the zips are 30 MB each. The final image copies only
# built output, so none of this has ever shipped, but one careless COPY would
# change that, and every build sends it across for no reason.
.drop
zoho.md
Levi.md
*.zip
*.eml
*.pdf
# Agent workspaces and local editor state. Gitignored, and no more use to a
# build than they are to the image.
.claude
.codex
.agents
.learnings
*.code-workspace
# Private docs (ignored per requirements) # Private docs (ignored per requirements)
DEVELOPMENT_LOG.md DEVELOPMENT_LOG.md
FUTURE.md FUTURE.md

View File

@ -132,9 +132,17 @@ else
fi fi
# Said last so it is the thing still on screen when the editor opens. # Said last so it is the thing still on screen when the editor opens.
if ! git diff --quiet; then #
say "NOTE: unstaged changes are present. The guards ran against the working" # Untracked files count here too, and used not to. The build above runs against
say " tree, so they did not verify this commit in isolation." # the WORKING TREE: a new module that is imported but never added makes the
# build pass and the commit itself unbuildable, and `docker build .` would ship
# the file anyway while the tagged commit could not produce it.
untracked=$(git ls-files --others --exclude-standard | wc -l | tr -d ' ')
if ! git diff --quiet || [ "$untracked" != "0" ]; then
say "NOTE: the guards ran against the working tree, so they did not verify"
say " this commit in isolation."
git diff --quiet || say " unstaged changes to tracked files are present."
[ "$untracked" = "0" ] || say " ${untracked} untracked file(s) are present, and the build saw them."
fi fi
exit 0 exit 0

View File

@ -19,6 +19,13 @@ COPY . .
ARG VITE_RECAPTCHA_SITE_KEY= ARG VITE_RECAPTCHA_SITE_KEY=
ENV VITE_RECAPTCHA_SITE_KEY=$VITE_RECAPTCHA_SITE_KEY ENV VITE_RECAPTCHA_SITE_KEY=$VITE_RECAPTCHA_SITE_KEY
# Sitemap dates, computed by scripts/release.sh where git exists. This build
# has no git: .dockerignore excludes .git and this image ships no git binary.
# Without this the sitemap goes out with no lastmod at all, which is what
# production served for months.
ARG SITEMAP_LASTMOD=
ENV SITEMAP_LASTMOD=$SITEMAP_LASTMOD
# Build the frontend # Build the frontend
RUN npm run build RUN npm run build
@ -88,7 +95,7 @@ COPY --from=native-deps /app/node_modules ./node_modules
# and without it the honest answer to "which version is running?" is "unknown" — # and without it the honest answer to "which version is running?" is "unknown" —
# which is what it reported on 2026-08-18, leaving the digest as the only way to # which is what it reported on 2026-08-18, leaving the digest as the only way to
# tell one deploy from another. docs/OPERATIONS.md step 3 depends on it. # tell one deploy from another. docs/OPERATIONS.md step 3 depends on it.
ARG APP_VERSION=0.9.4 ARG APP_VERSION=0.9.8
LABEL org.opencontainers.image.version="$APP_VERSION" \ LABEL org.opencontainers.image.version="$APP_VERSION" \
org.opencontainers.image.title="Queue North Website" \ org.opencontainers.image.title="Queue North Website" \
org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website" org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website"

View File

@ -98,10 +98,18 @@ Primary structure:
* 404 * 404
``` ```
Defined in `src/routes.jsx`. **Every one of them is prerendered to static HTML at Defined in `src/routes.jsx`, and listed for the build in `scripts/lib/routes.js`,
build time** by `scripts/prerender.js` — the SPA hydrates on top. `/privacy-policy` which the prerenderer checks against the router so the two cannot drift. **Every
additionally has a server-rendered fallback because Meta's crawler does not run one of them is prerendered to static HTML at build time** by
JavaScript. `scripts/prerender.js`, and the SPA hydrates on top of that HTML rather than
replacing it. There is no server-rendered fallback for `/privacy-policy` and
there never was: it is readable to Meta's crawler because it is prerendered, like
every other page.
Hydration was in fact failing on every page until 2026-09-10, so React was
discarding the prerendered DOM and rendering the whole page again on the client.
`scripts/qa-browser.mjs` now counts console errors, which is how that is kept
honest.
The standalone `/8x8` route was removed at `0.6.6`; that content now lives inside The standalone `/8x8` route was removed at `0.6.6`; that content now lives inside
the UCaaS and contact-centre service pages. the UCaaS and contact-centre service pages.
@ -224,7 +232,7 @@ Based on the redesign review (see [docs/design/REDESIGN_REVIEW.md](docs/design/R
- **Modern, clean, stable** — not experimental, not hacker aesthetic - **Modern, clean, stable** — not experimental, not hacker aesthetic
- **Business-first** — B2B UCaaS/IT partner, not a dev portfolio - **Business-first** — B2B UCaaS/IT partner, not a dev portfolio
- **Trust-forward** — 8x8 partnership, certifications, uptime SLAs front and center - **Trust-forward** — 8x8 partnership, certifications and support commitments front and center, each one attributable. Uptime figures belong to the vendor that offers them, never to Queue North: see the 2026-09-10 correction in `docs/design/REDESIGN_REVIEW.md`
- **Human but competent** — less corporate fluff, more concrete outcomes - **Human but competent** — less corporate fluff, more concrete outcomes
Color palette evolution (not rip-and-replace): Color palette evolution (not rip-and-replace):

View File

@ -74,6 +74,23 @@ suggest.
room. `npm run docker:test` runs the image locally, which is the closest thing room. `npm run docker:test` runs the image locally, which is the closest thing
that exists. that exists.
## Watchtower is no longer in the picture
Stack 39 runs Watchtower nightly at 02:00 with no label filter and no scope, so
it watches **every** container on this host. Until 2026-08-18 `qn-website-dev`
had no opt-out label and followed the moving `:dev` tag, which means the site
could — and almost certainly did — update itself overnight whenever anything
pushed that tag. That was the undocumented deploy path nobody could find.
Two things now prevent it: the stack pins an immutable version, which Watchtower
can never find an update for, and the service carries
`com.centurylinklabs.watchtower.enable=false`, which excludes it explicitly even
if somebody later puts a floating tag back.
**The consequence is deliberate and worth knowing: nothing updates this site
automatically any more.** Every change reaches production through
`npm run release` and `npm run deploy`, or it does not reach production.
## Where errors go ## Where errors go
**Nowhere. This project has no error tracking.** **Nowhere. This project has no error tracking.**
@ -229,6 +246,21 @@ npm run deploy # deploy: move stack 58 to what :dev now points a
Both take `--dry-run`, and both refuse rather than guess. Run the dry runs first; Both take `--dry-run`, and both refuse rather than guess. Run the dry runs first;
they print exactly what would change. they print exactly what would change.
**The image build cannot see git, and the sitemap needs it.** `.dockerignore`
excludes `.git` and the build image has no git binary, so the `lastmod` dates
that come from commit history silently came out empty: **the production sitemap
carried no dates at all** until 2026-09-10. `release.sh` now computes the dates
where git exists and passes them in as the `SITEMAP_LASTMOD` build arg, which the
builder stage of the `Dockerfile` reads. It then asks the built image whether its
sitemap has dates, and refuses to publish one that does not. Search engines
schedule recrawls partly on `lastmod`, and Bing's index feeds Copilot and ChatGPT
search, so an undated sitemap costs visibility on exactly the surfaces this site
was rewritten for.
`release.sh` also refuses to run while **untracked** files are present, not only
uncommitted ones. `docker build` packs the working tree, so an untracked file
produces an image that works and a tag that cannot rebuild it.
| | | | | |
| --- | --- | | --- | --- |
| Portainer | `https://192.168.1.11:9443` (nebula), API key in `~/.openclaw/credentials/portainer.md` | | Portainer | `https://192.168.1.11:9443` (nebula), API key in `~/.openclaw/credentials/portainer.md` |
@ -236,12 +268,20 @@ they print exactly what would change.
| Stack file on disk | `/data/compose/58/docker-compose.yml` on nebula | | Stack file on disk | `/data/compose/58/docker-compose.yml` on nebula |
**The stack file is NOT this repository's `docker-compose.yml`.** They are two **The stack file is NOT this repository's `docker-compose.yml`.** They are two
separate files that happen to look alike, and they have already drifted — the separate files that happen to look alike, and they drift. Editing the one in git
repository commits `CORS_ORIGIN=https://queuenorth.com` and line 21 of the stack changes nothing about production. That is the single most important sentence in
file says `https://queuenorth.com/`, with a trailing slash. Editing the one in this section.
git changes nothing about production. That is the single most important sentence
in this section, and `deploy.sh` does not paper over it: it reads the deployed They drifted once already: the stack carried `CORS_ORIGIN=https://queuenorth.com/`
file, changes only the `image:` line, and sends the rest back untouched. with a trailing slash while the repository committed it without one. Corrected on
2026-08-18 by `deploy.sh --fix-cors`, which closed #212.
`deploy.sh` does not paper over the split — it reads the deployed file, changes
only the lines you ask for, prints the diff before sending, and asserts the
environment-line count is unchanged. Four differences remain and all four are
correct: the stack uses `image:` where the repo has `build:`, names the container
`qn-website-dev`, publishes `3032:3001` rather than `3001:3001`, and carries the
Watchtower opt-out label.
**What a deploy costs.** It recreates the container, so both public front doors **What a deploy costs.** It recreates the container, so both public front doors
go down together for the few seconds it takes — they reach the same instance. go down together for the few seconds it takes — they reach the same instance.
@ -278,9 +318,11 @@ It also refuses a tag the registry does not hold. A missing tag would otherwise
become an outage: Portainer accepts the file, fails to pull, and leaves the become an outage: Portainer accepts the file, fails to pull, and leaves the
container stopped. container stopped.
**Stack 58 pins `:dev` as this is written**, which is the state the policy **Stack 58 pins `:v0.9.4` as of 2026-08-18.** It pinned `:dev` until then; the
corrects. The first deploy made under it repoints the stack at a version, and first deploy made under this policy repointed it, and the stack file now answers
after that the stack file answers the question on its own. "what is running" on its own. `scripts/status.sh --deployed-version` answers too,
because the image carries `org.opencontainers.image.version` from the same
release.
`:dev` and `:latest` remain in the registry, frozen. They are not deleted `:dev` and `:latest` remain in the registry, frozen. They are not deleted
because the running container was created from `:dev`, and removing the tag an because the running container was created from `:dev`, and removing the tag an

View File

@ -62,8 +62,15 @@ npm install
git config core.hooksPath .githooks # per clone. Not optional. See below git config core.hooksPath .githooks # per clone. Not optional. See below
bash scripts/check-env.sh --file .env # what is configured, before anything reads it bash scripts/check-env.sh --file .env # what is configured, before anything reads it
bash scripts/secrets.sh --tracked # what is already committed bash scripts/secrets.sh --tracked # what is already committed
node scripts/validate-content.js # whether the copy in src/data is publishable
node scripts/audit-html.js # what the built pages actually say
``` ```
`npm run verify` runs that scan too, as guard `20-secrets`, together with
`--built dist/` over the bundle the build just produced. A line that has to show
a credential shape, such as a usage example, carries `secrets-ok:` and a reason
on the same line; that excuses the one line, visibly.
Then [`architecture/GUARDS.md`](architecture/GUARDS.md) before you write a check Then [`architecture/GUARDS.md`](architecture/GUARDS.md) before you write a check
of your own — how to write one that can actually fail. of your own — how to write one that can actually fail.
@ -103,6 +110,13 @@ when `src/`, `server/`, `index.html`, `vite.config.js` or `package.json` is
staged. **That is a build, not a test.** It catches a broken import and will not staged. **That is a build, not a test.** It catches a broken import and will not
catch a broken behaviour. catch a broken behaviour.
It builds the **working tree**, not the commit, so it says so when they differ,
counting untracked files as well as unstaged edits. An untracked module that the
staged code imports makes the build pass and the commit itself unbuildable, and
`docker build` would ship the file anyway while the tag could not rebuild it.
`npm run release` refuses outright while untracked files are present, for the
same reason.
`post-commit` pushes, and that is the intent — but it has a consequence worth `post-commit` pushes, and that is the intent — but it has a consequence worth
holding on to: whatever documentation was not in that commit is now behind the holding on to: whatever documentation was not in that commit is now behind the
code by one push. That is the mechanical reason `docs/WORK_CYCLE.md` asks for doc code by one push. That is the mechanical reason `docs/WORK_CYCLE.md` asks for doc
@ -121,7 +135,7 @@ Run from the repository root.
| --- | --- | | --- | --- |
| `npm install` | dependencies | | `npm install` | dependencies |
| `npm run dev` | Vite and the Express API together, via `concurrently`. Frontend on 5173, API on 3001 | | `npm run dev` | Vite and the Express API together, via `concurrently`. Frontend on 5173, API on 3001 |
| `npm run build` | **three steps**: the client bundle, then an SSR bundle from `src/entry-server.jsx`, then `scripts/prerender.js`, which writes static HTML for every route. This is the only real gate this project has | | `npm run build` | **three steps**: the client bundle, then an SSR bundle from `src/entry-server.jsx`, then `scripts/prerender.js`, which writes static HTML for every route. This is the only real gate this project has, and it refuses rather than emit a wrong page: a render that throws, a Suspense fallback, or a template that already carries a canonical each fail the build, naming the route |
| `npm run build:client` | the client bundle alone. Does **not** prerender — do not use it to produce a release | | `npm run build:client` | the client bundle alone. Does **not** prerender — do not use it to produce a release |
| `npm run preview` | serve the built client | | `npm run preview` | serve the built client |
| `npm start` / `npm run server` | the Express server alone, serving `dist/` | | `npm start` / `npm run server` | the Express server alone, serving `dist/` |
@ -134,9 +148,10 @@ Run from the repository root.
**There is no `npm test`, and that is not an omission in this table.** There is **There is no `npm test`, and that is not an omission in this table.** There is
no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a
standing gap — and it is why `npm run release` says out loud that its gate is a standing gap — and it is why `npm run release` says out loud what its gate
build, a secret scan and a doc-header check rather than pretending those are actually is: a build that validates the content layer, an audit of the built
tests. HTML, a secret scan of the tree and the bundle, and a doc-header check. None of
those exercises a form, an API response, or a page in a browser.
**`release` and `deploy` are two commands on purpose.** Publishing an image and **`release` and `deploy` are two commands on purpose.** Publishing an image and
running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates running it are separate decisions; see `docs/OPERATIONS.md`. A deploy recreates
@ -153,10 +168,10 @@ curl -s https://qn.isnull.dev/api/health # same container, other ingress
If those two disagree, the container is fine and the problem is in front of it. If those two disagree, the container is fine and the problem is in front of it.
`docs/OPERATIONS.md` has the topology. `docs/OPERATIONS.md` has the topology.
## Two checks that are run by hand ## Three checks that are run by hand
Neither is adopted into `scripts/`, so neither runs in `verify.sh`. Both are None of them runs in `verify.sh`. The first is not adopted into `scripts/` at
worth running when the documents change a lot. all; the other two are, and the reason they still do not gate is below.
**`doc-claims.sh` — every path a document names must exist.** Run from the **`doc-claims.sh` — every path a document names must exist.** Run from the
template, and **exclude `docs/history/`**: template, and **exclude `docs/history/`**:
@ -179,10 +194,54 @@ error, so it is excluded rather than edited.
Without the exclusion it reports three findings in that file, every time, forever. Without the exclusion it reports three findings in that file, every time, forever.
Last run 2026-08-18: **240 claimed paths, all present, across 20 files.** Last run 2026-08-18: **240 claimed paths, all present, across 20 files.**
**`qa-browser.mjs` is in `scripts/` but not in `verify.sh`.** It needs
playwright, which is installed globally on this machine rather than being a
dependency of the project — adding ~300MB of browser to every clone for a check
nobody runs per-commit is the wrong trade. Reach for it when a UI defect is
filed, and before writing one:
```bash
node scripts/qa-browser.mjs # production
node scripts/qa-browser.mjs --url http://localhost:3099 # a local build
node scripts/qa-browser.mjs --paths /contact --viewports 320
```
Exit 2 means playwright was missing or the site was unreachable — nothing was
checked, which is not a pass.
**`device-sweep.mjs` renders every page on ten emulated phones and tablets.**
Same playwright trade as above, plus it needs something already serving the
build, so it is a tool you reach for rather than a gate.
```bash
npm run preview & # serves dist/ on 3001
node scripts/device-sweep.mjs # all ten devices
node scripts/device-sweep.mjs --devices "iPad Mini" # one, while iterating
node scripts/device-sweep.mjs --url https://queuenorth.com --report /tmp/sweep.md
```
It measures each box against its nearest **clipping** ancestor rather than
`document.scrollWidth`, and that distinction is the point: this site's `body`
carries `overflow-x: hidden`, so a page can slice content off its right edge and
still report a scrollWidth equal to the viewport. Eight kinds of finding, of
which `clipped`, `past_viewport` and `document_scrolls` are blocking. Exit 0
clean, 1 findings, 2 nothing swept.
It exists because #214, the header CTA clipped at iPad portrait, was fixed,
checked in a desktop window sized to 768, and released still broken. A desktop
window at 768 has a scrollbar, so the layout viewport was ~753px and the `md`
breakpoint the fix was about never engaged. A device profile has no scrollbar
inset, so 768 means 768. The engine is a copy of the Privacy LLC site's
`scripts/css-qc.mjs`; its provenance and the ways this driver differs are in the
header of `scripts/lib/css-audit.js`.
**`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and **`prove-guard.sh` is deliberately absent.** It breaks what a guard protects and
requires the guard to go red. This project has three guards, all shell scripts requires the guard to go red. This project has four guards, all shell scripts
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand that fail visibly, so §1 of `architecture/GUARDS.md` is performed by hand
instead — see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18. instead: see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 and 2026-09-10.
The four are `10-build` (which also runs the content check and the route-drift
check inside the prerenderer), `15-built-html`, `20-secrets` (tracked tree and
bundle) and `30-doc-headers`.
## Adding a script ## Adding a script

View File

@ -12,7 +12,7 @@ Review trigger: A guard is found to have been passing while the thing it guards
> **prove-guard.sh is not in this repository.** It lives in the template at > **prove-guard.sh is not in this repository.** It lives in the template at
> `~/.openclaw/Projects/Template/docs/architecture/scripts/`, and this project > `~/.openclaw/Projects/Template/docs/architecture/scripts/`, and this project
> declined it on adoption — its guards are three shell scripts in > declined it on adoption — its guards are four shell scripts in
> `scripts/verify.d/` that fail visibly on their own. It is named without > `scripts/verify.d/` that fail visibly on their own. It is named without
> backticks throughout for that reason. §1 below still applies and was performed > backticks throughout for that reason. §1 below still applies and was performed
> by hand on every guard here; `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18 > by hand on every guard here; `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18
@ -109,6 +109,24 @@ tr '\0' '\n' < /proc/$PID/environ | grep -c . # 0 here means "could not read"
This is the confident-absence failure one level up: the same trap as a screen This is the confident-absence failure one level up: the same trap as a screen
rendering a failed query as a count of zero, applied to your own diagnosis. rendering a failed query as a count of zero, applied to your own diagnosis.
**The same trap inside a data source.** `scripts/prerender.js` read sitemap dates
from `git log` inside a `try` whose `catch` was empty and commented *"git
unavailable or file untracked"*. Inside the image build git is always
unavailable, so every date came out empty and the sitemap shipped with none at
all, for months, while the build printed a cheerful success line. The fix is two
parts and both matter: pass the data in from where it exists, and **say the
count out loud on every run**, so "18 URLs, 0 dated" cannot read as success.
**The same trap inside a scanner.** A matcher that discards its errors turns
"could not run" into "found nothing". `grep "$pattern" 2>/dev/null` answers a
pattern it cannot read with exit 2 and no output, and a loop reading its matches
sees an empty list. `scripts/secrets.sh` ran its private-key pattern exactly that
way from the day the rule was written: the pattern starts with dashes, grep took
it for an option, and the scanner reported a clean tree over a staged private
key until 2026-09-10 (#235). Two rules follow. Pass every pattern with `-e`. And
compile each pattern once before trusting its silence, exiting 2 when one cannot
be read, which is what `secrets.sh` now does.
## 5. A guard that is often wrong is worse than none ## 5. A guard that is often wrong is worse than none
A check with a high false-positive rate trains everybody to skip its output, A check with a high false-positive rate trains everybody to skip its output,

View File

@ -4,8 +4,9 @@
Status: Current Status: Current
Owner: _null Owner: _null
Last reviewed: 2026-08-18 Last reviewed: 2026-08-18
Governs: docs/architecture/**, server/index.js, scripts/** — the module Governs: docs/architecture/**, server/index.js, scripts/**, src/data/** — the
boundaries, the database schema and the API response shapes module boundaries, the content layer, the database schema and the API
response shapes
Review trigger: Any new module, any change to a module boundary or a data shape; Review trigger: Any new module, any change to a module boundary or a data shape;
any new table or column; any new external service the server any new table or column; any new external service the server
calls; any change to how routes are prerendered calls; any change to how routes are prerendered
@ -50,17 +51,74 @@ this is not quite true is the privacy policy, below.
policy text live there as plain data, imported by both the client pages and policy text live there as plain data, imported by both the client pages and
`src/entry-server.jsx`. Adding a service is a data edit, not a component edit. `src/entry-server.jsx`. Adding a service is a data edit, not a component edit.
A service may carry a `page` object: owner-approved long-form copy, in
`src/data/serviceContent/<slug>.js`, shaped as `{ seo, hero, sections, related }`
where each section is `{ id, title, kind?, blocks }` and a block is one of `p`,
`h3`, `ul`, `ol`, `callout`, `image` or `links`. A service without one keeps the
short layout, so the two shapes coexist. Content files are **leaf modules**:
they import nothing, because `services.js` imports them and the prerenderer
imports that directly in Node, where Vite's resolution does not exist.
`scripts/lib/content.js` decides whether that data is publishable, and the build
refuses when it is not. It is the only thing standing between the copy sheets'
directions to the website manager ("Place an official screenshot", "Keep this
factual:") and a customer-facing page, since those lines look exactly like copy.
It also holds the rules that make a page quotable: a section opens with its
direct answer, ids start with a letter and are unique, every link resolves, and
no em dash, markdown or HTML tag survives into a string.
**`scripts/prerender.js` is a build step, not a runtime.** It renders every route **`scripts/prerender.js` is a build step, not a runtime.** It renders every route
to static HTML at build time using `src/entry-server.jsx`. Nothing at request to static HTML at build time using `src/entry-server.jsx`. Nothing at request
time renders React on the server. time renders React on the server.
It refuses to produce a page rather than produce a wrong one, and each refusal
names its route:
- a render that throws, so the build does not report a stack trace with no clue
which of the nineteen pages produced it
- a Suspense fallback (`<!--$!-->`), because `renderToString` does not wait: a
lazy import anywhere above a route would silently ship an empty page to every
crawler
- a template that already carries a canonical, which means the script is being
run over its own output, since `dist/index.html` is both template and home page
- content that does not hold together, checked by `scripts/lib/content.js`
before a single page renders
- a route the router declares that this build would not produce, which the
server would answer with `404.html`
Rules about what it moves, each bought with a defect:
- Metadata React leaves inline is hoisted into `<head>`, **except** inside an
inline `<svg>` (an SVG `<title>` labels a picture, not the page) and except
`<meta itemprop>` microdata, which belongs beside the thing it describes.
- **JSON-LD is never hoisted.** React hoists only async scripts with a `src`, so
on the client the `ld+json` script stays in the body where its component
renders it. Moving it to `<head>` made the prerendered DOM disagree with the
client's first render, and React responded by discarding the whole prerendered
page and re-rendering it, on every page, for months. Structured data is valid
anywhere in the document.
- The hero preload is keyed on the image React marked with a high fetch
priority. Keying it on `loading="eager"` matched the header logo, so every
page preloaded the logo and no page preloaded its own hero.
**Hydration is real again, and it is measurable.** Anything rendered only on the
client, such as sonner's `<Toaster>`, mounts *after* hydration; rendering it in
the first client pass puts a node in the tree that the prerendered HTML never
had, which is a mismatch, and a mismatch costs the entire prerendered page.
### The three boundaries worth knowing about ### The three boundaries worth knowing about
1. **The privacy policy has two renderers, one source.** `src/data/privacyPolicy.js` 1. **The privacy policy has one source and one renderer, and ships as static
is the single source of truth; `src/pages/PrivacyPolicy.jsx` renders it in the HTML.** `src/data/privacyPolicy.js` is the source of truth;
SPA and the prerender step emits a static copy. This exists because Meta's `src/pages/PrivacyPolicy.jsx` renders it through the shared
crawler does not execute JavaScript, and a policy it cannot read is a policy `src/components/content/ContentBlocks.jsx`, passing in the two block types
that does not count. **Do not add policy text to a component.** only a policy has, and the prerender step writes the result to
`dist/privacy-policy/index.html`. There is no separate server-side renderer,
and there never was: the page is readable to Meta's crawler because it is
prerendered, like every other page, and a policy an ad platform cannot read
is a policy that does not count. **Do not add policy text to a component**,
and when the renderer changes, prove this page's built HTML is unchanged
byte for byte before believing the change was safe.
2. **Zoho is an overlay, never a dependency.** Every handler writes SQLite first 2. **Zoho is an overlay, never a dependency.** Every handler writes SQLite first
and then calls the forwarder without awaiting it. A Zoho outage, a bad token and then calls the forwarder without awaiting it. A Zoho outage, a bad token
@ -141,10 +199,13 @@ each row says what it does *here*.
| Path | What it is | | Path | What it is |
| --- | --- | | --- | --- |
| `scripts/check-env.sh` | which of the 17 Zoho / reCAPTCHA / CORS / rate-limit variables are set and plausible, before the server reads them. Exit 2 means nothing was checked | | `scripts/check-env.sh` | which of the 17 Zoho / reCAPTCHA / CORS / rate-limit variables are set and plausible, before the server reads them. Exit 2 means nothing was checked |
| `scripts/secrets.sh` | credential shapes in a staged diff, and `--tracked` for a whole-tree audit. **`--built dist/` is the one that matters here**: `VITE_RECAPTCHA_SITE_KEY` is inlined into the bundle at build time, so the repository scan cannot see what users receive. Carries this project's own shapes, and one **tightened** pattern — see below | | `scripts/secrets.sh` | credential shapes in a staged diff, and `--tracked` for a whole-tree audit. **`--built dist/` is the one that matters here**: `VITE_RECAPTCHA_SITE_KEY` is inlined into the bundle at build time, so the repository scan cannot see what users receive. `npm run verify` runs both, as guard `20-secrets`. Every pattern is compiled before the scan, because a pattern grep cannot read produces the same silence as a clean tree (#235). A line that must show a credential shape carries `secrets-ok:` and its reason. Carries this project's own shapes, and one **tightened** pattern, described below |
| `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row | | `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row |
| `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after | | `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after |
| `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed | | `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed |
| `scripts/audit-html.js` | what the site actually serves: over `dist/` as guard `15-built-html`, and with `--url` against a live origin once per crawler user agent. The URL run is a check to make after a deploy, not a gate |
| `scripts/validate-content.js` | the content check on its own, for proving it fails and for a fast answer while writing copy. `npm run build` runs the same check inside the prerenderer, so a clean run here is not a substitute for a build |
| `scripts/lib/` | shared, side-effect-free modules: `routes.js` (the one route list, and the drift check against the router's own table), `content.js` (what must be true of `src/data/**` before a page is built from it), `html-audit.js` (what a served page must say) and `css-audit.js` (what a rendered page must measure, serialised into the browser by `device-sweep.mjs`) |
| `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only | | `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only |
| `scripts/healthcheck.sh` | a liveness tick against `queuenorth.com`, asserting HTTP 200 **and** `"status":"ok"` **and** `"db":"ok"` — a 503 with a JSON body is a real answer, not an outage. `HEALTHCHECK_BASE_URL` points it at the other front door | | `scripts/healthcheck.sh` | a liveness tick against `queuenorth.com`, asserting HTTP 200 **and** `"status":"ok"` **and** `"db":"ok"` — a 503 with a JSON body is a real answer, not an outage. `HEALTHCHECK_BASE_URL` points it at the other front door |
| `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts | | `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts |
@ -154,6 +215,8 @@ each row says what it does *here*.
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after | | `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template | | `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
| `scripts/qa-browser.mjs` | renders the site in real Chromium at real viewports and **measures** it — horizontal scroll, broken images, elements past the right edge, CLS and LCP. Exit 2 if playwright is missing, because "could not check" is not a pass. Not in `verify.sh`: playwright is global here, not a project dependency |
| `scripts/device-sweep.mjs` | renders every page on ten emulated phones and tablets and measures the layout: clipping, boxes past the viewport, media wider than its container, occluded sticky headers, tiny text, touch targets. Compares each box against its nearest **clipping** ancestor, because `body{overflow-x:hidden}` makes `document.scrollWidth` agree with the viewport while content is being sliced off. Exit 2 if nothing was swept. Not in `verify.sh`: playwright is global here, and it needs a server already serving the build |
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template | | `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and **Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and

View File

@ -692,7 +692,7 @@ Required primitives from `@/components/ui/`:
- `Sheet` — mobile navigation - `Sheet` — mobile navigation
- `Input`, `Textarea`, `Select` — forms - `Input`, `Textarea`, `Select` — forms
- `Badge` — certifications, status indicators - `Badge` — certifications, status indicators
- `Accordion`FAQ or details sections - `Accordion`details sections. **Not for an FAQ**: the owner-approved service pages keep every question and answer visible as `h3` plus `p`, because an answer a reader has to click for is one an answer engine may not quote
- `NavigationMenu` — desktop nav (optional if simple) - `NavigationMenu` — desktop nav (optional if simple)
- `Toast` / `Toaster` — success/error feedback - `Toast` / `Toaster` — success/error feedback
- `Dialog` — optional modal content - `Dialog` — optional modal content
@ -759,6 +759,36 @@ Required primitives from `@/components/ui/`:
- Dark navy section - Dark navy section
- Centered copy + primary button - Centered copy + primary button
### Long-Form Service Page Blueprint (added 2026-09-10)
A second shape for a service page, used when the owner supplies approved
long-form copy (`src/data/serviceContent/<slug>.js`). A service without that copy
keeps the blueprint above, so the two live side by side. Unified Communications
and Contact Center are the first two, at roughly 1,100 and 2,500 words.
**Page Hero** — unchanged band, carrying the approved H1 and subheading, and two
buttons: the approved primary CTA, and a secondary one outlined in white. Both go
to the contact form.
**Lead** — the approved hero copy sits directly *under* the hero band, at
`text-lg`, so the buttons stay above the fold on a phone.
**Sections** — one `<article id>` per approved H2, in sheet order, rendered by
`src/components/content/ContentBlocks.jsx`. Each opens with its direct answer
paragraph, then lists, steps, a callout or a figure. The ids are the anchors a
search result or an AI answer deep-links to, so they are stable, letter-first and
never renumbered.
**FAQ** — a plain section: `h3` question, `p` answer, always visible. **No
accordion, and no FAQPage markup**, both on the owner's instruction. An answer
behind a click is an answer that may not be quoted.
**Related services** — the approved anchor texts, at the end of the main column.
**Sidebar** — the same Quick Info card as the short layout, unchanged.
The build refuses copy that breaks any of this: see `scripts/lib/content.js`.
### Contact / Support Form Layout ### Contact / Support Form Layout
**Desktop: Two-Column** **Desktop: Two-Column**
@ -796,6 +826,46 @@ Required primitives from `@/components/ui/`:
- Mobile section padding: `py-16` (64px) - Mobile section padding: `py-16` (64px)
- Desktop section padding: `py-24` (96px) - Desktop section padding: `py-24` (96px)
- Card padding: `p-4 md:p-6` - Card padding: `p-4 md:p-6`
- **Standalone link lists: `space-y-4`, not `space-y-2`.** See the tap-target
rule below. This is a change from what the rest of this file describes, made
2026-09-10; it affects the footer columns, the privacy contents and the
related-links lists.
**Tap targets (added 2026-09-10)**
A standalone text link renders 17 to 20px tall, and a finger needs about 32.
`.tap-target` in `src/index.css` grows the hit box by 8px above and below and
takes those 8px back out of the layout, so the line the link sits on does not
move. **It only works if the list leaves 16px between rows**: the negative
margin does not shrink the box, only its effect on layout, so at `space-y-2`
each link's box reached 8px into a gap its neighbour was already reaching 8px
into. They overlapped, and `getBoundingClientRect` still read 33px: a target
that measured right and was not there.
Three rules follow from that:
- A link in a **vertical list** gets `.tap-target`, and the list gets
`space-y-4` or `gap-y-4`.
- A link that fills a **column** gets `block` as well, so the whole row is the
target rather than the width of the word. That is also what keeps it passing:
a 39x36 word is judged as a compact target and wanted 44px of height, where a
200x36 row is judged as a row and wants 32.
- A link **inside a sentence** gets nothing. WCAG 2.5.8 exempts it, and padding
would reach into the lines above and below. `LINK_CLASS` in `ContentBlocks`
is therefore padding-free by design: it is used both ways, and the callers
that need a target add `.tap-target` beside it.
**Breakpoint for the desktop header: `lg`, not `md` (changed 2026-09-10)**
768 to 1023 gets the `Sheet` menu. The desktop row cannot fit 768: brand, six
nav links and the CTA want 787px of natural width against 736px of container,
so flex shrank the CTA and wrapped its label and it *still* overflowed. The
menu is the better tablet experience regardless: 44px rows instead of 17px
ones, and the Services and Industries submenus are reachable, where the desktop
dropdowns open on hover and a touch device has no hover.
The wordmark holds at `text-xl` until `xl`. At exactly 1024 the desktop row had
four pixels of room; at `text-xl` it has sixty-nine.
### Asset / Image Treatment ### Asset / Image Treatment
@ -826,6 +896,12 @@ Required primitives from `@/components/ui/`:
❌ Gradient overlays on every section ❌ Gradient overlays on every section
❌ Multiple competing typefaces ❌ Multiple competing typefaces
❌ Large font sizes without line-height spacing ❌ Large font sizes without line-height spacing
❌ A partner logo scaled up inside `overflow-hidden` to fill its tile. The Cisco
mark sat in a 700x700 canvas it filled 66% of, so `object-contain` rendered
it small beside 8x8's; `scale-[1.5]` and `scale-[2]` made it match and cut
13px off the trademark on `/` and 24px on `/about`. Crop the asset's own
`viewBox` to the artwork instead and let the tile's padding be the clear
space, so nothing is clipped and nothing is distorted
--- ---

View File

@ -155,6 +155,23 @@ This section should appear immediately after hero.
This is critical. This is critical.
> **CORRECTED 2026-09-10, by the owner.** The two lists above are what this
> review recommended in May, and Levi Halford's approved copy sheets of
> 2026-08-28 overrule them. A figure goes on a page only when it is current,
> attributed to the vendor it belongs to, and verified at the time of
> publication. **A vendor's SLA is never presented as a Queue North guarantee**,
> and unsupported superlatives ("best", "leading", "#1") do not appear at all.
>
> The specific instruction was to remove the unattributed "99.999% uptime
> reliability" from the contact centre page, which had shipped since May. It is
> gone as of that date. The numbers above are examples of the SHAPE of a trust
> signal, not copy to reach for: an invented one costs more than the vague
> sentence it replaced, and `docs/planning/PROJECT_PLAN.md` already requires that
> nothing on the page be a claim the business cannot substantiate.
>
> The same sheets are also the standard for what an approved page looks like:
> `src/data/serviceContent/` holds the two written this way.
B2B buyers purchase risk reduction, not technology. B2B buyers purchase risk reduction, not technology.
--- ---

View File

@ -46,6 +46,303 @@ sequence would have implied more.
## Entries ## Entries
### 2026-09-10: a device sweep, and a fix that was released without ever being exercised
Every page rendered on ten emulated phones and tablets, on real Playwright
device profiles rather than a resized window, and measured. **190 page loads, 21
blocking and 530 high findings** on pages that had already passed Batch 11 and
Batch 16. Three defects, all live in production. Filed as #238 to #241 under
Batch 19.
**#238 is a regression of a fix that could not have worked.** #214 was "the
header CTA is clipped at iPad portrait". v0.9.5 tightened the nav gaps at `md`,
checked it in a desktop browser window sized to 768, and released. A desktop
window at 768 has a scrollbar, so the layout viewport was about 753px, the `md`
breakpoint never engaged, and the desktop header the fix was about was never on
screen. On a real 768px device nothing had changed: `Request Consultation` sat
25px past the right edge on all 19 routes, and `body{overflow-x:hidden}` sliced
it off with no scrollbar to hint at it.
The arithmetic says the approach was never available. At a true 768 the brand,
six nav links and the CTA want 787px of natural width against 736px of
container; the CTA had already been squeezed from 176px to 114px and its label
wrapped, and it still did not fit. No gap tightening closes 51px. So 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 the
Services and Industries submenus are reachable, where the desktop dropdowns open
on hover and a touch device has no hover. The CTA gained `shrink-0
whitespace-nowrap`, so the next row that does not fit overflows visibly instead
of being quietly squeezed past the edge.
**#239, the Cisco mark.** It ships in a 700x700 canvas it fills 66% of, so
`object-contain` rendered it small beside 8x8's and both pages compensated with
`scale-[1.5]` and `scale-[2]` inside `overflow-hidden`, cutting 13px off the
trademark on `/` and 24px on `/about`. Cropping the asset's own `viewBox` to the
artwork and dropping the scale renders it at 62x46 on `/`, the same size as the
8x8 logo beside it, with nothing clipped.
**#240, tap targets, cost a round to get right.** Padding an `inline` link is
painted and hit-tested but never enters the line box, so it reports a taller
rect while overlapping its neighbours. And an `inline-block` with negative
margins overlaps too at `space-y-2`: each link reached 8px into a gap the
neighbour was already reaching 8px into, and hit-testing gave the whole gap to
whichever painted last. Both versions measure 33px and neither is 33px. **A
target that measures right and is not there is worse than one that measures
wrong.** The rule that survived is in `design/OVERHAUL_PLAN.md`: `.tap-target`
is `inline-block`, and every list that uses it moves to `space-y-4`. Then a
second surprise: a 39x17 link grown to 39x36 stops being row-shaped and starts
being judged as a compact target wanting 44px, so footer links also take
`block` and the whole row becomes the target.
**#241 is the instrument.** `scripts/device-sweep.mjs` plus
`scripts/lib/css-audit.js`, the engine copied from the Privacy LLC site's
`css-qc.mjs`. It compares every box against its nearest **clipping** ancestor
rather than `document.scrollWidth`, which this site's `body` makes agree with
the viewport while content is sliced off the right edge. Not wired into
`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. Worth
recording that the original in the other repository declares device profiles and
then only calls `setViewportSize`, so its `isMobile` and `deviceScaleFactor`
never take effect. It is a width sweep wearing a phone's clothes, which is the
same blind spot in a different form.
**Proven, not assumed.** After the fixes the sweep reports zero blocking and
zero high across all ten devices. Each defect was then re-introduced and the
sweep reported it again: `clipped x1` and `media_overflow x1` for the logo,
`past_viewport x19` for the header, `touch_target x342` for the footer. The
first attempt at the header proof reverted only the nav's breakpoint and left
the CTA at `lg`, so the element that overflows was not on screen and the sweep
correctly reported nothing. A wrong mutation, not a blind checker, and worth
writing down because it looks identical to a checker that has stopped working.
Exit codes were proven separately: 2 for an unreachable origin and for an
unknown `--devices` name, because "nothing was swept" must never read as
"nothing was wrong".
**Deployed as v0.9.8**, from 0e47183. Production ran 0.9.6 until now, so this
is the first deploy carrying both the sweep fixes and the "25+ years of industry
experience" wording.
Checked after the recreate rather than trusting the deploy's own report, because
#236 is still open and did exactly what it says it does: `deploy.sh` printed
"0.9.6 -> 0.9.6" and an unchanged digest, having read both before the container
was replaced. Independently: `status.sh` shows `qn-website-dev` healthy on
v0.9.8 with 0 restarts, and its image id `62d076e3800d` is the id of the image
`release.sh` built. Both front doors answer `{"status":"ok","db":"ok"}`. The
crawler audit reports nothing wrong across 18 pages as all five agents, and 18
sitemap entries carry a lastmod. The device sweep against production, all ten
profiles and 190 page loads, reports zero findings, which is the first time this
site has been measured on devices in production rather than locally.
`qn.isnull.dev` reports one finding, the already-filed #237.
**Next action:** #217, submitting the two service URLs to Google Search Console
and Bing Webmaster Tools. Its release and deploy halves are done, at v0.9.8
rather than the v0.9.6 the issue was written against.
**Blockers:** #216 needs Levi's four vendor screenshots. #217 needs Null's or
Levi's Search Console and Bing accounts.
**Blockers:** #216 needs Levi's four vendor screenshots. #217 needs Google
Search Console and Bing Webmaster Tools access to submit the two service URLs.
### 2026-09-10 — Levi's approved pages shipped, and the guards that should have caught what was found on the way
Levi Halford approved two copy sheets on 2026-08-28 and emailed them the same
day. They sat for thirteen days. Both pages are live as v0.9.6.
**What shipped for the client.** `/services/unified-communications` and
`/services/contact-center` now serve his approved copy verbatim: 1,125 and 2,499
words, 22 sections, 17 FAQs, every section opening with its direct answer under a
stable anchor. Titles, H1s and descriptions are byte-exact, including
descriptions of 164 and 191 characters, which are emitted whole rather than
clamped at 158 as the old code would have. His two constraints were proven rather
than assumed: the footer is byte for byte identical to the pre-batch build on all
19 pages, and the Quick Info box is unchanged.
**The sheets mix 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.
Extraction was done by four independent reviewers per page (coverage, leakage,
structure, fidelity) and then by a build-time check that refuses twenty such
phrases. The reviewers produced nine findings; the repair agent applied four and
rejected five with reasons, each of which held up.
**Two independent code reviews of the plan found nineteen landmines before any
code was written.** The ones that were real, and fixed:
| What | Live since |
| --- | --- |
| The secret scanner never ran its private-key pattern: grep read the leading dashes as an option and `2>/dev/null` hid the error | the rule was written |
| React discarded the prerendered DOM and re-rendered every page: hydration failed site-wide, confirmed in Chromium as error #418 | months |
| The production sitemap carried no `lastmod` at all: the image build has no git and the failure was swallowed | months |
| Every page preloaded the header logo instead of its own hero image | months |
| Eleven pages shipped a description reading as one run-on sentence | months |
| `querySelector(hash)` threw on a fragment that is not a valid CSS selector, blanking the whole page | always |
| `.drop/`, `zoho.md` (live secrets) and 60 MB of zips were sent into every Docker build context | always |
**Guards, so none of that class returns.** The content layer is validated before
a page renders (17 mutations, each producing exactly one finding). The built HTML
is audited as guard `15-built-html`: one title, description, canonical and h1 per
page, structured data that parses, no FAQPage, links and anchors that resolve,
the preload matching the hero (9 mutations, each caught). `audit-html.js --url`
fetches every page once per crawler after a deploy. `qa-browser` went from 5 of 18
pages to all of them and now hears console errors, which is how the hydration
failure was confirmed. `secrets.sh` compiles every pattern before trusting its
silence.
**Shared code, where it earned it.** One block renderer serves the privacy policy
and the two new pages, proven by the privacy page's HTML being identical to the
byte. One description builder. One route list, with the router checked against it
at build time.
- **Closed:** #218, #219, #220, #221, #222, #226, #227, #228, #229, #230, #231,
#232, #233, #234, #235, and #223 and #224. Two of #223's three claims were
disproved live and corrected on the issue rather than acted on.
- **Filed from the deploy itself:** #236, `deploy.sh` printed the digest and
version from before the recreate, so a successful deploy read as a no-op; #237,
Cloudflare rewrites the privacy email on `qn.isnull.dev`.
- **Next action:** submit both URLs in Google Search Console (Test Live URL, then
Request Indexing) and Bing Webmaster Tools, which needs account access this
session did not have (#217). Bing matters as much as Google: its index feeds
Copilot and ChatGPT search. Then reply to Levi with what shipped and ask for
the four vendor screenshots (#216), which only he can clear for use.
- **Blockers:** #216 and #217 both wait on somebody with the accounts and the
partner material. #211 is unchanged and still the real exposure: the lead
database now has six dumps, all on one workstation, still with no schedule and
no copy anywhere else.
### 2026-08-18 — Batch 11 closed without a line of code, and a browser found what twenty issues had not
Batch 11's four issues were all viewport claims. **All four were false**, and
this time they were disproved by rendering the site rather than by reading it.
| # | claim | measured |
| --- | --- | --- |
| 195 | dropdowns clip on constrained viewports | **no clipping ancestor exists** — walked every ancestor, all `overflow: visible`. At 768px the Industries dropdown ends 95px clear of the edge |
| 196 | logo overlaps nav at 320px | text ends x=252, burger starts x=264 — a **12px gap**. No truncation, no horizontal scroll |
| 197 | no image fallbacks, users see broken placeholders | every image 200, **zero broken** after a full scroll, CLS 0.008 against a 0.1 threshold, LCP 220ms |
| 198 | click handler misses nested children | `e.target.closest('a')` **already is** delegation; the listener is on `document` |
#196 also conflated two unrelated elements — `w-[85vw] max-w-[300px]` is the
mobile Sheet panel, not the header.
**Running total across Batches 10 and 11: three of ten were real.** Seven
misstated their own evidence, and two of those would have made the site worse
if actioned.
**So I built the thing that should have existed first.** `scripts/qa-browser.mjs`
drives real Chromium at real viewports and measures horizontal scroll, broken
images, elements past the right edge, CLS and LCP.
**On its first production run it found two defects none of the twenty had
noticed**, and both were real:
- **#214 (P1)** — at exactly 768px, iPad portrait, the header's *Request
Consultation* CTA measured x 676-778 against a 768px viewport. Ten pixels
sliced off, invisible because `overflow-x: hidden` suppresses the scrollbar,
and unreachable because the burger menu is already hidden at that width. The
primary conversion action, cut off on one of the most common tablet viewports
there is.
- **#215 (P2)** — Google's reCAPTCHA iframe is a fixed 304px. At 320px it ran
25px past the edge, clipping the branding and the privacy links.
Both fixed, released as **v0.9.5**, deployed, and verified live: the CTA now
636-752 at 768px, the widget 41-299 at 320px, and `qa-browser.mjs` reports
nothing across five pages at five viewports.
**The tool lied to me twice before I trusted it**, which is the part worth
keeping. It reported a healthy lazy-loaded badge as broken at three viewports —
a coarse scroll outrunning the intersection observer — and its own argument
parser swallowed `320` as a path when `--paths` preceded `--viewports`. Both
found and fixed by checking its output against reality before believing it. A
checker that cries wolf is how the next real finding gets ignored, and this one
nearly started its life doing exactly that.
- **Closed:** #195, #196, #197, #198 (no change required, each with the
measurement), **Batch 11 milestone**; then #214, #215 and the new **Batch 16**
milestone, both fixed and shipped. Two releases and two deploys today, no
rollbacks.
- **Next action:** Batch 12 — the eleven content and SEO issues (#199#209).
These are copy judgements measured against `docs/design/REDESIGN_REVIEW.md`,
not measurable claims, so the browser tool does not help. Expect the same hit
rate and read each against the positioning document before rewriting anything.
- **Blockers:** none on the work. Six issues wait on the site owner (#68, #110,
#162, #213 in Batch 13; #69, #70 in Batch 14). **#211 is still the real
exposure**: the lead database has one backup copy, on one workstation, with no
schedule — four dumps taken today, all in the same place.
### 2026-08-18 — v0.9.4 released and deployed. Production is on a number
The first release and the first deploy this project has ever made through a
script. Both worked; the site did not go down beyond the recreate.
**Asked three times whether it would break anything, and it would have — twice.**
Both were found by checking rather than by running, which is the only reason
this entry is not an incident report.
*`release.sh` would have shipped a site whose contact form could not 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 that variable is unset in every shell. The
`:-` made empty silently acceptable. An empty key makes
`RecaptchaPlaceholder.jsx` render *"Security verification is not configured."*
and never produce a token, and the server has `RECAPTCHA_ENABLED=true`, so every
submission is rejected. **Lead capture would have stopped entirely** — the whole
purpose of the site — and nothing about a successful build would have said so.
*`deploy.sh`'s default would have rolled production back two months.* The newest
published **numbered** tag was `v0.8.3`, built 2026-05-28; the running `:dev`
image was built 2026-08-01. Inspecting both images showed `v0.8.3` has no
`privacy-policy/` and **no prerendered route directories at all**. The default
path would have removed the privacy policy — which exists specifically because
Meta's crawler does not run JavaScript — and every prerendered route.
Both are now guards that refuse, not cautions that document. The release one
also greps the **built image's** bundle for the key before pushing, because a
guard that trusts the wiring is the wiring.
**What was checked before touching production**, since the question was asked
directly: the registry pulls anonymously (making the repo private did not affect
packages — they are owned separately); `docker compose config` validates the
rewritten stack file; the env-line count is asserted, and `compose config`
showed exactly why — without the `Env` array the `${VAR:-false}` defaults
resolve reCAPTCHA and Zoho WebToLead to **false**, so a stripped env would come
back healthy and quietly capture nothing; only one of the fourteen commits since
the running image touches anything a visitor sees.
**Watchtower was the missing piece of the story.** Stack 39 runs nightly at
02:00 with no label filter, watching every container, and `qn-website-dev` had
no opt-out. Following the moving `:dev` tag, the site was updating itself
overnight — the undocumented deploy path nobody could find. It now pins a
version *and* carries the opt-out label, so nothing changes without a deliberate
deploy.
**The deploy itself:** one PUT, three lines changed — the image, the CORS
trailing slash, and the Watchtower label — with a verified backup taken first
and automatic rollback armed. It did not need to roll back.
```
digest …3253b10c -> …1d283944
version none -> 0.9.4
CORS https://queuenorth.com/ -> https://queuenorth.com
leads 3 before, 3 after
```
`status.sh --deployed-version` answers `0.9.4` — the first time this project has
been able to say what is running without reading a digest.
- **Closed:** **#212** (CORS origin), with the live header as evidence. Batch 10
is now not only fixed but *shipped*`aria-expanded` and the dual-tone focus
ring are live, and the six Label-in-Name failures are gone from the served
HTML.
- **Next action:** Batch 11 — UI defects on narrow viewports (#195#198). Verify
each claim by rendering at the stated width first: four of Batch 10's six
issues misstated their own evidence, and these are all viewport-dependent.
- **Blockers:** none on the work. Six issues remain blocked on the site owner
(#68, #110, #162, #213 in Batch 13; #69, #70 in Batch 14). The backup is still
a single copy on one workstation with no schedule — #211, and it is the one
thing here that would actually hurt.
### 2026-08-18 — Policy: production always runs a numbered version ### 2026-08-18 — Policy: production always runs a numbered version
`_null`: *always stay on a num version.* Recorded here because it changed three `_null`: *always stay on a num version.* Recorded here because it changed three

View File

@ -94,15 +94,23 @@ Observable, in this order:
the visitor sees a confirmation either way. the visitor sees a confirmation either way.
3. The site passes WCAG 2.1 AA on the pages a buyer actually walks — currently it 3. The site passes WCAG 2.1 AA on the pages a buyer actually walks — currently it
does not, which is what `Batch 10` in the tracker is for. does not, which is what `Batch 10` in the tracker is for.
4. Nothing on the page is a claim the business cannot substantiate. Two open 4. Nothing on the page is a claim the business cannot substantiate. As of
issues say it currently is (#108, #110). 2026-09-10 that holds: the fabricated-looking certification number was
removed on the owner's instruction in May (#108), and the owner confirmed the
"25+ years" experience claim (#110). It is a standing test, not a finished
one: the copy sheets the owner now supplies are approved page by page, and
`docs/design/REDESIGN_REVIEW.md` carries his 2026-09-10 direction that a
figure must be current, vendor-attributed and verified.
## Known risks ## Known risks
- **Unverifiable marketing claims are live.** A fabricated-looking certification - **Unverifiable marketing claims. Cleared, and worth re-reading before the next
number and an unverified "25+ years" both ship today (#108, #110). This is the copy change.** Both instances that prompted this are resolved: the
only risk here that is a credibility problem rather than an engineering one, certification number came off the site in May, and the owner confirmed "25+
and neither can be fixed without the owner. years" on 2026-09-10. What remains is the habit that produced them, which is
why the unattributed "99.999% uptime" line was removed from the contact centre
page on the owner's instruction the same week. A claim goes on a page when
somebody can substantiate it, and the owner is the only one who can.
- **The lead database has no backup.** `/app/db/queuenorth.db` in a Docker named - **The lead database has no backup.** `/app/db/queuenorth.db` in a Docker named
volume on nebula is the only copy of every inbound lead and support request. volume on nebula is the only copy of every inbound lead and support request.
Tracked in `Batch 15`. Until a restore has been proven, this project has a Tracked in `Batch 15`. Until a restore has been proven, this project has a

View File

@ -21,13 +21,13 @@ reads as one nobody got to and this one is a decision.
| Pass | Result | Notes | | Pass | Result | Notes |
| --- | --- | --- | | --- | --- | --- |
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan | | A — First run | **Partial** | `scripts/qa-browser.mjs` loads five pages cold at five viewports and measures render, images, CLS and LCP. **Not run:** any real device, any interaction |
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside | | B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 | | C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it | | D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that | | E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
| F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing | | F — Accessibility | **Partial** | A static audit was run on 2026-08-18 against the six filed defects (#189#194): contrast computed rather than eyeballed, and every `aria-label` checked against its visible text for WCAG 2.5.3. Three were real and fixed, three were not defects. **Still not run:** anything in a browser — no keyboard-only walk, no screen reader, no zoom or reflow testing |
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured | | G — Performance | **Partial** | First numbers ever recorded, 2026-08-18 via `scripts/qa-browser.mjs`: LCP 136-264ms and CLS 0-0.032 across five pages at five viewports, against thresholds of 2500ms and 0.1. **Not run:** anything under load, any bundle-size budget, any throttled network or CPU |
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project | | H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
## Pass H, as it applies here ## Pass H, as it applies here
@ -72,6 +72,16 @@ existing is not a failure; it not existing while the gaps do is.
- **The lead database has never been backed up or restored.** `backup.sh` and - **The lead database has never been backed up or restored.** `backup.sh` and
`restore-check.sh` exist as of 2026-08-18 and neither has been run against `restore-check.sh` exist as of 2026-08-18 and neither has been run against
production. Filed as an issue in `Batch 15`. production. Filed as an issue in `Batch 15`.
- **There is now a way to measure the rendered site**: `scripts/qa-browser.mjs`
drives real Chromium at real viewports. It exists because Batches 10 and 11
were twenty issues filed from reading markup of which **seven of ten
misstated their own evidence**, and on its first production run it found two
defects none of the twenty had noticed — a header CTA clipped at iPad portrait
and the reCAPTCHA widget cut off at 320px. Reach for it before believing a UI
defect report, and before writing one.
- **It measures five things and no more**: horizontal scroll, broken images,
elements past the right edge, CLS and LCP. It opens no menus, presses no keys
and reads no contrast. A clean run is not "the UI is correct".
- **No accessibility testing has ever been done with a screen reader or by - **No accessibility testing has ever been done with a screen reader or by
keyboard alone**, and that is still true after Batch 10. The fixes there were keyboard alone**, and that is still true after Batch 10. The fixes there were
verified by computing contrast, by re-auditing accessible names against visible verified by computing contrast, by re-auditing accessible names against visible

View File

@ -67,6 +67,55 @@ carried as skipped.** No money moves through this site: no checkout, no
subscription, no refund path. A pass that never applies is noise, and one that is subscription, no refund path. A pass that never applies is noise, and one that is
always skipped is a lie. always skipped is a lie.
## Measure it before you file it
Batches 10 and 11 were twenty UI and accessibility issues written from reading
source. **Seven of ten misstated their own evidence** — two contrast figures
were wrong, one proposed a colour measuring 1.96:1 against the background it
would sit on, one asked for a change that would have spread a Level A failure,
one described a clipping ancestor that does not exist, one an overlap that is a
12px gap, one an attribute that is not in the file.
None of that was carelessness about *whether* something was wrong. It was
confidence about *how much*, without measuring.
So, before filing a UI defect and before acting on one:
```bash
node scripts/qa-browser.mjs # production, every page in its sitemap, four widths
node scripts/qa-browser.mjs --url http://localhost:3099 --viewports 320,768
node scripts/device-sweep.mjs # every page, ten emulated phones and tablets
```
Contrast is arithmetic — composite the colour over its background and compute
the ratio, do not judge it by eye. Overlap is two rectangles. "Does it clip" is
a computed style you can read off the ancestors. A filed defect's numbers are a
claim to check, not a measurement.
### A resized desktop window is not a device (added 2026-09-10)
Two defect classes reached production through a QA round that looked thorough,
and both were invisible to the instrument being used.
**A window sized to 768 is not 768.** It has a scrollbar, so the layout viewport
is about 753, so the `md` breakpoint never engages and the desktop layout the
check is about is never on screen. That is how #214, the header CTA clipped at
iPad portrait, was fixed, checked at "768", released, and was still 25px past
the right edge on every page. A device profile has no scrollbar inset. Check a
breakpoint on a device, or on an emulated one; never on a window you dragged.
**`document.scrollWidth` is not evidence of fitting.** This site's `body`
carries `overflow-x: hidden`, so content sliced off the right edge leaves
`scrollWidth === innerWidth` and no scrollbar to hint at it. Compare a box
against its nearest **clipping** ancestor, which is what
`scripts/lib/css-audit.js` does. The first sweep found 21 blocking and 530 high
findings on pages that had passed every earlier round.
Touch targets are the other class that got through, for a related reason: a
17px-tall footer link is fine to click and fiddly to tap, and nothing in a
desktop pass distinguishes them. The rule that came out of it is in
`design/OVERHAUL_PLAN.md` under Tap targets.
## What counts as a finding ## What counts as a finding
A finding needs: what was done, what happened, what should have happened, and A finding needs: what was done, what happened, what should have happened, and

View File

@ -53,7 +53,9 @@ so: that is the one that went, and that is why.
credentials in the tracked tree — including in the history, which a `git log -p` credentials in the tracked tree — including in the history, which a `git log -p`
search covers and a directory listing does not. `scripts/secrets.sh --tracked` search covers and a directory listing does not. `scripts/secrets.sh --tracked`
does the tree; `--built dist/` does the bundle, which is the artifact users does the tree; `--built dist/` does the bundle, which is the artifact users
actually receive and the one the repository scan never sees. actually receive and the one the repository scan never sees. `npm run verify`
runs both, as guard `20-secrets`. Until 2026-09-10 neither could see a private
key: the scanner never ran that pattern (#235).
**A credential pasted into an agent transcript is a leaked credential, and **A credential pasted into an agent transcript is a leaked credential, and
rotating it is the only fix.** Deleting the message does not help and neither rotating it is the only fix.** Deleting the message does not help and neither

View File

@ -19,8 +19,7 @@ is short enough to finish rather than a document to skim.
## Before a release ## Before a release
- [ ] `bash scripts/secrets.sh --tracked` is clean — proves nothing credential-shaped is committed - [ ] `npm run verify` passes guard `20-secrets`. It runs `scripts/secrets.sh --tracked`, which proves nothing credential-shaped is committed, and `--built dist/` over the bundle the build just made, which proves the *bundle* is clean, something the tracked scan cannot tell you
- [ ] `npm run build && bash scripts/secrets.sh --built dist/` is clean — proves the *bundle* is clean, which the tracked scan cannot tell you
- [ ] `bash scripts/check-env.sh --file .env` exits 0 — proves every variable the server reads is set and shaped right, before it reads them. **Exit 2 is not a pass** - [ ] `bash scripts/check-env.sh --file .env` exits 0 — proves every variable the server reads is set and shaped right, before it reads them. **Exit 2 is not a pass**
- [ ] `npm audit` shows no high or critical advisory in production dependencies — proves no known-exploitable code ships - [ ] `npm audit` shows no high or critical advisory in production dependencies — proves no known-exploitable code ships
- [ ] `bash scripts/preflight.sh` is clean against **both** front doors — `queuenorth.com` by default and `PREFLIGHT_ORIGIN=https://qn.isnull.dev` for the other. Proves headers, CSP and TLS survived the deploy on two separate ingresses that can rot independently - [ ] `bash scripts/preflight.sh` is clean against **both** front doors — `queuenorth.com` by default and `PREFLIGHT_ORIGIN=https://qn.isnull.dev` for the other. Proves headers, CSP and TLS survived the deploy on two separate ingresses that can rot independently
@ -73,3 +72,4 @@ with.
| 2026-05-11 → 2026-06-14 | The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` were hardcoded in `index.html` and later `src/pages/Contact.jsx`, and reached four commits on what was then a **public** repository, before being moved to environment variables at `05b27d2`. Low severity — they are public-by-design form identifiers a browser renders anyway — and deliberately **not** rewritten out of the history, which is now private | `scripts/secrets.sh` in the `pre-commit` hook, scanning the staged diff. This is the finding that makes the hook worth having in a project with no test suite to run beside it | | 2026-05-11 → 2026-06-14 | The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` were hardcoded in `index.html` and later `src/pages/Contact.jsx`, and reached four commits on what was then a **public** repository, before being moved to environment variables at `05b27d2`. Low severity — they are public-by-design form identifiers a browser renders anyway — and deliberately **not** rewritten out of the history, which is now private | `scripts/secrets.sh` in the `pre-commit` hook, scanning the staged diff. This is the finding that makes the hook worth having in a project with no test suite to run beside it |
| 2026-08-18 | Nobody had ever checked whether the reCAPTCHA **secret** key had reached git. It had not — zero commits, zero tracked files — but "we would have noticed" is not a check | `secrets.sh --tracked` is now the first thing run in a fresh clone, per `docs/TOOLS.md` | | 2026-08-18 | Nobody had ever checked whether the reCAPTCHA **secret** key had reached git. It had not — zero commits, zero tracked files — but "we would have noticed" is not a check | `secrets.sh --tracked` is now the first thing run in a fresh clone, per `docs/TOOLS.md` |
| 2026-08-18 | The live lead database had no backup and no restore had ever been attempted, and nothing in any document said so | `scripts/backup.sh` and `scripts/restore-check.sh`, **both run against production the same day**: a verified snapshot was taken from the running container and replayed into a scratch database — 2 tables, 3 rows, under a second. `docs/OPERATIONS.md` now carries a real *Last verified restore* date. What is still missing is a schedule and an off-machine copy, tracked in `Batch 15` | | 2026-08-18 | The live lead database had no backup and no restore had ever been attempted, and nothing in any document said so | `scripts/backup.sh` and `scripts/restore-check.sh`, **both run against production the same day**: a verified snapshot was taken from the running container and replayed into a scratch database — 2 tables, 3 rows, under a second. `docs/OPERATIONS.md` now carries a real *Last verified restore* date. What is still missing is a schedule and an off-machine copy, tracked in `Batch 15` |
| 2026-09-10 | `scripts/secrets.sh` had never run its private-key pattern: grep read the leading dashes as an option and `2>/dev/null` hid the error, so a staged private key passed the pre-commit hook. Its NAME=value pattern matched nothing at all until 2026-08-29, and missed quoted values until 2026-09-10. Nothing ran the bundle scan this checklist asked for | Every pattern is compiled before the scan, and an unreadable one exits 2 instead of passing; every grep takes `-e`; quoted values are caught; a documentation line is excused only by a visible `secrets-ok:` note; guard `20-secrets` now scans `dist/` as well (#235) |

View File

@ -9,10 +9,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0B1B3F" /> <meta name="theme-color" content="#0B1B3F" />
<title>Queue North Technologies | Business Communications & IT Partner</title> <title>Queue North Technologies | Business Communications & IT Partner</title>
<meta name="description" content="Queue North Technologies is a veteran-owned 8x8 Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions. 25+ years of proven reliability." /> <meta name="description" content="Queue North Technologies is a veteran-owned 8x8 Certified Partner providing business phone systems, UCaaS, contact center, IT support, and networking solutions. 25+ years of industry experience." />
<!-- Open Graph fallback for crawlers that don't execute JavaScript --> <!-- Open Graph fallback for crawlers that don't execute JavaScript -->
<meta property="og:title" content="Queue North Technologies | Business Communications & IT Partner" /> <meta property="og:title" content="Queue North Technologies | Business Communications & IT Partner" />
<meta property="og:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions. 25+ years of proven reliability." /> <meta property="og:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions. 25+ years of industry experience." />
<meta property="og:url" content="https://queuenorth.com" /> <meta property="og:url" content="https://queuenorth.com" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Queue North Technologies" /> <meta property="og:site_name" content="Queue North Technologies" />
@ -22,13 +22,13 @@
<meta property="og:image:type" content="image/png" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Queue North Technologies Business Communications & IT Partner" /> <meta property="og:image:alt" content="Queue North Technologies: Business Communications & IT Partner" />
<!-- Twitter / X Card fallback --> <!-- Twitter / X Card fallback -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Queue North Technologies | Business Communications & IT Partner" /> <meta name="twitter:title" content="Queue North Technologies | Business Communications & IT Partner" />
<meta name="twitter:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions." /> <meta name="twitter:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions." />
<meta name="twitter:image" content="https://queuenorth.com/assets/og-image.png" /> <meta name="twitter:image" content="https://queuenorth.com/assets/og-image.png" />
<meta name="twitter:image:alt" content="Queue North Technologies Business Communications & IT Partner" /> <meta name="twitter:image:alt" content="Queue North Technologies: Business Communications & IT Partner" />
<!-- The prerenderer injects a per-route <link rel="preload"> for that page's <!-- The prerenderer injects a per-route <link rel="preload"> for that page's
hero image here (scripts/prerender.js). --> hero image here (scripts/prerender.js). -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "queuenorth-website", "name": "queuenorth-website",
"version": "0.9.4", "version": "0.9.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "queuenorth-website", "name": "queuenorth-website",
"version": "0.9.4", "version": "0.9.8",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",

View File

@ -1,7 +1,7 @@
{ {
"name": "queuenorth-website", "name": "queuenorth-website",
"private": true, "private": true,
"version": "0.9.4", "version": "0.9.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"", "dev": "concurrently \"vite\" \"node server/index.js\"",

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve"> viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#07182D;} .st0{fill:#07182D;}
</style> </style>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve"> viewBox="82 105 537 463" style="enable-background:new 82 105 537 463;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#FFFFFF;} .st0{fill:#FFFFFF;}
</style> </style>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -1,7 +1,7 @@
{ {
"name": "Queue North Technologies", "name": "Queue North Technologies",
"short_name": "Queue North", "short_name": "Queue North",
"description": "Veteran-owned 8x8 Certified Partner business phone, UCaaS, contact center, IT support, and networking solutions.", "description": "Veteran-owned 8x8 Certified Partner: business phone, UCaaS, contact center, IT support, and networking solutions.",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",

194
scripts/audit-html.js Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env node
//
// Audits what the site actually serves, in two modes.
//
// node scripts/audit-html.js # dist/, run as guard 15-built-html
// node scripts/audit-html.js --url https://queuenorth.com
// node scripts/audit-html.js --url http://localhost:3001 --agents Googlebot
//
// Build mode is a gate: it reads dist/ and refuses on a finding. URL mode
// fetches every page in the live sitemap once per crawler user agent and reports
// what those crawlers actually receive. URL mode is a check to run AFTER a
// deploy, deliberately not wired into deploy.sh: a check that runs after
// publication cannot stop it, and pretending otherwise is worse than not having
// it (GUARDS.md rule 6).
//
// Exit 0 clean, 1 findings, 2 nothing was audited.
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { auditLinks, auditPage, auditSitemap, parseSitemap } from './lib/html-audit.js'
import { ROUTES } from './lib/routes.js'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const distDir = path.join(root, 'dist')
// The crawlers this site is written for. A spoofed agent string is not the real
// crawler, and a "verified bots only" rule at the edge would answer this with a
// 403 while serving the real one, so treat a pass as evidence the ORIGIN is not
// blocking, not as proof the crawler is happy.
const AGENTS = {
'OAI-SearchBot': 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)',
PerplexityBot: 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)',
ClaudeBot: 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)',
Googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
}
const argv = process.argv.slice(2)
const flag = (name) => {
const at = argv.indexOf(`--${name}`)
return at === -1 ? null : argv[at + 1]
}
const origin = flag('url')
const agents = (flag('agents')?.split(',') ?? Object.keys(AGENTS)).filter((name) => AGENTS[name])
const descriptionSource = (routePath) => {
const service = services.find((item) => routePath === `/services/${item.id}`)
if (service) return { shortDesc: service.shortDesc, approvedDescription: Boolean(service.page?.seo?.description) }
const industry = industries.find((item) => routePath === `/industries/${item.id}`)
if (industry) return { shortDesc: industry.shortDesc, approvedDescription: false }
return { shortDesc: null, approvedDescription: false }
}
const report = (findings, checked, what) => {
if (findings.length) {
console.error(`audit: ${findings.length} finding(s) in ${what}:`)
for (const finding of findings) console.error(` ${finding}`)
process.exit(1)
}
console.log(`audit: ${checked} page(s) in ${what}, nothing wrong.`)
}
// --- build mode --------------------------------------------------------------
const newestSourceMtime = () => {
let newest = 0
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else newest = Math.max(newest, statSync(full).mtimeMs)
}
}
for (const dir of ['src', 'public', 'scripts/lib']) walk(path.join(root, dir))
for (const file of ['index.html', 'scripts/prerender.js']) newest = Math.max(newest, statSync(path.join(root, file)).mtimeMs)
return newest
}
const auditBuild = () => {
const sitemapPath = path.join(distDir, 'sitemap.xml')
if (!existsSync(sitemapPath)) {
console.error('audit: dist/sitemap.xml is missing, so the build never finished and NOTHING was audited. Run npm run build.')
process.exit(2)
}
// The prerender writes the sitemap last. A source file newer than it means
// dist/ is stale, and auditing stale output is auditing nothing.
if (newestSourceMtime() > statSync(sitemapPath).mtimeMs) {
console.error('audit: dist/ is older than the sources, so NOTHING was audited. Run npm run build.')
process.exit(2)
}
const entries = parseSitemap(readFileSync(sitemapPath, 'utf8'))
const findings = [...auditSitemap(entries)]
const pages = []
for (const routePath of ROUTES) {
const file = routePath === '/' ? path.join(distDir, 'index.html') : path.join(distDir, routePath, 'index.html')
if (!existsSync(file)) {
findings.push(`${routePath}: the build produced no page, so the server would answer it with 404.html`)
continue
}
const html = readFileSync(file, 'utf8')
pages.push([routePath, html])
findings.push(...auditPage(html, { path: routePath, ...descriptionSource(routePath) }))
}
const notFound = path.join(distDir, '404.html')
if (!existsSync(notFound)) findings.push('404.html: missing from the build')
else findings.push(...auditPage(readFileSync(notFound, 'utf8'), { path: '/404', notFound: true }))
findings.push(...auditLinks(pages, { fileExists: (href) => existsSync(path.join(distDir, href.replace(/^\//, ''))) }))
report(findings, pages.length, 'the build')
}
// --- url mode ----------------------------------------------------------------
const fetchAs = async (url, agent) => {
const response = await fetch(url, {
headers: { 'User-Agent': AGENTS[agent] },
redirect: 'manual',
signal: AbortSignal.timeout(20000),
})
return { status: response.status, body: await response.text() }
}
const auditOrigin = async () => {
let entries
try {
const response = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
entries = parseSitemap(await response.text())
} catch (error) {
console.error(`audit: could not read ${origin}/sitemap.xml (${error.message}), so NOTHING was audited.`)
process.exit(2)
}
if (!entries.length) {
console.error(`audit: ${origin}/sitemap.xml lists no pages, so NOTHING was audited.`)
process.exit(2)
}
const findings = [...auditSitemap(entries, { requireLastmod: true })]
const pages = []
for (const entry of entries) {
const url = `${origin}${entry.path}`
const bodies = new Map()
for (const agent of agents) {
let result
try {
result = await fetchAs(url, agent)
} catch (error) {
findings.push(`${entry.path}: ${agent} could not fetch it (${error.message})`)
continue
}
if (result.status !== 200) findings.push(`${entry.path}: ${agent} got HTTP ${result.status}`)
bodies.set(agent, result.body)
}
const distinct = new Set(bodies.values())
if (distinct.size > 1) {
// Two different bodies can mean two different things, and only one of them
// is a problem worth chasing: the origin choosing what to serve by agent,
// or something in front of it varying every response. Cloudflare's email
// obfuscation does the second, with a token that changes each time. Ask
// the same agent twice before blaming the agents.
let varies = false
try {
const again = await fetchAs(url, agents[0])
varies = again.body !== bodies.get(agents[0])
} catch {
varies = false
}
findings.push(
varies
? `${entry.path}: the response body changes between identical requests, so something in front of the origin is rewriting it (Cloudflare email obfuscation does this). Crawlers do not all receive the same page.`
: `${entry.path}: crawlers were served different bytes (${bodies.size} agents, ${distinct.size} versions)`,
)
}
const body = bodies.values().next().value
if (body) {
pages.push([entry.path, body])
findings.push(...auditPage(body, { path: entry.path, ...descriptionSource(entry.path) }))
}
}
findings.push(...auditLinks(pages, { fileExists: () => true }))
report(findings, pages.length, `${origin} as ${agents.length} crawler(s)`)
}
if (origin) await auditOrigin()
else auditBuild()

View File

@ -58,7 +58,7 @@
# is the container, and the database is elsewhere # is the container, and the database is elsewhere
# NODE_ENV=Production matches no `=== "production"` anywhere, so every # NODE_ENV=Production matches no `=== "production"` anywhere, so every
# branch takes its development arm in production # branch takes its development arm in production
# JWT_SECRET=devsecret the eight characters someone typed to get the dev # JWT_SECRET=devsecret the eight characters someone typed to get the dev (secrets-ok: example)
# server up, now signing real sessions # server up, now signing real sessions
# API_URL=https://api.example.com/ # API_URL=https://api.example.com/
# one trailing slash; every joined path is `//v1/...` # one trailing slash; every joined path is `//v1/...`
@ -247,12 +247,12 @@ SPEC=(
# API_KEY, KEYCLOAK_SECRET and MONKEY_HOST alike. The last is a false positive # API_KEY, KEYCLOAK_SECRET and MONKEY_HOST alike. The last is a false positive
# and costs nothing: its value is described rather than shown. # and costs nothing: its value is described rather than shown.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SECRET_NAME_PATTERN='SECRET|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIAL|PRIVATE|SALT|SIGNATURE|SIGNING|AUTH|KEY|DSN|COOKIE' SECRET_NAME_PATTERN='SECRET|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIAL|PRIVATE|SALT|SIGNATURE|SIGNING|AUTH|KEY|DSN|COOKIE' # secrets-ok: names, not a value
# Values a `secret-min-length` variable must not be, compared in lower case and # Values a `secret-min-length` variable must not be, compared in lower case and
# never echoed. These are the strings typed to make the dev server start, which # never echoed. These are the strings typed to make the dev server start, which
# then travel to production inside a copied .env and satisfy every length rule. # then travel to production inside a copied .env and satisfy every length rule.
PLACEHOLDER_SECRETS='changeme change-me change_me changethis secret mysecret supersecret password passwd hunter2 test testing example placeholder todo tbd xxx xxxx admin dev devsecret development your-secret-here your_secret_here notasecret 123456 12345678 abc123' PLACEHOLDER_SECRETS='changeme change-me change_me changethis secret mysecret supersecret password passwd hunter2 test testing example placeholder todo tbd xxx xxxx admin dev devsecret development your-secret-here your_secret_here notasecret 123456 12345678 abc123' # secrets-ok: known-weak values
# Redaction applies to NAMES read out of a file too, not only to values. # Redaction applies to NAMES read out of a file too, not only to values.
# #

233
scripts/device-sweep.mjs Executable file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env node
//
// Render every page on emulated phones and tablets and MEASURE the layout.
//
// node scripts/device-sweep.mjs # localhost:3001
// node scripts/device-sweep.mjs --url https://queuenorth.com
// node scripts/device-sweep.mjs --devices "iPhone SE,iPad Mini"
// node scripts/device-sweep.mjs --report /tmp/sweep.md --shots /tmp/sweep
//
// Exit codes: 0 nothing found. 1 findings. 2 NOTHING WAS SWEPT: playwright
// missing, chromium unlaunchable, or no sitemap. Two is not a pass.
//
// ## Which incident motivated it
//
// #214 was "the header CTA is clipped at iPad portrait". It was fixed in v0.9.5
// by tightening the nav gaps, checked in a desktop browser window sized to 768,
// and released. The check was worthless: a desktop window at 768 has a scrollbar,
// so the layout viewport was ~753px, the md breakpoint never engaged, and the
// desktop header the fix was about was never on screen. The CTA was still 25px
// past the right edge in production, on every page, and body{overflow-x:hidden}
// sliced it off with no scrollbar to hint that anything was missing.
//
// A real device profile has no scrollbar inset, so 768 means 768. It also brings
// deviceScaleFactor, isMobile and hasTouch, which change hover media queries and
// text metrics. Those differences are the whole reason this exists alongside
// qa-browser.mjs: that script varies width, this one varies device.
//
// ## How it differs from qa-browser.mjs
//
// qa-browser a few widths, one desktop context; contrast, CLS, LCP,
// broken images, horizontal scroll
// device-sweep ten real device profiles; eight classes of layout defect
// from scripts/lib/css-audit.js, measured against each box's
// nearest CLIPPING ancestor rather than document.scrollWidth
//
// Neither replaces the other. Reach for both when a UI defect is filed.
//
// ## Why it is not wired into verify.sh
//
// Same trade as qa-browser: playwright is a global install here, not a
// dependency, and this needs a server already serving the build. A guard that
// cannot run on a clean clone is a guard that gets skipped, and verify.sh
// treating a skip as a pass is the failure mode GUARDS.md exists to prevent.
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { audit } from './lib/css-audit.js'
import { parseSitemap } from './lib/html-audit.js'
const args = process.argv.slice(2)
const opt = (name, dflt) => {
const i = args.indexOf(`--${name}`)
return i === -1 ? dflt : args[i + 1]
}
const URL_BASE = opt('url', 'http://localhost:3001').replace(/\/$/, '')
const REPORT = opt('report', null)
const SHOTS = opt('shots', null)
const ONLY = opt('devices', null)?.split(',').map((s) => s.trim())
let chromium
let devices
try {
const require = createRequire(import.meta.url)
let root
try {
root = require.resolve('playwright')
} catch {
root = path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright', 'index.js')
}
;({ chromium, devices } = require(root))
} catch {
console.error('device-sweep: playwright is not available, so NOTHING was swept.')
console.error(' npm i -g playwright && npx playwright install chromium')
process.exit(2)
}
// Portrait and landscape both, because a tablet is held both ways and a phone in
// landscape is the shape that catches a menu taller than the viewport. iPad Mini
// portrait is 768 exactly, which is the md breakpoint, which is where #214 was.
// Playwright has no landscape entries for these, so the two rotated profiles keep
// the device's scale factor and touch flags and swap the viewport by hand.
const PROFILES = [
['iPhone SE', devices['iPhone SE']],
['iPhone 12', devices['iPhone 12']],
['iPhone 14 Pro Max', devices['iPhone 14 Pro Max']],
['Pixel 7', devices['Pixel 7']],
['Galaxy S9+', devices['Galaxy S9+']],
['iPhone 12 landscape', { ...devices['iPhone 12'], viewport: { width: 664, height: 390 } }],
['iPad Mini', devices['iPad Mini']],
['iPad Mini landscape', { ...devices['iPad Mini'], viewport: { width: 1024, height: 768 } }],
['iPad Pro 11', devices['iPad Pro 11']],
['iPad Pro 11 landscape', { ...devices['iPad Pro 11'], viewport: { width: 1194, height: 834 } }],
].filter(([label, profile]) => profile && (!ONLY || ONLY.includes(label)))
if (!PROFILES.length) {
console.error(`device-sweep: no device profile matched ${ONLY?.join(', ')}, so NOTHING was swept.`)
process.exit(2)
}
// Whatever the target says it serves, plus a path it does not: 404.html is a page
// users reach and it has never been in anybody's hand-typed list.
let routes
try {
const response = await fetch(`${URL_BASE}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
routes = parseSitemap(await response.text()).map((entry) => entry.path)
if (!routes.length) throw new Error('it lists no pages')
} catch (error) {
console.error(`device-sweep: could not read ${URL_BASE}/sitemap.xml (${error.message}), so NOTHING was swept.`)
process.exit(2)
}
routes.push('/no-such-page')
const browser = await chromium.launch().catch((error) => {
console.error('device-sweep: could not launch chromium, so NOTHING was swept:', error.message)
process.exit(2)
})
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
const AUDIT_SRC = audit.toString()
const findings = []
let loads = 0
for (const [label, profile] of PROFILES) {
const context = await browser.newContext({ ...profile })
const page = await context.newPage()
for (const route of routes) {
let response
try {
response = await page.goto(`${URL_BASE}${route}`, { waitUntil: 'networkidle', timeout: 45000 })
} catch (error) {
findings.push({ kind: 'load_failed', severity: 'blocking', route, device: label, says: error.message.split('\n')[0], path: '', text: '' })
continue
}
loads++
if (!response || (response.status() >= 400 && route !== '/no-such-page')) {
findings.push({ kind: 'http_error', severity: 'blocking', route, device: label, says: `HTTP ${response?.status()}`, path: '', text: '' })
continue
}
// Scroll the whole page before measuring, so lazy images have loaded and
// sticky elements have been in their stuck state. Measuring at the top only
// reports a page nobody has used yet.
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 400) {
window.scrollTo(0, y)
await new Promise((resolve) => setTimeout(resolve, 60))
}
window.scrollTo(0, 0)
})
await page.waitForTimeout(500)
for (const finding of await page.evaluate(`(${AUDIT_SRC})()`)) {
findings.push({ ...finding, route, device: label })
}
if (SHOTS) {
const name = `${label.replace(/\W+/g, '-')}${route.replace(/\//g, '_') || '_root'}.png`
await page.screenshot({ path: path.join(SHOTS, name), fullPage: true })
}
}
await context.close()
}
await browser.close()
if (!loads) {
console.error(`device-sweep: every page load failed against ${URL_BASE}, so NOTHING was measured.`)
process.exit(2)
}
// The same defect on six devices is one defect. Group on what identifies it
// (route, element, text) and keep the device list, because "only iPad Mini"
// versus "all ten" is the difference between a breakpoint bug and a layout bug.
const groups = new Map()
for (const finding of findings) {
const key = `${finding.kind}|${finding.route}|${finding.path}|${(finding.text || '').slice(0, 40)}`
if (!groups.has(key)) groups.set(key, { ...finding, devices: new Set(), count: 0 })
groups.get(key).devices.add(finding.device)
groups.get(key).count++
}
const rows = [...groups.values()].sort(
(a, b) => (b.severity === 'blocking') - (a.severity === 'blocking') || b.devices.size - a.devices.size,
)
const bySeverity = (severity) => rows.filter((row) => row.severity === severity)
if (REPORT) {
const lines = [
`# Device sweep: ${URL_BASE}`,
'',
`${routes.length} routes x ${PROFILES.length} devices = ${loads} page loads`,
`Devices: ${PROFILES.map(([label]) => label).join(', ')}`,
'',
`**${bySeverity('blocking').length} blocking, ${bySeverity('high').length} high, ${bySeverity('info').length} informational** (grouped from ${findings.length})`,
'',
]
for (const severity of ['blocking', 'high', 'info']) {
const group = bySeverity(severity)
if (!group.length) continue
lines.push(`## ${severity}`, '')
for (const row of group) {
lines.push(
`- **${row.route}** ${row.kind}: ${row.says || ''}`,
` - \`${row.path}\`${row.text ? `, text: ${JSON.stringify(String(row.text).slice(0, 60))}` : ''}`,
` - on ${[...row.devices].join(', ')}`,
row.detail ? ` - \`${JSON.stringify(row.detail)}\`` : '',
)
}
lines.push('')
}
mkdirSync(path.dirname(path.resolve(REPORT)), { recursive: true })
writeFileSync(REPORT, lines.filter((line) => line !== '').join('\n') + '\n')
}
console.log(`device-sweep: ${loads} page loads across ${PROFILES.length} device(s) of ${URL_BASE}`)
for (const severity of ['blocking', 'high', 'info']) {
const counts = new Map()
for (const row of bySeverity(severity)) counts.set(row.kind, (counts.get(row.kind) || 0) + 1)
for (const [kind, n] of counts) console.log(` ${severity}: ${kind} x${n}`)
}
if (REPORT) console.log(` report: ${REPORT}`)
if (!rows.length) {
console.log(' nothing wrong.')
process.exit(0)
}
for (const row of rows.slice(0, 20)) {
console.log(` ${row.severity.padEnd(8)} ${row.route} ${row.kind}: ${row.says || ''} [${row.path}]`)
}
if (rows.length > 20) console.log(` ...and ${rows.length - 20} more${REPORT ? ' in the report' : ' (pass --report to list them all)'}`)
process.exit(1)

278
scripts/lib/content.js Normal file
View File

@ -0,0 +1,278 @@
// What must be true of the content layer before a page is built from it.
//
// ## Why this exists
//
// `src/data/*.js` is prose in a data structure, and nothing checked it. A
// misspelt block type, a link to a route that does not exist, a section whose
// answer paragraph is missing, a missing `benefits` list that crashes the
// prerender with `Cannot read properties of undefined`: each was found by a
// person looking at a page, or not found at all.
//
// The long-form service pages made that worse in a specific way. Their copy is
// owner-approved and arrives as a 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 screenshot". Those lines
// look exactly like copy. Publishing one puts an internal instruction on a
// customer-facing page, and no build step would have noticed.
//
// So this refuses to build rather than publish. It runs inside prerender.js,
// before any page renders, which means every `npm run build` enforces it: the
// pre-commit hook, `npm run verify`, and the Docker image build.
//
// Exit codes for the runner: 0 clean, 1 findings, 2 nothing was checked.
import { existsSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { ROUTES } from './routes.js'
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
// A block type, and the field it cannot be missing.
const BLOCK_FIELDS = {
p: 'text',
h3: 'text',
ul: 'items',
ol: 'items',
callout: 'text',
image: 'alt',
links: 'items',
}
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
// Ids already used by the layout. A section id that collides with one of these
// makes `#contact-form` scroll to the wrong element.
const RESERVED_IDS = new Set(['contact-form', 'mobile-nav-content', 'support-portal', 'root'])
// Directions to the website manager, which the sheets mix into their copy.
const SHEET_DIRECTIONS = [
/website manager/i,
/not for the (public )?webpage/i,
/place an official/i,
/do not add faq/i,
/this section is (important|highly useful)/i,
/\bQ\d+:/,
/approved visual/i,
/suggested (alt text|caption)/i,
/before publication/i,
/avoid (unsupported|promising|implying)/i,
/link naturally to/i,
/keep this factual/i,
/factual differentiators/i,
/reliability wording/i,
/^direct[- ]answer/i,
]
// Markdown that would print literally on the page, and characters that must not
// reach it. React escapes text, so pasted markup shows up as itself.
const TEXT_FAULTS = [
[/—/, 'an em dash (U+2014), which Null asked for nowhere in site copy'],
[/<2F>/, 'a U+FFFD replacement character, so the source was decoded with the wrong encoding'],
[/\*\*/, 'markdown bold markers'],
[/`/, 'a backtick'],
[/\]\(/, 'a markdown link'],
[/<\/?[a-z][^>]*>/i, 'an HTML tag'],
]
export const validateContent = ({ services, industries }) => {
const errors = []
const warnings = []
let checked = 0
const fail = (where, detail) => errors.push(`${where}: ${detail}`)
// Every string that reaches a page, wherever it sits in the tree.
const checkText = (where, value) => {
if (typeof value !== 'string') return
checked += 1
for (const [pattern, detail] of TEXT_FAULTS) {
if (pattern.test(value)) fail(where, `${detail} in ${JSON.stringify(value.slice(0, 60))}`)
}
for (const pattern of SHEET_DIRECTIONS) {
if (pattern.test(value)) {
fail(where, `reads as a direction to the website manager, not page copy: ${JSON.stringify(value.slice(0, 80))}`)
break
}
}
}
const checkLink = (where, to, ownIds) => {
checked += 1
if (typeof to !== 'string' || !to) return fail(where, 'link has no destination')
const [route, fragment] = to.split('#')
if (route && !ROUTES.includes(route)) fail(where, `links to ${route}, which is not a route this site serves`)
if (fragment && !route && !ownIds.has(fragment) && !RESERVED_IDS.has(fragment)) {
fail(where, `links to #${fragment}, which is not a section on this page`)
}
}
const checkBlocks = (where, section, ownIds) => {
const blocks = section.blocks
if (!Array.isArray(blocks) || blocks.length === 0) return fail(where, 'has no blocks')
blocks.forEach((block, index) => {
const at = `${where} blocks[${index}]`
const field = BLOCK_FIELDS[block?.type]
if (!field) {
return fail(at, `unknown block type ${JSON.stringify(block?.type)}. Known types: ${Object.keys(BLOCK_FIELDS).join(', ')}`)
}
if (block[field] == null) return fail(at, `a ${block.type} block has no ${field}`)
checked += 1
if (block.type === 'p') {
if (Array.isArray(block.text)) {
block.text.forEach((part, i) => {
if (typeof part === 'string') checkText(`${at}.text[${i}]`, part)
else {
checkText(`${at}.text[${i}].text`, part?.text)
checkLink(`${at}.text[${i}]`, part?.to, ownIds)
}
})
} else checkText(`${at}.text`, block.text)
} else if (block.type === 'ul' || block.type === 'ol') {
if (!Array.isArray(block.items) || !block.items.length) fail(at, 'a list with no items')
block.items?.forEach((item, i) => {
if (typeof item === 'string') checkText(`${at}.items[${i}]`, item)
else {
checkText(`${at}.items[${i}].text`, item?.text)
checkText(`${at}.items[${i}].detail`, item?.detail)
if (item?.text == null) fail(`${at}.items[${i}]`, 'a step with no text')
}
})
} else if (block.type === 'links') {
block.items?.forEach((item, i) => {
checkText(`${at}.items[${i}].label`, item?.label)
checkLink(`${at}.items[${i}]`, item?.to, ownIds)
})
} else if (block.type === 'image') {
checkText(`${at}.alt`, block.alt)
checkText(`${at}.caption`, block.caption)
if (!block.alt) fail(at, 'an image with no alt text')
// A slot waiting for a file the owner has to supply carries src: null and
// renders nothing. A src that IS set must point at a file that exists,
// or the page ships a broken image nobody sees until a visitor does.
if (block.src) {
if (!block.src.startsWith('/') || block.src.includes('..')) {
fail(at, `image src ${JSON.stringify(block.src)} must be a site-absolute path under public/`)
} else if (!existsSync(path.join(publicDir, block.src.replace(/^\//, '')))) {
fail(at, `image src ${JSON.stringify(block.src)} does not exist under public/`)
}
if (!block.width || !block.height) fail(at, 'an image with a src needs width and height, or the page shifts as it loads')
}
} else {
checkText(`${at}.text`, block.text)
}
})
// The opening block decides whether an answer engine can quote the section.
const first = blocks[0]?.type
if (section.kind === 'faq') {
blocks.forEach((block, index) => {
if (block.type !== 'h3') return
if (blocks[index + 1]?.type !== 'p') fail(`${where} blocks[${index}]`, 'a FAQ question with no answer paragraph after it')
})
if (first !== 'h3') fail(where, 'a FAQ section must open with its first question')
} else if (section.kind === 'list') {
if (first !== 'ul' && first !== 'ol') fail(where, "kind 'list' says the section opens with a list, and it does not")
} else if (first !== 'p') {
fail(where, `opens with a ${first} block. A section opens with its direct answer, which is what a search result or an AI answer quotes. Use kind 'list' when the approved copy genuinely has no answer paragraph.`)
}
}
const checkPage = (where, page) => {
const ownIds = new Set((page.sections || []).map((section) => section.id))
if (!page.seo?.title) fail(`${where}.page.seo`, 'no title')
if (!page.seo?.description) fail(`${where}.page.seo`, 'no description')
checkText(`${where}.page.seo.title`, page.seo?.title)
checkText(`${where}.page.seo.description`, page.seo?.description)
if (page.seo?.description && page.seo.description.length > 160) {
warnings.push(
`${where}.page.seo.description is ${page.seo.description.length} characters. Search results show about 155 to 160, so the tail may not be seen. Owner-approved copy is published as written.`,
)
}
for (const field of ['h1', 'subheading']) {
if (!page.hero?.[field]) fail(`${where}.page.hero`, `no ${field}`)
checkText(`${where}.page.hero.${field}`, page.hero?.[field])
}
if (!Array.isArray(page.hero?.intro) || !page.hero.intro.length) fail(`${where}.page.hero`, 'no intro copy')
page.hero?.intro?.forEach((text, i) => checkText(`${where}.page.hero.intro[${i}]`, text))
for (const cta of ['primaryCta', 'secondaryCta']) {
const value = page.hero?.[cta]
if (!value) {
if (cta === 'primaryCta') fail(`${where}.page.hero`, 'no primaryCta')
continue
}
checkText(`${where}.page.hero.${cta}.label`, value.label)
checkLink(`${where}.page.hero.${cta}`, value.to, ownIds)
}
if (!Array.isArray(page.sections) || !page.sections.length) return fail(`${where}.page`, 'no sections')
const seen = new Set()
page.sections.forEach((section, index) => {
const at = `${where}.page.sections[${index}]${section?.id ? ` (${section.id})` : ''}`
if (!ID_SHAPE.test(section?.id || '')) {
fail(at, `id ${JSON.stringify(section?.id)} must be lowercase, start with a letter and join words with single hyphens. An id starting with a digit cannot be used as a CSS selector`)
}
if (RESERVED_IDS.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} is already used by the page layout`)
if (seen.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} appears twice on this page`)
seen.add(section?.id)
if (!section?.title) fail(at, 'no title')
checkText(`${at}.title`, section?.title)
checkBlocks(at, section, ownIds)
})
page.related?.forEach((link, i) => {
checkText(`${where}.page.related[${i}].label`, link?.label)
checkLink(`${where}.page.related[${i}]`, link?.to, ownIds)
})
}
for (const service of services || []) {
const where = `services/${service?.id}`
for (const field of ['id', 'name', 'shortDesc', 'homeDesc', 'icon']) {
if (!service?.[field]) fail(where, `no ${field}`)
}
checkText(`${where}.shortDesc`, service.shortDesc)
checkText(`${where}.homeDesc`, service.homeDesc)
if (service.page) checkPage(where, service.page)
else {
// The layout used by every service without long-form copy maps these with
// no guard, so a missing one is a build crash with no useful message.
if (!service.fullDesc) fail(where, 'no fullDesc, and no page copy either')
for (const field of ['benefits', 'idealFor']) {
if (!Array.isArray(service[field]) || !service[field].length) fail(where, `no ${field}, which the service layout maps without checking`)
}
checkText(`${where}.fullDesc`, service.fullDesc)
}
// Every service is a card on the Services index, which shows idealFor[0].
if (!Array.isArray(service.idealFor) || !service.idealFor.length) {
fail(where, 'no idealFor. The Services index prints idealFor[0] as "Best fit"')
}
service.related?.forEach((link, i) => {
checkText(`${where}.related[${i}].label`, link?.label)
checkLink(`${where}.related[${i}]`, link?.to, new Set())
})
}
for (const industry of industries || []) {
const where = `industries/${industry?.id}`
for (const field of ['id', 'name', 'shortDesc', 'fullDesc', 'icon']) {
if (!industry?.[field]) fail(where, `no ${field}`)
}
for (const field of ['painPoints', 'solutions']) {
if (!Array.isArray(industry?.[field]) || !industry[field].length) fail(where, `no ${field}, which the industry layout maps without checking`)
}
checkText(`${where}.shortDesc`, industry?.shortDesc)
checkText(`${where}.fullDesc`, industry?.fullDesc)
industry?.related?.forEach((link, i) => {
checkText(`${where}.related[${i}].label`, link?.label)
checkLink(`${where}.related[${i}]`, link?.to, new Set())
})
}
return { errors, warnings, checked }
}

589
scripts/lib/css-audit.js Normal file
View File

@ -0,0 +1,589 @@
// Measures a rendered page and reports where the layout is wrong.
//
// This function is not run here. It is serialised with toString() and evaluated
// inside the browser by scripts/device-sweep.mjs, so it may use only what a page
// has: no imports, no Node globals, no closure over anything in this file.
//
// It measures rather than guesses. Every box is compared against its nearest
// CLIPPING ancestor instead of document.scrollWidth, which lies the moment any
// container carries overflow-x: hidden or clip, and this site's body does, so
// a page can slice content off its right edge and still report a scrollWidth
// equal to the viewport. That is exactly how the header CTA at iPad portrait
// survived a fix and a release.
//
// It reports eight kinds: clipped, past_viewport, document_scrolls,
// media_overflow, sticky_occluded, active_tab_offscreen, tiny_text and
// touch_target. Each finding carries a severity, a devtools-pasteable selector
// path, and the numbers it was decided on, so a finding can be re-measured
// rather than re-argued.
//
// Provenance: lifted from the Privacy LLC site's scripts/css-qc.mjs, which is
// where the thresholds were argued out and where the comments explaining each
// one were written. Copied rather than shared because the two repositories have
// no common package; if a threshold changes in one, it does not change in the
// other. The logic is that file's, unchanged. The prose is not byte-identical:
// this repository does not use em dashes, so the comments and the two report
// strings were repunctuated. Diff it on words, not bytes. The driver here differs from that one in a way that matters: css-qc
// declares Playwright device profiles but only ever calls setViewportSize, so
// its deviceScaleFactor, isMobile and hasTouch fields never take effect and it
// is a width sweep wearing a phone's clothes.
export const audit = function audit() {
const EPS = 1;
const vw = window.innerWidth;
const vh = window.innerHeight;
const findings = [];
const push = (f) => findings.push(f);
/** A selector a human can paste into devtools. Short, not unique-at-all-costs. */
function pathOf(el) {
const parts = [];
let node = el;
while (node && node.nodeType === 1 && parts.length < 4) {
let part = node.tagName.toLowerCase();
// `getAttribute`, not `.id`. A <form> containing <input name="id"> has
// its `id` property clobbered by that input, so `.id` returns an element
// and the path printed as `form#[object HTMLInputElement]`.
const id = node.getAttribute("id");
if (id) {
parts.unshift(`${part}#${id}`);
break;
}
const cls = (node.getAttribute("class") || "")
.split(/\s+/)
.filter((c) => c && !c.includes("[") && !c.includes(":"))
.slice(0, 2)
.join(".");
if (cls) part += `.${cls}`;
parts.unshift(part);
node = node.parentElement;
}
return parts.join(" > ");
}
const text = (el) => (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
const els = Array.from(document.body.querySelectorAll("*"));
const info = new Map();
const ellipsis = new Set();
/**
* Inside a closed disclosure, and therefore not on screen at all.
*
* Chrome does not `display: none` a closed `<details>`. It skips the
* subtree with `content-visibility`, and the descendants keep reporting
* layout boxes at their unconstrained size. The signature form in the
* documents table measured 149px wide at x=255 on a 320px screen while the
* closed `<details>` around it correctly measured 48px. Reporting that is
* reporting content nobody can see, and it is the third distinct class of
* false positive this audit had to learn about.
*/
function inClosedDisclosure(el) {
const details = el.closest("details:not([open])");
if (!details) return false;
const summary = details.querySelector(":scope > summary");
return !(summary && summary.contains(el));
}
for (const el of els) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") continue;
if (cs.contentVisibility === "hidden") continue;
if (inClosedDisclosure(el)) continue;
info.set(el, { rect, cs });
if (cs.textOverflow === "ellipsis") ellipsis.add(el);
}
/** The nearest ancestor that scrolls horizontally on purpose. */
function scrollerOf(el) {
let node = el.parentElement;
while (node && node !== document.body) {
const rec = info.get(node);
if (rec && (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll")) return node;
node = node.parentElement;
}
return null;
}
/** The nearest ancestor that cuts content off without letting anyone scroll to it. */
function clipperOf(el) {
let node = el.parentElement;
while (node && node !== document.documentElement) {
const rec = info.get(node);
if (!rec) { node = node.parentElement; continue; }
if (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll") return null;
if (rec.cs.overflowX === "hidden" || rec.cs.overflowX === "clip") return node;
node = node.parentElement;
}
return null;
}
// --- 1. Content past the right edge of the viewport -----------------------
//
// Leaves only: an element that overflows and has no overflowing descendant is
// the thing that is actually too wide. Reporting its ancestors as well would
// bury the one line that names the culprit under the whole chain it pushed.
const overViewport = new Set();
for (const [el, { rect }] of info) {
if (rect.right <= vw + EPS && rect.left >= -EPS) continue;
if (scrollerOf(el)) continue;
// Contained by something that clips: the reader does not see this past the
// edge, they see it cut off, which check 2 reports, with the clipper named.
// Reporting it here as well was the single largest source of noise in the
// first run: every `truncate` in the admin has a child span whose rect runs
// off the viewport by design, ellipsis and all.
const clipper = clipperOf(el);
if (clipper) {
const box = info.get(clipper);
if (box && box.rect.right <= vw + EPS) continue;
}
overViewport.add(el);
}
for (const el of overViewport) {
if (Array.from(overViewport).some((other) => other !== el && el.contains(other))) continue;
const { rect, cs } = info.get(el);
push({
kind: "past_viewport",
severity: "blocking",
path: pathOf(el),
text: text(el),
detail: {
right: Math.round(rect.right),
viewport: vw,
over: Math.round(rect.right - vw),
width: cs.width,
minWidth: cs.minWidth,
whiteSpace: cs.whiteSpace,
position: cs.position,
},
says: `${Math.round(rect.right - vw)}px past the right edge`,
});
}
// --- 2. Content clipped by an ancestor, with no way to scroll to it -------
const clipped = new Map();
for (const [el, { rect }] of info) {
const clipper = clipperOf(el);
if (!clipper) continue;
const box = info.get(clipper);
if (!box) continue;
if (rect.right <= box.rect.right + EPS && rect.left >= box.rect.left - EPS) continue;
// A clipper wider than the viewport is already reported by check 1.
if (box.rect.right > vw + EPS) continue;
const seen = clipped.get(clipper) || [];
seen.push({ el, rect });
clipped.set(clipper, seen);
}
for (const [clipper, children] of clipped) {
const leaves = children.filter(
({ el }) => !children.some((other) => other.el !== el && el.contains(other.el)),
);
const box = info.get(clipper);
// Overflow has two sides. Picking the right-most leaf and subtracting made
// a left-side overflow report as "cut off by -150px", which is not a
// sentence. Measure how far each leaf escapes in whichever direction it
// escapes, and rank by that.
const escape = ({ rect }) =>
Math.max(0, rect.right - box.rect.right, box.rect.left - rect.left);
const worst = leaves.reduce((a, b) => (escape(b) > escape(a) ? b : a), leaves[0]);
const side = worst.rect.right - box.rect.right >= box.rect.left - worst.rect.left ? "right" : "left";
// `text-overflow: ellipsis` is a container saying "I will cut text off and
// show that I did". That is an affordance, not silent loss: the reader can
// see there is more. It stops being one the moment something interactive or
// replaced is inside, because a button behind an ellipsis is still a button
// nobody can press.
const INTERACTIVE = "a[href], button, summary, input, select, textarea, img, video, iframe, [role=button], [role=tab]";
const carries = ({ el }) =>
(el.textContent || "").trim().length > 0 || el.matches(INTERACTIVE) || el.querySelector(INTERACTIVE);
/**
* A control is only *swallowed* when little enough of it survives the clip
* to stop being aimable.
*
* The first version of this asked whether a control was present at all, and
* that is too coarse for the commonest shape in the admin: a truncated cell
* whose text *is* a link. `span.block.truncate > a` reports the anchor's
* full 189px box against a 144px cell, so the anchor counted as swallowed,
* while on screen 161px of it is visible, ellipsised, and perfectly
* clickable. Three blocking findings on the projects board, all of them the
* repository link reading `null/Privacy-Period-Tr...`, none of them a fault.
*
* What the rule is really protecting against is a control the clip puts out
* of reach, so measure that: how much of it is left inside the box. Below
* the 24px WCAG floor (or its own width, for a control smaller than that)
* there is nothing to press and the ellipsis is not an affordance any more.
*/
const MIN_AIMABLE = 24;
const controlsIn = (el) => [
...(el.matches(INTERACTIVE) ? [el] : []),
...el.querySelectorAll(INTERACTIVE),
];
const swallowed = (control) => {
const rect = control.getBoundingClientRect();
const visible = Math.min(rect.right, box.rect.right) - Math.max(rect.left, box.rect.left);
return visible < Math.min(MIN_AIMABLE, rect.width);
};
const swallowsControls = leaves.some(({ el }) => controlsIn(el).some(swallowed));
// Clipping only costs something when something was in it. A decorative
// element parked outside its box is the technique, not a fault: the hover
// shimmer on the radar capture button is `absolute inset-0 -translate-x-full`
// and lives entirely to the left of the button until you hover it, which
// this reported as "148px past the left edge" at every width for twenty
// runs. An `aria-hidden` span with no text and no controls has nothing to
// lose.
if (!leaves.some(carries)) continue;
if (ellipsis.has(clipper) && !swallowsControls) continue;
push({
kind: "clipped",
severity: "blocking",
path: pathOf(clipper),
text: text(worst.el),
detail: {
side,
clipper: [Math.round(box.rect.left), Math.round(box.rect.right)],
child: [Math.round(worst.rect.left), Math.round(worst.rect.right)],
over: Math.round(escape(worst)),
overflowX: box.cs.overflowX,
childPath: pathOf(worst.el),
hiddenChildren: leaves.length,
},
says:
`${leaves.length} element(s) cut off by ${Math.round(escape(worst))}px past the ${side} edge ` +
`of an overflow-x:${box.cs.overflowX} box, with no scrollbar and no hint`,
});
}
// --- 3. The weakest signal, kept for completeness -------------------------
const doc = document.documentElement;
if (doc.scrollWidth > doc.clientWidth + EPS) {
push({
kind: "document_scrolls",
severity: "blocking",
path: "html",
text: "",
detail: { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth },
says: `the page itself scrolls sideways by ${doc.scrollWidth - doc.clientWidth}px`,
});
}
// --- 4. Touch targets ------------------------------------------------------
//
// 44px is the number both platform guidelines land on. Elements nested inside
// a larger tappable ancestor are skipped: the ancestor is the target.
const TAPPABLE = "a[href], button, summary, input, select, textarea, [role=button], [role=tab]";
for (const el of document.body.querySelectorAll(TAPPABLE)) {
const rec = info.get(el);
if (!rec) continue;
// A control inside a <label> is aimed at through the label, so the label is
// what gets measured. Skipping it outright, which this did first, means
// wrapping a 16px checkbox in a label silences the check without making the
// target any bigger, and the fix for the one real instance of that was
// exactly such a wrapper. A checker you can satisfy by adding an element is
// not a checker.
const label = el.closest("label");
const measured = label ? info.get(label) : null;
const rect = measured ? measured.rect : rec.rect;
if (label && !measured) continue;
const parent = el.parentElement && el.parentElement.closest(TAPPABLE);
if (parent) continue;
// Two rules, because a link and a button are not the same shape of target.
//
// A control must be at least **32px thick and 44px long**, not 44x44.
//
// 44x44 is WCAG 2.5.5, the AAA figure, and applying it to anything matching
// `button` produced ninety findings that all said the same thing: the admin
// renders lists whose rows are controls. A milestone row is 300px wide and
// 20px tall; taking it to 44 doubles the height of a twenty-item list, and
// that is a decision about how much the board shows per screen rather than
// a fix. 2.5.8 (AA) sets the floor at 24.
//
// So the rule is about the shape of a finger, not a square: you need
// thickness in whichever axis is scarce, and length in the other. An icon
// button is 44x44 and passes on both counts; a list row at 300x32 passes;
// a row at 300x20 fails on thickness; a 40x40 icon button fails on length,
// which is what caught `size="icon"` being `size-10`. Decided deliberately,
// and 32 rather than 24 so there is margin above the AA floor.
//
// A text link is judged on height and on its smaller dimension only. The
// first version demanded 44px in both axes and duly reported "FAQ", 27px
// wide, 44px tall, with 24px of gap either side, as a defect on nine
// routes. Padding a three-letter word out to 44px to satisfy a checker is
// the checker driving the design. WCAG 2.5.8 sets 24px as the floor and
// exempts inline links in text for exactly this reason; the axis that is
// actually scarce in a horizontal nav row is the vertical one.
//
// A link inside a sentence is exempt, and this is not a loophole: WCAG
// 2.5.8 says so in as many words. Its height is the line height of the
// prose around it; the only way to give it 44px is to break the paragraph.
// Nine of these were being reported on the FAQ and the legal pages.
if (el.tagName === "A") {
const parent = el.parentElement;
const around = parent ? parent.textContent.trim().length : 0;
if (around > (el.textContent || "").trim().length + 3) continue;
}
const isControl = el.matches("button, summary, input, select, textarea, [role=button]");
const thickness = Math.min(rect.width, rect.height);
const length = Math.max(rect.width, rect.height);
// Row-shaped: twice as wide as it is tall. A list row, not a thing you aim
// at. This is what separates "the milestone row" from "the icon button",
// and it has to be measured rather than guessed from the tag, because both
// are `<button>`.
// 1.5x, not 2x. A 60x32 link in a dashboard widget is a row by every
// sensible reading and missed a 2x test by four pixels. "FAQ" at 27x44 is
// still nowhere near it, which is the case this threshold exists to keep out.
const rowShaped = rect.width >= rect.height * 1.5;
let short;
let narrow;
if (isControl && rowShaped) {
// A control that is a row. Thickness is what a thumb needs; the length
// takes care of itself.
short = thickness < 32 - EPS;
narrow = length < 44 - EPS;
} else if (isControl) {
// A compact control: an icon button, a checkbox. You aim at a point, so
// it needs the full 44 in both axes. The 32px relaxation above is for
// rows and must not leak here: it would accept `size="icon"` back at
// 40x44, which is the exact defect this caught a few commits ago.
short = thickness < 44 - EPS;
narrow = length < 44 - EPS;
} else if (rowShaped) {
// A link that is a row obeys the row rule. The docs file list and the
// dashboard's widget links are links by tag and rows by shape.
short = rect.height < 32 - EPS;
narrow = false;
} else {
// A link that is a word in a nav. Judged on height, because the vertical
// axis is the scarce one in a horizontal row, and on 24px of thickness.
// demanding 32 here would report "FAQ" at 27px wide, which is the
// checker driving the design again.
short = rect.height < 44 - EPS;
narrow = thickness < 24 - EPS;
}
if (!short && !narrow) continue;
push({
kind: "touch_target",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { width: Math.round(rect.width), height: Math.round(rect.height) },
says:
`${Math.round(rect.width)}x${Math.round(rect.height)}px, ` +
(short
? `thinner than the ${isControl && !rowShaped ? 44 : rowShaped ? 32 : 44}px a touch target needs`
: `shorter than the ${isControl ? 44 : 24}px minimum`),
});
}
// --- 5. Text too small to read --------------------------------------------
for (const [el, { cs }] of info) {
const own = Array.from(el.childNodes).some(
(n) => n.nodeType === 3 && n.textContent.trim().length > 3,
);
if (!own) continue;
const size = parseFloat(cs.fontSize);
if (size >= 12 - 0.01) continue;
// Visually hidden. `sr-only` clips text to a 1px box so a screen reader
// still reads it and nobody sees it, so its font-size is not a legibility
// question, and 28 of the 87 findings here were the Ripley quote's
// `sr-only` companion saying the same thing on every admin screen.
const box = info.get(el).rect;
if (box.width <= 1 || box.height <= 1) continue;
// Small uppercase tracked text is a label, not prose.
//
// This codebase writes badges, eyebrows and machine values as 10-11px mono
// uppercase with positive letter-spacing: a deliberate typographic
// register, used in over two hundred places. Reporting every one of them at
// "high" produces a list nobody will ever work through, which is how a
// report stops being read. What actually harms a reader is small *prose*,
// so that is what this reports. There is no WCAG minimum font size to
// appeal to here; this is a judgement, and it is written down so the next
// person can disagree with it on purpose.
const tracked = parseFloat(cs.letterSpacing) > 0;
if (cs.textTransform === "uppercase" && tracked) continue;
push({
kind: "tiny_text",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { fontSize: cs.fontSize },
says: `${cs.fontSize} text`,
});
}
// --- 6. Media wider than the box holding it -------------------------------
for (const el of document.body.querySelectorAll("img, video, iframe, canvas, svg")) {
const rec = info.get(el);
const parent = el.parentElement && info.get(el.parentElement);
if (!rec || !parent) continue;
if (rec.rect.width <= parent.rect.width + EPS) continue;
push({
kind: "media_overflow",
severity: "high",
path: pathOf(el),
text: el.getAttribute("alt") || el.getAttribute("src") || "",
detail: {
mediaWidth: Math.round(rec.rect.width),
containerWidth: Math.round(parent.rect.width),
},
says: `${Math.round(rec.rect.width - parent.rect.width)}px wider than its container`,
});
}
// --- 7. Two sticky boxes fighting over the same edge ----------------------
//
// The project header is `sticky top-0 z-20` under a shell bar that is also
// stuck at 0 with an opaque background and z-40. Nothing errors; the header
// just slides underneath and is never seen again.
const sticky = [];
for (const [el, { cs, rect }] of info) {
if (cs.position !== "sticky" && cs.position !== "fixed") continue;
if (cs.top === "auto") continue;
// A decoration cannot occlude anything: it does not take clicks and it is
// not what the reader is looking for. The navigation progress bar is
// `pointer-events-none fixed top-0 z-[100] h-[3px]`, and without this it
// reported the entire admin header as hidden behind it on all 25 screens.
if (cs.pointerEvents === "none") continue;
sticky.push({ el, top: parseFloat(cs.top) || 0, z: parseInt(cs.zIndex, 10) || 0, rect, cs });
}
for (const a of sticky) {
for (const b of sticky) {
if (a === b || a.el.contains(b.el) || b.el.contains(a.el)) continue;
if (Math.abs(a.top - b.top) > EPS) continue;
if (a.z >= b.z) continue;
// Same offset and behind only matters if the thing in front actually
// covers it, in both axes.
//
// Vertically, a quarter is enough: what a stuck header loses first is its
// top, which is where its title is. A 50% threshold, which this used
// first, let the real case through, because the project header is
// 294px tall and the bar over it is 132px, so it was "only" 45% hidden.
//
// Horizontally is not optional. The admin sidebar is `fixed inset-y-0
// z-40` and overlaps every sticky header on the page vertically while
// sitting entirely to their left, which without this reads as the whole
// admin being permanently occluded by its own navigation.
const vertical =
Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
const horizontal =
Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
if (vertical < Math.max(24, a.rect.height * 0.25)) continue;
if (horizontal < a.rect.width * 0.5) continue;
// a is behind b at the same offset: a is the one that disappears.
push({
kind: "sticky_occluded",
severity: "high",
path: pathOf(a.el),
text: text(a.el),
detail: { top: a.top, zIndex: a.z, coveredBy: pathOf(b.el), coveredByZ: b.z },
says: `sticky at top:${a.top}px with z-index ${a.z}, behind another stuck at the same offset with z-index ${b.z}`,
});
break;
}
}
// --- 8. The selected tab is scrolled out of sight -------------------------
for (const el of document.body.querySelectorAll('[aria-selected="true"], [data-state="active"]')) {
const rec = info.get(el);
if (!rec) continue;
const scroller = scrollerOf(el);
if (!scroller) continue;
const box = info.get(scroller);
if (!box) continue;
if (rec.rect.left >= box.rect.left - EPS && rec.rect.right <= box.rect.right + EPS) continue;
push({
kind: "active_tab_offscreen",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { tabLeft: Math.round(rec.rect.left), visibleFrom: Math.round(box.rect.left), visibleTo: Math.round(box.rect.right) },
says: "the selected tab is outside the visible part of its scroller, so nothing on screen looks selected",
});
}
// There is no check here for `100vh`.
//
// There was, and it could never fire: `getComputedStyle` resolves `100vh` to
// a pixel value, so nothing in the browser can tell it apart from a height
// that was written in pixels. A check that cannot fail is worse than no
// check, because it reads as coverage. The rule it was reaching for ("use dvh,
// because 100vh is the viewport with the mobile URL bar hidden") is a property
// of the source, so it lives in `tests/responsive-guards.test.ts` where a
// source grep is the honest instrument.
return findings;
};

162
scripts/lib/html-audit.js Normal file
View File

@ -0,0 +1,162 @@
// What must be true of a page after it is built, and again after it is served.
//
// ## Why this exists
//
// Everything else in this repository checks an input: the content check reads
// the data, the secret scan reads the diff, the build reads the source. Nothing
// read the OUTPUT, and the output 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 description that read as one run-on
// sentence. Both are obvious in the built HTML and invisible in the source.
//
// It also guards a specific hazard. react-helmet-async on React 19 does not
// merge: a second <SEO> anywhere on a page silently emits a second title and a
// second canonical, and search engines pick whichever they like.
//
// ## Two modes, one set of rules
//
// Build mode reads dist/ and is a gate. URL mode fetches a live origin once per
// crawler user agent and is a check to run after a deploy, not a gate, because a
// check that runs after publication cannot stop it.
import { SITE_URL } from '../../src/lib/seo.js'
import { ROUTES } from './routes.js'
// React writes its own text separators as comments, and the index.html template
// carries a commented-out preload. Anything counting tags must strip comments
// first or it will count that one.
export const stripComments = (html) => html.replace(/<!--[\s\S]*?-->/g, '')
export const parseSitemap = (xml) =>
[...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((entry) => {
const loc = entry[1].match(/<loc>([^<]+)<\/loc>/)?.[1] ?? ''
const lastmod = entry[1].match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? null
let path = '/'
try {
path = new URL(loc).pathname
} catch {
path = loc
}
return { loc, path, lastmod }
})
const all = (html, pattern) => [...html.matchAll(pattern)]
/**
* Every rule that applies to one page.
* @returns {string[]} findings, each a sentence naming what is wrong
*/
export const auditPage = (rawHtml, { path, notFound = false, shortDesc = null, approvedDescription = false }) => {
const html = stripComments(rawHtml)
const [head = '', body = ''] = html.split('</head>')
const findings = []
const say = (detail) => findings.push(`${notFound ? '404.html' : path}: ${detail}`)
const titles = all(head, /<title[^>]*>([\s\S]*?)<\/title>/g)
if (titles.length !== 1) say(`${titles.length} <title> tags in <head>, expected exactly 1`)
else if (!titles[0][1].trim()) say('an empty <title>')
const descriptions = all(head, /<meta name="description" content="([^"]*)"/g)
if (descriptions.length !== 1) say(`${descriptions.length} meta descriptions, expected exactly 1`)
else if (!descriptions[0][1].trim()) say('an empty meta description')
const canonicals = all(head, /<link rel="canonical" href="([^"]+)"/g)
if (notFound) {
if (canonicals.length) say('a canonical, which a 404 must not claim')
if (!/name="robots" content="[^"]*noindex/.test(head)) say('no noindex, so the 404 page invites indexing')
} else if (canonicals.length !== 1) {
say(`${canonicals.length} canonicals, expected exactly 1`)
} else {
const expected = `${SITE_URL}${path === '/' ? '' : path}`
if (canonicals[0][1] !== expected) say(`canonical is ${canonicals[0][1]}, expected ${expected}`)
}
const h1s = all(body, /<h1[\s>]/g)
if (h1s.length !== 1) say(`${h1s.length} <h1> tags, expected exactly 1`)
for (const block of all(html, /<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/g)) {
try {
const parsed = JSON.parse(block[1])
const types = JSON.stringify(parsed).match(/"@type":"([A-Za-z]+)"/g) || []
// The approved sheets are explicit: the FAQ is visible page copy and gets
// no FAQPage markup.
if (types.some((type) => type.includes('FAQPage'))) say('FAQPage structured data, which the owner ruled out')
} catch (error) {
say(`structured data that does not parse: ${error.message}`)
}
}
if (/—/.test(html)) say('an em dash, which Null asked for nowhere a visitor or crawler reads')
if (/<2F>/.test(html)) say('a U+FFFD replacement character, so something was decoded with the wrong encoding')
// The preload must name the image the page actually paints first.
const hero = body.match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0]
const heroSrc = hero?.match(/\bsrc="([^"]+)"/i)?.[1]
const preloads = all(head, /<link rel="preload"[^>]*as="image"[^>]*href="([^"]+)"/g)
if (heroSrc) {
if (preloads.length !== 1) say(`${preloads.length} image preloads, expected exactly 1 for ${heroSrc}`)
else if (preloads[0][1] !== heroSrc) say(`preloads ${preloads[0][1]} while the hero image is ${heroSrc}`)
} else if (preloads.length) {
say(`preloads ${preloads[0][1]} while the page paints no high-priority image`)
}
// The run-on this project shipped for months: a short description followed
// straight by the next sentence with no full stop between them.
if (shortDesc && !approvedDescription && descriptions.length === 1) {
const stripped = shortDesc.replace(/\s+/g, ' ').trim()
const description = descriptions[0][1].replace(/\s+/g, ' ')
if (description.includes(`${stripped} `) && !description.includes(`${stripped}. `)) {
say('the description runs its short description into the next sentence with no full stop')
}
}
return findings
}
/** ids and internal links, across every page at once. */
export const auditLinks = (pages, { fileExists = () => true } = {}) => {
const findings = []
const idsByPath = new Map()
for (const [path, html] of pages) {
idsByPath.set(path, new Set([...stripComments(html).matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])))
}
for (const [path, html] of pages) {
const body = stripComments(html).split('</head>')[1] ?? ''
const seen = new Set()
for (const match of all(body, /href="(\/[^"]*)"/g)) {
const href = match[1]
if (seen.has(href)) continue
seen.add(href)
const [route, fragment] = href.split('#')
const target = route === '' ? path : route
if (route && !ROUTES.includes(route)) {
// Not a route, so it must be a file the build actually emits.
if (!fileExists(route)) findings.push(`${path}: links to ${route}, which is neither a route nor a file in the build`)
continue
}
if (fragment) {
const ids = idsByPath.get(target)
if (ids && !ids.has(fragment)) findings.push(`${path}: links to ${href}, and #${fragment} is not on that page`)
}
}
}
return findings
}
/** The sitemap must list exactly the routes this site serves. */
export const auditSitemap = (entries, { requireLastmod = false } = {}) => {
const findings = []
const listed = new Set(entries.map((entry) => entry.path))
for (const route of ROUTES) if (!listed.has(route)) findings.push(`sitemap.xml: does not list ${route}`)
for (const path of listed) if (!ROUTES.includes(path)) findings.push(`sitemap.xml: lists ${path}, which is not a route`)
if (requireLastmod) {
const undated = entries.filter((entry) => !entry.lastmod).length
if (undated) {
findings.push(
`sitemap.xml: ${undated} of ${entries.length} URLs carry no lastmod, so search engines cannot tell what changed`,
)
}
}
return findings
}

132
scripts/lib/routes.js Normal file
View File

@ -0,0 +1,132 @@
// Every route this site serves, in one place.
//
// `scripts/prerender.js` used to build its own list while `src/routes.jsx` built
// the router's, and nothing compared them. A route added to one and not the
// other is not a small bug: the page is never prerendered, so `server/index.js`
// answers a direct request for it with `dist/404.html`, and every visitor
// following a link and every crawler reading the sitemap gets a 404 on a page
// the site's own navigation points at.
//
// Plain JavaScript with no side effects, because prerender imports it in Node
// before Vite exists, and so does the validator runner.
import { execFileSync } from 'child_process'
import path from 'path'
import { fileURLToPath } from 'url'
import { services } from '../../src/data/services.js'
import { industries } from '../../src/data/industries.js'
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..')
export const STATIC_ROUTES = [
'/',
'/about',
'/services',
'/industries',
'/contact',
'/support',
'/privacy-policy',
]
export const ROUTES = [
...STATIC_ROUTES,
...services.map((service) => `/services/${service.id}`),
...industries.map((industry) => `/industries/${industry.id}`),
]
const join = (base, path) => `${base}/${path}`.replace(/\/{2,}/g, '/')
/** Flattens the router's nested table into the paths it declares. */
export const routerPaths = (table, base = '') =>
table.flatMap((route) => {
const self = route.index ? base || '/' : route.path === '/' ? '/' : join(base, route.path ?? '')
const children = route.children ? routerPaths(route.children, self === '/' ? '' : self) : []
return [self, ...children]
})
/**
* Routes the router declares that nothing prerenders. A path with a parameter
* (`/services/:slug`) or the catch-all is covered by the data lists above
* rather than by a literal, so neither counts as drift.
*/
export const routeDrift = (table) => [
...new Set(
routerPaths(table)
.filter((route) => !route.includes(':') && !route.includes('*'))
.filter((route) => !ROUTES.includes(route)),
),
]
// --- sitemap dates -----------------------------------------------------------
//
// The files that produce each page. A page's `lastmod` is the newest commit
// date among them, never the build timestamp: a sitemap that marks every page
// as changed on every deploy is one search engines learn to ignore.
const ROUTE_SOURCES = {
'/': ['src/pages/Home.jsx'],
'/about': ['src/pages/About.jsx'],
'/services': ['src/pages/Services.jsx', 'src/data/services.js'],
'/industries': ['src/pages/Industries.jsx', 'src/data/industries.js'],
'/contact': ['src/pages/Contact.jsx'],
'/support': ['src/pages/Support.jsx'],
'/privacy-policy': ['src/pages/PrivacyPolicy.jsx', 'src/data/privacyPolicy.js'],
}
export const sourcesFor = (url) => {
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
if (url.startsWith('/services/')) {
// A page with owner-approved copy has its own content file, so editing that
// copy moves that page's date and no other.
const slug = url.slice('/services/'.length)
return ['src/pages/ServiceDetail.jsx', 'src/data/services.js', `src/data/serviceContent/${slug}.js`]
}
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
}
const gitLastModified = (files) => {
let newest = null
for (const file of files) {
try {
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (iso && (!newest || iso > newest)) newest = iso
} catch {
// git is unavailable, or the file is untracked. Either way, no date from
// this file. Whether that leaves the ROUTE undated is the caller's
// problem to report, and it must not pass silently: the production
// sitemap carried no dates at all for months because this catch was the
// end of the story.
}
}
return newest ? newest.slice(0, 10) : null
}
/**
* Route to YYYY-MM-DD, for the sitemap.
*
* The image build has no git: `.dockerignore` excludes `.git` and node:alpine
* ships no git binary. So a map computed where git DOES exist can be injected
* through SITEMAP_LASTMOD, which is what scripts/release.sh does.
*/
export const lastModByRoute = () => {
const injected = process.env.SITEMAP_LASTMOD
if (injected) {
try {
const parsed = JSON.parse(injected)
if (parsed && typeof parsed === 'object') return parsed
console.warn('routes: SITEMAP_LASTMOD is not an object, so falling back to git.')
} catch (error) {
console.warn(`routes: SITEMAP_LASTMOD is not valid JSON (${error.message}), so falling back to git.`)
}
}
const dates = {}
for (const route of ROUTES) {
const date = gitLastModified(sourcesFor(route))
if (date) dates[route] = date
}
return dates
}

View File

@ -13,33 +13,21 @@
// //
// Run automatically as part of `npm run build`. // Run automatically as part of `npm run build`.
import { execFileSync } from 'child_process'
import { mkdirSync, readFileSync, writeFileSync } from 'fs' import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { render } from '../dist-ssr/entry-server.js' import { render, routes as routerTable } from '../dist-ssr/entry-server.js'
import { services } from '../src/data/services.js' import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js' import { industries } from '../src/data/industries.js'
import { ROUTES as routes, lastModByRoute, routeDrift } from './lib/routes.js'
import { validateContent } from './lib/content.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '../dist') const distDir = path.join(__dirname, '../dist')
const STATIC_ROUTES = [ // The route list and the router's own table both come from elsewhere now, so
'/', // this file cannot disagree with either. See scripts/lib/routes.js.
'/about',
'/services',
'/industries',
'/contact',
'/support',
'/privacy-policy',
]
const routes = [
...STATIC_ROUTES,
...services.map((s) => `/services/${s.id}`),
...industries.map((i) => `/industries/${i.id}`),
]
// Tags the SEO component owns per-route. They are stripped from the template so // Tags the SEO component owns per-route. They are stripped from the template so
// Helmet's values replace them instead of duplicating them. // Helmet's values replace them instead of duplicating them.
@ -56,27 +44,77 @@ const TEMPLATE_TAGS_TO_STRIP = [
// <head> in the browser and in its streaming renderer, but renderToString leaves // <head> in the browser and in its streaming renderer, but renderToString leaves
// them inline, so the prerenderer performs the same hoist. Leaving them in <body> // them inline, so the prerenderer performs the same hoist. Leaving them in <body>
// would put every title, canonical, and og: tag somewhere crawlers ignore. // would put every title, canonical, and og: tag somewhere crawlers ignore.
const HOISTABLE_TAGS = //
/<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>|<script[^>]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/g // JSON-LD is deliberately NOT in this list. React hoists only async scripts with
// a src, so on the client the ld+json script stays where its component renders
// it, in the body. Moving it to <head> here made the prerendered DOM disagree
// with the client's first render, and React threw out the whole prerendered page
// and re-rendered it (error #418, on every page). Structured data is valid
// anywhere in the document, so the honest fix is to leave it alone.
const HOISTABLE_TAGS = /<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>/g
// An inline <svg> may carry its own <title>, and microdata rides in
// <meta itemprop> tags. Neither belongs in <head>: hoisting an SVG title gives
// the page two titles, which is the exact shape of defect this hoist exists to
// prevent. So SVG blocks are parked before the hoist and put back after.
const SVG_BLOCK = /<svg\b[\s\S]*?<\/svg>/gi
const SVG_TOKEN = 'svg'
// renderToString does not wait for Suspense: it emits the fallback and marks it.
// A page carrying one of these lost its content silently, and crawlers would
// receive the fallback as the page.
const SUSPENSE_MARKERS = ['<!--$!-->', '<!--$?-->']
// Substitute a marker that must appear exactly once, without regex replacement
// semantics. String.replace interprets `$&`, `$'` and `$$` inside the
// REPLACEMENT, and the replacement here is page copy, which is not ours to
// trust: one `$` before an escaped entity would inject markup into the page.
const replaceOnce = (text, marker, replacement, url) => {
const parts = text.split(marker)
if (parts.length !== 2) {
throw new Error(
`prerender: ${url}: expected exactly one ${marker} in the template, found ${parts.length - 1}.`,
)
}
return `${parts[0]}${replacement}${parts[1]}`
}
const buildPage = (template, url) => { const buildPage = (template, url) => {
const { html } = render(url) let html
try {
;({ html } = render(url))
} catch (error) {
// Without the route, the build fails with a stack trace and no clue which
// of the nineteen pages produced it.
throw new Error(`prerender: ${url} could not be rendered: ${error.message}`, { cause: error })
}
const hoisted = html.match(HOISTABLE_TAGS) || [] const svgs = []
const body = html.replace(HOISTABLE_TAGS, '') const parked = html.replace(SVG_BLOCK, (svg) => `${SVG_TOKEN}${svgs.push(svg) - 1}${SVG_TOKEN}`)
// Preload this route's own LCP image. The hero is the one marked eager during const hoisted = []
// render, so the hint always matches what the page actually paints first. const body = parked
.replace(HOISTABLE_TAGS, (tag) => {
if (/\bitemprop=/i.test(tag)) return tag
hoisted.push(tag)
return ''
})
.replace(new RegExp(`${SVG_TOKEN}(\\d+)${SVG_TOKEN}`, 'g'), (_, index) => svgs[Number(index)])
// Preload this route's own LCP image: the one React marked with a high fetch
// priority. Keying on `loading="eager"` matched the header logo, which is on
// every page, so every page preloaded the logo and no page preloaded its own
// hero. React writes the attribute camelCase in HTML, hence the /i.
const heroSrc = html const heroSrc = html
.match(/<img[^>]*loading="eager"[^>]*>/)?.[0] .match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0]
.match(/src="([^"]+)"/)?.[1] ?.match(/\bsrc="([^"]+)"/i)?.[1]
const head = [] const head = []
if (heroSrc) { if (heroSrc) {
head.push(`<link rel="preload" as="image" href="${heroSrc}" fetchpriority="high" />`) head.push(`<link rel="preload" as="image" href="${heroSrc}" fetchpriority="high" />`)
} }
// React emits its own image preload for the eager hero; drop it so the hint // React emits its own preload for that hero; drop it so the hint above is not
// above isn't duplicated. // duplicated.
for (const tag of hoisted) { for (const tag of hoisted) {
if (/rel="preload"[^>]*as="image"/.test(tag)) continue if (/rel="preload"[^>]*as="image"/.test(tag)) continue
head.push(tag) head.push(tag)
@ -87,8 +125,17 @@ const buildPage = (template, url) => {
page = page.replace(pattern, '') page = page.replace(pattern, '')
} }
page = page.replace('</head>', ` ${head.join('\n ')}\n </head>`) page = replaceOnce(page, '</head>', ` ${head.join('\n ')}\n </head>`, url)
page = page.replace('<div id="root"></div>', `<div id="root">${body}</div>`) page = replaceOnce(page, '<div id="root"></div>', `<div id="root">${body}</div>`, url)
const marker = SUSPENSE_MARKERS.find((m) => page.includes(m))
if (marker) {
throw new Error(
`prerender: ${url} shipped a Suspense fallback (${marker}) instead of its content. ` +
'renderToString does not wait, so a lazy import or a Suspense boundary above this route ' +
'silently empties the page for every crawler.',
)
}
return page return page
} }
@ -100,6 +147,42 @@ const outputPathFor = (url) =>
const template = readFileSync(path.join(distDir, 'index.html'), 'utf8') const template = readFileSync(path.join(distDir, 'index.html'), 'utf8')
// dist/index.html is both the template and the output for `/`, so running this
// script twice without a rebuild would treat a finished page as the template and
// give every page two canonicals and two of every head tag.
if (/rel="canonical"/.test(template)) {
throw new Error(
'prerender: dist/index.html already carries a canonical, so it is a rendered page rather than the ' +
'template. Run `vite build` before prerendering.',
)
}
// Content before pages. A page built from broken data is worse than no build:
// it looks finished. This is the check that keeps a website-manager direction
// out of the copy, and it runs on every build, including the image build.
const content = validateContent({ services, industries })
for (const warning of content.warnings) console.warn(`prerender: note: ${warning}`)
if (content.checked === 0) {
throw new Error('prerender: the content check examined nothing, which is not a pass. Did src/data fail to import?')
}
if (content.errors.length) {
console.error(`\nprerender: ${content.errors.length} content problem(s):`)
for (const error of content.errors) console.error(` ${error}`)
throw new Error('prerender: refusing to build pages from content that does not hold together.')
}
// The router and this script must agree on which pages exist. A route declared
// in src/routes.jsx and missing here is never written to dist/, and the server
// serves 404.html for it.
const drift = routeDrift(routerTable)
if (drift.length) {
throw new Error(
`prerender: ${drift.join(', ')} ${drift.length === 1 ? 'is a route' : 'are routes'} the router declares and this ` +
`build does not produce, so the server would answer ${drift.length === 1 ? 'it' : 'them'} with 404.html. ` +
'Add to STATIC_ROUTES in scripts/lib/routes.js.',
)
}
const written = [] const written = []
for (const url of routes) { for (const url of routes) {
const page = buildPage(template, url) const page = buildPage(template, url)
@ -126,36 +209,6 @@ for (const [url, size] of written) {
const SITE_URL = 'https://queuenorth.com' const SITE_URL = 'https://queuenorth.com'
// lastmod comes from git history for the files that produce each route. A build
// timestamp would mark every page as changed on every deploy, which search engines
// learn to distrust.
const lastModifiedFor = (files) => {
let newest = null
for (const file of files) {
try {
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
cwd: path.join(__dirname, '..'),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (iso && (!newest || iso > newest)) newest = iso
} catch {
// git unavailable or file untracked — fall through
}
}
return newest ? newest.slice(0, 10) : null
}
const ROUTE_SOURCES = {
'/': ['src/pages/Home.jsx'],
'/about': ['src/pages/About.jsx'],
'/services': ['src/pages/Services.jsx', 'src/data/services.js'],
'/industries': ['src/pages/Industries.jsx', 'src/data/industries.js'],
'/contact': ['src/pages/Contact.jsx'],
'/support': ['src/pages/Support.jsx'],
'/privacy-policy': ['src/pages/PrivacyPolicy.jsx', 'src/data/privacyPolicy.js'],
}
const PRIORITY = { const PRIORITY = {
'/': '1.0', '/': '1.0',
'/services': '0.9', '/services': '0.9',
@ -168,15 +221,13 @@ const PRIORITY = {
const CHANGEFREQ = { '/': 'weekly', '/privacy-policy': 'yearly' } const CHANGEFREQ = { '/': 'weekly', '/privacy-policy': 'yearly' }
const sourcesFor = (url) => { // Dates come from git where git exists, and from the map release.sh injects
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url] // where it does not. See scripts/lib/routes.js.
if (url.startsWith('/services/')) return ['src/pages/ServiceDetail.jsx', 'src/data/services.js'] const lastmodByRoute = lastModByRoute()
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
}
const entries = routes.map((url) => { const entries = routes.map((url) => {
const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}` const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}`
const lastmod = lastModifiedFor(sourcesFor(url)) const lastmod = lastmodByRoute[url] ?? null
return [ return [
' <url>', ' <url>',
` <loc>${loc}</loc>`, ` <loc>${loc}</loc>`,
@ -198,4 +249,15 @@ const sitemap = [
].join('\n') ].join('\n')
writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap) writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap)
console.log(`\nGenerated sitemap.xml with ${routes.length} URLs`)
// Say how many pages carry a date, every time. The production sitemap carried
// none at all for months: the image build has no git, and the failure to read
// it was swallowed. Silence is what let that run.
const dated = routes.filter((url) => lastmodByRoute[url]).length
console.log(`\nGenerated sitemap.xml with ${routes.length} URLs, ${dated} carrying a lastmod`)
if (dated < routes.length) {
console.warn(
`prerender: ${routes.length - dated} URL(s) have no lastmod. git history is not readable here, which is ` +
'normal inside the image build. Pass SITEMAP_LASTMOD, as scripts/release.sh does.',
)
}

232
scripts/qa-browser.mjs Executable file
View File

@ -0,0 +1,232 @@
#!/usr/bin/env node
//
// Render the site in a real browser at real viewports and MEASURE it.
//
// node scripts/qa-browser.mjs # production, default widths
// node scripts/qa-browser.mjs --url http://localhost:3001
// node scripts/qa-browser.mjs --viewports 320,768
// node scripts/qa-browser.mjs --paths / /about /contact
// node scripts/qa-browser.mjs --shots /tmp/qa # also write screenshots
//
// Exit codes: 0 nothing found. 1 findings, each named. 2 NOTHING WAS CHECKED —
// playwright missing or the site unreachable. Two is not a pass.
//
// ## Which incident motivated it
//
// Batches 10 and 11 were twenty-ish UI and accessibility defects filed from
// reading markup. **Seven of ten misstated their own evidence.** Two contrast
// figures were simply wrong when computed; one proposed a colour measuring
// 1.96:1 against the background it would sit on, which would have made the
// footer materially worse; one asked for aria-labels whose existing versions
// were already WCAG 2.5.3 Level A failures, so doing what it said would have
// spread the defect; one described a clipping ancestor that does not exist;
// one described an overlap that is a 12px gap; one described a readOnly
// attribute that is not in the file.
//
// Every one of those took minutes to disprove with a browser and would have
// taken hours to "fix". The cost of not having this script was not the fixes —
// it was very nearly making the site worse, twice, on the strength of a
// confident sentence.
//
// So: numbers, from the thing itself. Contrast is arithmetic, overlap is two
// rectangles, and "does it clip" is a computed style you can read.
//
// ## What it measures
//
// horizontal scroll scrollWidth > innerWidth, and which element causes it
// broken images after scrolling the whole page, so lazy ones are fair
// CLS cumulative layout shift, good < 0.1
// LCP largest contentful paint, good < 2500ms
// overflowing nodes any element whose right edge is past the viewport
//
// ## Why it is not wired into verify.sh
//
// playwright is not a dependency of this project — it is installed globally on
// this machine. Adding ~300MB of browser to every clone to run a check nobody
// runs on every commit is the wrong trade. This is a tool you reach for when a
// UI defect is filed, and `docs/qa/ClaudeQAPlan.md` says to reach for it.
//
// If it is ever made a devDependency, wire it in as scripts/verify.d/40-browser.
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
import path from 'node:path'
import { parseSitemap } from './lib/html-audit.js'
const args = process.argv.slice(2)
const opt = (name, dflt) => {
const i = args.indexOf(`--${name}`)
return i === -1 ? dflt : args[i + 1]
}
const URL_BASE = (opt('url', 'https://queuenorth.com')).replace(/\/$/, '')
const VIEWPORTS = (opt('viewports', '320,390,768,1024')).split(',').map(Number)
// Stop at the next flag rather than filtering non-flags out of the whole tail:
// `--paths / --viewports 320` otherwise swallowed "320" as a path and then
// tried to navigate to it. Caught by the tool reporting four findings that were
// its own argument handling.
const collect = (name, dflt) => {
const i = args.indexOf(`--${name}`)
if (i === -1) return dflt
const out = []
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j])
return out.length ? out : dflt
}
const SHOTS = opt('shots', null)
// The default path list used to be five hand-typed pages: the home page, about,
// services, contact and support. That is 5 of the 18 this site serves, and it
// included none of the service or industry pages, so a defect on any of them was
// invisible to the tool that exists to find defects. The default is now whatever
// the target itself says it serves.
//
// Failing to read the sitemap exits 2. "I checked nothing" must never look like
// "I found nothing".
let PATHS = collect('paths', null)
if (!PATHS) {
try {
const response = await fetch(`${URL_BASE}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
PATHS = parseSitemap(await response.text()).map((entry) => entry.path)
if (!PATHS.length) throw new Error('it lists no pages')
} catch (e) {
console.error(`qa-browser: could not read ${URL_BASE}/sitemap.xml (${e.message}), so NOTHING was checked.`)
console.error(' Pass --paths to check specific pages instead.')
process.exit(2)
}
}
// playwright is global here. Resolve it explicitly rather than failing with a
// bare MODULE_NOT_FOUND, which reads as "the script is broken" rather than
// "install this".
let chromium
try {
const require = createRequire(import.meta.url)
let root
try { root = require.resolve('playwright') } catch {
const g = execSync('npm root -g', { encoding: 'utf8' }).trim()
root = path.join(g, 'playwright', 'index.js')
}
;({ chromium } = require(root))
} catch (e) {
console.error('qa-browser: playwright is not available, so NOTHING was checked.')
console.error(' npm i -g playwright && npx playwright install chromium')
process.exit(2)
}
const findings = []
const say = (...a) => console.log(...a)
const browser = await chromium.launch().catch(e => {
console.error('qa-browser: could not launch chromium, so nothing was checked:', e.message)
process.exit(2)
})
for (const p of PATHS) {
for (const width of VIEWPORTS) {
const page = await browser.newPage({ viewport: { width, height: 900 } })
await page.addInitScript(() => {
window.__cls = 0; window.__lcp = 0
new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value })
.observe({ type: 'layout-shift', buffered: true })
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lcp = e.startTime })
.observe({ type: 'largest-contentful-paint', buffered: true })
})
const httpFailed = []
page.on('response', r => {
if (r.request().resourceType() === 'image' && r.status() >= 400) httpFailed.push(`${r.status()} ${r.url()}`)
})
// A page that throws still paints, so every measurement below can look
// healthy while the page is broken. React reported a hydration mismatch on
// every page of this site for months and nothing here noticed, because
// nothing here was listening.
const consoleErrors = []
page.on('pageerror', e => consoleErrors.push(String(e).split('\n')[0]))
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text().split('\n')[0]) })
let resp
try {
resp = await page.goto(URL_BASE + p, { waitUntil: 'networkidle', timeout: 45000 })
} catch (e) {
findings.push(`${p} @${width}: could not load — ${e.message.split('\n')[0]}`)
await page.close(); continue
}
if (!resp || resp.status() >= 400) {
findings.push(`${p} @${width}: HTTP ${resp ? resp.status() : '?'}`)
await page.close(); continue
}
// Scroll the whole page so lazy images are actually requested, then chase
// any that still have not loaded by scrolling to each one directly.
//
// Both passes are needed and the second is the one that matters. A coarse
// scroll can outrun the intersection observer, and the first version of
// this reported a perfectly healthy lazy badge as broken at three
// viewports — a false positive in the tool, on the very run that was
// meant to demonstrate the tool. Anything that survives BOTH passes plus
// an observed HTTP failure is worth reporting; anything less is noise, and
// noise is how a checker gets ignored.
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 300) {
window.scrollTo(0, y); await new Promise(r => setTimeout(r, 80))
}
window.scrollTo(0, 0)
})
await page.waitForTimeout(800)
const stragglers = await page.$$('img')
for (const h of stragglers) {
const ok = await h.evaluate(i => i.complete && i.naturalWidth > 0)
if (ok) continue
await h.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {})
await page.waitForTimeout(400)
}
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(600)
const m = await page.evaluate(() => {
const vw = window.innerWidth
const over = [...document.querySelectorAll('body *')]
.filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > vw + 1 })
.slice(0, 5)
.map(e => `${e.tagName.toLowerCase()}${e.className ? '.' + String(e.className).split(' ')[0] : ''} (right ${Math.round(e.getBoundingClientRect().right)})`)
return {
vw,
scrollWidth: document.documentElement.scrollWidth,
broken: [...document.images].filter(i => !(i.complete && i.naturalWidth > 0)).map(i => i.getAttribute('src')),
images: document.images.length,
cls: +(window.__cls || 0).toFixed(4),
lcp: Math.round(window.__lcp || 0),
over,
}
})
const bits = []
if (m.scrollWidth > m.vw) { bits.push(`H-SCROLL ${m.scrollWidth}>${m.vw}`); findings.push(`${p} @${width}: horizontal scroll ${m.scrollWidth} > ${m.vw}`) }
if (httpFailed.length) { bits.push(`HTTP-FAIL ${httpFailed.length}`); findings.push(`${p} @${width}: image request failed — ${httpFailed.join(', ')}`) }
if (m.broken.length) { bits.push(`BROKEN ${m.broken.length}`); findings.push(`${p} @${width}: image never rendered after being scrolled to — ${m.broken.join(', ')}`) }
if (m.cls > 0.1) { bits.push(`CLS ${m.cls}`); findings.push(`${p} @${width}: CLS ${m.cls} (>0.1)`) }
if (m.lcp > 2500) { bits.push(`LCP ${m.lcp}ms`); findings.push(`${p} @${width}: LCP ${m.lcp}ms (>2500)`) }
if (m.over.length) { bits.push(`OVERFLOW ${m.over.length}`); findings.push(`${p} @${width}: past the right edge — ${m.over.join('; ')}`) }
if (consoleErrors.length) { bits.push(`JS-ERROR ${consoleErrors.length}`); findings.push(`${p} @${width}: the page logged an error — ${[...new Set(consoleErrors)].slice(0, 2).join(' | ')}`) }
say(` ${p.padEnd(12)} @${String(width).padEnd(5)} imgs=${String(m.images).padEnd(3)} cls=${String(m.cls).padEnd(7)} lcp=${String(m.lcp + 'ms').padEnd(8)} ${bits.length ? '‼ ' + bits.join(' ') : 'ok'}`)
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${p.replace(/\//g, '_') || 'root'}-${width}.png`, fullPage: false })
await page.close()
}
}
await browser.close()
say('')
if (findings.length) {
say(`qa-browser: ${findings.length} finding(s):`)
for (const f of findings) say(` - ${f}`)
process.exit(1)
}
say(`qa-browser: nothing found across ${PATHS.length} path(s) x ${VIEWPORTS.length} viewport(s).`)
say(' That is not "the UI is correct" — it is these six measurements,')
say(' on these pages, at these widths. Nothing here opens a menu or')
say(' uses a keyboard.')

View File

@ -215,6 +215,18 @@ if ! git diff --quiet || ! git diff --cached --quiet; then
cannot tell your work in progress from a release." cannot tell your work in progress from a release."
fi fi
# Untracked files are checked separately, and they matter more than they look.
# `docker build .` packs the WORKING TREE, not the commit: an untracked module
# that the code imports produces an image that works and a tagged commit that
# cannot be rebuilt. The tag is supposed to be the record of what shipped.
untracked=$(git ls-files --others --exclude-standard)
if [ -n "$untracked" ]; then
say "the working tree has untracked files:"
printf '%s\n' "$untracked" | sed 's/^/ /' >&2
die "add or remove them first. docker build packs the working tree, so these
would go into the image while the tag could not rebuild it."
fi
current=$(node -p "require('./package.json').version" 2>/dev/null) \ current=$(node -p "require('./package.json').version" 2>/dev/null) \
|| stop "could not read the version from package.json." || stop "could not read the version from package.json."
@ -265,8 +277,9 @@ if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was changed. It would have:" say "--dry-run: nothing was changed. It would have:"
say " set version ${next} in ${FILES[*]}" say " set version ${next} in ${FILES[*]}"
say " bash scripts/verify.sh" say " bash scripts/verify.sh"
say " docker build --build-arg APP_VERSION=${next} -t ${IMAGE}:${TAG} ." say " docker build --build-arg APP_VERSION=${next} --build-arg SITEMAP_LASTMOD=<dates> -t ${IMAGE}:${TAG} ."
say " verify the image's org.opencontainers.image.version label reads ${next}" say " verify the image's org.opencontainers.image.version label reads ${next}"
say " verify the image's sitemap carries dates"
say " verify the built bundle contains the reCAPTCHA site key" say " verify the built bundle contains the reCAPTCHA site key"
say " docker push ${IMAGE}:${TAG}" say " docker push ${IMAGE}:${TAG}"
say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)" say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)"
@ -315,9 +328,11 @@ if ! bash scripts/verify.sh; then
exit 1 exit 1
fi fi
say "NOTE: those guards are a build, a secret scan and a doc-header check." say "NOTE: those guards are a build (which validates the content layer), an"
say " There is no test suite in this repository, so nothing above" say " audit of the built HTML, a secret scan of the tree and the bundle,"
say " exercised a single route, form or API response." say " and a doc-header check. There is no test suite in this repository,"
say " so nothing above exercised a form or an API response, and nothing"
say " loaded a page in a browser."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build, verify what came out, then push. Nothing is committed until all three # Build, verify what came out, then push. Nothing is committed until all three
@ -325,9 +340,21 @@ say " exercised a single route, form or API response."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
say "building ${IMAGE}:${TAG}" say "building ${IMAGE}:${TAG}"
# Sitemap dates come from git history, and the image build cannot see it:
# .dockerignore excludes .git and node:alpine ships no git binary. Computing the
# map here, where git exists, and passing it in is what puts dates in the
# production sitemap. Without it every URL goes out undated, which is exactly
# what production served until 2026-09-10.
sitemap_lastmod=$(node -e "import('./scripts/lib/routes.js').then(m => console.log(JSON.stringify(m.lastModByRoute())))" 2>/dev/null)
if [ -z "$sitemap_lastmod" ] || [ "$sitemap_lastmod" = "{}" ]; then
die "could not compute sitemap dates from git history, so the image would ship an
undated sitemap. Run this from a full clone, not an export."
fi
if ! docker build \ if ! docker build \
--build-arg "APP_VERSION=${next}" \ --build-arg "APP_VERSION=${next}" \
--build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \ --build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \
--build-arg "SITEMAP_LASTMOD=${sitemap_lastmod}" \
-t "${IMAGE}:${TAG}" . ; then -t "${IMAGE}:${TAG}" . ; then
say "build failed. The version bump is in your working tree and NOTHING was" say "build failed. The version bump is in your working tree and NOTHING was"
say " published or committed. 'git checkout -- ${FILES[*]}' undoes it." say " published or committed. 'git checkout -- ${FILES[*]}' undoes it."
@ -358,6 +385,25 @@ fi
# makes, for the value that actually stops the product working. This survives # makes, for the value that actually stops the product working. This survives
# somebody editing the Dockerfile's ARG/ENV pair or adding dist to # somebody editing the Dockerfile's ARG/ENV pair or adding dist to
# .dockerignore, neither of which would fail the build. # .dockerignore, neither of which would fail the build.
say "verifying the image's sitemap carries dates…"
# Ask the artifact, not the wiring. A missing build arg, a typo in the
# Dockerfile's ARG/ENV pair, or a .dockerignore change would each leave the
# sitemap undated while the build still succeeds.
dated=$(docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \
"grep -c '<lastmod>' /app/dist/sitemap.xml" 2>/dev/null | tr -d '\r\n')
if [ "${dated:-0}" -lt 1 ]; then
docker rmi "${IMAGE}:${TAG}" >/dev/null 2>&1
say "the image's sitemap carries no lastmod at all. Search engines schedule"
say " recrawls on it, and Bing's index feeds Copilot and ChatGPT search."
say " Check ARG/ENV SITEMAP_LASTMOD in the builder stage of the"
say " Dockerfile. Nothing was published or committed; the local image"
say " was removed."
exit 1
fi
say " ${dated} URL(s) dated."
say "verifying the bundle carries the reCAPTCHA site key…" say "verifying the bundle carries the reCAPTCHA site key…"
if ! docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \ if ! docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \

View File

@ -26,7 +26,7 @@
# bash scripts/secrets.sh # staged changes (use in pre-commit) # bash scripts/secrets.sh # staged changes (use in pre-commit)
# bash scripts/secrets.sh --tracked # everything tracked, for an audit # bash scripts/secrets.sh --tracked # everything tracked, for an audit
# bash scripts/secrets.sh --built dist/ # the artifact users receive # bash scripts/secrets.sh --built dist/ # the artifact users receive
# SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh # SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh # secrets-ok: a path
# bash scripts/secrets.sh --allow docs/examples/ # bash scripts/secrets.sh --allow docs/examples/
# #
# ## --built, and why the repository is the wrong place to stop # ## --built, and why the repository is the wrong place to stop
@ -56,7 +56,16 @@
# in the history** after you delete it, and this will not tell you that — the # in the history** after you delete it, and this will not tell you that — the
# fix there is a rotation, not a scan. Rotate first, then clean up. # fix there is a rotation, not a scan. Rotate first, then clean up.
# #
# Exit codes: 0 nothing found. 1 a candidate found. 2 nothing was scanned. # ## Excusing one line
#
# A line that has to show a credential SHAPE, such as a usage example or a list
# of known-weak values, carries `secrets-ok:` and a reason on the same line.
# That excuses that one line and nothing else, and it stays visible in review
# and to grep, which an --allow path does not. There is no blanket exemption for
# comments: a key pasted into a comment is still a leaked key.
#
# Exit codes: 0 nothing found. 1 a candidate found. 2 nothing was scanned, or a
# pattern could not be read, which is the same thing.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -uo pipefail set -uo pipefail
@ -167,10 +176,20 @@ PATTERNS=(
'//[^/@[:space:]:"'"'"'{},<>]+:[^/@[:space:]"'"'"'{},<>]+@' '//[^/@[:space:]:"'"'"'{},<>]+:[^/@[:space:]"'"'"'{},<>]+@'
'[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+' '[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+'
'\b(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{20,}' # an authorization header '\b(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{20,}' # an authorization header
# Anchored to the start of a line or an `export`, because unanchored it # NAME=value, where NAME is upper-case and contains SECRET, TOKEN, PASS or
# matched `access_token = $1` in SQL and `apiKey=` in a property list — three # API_KEY. Three things about it were learned the hard way:
# findings in src/ that were column names, not credentials. #
'(^|export )[A-Z][A-Z0-9_]*(SECRET|TOKEN|PASSWORD|API_KEY|PASSWD)[A-Z0-9_]*=[^[:space:]"'"'"']{8,}' # - It must not be anchored to the start of a line. Neither scan mode ever
# presents one: a staged diff starts every line with "+", and --tracked
# prefixes each with its file name. Anchored, it matched nothing at all, so
# the name now merely must not continue an identifier.
# - Case matters. Lower-case `access_token = $1` in SQL and `apiKey=` in a
# property list are column and field names, not credentials.
# - The value may be quoted. Unquoted-only, it missed API_KEY="...", which is
# how a real secret is most often written down. A value starting with `$`
# is a variable reference and one starting with `<` is a placeholder, and
# neither is flagged.
'(^|[^A-Za-z0-9_])[A-Z0-9_]*(SECRET|TOKEN|PASSWORD|PASSWD|PASS|API_KEY)[A-Z0-9_]*=["'"'"']?[^[:space:]"'"'"'$<][^[:space:]"'"'"']{7,}'
'-----BEGIN [A-Z ]*PRIVATE KEY-----' '-----BEGIN [A-Z ]*PRIVATE KEY-----'
'\bghp_[A-Za-z0-9]{20,}' # GitHub '\bghp_[A-Za-z0-9]{20,}' # GitHub
'\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack '\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack
@ -277,7 +296,30 @@ else
done < <(git ls-files) done < <(git ls-files)
fi fi
# Every pattern must compile before its silence means anything. grep answers an
# unreadable pattern with exit 2, and the scan below discards grep's errors, so
# a broken pattern reads as "no credential shapes", the same silence as a clean
# tree. The private-key pattern was exactly that for as long as it existed: it
# starts with dashes, grep took it for an option, and nothing said so.
for pattern in "${PATTERNS[@]}" "${NOTED_PATTERNS[@]}"; do
grep -qE -e "$pattern" </dev/null 2>/dev/null
if [ "$?" -eq 2 ]; then
say "grep cannot read the pattern '${pattern:0:40}', so nothing was scanned."
exit 2
fi
done
if [ -z "$CONTENT" ]; then if [ -z "$CONTENT" ]; then
# "Nothing to scan" means two different things and collapsing them makes a
# DELETE-ONLY COMMIT IMPOSSIBLE. In staged mode CONTENT is built from ADDED
# lines only, so a commit that only deletes has none — a correct measurement,
# not a failure to measure, because a deletion cannot introduce a credential.
# In tracked/built mode empty means nothing was examined at all, which IS the
# could-not-check state exit 2 exists to name.
if [ "$MODE" = "staged" ]; then
say "no added lines in $WHAT — a deletion cannot introduce a credential."
exit 0
fi
say "nothing to scan in $WHAT." say "nothing to scan in $WHAT."
exit 2 exit 2
fi fi
@ -294,6 +336,9 @@ for pattern in "${PATTERNS[@]}"; do
done done
[ -n "$skip" ] && continue [ -n "$skip" ] && continue
# One line excused on purpose, with its reason beside it. See the header.
case "$hit" in *secrets-ok:*) continue ;; esac
# The match is masked, then the line is truncated. Truncation alone was not # The match is masked, then the line is truncated. Truncation alone was not
# enough and used to be all there was: it bounds how much of a LONG value # enough and used to be all there was: it bounds how much of a LONG value
# reaches the terminal and prints a short one whole, so the scanner # reaches the terminal and prints a short one whole, so the scanner
@ -307,7 +352,7 @@ for pattern in "${PATTERNS[@]}"; do
[ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]" [ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]"
printf ' %.120s…\n' "$masked" printf ' %.120s…\n' "$masked"
found=$((found + 1)) found=$((found + 1))
done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20) done < <(printf '%s\n' "$CONTENT" | grep -nEI -e "$pattern" 2>/dev/null | head -20)
done done
# The public-by-design tier. Printed, counted, and deliberately not fatal. # The public-by-design tier. Printed, counted, and deliberately not fatal.
@ -322,7 +367,7 @@ if [ "$MODE" = "built" ]; then
fi fi
printf ' %.120s…\n' "$hit" printf ' %.120s…\n' "$hit"
noted=$((noted + 1)) noted=$((noted + 1))
done < <(printf '%s\n' "$CONTENT" | grep -oEI "$pattern" 2>/dev/null | sort -u | head -20) done < <(printf '%s\n' "$CONTENT" | grep -oEI -e "$pattern" 2>/dev/null | sort -u | head -20)
done done
if [ "$noted" -gt 0 ]; then if [ "$noted" -gt 0 ]; then

View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
//
// The content check, on its own, for proving it fails and for a quick answer
// while writing copy. The build runs the same check inside prerender.js, so a
// clean run here is not a substitute for `npm run build`.
//
// node scripts/validate-content.js
//
// Exit 0 nothing wrong, 1 findings, 2 nothing was checked.
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { validateContent } from './lib/content.js'
const { errors, warnings, checked } = validateContent({ services, industries })
for (const warning of warnings) console.warn(`content: note: ${warning}`)
if (!services?.length || !industries?.length || checked === 0) {
console.error('content: nothing was checked, which is not a pass. Did the data modules fail to import?')
process.exit(2)
}
if (errors.length) {
console.error(`content: ${errors.length} problem(s):`)
for (const error of errors) console.error(` ${error}`)
process.exit(1)
}
console.log(`content: ${checked} field(s) checked across ${services.length} service(s) and ${industries.length} industr(ies), nothing wrong.`)

21
scripts/verify.d/15-built-html Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
#
# What the site actually serves, checked after it is built.
#
# Every other guard here reads an input: the content check reads the data, the
# secret scan reads the diff, the build reads the source. This one reads the
# OUTPUT, which is the only thing a visitor or a crawler ever sees. Two live
# defects made the case for it: every page preloaded the wrong image for months,
# and eleven pages shipped a description that read as one run-on sentence. Both
# are plain in the built HTML and invisible in the source.
#
# It sorts after 10-build on purpose: there is nothing to read until the build
# has run, and it refuses (exit 2) rather than pass when dist/ is missing or
# older than the sources.
#
# Exit 0 clean, 1 findings, 2 nothing was audited.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
[ -f scripts/audit-html.js ] || { echo "audit: scripts/audit-html.js is missing, so nothing was audited." >&2; exit 2; }
node scripts/audit-html.js

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Credentials in the tracked tree. # Credentials in the tracked tree, and in the bundle users receive.
# #
# `scripts/secrets.sh` runs on the staged diff from the pre-commit hook, which # `scripts/secrets.sh` runs on the staged diff from the pre-commit hook, which
# is the cheap moment. This is the whole-tree version, run as part of verify so # is the cheap moment. This is the whole-tree version, run as part of verify so
@ -11,9 +11,25 @@
# commits of a then-public repository for a month, and a staged-diff scan # commits of a then-public repository for a month, and a staged-diff scan
# installed afterwards would never have mentioned them. # installed afterwards would never have mentioned them.
# #
# It also scans dist/, which 10-build has just produced. A key can reach the
# bundle from an environment variable inlined at build time without ever being
# committed, and the tracked scan cannot see that. SECURITY_CHECKLIST.md listed
# this as a manual release check for months; nothing ran it.
#
# Exit 0 clean, 1 findings, 2 the scanner could not run. # Exit 0 clean, 1 findings, 2 the scanner could not run.
set -uo pipefail set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1 cd "$(git rev-parse --show-toplevel)" || exit 1
[ -f scripts/secrets.sh ] || { echo "secrets: scripts/secrets.sh is missing — nothing was scanned." >&2; exit 2; } [ -f scripts/secrets.sh ] || { echo "secrets: scripts/secrets.sh is missing, so nothing was scanned." >&2; exit 2; }
bash scripts/secrets.sh --tracked
status=0
bash scripts/secrets.sh --tracked || status=$?
if [ ! -d dist ]; then
echo "secrets: dist/ is missing, so the built output was not scanned. Run 10-build first." >&2
exit 2
fi
bash scripts/secrets.sh --built dist/ || { rc=$?; [ "$rc" -gt "$status" ] && status=$rc; }
exit "$status"

View File

@ -86,7 +86,14 @@ const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 })
return ( return (
<div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}> <div className={`rounded-md border bg-background px-4 py-3 ${error || loadError ? 'border-red-500' : 'border-border'}`}>
<div ref={containerRef} /> {/* Google renders a fixed 304px iframe here and its width cannot be
overridden. At 320px that runs 25px past the viewport, where
body{overflow-x:hidden} clips the branding and the privacy links.
Scaling is the only lever; the wrapper height is reduced to match so
the layout gains no dead space. See #215. */}
<div className="recaptcha-scale">
<div ref={containerRef} />
</div>
{!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>} {!isReady && !loadError && <p className="text-sm text-soft-text">Loading security verification...</p>}
{(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>} {(error || loadError) && <p className="mt-2 text-xs text-red-500">{error || loadError}</p>}
</div> </div>

View File

@ -1,7 +1,7 @@
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
const DEFAULT_IMAGE = 'https://queuenorth.com/assets/og-image.png' const DEFAULT_IMAGE = 'https://queuenorth.com/assets/og-image.png'
const DEFAULT_IMAGE_ALT = 'Queue North Technologies Business Communications & IT Partner' const DEFAULT_IMAGE_ALT = 'Queue North Technologies: Business Communications & IT Partner'
const SITE_NAME = 'Queue North Technologies' const SITE_NAME = 'Queue North Technologies'
const SEO = ({ title, description, url, type = 'website', image = DEFAULT_IMAGE, jsonLd, noindex = false }) => { const SEO = ({ title, description, url, type = 'website', image = DEFAULT_IMAGE, jsonLd, noindex = false }) => {

View File

@ -4,10 +4,23 @@ import { useLocation } from 'react-router-dom'
export default function ScrollToTop() { export default function ScrollToTop() {
const { pathname, hash } = useLocation() const { pathname, hash } = useLocation()
// Cross-page navigation: scroll to hash or top on route change // Cross-page navigation: scroll to hash or top on route change.
//
// getElementById, not querySelector. querySelector THROWS on a fragment that
// is not a valid CSS selector, and an id starting with a digit is not one:
// `#8x8-implementation` and even `/#1` would throw here, in an effect inside
// the root route, and the error boundary would replace the header, the page
// and the footer with the error screen. The long-form service pages exist to
// be deep-linked from search results and AI answers, so a link nobody here
// wrote must never be able to blank the page. An unknown id scrolls to top.
useEffect(() => { useEffect(() => {
if (hash) { if (hash.length > 1) {
const el = document.querySelector(hash) let el = null
try {
el = document.getElementById(decodeURIComponent(hash.slice(1)))
} catch {
el = null // a fragment that is not valid percent-encoding
}
if (el) { if (el) {
el.scrollIntoView({ behavior: 'smooth' }) el.scrollIntoView({ behavior: 'smooth' })
return return

View File

@ -0,0 +1,170 @@
import { Link } from 'react-router-dom'
/**
* One renderer for every long-form page on this site.
*
* The privacy policy had the only block renderer here, and the two approved
* service pages needed the same vocabulary plus ordered steps, a figure and a
* link list. Two renderers would have drifted, and the privacy page is the one
* page a lead form depends on being readable, so it uses this one too, with its
* two policy-only block types passed in rather than forked.
*
* The markup is deliberately identical to what the privacy page emitted before
* this component existed, down to prop order, because React writes attributes in
* that order and the page's output had to stay byte for byte the same.
*
* An unknown block type THROWS. The old renderer sent one to the paragraph case,
* which rendered an empty <p>, so a misspelt type silently dropped a paragraph
* of copy and no build or check would have said a word. Failing here fails the
* prerender, which names the route.
*/
export const LINK_CLASS =
'font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors'
/**
* Paragraph text is either a string or a list of parts, where a part is a
* string or a link. That is what lets approved copy carry an internal link
* without the copy itself knowing any markup.
*/
export const Inline = ({ value }) => {
if (!Array.isArray(value)) return value
return value.map((part, index) =>
typeof part === 'string' ? (
part
) : (
<Link key={index} to={part.to} className={LINK_CLASS}>
{part.text}
</Link>
),
)
}
export const Paragraph = ({ block }) => (
<p className="mt-4 text-base leading-relaxed text-soft-text">
<Inline value={block.text} />
</p>
)
const Heading = ({ block }) => (
<h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3>
)
const BulletList = ({ block }) => (
<ul className="mt-4 space-y-2">
{block.items.map((item) => (
<li key={item} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
)
// A sequence, where a step is either a line or a line plus its explanation.
// Native list numbering, so the number is the list's and not typed into copy.
const StepList = ({ block }) => (
<ol className="mt-4 space-y-3 list-decimal pl-5 marker:font-numeric marker:font-semibold marker:text-primary-blue">
{block.items.map((item) => {
const step = typeof item === 'string' ? { text: item } : item
return (
<li key={step.text} className="pl-1 text-base leading-relaxed text-soft-text">
<span className="font-semibold text-text">{step.text}</span>
{step.detail ? <span className="block text-base leading-relaxed text-soft-text">{step.detail}</span> : null}
</li>
)
})}
</ol>
)
const Callout = ({ block }) => (
<p className="mt-5 rounded-md border border-border border-l-[3px] border-l-accent-gold bg-section-alt p-5 text-base leading-relaxed text-text">
<Inline value={block.text} />
</p>
)
// Renders only once a file exists. A slot waiting on an image the owner has to
// supply renders nothing at all: a placeholder that looks deliberate outlives
// the issue that would have replaced it.
const Figure = ({ block }) =>
block.src ? (
<figure className="mt-6">
<img
src={block.src}
alt={block.alt}
width={block.width}
height={block.height}
loading="lazy"
decoding="async"
className="w-full rounded-md border border-border bg-white"
/>
{block.caption ? (
<figcaption className="mt-2 text-sm text-soft-text">{block.caption}</figcaption>
) : null}
</figure>
) : null
// space-y-4 and tap-target, not the space-y-2 this had: a standalone link in a
// list is a tap target, and .tap-target (index.css) needs 16px between rows to
// keep neighbouring hit boxes from overlapping. LINK_CLASS itself must stay
// padding-free, because it is also used inside sentences.
const LinkList = ({ block }) => (
<ul className="mt-4 space-y-4">
{block.items.map((item) => (
<li key={item.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" />
<Link to={item.to} className={`tap-target ${LINK_CLASS}`}>
{item.label}
</Link>
</li>
))}
</ul>
)
export const BASE_RENDERERS = {
p: Paragraph,
h3: Heading,
ul: BulletList,
ol: StepList,
callout: Callout,
image: Figure,
links: LinkList,
}
export const Block = ({ block, section, index, renderers = BASE_RENDERERS }) => {
const Render = renderers[block?.type]
if (!Render) {
throw new Error(
`content: section ${JSON.stringify(section)}, block ${index}: unknown block type ` +
`${JSON.stringify(block?.type)}. Known types: ${Object.keys(renderers).join(', ')}.`,
)
}
return <Render block={block} />
}
/**
* A section of long-form copy: an anchor, its heading, and its blocks. The id
* is what a search result or an AI answer links to, so it is on the article.
*/
export const ContentSection = ({ section, renderers = BASE_RENDERERS, headingClassName }) => (
<article id={section.id} className="mt-12 scroll-mt-28">
<h2 className={headingClassName ?? 'text-2xl md:text-3xl font-bold text-primary-navy'}>
{section.number ? (
<>
<span className="font-numeric">{section.number}.</span> {section.title}
</>
) : (
section.title
)}
</h2>
{section.blocks.map((block, index) => (
<Block
key={`${section.id}-${index}`}
block={block}
section={section.id}
index={index}
renderers={renderers}
/>
))}
</article>
)

View File

@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { LINK_CLASS } from './ContentBlocks'
/**
* The links a page points at, with the anchor text the copy sheet approved.
*
* Shared by the service and industry pages, which want the same list in two
* places and at two heading levels. It renders NOTHING without links, so a page
* that has none is byte for byte what it was.
*/
export const LinkList = ({ links, className = '' }) => (
<ul className={className}>
{links.map((link) => (
<li key={link.to} className="flex gap-3 text-base leading-relaxed text-soft-text">
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-primary-blue" aria-hidden="true" />
<Link to={link.to} className={`tap-target ${LINK_CLASS}`}>
{link.label}
</Link>
</li>
))}
</ul>
)
const RelatedLinks = ({ links, title = 'Related services', as: Heading = 'h2', className = '', headingClassName }) => {
if (!links?.length) return null
return (
<section className={className}>
<Heading className={headingClassName ?? 'text-2xl font-bold text-primary-navy'}>{title}</Heading>
{/* space-y-4 because each row is a tap target: see .tap-target in index.css. */}
<LinkList links={links} className="mt-4 space-y-4" />
</section>
)
}
export default RelatedLinks

View File

@ -56,12 +56,16 @@ const Footer = () => {
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span> <span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
</Link> </Link>
<p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p> <p className="text-navy-light text-sm leading-relaxed mb-5">{companyInfo.tagline}</p>
<div className="space-y-2 text-navy-light text-sm mb-6"> {/* space-y-4, not space-y-2: see .tap-target in index.css. The links
are 17px tall and their hit boxes are 33px, so the rows have to be
at least 33px apart or the boxes overlap and only the last one
painted is really tappable. */}
<div className="space-y-4 text-navy-light text-sm mb-6">
<div> <div>
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a> <a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="tap-target block w-fit hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
</div> </div>
<div> <div>
<a href={`tel:+1${companyInfo.tollFree.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a> <a href={`tel:+1${companyInfo.tollFree.replace(/\D/g, '')}`} className="tap-target block w-fit hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a>
</div> </div>
</div> </div>
<Link <Link
@ -79,12 +83,12 @@ const Footer = () => {
{/* Quick Links */} {/* Quick Links */}
<div className="lg:pt-16"> <div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Quick Links</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Quick Links</h3>
<ul className="space-y-2"> <ul className="space-y-4">
{quickLinks.map((link) => ( {quickLinks.map((link) => (
<li key={link.name}> <li key={link.name}>
<Link <Link
to={link.href} to={link.href}
className="text-navy-light hover:text-white transition-colors text-sm" className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={link.name} aria-label={link.name}
> >
{link.name} {link.name}
@ -97,12 +101,12 @@ const Footer = () => {
{/* Services */} {/* Services */}
<div className="lg:pt-16"> <div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Services</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Services</h3>
<ul className="space-y-2"> <ul className="space-y-4">
{services.map((service) => ( {services.map((service) => (
<li key={service.name}> <li key={service.name}>
<Link <Link
to={service.href} to={service.href}
className="text-navy-light hover:text-white transition-colors text-sm" className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={service.name} aria-label={service.name}
> >
{service.name} {service.name}
@ -115,12 +119,12 @@ const Footer = () => {
{/* Industries */} {/* Industries */}
<div className="lg:pt-16"> <div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Industries</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Industries</h3>
<ul className="space-y-2"> <ul className="space-y-4">
{industries.map((industry) => ( {industries.map((industry) => (
<li key={industry.name}> <li key={industry.name}>
<Link <Link
to={industry.href} to={industry.href}
className="text-navy-light hover:text-white transition-colors text-sm" className="tap-target block text-navy-light hover:text-white transition-colors text-sm"
aria-label={industry.name} aria-label={industry.name}
> >
{industry.name} {industry.name}
@ -166,7 +170,7 @@ const Footer = () => {
</p> </p>
<Link <Link
to="/privacy-policy" to="/privacy-policy"
className="text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit" className="tap-target text-navy-light text-xs hover:text-primary-cyan transition-colors w-fit"
aria-label="Read the Queue North Technologies Privacy Policy" aria-label="Read the Queue North Technologies Privacy Policy"
> >
Privacy Policy Privacy Policy

View File

@ -68,12 +68,31 @@ const Header = () => {
width="200" width="200"
height="200" height="200"
/> />
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span> <span className="font-bold text-sm sm:text-xl xl:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
</Link> </Link>
</div> </div>
{/* Desktop Nav */} {/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-6" aria-label="Main navigation"> {/*
The desktop row starts at lg, not md. Tightening the gaps at md (#214)
did not fix iPad portrait, it only hid the overflow better: measured at
a true 768px the three items want 787px of their natural width against
736px of container, so flex shrank the CTA to 114px, wrapped "Request
Consultation" inside it, and still pushed it 25px past the viewport,
where body{overflow-x:hidden} sliced it off with no scrollbar. That
gap-4 was verified in a desktop window whose scrollbar left ~753px, so
md never engaged and the fix was never actually exercised.
768 to 1023 gets the menu button instead, which is the better tablet
experience anyway: 44px rows rather than 17px ones, and the Services
and Industries submenus are reachable, where the desktop dropdowns
open on hover and a touch device has no hover.
The wordmark stays at text-xl until xl. At exactly 1024 the row was
988px inside 992px of container: four pixels, which a font fallback
or one more nav item would eat. At text-xl it has room.
*/}
<nav className="hidden lg:flex items-center gap-5 xl:gap-6" aria-label="Main navigation">
{navLinks.map((link) => { {navLinks.map((link) => {
const hasDropdown = link.name === 'Services' || link.name === 'Industries' const hasDropdown = link.name === 'Services' || link.name === 'Industries'
return ( return (
@ -97,7 +116,7 @@ const Header = () => {
to={link.href} to={link.href}
onFocus={() => hasDropdown && setOpenDropdown(link.name)} onFocus={() => hasDropdown && setOpenDropdown(link.name)}
onClick={closeDropdown} onClick={closeDropdown}
className={`text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`} className={`block py-3 text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`}
> >
{link.name} {link.name}
</Link> </Link>
@ -141,17 +160,23 @@ const Header = () => {
</nav> </nav>
{/* CTA Button */} {/* CTA Button */}
<div className="hidden md:block"> {/* shrink-0 and whitespace-nowrap so a row that no longer fits overflows
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors"> visibly and the device sweep catches it, instead of flex quietly
squeezing the button and wrapping its label to keep the total inside
a viewport it is already past. */}
<div className="hidden lg:block shrink-0">
<Link to="/contact#contact-form" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors">
Request Consultation Request Consultation
</Link> </Link>
</div> </div>
{/* Mobile Menu */} {/* Mobile Menu */}
<div className="md:hidden"> <div className="lg:hidden">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}> <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<button className="p-2 text-white hover:text-primary-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu"> {/* p-2.5, so the 24px icon sits in a 44x44 target. It is now the
only navigation up to 1023px, and 40x40 was under the mark. */}
<button className="p-2.5 text-white hover:text-primary-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu">
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />

View File

@ -1,6 +1,10 @@
export const industries = [ export const industries = [
{ {
id: 'healthcare', id: 'healthcare',
related: [
{ to: '/services/unified-communications', label: 'Business phone and UCaaS solutions' },
{ to: '/services/contact-center', label: 'Contact center and CCaaS solutions' },
],
name: 'Healthcare', name: 'Healthcare',
shortDesc: 'HIPAA-compliant communications and infrastructure for medical providers', shortDesc: 'HIPAA-compliant communications and infrastructure for medical providers',
fullDesc: 'Patient experience, scheduling, and staff coordination with a focus on compliance.', fullDesc: 'Patient experience, scheduling, and staff coordination with a focus on compliance.',
@ -20,6 +24,10 @@ export const industries = [
}, },
{ {
id: 'retail', id: 'retail',
related: [
{ to: '/services/unified-communications', label: 'Business phone and UCaaS solutions' },
{ to: '/services/contact-center', label: 'Contact center and CCaaS solutions' },
],
name: 'Retail', name: 'Retail',
shortDesc: 'Connect stores, teams, and customers with scalable retail communications', shortDesc: 'Connect stores, teams, and customers with scalable retail communications',
fullDesc: 'Connect stores, back office, and support teams while improving customer interaction.', fullDesc: 'Connect stores, back office, and support teams while improving customer interaction.',
@ -39,6 +47,10 @@ export const industries = [
}, },
{ {
id: 'manufacturing', id: 'manufacturing',
related: [
{ to: '/services/unified-communications', label: 'Business phone and UCaaS solutions' },
{ to: '/services/contact-center', label: 'Contact center and CCaaS solutions' },
],
name: 'Manufacturing', name: 'Manufacturing',
shortDesc: 'Industrial communications for production floors and distributed operations', shortDesc: 'Industrial communications for production floors and distributed operations',
fullDesc: 'Reliable office-to-plant communications including paging and alerting for production environments.', fullDesc: 'Reliable office-to-plant communications including paging and alerting for production environments.',
@ -58,6 +70,10 @@ export const industries = [
}, },
{ {
id: 'education-finance', id: 'education-finance',
related: [
{ to: '/services/unified-communications', label: 'Business phone and UCaaS solutions' },
{ to: '/services/contact-center', label: 'Contact center and CCaaS solutions' },
],
name: 'Education & Finance', name: 'Education & Finance',
shortDesc: 'Secure, reliable communications for schools and financial institutions', shortDesc: 'Secure, reliable communications for schools and financial institutions',
fullDesc: 'Campus communications and customer-facing communications in tightly regulated environments.', fullDesc: 'Campus communications and customer-facing communications in tightly regulated environments.',

View File

@ -0,0 +1,586 @@
// Contact Center page content.
// Source: .drop/2026-08-28-levi-uc-cc-sheets/Contact SEO + Search Sheet APPROVED 2026-08-28.md
// Owner-approved 2026-08-28. Copy is verbatim from the sheet; website-manager
// directions in the sheet are not published here.
// Leaf module: no imports, no JSX.
//
// Punctuation: the generation rule for these modules forbids U+2014 anywhere, so
// each em dash in the approved copy is rendered as the comma the sentence needs.
// Four places are affected: hero.intro[1], the "as they happen" routing bullet, the
// implementation direct answer, and the AI callout. Words are unchanged in all four.
// This is the rule, not a house-style drift: do not "restore" them.
//
// ol item contract, shared by every generated content module: an 'ol' block's items
// are EITHER plain strings (a simple sequence) OR objects of the shape
// { text, detail } (a step title plus its explanation). Both shapes are valid, and a
// block never mixes them. A renderer must normalise before rendering: treat a string
// item as { text: item, detail: undefined } and emit detail only when it is present.
// This module's implementation sequence uses { text, detail } because the approved
// sheet gives every step an explanation; the peer module content/unified-communications.js
// uses plain strings because its sheet does not.
export const page = {
seo: {
title: 'Contact Center & CCaaS Solutions | Queue North',
description:
'Modern contact center and CCaaS solutions from Queue North Technologies, including 8x8 and Cisco Webex implementation, omnichannel routing, analytics, CRM integration, migration, and support.',
},
hero: {
h1: 'Contact Center & Customer Experience Solutions',
subheading:
'Design, implement, and support customer-engagement environments built around routing, channels, agents, reporting, integrations, and the customer experience.',
intro: [
'Queue North Technologies helps businesses design, implement, migrate, and support modern contact center environments using platforms including 8x8 Contact Center and Cisco Webex Contact Center.',
'A contact center should connect customers to the right resource, through the right channel, with the information and workflow the agent needs to resolve the interaction effectively. Queue North approaches contact center projects as an operational design problem, not simply a software-license purchase.',
],
primaryCta: { label: 'Request a Contact Center Review', to: '/contact#contact-form' },
secondaryCta: { label: 'Talk With an Engineer', to: '/contact#contact-form' },
},
sections: [
{
id: 'contact-center-vs-phone-queue',
title: 'When Does a Business Need a Contact Center Instead of a Phone Queue?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A basic phone queue may be enough when a small team primarily needs to answer inbound calls and distribute them among available users. A contact center becomes more appropriate when customer interactions require greater control, visibility, consistency, and coordination across agents, departments, channels, and business systems.',
},
{
type: 'p',
text: 'The difference is not simply having more features. A properly designed contact center can help reduce unnecessary transfers, improve first-contact resolution, give supervisors better operational visibility, create more structured coaching and quality-management processes, and provide customers with additional ways to engage with the organization.',
},
{ type: 'p', text: 'A business may benefit from CCaaS when it needs:' },
{
type: 'ul',
items: [
'Skills-based and intelligent routing to connect customers with the right resource sooner, reduce unnecessary transfers, and improve first-contact resolution.',
'Multiple departments, queues, or agent groups that require different routing rules, priorities, escalation paths, or service levels.',
'Voice and digital customer channels such as SMS, web chat, messaging, and email managed through a more coordinated customer-service environment.',
'Real-time agent and supervisor visibility so leaders can monitor queue conditions, agent availability, service levels, and developing issues as they happen, supporting a more proactive rather than reactive operation.',
'Historical reporting and analytics to identify trends in call volume, wait times, abandonment, transfers, agent performance, customer demand, and other operational metrics.',
'Call recording and quality-management workflows that support structured coaching, performance review, compliance requirements, and more effective agent training.',
'CRM, help-desk, or business-system integration so agents can access relevant customer information, reduce duplicate data entry, and complete workflows without constantly moving between disconnected applications.',
'Outbound campaigns and proactive engagement for functions such as appointment reminders, customer follow-up, collections, notifications, sales outreach, or service communications.',
'AI-assisted agent and customer-service capabilities such as self-service, interaction summaries, knowledge assistance, routing intelligence, quality analysis, and supervisor insights where those capabilities solve a defined business problem.',
'Distributed or remote agents that still require centralized routing, supervision, reporting, quality management, and a consistent agent experience.',
'Greater visibility into the customer journey so the organization can understand not only how many interactions occurred, but where customers wait, transfer, abandon, escalate, or repeatedly contact the business.',
],
},
{
type: 'p',
text: 'A contact center is therefore not simply a larger phone queue. It is an operational platform for managing how customer interactions enter the organization, how they are routed and resolved, how agents and supervisors work, and how the business measures and improves the customer experience over time.',
},
],
},
{
id: 'contact-center-implementation',
title: 'What Should a Contact Center Implementation Include?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A successful contact center implementation should begin with understanding how the business actually operates, not with configuring queues and licenses.',
},
{
type: 'p',
text: 'Before designing the new environment, Queue North reviews the organizations customer-interaction workflows, internal policies, procedures, business processes, departments, roles, escalation paths, communication channels, reporting needs, and system dependencies.',
},
{
type: 'p',
text: 'The objective is to design the contact center around the organizations existing operating model so employees make little to no unnecessary change simply to accommodate new software.',
},
{
type: 'p',
text: 'Queue North recommends operational changes only when they create a clear business benefit, such as reducing handling time, eliminating duplicate work, lowering cost, improving customer experience, increasing visibility, or enabling a valuable capability that was not possible in the previous environment.',
},
{ type: 'h3', text: 'Queue North implementation sequence' },
{
type: 'ol',
items: [
{
text: 'Current-state business and operational discovery.',
detail:
'Document how the organization currently serves customers, how work moves between departments, who owns each type of interaction, and where operational pain points exist.',
},
{
text: 'Workflow, policy, procedure, and process review.',
detail:
'Evaluate the internal workflows, policies, procedures, approval paths, escalation rules, service standards, and business processes that influence how customer interactions are handled.',
},
{
text: 'Customer-interaction and channel review.',
detail:
'Identify why customers contact the organization, which channels they use, how those interactions are handled today, and where customers experience delays, transfers, abandonment, or unnecessary effort.',
},
{
text: 'Department, queue, role, and agent inventory.',
detail:
'Map departments, teams, supervisors, agents, responsibilities, skills, locations, working models, and ownership of customer-interaction types.',
},
{
text: 'Business-requirement and improvement analysis.',
detail:
'Separate requirements that must be preserved from processes that could reasonably be improved. Any proposed operational change must have a defined benefit rather than being introduced simply because the new platform works differently.',
},
{
text: 'Call-flow, interaction-flow, and routing design.',
detail:
'Design routing around the businesss real workflows, customer reasons for contact, skills, departments, priorities, account context, and escalation requirements.',
},
{
text: 'Skills, priority, and escalation planning.',
detail:
'Define how interactions should be routed, prioritized, overflowed, escalated, or reassigned while preserving the organizations existing operating rules where appropriate.',
},
{
text: 'Business-hours, after-hours, and exception design.',
detail:
'Document standard hours, holidays, emergency procedures, closures, on-call workflows, seasonal changes, and exception handling.',
},
{
text: 'CRM and business-system integration review.',
detail:
'Determine which systems agents and supervisors already use and where integration can reduce duplicate work, improve context, automate updates, or simplify customer handling.',
},
{
text: 'Reporting, KPI, and management requirements.',
detail:
'Identify what leadership, supervisors, and operations teams need to know in real time and historically, and design reporting around those business questions.',
},
{
text: 'Platform and feature mapping.',
detail:
'Map the approved operational design to the selected platforms capabilities, licensing, channels, integrations, AI functions, and administrative tools.',
},
{
text: 'Agent and supervisor workspace design.',
detail:
'Configure the user experience around the work agents and supervisors already perform, minimizing unnecessary screen changes, duplicate entry, and process disruption.',
},
{
text: 'Platform configuration and integration development.',
detail:
'Build the queues, routing logic, channels, permissions, reports, integrations, automation, and other approved components.',
},
{
text: 'Functional, workflow, and user-acceptance testing.',
detail:
'Test not only whether features work technically, but whether real business processes work correctly from beginning to end.',
},
{
text: 'Cutover and migration planning.',
detail:
'Define migration timing, dependencies, rollback considerations, number or carrier changes, agent readiness, communication plans, and operational continuity.',
},
{
text: 'Agent, supervisor, and administrator training.',
detail:
'Train users in the context of their actual roles and workflows rather than providing generic platform demonstrations.',
},
{
text: 'Post-deployment validation and optimization.',
detail:
'Confirm that the implemented environment supports the intended workflows, reporting, routing, integrations, and customer experience, then make targeted adjustments based on real operating results.',
},
],
},
{
type: 'p',
text: 'Queue North designs the contact center around how the business operates. The technology must support the organizations workflows, policies, procedures, and processes, not force the organization to change how it works simply to accommodate the software. Operational changes are recommended only when they create a clear benefit in time, cost, efficiency, visibility, customer experience, or use of a valuable capability introduced by the new solution.',
},
],
},
{
id: 'ccaas-8x8-implementation',
title: '8x8 Contact Center Implementation and Optimization',
kind: undefined,
blocks: [
{
type: 'p',
text: '8x8 Contact Center supports voice and digital customer interactions, omnichannel routing, analytics, supervisor tools, AI-assisted capabilities, proactive messaging, outbound workflows, and other customer-experience functions.',
},
{
type: 'p',
text: 'Queue North can help customers translate those platform capabilities into an operational design that fits the business.',
},
{ type: 'h3', text: 'Queue North services may include' },
{
type: 'ul',
items: [
'8x8 Contact Center implementation.',
'Queue and channel configuration.',
'Omnichannel routing.',
'Skills and agent-group design.',
'Business-hours and after-hours routing.',
'Agent and supervisor configuration.',
'Analytics and reporting setup.',
'Number and telephony planning.',
'CRM / business-system integration.',
'Testing and cutover.',
'Administrator guidance.',
'Ongoing support and optimization.',
],
},
{
type: 'image',
src: null,
alt: '8x8 Contact Center agent and customer experience workspace',
caption: '8x8 Contact Center',
width: 1600,
height: 900,
},
],
},
{
id: 'cisco-webex-contact-center',
title: 'Cisco Webex Contact Center',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Cisco Webex Contact Center is a cloud contact center platform supporting voice and digital customer interactions, intelligent routing, agent and supervisor experiences, analytics, AI-assisted workflows, and business-system integration.',
},
{
type: 'p',
text: 'Queue North can help evaluate and implement Webex Contact Center as part of a broader Cisco communications or customer-experience strategy.',
},
{ type: 'h3', text: 'Queue North services may include' },
{
type: 'ul',
items: [
'Contact-center discovery and design.',
'Voice and digital-channel planning.',
'Queue and routing configuration.',
'Agent and supervisor setup.',
'Integration requirements.',
'Reporting and analytics planning.',
'Migration and cutover.',
'Administrator guidance.',
'Ongoing support.',
],
},
{
type: 'image',
src: null,
alt: 'Cisco Webex Contact Center agent workspace',
caption: 'Cisco Webex Contact Center',
width: 1600,
height: 900,
},
],
},
{
id: 'omnichannel-customer-engagement',
title: 'Omnichannel Customer Engagement',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Omnichannel contact center design allows customer interactions from voice and supported digital channels to be managed through a coordinated service model rather than through disconnected tools.',
},
{
type: 'p',
text: 'Depending on the selected platform and licensing, channels may include:',
},
{
type: 'ul',
items: [
'Voice.',
'Web chat.',
'SMS / messaging.',
'Email.',
'Social or messaging applications.',
'Video or elevated-interaction workflows.',
'Self-service / virtual-agent interactions.',
],
},
],
},
{
id: 'contact-center-routing',
title: 'Contact Center Routing Should Match the Customer Journey',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Routing is one of the most important parts of a contact center design.',
},
{
type: 'p',
text: 'The objective is not simply to place callers in a queue. A good design considers:',
},
{
type: 'ul',
items: [
'Why the customer is contacting the organization.',
'Which team can resolve the request.',
'Agent skills.',
'Language requirements.',
'Customer or account context.',
'Business hours.',
'Priority customers or interaction types.',
'Overflow rules.',
'Escalation paths.',
'Callback options.',
'Self-service opportunities.',
'What happens when no agent is available.',
],
},
{
type: 'p',
text: 'Queue North can document and build routing logic around the actual operating model rather than forcing the organization into a generic queue structure.',
},
],
},
{
id: 'contact-center-ai',
title: 'How Can AI Be Used in a Contact Center?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'AI can assist contact centers with tasks such as customer self-service, interaction routing, agent guidance, conversation summaries, analytics, quality review, sentiment analysis, knowledge assistance, and supervisor insights. The appropriate use depends on the platform, data, workflow, compliance requirements, and the business problem being solved.',
},
{ type: 'p', text: 'Queue North principle:' },
{
type: 'callout',
text: 'Use AI where it removes friction or improves decisions, not merely because the platform includes an AI feature.',
},
{
type: 'p',
text: 'Availability varies by platform, edition, licensing, region, and release status.',
},
],
},
{
id: 'contact-center-reporting',
title: 'Contact Center Reporting Should Answer Operational Questions',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A contact center produces large amounts of interaction data, but dashboards are useful only when the business knows what it needs to measure.',
},
{ type: 'p', text: 'Reporting requirements may include:' },
{
type: 'ul',
items: [
'Interaction volume.',
'Service level.',
'Average speed of answer.',
'Abandonment.',
'Handle time.',
'Queue performance.',
'Agent availability.',
'Transfer behavior.',
'Channel usage.',
'Customer sentiment.',
'Quality-management results.',
'First-contact resolution where measurable.',
'Contact reasons / topics.',
'Callback performance.',
'Outbound campaign performance.',
],
},
{
type: 'p',
text: 'Queue North can help define the operational questions first and then configure reporting around the metrics that matter.',
},
],
},
{
id: 'crm-integration',
title: 'Connect the Contact Center to the Systems Agents Already Use',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A contact center becomes more useful when agents can access relevant customer information and update business workflows without moving between disconnected applications.',
},
{
type: 'p',
text: 'Depending on the platform and customer requirements, Queue North can help evaluate or build integrations involving:',
},
{
type: 'ul',
items: [
'CRM systems.',
'Help-desk / ticketing systems.',
'Customer records.',
'Screen pops.',
'Click-to-dial workflows.',
'Interaction logging.',
'Case or ticket creation.',
'Custom API workflows.',
'Queue North custom CRM integration applications, including an application that can identify missed calls associated with open opportunities or leads in the CRM.',
],
},
],
},
{
id: 'contact-center-migration',
title: 'What Should Be Reviewed Before a Contact Center Migration?',
kind: undefined,
blocks: [
{ type: 'p', text: 'Before replacing an existing contact center, review:' },
{
type: 'ul',
items: [
'Current phone numbers and carrier dependencies.',
'Queues and routing logic.',
'Agent and supervisor counts.',
'Existing channels.',
'IVR / self-service flows.',
'Recording and retention requirements.',
'CRM / help-desk integrations.',
'Reports and historical data requirements.',
'Workforce and quality workflows.',
'Security and compliance requirements.',
'Network and endpoint readiness.',
'Business-continuity requirements.',
'Cutover constraints.',
'Training requirements.',
],
},
{
type: 'p',
text: 'A migration plan should distinguish between functionality that must be replicated, functionality that should be redesigned, and legacy processes that should not be carried forward simply because they exist today.',
},
],
},
{
id: 'when-to-review-your-contact-center',
title: 'When Should You Review Your Contact Center?',
kind: undefined,
blocks: [
{ type: 'p', text: 'A review may be worthwhile when:' },
{
type: 'ul',
items: [
'Customers wait too long or interaction abandonment is increasing.',
'Routing rules have become difficult to manage.',
'Supervisors cannot easily see what is happening in real time.',
'Reporting does not answer basic operational questions.',
'Agents work across too many disconnected applications.',
'The organization wants digital channels in addition to voice.',
'Remote or distributed agents are difficult to support.',
'Existing CRM integration is weak or manual.',
'Quality-management processes are inconsistent.',
'AI capabilities are being considered without a clear use case.',
'The current contract is approaching renewal.',
'The organization is consolidating locations, departments, or platforms.',
],
},
],
},
{
id: 'why-queue-north',
title: 'Why Queue North Technologies?',
kind: 'list',
blocks: [
{
type: 'ul',
items: [
'8x8 and Cisco contact-center expertise.',
'Contact-center discovery and implementation experience.',
'Call-flow and routing design.',
'UCaaS and CCaaS knowledge in the same organization.',
'Custom CRM integration applications.',
'Network and infrastructure knowledge beyond the contact-center platform.',
'Migration and cutover planning.',
'Ongoing managed-support options.',
'Direct access to technical resources.',
'Veteran-owned and operated.',
],
},
],
},
{
id: 'faq',
title: 'Frequently Asked Questions',
kind: 'faq',
blocks: [
{ type: 'h3', text: 'What is CCaaS?' },
{
type: 'p',
text: 'CCaaS, or Contact Center as a Service, is a cloud-delivered contact center platform used to manage customer interactions, routing, agents, channels, reporting, and related customer-experience workflows.',
},
{ type: 'h3', text: 'What is the difference between a call center and a contact center?' },
{
type: 'p',
text: 'A call center is primarily focused on telephone calls. A contact center may manage voice plus digital channels, advanced routing, agent and supervisor tools, analytics, integrations, and other customer-engagement workflows.',
},
{
type: 'h3',
text: 'When does a business need a contact center instead of a normal phone queue?',
},
{
type: 'p',
text: 'A contact center becomes more useful when the organization needs advanced routing, multiple channels, detailed analytics, agent and supervisor tools, CRM integration, outbound engagement, quality management, or more control over the customer-service operation.',
},
{ type: 'h3', text: 'What should a contact center implementation include?' },
{
type: 'p',
text: 'A project may include discovery, routing design, queue and agent planning, channel configuration, integrations, reporting, platform configuration, testing, migration, training, and post-deployment support.',
},
{ type: 'h3', text: 'Can Queue North implement 8x8 Contact Center?' },
{
type: 'p',
text: 'Yes. Queue North is an 8x8 Certified Partner and can assist with 8x8 Contact Center planning, implementation, routing, configuration, migration, support, and optimization.',
},
{ type: 'h3', text: 'Does Queue North support Cisco Webex Contact Center?' },
{
type: 'p',
text: "Yes. Queue North supports Cisco communications environments and can help evaluate and implement Cisco Webex Contact Center where it fits the customer's requirements.",
},
{ type: 'h3', text: 'Can a contact center integrate with a CRM?' },
{
type: 'p',
text: 'Often, yes. The exact integration depends on the contact-center platform, CRM, available connectors/APIs, licensing, and the workflow the business needs.',
},
{ type: 'h3', text: 'Can AI be added to an existing contact center?' },
{
type: 'p',
text: 'Potentially. Modern contact-center platforms provide AI capabilities for areas such as self-service, routing, agent assistance, summaries, analytics, and quality workflows. Availability and suitability depend on the platform and business requirements.',
},
{
type: 'h3',
text: 'Can Queue North help improve an existing contact center without replacing it?',
},
{
type: 'p',
text: 'Potentially, yes. Queue North can review routing, configuration, reporting, integrations, support, and operational issues before recommending whether a platform replacement is actually justified.',
},
{ type: 'h3', text: 'Does Queue North provide support after implementation?' },
{
type: 'p',
text: 'Yes. Queue North offers three tiers of ongoing managed support in addition to implementation and migration services.',
},
],
},
],
related: [
{ to: '/services/unified-communications', label: 'business phone and UCaaS solutions' },
{ to: '/services/managed-support', label: 'ongoing communications and contact-center support' },
{ to: '/services/local-networking', label: 'business network design and support' },
{ to: '/services/wireless-access', label: 'business Wi-Fi' },
{ to: '/services/consulting-training', label: 'communications consulting and training' },
{ to: '/contact', label: 'request a contact center review' },
],
}

View File

@ -0,0 +1,363 @@
// Unified Communications service page content.
// Source: owner-approved Unified Communications SEO + AI Search change sheet,
// approved 2026-08-28. Customer-facing copy only; website-manager directions,
// verification steps and internal-only blocks are excluded.
// Leaf module: no imports, no JSX.
//
// ol item contract, shared by every generated content module: an 'ol' block's items
// are EITHER plain strings (a simple sequence) OR objects of the shape
// { text, detail } (a step title plus its explanation). Both shapes are valid, and a
// block never mixes them. A renderer must normalise before rendering: treat a string
// item as { text: item, detail: undefined } and emit detail only when it is present.
// This module's implementation sequence uses plain strings because the approved sheet
// gives its steps no explanation; the peer module content/contact-center.js uses
// { text, detail } because its sheet does.
export const page = {
seo: {
title: 'Business Phone & UCaaS Solutions | Queue North',
description:
'Modern business phone and UCaaS solutions from Queue North Technologies, including 8x8 and Cisco implementation, migration, number porting, deployment, and support.',
},
hero: {
h1: 'Business Phone & Unified Communications Solutions',
subheading:
'Cloud phone, messaging, meetings, and collaboration designed around how your business actually works.',
intro: [
'Queue North Technologies helps businesses replace fragmented or outdated phone systems with cloud communications designed around their users, locations, workflows, and support requirements. We design, implement, migrate, and support unified communications environments using platforms including 8x8 and Cisco Webex.',
],
primaryCta: { label: 'Request a Communications Review', to: '/contact#contact-form' },
secondaryCta: { label: 'Talk With an Engineer', to: '/contact#contact-form' },
},
sections: [
{
id: 'business-phone-systems-built-around-the-business',
title: 'Business Phone Systems Built Around the Business',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A modern business phone system should do more than make and receive calls. It should support how customers reach the organization, how calls move between people and locations, how employees work remotely or on site, and how the system will be supported after deployment.',
},
{
type: 'p',
text: 'Queue North designs unified communications environments that can combine:',
},
{
type: 'ul',
items: [
'Business voice.',
'Desktop and mobile calling.',
'Messaging and business SMS.',
'Video meetings and collaboration.',
'Auto attendants.',
'Ring groups and call queues.',
'Multi-site communications.',
'Number porting and DID planning.',
'Microsoft Teams voice integration where appropriate.',
'User and administrator planning.',
],
},
],
},
{
id: 'ucaas-8x8-implementation',
title: '8x8 UCaaS Implementation and Support',
kind: undefined,
blocks: [
{
type: 'p',
text: 'An 8x8 implementation should include more than licensing and user creation. A successful deployment requires planning for sites, users, phone numbers, dial plans, routing, auto attendants, queues, endpoints, network readiness, migration, testing, and post-cutover support.',
},
{
type: 'p',
text: 'Queue North can assist with:',
},
{
type: 'ul',
items: [
'8x8 Work implementation.',
'Existing-system migration.',
'User and site configuration.',
'Number claiming and number porting.',
'Auto attendant and call-flow configuration.',
'Ring groups and call queues.',
'Device and endpoint planning.',
'Network-readiness review.',
'Administrator guidance.',
'Ongoing 8x8 support.',
],
},
{
type: 'image',
src: null,
alt: '8x8 Work business communications application',
caption: '8x8 Work',
width: 1600,
height: 900,
},
],
},
{
id: 'cisco-webex-communications-options',
title: 'Cisco Webex Communications Options',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Cisco Webex can provide calling, messaging, meetings, and collaboration in a common application experience. The correct design depends on the customer\'s existing Cisco environment, cloud strategy, user model, integrations, support requirements, and migration plan.',
},
{
type: 'p',
text: 'Queue North also has experience with Cisco Unified Communications Manager environments and can help evaluate whether a cloud, on-premises, or transitional approach is appropriate for the business.',
},
{
type: 'image',
src: null,
alt: 'Cisco Webex communications application',
caption: 'Cisco Webex',
width: 1600,
height: 900,
},
],
},
{
id: 'phone-system-migration-review',
title: 'What Should Be Reviewed Before a Phone-System Migration?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Before migrating a business phone system, review the current users, locations, phone numbers, call flows, carrier dependencies, network readiness, emergency-calling requirements, integrations, devices, cutover plan, and support ownership. Problems in any of these areas can cause avoidable disruption even when the new platform itself is working correctly.',
},
{
type: 'h3',
text: 'Queue North implementation sequence',
},
{
type: 'ol',
items: [
'Current-state discovery.',
'User and location inventory.',
'Phone-number and porting review.',
'Call-flow and routing design.',
'Network-readiness review.',
'Platform configuration.',
'Testing and validation.',
'Number-port / cutover planning.',
'User and administrator training.',
'Post-cutover support.',
],
},
],
},
{
id: 'keeping-existing-phone-numbers',
title: 'Can Existing Business Phone Numbers Be Kept?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'In many migrations, existing business telephone numbers can be ported to the new service. Portability depends on the current carrier, number eligibility, account information, service records, and other carrier requirements.',
},
{
type: 'p',
text: 'Queue North can include number inventory, portability review, documentation, port scheduling, and cutover planning as part of an approved migration.',
},
],
},
{
id: 'multiple-locations-and-hybrid-teams',
title: 'Unified Communications for Multiple Locations and Hybrid Teams',
kind: undefined,
blocks: [
{
type: 'p',
text: 'Queue North can design communications environments for:',
},
{
type: 'ul',
items: [
'Multiple offices.',
'Remote employees.',
'Hybrid teams.',
'Centralized reception.',
'Shared call handling.',
'Location-specific business hours.',
'Department call queues.',
'Mobile and desktop users.',
],
},
{
type: 'p',
text: 'The objective is consistent communications without forcing every location to operate as an isolated phone system.',
},
],
},
{
id: 'network-readiness',
title: 'A Cloud Phone System Still Depends on the Network',
kind: undefined,
blocks: [
{
type: 'p',
text: [
'A UCaaS platform can only perform as well as the network path supporting it. Before a major migration, the business should review internet connectivity, ',
{ to: '/services/local-networking', text: 'LAN design' },
', ',
{ to: '/services/wireless-access', text: 'Wi-Fi' },
' where voice is used wirelessly, quality of service, firewall requirements, resiliency, and any site-specific dependencies that could affect call quality or availability.',
],
},
],
},
{
id: 'when-to-review-your-phone-system',
title: 'When Should You Review Your Current Phone System?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A review may be worthwhile when:',
},
{
type: 'ul',
items: [
'A contract is approaching renewal.',
'Employees rely on disconnected communications tools.',
'Calls are frequently transferred incorrectly.',
'Customers report difficulty reaching the right person.',
'Missed-call or voicemail complaints are recurring.',
'Adding users or locations is unnecessarily difficult.',
'Support ownership is unclear.',
'The organization is paying for unused licenses or features.',
'The current system does not integrate well with other business applications.',
'A move, acquisition, new location, or major staffing change is approaching.',
],
},
],
},
{
id: 'after-go-live',
title: 'What Happens After the New Phone System Goes Live?',
kind: undefined,
blocks: [
{
type: 'p',
text: 'A phone-system project should not end at cutover.',
},
{
type: 'p',
text: 'Post-deployment work may include:',
},
{
type: 'ul',
items: [
'User issues.',
'Routing corrections.',
'Number and caller-ID validation.',
'Auto-attendant changes.',
'Device problems.',
'Administrator questions.',
'Adoption and training.',
'Platform changes as the business evolves.',
],
},
{
type: 'p',
text: 'Queue North offers ongoing support options so the customer has a defined path for operational help after implementation.',
},
],
},
{
id: 'why-queue-north-technologies',
title: 'Why Queue North Technologies?',
kind: 'list',
blocks: [
{
type: 'ul',
items: [
'8x8 and Cisco expertise.',
'Custom CRM Integration Applications',
'Direct access to technical resources.',
'Migration and implementation experience.',
'Vendor-neutral recommendations.',
'Network and infrastructure knowledge beyond the phone platform.',
'Ongoing managed-support options.',
'Veteran-owned and operated.',
],
},
],
},
{
id: 'faq',
title: 'Frequently Asked Questions',
kind: 'faq',
blocks: [
{ type: 'h3', text: 'What is UCaaS?' },
{
type: 'p',
text: 'UCaaS, or Unified Communications as a Service, combines business calling, messaging, meetings, and collaboration through a cloud-delivered platform.',
},
{ type: 'h3', text: 'What should an 8x8 implementation include?' },
{
type: 'p',
text: 'An implementation may include discovery, user and site planning, number inventory and porting, call-flow design, platform configuration, device planning, network readiness, testing, cutover, training, and post-deployment support.',
},
{ type: 'h3', text: 'Can Queue North migrate existing business phone numbers?' },
{
type: 'p',
text: 'Queue North can plan and coordinate number porting when the numbers are eligible. Portability depends on the current carrier and account details.',
},
{ type: 'h3', text: 'Do all existing phones have to be replaced?' },
{
type: 'p',
text: 'Not necessarily. Device requirements depend on the selected platform, supported hardware, user needs, and the condition of the existing environment.',
},
{ type: 'h3', text: 'Can 8x8 or Webex support multiple offices and remote users?' },
{
type: 'p',
text: 'Yes, both platforms can support distributed users and locations when the deployment is designed around the organization\'s requirements.',
},
{ type: 'h3', text: 'Can a business phone system integrate with Microsoft Teams?' },
{
type: 'p',
text: 'In many environments, yes. The correct architecture depends on the existing Microsoft and communications environment and should be reviewed before implementation.',
},
{ type: 'h3', text: 'Does Queue North provide support after implementation?' },
{
type: 'p',
text: 'Yes. Queue North offers 3 tiers of ongoing support in addition to implementation and migration services.',
},
],
},
],
related: [
{ to: '/services/contact-center', label: 'contact center and CCaaS solutions' },
{ to: '/services/managed-support', label: 'ongoing communications support' },
{ to: '/services/local-networking', label: 'business network design and support' },
{ to: '/services/wireless-access', label: 'business Wi-Fi' },
{ to: '/services/infrastructure-cabling', label: 'structured cabling and infrastructure' },
{ to: '/services/consulting-training', label: 'communications consulting and training' },
{ to: '/contact', label: 'request a communications review' },
],
}

View File

@ -1,23 +1,17 @@
// Services, as data. Two of them carry owner-approved long-form copy in
// src/data/serviceContent/, which the service page renders in place of its short
// layout; the rest keep the short one. See docs/architecture/README.md.
import { page as unifiedCommunicationsPage } from './serviceContent/unified-communications.js'
import { page as contactCenterPage } from './serviceContent/contact-center.js'
export const services = [ export const services = [
{ {
id: 'unified-communications', id: 'unified-communications',
name: 'Unified Communications', name: 'Unified Communications',
shortDesc: 'Modernize your business communications with seamless integration', shortDesc: 'Modernize your business communications with seamless integration',
homeDesc: 'Stop juggling separate phone, video, and messaging systems. One platform, one bill, zero headaches.', homeDesc: 'Stop juggling separate phone, video, and messaging systems. One platform, one bill, zero headaches.',
fullDesc: `Voice, meetings, and messaging that keep your people connected without adding operational friction.
As an 8x8 Certified Partner, we have deep expertise in implementing and supporting 8x8 UCaaS solutions, ensuring our customers get the most value from their investment. Our 8x8 expertise includes VoIP implementation, cloud PBX migration, unified communications deployments, and ongoing system support.
Our solutions include Cisco Webex, Cisco Unified Communications Manager, and 8x8 UCaaS platforms.`,
icon: 'message-circle', icon: 'message-circle',
benefits: [ page: unifiedCommunicationsPage,
'Seamless voice, video, and messaging integration',
'Mobile and desktop app support',
'Persistent chat and file sharing',
'Presence indicators for real-time visibility',
'8x8 UCaaS implementation and migration',
'VoIP and cloud PBX setup',
],
idealFor: [ idealFor: [
'Remote and hybrid teams', 'Remote and hybrid teams',
'Distributed workforces', 'Distributed workforces',
@ -30,21 +24,9 @@ Our solutions include Cisco Webex, Cisco Unified Communications Manager, and 8x8
name: 'Contact Center', name: 'Contact Center',
shortDesc: 'Deliver exceptional customer experiences with modern contact center solutions', shortDesc: 'Deliver exceptional customer experiences with modern contact center solutions',
homeDesc: 'Your customers reach a real person faster. Lower wait times, happier callers, better reviews.', homeDesc: 'Your customers reach a real person faster. Lower wait times, happier callers, better reviews.',
fullDesc: `Customer engagement built with routing, reporting, and workflow control that support real operational performance.
As an 8x8 Certified Partner, we deliver enterprise-grade contact center solutions with 99.999% uptime reliability. Our 8x8 expertise includes contact center setup, omnichannel routing, AI-powered agent assistance, and real-time analytics dashboards.
We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading platforms.`,
icon: 'users', icon: 'users',
image: '/assets/modern-call-center.webp', image: '/assets/modern-call-center.webp',
benefits: [ page: contactCenterPage,
'Omnichannel customer interactions',
'Real-time analytics and reporting',
'AI-powered agent assistance',
'Scalable cloud infrastructure',
'8x8 Contact Center setup and optimization',
'99.999% uptime reliability',
],
idealFor: [ idealFor: [
'Customer service teams', 'Customer service teams',
'B2C businesses with high volume', 'B2C businesses with high volume',
@ -55,6 +37,13 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
{ {
id: 'managed-support', id: 'managed-support',
name: 'Managed Support', name: 'Managed Support',
// Asked for by both approved copy sheets: Unified Communications section 15
// and Contact Center section 18 both list this page as one that should link
// back to them.
related: [
{ to: '/services/unified-communications', label: 'Business phone and UCaaS solutions' },
{ to: '/services/contact-center', label: 'Contact center and CCaaS solutions' },
],
shortDesc: 'Expert IT support with proactive monitoring and rapid response', shortDesc: 'Expert IT support with proactive monitoring and rapid response',
homeDesc: 'Your IT runs itself. 24/7 monitoring catches problems before you even notice them.', homeDesc: 'Your IT runs itself. 24/7 monitoring catches problems before you even notice them.',
fullDesc: 'Consistent support, clear accountability, and lifecycle management that keep your environment stable long after deployment.', fullDesc: 'Consistent support, clear accountability, and lifecycle management that keep your environment stable long after deployment.',
@ -117,7 +106,7 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
id: 'wireless-access', id: 'wireless-access',
name: 'Wireless Access', name: 'Wireless Access',
shortDesc: 'Enterprise-grade Wi-Fi solutions for reliable mobile connectivity', shortDesc: 'Enterprise-grade Wi-Fi solutions for reliable mobile connectivity',
homeDesc: 'Wi-Fi that just works everywhere in your building. No dead zones, no complaints.', homeDesc: 'Wi-Fi that just works, everywhere in your building. No dead zones, no complaints.',
fullDesc: 'Business Wi-Fi designed for usable coverage, dependable performance, and fewer support headaches across your environment.', fullDesc: 'Business Wi-Fi designed for usable coverage, dependable performance, and fewer support headaches across your environment.',
icon: 'wifi', icon: 'wifi',
image: '/assets/wireless.webp', image: '/assets/wireless.webp',

View File

@ -8,6 +8,11 @@ import './index.css'
const AppRoutes = () => useRoutes(routes) const AppRoutes = () => useRoutes(routes)
// Re-exported so the prerenderer can compare what the router declares against
// what it is about to build. A route in one list and not the other is a page
// the server answers with a 404.
export { routes }
/** /**
* Renders a single route to static HTML at build time. * Renders a single route to static HTML at build time.
* @param {string} url route path, e.g. '/about' * @param {string} url route path, e.g. '/about'

View File

@ -103,3 +103,56 @@ a:hover {
outline-offset: 2px; outline-offset: 2px;
} }
} }
@layer components {
/*
* A standalone text link is 17 to 20px tall. A finger needs about 32, and the
* device sweep found 491 links under that across the site: every footer
* column, the privacy contents, the related-services lists, the back links.
*
* This grows the hit box by 8px above and below and then takes those 8px back
* out of the layout, so the line the link sits on does not move.
*
* **The list it sits in must leave at least 16px between rows.** The negative
* margin does not shrink the box, only its effect on layout, so at the 8px
* spacing these lists used to have, each link's box reached 8px into a gap the
* neighbour was already reaching 8px into. They overlapped, hit-testing gave
* the whole gap to whichever painted last, and getBoundingClientRect still
* read 33px: a checker satisfied by a target that was not really there. Every
* caller therefore pairs this with space-y-4 or gap-y-4, which makes the row
* pitch 33 and the boxes tile exactly.
*
* Not for a link inside a sentence: WCAG 2.5.8 exempts those, and the padding
* would reach into the lines above and below it.
*
* `display` is deliberately inline-block rather than a padding-only rule.
* Vertical padding on an inline box is painted and hit-tested but does not
* enter the line box, so an inline link would report a taller rect while
* overlapping its neighbours. Any caller that needs a different box (the
* "Learn more" links are inline-flex) sets it with a Tailwind utility, which
* wins on layer order.
*/
.tap-target {
display: inline-block;
padding-block: 0.5rem;
margin-block: -0.5rem;
}
/*
* reCAPTCHA v2's checkbox iframe is a fixed 304px and Google does not allow
* it to be resized. Below ~360px that overflows the viewport and gets sliced
* off by the overflow-x:hidden above taking the branding and the privacy
* and terms links with it. Scaling is the only lever available.
*
* The height is reduced by the same factor because transform does not affect
* layout: without it the wrapper would keep reserving the full-size box and
* the form would gain a strip of dead space under the widget. See #215.
*/
@media (max-width: 360px) {
.recaptcha-scale {
transform: scale(0.85);
transform-origin: 0 0;
height: 66px; /* 78px widget * 0.85, so the box matches what is drawn */
}
}
}

View File

@ -13,13 +13,35 @@ export const websiteLd = {
const MAX_DESCRIPTION = 158 const MAX_DESCRIPTION = 158
// A fragment that already ends a sentence keeps its punctuation, including when
// it closes with a quote or a bracket. One that does not gets a full stop.
const asSentence = (text) => (/[.!?…]["'”’)\]]?$/.test(text) ? text : `${text}.`)
/** /**
* Joins description fragments and trims to what search engines actually display, * The description a page emits, from one of two sources.
* cutting on a word boundary rather than mid-word. *
* @param {...string} parts * `approved` is owner-approved text. It is emitted exactly as written: never
* joined to anything, never clamped. Levi's Unified Communications and Contact
* Center descriptions are 164 and 191 characters, and clamping them would have
* shipped his approved copy cut off mid-sentence with an ellipsis.
*
* `parts` are fragments this site composes itself, and they are joined AS
* SENTENCES. Joining them with a space alone is what produced "...seamless
* integration Delivered by Queue North..." on all eleven detail pages: no
* shortDesc in services.js or industries.js ends in a full stop, and nothing
* added one. The description is the first thing a searcher reads.
*
* @param {{approved?: string, parts?: string[]}} source
*/ */
export const clampDescription = (...parts) => { export const buildDescription = ({ approved, parts = [] }) => {
const text = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() if (approved) return approved
const text = parts
.map((part) => part?.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.map(asSentence)
.join(' ')
if (text.length <= MAX_DESCRIPTION) return text if (text.length <= MAX_DESCRIPTION) return text
const cut = text.slice(0, MAX_DESCRIPTION) const cut = text.slice(0, MAX_DESCRIPTION)

View File

@ -1,4 +1,4 @@
import { StrictMode } from 'react' import { StrictMode, useEffect, useState } from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client' import { createRoot, hydrateRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
@ -7,13 +7,24 @@ import router from './router.jsx'
import App from './App.jsx' import App from './App.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx' import ErrorBoundary from './components/ErrorBoundary.jsx'
// sonner renders a <section> that the prerendered HTML does not contain, since
// the server entry mounts the routes and nothing else. Rendering it on the first
// client pass is therefore a hydration mismatch, and React answers a mismatch by
// discarding the prerendered DOM and re-rendering the page. Mounting it after
// hydration costs nothing: a toast can only ever follow an interaction.
const ToasterAfterHydration = () => {
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
return hydrated ? <Toaster position="top-right" /> : null
}
// Wrap the router with providers // Wrap the router with providers
const Root = () => ( const Root = () => (
<StrictMode> <StrictMode>
<HelmetProvider> <HelmetProvider>
<ErrorBoundary> <ErrorBoundary>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster position="top-right" /> <ToasterAfterHydration />
</ErrorBoundary> </ErrorBoundary>
</HelmetProvider> </HelmetProvider>
</StrictMode> </StrictMode>

View File

@ -16,10 +16,10 @@ const proofPoints = [
{ {
label: 'Cisco Partner', label: 'Cisco Partner',
detail: 'Networking and communications implementation', detail: 'Networking and communications implementation',
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png', logo: '/assets/brand/cisco-partner-logo-white.svg',
logoAlt: 'Cisco Partner certification logo', logoAlt: 'Cisco Partner certification logo',
logoClassName: 'h-full w-full scale-[2]', logoClassName: 'h-full w-full',
containerClass: 'p-1 overflow-hidden', containerClass: 'p-1',
}, },
{ {
label: 'Veteran-Owned Certified', label: 'Veteran-Owned Certified',

View File

@ -180,7 +180,7 @@ const Contact = () => {
'Cisco Certified Partner', 'Cisco Certified Partner',
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>, <div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
'SMB to Enterprise solutions', 'SMB to Enterprise solutions',
'No vendor bias we recommend what fits', 'No vendor bias: we recommend what fits',
] ]
return ( return (

View File

@ -94,7 +94,7 @@ const Home = () => {
<> <>
<SEO <SEO
title="Business Phone, UCaaS & IT Services | Queue North" title="Business Phone, UCaaS & IT Services | Queue North"
description="Veteran-owned 8x8 and Cisco Certified Partner delivering business phone systems, UCaaS, contact center, IT support, and networking. 25+ years." description="Veteran-owned 8x8 and Cisco Certified Partner delivering business phone systems, UCaaS, contact center, IT support, and networking. 25+ years of industry experience."
url="https://queuenorth.com" url="https://queuenorth.com"
jsonLd={[organizationLd, websiteLd]} jsonLd={[organizationLd, websiteLd]}
/> />
@ -154,11 +154,11 @@ const Home = () => {
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span> <span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span>
</div> </div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start"> <div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1 overflow-hidden"> <span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-2">
<img <img
src="/assets/brand/cisco-partner-logo-midnight.svg" src="/assets/brand/cisco-partner-logo-midnight.svg"
alt="Cisco Partner certification logo" alt="Cisco Partner certification logo"
className="h-full w-full object-contain scale-[1.5]" className="h-full w-full object-contain"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
@ -219,7 +219,7 @@ const Home = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link to={`/services/${service.id}`} className={`inline-flex items-center gap-1 text-sm font-semibold ${accent.link}`} aria-label={`Learn more about ${service.name}`}> <Link to={`/services/${service.id}`} className={`tap-target inline-flex items-center gap-1 text-sm font-semibold ${accent.link}`} aria-label={`Learn more about ${service.name}`}>
Learn more Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" /> <ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
@ -254,7 +254,7 @@ const Home = () => {
</div> </div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Responsiveness</h3> <h3 className="text-lg font-semibold text-primary-navy mb-2">Responsiveness</h3>
<p className="text-sm text-soft-text"> <p className="text-sm text-soft-text">
When you call, a human answers not a ticket queue, not a chatbot. Real support from people who know your system. When you call, a human answers: not a ticket queue, not a chatbot. Real support from people who know your system.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -287,7 +287,7 @@ const Home = () => {
</div> </div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Vendor Neutrality</h3> <h3 className="text-lg font-semibold text-primary-navy mb-2">Vendor Neutrality</h3>
<p className="text-sm text-soft-text"> <p className="text-sm text-soft-text">
As an 8x8 and Cisco Certified Partner, we recommend what works best for you not what pays the highest commission. We've tested the alternatives so you don't have to. As an 8x8 and Cisco Certified Partner, we recommend what works best for you, not what pays the highest commission. We've tested the alternatives so you don't have to.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -317,7 +317,7 @@ const Home = () => {
</div> </div>
<h3 className="text-left text-xl font-semibold text-primary-navy mb-3" aria-label={industry.name}>{industry.name}</h3> <h3 className="text-left text-xl font-semibold text-primary-navy mb-3" aria-label={industry.name}>{industry.name}</h3>
<p className="text-sm text-soft-text mb-4" aria-label={industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}>{industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}</p> <p className="text-sm text-soft-text mb-4" aria-label={industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}>{industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}</p>
<Link to={`/industries/${industry.id}`} className="inline-flex items-center gap-1 text-sm font-semibold text-primary-navy hover:text-primary-blue" aria-label={`Learn more about ${industry.name} industry solutions`}> <Link to={`/industries/${industry.id}`} className="tap-target inline-flex items-center gap-1 text-sm font-semibold text-primary-navy hover:text-primary-blue" aria-label={`Learn more about ${industry.name} industry solutions`}>
Learn more Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" /> <ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link> </Link>

View File

@ -100,7 +100,7 @@ const Industries = () => {
</ul> </ul>
<Link <Link
to={`/industries/${industry.id}`} to={`/industries/${industry.id}`}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors" className="tap-target inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors"
aria-label={`Learn more about ${industry.name} solutions`} aria-label={`Learn more about ${industry.name} solutions`}
> >
See how we help See how we help

View File

@ -1,9 +1,10 @@
import SEO from '@/components/SEO' import SEO from '@/components/SEO'
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo' import { SITE_URL, buildBreadcrumbLd, buildDescription } from '@/lib/seo'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { industries } from '@/data/industries' import { industries } from '@/data/industries'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import RelatedLinks from '@/components/content/RelatedLinks'
import { AlertCircle, ArrowLeft, ArrowRight, Building2, CheckCircle2 } from 'lucide-react' import { AlertCircle, ArrowLeft, ArrowRight, Building2, CheckCircle2 } from 'lucide-react'
const IndustryDetail = () => { const IndustryDetail = () => {
@ -23,7 +24,7 @@ const IndustryDetail = () => {
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Industry Not Found</h1> <h1 className="text-3xl font-bold text-primary-navy mb-4">Industry Not Found</h1>
<p className="text-xl text-soft-text mb-8">The industry you're looking for doesn't exist.</p> <p className="text-xl text-soft-text mb-8">The industry you're looking for doesn't exist.</p>
<Link to="/industries" className="text-primary-navy hover:underline"> <Link to="/industries" className="tap-target text-primary-navy hover:underline">
Back to Industries Back to Industries
</Link> </Link>
</div> </div>
@ -33,11 +34,13 @@ const IndustryDetail = () => {
} }
const industryTitle = `${industry.name} Communications & IT | Queue North` const industryTitle = `${industry.name} Communications & IT | Queue North`
const industryDesc = clampDescription( const industryDesc = buildDescription({
industry.shortDesc, parts: [
`Queue North delivers phone, contact center, network, and IT support for ${industry.name.toLowerCase()} organizations.`, industry.shortDesc,
) `Queue North delivers phone, contact center, network, and IT support for ${industry.name.toLowerCase()} organizations.`,
const industryUrl = `https://queuenorth.com/industries/${industry.id}` ],
})
const industryUrl = `${SITE_URL}/industries/${industry.id}`
const industryBreadcrumbLd = buildBreadcrumbLd([ const industryBreadcrumbLd = buildBreadcrumbLd([
{ name: 'Industries', path: '/industries' }, { name: 'Industries', path: '/industries' },
{ name: industry.name, path: `/industries/${industry.id}` }, { name: industry.name, path: `/industries/${industry.id}` },
@ -131,6 +134,13 @@ const IndustryDetail = () => {
<h3 className="font-semibold text-text mb-2">Industry</h3> <h3 className="font-semibold text-text mb-2">Industry</h3>
<p className="text-soft-text">{industry.name}</p> <p className="text-soft-text">{industry.name}</p>
</div> </div>
<RelatedLinks
links={industry.related}
as="h3"
title="Related services"
className="pt-4 border-t border-border"
headingClassName="font-semibold text-text"
/>
<div className="pt-4 border-t border-border"> <div className="pt-4 border-t border-border">
<Link to="/contact#contact-form" className="flex w-full items-center justify-center gap-2 bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors"> <Link to="/contact#contact-form" className="flex w-full items-center justify-center gap-2 bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
Request Consultation Request Consultation
@ -138,7 +148,7 @@ const IndustryDetail = () => {
</Link> </Link>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<Link to="/industries" className="text-primary-navy hover:underline"> <Link to="/industries" className="tap-target text-primary-navy hover:underline">
Back to Industries Back to Industries
</Link> </Link>
</div> </div>

View File

@ -1,24 +1,26 @@
import SEO from '@/components/SEO' import SEO from '@/components/SEO'
import { buildBreadcrumbLd } from '@/lib/seo' import { buildBreadcrumbLd } from '@/lib/seo'
import { ShieldCheck } from 'lucide-react' import { ShieldCheck } from 'lucide-react'
import { BASE_RENDERERS, ContentSection, LINK_CLASS, Paragraph } from '@/components/content/ContentBlocks'
import { EFFECTIVE_DATE, LAST_UPDATED, PRIVACY_EMAIL, sections } from '@/data/privacyPolicy' import { EFFECTIVE_DATE, LAST_UPDATED, PRIVACY_EMAIL, sections } from '@/data/privacyPolicy'
// This page renders through the shared content renderer, with the two block
// types only a privacy policy has passed in beside the common ones. It is the
// one page an ad platform must be able to read, so its markup is unchanged: the
// renderer was written to emit exactly what this page emitted before.
const EmailLink = ({ className = '' }) => ( const EmailLink = ({ className = '' }) => (
<a <a href={`mailto:${PRIVACY_EMAIL}`} className={`${LINK_CLASS} ${className}`}>
href={`mailto:${PRIVACY_EMAIL}`}
className={`font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors ${className}`}
>
{PRIVACY_EMAIL} {PRIVACY_EMAIL}
</a> </a>
) )
// Renders a paragraph, splitting the privacy address out as a mailto link when present. // A paragraph, with the privacy address split out as a mailto link when the
const Paragraph = ({ text, linkEmail }) => { // block asks for it. Everything else is an ordinary paragraph.
if (!linkEmail || !text.includes(PRIVACY_EMAIL)) { const PolicyParagraph = ({ block }) => {
return <p className="mt-4 text-base leading-relaxed text-soft-text">{text}</p> if (!block.linkEmail || !block.text.includes(PRIVACY_EMAIL)) return <Paragraph block={block} />
}
const [before, after] = text.split(PRIVACY_EMAIL) const [before, after] = block.text.split(PRIVACY_EMAIL)
return ( return (
<p className="mt-4 text-base leading-relaxed text-soft-text"> <p className="mt-4 text-base leading-relaxed text-soft-text">
{before} {before}
@ -28,59 +30,37 @@ const Paragraph = ({ text, linkEmail }) => {
) )
} }
const Block = ({ block }) => { // A paragraph that is nothing but the address is a tap target, unlike the same
switch (block.type) { // link inside the sentences above and below, which WCAG 2.5.8 exempts.
case 'h3': const EmailBlock = () => (
return <h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3> <p className="mt-4 text-base leading-relaxed">
<EmailLink className="tap-target" />
</p>
)
case 'ul': const ContactBlock = () => (
return ( <div className="mt-5 rounded-md border border-border bg-section-alt p-6">
<ul className="mt-4 space-y-2"> <p className="text-base font-semibold text-primary-navy">Queue North Technologies</p>
{block.items.map((item) => ( <p className="mt-2 text-base leading-relaxed text-soft-text">
<li key={item} className="flex gap-3 text-base leading-relaxed text-soft-text"> Email: <EmailLink />
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-cyan" aria-hidden="true" /> </p>
<span>{item}</span> <p className="mt-1 text-base leading-relaxed text-soft-text">
</li> Website:{' '}
))} <a
</ul> href="https://queuenorth.com"
) className="font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors"
>
https://queuenorth.com
</a>
</p>
</div>
)
case 'callout': const POLICY_RENDERERS = {
return ( ...BASE_RENDERERS,
<p className="mt-5 rounded-md border border-border border-l-[3px] border-l-accent-gold bg-section-alt p-5 text-base leading-relaxed text-text"> p: PolicyParagraph,
{block.text} email: EmailBlock,
</p> contactBlock: ContactBlock,
)
case 'email':
return (
<p className="mt-4 text-base leading-relaxed">
<EmailLink />
</p>
)
case 'contactBlock':
return (
<div className="mt-5 rounded-md border border-border bg-section-alt p-6">
<p className="text-base font-semibold text-primary-navy">Queue North Technologies</p>
<p className="mt-2 text-base leading-relaxed text-soft-text">
Email: <EmailLink />
</p>
<p className="mt-1 text-base leading-relaxed text-soft-text">
Website:{' '}
<a
href="https://queuenorth.com"
className="font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors"
>
https://queuenorth.com
</a>
</p>
</div>
)
default:
return <Paragraph text={block.text} linkEmail={block.linkEmail} />
}
} }
const PrivacyPolicy = () => { const PrivacyPolicy = () => {
@ -124,12 +104,12 @@ const PrivacyPolicy = () => {
{/* Contents */} {/* Contents */}
<nav aria-label="Privacy policy contents" className="rounded-md border border-border bg-white p-6 shadow-sm"> <nav aria-label="Privacy policy contents" className="rounded-md border border-border bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Contents</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Contents</h2>
<ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2"> <ol className="mt-4 grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
{numberedSections.map((section) => ( {numberedSections.map((section) => (
<li key={section.id} className="text-sm"> <li key={section.id} className="text-sm">
<a <a
href={`#${section.id}`} href={`#${section.id}`}
className="text-soft-text hover:text-primary-blue transition-colors" className="tap-target text-soft-text hover:text-primary-blue transition-colors"
> >
<span className="font-numeric font-semibold text-primary-navy">{section.number}.</span>{' '} <span className="font-numeric font-semibold text-primary-navy">{section.number}.</span>{' '}
{section.title} {section.title}
@ -140,20 +120,7 @@ const PrivacyPolicy = () => {
</nav> </nav>
{sections.map((section) => ( {sections.map((section) => (
<article key={section.id} id={section.id} className="mt-12 scroll-mt-28"> <ContentSection key={section.id} section={section} renderers={POLICY_RENDERERS} />
<h2 className="text-2xl md:text-3xl font-bold text-primary-navy">
{section.number ? (
<>
<span className="font-numeric">{section.number}.</span> {section.title}
</>
) : (
section.title
)}
</h2>
{section.blocks.map((block, index) => (
<Block key={`${section.id}-${index}`} block={block} />
))}
</article>
))} ))}
</div> </div>
</section> </section>

View File

@ -1,9 +1,11 @@
import SEO from '@/components/SEO' import SEO from '@/components/SEO'
import { buildBreadcrumbLd, clampDescription } from '@/lib/seo' import { SITE_URL, buildBreadcrumbLd, buildDescription } from '@/lib/seo'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { services } from '@/data/services' import { services } from '@/data/services'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import RelatedLinks from '@/components/content/RelatedLinks'
import { ContentSection } from '@/components/content/ContentBlocks'
import { ArrowLeft, ArrowRight, CheckCircle2, Info, Zap } from 'lucide-react' import { ArrowLeft, ArrowRight, CheckCircle2, Info, Zap } from 'lucide-react'
const serviceImageAlt = { const serviceImageAlt = {
@ -29,7 +31,7 @@ const ServiceDetail = () => {
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Service Not Found</h1> <h1 className="text-3xl font-bold text-primary-navy mb-4">Service Not Found</h1>
<p className="text-xl text-soft-text mb-8">The service you're looking for doesn't exist.</p> <p className="text-xl text-soft-text mb-8">The service you're looking for doesn't exist.</p>
<Link to="/services" className="text-primary-navy hover:underline"> <Link to="/services" className="tap-target text-primary-navy hover:underline">
Back to Services Back to Services
</Link> </Link>
</div> </div>
@ -38,12 +40,18 @@ const ServiceDetail = () => {
) )
} }
const serviceTitle = `${service.name} | Queue North` // Owner-approved long-form copy, when this service has any. A service without
const serviceDesc = clampDescription( // it keeps the short layout, so the two shapes coexist page by page.
service.shortDesc, const page = service.page
'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.',
) // A service with owner-approved copy carries its own title and description in
const serviceUrl = `https://queuenorth.com/services/${service.id}` // `page.seo`, and both are emitted verbatim. Everything else composes.
const serviceTitle = service.page?.seo?.title ?? `${service.name} | Queue North`
const serviceDesc = buildDescription({
approved: service.page?.seo?.description,
parts: [service.shortDesc, 'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.'],
})
const serviceUrl = `${SITE_URL}/services/${service.id}`
const serviceBreadcrumbLd = buildBreadcrumbLd([ const serviceBreadcrumbLd = buildBreadcrumbLd([
{ name: 'Services', path: '/services' }, { name: 'Services', path: '/services' },
{ name: service.name, path: `/services/${service.id}` }, { name: service.name, path: `/services/${service.id}` },
@ -52,7 +60,7 @@ const ServiceDetail = () => {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Service', '@type': 'Service',
name: service.name, name: service.name,
description: service.shortDesc, description: service.page?.seo?.description ?? service.shortDesc,
provider: { provider: {
'@type': 'Organization', '@type': 'Organization',
name: 'Queue North Technologies', name: 'Queue North Technologies',
@ -93,13 +101,20 @@ const ServiceDetail = () => {
<ArrowLeft className="h-4 w-4" aria-hidden="true" /> <ArrowLeft className="h-4 w-4" aria-hidden="true" />
Services Services
</Link> </Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1> <h1 className="text-4xl md:text-5xl font-bold mb-6">{page?.hero.h1 ?? service.name}</h1>
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p> <p className="text-xl text-white/75 max-w-3xl leading-relaxed">
<div className="mt-8"> {page?.hero.subheading ?? service.shortDesc}
<Link to="/contact#contact-form" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"> </p>
Request This Service <div className="mt-8 flex flex-wrap gap-3">
<Link to={page?.hero.primaryCta.to ?? '/contact#contact-form'} className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
{page?.hero.primaryCta.label ?? 'Request This Service'}
<ArrowRight className="h-4 w-4" aria-hidden="true" /> <ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
{page?.hero.secondaryCta ? (
<Link to={page.hero.secondaryCta.to} className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
{page.hero.secondaryCta.label}
</Link>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -112,6 +127,25 @@ const ServiceDetail = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Left Column - Main Content */} {/* Left Column - Main Content */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{page ? (
<>
{/* The approved hero copy opens the page, under the hero band
rather than inside it, so the CTAs stay above the fold on a
phone. */}
{page.hero.intro.map((paragraph, index) => (
<p key={index} className="text-lg text-soft-text leading-relaxed mt-4 first:mt-0">
{paragraph}
</p>
))}
{page.sections.map((section) => (
<ContentSection key={section.id} section={section} />
))}
<RelatedLinks links={page.related} className="mt-12" />
</>
) : (
<>
<div className="mb-12"> <div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">What This Solves</h2> <h2 className="text-2xl font-bold text-primary-navy mb-4">What This Solves</h2>
<p className="text-lg text-soft-text mb-6 leading-relaxed"> <p className="text-lg text-soft-text mb-6 leading-relaxed">
@ -142,6 +176,12 @@ const ServiceDetail = () => {
))} ))}
</div> </div>
</div> </div>
{/* Renders nothing for a service with no related links, so the
pages that have none are unchanged. */}
<RelatedLinks links={service.related} className="mb-12" />
</>
)}
</div> </div>
{/* Right Column - Sidebar */} {/* Right Column - Sidebar */}
@ -166,7 +206,7 @@ const ServiceDetail = () => {
</Link> </Link>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<Link to="/services" className="text-primary-navy hover:underline"> <Link to="/services" className="tap-target text-primary-navy hover:underline">
Back to Services Back to Services
</Link> </Link>
</div> </div>

View File

@ -96,7 +96,7 @@ const Services = () => {
Everything your business communications needs. Everything your business communications needs.
</h1> </h1>
<p className="text-lg md:text-xl text-white/75 max-w-2xl leading-relaxed mb-8"> <p className="text-lg md:text-xl text-white/75 max-w-2xl leading-relaxed mb-8">
From phone systems to full network infrastructure designed, deployed, and supported by one accountable team. From phone systems to full network infrastructure: designed, deployed, and supported by one accountable team.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<Link <Link
@ -167,7 +167,7 @@ const Services = () => {
)} )}
<Link <Link
to={`/services/${service.id}`} to={`/services/${service.id}`}
className={`inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`} className={`tap-target inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
aria-label={`Learn more about ${service.name}`} aria-label={`Learn more about ${service.name}`}
> >
Learn more Learn more