Compare commits
25 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
652669920b | |
|
|
0e4718334b | |
|
|
12f1fd54df | |
|
|
ae966e8f56 | |
|
|
8e04201d68 | |
|
|
798575576b | |
|
|
7feb7174d3 | |
|
|
8cc9b4342f | |
|
|
6a455ae7e8 | |
|
|
11992d8d95 | |
|
|
aff80334df | |
|
|
cd0a517d4a | |
|
|
2f1e24892a | |
|
|
7cd556c4b5 | |
|
|
ee5186c1ee | |
|
|
7ec4241f7d | |
|
|
0d575f2977 | |
|
|
a6b87c7123 | |
|
|
b260bca24f | |
|
|
28b07abc28 | |
|
|
fef8b0718c | |
|
|
aab0104b01 | |
|
|
26136f4e5c | |
|
|
7415e190e3 | |
|
|
a25077dea7 |
|
|
@ -26,6 +26,27 @@ db
|
|||
logs
|
||||
*.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)
|
||||
DEVELOPMENT_LOG.md
|
||||
FUTURE.md
|
||||
|
|
|
|||
|
|
@ -132,9 +132,17 @@ else
|
|||
fi
|
||||
|
||||
# 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"
|
||||
say " tree, so they did not verify this commit in isolation."
|
||||
#
|
||||
# Untracked files count here too, and used not to. The build above runs against
|
||||
# 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
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ COPY . .
|
|||
ARG 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
|
||||
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" —
|
||||
# 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.
|
||||
ARG APP_VERSION=0.9.5
|
||||
ARG APP_VERSION=0.9.8
|
||||
LABEL org.opencontainers.image.version="$APP_VERSION" \
|
||||
org.opencontainers.image.title="Queue North Website" \
|
||||
org.opencontainers.image.source="https://dream.scheller.ltd/null/Queue-North-Website"
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -98,10 +98,18 @@ Primary structure:
|
|||
* 404
|
||||
```
|
||||
|
||||
Defined in `src/routes.jsx`. **Every one of them is prerendered to static HTML at
|
||||
build time** by `scripts/prerender.js` — the SPA hydrates on top. `/privacy-policy`
|
||||
additionally has a server-rendered fallback because Meta's crawler does not run
|
||||
JavaScript.
|
||||
Defined in `src/routes.jsx`, and listed for the build in `scripts/lib/routes.js`,
|
||||
which the prerenderer checks against the router so the two cannot drift. **Every
|
||||
one of them is prerendered to static HTML at build time** by
|
||||
`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 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
|
||||
- **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
|
||||
|
||||
Color palette evolution (not rip-and-replace):
|
||||
|
|
|
|||
|
|
@ -246,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;
|
||||
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` |
|
||||
|
|
|
|||
|
|
@ -62,8 +62,15 @@ npm install
|
|||
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/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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -121,7 +135,7 @@ Run from the repository root.
|
|||
| --- | --- |
|
||||
| `npm install` | dependencies |
|
||||
| `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 preview` | serve the built client |
|
||||
| `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
|
||||
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
|
||||
build, a secret scan and a doc-header check rather than pretending those are
|
||||
tests.
|
||||
standing gap — and it is why `npm run release` says out loud what its gate
|
||||
actually is: a build that validates the content layer, an audit of the built
|
||||
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
|
||||
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.
|
||||
`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
|
||||
worth running when the documents change a lot.
|
||||
None of them runs in `verify.sh`. The first is not adopted into `scripts/` at
|
||||
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
|
||||
template, and **exclude `docs/history/`**:
|
||||
|
|
@ -194,10 +209,39 @@ 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
|
||||
requires the guard to go red. This project has three guards, all shell scripts
|
||||
that fail visibly, so §1 of `architecture/GUARDS.md` was performed by hand
|
||||
instead — see `docs/history/DEVELOPMENT_LOG.md` for 2026-08-18.
|
||||
requires the guard to go red. This project has four guards, all shell scripts
|
||||
that fail visibly, so §1 of `architecture/GUARDS.md` is performed by hand
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
> `~/.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
|
||||
> 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
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
A check with a high false-positive rate trains everybody to skip its output,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
Status: Current
|
||||
Owner: _null
|
||||
Last reviewed: 2026-08-18
|
||||
Governs: docs/architecture/**, server/index.js, scripts/** — the module
|
||||
boundaries, the database schema and the API response shapes
|
||||
Governs: docs/architecture/**, server/index.js, scripts/**, src/data/** — the
|
||||
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;
|
||||
any new table or column; any new external service the server
|
||||
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
|
||||
`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
|
||||
to static HTML at build time using `src/entry-server.jsx`. Nothing at request
|
||||
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
|
||||
|
||||
1. **The privacy policy has two renderers, one source.** `src/data/privacyPolicy.js`
|
||||
is the single source of truth; `src/pages/PrivacyPolicy.jsx` renders it in the
|
||||
SPA and the prerender step emits a static copy. This exists because Meta's
|
||||
crawler does not execute JavaScript, and a policy it cannot read is a policy
|
||||
that does not count. **Do not add policy text to a component.**
|
||||
1. **The privacy policy has one source and one renderer, and ships as static
|
||||
HTML.** `src/data/privacyPolicy.js` is the source of truth;
|
||||
`src/pages/PrivacyPolicy.jsx` renders it through the shared
|
||||
`src/components/content/ContentBlocks.jsx`, passing in the two block types
|
||||
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
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `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/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/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/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 |
|
||||
|
|
@ -155,6 +216,7 @@ each row says what it does *here*.
|
|||
|
||||
| `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 |
|
||||
|
||||
**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
|
||||
|
|
|
|||
|
|
@ -692,7 +692,7 @@ Required primitives from `@/components/ui/`:
|
|||
- `Sheet` — mobile navigation
|
||||
- `Input`, `Textarea`, `Select` — forms
|
||||
- `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)
|
||||
- `Toast` / `Toaster` — success/error feedback
|
||||
- `Dialog` — optional modal content
|
||||
|
|
@ -759,6 +759,36 @@ Required primitives from `@/components/ui/`:
|
|||
- Dark navy section
|
||||
- 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
|
||||
|
||||
**Desktop: Two-Column**
|
||||
|
|
@ -796,6 +826,46 @@ Required primitives from `@/components/ui/`:
|
|||
- Mobile section padding: `py-16` (64px)
|
||||
- Desktop section padding: `py-24` (96px)
|
||||
- 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
|
||||
|
||||
|
|
@ -826,6 +896,12 @@ Required primitives from `@/components/ui/`:
|
|||
❌ Gradient overlays on every section
|
||||
❌ Multiple competing typefaces
|
||||
❌ 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,23 @@ This section should appear immediately after hero.
|
|||
|
||||
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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -46,6 +46,231 @@ sequence would have implied more.
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -94,15 +94,23 @@ Observable, in this order:
|
|||
the visitor sees a confirmation either way.
|
||||
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.
|
||||
4. Nothing on the page is a claim the business cannot substantiate. Two open
|
||||
issues say it currently is (#108, #110).
|
||||
4. Nothing on the page is a claim the business cannot substantiate. As of
|
||||
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
|
||||
|
||||
- **Unverifiable marketing claims are live.** A fabricated-looking certification
|
||||
number and an unverified "25+ years" both ship today (#108, #110). This is the
|
||||
only risk here that is a credibility problem rather than an engineering one,
|
||||
and neither can be fixed without the owner.
|
||||
- **Unverifiable marketing claims. Cleared, and worth re-reading before the next
|
||||
copy change.** Both instances that prompted this are resolved: the
|
||||
certification number came off the site in May, and the owner confirmed "25+
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -82,8 +82,9 @@ confidence about *how much*, without measuring.
|
|||
So, before filing a UI defect and before acting on one:
|
||||
|
||||
```bash
|
||||
node scripts/qa-browser.mjs # production, five pages, five widths
|
||||
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
|
||||
|
|
@ -91,6 +92,30 @@ 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
|
||||
|
||||
A finding needs: what was done, what happened, what should have happened, and
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
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
|
||||
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
|
||||
rotating it is the only fix.** Deleting the message does not help and neither
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ is short enough to finish rather than a document to skim.
|
|||
|
||||
## Before a release
|
||||
|
||||
- [ ] `bash scripts/secrets.sh --tracked` is clean — proves nothing credential-shaped is committed
|
||||
- [ ] `npm run build && bash scripts/secrets.sh --built dist/` is clean — proves the *bundle* is clean, which the tracked scan cannot tell you
|
||||
- [ ] `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
|
||||
- [ ] `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
|
||||
- [ ] `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-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-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) |
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0B1B3F" />
|
||||
<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 -->
|
||||
<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:type" content="website" />
|
||||
<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:width" content="1200" />
|
||||
<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 -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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: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
|
||||
hero image here (scripts/prerender.js). -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.8",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"private": true,
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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"
|
||||
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">
|
||||
.st0{fill:#07182D;}
|
||||
</style>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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"
|
||||
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">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Queue North Technologies",
|
||||
"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": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
# is the container, and the database is elsewhere
|
||||
# NODE_ENV=Production matches no `=== "production"` anywhere, so every
|
||||
# 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
|
||||
# API_URL=https://api.example.com/
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -13,33 +13,21 @@
|
|||
//
|
||||
// Run automatically as part of `npm run build`.
|
||||
|
||||
import { execFileSync } from 'child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
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 { 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 distDir = path.join(__dirname, '../dist')
|
||||
|
||||
const STATIC_ROUTES = [
|
||||
'/',
|
||||
'/about',
|
||||
'/services',
|
||||
'/industries',
|
||||
'/contact',
|
||||
'/support',
|
||||
'/privacy-policy',
|
||||
]
|
||||
|
||||
const routes = [
|
||||
...STATIC_ROUTES,
|
||||
...services.map((s) => `/services/${s.id}`),
|
||||
...industries.map((i) => `/industries/${i.id}`),
|
||||
]
|
||||
// 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.
|
||||
|
||||
// Tags the SEO component owns per-route. They are stripped from the template so
|
||||
// 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
|
||||
// them inline, so the prerenderer performs the same hoist. Leaving them in <body>
|
||||
// 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 { 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 body = html.replace(HOISTABLE_TAGS, '')
|
||||
const svgs = []
|
||||
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
|
||||
// render, so the hint always matches what the page actually paints first.
|
||||
const hoisted = []
|
||||
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
|
||||
.match(/<img[^>]*loading="eager"[^>]*>/)?.[0]
|
||||
.match(/src="([^"]+)"/)?.[1]
|
||||
.match(/<img\b[^>]*\bfetchpriority="high"[^>]*>/i)?.[0]
|
||||
?.match(/\bsrc="([^"]+)"/i)?.[1]
|
||||
|
||||
const head = []
|
||||
if (heroSrc) {
|
||||
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
|
||||
// above isn't duplicated.
|
||||
// React emits its own preload for that hero; drop it so the hint above is not
|
||||
// duplicated.
|
||||
for (const tag of hoisted) {
|
||||
if (/rel="preload"[^>]*as="image"/.test(tag)) continue
|
||||
head.push(tag)
|
||||
|
|
@ -87,8 +125,17 @@ const buildPage = (template, url) => {
|
|||
page = page.replace(pattern, '')
|
||||
}
|
||||
|
||||
page = page.replace('</head>', ` ${head.join('\n ')}\n </head>`)
|
||||
page = page.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
|
||||
page = replaceOnce(page, '</head>', ` ${head.join('\n ')}\n </head>`, url)
|
||||
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
|
||||
}
|
||||
|
|
@ -100,6 +147,42 @@ const outputPathFor = (url) =>
|
|||
|
||||
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 = []
|
||||
for (const url of routes) {
|
||||
const page = buildPage(template, url)
|
||||
|
|
@ -126,36 +209,6 @@ for (const [url, size] of written) {
|
|||
|
||||
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 = {
|
||||
'/': '1.0',
|
||||
'/services': '0.9',
|
||||
|
|
@ -168,15 +221,13 @@ const PRIORITY = {
|
|||
|
||||
const CHANGEFREQ = { '/': 'weekly', '/privacy-policy': 'yearly' }
|
||||
|
||||
const sourcesFor = (url) => {
|
||||
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
|
||||
if (url.startsWith('/services/')) return ['src/pages/ServiceDetail.jsx', 'src/data/services.js']
|
||||
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
|
||||
}
|
||||
// Dates come from git where git exists, and from the map release.sh injects
|
||||
// where it does not. See scripts/lib/routes.js.
|
||||
const lastmodByRoute = lastModByRoute()
|
||||
|
||||
const entries = routes.map((url) => {
|
||||
const loc = url === '/' ? SITE_URL : `${SITE_URL}${url}`
|
||||
const lastmod = lastModifiedFor(sourcesFor(url))
|
||||
const lastmod = lastmodByRoute[url] ?? null
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${loc}</loc>`,
|
||||
|
|
@ -198,4 +249,15 @@ const sitemap = [
|
|||
].join('\n')
|
||||
|
||||
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.',
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
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) => {
|
||||
|
|
@ -70,9 +71,30 @@ const collect = (name, dflt) => {
|
|||
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j])
|
||||
return out.length ? out : dflt
|
||||
}
|
||||
const PATHS = collect('paths', ['/', '/about', '/services', '/contact', '/support'])
|
||||
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".
|
||||
|
|
@ -115,6 +137,14 @@ for (const p of PATHS) {
|
|||
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 })
|
||||
|
|
@ -179,6 +209,7 @@ for (const p of PATHS) {
|
|||
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'}`)
|
||||
|
||||
|
|
@ -196,6 +227,6 @@ if (findings.length) {
|
|||
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 five measurements,')
|
||||
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.')
|
||||
|
|
|
|||
|
|
@ -215,6 +215,18 @@ if ! git diff --quiet || ! git diff --cached --quiet; then
|
|||
cannot tell your work in progress from a release."
|
||||
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) \
|
||||
|| 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 " set version ${next} in ${FILES[*]}"
|
||||
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 sitemap carries dates"
|
||||
say " verify the built bundle contains the reCAPTCHA site key"
|
||||
say " docker push ${IMAGE}:${TAG}"
|
||||
say " git commit -m 'chore(release): ${TAG}' (post-commit then pushes)"
|
||||
|
|
@ -315,9 +328,11 @@ if ! bash scripts/verify.sh; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
say "NOTE: those guards are a build, a secret scan and a doc-header check."
|
||||
say " There is no test suite in this repository, so nothing above"
|
||||
say " exercised a single route, form or API response."
|
||||
say "NOTE: those guards are a build (which validates the content layer), an"
|
||||
say " audit of the built HTML, a secret scan of the tree and the bundle,"
|
||||
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
|
||||
|
|
@ -325,9 +340,21 @@ say " exercised a single route, form or API response."
|
|||
# ---------------------------------------------------------------------------
|
||||
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 \
|
||||
--build-arg "APP_VERSION=${next}" \
|
||||
--build-arg "VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}" \
|
||||
--build-arg "SITEMAP_LASTMOD=${sitemap_lastmod}" \
|
||||
-t "${IMAGE}:${TAG}" . ; then
|
||||
say "build failed. The version bump is in your working tree and NOTHING was"
|
||||
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
|
||||
# somebody editing the Dockerfile's ARG/ENV pair or adding dist to
|
||||
# .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…"
|
||||
|
||||
if ! docker run --rm --entrypoint sh "${IMAGE}:${TAG}" -c \
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
# bash scripts/secrets.sh # staged changes (use in pre-commit)
|
||||
# bash scripts/secrets.sh --tracked # everything tracked, for an audit
|
||||
# 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/
|
||||
#
|
||||
# ## --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
|
||||
# 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
|
||||
|
|
@ -167,10 +176,20 @@ PATTERNS=(
|
|||
'//[^/@[:space:]:"'"'"'{},<>]+:[^/@[:space:]"'"'"'{},<>]+@'
|
||||
'[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+'
|
||||
'\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
|
||||
# matched `access_token = $1` in SQL and `apiKey=` in a property list — three
|
||||
# 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,}'
|
||||
# NAME=value, where NAME is upper-case and contains SECRET, TOKEN, PASS or
|
||||
# API_KEY. Three things about it were learned the hard way:
|
||||
#
|
||||
# - 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-----'
|
||||
'\bghp_[A-Za-z0-9]{20,}' # GitHub
|
||||
'\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack
|
||||
|
|
@ -277,7 +296,30 @@ else
|
|||
done < <(git ls-files)
|
||||
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
|
||||
# "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."
|
||||
exit 2
|
||||
fi
|
||||
|
|
@ -294,6 +336,9 @@ for pattern in "${PATTERNS[@]}"; do
|
|||
done
|
||||
[ -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
|
||||
# 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
|
||||
|
|
@ -307,7 +352,7 @@ for pattern in "${PATTERNS[@]}"; do
|
|||
[ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]"
|
||||
printf ' %.120s…\n' "$masked"
|
||||
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
|
||||
|
||||
# The public-by-design tier. Printed, counted, and deliberately not fatal.
|
||||
|
|
@ -322,7 +367,7 @@ if [ "$MODE" = "built" ]; then
|
|||
fi
|
||||
printf ' %.120s…\n' "$hit"
|
||||
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
|
||||
|
||||
if [ "$noted" -gt 0 ]; then
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
|
|
@ -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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/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
|
||||
# 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
|
||||
# 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.
|
||||
set -uo pipefail
|
||||
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; }
|
||||
bash scripts/secrets.sh --tracked
|
||||
[ -f scripts/secrets.sh ] || { echo "secrets: scripts/secrets.sh is missing, so nothing was scanned." >&2; exit 2; }
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
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 SEO = ({ title, description, url, type = 'website', image = DEFAULT_IMAGE, jsonLd, noindex = false }) => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,23 @@ import { useLocation } from 'react-router-dom'
|
|||
export default function ScrollToTop() {
|
||||
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(() => {
|
||||
if (hash) {
|
||||
const el = document.querySelector(hash)
|
||||
if (hash.length > 1) {
|
||||
let el = null
|
||||
try {
|
||||
el = document.getElementById(decodeURIComponent(hash.slice(1)))
|
||||
} catch {
|
||||
el = null // a fragment that is not valid percent-encoding
|
||||
}
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
</Link>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<Link
|
||||
|
|
@ -79,12 +83,12 @@ const Footer = () => {
|
|||
{/* Quick Links */}
|
||||
<div className="lg:pt-16">
|
||||
<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) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
{link.name}
|
||||
|
|
@ -97,12 +101,12 @@ const Footer = () => {
|
|||
{/* Services */}
|
||||
<div className="lg:pt-16">
|
||||
<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) => (
|
||||
<li key={service.name}>
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
{service.name}
|
||||
|
|
@ -115,12 +119,12 @@ const Footer = () => {
|
|||
{/* Industries */}
|
||||
<div className="lg:pt-16">
|
||||
<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) => (
|
||||
<li key={industry.name}>
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
{industry.name}
|
||||
|
|
@ -166,7 +170,7 @@ const Footer = () => {
|
|||
</p>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
Privacy Policy
|
||||
|
|
|
|||
|
|
@ -68,17 +68,31 @@ const Header = () => {
|
|||
width="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>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/* gap-4 at md, gap-6 from lg. At exactly 768px — iPad portrait — the five
|
||||
gap-6 gaps pushed the Request Consultation CTA 10px past the viewport,
|
||||
where body{overflow-x:hidden} sliced it off with no scrollbar to reveal
|
||||
it. Tightening to gap-4 frees 40px, which keeps the CTA on screen at md
|
||||
rather than deferring it to lg and leaving 768-1023px with none. */}
|
||||
<nav className="hidden md:flex items-center gap-4 lg: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) => {
|
||||
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
|
||||
return (
|
||||
|
|
@ -102,7 +116,7 @@ const Header = () => {
|
|||
to={link.href}
|
||||
onFocus={() => hasDropdown && setOpenDropdown(link.name)}
|
||||
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>
|
||||
|
|
@ -146,17 +160,23 @@ const Header = () => {
|
|||
</nav>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="hidden md:block">
|
||||
<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">
|
||||
{/* shrink-0 and whitespace-nowrap so a row that no longer fits overflows
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="md:hidden">
|
||||
<div className="lg:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<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>
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
export const industries = [
|
||||
{
|
||||
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',
|
||||
shortDesc: 'HIPAA-compliant communications and infrastructure for medical providers',
|
||||
fullDesc: 'Patient experience, scheduling, and staff coordination with a focus on compliance.',
|
||||
|
|
@ -20,6 +24,10 @@ export const industries = [
|
|||
},
|
||||
{
|
||||
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',
|
||||
shortDesc: 'Connect stores, teams, and customers with scalable retail communications',
|
||||
fullDesc: 'Connect stores, back office, and support teams while improving customer interaction.',
|
||||
|
|
@ -39,6 +47,10 @@ export const industries = [
|
|||
},
|
||||
{
|
||||
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',
|
||||
shortDesc: 'Industrial communications for production floors and distributed operations',
|
||||
fullDesc: 'Reliable office-to-plant communications including paging and alerting for production environments.',
|
||||
|
|
@ -58,6 +70,10 @@ export const industries = [
|
|||
},
|
||||
{
|
||||
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',
|
||||
shortDesc: 'Secure, reliable communications for schools and financial institutions',
|
||||
fullDesc: 'Campus communications and customer-facing communications in tightly regulated environments.',
|
||||
|
|
|
|||
|
|
@ -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 organization’s 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 organization’s 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 business’s 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 organization’s 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 platform’s 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 organization’s 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' },
|
||||
],
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
id: 'unified-communications',
|
||||
name: 'Unified Communications',
|
||||
shortDesc: 'Modernize your business communications with seamless integration',
|
||||
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',
|
||||
benefits: [
|
||||
'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',
|
||||
],
|
||||
page: unifiedCommunicationsPage,
|
||||
idealFor: [
|
||||
'Remote and hybrid teams',
|
||||
'Distributed workforces',
|
||||
|
|
@ -30,21 +24,9 @@ Our solutions include Cisco Webex, Cisco Unified Communications Manager, and 8x8
|
|||
name: 'Contact Center',
|
||||
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.',
|
||||
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',
|
||||
image: '/assets/modern-call-center.webp',
|
||||
benefits: [
|
||||
'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',
|
||||
],
|
||||
page: contactCenterPage,
|
||||
idealFor: [
|
||||
'Customer service teams',
|
||||
'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',
|
||||
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',
|
||||
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.',
|
||||
|
|
@ -117,7 +106,7 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
id: 'wireless-access',
|
||||
name: 'Wireless Access',
|
||||
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.',
|
||||
icon: 'wifi',
|
||||
image: '/assets/wireless.webp',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import './index.css'
|
|||
|
||||
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.
|
||||
* @param {string} url route path, e.g. '/about'
|
||||
|
|
|
|||
|
|
@ -105,6 +105,39 @@ a:hover {
|
|||
}
|
||||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -13,13 +13,35 @@ export const websiteLd = {
|
|||
|
||||
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,
|
||||
* cutting on a word boundary rather than mid-word.
|
||||
* @param {...string} parts
|
||||
* The description a page emits, from one of two sources.
|
||||
*
|
||||
* `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) => {
|
||||
const text = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
||||
export const buildDescription = ({ approved, parts = [] }) => {
|
||||
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
|
||||
|
||||
const cut = text.slice(0, MAX_DESCRIPTION)
|
||||
|
|
|
|||
15
src/main.jsx
15
src/main.jsx
|
|
@ -1,4 +1,4 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { StrictMode, useEffect, useState } from 'react'
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
|
|
@ -7,13 +7,24 @@ import router from './router.jsx'
|
|||
import App from './App.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
|
||||
const Root = () => (
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
<ToasterAfterHydration />
|
||||
</ErrorBoundary>
|
||||
</HelmetProvider>
|
||||
</StrictMode>
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ const proofPoints = [
|
|||
{
|
||||
label: 'Cisco Partner',
|
||||
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',
|
||||
logoClassName: 'h-full w-full scale-[2]',
|
||||
containerClass: 'p-1 overflow-hidden',
|
||||
logoClassName: 'h-full w-full',
|
||||
containerClass: 'p-1',
|
||||
},
|
||||
{
|
||||
label: 'Veteran-Owned Certified',
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ const Contact = () => {
|
|||
'Cisco Certified Partner',
|
||||
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
|
||||
'SMB to Enterprise solutions',
|
||||
'No vendor bias — we recommend what fits',
|
||||
'No vendor bias: we recommend what fits',
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ const Home = () => {
|
|||
<>
|
||||
<SEO
|
||||
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"
|
||||
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>
|
||||
</div>
|
||||
<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
|
||||
src="/assets/brand/cisco-partner-logo-midnight.svg"
|
||||
alt="Cisco Partner certification logo"
|
||||
className="h-full w-full object-contain scale-[1.5]"
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
|
@ -219,7 +219,7 @@ const Home = () => {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<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
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
|
|
@ -254,7 +254,7 @@ const Home = () => {
|
|||
</div>
|
||||
<h3 className="text-lg font-semibold text-primary-navy mb-2">Responsiveness</h3>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -287,7 +287,7 @@ const Home = () => {
|
|||
</div>
|
||||
<h3 className="text-lg font-semibold text-primary-navy mb-2">Vendor Neutrality</h3>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -317,7 +317,7 @@ const Home = () => {
|
|||
</div>
|
||||
<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>
|
||||
<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
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const Industries = () => {
|
|||
</ul>
|
||||
<Link
|
||||
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`}
|
||||
>
|
||||
See how we help
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
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 { industries } from '@/data/industries'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import RelatedLinks from '@/components/content/RelatedLinks'
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, Building2, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
const IndustryDetail = () => {
|
||||
|
|
@ -23,7 +24,7 @@ const IndustryDetail = () => {
|
|||
<div className="text-center">
|
||||
<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>
|
||||
<Link to="/industries" className="text-primary-navy hover:underline">
|
||||
<Link to="/industries" className="tap-target text-primary-navy hover:underline">
|
||||
Back to Industries
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -33,11 +34,13 @@ const IndustryDetail = () => {
|
|||
}
|
||||
|
||||
const industryTitle = `${industry.name} Communications & IT | Queue North`
|
||||
const industryDesc = clampDescription(
|
||||
const industryDesc = buildDescription({
|
||||
parts: [
|
||||
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([
|
||||
{ name: 'Industries', path: '/industries' },
|
||||
{ name: industry.name, path: `/industries/${industry.id}` },
|
||||
|
|
@ -131,6 +134,13 @@ const IndustryDetail = () => {
|
|||
<h3 className="font-semibold text-text mb-2">Industry</h3>
|
||||
<p className="text-soft-text">{industry.name}</p>
|
||||
</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">
|
||||
<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
|
||||
|
|
@ -138,7 +148,7 @@ const IndustryDetail = () => {
|
|||
</Link>
|
||||
</div>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import SEO from '@/components/SEO'
|
||||
import { buildBreadcrumbLd } from '@/lib/seo'
|
||||
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'
|
||||
|
||||
// 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 = '' }) => (
|
||||
<a
|
||||
href={`mailto:${PRIVACY_EMAIL}`}
|
||||
className={`font-semibold text-primary-blue underline underline-offset-4 hover:text-primary-navy transition-colors ${className}`}
|
||||
>
|
||||
<a href={`mailto:${PRIVACY_EMAIL}`} className={`${LINK_CLASS} ${className}`}>
|
||||
{PRIVACY_EMAIL}
|
||||
</a>
|
||||
)
|
||||
|
||||
// Renders a paragraph, splitting the privacy address out as a mailto link when present.
|
||||
const Paragraph = ({ text, linkEmail }) => {
|
||||
if (!linkEmail || !text.includes(PRIVACY_EMAIL)) {
|
||||
return <p className="mt-4 text-base leading-relaxed text-soft-text">{text}</p>
|
||||
}
|
||||
// A paragraph, with the privacy address split out as a mailto link when the
|
||||
// block asks for it. Everything else is an ordinary paragraph.
|
||||
const PolicyParagraph = ({ block }) => {
|
||||
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 (
|
||||
<p className="mt-4 text-base leading-relaxed text-soft-text">
|
||||
{before}
|
||||
|
|
@ -28,39 +30,15 @@ const Paragraph = ({ text, linkEmail }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const Block = ({ block }) => {
|
||||
switch (block.type) {
|
||||
case 'h3':
|
||||
return <h3 className="mt-8 text-lg font-semibold text-primary-navy">{block.text}</h3>
|
||||
|
||||
case 'ul':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
|
||||
case 'callout':
|
||||
return (
|
||||
<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">
|
||||
{block.text}
|
||||
</p>
|
||||
)
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
// A paragraph that is nothing but the address is a tap target, unlike the same
|
||||
// link inside the sentences above and below, which WCAG 2.5.8 exempts.
|
||||
const EmailBlock = () => (
|
||||
<p className="mt-4 text-base leading-relaxed">
|
||||
<EmailLink />
|
||||
<EmailLink className="tap-target" />
|
||||
</p>
|
||||
)
|
||||
)
|
||||
|
||||
case 'contactBlock':
|
||||
return (
|
||||
const ContactBlock = () => (
|
||||
<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">
|
||||
|
|
@ -76,11 +54,13 @@ const Block = ({ block }) => {
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
default:
|
||||
return <Paragraph text={block.text} linkEmail={block.linkEmail} />
|
||||
}
|
||||
const POLICY_RENDERERS = {
|
||||
...BASE_RENDERERS,
|
||||
p: PolicyParagraph,
|
||||
email: EmailBlock,
|
||||
contactBlock: ContactBlock,
|
||||
}
|
||||
|
||||
const PrivacyPolicy = () => {
|
||||
|
|
@ -124,12 +104,12 @@ const PrivacyPolicy = () => {
|
|||
{/* Contents */}
|
||||
<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>
|
||||
<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) => (
|
||||
<li key={section.id} className="text-sm">
|
||||
<a
|
||||
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>{' '}
|
||||
{section.title}
|
||||
|
|
@ -140,20 +120,7 @@ const PrivacyPolicy = () => {
|
|||
</nav>
|
||||
|
||||
{sections.map((section) => (
|
||||
<article key={section.id} id={section.id} className="mt-12 scroll-mt-28">
|
||||
<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>
|
||||
<ContentSection key={section.id} section={section} renderers={POLICY_RENDERERS} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 { services } from '@/data/services'
|
||||
import { Link } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
const serviceImageAlt = {
|
||||
|
|
@ -29,7 +31,7 @@ const ServiceDetail = () => {
|
|||
<div className="text-center">
|
||||
<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>
|
||||
<Link to="/services" className="text-primary-navy hover:underline">
|
||||
<Link to="/services" className="tap-target text-primary-navy hover:underline">
|
||||
Back to Services
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -38,12 +40,18 @@ const ServiceDetail = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const serviceTitle = `${service.name} | Queue North`
|
||||
const serviceDesc = clampDescription(
|
||||
service.shortDesc,
|
||||
'Delivered by Queue North, a veteran-owned 8x8 and Cisco Certified Partner.',
|
||||
)
|
||||
const serviceUrl = `https://queuenorth.com/services/${service.id}`
|
||||
// Owner-approved long-form copy, when this service has any. A service without
|
||||
// it keeps the short layout, so the two shapes coexist page by page.
|
||||
const page = service.page
|
||||
|
||||
// A service with owner-approved copy carries its own title and description in
|
||||
// `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([
|
||||
{ name: 'Services', path: '/services' },
|
||||
{ name: service.name, path: `/services/${service.id}` },
|
||||
|
|
@ -52,7 +60,7 @@ const ServiceDetail = () => {
|
|||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: service.name,
|
||||
description: service.shortDesc,
|
||||
description: service.page?.seo?.description ?? service.shortDesc,
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: 'Queue North Technologies',
|
||||
|
|
@ -93,13 +101,20 @@ const ServiceDetail = () => {
|
|||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Services
|
||||
</Link>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1>
|
||||
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p>
|
||||
<div className="mt-8">
|
||||
<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">
|
||||
Request This Service
|
||||
<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">
|
||||
{page?.hero.subheading ?? service.shortDesc}
|
||||
</p>
|
||||
<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" />
|
||||
</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>
|
||||
|
|
@ -112,6 +127,25 @@ const ServiceDetail = () => {
|
|||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Left Column - Main Content */}
|
||||
<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">
|
||||
<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">
|
||||
|
|
@ -142,6 +176,12 @@ const ServiceDetail = () => {
|
|||
))}
|
||||
</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>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
|
|
@ -166,7 +206,7 @@ const ServiceDetail = () => {
|
|||
</Link>
|
||||
</div>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const Services = () => {
|
|||
Everything your business communications needs.
|
||||
</h1>
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link
|
||||
|
|
@ -167,7 +167,7 @@ const Services = () => {
|
|||
)}
|
||||
<Link
|
||||
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}`}
|
||||
>
|
||||
Learn more
|
||||
|
|
|
|||
Loading…
Reference in New Issue