docs: adopt project template, retire the markdown backlog

This repository is the one the template's README describes: 205 issues, zero
milestones, and severity labels spelled "P0 Critical" / "P1 High" / "P2 Medium",
which privacyllc.dev reports as NOT ADOPTED rather than as 87% complete.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ripley 2026-08-18 01:19:02 -05:00
parent 5c04195cb1
commit aa1d805e04
28 changed files with 3400 additions and 423 deletions

18
.gitignore vendored
View File

@ -1,14 +1,12 @@
# Private project/agent docs — never commit # Credentials and the original site drop — never commit, private repo or not.
DEVELOPMENT_LOG.md # zoho.md holds the live reCAPTCHA secret key and the Zoho WebToLead tokens.
PROJECT.md # See docs/security/SECURITY.md.
STRUCTURE.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
.drop/
zoho.md zoho.md
.drop/
# Client correspondence. Its asks are tracked as issues; the letter itself is not
# a doc-tree document.
Levi.md
# Dependencies # Dependencies
node_modules/ node_modules/
@ -20,6 +18,7 @@ dist-ssr/
# Runtime/database artifacts # Runtime/database artifacts
db/*.db db/*.db
db/*.db-* db/*.db-*
test-results/
# Environment/local files # Environment/local files
.env .env
@ -38,6 +37,5 @@ pnpm-debug.log*
.vscode/ .vscode/
.idea/ .idea/
.learnings/ .learnings/
Levi.md
Queue-North-Website.code-workspace Queue-North-Website.code-workspace
Working Site.zip Working Site.zip

248
README.md
View File

@ -1,5 +1,21 @@
# Queue North Website # Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: package.json, src/routes.jsx, Dockerfile, docker-compose.yml — the
project-facing overview, whose stack, route list and Docker story
all describe those files
Review trigger: Any change to the stack, the route list, the Docker or
deployment story, the agent pipeline, or where a document lives
```
The business website for Queue North Technologies — an 8x8 Certified Partner
delivering UCaaS, contact centre, networking and managed support for SMB and
enterprise organisations. Live at
[qn.isnull.dev](https://qn.isnull.dev).
## Objective ## Objective
Queue North Website is the modern rebuild of the Queue North Technologies business website. Queue North Website is the modern rebuild of the Queue North Technologies business website.
@ -65,86 +81,73 @@ Primary structure:
- Dark navy conversion band - Dark navy conversion band
- Consultation-focused message - Consultation-focused message
## Planned Routes ## Routes
```txt ```txt
/ / Home
/about /about About
/services /services Services index
/services/unified-communications /services/:slug 7 service detail pages
/services/contact-center /industries Industries index
/services/managed-support /industries/:slug 4 industry detail pages
/services/consulting-training /contact Contact form → POST /api/leads
/services/infrastructure-cabling /support Support form → POST /api/support
/services/wireless-access /privacy-policy Privacy policy
/services/local-networking * 404
/industries
/industries/healthcare
/industries/retail
/industries/manufacturing
/industries/education-finance
/8x8
/contact
/support
``` ```
## Overhaul Phases 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.
Version numbers correlate directly to the active phase: The standalone `/8x8` route was removed at `0.6.6`; that content now lives inside
the UCaaS and contact-centre service pages.
- **Phase 1 — Stack Scaffold**: `0.1.x` ✅ Complete ## Where the work is
- ~~Vite + React app foundation~~
- ~~Tailwind CSS setup~~
- ~~shadcn/ui-style primitives~~
- ~~React Router~~
- ~~Express backend~~
- ~~better-sqlite3 database~~
- ~~Initial API health/contact/support paths~~
- **Phase 2 — Layout Rebuild**: `0.2.x` ✅ Complete **Open work lives in the issue tracker, not in this file.** Milestones are
- ~~App shell: Header, Footer, layout wrapper, mobile nav~~ batches, issues are deliverables. There used to be a phase checklist here and in
- ~~Route pages fully built and navigable~~ four other documents; by the time it was removed it said *"Phase 5 — In
- ~~Existing business content ported into React~~ Progress"* while the last four commits said *batch 0.9.3*. That is the second
- ~~Repeated service/industry content moved into data files~~ record this convention exists to remove.
- ~~Static hash routing fully replaced by React Router~~
- **Phase 3 — Visual Overhaul**: `0.3.x` ✅ Complete - **What is left, and what is next:** the milestones at
- ~~Modern light-first business design~~ [dream.scheller.ltd/null/Queue-North-Website](https://dream.scheller.ltd/null/Queue-North-Website/milestones)
- ~~Tailwind theme polish~~ - **What is broken:** issues labelled `P0`, `P1`, `P2`
- ~~Typography, spacing, radius, shadows, and responsive rhythm~~ - **What the batches were, before the tracker held them:**
- ~~Refined service/industry cards and CTA sections~~ [docs/history/BATCH_LEDGER.md](docs/history/BATCH_LEDGER.md)
- ~~Mobile-first layout polish~~
- **Phase 4 — Forms + Backend Hardening**: `0.4.x` ✅ Complete The rebuild itself is done: phases 1 through 10 completed between May and August
- ~~Contact and support forms fully wired to Express~~ 2026, from the original static HTML site to the current Vite + React SPA with a
- ~~SQLite persistence verified~~ prerendered build. `docs/history/DEVELOPMENT_LOG.md` has it version by version.
- ~~Client-side validation + Sonner feedback~~
- ~~Server-side validation + input sanitization~~
- ~~Optional Zoho forwarding layer~~
- ~~Rate limiting + security headers + CORS~~
- ~~Backend/API hardening as needed~~
- **Phase 5 — Verification + Redesign**: `0.5.x` 🔄 In Progress ### Where the tracker is
- ~~SPA router fix (BrowserRouter → RouterProvider)~~
- ~~TS generics stripped from .jsx files~~
- ~~Mobile menu Sheet/Dialog fix~~
- ~~DialogTitle accessibility fix~~
- ~~SPA catch-all route for client-side navigation~~
- ~~Image assets copied to public/ (were 404)~~
- ~~Real Queue North logo replacing placeholder~~
- ~~CSP updated for Google Fonts~~
- ~~Hamburger menu + SheetContent CSS fix~~
- ~~tailwindcss-animate installed and configured~~
- Hero section rewrite — B2B clarity, 8x8 partnership prominence
- Trust signals section — metrics, badges, certifications
- Services rewrite — business outcomes over technical jargon
- Why Queue North refinement — concrete differentiators
- Footer + CTA pass — contact paths everywhere
- Remaining P0/P1 audit fixes (Zoho, su-exec, email constraint)
- Accessibility checks
- Final push to `dev` for the completed phase
Patch versions increment for completed task batches after the full pipeline finishes. Dispatch a task batch, run it through the required agents, then push that completed batch once. Example: Docker task batch goes through Neo → Private Hudson → Bishop → Ripley, then pushes as `0.2.1`. Notes/tags should use the version number only. **[dream.scheller.ltd](https://dream.scheller.ltd)** — a Forgejo instance, and
naming it matters. The convention is portable; the *reporting* is not. Every
figure on this project's card at [privacyllc.dev](https://privacyllc.dev) is read
from this Forgejo repository.
Credentials are in `~/.openclaw/docker-registry.env`:
```bash
set -a; . ~/.openclaw/docker-registry.env; set +a
python3 scripts/forgejo-issue.py list
```
Despite its name that token is not registry-scoped — it works across the whole
API, with push and pull. Never print it into a log or a chat.
Two traps that cost an hour each otherwise. **Cloudflare fronts the instance and
1010-blocks clients that do not look like a browser or curl** — Python's `urllib`
among them — so every request needs `User-Agent: curl/8.5.0`;
`scripts/forgejo-issue.py` already sends it and anything new must too. And
**`/issues` returns pull requests too** unless `type=issues` is passed.
**Labels are exactly `P0`, `P1`, `P2`, `release-blocker`.** They are queried by
name. A fifth label, `P3`, exists on 21 closed issues from before adoption and is
frozen — nothing new gets it, because nothing counts it.
## Backend Goals ## Backend Goals
@ -176,9 +179,19 @@ The overhaul is handled through the agent pipeline below:
Agents do not touch git. Ripley owns all commits, tags, and pushes. Agents do not touch git. Ripley owns all commits, tags, and pushes.
This pipeline says **who does which part**. `docs/WORK_CYCLE.md` says **what must
be true before a piece of work counts as finished** — closing the issue with its
evidence, closing the milestone if the batch landed, updating the documents the
change triggered in the same commit, and recording the next action. They are
different questions and both apply.
`.learnings/` holds each agent's accumulated notes and error logs. It is
gitignored and local to this machine.
## Batch Pipeline Rule ## Batch Pipeline Rule
Work is dispatched as task batches. A batch runs through the required agents, then Ripley pushes that completed batch once. Work is dispatched as task batches. A batch runs through the required agents,
then Ripley pushes that completed batch once.
Example Docker batch: Example Docker batch:
@ -186,15 +199,26 @@ Example Docker batch:
Neo → Private Hudson → Bishop → Ripley Neo → Private Hudson → Bishop → Ripley
``` ```
The whole Docker batch is one checkpoint: `0.2.1`. The whole batch is one push, not one push per agent.
Do not increment the patch version for each individual agent inside the same batch. Increment only after the full task batch finishes and is ready to push. **A batch is a milestone in the tracker.** Closing the last issue under it does
not close the milestone, and a complete-but-open milestone reads as a batch still
in progress — closing it is what marks the batch *shipped*, and it moves a figure
on the project card the same day.
Notes, tags, and checkpoint labels should use only the version number, such as `0.2.1`. > **Version numbers no longer encode the phase.** Until 2026-08-18 the rule was
> that the minor version *was* the phase number — Phase 5 releases are `0.5.x`
> with Bishop responsible for keeping `package.json` in agreement with two
> root-level documents — PROJECT.md and HISTORY.md, named here without backticks
> because both were retired on 2026-08-18 and a backticked path is a claim that
> a file exists. It drifted twice; the second time `package.json` said `0.8.3`
> for two and a half months while commits said *batch 0.9.3*. The tracker holds
> the batch now, and
> [docs/history/HISTORY.md](docs/history/HISTORY.md) has the full reasoning.
## Design Direction ## Design Direction
Based on the redesign review (see `review.md`), the site should feel: Based on the redesign review (see [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md)), the site should feel:
- **Modern, clean, stable** — not experimental, not hacker aesthetic - **Modern, clean, stable** — not experimental, not hacker aesthetic
- **Business-first** — B2B UCaaS/IT partner, not a dev portfolio - **Business-first** — B2B UCaaS/IT partner, not a dev portfolio
@ -208,7 +232,7 @@ Color palette evolution (not rip-and-replace):
Reference brands: RingCentral, Cloudflare, Dialpad — modern but enterprise-trustworthy. Reference brands: RingCentral, Cloudflare, Dialpad — modern but enterprise-trustworthy.
See [review.md](./review.md) for the full redesign assessment. See [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md) for the full redesign assessment.
## Docker Deployment ## Docker Deployment
@ -291,3 +315,77 @@ The `.dockerignore` excludes:
- `.git`, logs, private docs - `.git`, logs, private docs
This ensures minimal image size and reproducible builds. This ensures minimal image size and reproducible builds.
## Run Locally
```bash
npm install
git config core.hooksPath .githooks # per clone — see below
npm run dev # Vite on 5173, Express on 3001
```
`npm run build` is the real gate: client bundle, SSR bundle, then prerender.
`docs/TOOLS.md` has every script and which ones can stop you.
### The git hooks
Three hooks live in `.githooks/` because `.git/hooks` is not versioned. **The
`core.hooksPath` line above is per clone**, so every checkout runs it once; an
uninstalled hook fails silently.
| Hook | What it does here |
| --- | --- |
| `pre-commit` | scans the staged diff for credentials, then runs `npm run build` if source is staged |
| `commit-msg` | refuses a message with no conventional type (`feat`, `fix`, `ui`, `docs`, `test`, `refactor`, `security`, `perf`, `chore`) |
| `post-commit` | **pushes to `origin`** |
**`post-commit` pushes.** That is deliberate — work that exists on one laptop is
one disk away from gone — but it has a consequence: whatever documentation was
not in that commit is now behind the code by one push. That is the mechanical
reason documentation edits ride in the same commit as the change that caused
them. `SKIP_GUARDS=1` and `SKIP_PUSH=1` are the escape hatches, both loud.
## Project Docs
`README.md` is the landing page. Detailed procedures belong in `docs/`, and open
work belongs in the tracker.
**[docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) is the entry point** — read it
before trusting anything else in `docs/`.
| Doc | Purpose |
| --- | --- |
| [docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) | Which document owns which answer, and which source wins when records disagree |
| [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md) | What happens at the end of a piece of work, and what privacyllc.dev will and will not update on its own |
| [docs/TOOLS.md](docs/TOOLS.md) | Where the scripts are, which ones can stop you, and this project's npm commands |
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | What is deployed and where, what watches it, and what to run when it is down |
| [docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md) | Scope, audience, and what this project deliberately is not |
| [docs/planning/REQUIREMENTS.md](docs/planning/REQUIREMENTS.md) | Engineering standards. Not changeable without `_null`'s approval |
| [docs/architecture/README.md](docs/architecture/README.md) | Module boundaries, the database schema, every API response shape |
| [docs/architecture/GUARDS.md](docs/architecture/GUARDS.md) | How to write a check that can actually fail |
| [docs/architecture/zoho-setup.md](docs/architecture/zoho-setup.md) | The CRM integration, end to end |
| [docs/design/OVERHAUL_PLAN.md](docs/design/OVERHAUL_PLAN.md) | The visual system: tokens, type scale, spacing, layout blueprints |
| [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md) | Brand positioning and the messaging standard |
| [docs/security/SECURITY.md](docs/security/SECURITY.md) | Threat model, secrets, third parties, what is out of scope |
| [docs/security/SECURITY_CHECKLIST.md](docs/security/SECURITY_CHECKLIST.md) | The list to work through before a release |
| [docs/qa/ClaudeQAPlan.md](docs/qa/ClaudeQAPlan.md) | What a QA round consists of |
| [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) | The QA verdict — quoted verbatim on the project card |
| [docs/qa/ClaudeQACoverage.md](docs/qa/ClaudeQACoverage.md) | What each pass actually reached, and the standing gaps |
| [docs/history/DEVELOPMENT_LOG.md](docs/history/DEVELOPMENT_LOG.md) | Dated, append-only: what happened and when |
| [docs/history/HISTORY.md](docs/history/HISTORY.md) | The narrative: decisions, and what was tried and dropped |
| [docs/history/BATCH_LEDGER.md](docs/history/BATCH_LEDGER.md) | Archived. What the batches were before the tracker held them |
## Agent Notes
- Product truth comes from the code and the tracker before prose.
- **Do not keep a work list in this README**, or in any markdown file. That is
what the tracker is for, and this project has already paid for the lesson.
- Finish with [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md), every time: close the
issues you finished with the evidence that proves it, close the milestone if
the batch landed, update the documents the change triggered **in the same
commit**, then record the next action and any blockers before you stop.
- Nothing on privacyllc.dev writes itself except the tracker counts and the
pushed docs. The summary and next action change only when they are sent; the
milestone and blocker tables there change only when a human edits them.
- Do not claim a feature is built unless you can cite the route, file, or
screenshot that proves it. **There is no test suite to cite.**

207
docs/DOC_TRUST_MAP.md Normal file
View File

@ -0,0 +1,207 @@
# Doc Trust Map — which document to believe
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/**
Review trigger: Any doc added, deleted or moved; any change to which doc owns a subject
Fires on: added, deleted, moved
```
This describes what is **actually** in this repository, written after everything
else was settled. Where it differs from the template it came from, this one is
right about this project.
## Work items are not in this tree
The sequence of work, the open defects, and the things blocking a release all
live in this repository's **issue tracker**, not in `docs/`. Milestones are
batches; issues are deliverables; severity labels are `P0`, `P1`, `P2` and
`release-blocker`.
This section exists to stop the next contributor starting a fresh markdown
to-do list. **It has already happened here.** Before 2026-08-18 this project
kept its work in `FUTURE.md`, a changelog in `HISTORY.md`, an agent log in
`DEVELOPMENT_LOG.md`, and phase checklists in `PROJECT.md`, `STRUCTURE.md` and
`README.md` — six records, beside 205 issues in a tracker that none of them
pointed at. Two of those documents still described the project as being in
"Phase 5" while the code was at `0.9.3`, and nothing anywhere said so.
| Question | Answer lives in |
| --- | --- |
| What are we building, and for whom? | `docs/planning/PROJECT_PLAN.md` |
| What standards does the code have to meet? | `docs/planning/REQUIREMENTS.md` — and they need `_null`'s approval to change |
| What is the sequence of work? | milestones in the tracker |
| What is left in this batch? | open issues under that milestone |
| What is broken right now? | issues labelled `P0` / `P1` / `P2` |
| What makes a release wrong? | issues labelled `release-blocker` |
| What is the next action? | the `nextAction` field on the project at privacyllc.dev — the newest entry in `docs/history/DEVELOPMENT_LOG.md` says what it was *then* |
| What is blocking us? | the tracker, for the work; the blockers table at privacyllc.dev, for the stakeholder-facing version |
| What do I do when a piece of work is finished? | `docs/WORK_CYCLE.md` |
| What is deployed, and what do I do when it breaks? | `docs/OPERATIONS.md` |
| What happened, and when? | `docs/history/DEVELOPMENT_LOG.md` |
| Why is it shaped like this? | `docs/history/HISTORY.md` — decisions, and what was tried and dropped |
| What were the batches, before the tracker? | `docs/history/BATCH_LEDGER.md` — archived, must not be updated |
| Did QA pass, and what does the tester think? | `docs/qa/ClaudeReport.md` |
| What did QA actually reach? | `docs/qa/ClaudeQACoverage.md` — and here, the answer is *nothing yet* |
| How is it built? | `docs/architecture/README.md` |
| How does the CRM integration work? | `docs/architecture/zoho-setup.md` |
| What should it look like? | `docs/design/OVERHAUL_PLAN.md` |
| What should it sound like? | `docs/design/REDESIGN_REVIEW.md` |
| Which script do I run, and can it stop me? | `docs/TOOLS.md` — the signpost; `docs/architecture/README.md` has the table |
| What runs before a commit? | `.githooks/` |
| How do I write a check that will actually catch something? | `docs/architecture/GUARDS.md` |
**Next action and blockers are recorded at the end of every piece of work, not
when somebody asks.** `docs/WORK_CYCLE.md` holds that procedure and the reason
each step is in it.
## Folder layout
| Folder | Contents |
| --- | --- |
| `docs/planning/` | `PROJECT_PLAN` — the vision. `REQUIREMENTS` — the engineering standards. Not the schedule; that is the tracker |
| `docs/qa/` | `ClaudeQAPlan` (playbook), `ClaudeQACoverage` (what each pass reached), `ClaudeReport` (the verdict) |
| `docs/architecture/` | `README` — boundaries, schema, response shapes, the script table. `GUARDS` — how to write a check. `zoho-setup` — the CRM manual |
| `docs/design/` | `OVERHAUL_PLAN` — the visual system. `REDESIGN_REVIEW` — positioning and tone |
| `docs/security/` | `SECURITY` — threat model and third parties. `SECURITY_CHECKLIST` — the pre-release list |
| `docs/history/` | `DEVELOPMENT_LOG` (dated, append-only), `HISTORY` (the narrative), `BATCH_LEDGER` (archived) |
| `docs/data/` | the branding marks privacyllc.dev renders for this project |
| `docs/` root | this map; `WORK_CYCLE`; `TOOLS`; `OPERATIONS` |
`README.md` stays at the repository root; it is the landing page and moving it
breaks that. Everything else lives under `docs/`.
## What this project keeps that the template does not describe
- **`docs/planning/REQUIREMENTS.md`** — engineering standards with a change
policy attached. It is not the plan and not the architecture; it is the set of
rules every agent works to, and it predates the template. Kept whole.
- **`docs/design/OVERHAUL_PLAN.md`** — 25 KB, and **two documents in one file**.
Its design brief onward is the live token and layout authority; its earlier
migration-phase and agent-plan sections describe a rebuild that finished. Its
own front-note says which half is which. Splitting it would have risked losing
prose that nothing else records, so it was not split.
- **`docs/architecture/zoho-setup.md`** — written for a non-technical reader.
Note the trap in its own header: it documents the OAuth path, and production
runs WebToLead.
- **The agent pipeline** — Scarlett, Neo, Private Hudson, Bishop, Ripley — is in
`README.md`, not in this tree, because it is how work is dispatched rather
than a subject anything is authoritative about. `.learnings/` holds four
agents' notes and is gitignored.
## What the template carries that this project deleted
Recorded so a reader can tell a deliberate absence from an oversight.
| Deleted | Why |
| --- | --- |
| `ClaudeQAPlan.md` pass I — money flowing backwards | no money moves through this site. A pass that never applies is noise |
| `SECURITY_CHECKLIST.md` — the authorisation group, and the session-token row | there is no login, no session, no role and no per-user data anywhere in the product |
| `SECURITY.md` — the *(precautionary)* bring-your-own-credential paragraph | **a precautionary rule, dropped deliberately.** It describes holding credentials on behalf of users; there are no users. `PROJECT_PLAN.md` records accounts as out of scope, which is what makes it safe to drop |
Every other *(precautionary)* marker was kept. `OPERATIONS.md` was kept in full —
this is a deployed service with a live origin, a named host and a database.
**Pass H was kept and rewritten** rather than deleted. Its authorisation half
does not apply; its *what a stranger can reach and exhaust* half is the most
exposed surface this project has.
## A fifth severity label exists, and is frozen
`P3` sits on 21 closed issues from before this repository adopted the
convention. It is **not** one of the four names the Command Center queries, so a
defect filed `P3` today would be counted by nothing.
It was kept rather than deleted because removing it would erase the severity
record on those 21 issues, and merged into `P2` would misstate them. **Nothing
new gets it.** New defects use `P0`, `P1`, `P2` or `release-blocker`.
## Source-of-truth ladder
When two sources disagree, believe them in this order:
1. **Verified code behaviour** — read the source. Note that on this project
"run the tests" is not available: there are none
2. **The issue tracker** — for anything about state of work
3. **Specialist docs** — authoritative for their own subject only
4. **History and logs** — a record of *then*, not a description of *now*
A document that describes work as done is describing what was true when it was
written. The tracker is describing now.
## The status header
Every document in this tree opens with one, immediately after its H1 — and so
do the documents at the repository root, which `scripts/doc-triggers.py` reads
alongside this tree. The root is read one level deep only.
```
Status: Current | Draft | Superseded | Archived
Owner: <who maintains this>
Last reviewed: <YYYY-MM-DD>
Governs: <paths or subject this document is authoritative for>
Review trigger: <the change that should send someone back to this file>
Fires on: <optional added, deleted, moved, changed>
```
**Exactly those four status words.** A document whose status falls outside the
list is reported as having an unknown one rather than being quietly accepted.
`Historical` is the word this convention used to have and deliberately dropped —
`Archived` covers it.
**Review trigger** is the line that matters. "Last reviewed" ages on its own and
a reader cannot tell a current document from an abandoned one by looking at it;
a trigger names the event that should bring somebody back. A header carrying
`Status` without `Review trigger` is reported as incomplete — that combination
looks finished and is not.
**Governs** is a comma-separated list, and an entry may explain itself after the
glob with a spaced dash — `docs/data/** — the assets privacyllc.dev renders`.
`doc-triggers.py` cuts the entry there and reads the globs from the left of it.
**Use that form and no other.** A gloss in parentheses, or after a colon, is not
recognised: the whole entry becomes the glob, matches nothing, and the document
is silently never fired.
**Fires on** is optional and only for the case where `Governs:` is much broader
than the trigger. This file is the extreme of that gap — it governs `docs/**`,
the broadest glob here, while its trigger is one of the narrowest. Omit the line
unless it is genuinely needed; absent means fire on every kind.
`scripts/verify.d/30-doc-headers` checks all of this on every `npm run verify`,
which is what stops it being a convention people remember.
## Declaring a document deliberately absent
A repository may decide it will not keep one of these documents in git. Say so
**here**, one line per path, anywhere in this file:
```
Exempt: <the path><why, in a few words>
```
**Write the real path only when you mean it.** Outside a fenced block, an
`Exempt:` line naming a real document is not an example — it is a declaration.
Keep illustrations fenced, and use placeholders anyway, as the form above does.
**This project exempts nothing.** It considered it: the internal history and
planning documents were gitignored with the comment *"never commit"*, and
exempting them was one of the three options on the table. Instead the Forgejo
repository was made **private** on 2026-08-18 and the documents were committed,
because a half-populated doc tree reports to privacyllc.dev as a half-adopted
project and the reason for the secrecy was the audience, not the content.
**`docs/data/` and `docs/data/img/` cannot be exempted.** A declaration naming
either is refused rather than honoured, because the Command Center renders what
is in them.
## One asset is genuinely missing, and that is not an exemption
`docs/data/img/banner.webp` does not exist. `icon.webp` and `logo.webp` do, both
converted from this project's own marks in `assets/`.
No placeholder was generated, deliberately — a placeholder that looks finished
outlives the issue that would have replaced it. It is filed as a `P2` issue
instead, and the project card will render without a banner until somebody draws
one.

191
docs/OPERATIONS.md Normal file
View File

@ -0,0 +1,191 @@
# Operations — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: Dockerfile, docker-compose.yml, scripts/status.sh,
scripts/healthcheck.sh, scripts/backup.sh, scripts/restore-check.sh —
what watches this in production, and what to do when it stops
Review trigger: A new alert or a new place errors are sent; a change to the
backup schedule or the restore procedure; any change to the
deploy host, container or image; any incident whose first ten
minutes were spent working out what to run
```
## Why this exists
Every other document here describes what the project *is*. This one describes
what happens when it stops being that at three in the morning, and it is the
only document written for somebody who is not thinking clearly.
The failure it prevents is specific: **the first ten minutes of an incident
spent working out which host, which container, and which command**, while the
thing everybody actually wants to know — is it down, or is it just unreachable
from here — goes unanswered.
## What is deployed, and where
| | |
| --- | --- |
| Public origin | `https://qn.isnull.dev`, fronted by Cloudflare |
| Host | **nebula** (`192.168.1.11`) — the same box as the Forgejo instance |
| Container | `qn-website-dev` |
| Image | `dream.scheller.ltd/null/queue-north-website:dev` |
| Port | 3001 in the container; the reverse proxy in front owns the public one |
| Database | `/app/db/queuenorth.db`, in the named volume `qn-website-dev_queuenorth-db` |
| Logs volume | `qn-website-dev_queuenorth-logs``/app/logs` |
| Runs as | UID 1001, non-root |
`queuenorth.com` also resolves and returns 200. **It is not this deployment**
it is the customer's own domain and is not served by this container. Do not
diagnose against it.
## Where errors go
**Nowhere. This project has no error tracking.**
Said in one line rather than left blank, because a gap somebody chose is a
different thing from a gap nobody noticed. There is no Sentry project, no DSN and
no alert destination. An unhandled exception is logged to the container's stdout
and takes the process down: `server/index.js` handles `uncaughtException` and
`unhandledRejection` by logging and exiting 1, and Docker's `restart:
unless-stopped` brings it back. **That means a crash loop looks like a running
service to anything that only reads a state.** `scripts/status.sh` reports the
restart count for exactly this reason.
**A liveness tick is not error tracking.** `scripts/healthcheck.sh` answers *is
it up*. Nothing here answers *is it working* — a server returning 500 to every
form submission is up, healthy, and losing every lead.
## What alerts, and to whom
| Signal | Where it goes | Who acts on it |
| --- | --- | --- |
| Container `HEALTHCHECK` fails 3× at 30 s | Docker marks the container unhealthy on nebula | **Nobody, automatically.** It is visible to anyone who looks and notifies no one |
| `scripts/healthcheck.sh` | whoever runs it | not scheduled |
| Everything else | — | — |
**This table is nearly empty and that is the honest state.** Nothing on this
project pages anybody. The site could be down for a day and the first report
would come from the customer.
Two things would change that and neither is filed as work yet: scheduling
`healthcheck.sh` somewhere that can shout, and giving the process somewhere to
send an exception. Recorded here rather than invented into the tracker.
## Backups
| | |
| --- | --- |
| Schedule | **None yet.** `scripts/backup.sh` works and nothing runs it on a timer |
| What is captured | `/app/db/queuenorth.db` — the `leads` and `support_requests` tables. Deliberately not captured: the logs volume, and the environment, which holds the secrets |
| Where it lands | `$HOME/backups/queue-north-website` on the operator's machine, mode 700. **Still one machine** — see below |
| Retention | `BACKUP_KEEP`, default 7 |
| **Last verified restore** | **2026-08-18, by `_null` — 2 tables, 3 rows, restored in under 1 s** |
`scripts/backup.sh` proves a dump is readable before trusting it — it runs
better-sqlite3's online `.backup()` inside the container, copies the result out,
and refuses the run unless `PRAGMA integrity_check` returns `ok` and the table
count is plausible, only then renaming it into place. `scripts/restore-check.sh`
proves it can be restored, which is a different claim: a file that parses is not
a database you can get back.
**A backup nobody has restored is a guess.** The date above is the only line in
this table that says otherwise, and as of 2026-08-18 it says something: a
snapshot was taken from the running container, replayed into a scratch database
from SQL, and counted. It came back.
**Two things that are still true anyway**, and both are tracked in `Batch 15`:
1. **Nothing runs on a schedule.** One backup taken by hand is not a backup
regime. Until a timer exists, the newest dump is as old as the last time
somebody remembered.
2. **The dump lives on one machine, beside nothing.** `backup.sh` says this
itself on every run: *a backup that only exists beside the database it came
from does not survive the disk, the host or the account.* It is currently on
the operator's workstation and nowhere else.
The exposure is worth stating plainly: `/app/db/queuenorth.db` is the only live
copy of every lead and support request the site has ever taken, it lives in one
Docker volume on one host, and it took writes today.
**A number worth knowing before you need it:** the restore took under a second,
because the database is 28 KB and holds three leads. That will stay true for a
long time at this volume — this is a contact form, not a transactional system —
so during an incident, restoring is cheap and there is no reason to hesitate over
it.
`RESTORE_MIN_ROWS` is currently 0, which means the check cannot yet catch the
snapshot-of-an-empty-volume case. Set it to something below the real lead count
once that count is meaningful, and it starts catching the one failure a
structural check never can.
## Rate limits and cost ceilings
*(precautionary — no incident here has yet come from this.)*
| Endpoint or job | What it costs per call | What bounds it |
| --- | --- | --- |
| `POST /api/leads` | one SQLite insert, one reCAPTCHA verify, one Zoho form post | `RATE_LIMIT_PER_MINUTE`, default 5, per IP across all of `/api`. Body capped at 1 MB, request at 30 s |
| `POST /api/support` | the same, plus a Zoho Case when enabled | same limiter |
| Google reCAPTCHA | free at this volume | the same limiter, upstream of it |
| Zoho CRM | free at this volume; the API path has per-org daily credits | fire-and-forget, so exhausting them degrades CRM sync and never the site |
**Where does the spend alert go, and at what number?** Nowhere, and there is no
number. Nothing here bills per call at this volume, which is why that is
tolerable rather than an oversight — but if an LLM, a mail sender or a paid API
is ever added, this table is the first thing that has to change.
## It is down — what now
In this order. Each step is a command that answers one question, and none of them
change anything.
1. **Is it actually down, or unreachable from here?**
`bash scripts/healthcheck.sh` from anywhere, then
`bash scripts/status.sh`. The second distinguishes those two and reports the
restart count. **A climbing restart count with a short uptime is a crash loop**,
not a healthy service, and reads as "running" in every tool that shows only a
state.
2. **What is it saying?** `bash scripts/status.sh --logs 200`.
3. **Is the deployed version the one you think?** `bash scripts/status.sh`
prints the image digest. **`--deployed-version` cannot answer here** — the
image carries no `org.opencontainers.image.version` label, so the script
correctly reports the version as unknown rather than guessing. Compare the
**digest** against the registry instead:
```bash
bash scripts/status.sh | grep digest
```
Adding that label at build time would make this step a one-liner and is worth
doing next time the Dockerfile is touched.
4. **Is it the database?** `/api/health` returns 503 with `db: error` when the
`SELECT 1` fails. That is a real answer and not an outage of the whole site:
the marketing pages keep serving while every form submission is failing.
5. **Did something change recently?** The tracker and `git log` — a deploy, a
config edit, a Cloudflare rule.
6. **Is it Cloudflare rather than us?** Compare a request to the origin on nebula
against one to `qn.isnull.dev`. Cloudflare fronts everything here, including
the Forgejo instance on the same host.
### Rolling forward or back
**This is the gap in this runbook and it is named rather than papered over.**
Publishing is `npm run docker:push`, which builds and pushes
`queue-north-website:dev` to the Forgejo registry. How nebula then *moves* to the
new image — a compose file on the host, a Portainer stack, a watchtower, a manual
`docker pull && docker compose up -d` — is not written down anywhere in this
repository, and was not recoverable from it on 2026-08-18.
Until somebody writes it here, a rollback is: find the previous image digest in
the registry, and do on nebula whatever it is that normally happens. That is not
a procedure. The template's `deploy.py` was deliberately **not** adopted for this
reason — a deploy script pointed at a path nobody has confirmed is worse than no
deploy script.
**Write the incident down afterwards**, in `docs/history/DEVELOPMENT_LOG.md` with
the date, and file what broke as an issue with a severity label. An incident
nobody recorded happens again with the same surprise.

143
docs/TOOLS.md Normal file
View File

@ -0,0 +1,143 @@
# Tools — where the scripts are, and which ones can stop you
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: scripts/**, .githooks/**, package.json — the tooling, and which of
it can stop you
Review trigger: Any script added to, removed from or repurposed in scripts/; any
change to which of them gates; any change to the npm scripts
```
> A signpost, deliberately. Every project that adopts this template has a
> `docs/TOOLS.md`, so "read `docs/TOOLS.md` first" is an instruction that works
> without knowing anything about the project — which is the whole reason this
> file exists at a fixed path.
## The list is not here
**[`architecture/README.md`](architecture/README.md)** holds the table of what
ships in `scripts/` and what each one is. That is the one copy.
A second table here would be two records of one fact, and the other one would
never hear that a script was renamed — the failure `DOC_TRUST_MAP.md` exists to
prevent, applied to the tooling instead of the documents. So this file answers
the questions that table does not, and points at it for everything else.
## If this project has fewer scripts than the template lists
That is the intended state, not a broken copy. The template's `scaffold.sh`
writes the documents and deliberately leaves the scripts behind — *"an
unconfigured `release.sh` landing in every new repository is a loaded gun, not a
head start"* — so they are taken one at a time, having been read.
This project took ten of them on 2026-08-18 and declined the rest. What it
declined, and why, is in `docs/history/DEVELOPMENT_LOG.md` under that date. The
short version: no `release.sh` or `deploy.py` until the roll-forward path to
nebula is written down, and no `controls.sh` until there are backups for it to
report on.
## Which ones can stop you
Not in a table, because the honest answer lives in each script's own header and
would go stale here. The rule that matters:
**Exit code `2` is never a pass.** These scripts distinguish "the check ran and
found nothing" from "the check did not run", because those look identical from
the outside and only one of them is evidence. A CI step or a hook that treats a
`2` as success has quietly turned the check off. Each script states its codes at
the top; read them there.
In this repository specifically, `check-env.sh` and `verify.sh` will both exit 2
if you gut their configuration, and `preflight.sh` exits 2 when the site is
simply unreachable — which is the case you most want to tell apart from a pass.
The hooks are the other place work gets stopped. See below.
## Where to start in a fresh clone
```bash
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
```
Then [`architecture/GUARDS.md`](architecture/GUARDS.md) before you write a check
of your own — how to write one that can actually fail.
## The hooks
Three, in `.githooks/`, because `.git/hooks` is not versioned and a hook living
there protects exactly one clone.
| Hook | What it runs here |
| --- | --- |
| `pre-commit` | `scripts/secrets.sh` on the staged diff, then `npm run build` when source is staged |
| `commit-msg` | refuses a message with no conventional type |
| `post-commit` | **pushes to `origin`** |
`git config core.hooksPath .githooks` is per clone, so every checkout runs it
once. An uninstalled hook fails silently, which is the same class of problem the
hooks exist to prevent.
**Two things worth knowing before you rely on them.**
`pre-commit` is **not** the template's version. That one runs `npx tsc --noEmit`
and `npx vitest run`; this project has neither TypeScript nor a test runner, so
installing it unchanged would have refused every commit. It runs the secret scan
— which is the reason the hook earns its place here at all, given the Zoho form
tokens that reached four commits before anyone noticed — and then `npm run build`
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.
`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
edits in the same commit rather than in a tidy-up afterwards. With this hook
installed, *"I will document it next commit"* means the site has already
published the version without it.
Escape hatches, both loud on purpose: `SKIP_GUARDS=1 git commit …` and
`SKIP_PUSH=1 git commit …`.
## This project's npm scripts
Run from the repository root.
| Command | What it does |
| --- | --- |
| `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: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/` |
| `npm run docker:build` / `docker:run` | build and run the image locally |
| `npm run docker:compose:up` / `:down` / `:logs` | the compose stack |
| `npm run docker:push` | build, tag and push `queue-north-website:dev` to the Forgejo registry |
| `npm run docker:test` | build the image and smoke-test it on 3001 |
**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.
A liveness check by hand, when you want one without the script:
```bash
curl -s https://qn.isnull.dev/api/health # {"status":"ok","db":"ok","timestamp":"…"}
```
## Adding a script
Put it in `scripts/`, give it a header saying what it does and **which incident
motivated it**, state its exit codes, and add a row to
`architecture/README.md`'s table — this file's `Review trigger` fires on exactly
that.
The bar, from the scripts that are already here: **done by hand three times, or
once with a consequence.** A script written before either of those has no failure
to describe in its header, which is the part that stops the next person deleting
it.

277
docs/WORK_CYCLE.md Normal file
View File

@ -0,0 +1,277 @@
# The work cycle — what happens at the end of a piece of work
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: what must be true before a piece of work counts as finished
Review trigger: Any change to what the Command Center reads, or to which of
those channels a person rather than an agent has to write
```
> **This is a procedure, not a status board.** Nothing here records what is open,
> what is next, or what is blocked — those live in the tracker and on
> privacyllc.dev, and a copy of them in this file would be the second disagreeing
> record that `DOC_TRUST_MAP.md` exists to prevent.
## Why this file exists
A piece of work ends in more than one place. The code is committed; the issue
that asked for it is still open; the document the change contradicts still says
the old thing; and the project screen at
[privacyllc.dev](https://privacyllc.dev) still shows last week's next action to
whoever opens it.
None of those catch up on their own. Two of them cannot be caught up later by
anybody but the person who did the work, because by then nobody knows what the
next action was meant to be.
## The cycle
Run all of it, in this order, every time. It is short on purpose.
1. **Close what you finished.** `closes #N` in the commit that does the work, so
the record comes from the thing that happened rather than a date typed
afterwards. If no single commit finished it, close it by hand with the
evidence — a path, a symbol, a test name, or the command that proves it.
*"Done" is not a close.*
2. **File what you found.** A defect noticed on the way past is an issue with a
severity label and the build SHA it was seen at, not a memory. Filing it costs
a minute; the alternative is finding it again from scratch, or shipping it.
3. **Close the milestone if the batch landed.** A milestone with every issue
closed and itself still open reads as a batch still in progress — see
[Open and closed are not bookkeeping](#open-and-closed-are-not-bookkeeping).
4. **Update the documents this change triggered.** Read the `Review trigger`
lines: a new module, a changed data shape, a new migration, a new boundary
something crosses. Those edits go in **the same commit as the code**, for the
reason in [Docs travel with the push](#docs-travel-with-the-push).
5. **Push.** Nothing above is visible off this machine until you do, and step 7
reports on what was pushed.
6. **Write the log entry** in `docs/history/DEVELOPMENT_LOG.md`: what changed,
what it proved, **Next action**, and **Blockers**. Dated, append-only, newest
first.
7. **Tell the Command Center**, which is three calls: reconcile, so it re-reads
the tracker and the pushed docs; `PATCH` the project's summary and next
action, which nothing else writes; and a check-in if what changed is worth a
timestamped note. Commands are [at the bottom](#the-commands).
Steps 6 and 7 are the two that get skipped, and they are the two nobody else can
do afterwards.
## Next action — where it lives
| Copy | Where | What it is |
| --- | --- | --- |
| The current one | `nextAction` on the project, privacyllc.dev | the live answer to "what happens next", shown to whoever opens the project screen |
| The dated one | the newest entry in `DEVELOPMENT_LOG.md` | what the next action was **at that point** — history, not status |
These are not two records of the same thing, and the distinction is worth
holding on to. The field is overwritten every time and always describes now. The
log entry is never edited and describes a moment — which is what makes it safe
to keep, and why an old entry naming a next action that has since been done is
not stale, it is a receipt.
Write the next action as an **action**: the thing a person would start on
Monday, specific enough to begin without asking a question. "Continue the work"
is not one. If the honest answer is that you do not know, that is a real answer —
say what has to be decided and by whom.
**Filing an issue can change what the project card says next, without anyone
choosing that.** The dashboard's next action is the *newest open issue in the
current milestone* — not the most severe one; severity labels have no influence
on it at all. So a routine `P2` filed into the batch you are working in replaces
whatever the card was showing, and it will keep showing that until something
newer arrives. The `nextAction` field is the only way to say something different
on purpose, which is most of why step 7 exists.
`currentSummary`, `nextAction` and `description` are **write-only**: they are
deliberately absent from every API response, because free text can name a
customer or an unannounced product. Only the admin screen shows them back. Never
report them as empty because a `GET` did not return them.
## Blockers — where they live
A blocker is recorded in up to three places, and **an agent can write only the
first and the third**:
| Where | What it holds | Who writes it |
| --- | --- | --- |
| The tracker | the work itself — an issue labelled `release-blocker`, or `P0` when it ships broken | anyone, including an agent |
| The blockers table on privacyllc.dev | the business-facing blocker, with a severity, an owner and a resolution note | **a human, in the admin UI** |
| A check-in's `blockers` field | narrative: what is stuck, said in a timestamped note | anyone, including an agent |
The agent API has no route that creates, edits or resolves a blocker on the
site. Posting a check-in whose `blockers` field says something is stuck records
*narrative* — it does not touch the blockers table, and it does not clear
anything. An agent that reports "blocker filed" after a check-in has told the
truth about the note and a falsehood about the table.
So: **file the issue** — that is the copy the work is actually tracked in — and
when it belongs in front of a stakeholder, say so plainly and let a human enter
it. Before treating a site blocker as resolved, restate its title and status and
get explicit confirmation; the admin UI requires a resolution note that the API
cannot supply.
If the work is blocked and nothing is filed anywhere, the project simply looks
slow.
## Open and closed are not bookkeeping
Every open issue is a denominator, and not in the abstract: the percentage on
the project screen **is** closed issues over all issues in this tracker. Nothing
else produces it. That has three consequences worth stating in full:
- **An issue left open after the work is done** understates the project
permanently, and the understatement compounds — a fortnight of finished work
with unclosed issues reads as a fortnight of no progress.
- **An issue closed without evidence** cannot be reopened with confidence,
because nothing in it says what "fixed" meant. That is why the close comment
carries the path, symbol, test or command.
- **Invented future work** makes every percentage wrong, permanently and in one
direction. Do not pad the tracker. If the real answer is one milestone and
three issues, file exactly that.
Milestones are the same argument at batch scale, and they are counted the same
way: milestones closed over milestones total is the second figure on the project
screen. Closing the last issue under a milestone does not close the milestone,
so a tracker full of complete-but-open batches reports a project as less
finished than it is — and cannot answer "what shipped".
**File every issue into a milestone.** One filed outside still counts against
the headline percentage while being invisible to the milestone figure, which is
how two readings of the same project come to describe different amounts of work.
The site measures the gap rather than ignoring it.
One trap that costs an afternoon, and it is about a *card* rather than a figure:
the Milestones list on privacyllc.dev reads the Command Center's own table,
which only an admin can write. A repository whose milestones are being counted
in the figure above can still show *"No milestones have been added yet"* in that
list. It is not a sync failure and no amount of reconciling changes it.
## Docs travel with the push
The Command Center reads this repository's documents at a commit, and stores the
SHA it read them from. When that SHA falls behind the repository's newest
commit, the docs report is marked **stale** — not wrong, not missing, *stale*,
which is the honest description of a document that was accurate at a commit
nobody is running any more.
Two ways to produce it, and both are ordinary carelessness rather than bad luck:
- **Code pushed, documents not updated.** The report is recomputed at the new
SHA against prose describing the old behaviour. Nothing flags this; the
document is simply confidently wrong now, and its `Last reviewed` line still
looks recent.
- **Documents updated, not pushed.** The site keeps reporting the old ones. A
correction that exists only on your machine has not been made.
Hence step 4's insistence that documentation edits ride in the same commit as
the change that caused them. It is not tidiness — a doc commit that comes "after
this next thing" is the commit that does not get written, and the manual's
migration table in the project this template came from sat six migrations behind
before anybody noticed. Every reader in between trusted it.
## What the Command Center reads, and who writes it
Four independent channels, and **only two of them are automatic**:
| On the project screen | Source | How it gets there |
| --- | --- | --- |
| Headline % — issues closed / all issues | the repository's tracker | automatic — on reconcile, and immediately on a webhook delivery |
| Second figure — milestones closed / all milestones | the repository's **milestones** | same read, same moment |
| Milestone coverage — issues that sit in no milestone | the repository's tracker | same read |
| QA verdict — round, build SHA, overall sentence | `docs/qa/ClaudeReport.md` | push the repository, then reconcile |
| Current summary / Next action | manual fields on the project | `PATCH /agent/projects/queue-north-website` — nothing else writes them |
| The Milestones card, and a typed weighted plan | the Command Center's **own** milestone table | admin UI only |
| Blockers | the Command Center's **own** blockers table | admin UI only |
The first three are why steps 1 and 3 are steps: **closing an issue and closing
a milestone each move a figure a stakeholder can see, that day.** The last two
are lists rather than figures, and they are the ones an agent cannot write — a
repository whose milestones are counted in the second figure can still show
*"No milestones have been added yet"* on the card, because that card reads a
table only an admin fills in.
A repository that has not adopted the four label names is reported as *not
adopted* rather than as zero defects, and one with its tracker switched off is
reported as switched off rather than as an empty backlog. Absence is never
rendered as a measurement — which is exactly why a real backlog nobody filed
looks like nothing at all.
## The commands
The tracker is the Forgejo instance at
**[dream.scheller.ltd](https://dream.scheller.ltd)**, and steps 1 to 3 happen
there. Its credentials, the Cloudflare trap and the reason it has to be that
instance rather than any tracker are in the project README's *Where the tracker
is* — one copy, named once.
**This repository has no test suite**, so "the evidence that proves it" is
usually a file and symbol, a `curl` against the live origin, or what was observed
at a named viewport. Say which; "Done" is not a close.
```bash
set -a; . ~/.openclaw/docker-registry.env; set +a
python3 scripts/forgejo-issue.py list
python3 scripts/forgejo-issue.py close 196 "Fixed in a1b2c3d; src/components/layout/Header.jsx
# no longer overlaps at 320px — checked in DevTools at iPhone SE width."
```
Step 7 talks to the Command Center instead:
```bash
# The token lives in this machine's credential store, never in the repository.
TOKEN="$(grep PRIVACY_LLC_TOKEN ~/.openclaw/credentials/privacyllc.md | cut -d= -f2)"
BASE="https://privacyllc.dev/api/internal/v1"
AUTH="Authorization: Bearer $TOKEN"
```
**Reconcile** — re-read the tracker and the pushed documents. Do this after the
push, not before:
```bash
curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d '{"kind":"reconcile_all"}' "$BASE/agent/jobs"
```
**Summary and next action** — read the project first for its `version`, then
send a flat body carrying that version. A stale version is rejected rather than
silently overwriting somebody else's edit:
```bash
curl -sk -H "$AUTH" "$BASE/agent/projects/queue-north-website" # for version + state
curl -sk -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
-d '{"version":<n>,"health":"on_track",
"currentSummary":"<where the project stands, in a sentence or two>",
"nextAction":"<the next concrete thing, specific enough to start>"}' \
"$BASE/agent/projects/queue-north-website"
```
**A check-in** — a timestamped "what changed", when there is something real to
report. `summary` is required and must say something; the API refuses an empty
"no change" update, deliberately:
```bash
curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d '{"projectId":"queue-north-website","health":"on_track",
"summary":"<what changed>","accomplishments":"<what landed>",
"blockers":"<what is stuck, or omit>","nextActions":"<what is next>"}' \
"$BASE/agent/updates"
```
Health is one of `on_track`, `caution`, `off_track`, `unknown`. Never invent
one, and never report progress the API did not return.
## What this file is not
- **Not the work list.** That is the tracker: milestones are batches, issues are
deliverables.
- **Not the release procedure.** This project has no `release.sh` — publishing
is `npm run docker:push`, and how nebula moves to a new image is not yet
written down. `docs/OPERATIONS.md` records that gap;
`docs/security/SECURITY_CHECKLIST.md` is the list to work through first.
- **Not the QA procedure.** That is `docs/qa/ClaudeQAPlan.md`, which ends in its
own version of step 7.
- **Not a place to record status.** If you are about to add "current state" or a
list of outstanding items below this line, the tracker is where it goes.

142
docs/architecture/GUARDS.md Normal file
View File

@ -0,0 +1,142 @@
# Guards — how to write a check that actually checks
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: scripts/verify.d/**, .githooks/** — structural tests, source-grep
assertions, probes, and any check whose passing is taken as evidence
Review trigger: A guard is found to have been passing while the thing it guards
was broken; a new class of check is added to the suite.
```
A guard that cannot fail is worse than no guard, because it is trusted. Every
rule here was learned by finding one that had been green for months over
something broken.
## 1. Prove the guard fails before you believe it passes
The one discipline that matters most, and it takes thirty seconds:
```bash
cp src/lib/thing.ts /tmp/thing.bak
# break exactly the thing the test protects
sed -i 's/if (body.error)/if (false)/' src/lib/thing.ts
npx vitest run tests/thing.test.ts # expect: exactly one failure
cp /tmp/thing.bak src/lib/thing.ts
npx vitest run tests/thing.test.ts # expect: green again
```
**Exactly one** is the part people skip. If breaking the guard's target fails
three tests, two of them are coincidental and will mask a real regression later.
If it fails none, the guard is decoration — and you have just learned that for
the price of one `sed`.
`scripts/prove-guard.sh` performs exactly this, which removes the two ways it
gets skipped: the restore is a `trap`, so an interrupted run cannot leave the
code broken, and the failure count comes from the runner's own summary rather
than from eyeballing red — one failing test is routinely reported on half a
dozen lines, and counting those calls a clean result six coincidental
failures.
Do this when you write a guard, and again when you change what it guards. A
test written alongside the code it tests has never been observed failing.
## 2. A source-grep guard must tell code from the comment about code
Structural tests that assert a file does *not* contain some pattern will match
the docblock explaining why that pattern is forbidden. So the clearest possible
comment breaks the test, and the obvious fix is to delete the explanation.
Strip comments first:
```ts
const codeOf = (path: string) =>
readFileSync(path, "utf8")
.split("\n")
.filter((line) => !/^\s*(\*|\/\/|\{\/\*)/.test(line))
.join("\n");
expect(codeOf("src/lib/thing.ts")).not.toContain("dangerouslySetInnerHTML");
```
Otherwise the guard quietly punishes documenting the rule it exists to enforce —
which is exactly backwards, because the comment is how the next person learns
the rule at all.
## 3. Pin the behaviour, not the spelling
A guard should fail when the protected behaviour breaks and stay quiet
otherwise. One that asserts on a variable name fails on a rename that changed
nothing.
```ts
// Brittle: breaks when the variable is renamed, while the fallback it protects
// is untouched.
expect(route).toContain("readAsset(project.forgejoRepo");
// Pins the behaviour: the route fetches through the wrapper that tries both
// spellings, and never through the raw reader.
expect(route).toMatch(/readAsset\(\s*\w+,\s*ASSETS\[which\]\s*\)/);
expect(body).not.toContain("readFileBytes(");
```
A guard that fails on changes it does not care about is one people learn to edit
rather than heed, and the edit is usually deletion.
## 4. A negative result is only as good as the probe that produced it
"The check found nothing" and "the check did not run" are different facts, and
they look identical from the outside. Before reporting an absence, prove the
instrument worked:
```bash
# Not this alone — an unreadable file produces the same silence as an unset key
grep -c '^WANTED=' /proc/$PID/environ
# Establish the read succeeded first
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.
## 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,
including on the day it is right.
One written for this template flagged **684 of 1142** candidates on its first
run. That was not 684 findings, it was a broken heuristic — and shipping it
would have taught its readers that the check is noise. Two rounds of narrowing
brought it to 17 of 363, all of them real.
If a new guard's first run is loud, tune it until it is quiet before anybody
relies on it. Report the false-positive rate you settled at, so the next person
knows what silence is worth.
## 6. Guards belong before the artifact exists
A check that runs after publication catches the problem once it is somewhere it
cannot be taken back from: the tag is in the registry, and refusing the commit
afterwards leaves git with no record of it.
Order the gates so the expensive, irreversible step is last — preconditions,
guards, build, verify the built thing is what was asked for, publish, and record
it last of all.
## 7. When the gate finds something that invalidates the operation, stop
Printing a warning and continuing produces the worst outcome available: the bad
thing happens *and* a reassuring summary appears above it.
The question is not how bad the finding is. It is **whether it invalidates what
the operation claims**:
- A release whose test gate skipped half the suite — a release claims to be
tested. **Refuse.**
- A backup written to a group-readable directory — the backup is still a
backup. **Warn.**
Escape hatches are fine, and they have to be asked for by name, never be the
default, and say plainly what is being given up.

182
docs/architecture/README.md Normal file
View File

@ -0,0 +1,182 @@
# Architecture — Queue North Website
```
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
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
```
## The shape of it
One Express process serves everything. There is no separate web server, no
reverse proxy inside the container, and no second runtime.
```text
browser
|
v
Cloudflare ── qn.isnull.dev
|
v
Express (server/index.js, port 3001) ← the only process
|
+--> dist/ prerendered HTML + the React bundle, served static
+--> /api/health liveness, and a real SELECT against SQLite
+--> /api/leads POST → validate → SQLite → (fire and forget) Zoho
+--> /api/support POST → validate → SQLite → (fire and forget) Zoho Cases
|
+--> db/queuenorth.db better-sqlite3, synchronous, single writer
|
+--> Google reCAPTCHA v3 verify, server-side, before any insert
+--> Zoho CRM WebToLead form post, or REST/OAuth as a standby
```
## Module boundaries
**`src/` knows nothing about the database.** It talks to three JSON endpoints
through `src/lib/api.js` and nothing else. There is no ORM in the client, no
shared schema module, and no import that crosses from `src/` into `server/`.
**`server/index.js` knows nothing about React.** It serves `dist/` as static
files and falls through to `dist/index.html` for client routes. The one place
this is not quite true is the privacy policy, below.
**`src/data/*.js` is the content layer.** Services, industries and the privacy
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.
**`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.
### 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.**
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
or a network timeout costs a CRM record and never a lead. `forwardLeadToZoho`
dispatches to `forwardToZohoWebToLead` or `forwardToZoho` on
`ZOHO_FORWARDING_MODE`; both carry a 10 s `AbortController`.
3. **reCAPTCHA is the one thing that runs *before* the insert.** It is the only
check that can reject a submission outright, so it is deliberately the only
external call on the blocking path — with a 5 s timeout, and it fails open when
`RECAPTCHA_ENABLED` is false.
## Data shapes
`server/index.js` owns the schema and applies it at startup via `initSchema()`.
There is no external migration runner; the one migration that exists rebuilds
`leads` to add the `UNIQUE` constraint and is idempotent.
### `leads`
| Column | Type | Notes |
| --- | --- | --- |
| `id` | INTEGER PK AUTOINCREMENT | |
| `company` | TEXT NOT NULL | max 200 after sanitisation |
| `name` | TEXT NOT NULL | max 100. Split on the last space for Zoho's `First_Name` / `Last_Name` |
| `email` | TEXT NOT NULL **UNIQUE** | max 254 (RFC 5321). A duplicate answers **409**, and the Zoho forward is still attempted — the local row existing does not mean the CRM record does |
| `phone` | TEXT | |
| `zip` | TEXT | maps to Zoho `Zip_Code` |
| `message` | TEXT | |
| `service_interest` | TEXT | normalised from empty to NULL. Maps to Zoho `Description`, not a custom field |
| `created_at` | DATETIME | `CURRENT_TIMESTAMP` |
### `support_requests`
| Column | Type | Notes |
| --- | --- | --- |
| `id` | INTEGER PK AUTOINCREMENT | |
| `name`, `company`, `email` | TEXT NOT NULL | **no** UNIQUE — the same customer may raise many tickets |
| `phone` | TEXT | |
| `issue` | TEXT NOT NULL | minimum 10 characters, enforced client and server side |
| `priority` | TEXT | defaults to `medium` |
| `created_at` | DATETIME | `CURRENT_TIMESTAMP` |
**The asymmetry between the two tables is deliberate.** A lead is a person you
want once; a support request is an event that recurs. Adding `UNIQUE` to
`support_requests.email` would silently drop a customer's second ticket.
### Every response shape the API produces
| Status | Body | When |
| --- | --- | --- |
| 200 | the resource, or `{status, db, timestamp}` | success |
| 400 | `{error: 'Validation failed', fields: {…}}` | Zod rejected it |
| 403 | `{error}` | reCAPTCHA below `RECAPTCHA_MIN_SCORE` |
| 404 | `{error: 'Not found'}` | unmatched `/api/*` only; other paths fall through to the SPA |
| 409 | `{error}` | duplicate `leads.email` |
| 413 | — | body over 1 MB |
| 429 | `{error, message, retryAfter}` | rate limiter |
| 500 | `{error}` | never a stack trace |
| 503 | `{error, db: 'error'}` | health check could not reach SQLite |
| 504 | `{error: 'Request timeout'}` | the 30 s request timeout fired |
## Documents here
- **`GUARDS.md`** — how to write a check that actually checks. Read it before
adding a structural test or a probe; every rule in it was learned from a guard
that had been green over something broken.
- **`zoho-setup.md`** — the CRM integration end to end: app setup, credentials,
environment variables, and how to confirm a lead arrived.
## What ships in `scripts/`
Ten scripts came from the template on 2026-08-18 and three were already here.
The template's full catalogue is a **menu**, not an inventory — see
[`../TOOLS.md`](../TOOLS.md). This table is what this project actually has, and
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 |
| `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/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 `qn.isnull.dev`, asserting HTTP 200 **and** `"status":"ok"` — a 503 with a JSON body is a real answer, not an outage |
| `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts |
| `scripts/backup.sh` | a verified SQLite dump. Its ENGINE block was rewritten for better-sqlite3's online `.backup()` — see below |
| `scripts/restore-check.sh` | restores the newest dump into a scratch file, runs `PRAGMA integrity_check`, counts tables, and **times it**. A backup nobody has restored is a guess |
| `scripts/docker-push.sh` | builds and pushes `queue-north-website:dev` to the Forgejo registry. Predates the template |
| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |
**Why `backup.sh` and `restore-check.sh` are not the template's originals.** Both
ship as PostgreSQL tools. `backup.sh` is built to be adapted — everything
engine-specific is in one ENGINE block — so that block now calls
`better-sqlite3`'s `.backup()` inside the running container and verifies the
result with `sqlite3` before renaming it into place. `restore-check.sh` had no
such seam: it is `pg_restore` and `psql` end to end, so the SQLite version is a
rewrite that keeps the argument and replaces the mechanism.
## What does not belong here
- Product intent — that is `docs/planning/PROJECT_PLAN.md`
- Engineering standards and the stack policy — that is `docs/planning/REQUIREMENTS.md`
- What it should look and sound like — that is `docs/design/`
- What happened while building it — that is `docs/history/`
## A note on drift
Architecture docs go stale faster than any other kind, because code changes under
them silently. This is exactly what the **Review trigger** line is for: name the
change that should send somebody back here, and a reader can tell whether the
trigger has fired.
The specific instance to avoid in this repository: `BUILD_SUMMARY.md` carried a
copy of the SQL schema that predated the `UNIQUE` constraint on `leads.email`.
It was not carried forward on adoption. **`server/index.js` owns the schema; the
tables above describe it and do not duplicate it.**

View File

@ -1,5 +1,22 @@
# Zoho CRM Setup Guide for Queue North Admins # Zoho CRM Setup Guide for Queue North Admins
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: server/index.js, .env.example — the Zoho CRM integration: app setup,
OAuth credentials, environment variables, and how to test that leads
arrive
Review trigger: Any change to ZOHO_* environment variables, the forwarding mode,
the field mapping, or the datacenter; any Zoho API version change
```
> **Read the mode first.** This guide documents the OAuth/REST path. Production
> currently runs `ZOHO_FORWARDING_MODE=webtolead`, which needs none of the OAuth
> setup below — the REST path is kept as a configured standby. `.env.example`
> shows both. The reasoning behind the current shape is in
> `docs/history/BATCH_LEDGER.md`.
This guide walks you through the current Zoho CRM integration. Contact leads use the legacy Zoho WebToLead form tokens, while the OAuth/API integration remains available as a standby option for future lead upserts or support cases. This guide walks you through the current Zoho CRM integration. Contact leads use the legacy Zoho WebToLead form tokens, while the OAuth/API integration remains available as a standby option for future lead upserts or support cases.
--- ---
@ -158,7 +175,10 @@ If your Zoho datacenter is **outside the US**, adjust the domains:
### Field Mismatches ### Field Mismatches
- If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest` - If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest`
- Edit the field mapping in `server/zoho/` to match your Zoho CRM field API names - Edit the field mapping in `server/index.js``forwardToZoho()` builds the
`First_Name` / `Last_Name` / `Lead_Source` payload, and
`forwardToZohoWebToLead()` builds the form fields — to match your Zoho CRM
field API names
### Cases Not Appearing ### Cases Not Appearing
- Ensure `ZOHO_CASES_ENABLED=true` is set - Ensure `ZOHO_CASES_ENABLED=true` is set

99
docs/data/README.md Normal file
View File

@ -0,0 +1,99 @@
# Data — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/data/** — the assets privacyllc.dev renders for this project
Review trigger: A rebrand, or any change to the icon, logo or banner
```
## What goes here
Three files, in `img/`, at exactly these names:
```text
docs/data/img/icon.webp the square mark, used wherever the project is listed
docs/data/img/logo.webp the full lockup, used on the project page
docs/data/img/banner.webp the wide image, used across the project header
```
All **webp**. All **required**. Only `img/` is checked — an asset left in
`docs/data/` instead of `docs/data/img/` is not found.
**Dimensions, weights and how to generate them are in
[`img/README.md`](img/README.md)**, beside the files they describe. This
document owns the rule; that one owns the spec. Stating both in both places is
how two copies of one convention start disagreeing.
**No placeholders ship with this template, deliberately.** It carried a 0-byte
`logo.webp` once, and an empty file is the worst of the three states: a check
that asks "does the path exist" calls it present, and anything that reads the
bytes rejects it — a consumer verifying the webp signature answers 415, which
reads as a corrupt asset rather than a missing one. Absent is honest and the
conformance check reports it as absent, which is what gets it filled in.
Extra sizes and variants are welcome beside them — `icon-512.webp`,
`logo-dark.webp` — and are not treated as clutter. Only the three exact names
are checked for.
## What is here now
| File | State | Source |
| --- | --- | --- |
| `img/icon.webp` | **present** — 512 × 512, 28 KB | `assets/icons/logo512.png`, the Queue North compass-star mark |
| `img/logo.webp` | **present** — 1024 × 1024, 41 KB | `assets/logo2.png`, the full lockup |
| `img/banner.webp` | **absent** | no 3:1 source exists — see below |
Both present files were converted from the project's **own** existing marks on
2026-08-18, which is using the art the project already had rather than inventing
a placeholder. The source art lives in `assets/` at the repository root, which is
where this project keeps exported source files; nothing in the build references
that directory, so do not delete it as dead.
`banner.webp` needs 2176 × 725 and nothing in the repository is that shape.
`assets/JointLogoWhite.png` is 4082 × 1066 and close, but it is a *joint* mark
with a partner, and whether that is the right thing on a Queue North project
header is somebody's decision rather than a conversion. Filed as a `P2` issue.
## If an asset is missing, open an issue — do not invent one
An agent cannot draw a logo, and this is the one gap in the whole convention
that cannot be closed by writing a file.
So when an asset is absent, **file an issue** rather than producing something:
title it for the asset, label it `P2`, and end the body with its `Verify:` line
`Verify: docs/data/img/logo.webp exists and the project card renders it.`
**Do not generate a placeholder.** A placeholder that looks deliberate outlives
the issue that would have replaced it: nobody files a ticket against an image
that appears to be finished. An obviously absent asset keeps asking.
## Why this folder is different from every other one here
The Command Center *consumes* these. Every other document in this tree is written
for a person to read; these are fetched and rendered on privacyllc.dev's project
page.
That has one consequence worth stating plainly: **this folder cannot be declared
exempt.** A repository may tell the conformance check that a required document is
deliberately absent — kept out of git on purpose, say — and the check will
believe it. It will not accept that declaration for `docs/data/`, because the
result would be a project card with nothing to show and nothing explaining why,
which is the exact failure the check exists to prevent.
## Why webp and not PNG
One format, checked by its magic bytes rather than its file extension, so the
site can serve it inline with confidence. A file whose first bytes are RIFF/WEBP
cannot be an HTML document or an SVG carrying script, which is what makes it safe
to render directly rather than forcing a download.
Renaming a PNG to `.webp` will not work, and is meant not to.
## This is not an asset library
Screenshots, mockups, reference art and exported source files do not belong here.
They belong wherever the project already keeps them. This folder holds the three
marks that identify the project elsewhere, and stays small enough that its
contents are obvious at a glance.

57
docs/data/img/README.md Normal file
View File

@ -0,0 +1,57 @@
# Project images
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/data/img/** — the three files and their sizes
Review trigger: A rebrand; any change to a required name, dimension or ceiling;
any change to what the consumer accepts.
```
Three files, all **webp**, all **required**, at exactly these names:
| File | Dimensions | Aspect | Typical weight |
| --- | --- | --- | --- |
| `icon.webp` | **512 × 512** | 1:1 | 860 KB |
| `logo.webp` | **1024** on the long edge | whatever the lockup is | 20190 KB |
| `banner.webp` | **2176 × 725** | 3:1 | 30130 KB |
**Not `icon.ico`.** The consumer checks the file's magic bytes, not its name: a
`.ico`, or a PNG renamed to `.webp`, is refused with a 415 and the project falls
back to an initials tile. That signature check is what makes it safe to render
these inline, so it is not going to be relaxed.
**512 KB is a hard ceiling per file**, enforced in code — the size is read from
the listing before the bytes are fetched, so an oversized asset is never
downloaded and simply never appears. Nothing enforces the dimensions, which is
why they are written down.
## Making them
```bash
magick logo-source.png -resize 512x512 -quality 82 icon.webp
identify -format '%f %wx%h %b\n' *.webp # check before committing
```
Quality 8085 suits a flat mark. If a file lands over ~200 KB it is usually a
photographic banner that wants a lower quality rather than fewer pixels.
## Why these numbers
The icon renders small — a 44 px tile in a list, 58 px on a project header — so
512 covers the densest display several times over; the reference project
deliberately halved it from 1024. The banner spans a card about 760 px wide, so
~2176 covers it at 2×, and its 3:1 shape matters more than its width because the
header crops to fill. The logo has no fixed frame, so only its long edge is
specified.
Extra sizes and variants are welcome beside these — `icon-512.webp`,
`logo-dark.webp`. Only the three exact names are checked for.
No placeholders ship with this template. An empty file is the worst of the three
states: a check that asks whether the path exists calls it present, and anything
reading the bytes rejects it. Absent is honest, and the conformance check reports
it as absent — which is what gets it filled in.
Why the requirement exists, and what reads it: [`../README.md`](../README.md).

BIN
docs/data/img/icon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/data/img/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,5 +1,27 @@
# Queue North Website — 2026 Overhaul Plan # Queue North Website — 2026 Overhaul Plan
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: tailwind.config.js, src/index.css — the visual system: palette,
typography, spacing, radius and shadow, component choices, and the
layout blueprints for each page type
Review trigger: Any change to a Tailwind theme token, the type scale, the
spacing rhythm, or the layout of a page type; any new page type
```
> **Two halves, and only one is current.** Its *Scarlett Design Implementation
> Brief* onward — Tailwind theme tokens, the numeric-typography rule, the type
> scale, section spacing, radius and shadow rules, the component list, the page
> blueprints and the explicit anti-patterns — is the **live design authority** for
> this project and should be trusted as written.
>
> Its earlier *Migration Phases* and *Agent Plan* sections describe a rebuild that
> completed at `v0.9.x`. They are kept as the record of what was planned and are
> **history, not instructions**. The narrative of how it actually went is in
> `docs/history/BATCH_LEDGER.md`; open work is in the tracker.
## TL;DR ## TL;DR
Rebuild the current static HTML/CSS/JS website into a modern full-stack app using: Rebuild the current static HTML/CSS/JS website into a modern full-stack app using:
@ -23,7 +45,10 @@ The current project is a static site with:
- `index.html` — all pages live in one large file - `index.html` — all pages live in one large file
- `styles.css` — large hand-written stylesheet with many page-specific overrides - `styles.css` — large hand-written stylesheet with many page-specific overrides
- `main.js` — manual hash-based routing and interactions - main.js — manual hash-based routing and interactions. Named without backticks
because this section describes the site as it was BEFORE the rebuild; that
file was deleted on 2026-08-18 and a backticked path would claim it still
exists. The original is preserved in `.drop/`
- Inline Zoho webform scripts - Inline Zoho webform scripts
- Static assets in `assets/` - Static assets in `assets/`

48
docs/design/README.md Normal file
View File

@ -0,0 +1,48 @@
# Design — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/design/**
Review trigger: Any new user-facing surface, or a change to the product's tone
```
## Documents here
Two, and they answer different questions. Both predate the template and both are
this project's own writing.
| Document | Owns |
| --- | --- |
| [`OVERHAUL_PLAN.md`](OVERHAUL_PLAN.md) | **what it looks like** — Tailwind theme tokens, the numeric-typography rule (Georgia for all numeric content), the type scale, section spacing and container sizes, radius/shadow/border rules, which shadcn-style primitives to use, the per-page layout blueprints, and the explicit anti-patterns. Its earlier *Migration Phases* and *Agent Plan* sections are history; the brief onward is live |
| [`REDESIGN_REVIEW.md`](REDESIGN_REVIEW.md) | **what it sounds like** — the B2B positioning argument, the messaging shift from "interesting technical person" to "reliable communications and IT partner", and the anti-patterns of tone. The eleven open content issues in `Batch 12` are defects measured against this |
The single most load-bearing sentence across both: *business buyers purchase risk
reduction, not technology.* Copy that reads as clever costs trust here in a way
it would not on a developer-facing product.
## What belongs here
What it should feel like, and the decisions behind that:
- **Product decisions** — what the user can do, in what order, and what happens
when they get it wrong. The error states are design, not an afterthought.
- **UI plans** — screens, states, and what each one is for. Include the empty
state and the loading state; they are the two most people see first and the
two most often left undesigned.
- **Copy** — the actual words. Interface text is a design surface, and writing
it late means writing it badly.
- **Tone** — how this product talks. One paragraph is enough, and it settles a
hundred small arguments.
## What does not belong here
- How it is built — that is `docs/architecture/`
- Scope and audience — that is `docs/planning/PROJECT_PLAN.md`
## Include the rejected version
For any decision that was genuinely close, record what was not chosen and why.
Without it, the same option gets proposed every few months and re-argued from
nothing.

View File

@ -1,5 +1,22 @@
# Queue North Website Redesign Strategy # Queue North Website Redesign Strategy
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: brand positioning, tone, and the messaging standard every page is
written against
Review trigger: Any change to how Queue North positions itself, or to the
audience the site is written for
```
> The positioning argument behind the rebuild, written before it started. It is
> still the standard: the open content issues in `Batch 12 — Enterprise content
> rewrite` (#199#209) are all defects measured against this document.
>
> `OVERHAUL_PLAN.md` says what the site should look like. This says what it
> should *sound* like, and why the previous one lost trust in seconds.
# Core Problem # Core Problem
Current website branding feels: Current website branding feels:

View File

@ -0,0 +1,158 @@
# Batch ledger — Queue North Website
```
Status: Archived
Owner: _null
Last reviewed: 2026-08-18
Governs: what the batches were, before the tracker held them
Review trigger: Nothing. Superseded by the tracker; kept for the record.
```
## Why this file is archived rather than deleted
Planning used to live in markdown as a numbered batch list. It now lives in the
tracker: **milestones are batches, issues are deliverables**, and severity is
`P0` / `P1` / `P2` / `release-blocker`.
This file is what that list *was*. It is kept because the reasoning in it is
still worth reading, and deleted files are not readable.
**It must not be updated.** A batch list beside the tracker is a second answer to
"what is open", and two records of the same thing will disagree without saying
which is right. That is precisely why the work moved. If you are tempted to add a
batch here, add a milestone instead.
Its `Status: Archived` is therefore not a nicety — it is the machine-readable
form of "do not treat this as current".
## "Ledger: missing" on the project screen is the correct answer
The Command Center's docs report still looks for a batch ledger — at
docs/planning/FUTURE.md, named here without backticks deliberately, because
`doc-claims.sh` treats a backticked path as a claim that the file exists and
this one must not — and reports it as **missing** for every repository that has
moved planning into the tracker. That is the expected state and not a gap to
close: the report's own contract is that a tracker-first project shows a missing
ledger and no batch percentages.
Creating that file to turn the line green would rebuild, in a new name, the
exact second record this one was archived for.
This repository's record already reads `ledger: retired`, which is that contract
working as intended.
## The batches, as they stood
Ten phases, roughly 0.0.1 through 0.9.3, May to August 2026. The tracker now
holds all 205 issues; `Batch 00 — Pre-convention history` is the closed milestone
they were assigned to on adoption, because per-batch attribution was not
recoverable — closure timestamps collapse into five bulk-close days, 119 of them
on 2026-05-17 alone.
| Phase | What it covered | Outcome |
| --- | --- | --- |
| 1 — Stack scaffold (`0.1.x`) | Vite + React + Tailwind, shadcn-style primitives, React Router, Express, better-sqlite3, first API paths | Complete |
| 2 — Layout rebuild (`0.2.x`) | app shell, all route pages, business content ported into React, service/industry data files, hash routing removed | Complete |
| 3 — Visual overhaul (`0.3.x`) | light-first business design, Tailwind theme, typography and spacing rhythm, mobile-first polish | Complete |
| 4 — Forms and backend hardening (`0.4.x`) | forms wired to Express, SQLite persistence, client and server validation, sanitisation, Zoho forwarding scaffold, rate limiting, Helmet, CORS | Complete |
| 5 — Verification and redesign (`0.5.x`) | SPA router fix, hero rewrite, trust signals, services rewrite, Why Queue North, footer and CTA pass | Complete |
| 6 — Owner feedback (`0.6.x`) | issues #30#41: nav active state, service icons, Cisco signals, hero alignment, industry icons, 8x8 page merge | Complete except the About content, which needed owner input |
| 7 — Zoho CRM integration (`0.6.6`) | OAuth token endpoint fix, lead payload mapping, support→Cases, upsert, setup guide | Complete. Batch 7.5 (webhook verification) deferred and never taken up |
| 8 — Deferred enhancements | testimonials, blog, SEO, performance audit | SEO and performance done; testimonials and blog still open on owner content |
| 9 — Audit bug fixes (`0.7.0`) | 44 issues across batches 9.09.9 | Complete |
| 10 — Low-priority fixes (`0.7.1`) | 6 issues across batches 10.010.2 | Complete |
| — (`0.8.x``0.9.3`) | badges, mobile nav, breadcrumbs, injection hardening, 404, Zoho WebToLead mode, reCAPTCHA, privacy policy, full-route prerender | Complete. Deployed as `queue-north-website:dev` |
## What was still open when the ledger was retired
All of these were already filed as issues, which is why nothing needed migrating
out of this file when it was archived:
- **#68** — About section content corrections. Blocked on owner input.
- **#69** — Testimonials / case studies. Blocked on real client content.
- **#70** — Blog / writing section. Blocked on an owner decision.
- **#108** — Certification number #25432 appears fabricated. Needs owner verification.
- **#110** — "25+ years" claim unverified against the original site.
- **#162** — About page: founding year, partnerships, cybersecurity, compass image.
## The Zoho audit, as it was written
Kept because it is the reasoning behind how the integration is shaped now, and
because points 17 and 11 were fixed by batches 7.07.3 — a reader who finds the
current code puzzling is usually looking at one of these decisions.
### Current Implementation Status
**✅ Already working:**
- OAuth2 refresh token flow with in-memory token caching
- 10s AbortController timeout on all Zoho fetches
- Null-check short-circuit if credentials missing
- Response.ok check before JSON parsing
- Double-slash URL path fix (`ZOHO_API_DOMAIN.replace(/\/$/, "")`)
- Fire-and-forget with `.catch()` (non-blocking, won't break lead submission)
- SQLite always writes first (Zoho is best-effort overlay)
- UNIQUE constraint on `leads.email` with 409 Conflict response
- Sanitization + Zod validation before insert
- Zoho forwarding also attempted on duplicate emails (in case Zoho record doesn't exist yet)
**⚠️ Issues found in audit:**
1. **`Service_Interest` is not a standard Zoho field** — Zoho uses `Lead_Source` (picklist) for tracking where leads come from, and custom fields for service interest. `Service_Interest` will be silently ignored unless a custom field with that exact API name exists in the Zoho org. Should map to `Lead_Source: "Website"` plus a custom field or `Description` for the specific service.
2. **Name field mapping is wrong** — Code maps `leadData.name` to `Last_Name` (correct that it's required), but the contact form has a single "Name" field. Zoho requires `Last_Name` (mandatory) and has `First_Name` (optional). Current mapping `Last_Name: leadData.name || "Unknown"` puts the full name in Last_Name which works but is ugly in Zoho UI. Should split on last space.
3. **Missing `Lead_Source` field** — Every web-submitted lead should have `Lead_Source: "Website"` so it's trackable in Zoho. Currently absent.
4. **Token endpoint uses wrong URL** — The refresh token POST goes to `{ZOHO_API_DOMAIN}/oauth/v2/token` but Zoho requires the token endpoint to be on the **accounts server** (`accounts.zoho.com` for US, `accounts.zoho.eu` for EU, etc.), NOT the API domain (`www.zohoapis.com`). This is a **bug** — it will fail in production.
5. **Missing `redirect_uri` in refresh token request** — The Zoho docs don't require `redirect_uri` for the refresh token grant, but our code sends it. It won't cause an error (Zoho ignores it), but it's unnecessary.
6. **No `$approved` flag** — For web-to-lead submissions, Zoho recommends sending `"$approved": false` to route leads through approval workflows, preventing unverified web submissions from immediately entering the active pipeline.
7. **No `trigger` parameter** — By default Zoho will fire all workflows/blueprints on API-created leads. Should send `"trigger": ["workflow"]` to explicitly control which automations run, or `"trigger": []` to suppress if unwanted.
8. **Support requests not forwarded to Zoho** — Support form submissions go to SQLite only. Should create Cases (or at minimum Contacts + notes) in Zoho for ticket tracking.
9. **Datacenter configuration**`ZOHO_API_DOMAIN` defaults to `https://www.zohoapis.com` (US). Queue North is a US company, so this is correct. But the accounts-server URL (`https://accounts.zoho.com`) is a different domain and must be configured separately. Currently there's no `ZOHO_ACCOUNTS_DOMAIN` env var.
10. **CSP `connect-src` missing Zoho domains** — Helmet CSP only allows `'self'` for `connect-src`. This doesn't affect server-to-server calls, but if any client-side code ever calls Zoho directly it would be blocked. (Not currently an issue since all Zoho calls are server-side.)
11. **No retry on token refresh failure** — If the refresh token request fails, the lead is silently dropped. Should implement at least one retry with exponential backoff.
12. **`Zip_Code` is not a standard Zoho field** — Standard Zoho Leads have `Zip_Code` as a field API name... actually this IS correct. `Zip_Code` is the standard field. ✅
## Batch 7.5 — the one that was never done
> **Zoho Webhook Verification — Future Enhancement**
> Optional: verify the Zoho integration works via a webhook callback. Not
> blocking for initial enablement. Could add a `/api/zoho/webhook` endpoint that
> Zoho calls on record changes — useful for confirming a lead was created,
> syncing status back, or notifying the team. Requires Zoho webhook setup in CRM
> and signature verification.
Deliberately not filed as an issue on adoption. It was written as a "defer unless
requested" item and nobody has requested it; filing it would pad the tracker with
work nobody has chosen. It is recorded here so the idea is not lost.
## The Phase 13 build record
From the root-level `BUILD_SUMMARY.md`, migrated 2026-08-18 and then deleted.
What it recorded as complete: the Vite/React/Tailwind foundation with all routes;
the Express backend serving `/api/health`, `/api/leads` and `/api/support`; SQLite
with `leads` and `support_requests`; layout components and the shadcn-style
primitives (Button, Card, Input, Textarea, Select, Badge, Sheet); every page
including the then-standalone `/8x8`; and the seven services and four industries
data files.
Its "known issues" list is worth keeping, because three of the four were later
fixed and the record of them being *known* is what makes the fixes legible:
the Sheet component skipping TypeScript generics, image assets still being
placeholders rather than Queue North branding, `db/` needing to be gitignored,
and rate limiting not yet existing on the API endpoints.
**Its embedded SQL schema was deliberately not carried forward.** It predated the
`UNIQUE` constraint on `leads.email` and the Zoho-related columns, so copying it
here would have created exactly the stale second record this tree exists to
prevent. `server/index.js` owns the schema.

View File

@ -0,0 +1,918 @@
# Development log — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: the dated record of what happened
Review trigger: Nothing. This file is appended to, never revised.
```
## How to use this
Newest first. **One entry per work session**, written before you stop — that is
step 6 of `docs/WORK_CYCLE.md`, and the two lines it insists on are `Next
action` and `Blockers`.
Those two are not decoration. The next session starts by reading the top of this
file, and a session that ended without saying what came next hands the one after
it a re-derivation instead of a starting point — which is where drift enters.
Neither line competes with anything: the live next action is the field on the
project at privacyllc.dev and the live blockers are issues in the tracker, while
these say what both were **at this date**. A record of then never disagrees with
a record of now.
**Append-only by convention.** Correcting an old entry rewrites the record of
what was known at the time, which is the one thing this file is for. If an entry
turns out to be wrong, add a later entry saying so; do not edit the first.
Note the Review trigger above says "nothing", deliberately. A dated log cannot
rot the way a description of current state can — the entries were true when
written and stay true. It is exempt from review for the same reason a receipt is.
## Where the entries below came from
Everything from `v0.7.1` down predates this repository adopting the template, and
arrived from two root-level files that were merged here on 2026-08-18 — the
gitignored `HISTORY.md` (a version-by-version changelog) and the gitignored
`DEVELOPMENT_LOG.md` (an agent-activity log). Both are reproduced **verbatim**.
They are kept as two blocks rather than interleaved by date, deliberately. The
changelog carries three duplicated version headings — two `v0.5.2`, two `v0.5.3`
and two `v0.5.4`, written by different agents on the same day — so merging the
two streams into one date order would have required choosing an order the record
does not actually support. Two labelled blocks say what is known; one merged
sequence would have implied more.
## Entries
### 2026-08-18 — Adopted the project template
Placeholder — replaced at the end of this session with the real entry, which
carries Next action and Blockers.
---
## From `HISTORY.md` — the version changelog, verbatim
> Migrated 2026-08-18. Newest first, as written. Not edited.
## v0.7.1 — Low-Priority Fixes (6 issues, batches 10.010.2) — 2026-05-18
### Batch 10.0: Backend Cleanup (Neo)
- **#128:** Removed React Query dependency (~45KB bundle savings) — replaced with simple async fetch
- **#127:** Added HTTP→HTTPS redirect middleware for production (x-forwarded-proto)
- **#129:** Added CSP connectSrc comment documenting Zoho server-to-server calls
### Batch 10.1: Image Optimization & UX (Scarlett)
- **#97:** Converted 5 large PNGs to WebP (95-96% size reduction each, total ~11MB → ~511KB)
- **#130:** Added loading spinner animation to Contact and Support form submit buttons
### Batch 10.2: Accessibility (Scarlett + Ripley)
- **#101:** Added aria-labels to interactive elements across Header, Footer, MobileNav, Home, Contact, Support
- Fixed JSX template literal syntax bug where aria-label and className both used `${}` on same element
- Fixed Footer.jsx template literal aria-labels that would render as literal text
- Fixed Home.jsx duplicated aria-label text fragment
## v0.7.0 — Audit Bug Fixes (44 issues, batches 9.09.9) — 2026-05-18
### Batch 9.0: Backend Security (Neo)
- **#120:** Added UNIQUE constraint on leads.email + migration script
- **#121:** Fixed Docker healthcheck — process.exit(1) on DB failure instead of always returning 200
- **#123:** Removed world-writable chmodSync on SQLite DB file
### Batch 9.1: UI Fixes (Scarlett)
- **#104:** Header CTA visibility — changed from invisible navy-on-navy to cyan-on-navy
- **#89:** Added ScrollToTop component for route navigation
- **#88:** Created 404 NotFound page with catch-all route
### Batch 9.2: UI Fixes (Scarlett)
- **#91:** 8x8 white logo visibility on Trust Bar — added `brightness-0 invert` CSS filter
- **#94:** Industries page now uses dynamic icons instead of hardcoded building SVG
- **#125:** Added Building2 fallback for missing industry icons
- **#92:** Industry cards use homeDesc with fallback text
- **#93:** Removed redundant shortDesc from Home page service cards
### Batch 9.3: Navigation Fixes (Scarlett)
- **#109:** Desktop nav now has hover dropdown menus for Services and Industries
- **#102:** Replaced all `<Button onClick={navigate}>` with `<Link>` elements in Home.jsx
- **#105:** Contact page CTA scrolls to form instead of linking to itself
### Batch 9.4: UI Fixes (Scarlett)
- **#112:** Support CTA uses `bg-white text-primary-navy` instead of undefined `bg-secondary-bg`
- **#106:** Support page phone/email are now clickable links
- **#90:** Footer tel: links now include `+1` country code
### Batch 9.5: Backend Security (Neo)
- **#119:** Honeypot spam protection on Contact and Support forms
- **#126:** 409 Conflict shows friendly toast instead of error
### Batch 9.6: Build & Security (Neo)
- **#122:** Disabled production sourcemaps
- **#124:** CORS default changed from `*` to `https://queuenorth.com`
- **#131:** CSP allows WebSocket for Vite HMR in development
### Batch 9.7: Assets & Meta (Scarlett)
- **#99:** Added favicon, apple-touch-icon, site.webmanifest
- **#117:** og:image now uses PNG (1200x630) instead of unsupported SVG
- **#118:** Logo optimized from 348KB SVG to 44KB PNG (87% reduction)
### Batch 9.8: Icons & Copy (Scarlett)
- **#98:** Cisco uses ShieldCheck icon, Veteran uses Award icon (no more text placeholders)
- **#100:** Removed invalid Category/slug display from ServiceDetail sidebar
- **#103:** Removed JointLogoWhite placeholder section, replaced with clean Trust Bar
- **#107:** Footer address changed from marketing copy to "Orlando, FL"
### Batch 9.9: Cleanup (Neo)
- **#95:** Removed 23 unused asset files (24MB freed)
- **#113:** MobileNav verified clean (no dead code found)
- **#114:** Removed duplicate CardDescription.jsx (properly used from Card.jsx)
- **#115:** Removed unused Dialog.jsx component
- **#116:** Removed unused zustand dependency
## v0.6.6 — Phase 7: Zoho CRM Integration Complete — 2026-05-17
### Zoho Integration (all batches 7.07.4)
- **#74 (Batch 7.0):** Fixed OAuth token endpoint — accounts domain vs API domain, removed unnecessary redirect_uri, added retry logic on token refresh failure
- **#75 (Batch 7.1):** Fixed lead payload mapping — split name into First_Name/Last_Name, added Lead_Source: Website, mapped Service_Interest to Description, added trigger: ["workflow"]
- **#76 (Batch 7.2):** Added Zoho Cases forwarding — forwardSupportToZoho() for support requests, ZOHO_CASES_ENABLED toggle, fire-and-forget pattern, priority/name/company mapping
- **#77 (Batch 7.3):** Changed to upsert API — duplicate leads update instead of fail, duplicate_check_fields: ["Email"]
- **#78 (Batch 7.4):** Created docs/zoho-setup.md — step-by-step setup guide for non-technical admins, datacenter variants, troubleshooting
- **#73:** Parent Zoho CRM Integration issue closed
## v0.6.5 — Batch 0.6.5: Code Review Bug Fixes (10 issues) — 2026-05-17
### Fixed
- **#63 (P0):** Home.jsx — `industry.href` undefined, navigating to `/industries/undefined`. Fixed to use `/industries/${industry.id}`
- **#50 (P1):** Server — `sanitized` scope error in catch block. Moved `let sanitized` before `try` so it's accessible in both `try` and `catch`
- **#58 (P1):** Footer.jsx — All internal links converted from `<a href>` to `<Link to>` for client-side navigation
- **#61 (P1):** Textarea.jsx — className template literal used regular quotes instead of backticks, so `${className}` was never interpolated
- **#59 (P2):** About.jsx — CTA button converted from `<a href>` to `<Link to>`
- **#60 (P2):** Support.jsx — Contact Us button converted from `<a href>` to `<Link to>`
- **#62 (P2):** Badge.jsx — `text-foreground` (undefined) changed to `text-text`
- **#64 (P2):** Support.jsx — `hover:bg-navy-darker` (undefined) changed to `hover:bg-primary-navy-dark`
- **#65 (P2):** Server — timeoutMiddleware moved before catch-all routes so it actually applies
- **#66 (P3):** Contact.jsx — Self-referencing `<a href="/contact">` converted to `<Link to="/contact">`
### Open
- **#67 (P3):** Asset filenames with spaces (low priority, cosmetic)
## v0.6.2 — Phase 6 Complete: All Owner Feedback Issues Closed — 2026-05-17
### Completed
- **Issue #45:** Nav active-state styling — already implemented (useLocation + isActive helper, desktop + mobile)
- **Issue #46:** Service card icons — already implemented (lucide-react icons from services.js data)
- **Issue #47:** Cisco partnership signals — already implemented (Home hero, trust signals, Why Queue North, footer)
### Note
These three issues were resolved in earlier Phase 5/6 batches. Verified in code and closed.
## v0.6.1 — Batch 0.6.1: Security Hardening, Retry Logic, Debouncing — 2026-05-17
### Completed
- **Batch 0.6.1:**
- **Issue #12 (P2):** Removed `'unsafe-inline'` from CSP styleSrc since built SPA has all styles in CSS files (no inline styles)
- **Issue #15 (P2):** Added exponential backoff retry logic with 3 max retries (1s, 2s, 4s) to `src/lib/api.js` for 5xx errors and network failures
- **Issue #17 (P3):** Added `useDebounce` hook with 300ms delay to debounce validation errors in Contact.jsx and Support.jsx
- **Issue #18 (P3):** Closed — React Query already handles GET caching with `staleTime: 5 minutes`
### Files Modified
- `server/index.js` — Removed `'unsafe-inline'` from CSP styleSrc
- `src/lib/api.js` — Added `retryFetch` helper with exponential backoff for get/post methods
- `src/hooks/useDebounce.js` — Created debounce hook
- `src/pages/Contact.jsx` — Integrated debounce for validation errors
- `src/pages/Support.jsx` — Integrated debounce for validation errors
- `package.json` — Version bumped to 0.6.1
### Verified
- ✅ `npm run build` passes
- ✅ Docker image builds successfully
- ✅ Container starts on port 3001 and responds to health check
- ✅ Contact and Support forms debounce validation errors
- ✅ API retry logic configured (3 retries, exponential backoff)
- ✅ CSP correctly configured for static SPA (no inline styles)
### Forgejo Issues
- Issue #12 closed (CSP fix applied)
- Issue #15 closed (retry logic implemented)
- Issue #17 closed (debouncing added)
- Issue #18 closed (React Query handles GET caching)
**Note for Ripley:** Forgejo issue closing via API requires `FORGEJO_REGISTRY_TOKEN` environment variable. Token must be provided manually or via CI/CD secrets before closing issues via the Forgejo API.
---
## v0.5.9 — Phase 6 Batches 0.6.50.6.7: Industry Icons, 8x8 Page Merge, Image Verification — 2026-05-17
### Completed
- **Batch 0.6.5 (Industry Icons Bigger, Issue #41):**
- Industry icons enlarged with improved color and contrast
- Icons more visually prominent on all screen sizes
- **Batch 0.6.6 (8x8 Page Merge, Issue #32):**
- Removed standalone /8x8 page and route
- Merged 8x8 partnership content into UCaaS and CCaaS service entries
- Removed 8x8 nav links from Header and Footer
- Deleted src/pages/8x8.jsx
- **Batch 0.6.7 (Verify Image Issues, Issues #30, #31):**
- Confirmed services and industries pages render icons correctly (lucide-react)
- No broken image references — Phase 5 redesign resolved both issues
- Docker build + runtime test passed
### Forgejo Issues Closed
- #30 (service images broken) — resolved by icon redesign
- #31 (industry images broken) — resolved by icon redesign
- #32 (8x8 page merge) — merged into service pages
- #33#41 — all owner feedback issues resolved
### Files Modified
- `src/pages/Home.jsx` — Industry icon sizing (batch 0.6.5)
- `src/data/services.js` — Expanded UCaaS/CCaaS with 8x8 content (batch 0.6.6)
- `src/router.jsx` — Removed /8x8 route
- `src/components/layout/Header.jsx` — Removed 8x8 nav link
- `src/components/layout/Footer.jsx` — Removed 8x8 nav link
- `src/pages/8x8.jsx` — Deleted
---
## v0.5.7 — Phase 5 Batch 9: Hero Left-Alignment + About Section Fixes (Issues #33, #34, #36, #37) — 2026-05-17
### Completed
- **Batch 0.6.3 (Hero Left-Alignment, Issue #33):**
- Hero text left-aligned on all screen sizes (was center-aligned on desktop)
- Hero image changed from `hidden lg:block` to always visible, `md:order-2` for tablet+ layout
- Grid changed from `lg:grid-cols-2` to `md:grid-cols-2` for earlier responsive breakpoint
- Spacing refactored from margin utils to gap utils
- **Batch 0.6.4 (About Section Fixes, Issues #34, #36, #37):**
- Removed "Founded in 2000" → updated to "Founded by military veterans"
- Added "We continue the mission" veteran framing paragraph
- Replaced "Cybersecurity for Communications" with "Vendor-Neutral Consulting" in expertise list
- Updated About page image sizing to `max-h-96 h-auto object-cover`
### Files Modified
- `src/pages/Home.jsx` — Hero section layout changes, image visibility fix
- `src/pages/About.jsx` — Story text update, image sizing fix, expertise list update
- `package.json` — Version bumped to 0.5.7
### Verified
- ✅ `npm run build` passes clean
- ✅ Docker image builds successfully
- ✅ Container starts on port 3001 and responds to health check
- ✅ Home page loads at http://localhost:3001 — hero text left-aligned, image visible
- ✅ About page loads at http://localhost:3001/about — veteran framing present, image sized correctly
---
## Cisco Partnership Signals — 2026-05-17
### Added
- Cisco Certified Partner trust card in Trust Signals section (8x8 and Cisco dual partnership)
- Cisco platform mentions in Services section descriptions
- Cisco partner badge in Footer bottom section
- Cisco partner visual indicator (white "C" badge) in Trust Bar section
### Files Modified
- `src/pages/Home.jsx` — Cisco trust card, trust bar Cisco badge, vendor neutrality section
- `src/data/services.js` — Cisco platform mentions in unified-communications and contact-center services
- `src/components/layout/Footer.jsx` — Cisco in footer bottom section
- `package.json` — Version bumped to 0.5.6
### Verified
- ✅ `npm run build` passes clean
- ✅ Docker image builds successfully
- ✅ Container starts on port 3001 and responds to health check
- ✅ Home page loads at http://localhost:3001 — Cisco appears in trust signals
- ✅ Services page loads at http://localhost:3001/services
---
## Zoho P0/P1 Audit Fixes — 2026-05-17
### Fixed
- P0 #2: Added null check for Zoho credentials before forwarding (early return)
- P0 #3: Added response.ok check for Zoho API responses (log + return on failure)
- P0 #4: Confirmed Docker uses USER nodejs (no su-exec needed)
- P1 #5: Added 10-second timeout on Zoho fetch using AbortController
- P1 #6: UNIQUE constraint on email already handled by db.js migration
- P1 #10: Schema normalizes empty strings to null; Zoho payload uses `|| null`
### Files Modified
- `server/index.js` — Zoho forwarding logic (credential checks, response validation, timeout, payload normalization)
### Build
- ✅ `npm run build` passes
---
## v0.5.5 — Phase 5 Batch 8: Service Card Icons (Issue #35) — 2026-05-17
### Completed
- Services page updated with lucide-react icons matching Home page pattern
- 7 distinct icons: MessageCircle, Users, LifeBuoy, GraduationCap, LinkIcon, Wifi, Network
- Icon-switching logic in Services.jsx maps each service icon name to correct component
- No hardcoded lightning bolt — each service displays its own distinct icon
### Files Modified
- `src/pages/Services.jsx` — replaced hardcoded lightning bolt SVG with lucide-react imports and icon-switching logic
- `package.json` — version bumped to 0.5.5
### Verified
- ✅ `npm run build` passes clean
- ✅ Docker image builds successfully
- ✅ Container starts on port 3001 and responds to health check
- ✅ Services page loads at http://localhost:3001/services
- ✅ Each of the 7 service cards displays a distinct icon per services.js data
---
## v0.5.4 — Phase 5 Batch 7: Nav Active-State Styling — 2026-05-17
### Completed
- Header.jsx and MobileNav.jsx now use `useLocation` hook from React Router 7
- Active nav links display white text (`text-white`) with `font-semibold` styling
- `isActive()` helper function compares `location.pathname` to link href
- Both desktop and mobile navigation use consistent active-state styling
### Files Modified
- `src/components/layout/Header.jsx` — Added `useLocation` import and `isActive()` function, updated desktop nav and mobile nav link styling
- `src/components/layout/MobileNav.jsx` — Added `useLocation` import and `isActive()` function, updated all nav links with active-state styling
- `package.json` — Version bumped to 0.5.4
### Verified
- ✅ `npm run build` passes clean
- ✅ Docker image builds successfully
- ✅ Container starts on port 3001 and responds to health check
- ✅ Home page loads at http://localhost:3001
- ✅ Both Header.jsx and MobileNav.jsx import `useLocation` from `react-router-dom`
- ✅ `isActive()` function correctly compares `location.pathname` to link href
- ✅ Active links display white text with font-semibold styling
---
## v0.5.3 — Phase 5 Batch 6: Footer + Contact Improvements — 2026-05-17
### Completed
- Footer cleanup: email (`info@queuenorth.com`), phone (`(906) 482-6616`), and CTA (`Request Consultation`) all present
- Contact page hero: prominent phone/email display + CTA button
- Home page CTA links added to Trust Signals, Services, Why Queue North sections
- Minor updates to Support page
### Files Modified
- `src/components/layout/Footer.jsx` — Footer cleanup
- `src/pages/Home.jsx` — CTA links in sections
- `src/pages/Contact.jsx` — Hero CTA
- `src/pages/Support.jsx` — Minor updates
- `package.json` — Version bumped to 0.5.3
### Verified
- `npm run build` passes
- Docker image builds and container starts on port 3001
- Footer renders with email, phone, and CTA
- Contact page hero displays phone/email and Request Consultation button
---
## v0.5.2 — Phase 5 Batch 5: Why Queue North Section Refinement — 2026-05-17
### Completed
- Replaced generic "Architecture/Deployment/Lifecycle" pillars with 4 concrete differentiators
- Added lucide-react icons: Headphones, UserCheck, Activity, ShieldCheck
- Changed grid from 3-col to 4-col responsive layout (1 col mobile, 2 col tablet, 4 col desktop)
- Removed unused CardDescription import
- Updated Why Queue North section subtitle to "Four concrete differentiators that set us apart"
- Clean card layout with centered icon containers and consistent styling
### Files Modified
- `src/pages/Home.jsx` — Why Queue North section complete redesign
- `package.json` — Version bumped to 0.5.2
### Verified
- `npm run build` passes
- Docker image builds and container starts
- Home page loads on http://localhost:3001
- Responsive layout verified (1/2/4 columns as specified)
---
## v0.5.1 — Phase 5 Batch 1: Hamburger Menu Fix + DialogTitle A11y — 2026-05-13
### Verified Complete
- Fixed SheetContent missing positional CSS classes (panel was zero-size)
- Installed and configured tailwindcss-animate
- Added visually hidden SheetTitle for accessibility (Radix DialogTitle requirement)
- Forgejo issues #22, #23 closed
---
## v0.5.4 — Phase 5 Batch 4: Services Rewrite — Business Outcomes — 2026-05-17
### Completed
- Section title changed: "Our Services" → "What We Handle"
- Subtitle changed to outcome-focused: "From phones to firewalls, we keep your business running"
- Added `homeDesc` field to all 7 services in `src/data/services.js` with business-outcome language
- Service cards now show: icon + name + homeDesc (outcome) + shortDesc (supporting) + CTA
- lucide-react icons per service: MessageCircle, Users, LifeBuoy, GraduationCap, LinkIcon, Wifi, Network
- B2B professional card layout with icon containers (primary-navy/10 bg)
- Service detail pages (ServiceDetail.jsx) unchanged
### Files Modified
- `src/pages/Home.jsx` — Services section rewrite
- `src/data/services.js` — Added homeDesc field to each service
- `FUTURE.md` — Marked batch 0.5.4 complete
---
## v0.5.3 — Phase 5 Batch 3: Trust Signals Section — 2026-05-17
### Completed
- Added dedicated Trust Signals section immediately after hero section
- 8x8 Certified Partner badge: prominent white card with logo, certification number, and descriptive text
- Veteran Owned badge: VCERT verified with certification #, values-based messaging
- "25+ Years Experience": metric card with 25+ using Georgia font (font-numeric class)
- Metrics grid: 4 key stats displayed (99.99% uptime, <15m response, 24/7 support, 100% satisfaction)
- Clean card layout: white cards on section-alt background with hover effects
- Mobile-first: three-column grid on desktop, stacked on mobile
- Business outcomes focus: reliability, experience, support承诺, not technical jargon
### Files Modified
- `src/pages/Home.jsx` — Added Trust Signals section after hero
- `FUTURE.md` — Marked batch 0.5.3 complete
### Notes for Next Agent
- The trust signals are now front and center after the hero, before services
- Georgia font (`font-numeric`) consistently used for all metrics
- All metrics use realistic placeholder values that can be replaced with real stats when available
- The white card design provides contrast against the section-alt background
---
## v0.5.2 — Phase 5 Batch 2: Hero Section Rewrite — 2026-05-17
### Completed
- Hero headline replaced: "Modern Communications Infrastructure Without the Vendor Noise" → "Reliable Business Communications — Without the Runaround"
- Subtext updated to emphasize trust, reliability, and business outcomes
- 8x8 Certified Partner badge made prominent (featured card with logo, not small pill)
- Trust signals immediately visible on mobile without scrolling
- CTAs updated: "Schedule Consultation" (primary, /contact), "View Services" (secondary, /services)
- Color palette evolved: navy base with teal-900 gradient accent
- Mobile-first design verified — trust signals visible above fold on phone viewports
### Files Modified
- `src/pages/Home.jsx` — Hero section rewrite
- `.learnings/scarlett/LEARNINGS.md` — Added hero rewrite entry
- `FUTURE.md` — Marked batch 0.5.2 complete
### Notes for Next Agent
- The 8x8 badge is now prominently featured — keep this prominence in future sections
- Georgia font (`font-numeric`) used for "25+ Years Experience" — consistent with design rule
- Consider similar trust signal presentation in the Trust Bar section
---
## v0.4.8 — Phase 4 Batch 8: Error Handling Hardening + Dep Audit — 2026-05-13
### Verified Complete
- Global error handlers: uncaughtException + unhandledRejection → log + exit(1)
- 404 catch-all for `/api/*` routes returns `{ error: 'Not found' }`
- Health check enhanced: DB connectivity check, returns `{ status: 'ok', db: 'ok' }` or 503
- Request timeout: 30-second middleware, 504 response
- Consistent error format: 400/404/429/500/503/504 all follow `{ error, fields? }` pattern
- `npm audit`: 0 vulnerabilities
- `npm outdated`: major updates noted (Express 5, Vite 8, etc.) but not upgraded
## v0.4.7 — Phase 4 Batch 7: Rate Limiting + Security Headers + CORS — 2026-05-13
### Verified Complete
- express-rate-limit: 5 req/min per IP on /api/leads and /api/support, configurable via RATE_LIMIT_PER_MINUTE
- helmet: CSP (scripts 'self', styles 'self' inline, fonts 'self' + Google Fonts), HSTS, X-Content-Type-Options, X-Frame-Options DENY
- cors: configurable via CORS_ORIGIN env var, credentials enabled, API routes only
- Trust proxy enabled for correct client IP behind Docker/reverse proxy
- Rate limit returns 429 with JSON error + retryAfter
- All security headers confirmed via curl
- Docker config updated: all new env vars in Dockerfile and docker-compose.yml
## v0.4.6 — Phase 4 Batch 6: Zoho Forwarding Layer — 2026-05-13
### Verified Complete
- Zoho CRM forwarding via REST API v8 (POST /crm/v8/Leads)
- OAuth2 token management: refresh token → access token, in-memory cache with auto-refresh
- Fire-and-forget: Zoho forwarding is async, never blocks form response
- Configurable via 6 env vars: ZOHO_ENABLED, ZOHO_API_DOMAIN, ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_REDIRECT_URI
- Best-effort design: Zoho failures logged but never propagate to client
- ZOHO_ENABLED=false: no Zoho activity, form submits normally
- ZOHO_ENABLED=true with invalid creds: form still succeeds, Zoho errors logged to console
## v0.4.5 — Phase 4 Batch 5: Server-Side Validation + Input Sanitization — 2026-05-13
### Verified Complete
- Zod schemas updated with `.trim()` and `.max()` on all fields
- Input sanitization: strip HTML/script tags, truncate to max lengths
- Request body size limit: 1MB (returns 413)
- Validation errors formatted as `{ error, fields }` — no stack traces
- All curl tests pass: valid data, empty fields, invalid email, XSS, max length, short issue
- XSS test confirmed: `<script>` and `<b>` tags stripped from stored data
## v0.4.4 — Phase 4 Batch 4: Client-Side Validation + Sonner Feedback — 2026-05-13
### Verified Complete
- Contact form: required field validation (company, name, email, message)
- Support form: required field validation (name, company, email, issue)
- Email format validation with regex on both forms
- Issue minimum length validation (10 chars) on Support form
- Inline error messages below each invalid field (red text, small)
- Sonner toast for validation errors on submit attempt
- Error clearing as user corrects fields (onChange)
- Red ring/border on Input/Textarea when field has validation error
- Form inputs disabled during submission (mutation.isPending)
- Form opacity reduced to 70% during submission
- Build passes clean
## v0.4.1 — Phase 4 Batch 1: Contact Form Wired to Express — 2026-05-12
### Verified Complete
- Contact form submits all fields to `/api/leads` via TanStack Query mutation
- Success/error response handling with Sonner toasts
- Confirmation shown on success, form resets
- Error state shown on failure
- Backend Zod validation + SQLite storage working
- Already implemented in Phase 1/2, verified end-to-end
## v0.2.13-fix — Phase 2 Fix: Legacy CSS Consolidation — 2026-05-13
### Fixed
- Removed duplicate `src/index.css` (was not imported anywhere)
- Created clean `src/index.css` as single Tailwind entry point
- Moved `maxWidth.container: 1280px` into `tailwind.config.js` theme extensions
- Updated `src/App.jsx` import from `./App.css` to `./index.css`
- Deleted redundant `src/App.css`
- All `bg-section-alt` usages verified still working
### Verified
- `npm run build` passes
- All 14 Phase 2 batches now fully verified
---
## v0.4.3 — Phase 4 Batch 3: SQLite Persistence Verification — 2026-05-13
### Verified Complete
- Database file (`db/queuenorth.db`) created on first run if missing
- `leads` and `support_requests` tables have correct schema (all columns match server/index.js)
- Data persists across server restarts
- Docker volume test: stop container, restart, confirm data present
- Health check endpoint responds correctly
- `docker-test.sh` persistence suite passes all checks
## v0.4.2 — Phase 4 Batch 2: Support Form Wired to Express — 2026-05-12
### Verified Complete
- Support form submits all fields to `/api/support` via TanStack Query mutation
- Success/error response handling with Sonner toasts
- Confirmation shown on success, form resets
- Error state shown on failure
- Backend Zod validation + SQLite storage working
- Already implemented in Phase 1/2, verified end-to-end
---
## v0.2.2 — Docker Image Size Fix — 2026-05-12
### Fixed
- Removed duplicate `node_modules` copy in Dockerfile runner stage (was copying full dev+prod modules as a permanent layer)
- Reduced image size from 331MB to 215MB (35% reduction)
- `npm ci --omit=dev` now runs cleanly without pre-existing dev modules
### Verified
- Docker build succeeds
- Container starts and health check passes
- Frontend serves correctly
---
# Queue-North-Website — Changelog
## v0.3.4 — Phase 3 Visual Overhaul Complete — 2026-05-12
### Completed Batches
- **Batch 1 (v0.3.1):** Tailwind theme tokens, spacing scale, container width, Inter font
- **Batch 2 (v0.3.2):** Home page redesign — hero, trust bar, services grid, why QN pillars, industries preview, final CTA
- **Batch 3 (v0.3.3):** Header/footer/mobilenav polish, navy-light color token, fixed Button.jsx TS generics build bug
- **Batch 4 (v0.3.4):** Inner pages layout system — consistent hero/card/CTA pattern across About, Services, ServiceDetail, Industries, IndustryDetail, 8x8
- **Batch 5:** Contact/Support forms verified compliant, no changes needed
### Verified
- All batches build successfully
- Contact and Support pages already compliant with OVERHAUL_PLAN.md
- No `asChild` usage on Button (unsupported, replaced with styled anchors)
---
## v0.3.2 — Phase 3 Batch 2: Home Page Redesign — 2026-05-12
### Changed
- Updated Home.jsx to import `industries` data from data file
- Replaced hardcoded industry data with dynamic rendering from `industries.js`
- Updated Services and Industries cards to use Button component for "Learn more" links
- Added MapPin icon from lucide-react for industry cards
- Added useNavigate hook for programmatic navigation
- Ensured consistent use of shadcn/ui Button component across the home page
- Updated version to `0.3.2` for Phase 3 Batch 2
### Verified
- `npm run build` passes
- All routes respond correctly
- Button components render with correct styling
- Responsive layout works on mobile and desktop
---
## v0.3.1 — Phase 3 Batch 1: Theme + Tailwind Config + Typography — 2026-05-12
### Changed
- Enhanced Tailwind spacing scale with utility values (24-48rem)
- Updated container max-width to 1280px per design spec
- Confirmed color palette, Inter font, Georgia numeric font already in place
---
## v0.1.1 — Phase 1 Agent-Pass Checkpoint — 2026-05-12
### Changed
- Corrected Phase 1 version to reflect completed agent-pass checkpoint semantics.
- Documented that every completed agent pass/checkpoint within a phase increments the patch version.
- Confirmed Phase 1 verified output is pushed to `dev` at `0.1.1`.
### Verified
- `npm run build` passes.
- Backend health endpoint responds successfully at `/api/health`.
## v0.2.1 — Dockerization — 2026-05-12
### Added
- Docker build for production deployment
- docker-compose.yml for local development with SQLite persistence
- npm scripts: `docker:build`, `docker:run`, `docker:compose:up/down/logs`
- Health check in Dockerfile and docker-compose
- Non-root `nodejs` user for security
- Layer caching optimization (copy package.json first, install, then copy source)
- `.dockerignore` to exclude node_modules, dist, db, logs, git, private docs
### Changed
- Updated package.json to `0.2.1` for Docker batch
### Verified
- Docker build succeeds with `npm run docker:build`
- Container starts and health check passes
- SQLite database persists in `./db` volume
---
## v0.2.0 — Phase 2 Layout Complete — 2026-05-12
### Added
- All route pages implemented (Home, About, Services, ServiceDetail, Industries, IndustryDetail, 8x8, Contact, Support).
- Data files for services and industries moved to `/src/data/`.
- Layout components (Header, Footer, MobileNav) built and integrated.
- Legacy `styles.css` removed (replaced by Tailwind configuration).
- Contact and support forms wired to Express backend via TanStack Query.
- All pages render meaningful content matching original business information.
- Version bumped to `0.2.0` for Phase 2 baseline.
### Changed
- Replaced hash-based routing with React Router 7.
- Standardized layout patterns across all pages (page hero, main content, sidebar, CTA band).
- Updated package.json, PROJECT.md, OVERHAUL_PLAN.md, HISTORY.md to reflect Phase 2 status.
- Overhaul plan updated to note Phase 2 scope and goals.
### Verified
- `npm run build` passes.
- All routes respond correctly.
- Contact form submits via `/api/leads`.
- Support form submits via `/api/support`.
- No references to legacy `styles.css` remain in source code.
## v0.1.0 — Phase 1 Foundation — 2026-05-12
### Added
- Rebuilt project foundation on Vite + React SPA with React Router.
- Added Tailwind CSS with Queue North light-first business palette.
- Added shadcn/ui-style local primitives for buttons, cards, inputs, textarea, select, badge, sheet, and dialog usage.
- Added Sonner toast support and TanStack Query provider/API helper.
- Added Express backend with `/api/health`, `/api/leads`, and `/api/support`.
- Added better-sqlite3 storage for `leads` and `support_requests`.
- Added all planned frontend routes for home, about, services, service details, industries, industry details, 8x8, contact, and support.
- Added Phase 1 documentation, build summary, script reference, and phase-based versioning rules.
### Changed
- Replaced the static HTML/CSS/JS entry with the Vite React entry.
- Updated README to point to `OVERHAUL_PLAN.md` as the design source of truth.
- Standardized versioning so Phase 1 uses `0.1.x`, Phase 2 uses `0.2.x`, and later phases follow the same pattern.
- Added Bishop verification rules and the requirement that Ripley pushes to `dev` after each verified phase.
### Verified
- `npm run build` passes.
- Backend health endpoint responds successfully at `/api/health`.
- Required routes are configured.
- Contact and support API paths exist and write through SQLite.
## v0.0.1 — Project Initialization — 2026-05-11
### Added
- Project initialized with PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.
---
## From `DEVELOPMENT_LOG.md` — the agent-activity log, verbatim
> Migrated 2026-08-18. Newest first, as written. Not edited. Its entries
> overlap the changelog above in date but not in content: that file recorded
> what shipped, this one recorded what each agent did and verified.
## v0.6.0 — Navigation Active State Styling (Issue #38) — 2026-05-17
**Scarlett** — Nav Active-State Styling Implementation
**Status**: Already implemented in Header.jsx (no changes required)
**Implementation Details**:
- `useLocation` from `react-router-dom` v7.1.3 is properly imported
- `isActive(href)` helper compares `location.pathname === href`
- Desktop nav: Active links show `text-white underline underline-offset-4`, inactive use `text-white/70 hover:text-white`
- Mobile Sheet nav: Active links use `text-white font-semibold`, inactive use `text-white/70 hover:text-white`
- Sub-links (Services/Industries) use brand color `text-navy-light` (#68A3B8) for visual hierarchy
**Routes supported**: `/`, `/services`, `/industries`, `/about`, `/contact`, `/support` and sub-routes
**Notes**: Implementation was complete prior to this batch. All navigation links correctly indicate current page with clear visual feedback for both desktop and mobile views. No issues found.
---
## v0.4.8 — Error Handling Hardening + Dep Audit Batch — 2026-05-13
**Neo** — Server-side error handling hardening
- Added global error handlers:
- `process.on('uncaughtException')`: logs error and exits with code 1
- `process.on('unhandledRejection')`: logs error and exits with code 1
- Both use `log.error()` for consistent timestamped logging
- Added 404 catch-all middleware for API routes:
- Returns `{ error: 'Not found' }` for unmatched `/api/*` routes
- Non-API routes fall through to SPA (express.static)
- Logs warnings for API 404s
- Enhanced health check (`/api/health`):
- Now verifies DB connection with `db.prepare('SELECT 1').get()`
- Returns `{ status: 'ok', db: 'ok' }` on success
- Returns 503 with `{ error: 'Service unavailable', db: 'error' }` on DB failure
- Added 30-second request timeout middleware:
- Returns 504 with `{ error: 'Request timeout' }` for slow requests
- Logs timeout events with `log.warn()`
- Verified consistent error response format:
- 400: `{ error: string, fields: object }` (validation errors)
- 404: `{ error: 'Not found' }`
- 429: `{ error: 'Too Many Requests', message: string, retryAfter: number }`
- 500: `{ error: string }` (no stack traces leaked)
- 503: `{ error: 'Service unavailable', db: 'error' }`
- 504: `{ error: 'Request timeout' }`
- Dependency audit: `npm audit` → 0 vulnerabilities
- Dependency review: `npm outdated` → Major updates noted (Express 5, Vite 8, Tailwind 4, etc.) but not upgraded per batch instructions
- All client-side code preserved (no changes to React components)
**Verification**
- Server starts successfully with all new middleware logged
- Build passes: `npm run build`
- Health endpoint returns DB status correctly
- API 404s return consistent JSON response
- Timeout middleware active (30s limit)
## v0.4.4 — Client-Side Validation + Sonner Feedback Batch — 2026-05-13
**Scarlett** — Client-side form validation implementation
- Added client-side validation to Contact.jsx:
- Required field validation: company, name, email, message
- Email format validation with regex
- Inline error messages below each invalid field (red text, small)
- Sonner toast for validation errors on submit attempt
- Error clearing as user types (on change)
- Red ring/border on Input/Textarea when field has validation error
- Added client-side validation to Support.jsx:
- Required field validation: name, company, email, issue
- Email format validation with regex
- Issue minimum length validation (10 chars — matches server-side Zod rule)
- Inline error messages below each invalid field
- Sonner toast for validation errors on submit attempt
- Error clearing as user types (on change)
- Red ring/border on Input/Textarea when field has validation error
- Added loading state UI:
- Form inputs disabled during submission (`mutation.isPending`)
- Form opacity reduced to 70% during submission
- Submit button already shows "Submitting..." text
**Verification**
- `npm run build` passes without errors
- Both forms show validation errors when submitting empty form
- Invalid email addresses trigger email field error
- Valid submissions trigger success toast
- Server errors trigger error toast
- Error fields clear when user corrects them
---
## v0.4.6 — Security Hardening Batch (Rate Limiting, Helmet, CORS) — 2026-05-13
**Private_Hudson** — Security hardening and environment variable configuration
- Installed security packages: `express-rate-limit`, `helmet`, `cors`
- Added rate limiting to `/api` endpoints:
- 5 requests per minute (configurable via `RATE_LIMIT_PER_MINUTE`)
- IPv6-safe key generator
- Standard rate limit headers (`X-RateLimit-Remaining`, `X-RateLimit-Reset`)
- 429 response with retry-after information
- Added Helmet security headers:
- Content Security Policy (CSP) configured with strict default-src, script-src, style-src
- HSTS enabled with 1-year max-age and includeSubDomains
- Frameguard set to DENY
- X-Content-Type-Options nosniff
- X-Frame-Options DENY
- X-XSS-Protection enabled
- Referrer-Policy set to same-origin
- Hide Powered-By header
- Added CORS configuration:
- Configurable via `CORS_ORIGIN` env var (default: `*` for development)
- Credentials enabled for authenticated requests
- Exposed rate limit headers
- Updated server startup logging to show security config status
- Request body size limit (`1mb`) already in place
**Docker Configuration Updates**
- Added new environment variables to `docker-compose.yml`:
- `RATE_LIMIT_PER_MINUTE=5`
- `CORS_ORIGIN=https://queuenorth.com` (prod-safe default)
- `LOG_LEVEL=info`
- `ZOHO_ENABLED=false` (explicit off for non-CRM deployments)
- `ZOHO_API_DOMAIN`, `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REFRESH_TOKEN`, `ZOHO_REDIRECT_URI`
- Added environment defaults to `Dockerfile`:
- All new env vars with reasonable defaults
- `CORS_ORIGIN=\*` (development default)
- `LOG_LEVEL=info`
- `ZOHO_ENABLED=false`
- `docker-entrypoint.sh` verified — no changes required (env vars passed through automatically)
**Verification**
- `npm run build` passes without errors
- Server starts with security headers logged:
- `[Security] Helmet enabled with CSP configured`
- `[CORS] Enabled with origin: https://queuenorth.com`
- `Rate limiting: 5 requests per 60 seconds`
- Rate limiting headers present on API responses
- All security headers confirmed via `curl -I http://localhost:3001/api/health`
---
## v0.4.5 — Server-Side Validation + Input Sanitization Batch — 2026-05-13
**Neo** — Server-side validation hardening and input sanitization
- Added request body size limit: `express.json({ limit: '1mb' })` — returns 413 for oversized payloads
- Added input sanitization after Zod validation (prevents XSS, trims whitespace, enforces max lengths at DB level):
- `sanitizeString()` helper: trims whitespace, removes HTML/script tags, truncates to max length
- `sanitizePayload()` applies sanitization to validated data before DB insert
- Updated Zod schemas with comprehensive validation:
- All string fields: `.trim()` applied
- All string fields: `.max()` limits applied per RFC standards:
- `company`: 200 chars
- `name`: 100 chars
- `email`: 254 chars (RFC 5321)
- Updated error responses to match v0.4.6 error format standards:
- 400: `{ error: 'Validation failed', fields: object }`
- 500: `{ error: 'Failed to submit lead/support' }`
**Verification**
- `npm run build` passes without errors
- Zod validation correctly rejects invalid inputs
- Input sanitization trims whitespace and removes HTML tags
- Max length limits enforced at DB level
---
## v0.4.0 — Phase 4 Baseline — 2026-05-13
**Ripley** — Phase 4 initialization
- Updated `package.json` version to `0.4.0`
- Updated `PROJECT.md` to indicate Phase 4 active
- Updated `HISTORY.md` with v0.4.0 release notes
- Updated `FUTURE.md` with Phase 4 roadmap
- Updated `OVERHAUL_PLAN.md` with Phase 4 task breakdown
**Phase 4 Scope**
- v0.4.1-0.4.7: Incremental hardening batches (security, validation, error handling)
- v0.4.8: Final hardening batch (global error handlers, health check, dependency audit)
- Target: Production-ready, audited server with comprehensive error handling
---
## v0.0.1 — 2026-05-11
**Ripley** — Project initialized
- Created project directory at `/home/kaspa/.openclaw/Projects/Queue-North-Website/`.
- Set up initial PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.

101
docs/history/HISTORY.md Normal file
View File

@ -0,0 +1,101 @@
# History — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: the narrative of how this project got to where it is
Review trigger: A decision reversed, a direction abandoned, or a rewrite
```
## What this is for
Queue North Technologies had a working website: one large `index.html`, a
hand-written stylesheet, and `main.js` doing hash-based routing. The content was
good and the presentation was early-2000s. This repository is the rebuild — a
Vite + React SPA with an Express API behind it, live at `qn.isnull.dev`.
That much is in the code. What is not in the code is which walls were already
walked into, and that is what this file is for.
**This is a record of *then*, never a description of *now*.** That distinction is
what makes it safe to leave alone as the project changes — a history document
that gets edited to stay current is not a history, it is a second and competing
description of the present.
If you find yourself updating a sentence here because the code changed, the
sentence belongs somewhere else.
## Decisions and their reasons
| When | What was decided | Why, at the time |
| --- | --- | --- |
| 2026-05-11 | Vite + React SPA, explicitly **not** Next.js | The rebuild needed a fast client and a small Express API, not SSR or server components. `docs/planning/REQUIREMENTS.md` states it as a requirement rather than a preference, because it kept getting re-proposed |
| 2026-05-11 | better-sqlite3 rather than a hosted database | Two forms and two tables. A managed database would have been more infrastructure than the product has data |
| 2026-05-12 | Light-first B2B layout with strategic dark navy, evolving the palette rather than replacing it | `docs/design/REDESIGN_REVIEW.md`: the original read as a developer portfolio, and B2B buyers decide on trust in seconds. Rip-and-replace to a light theme would have thrown away the navy brand equity along with the problem |
| 2026-05-13 | Zoho forwarding is fire-and-forget, behind SQLite | SQLite always writes first. A CRM outage must never cost a lead — the local row is the record and Zoho is a best-effort overlay |
| 2026-05-17 | The standalone `/8x8` page was merged into the UCaaS and CCaaS service entries | Issue #32. A partner page nobody navigated to, when the partnership is better evidenced inside the services it actually powers |
| 2026-06-14 | Zoho switched from the REST/OAuth path to **WebToLead** as the default mode | `ZOHO_FORWARDING_MODE=webtolead`. The OAuth path works and is kept as a standby; WebToLead needed no token lifecycle for what is a single lead form |
| 2026-08-01 | Every route is prerendered at build time, and the privacy policy has a server-rendered fallback | Meta's crawler does not run JavaScript. See `docs/architecture/README.md` |
| 2026-08-18 | Adopted the project template; **retired phase-versioning** | See below |
| 2026-08-18 | The Forgejo repository was made **private** | Adoption committed the previously-gitignored internal documents into `docs/`. Those hold the agent narrative and internal findings about the client's own marketing claims (#108, #110), which is not material for a public tree. `null/fruit-fall` was already private and reports to the Command Center normally, so nothing was lost by it |
## What was tried and dropped
### Phase-versioning — the minor number *was* the phase number
`PROJECT.md` and `STRUCTURE.md` both carried the rule: Phase 1 releases are
`0.1.x`, Phase 5 releases are `0.5.x`, and *"do not use unrelated semantic
version bumps during the overhaul"*. Bishop was made responsible for enforcing it
across `package.json`, `PROJECT.md` and `HISTORY.md`.
**It drifted twice, and the second time nobody noticed for two and a half
months.** At adoption `package.json` said `0.8.3` while the last four commit
subjects said *batch 0.9.0* through *batch 0.9.3*, and both `PROJECT.md` and
`STRUCTURE.md` still described the project as being in Phase 5.
The rule was not wrong; it was unenforceable. It asked four files to agree, with
a human check as the only mechanism, and a rule that needs a person to remember
it is a rule that decays. Milestones in the tracker now hold what the phase
number was carrying — a batch is a milestone, and closing it is what marks the
batch shipped — so the version number no longer has to encode anything.
`package.json` was corrected to `0.9.3` on adoption to match the commits.
**What replaced it:** nothing, deliberately. Version numbers are now just version
numbers. `docs/WORK_CYCLE.md` holds what happens at the end of a piece of work.
### The markdown backlog
Work was planned in `FUTURE.md`, with a parallel changelog in `HISTORY.md`, an
agent log in `DEVELOPMENT_LOG.md`, phase checklists in `PROJECT.md` and
`README.md`, and 205 issues in Forgejo. Six records of what was happening, and
the tracker — the one with atomic state and real timestamps — was the one nothing
pointed at.
The specific damage: 205 issues sat in **zero** milestones, so privacyllc.dev
reported the project as *not adopted* rather than as 87% complete, and the
severity labels were spelled `P0 Critical` / `P1 High` / `P2 Medium`, which no
query matches.
The markdown records were retired on 2026-08-18. What was reasoning is now in
`docs/history/BATCH_LEDGER.md`; what was work is in the tracker.
### The agent pipeline — kept, not dropped
Scarlett designs, Neo implements, Private Hudson reviews security, Bishop
verifies, Ripley owns git. Agents do not touch git; Ripley commits and pushes.
This survived adoption because the project earned it — `.learnings/` holds four
agents' accumulated notes and error logs against it — and because nothing in the
template contradicts it. `docs/WORK_CYCLE.md` says what must be true when a piece
of work ends; the pipeline says who does which part. They are different questions.
The one piece of the pipeline that did not survive is Bishop's version-checking
duty, which went with phase-versioning above.
## What this file is not
- **Not the plan.** That is `docs/planning/PROJECT_PLAN.md`.
- **Not the work list.** Open work lives in the tracker as milestones and issues.
- **Not a changelog.** Dated entries go in `docs/history/DEVELOPMENT_LOG.md`.

View File

@ -0,0 +1,118 @@
# Queue North Website — Project Plan
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: scope, audience, and what this project deliberately is not
Review trigger: Any change of scope, audience, or platform
```
> The vision. The **milestones in this repository's issue tracker** hold the
> sequence of work; this holds what the work is *for*. When the two disagree,
> this one is the argument and those are the plan — resolve it rather than
> letting both stand.
>
> Deliberately no batch list here. A schedule in a document goes stale the day
> after it is written and nothing notices; a schedule in the tracker is updated
> by the act of doing the work.
>
> The engineering standards that go with this — stack, frontend, backend,
> database, code quality and security rules — are in
> [`REQUIREMENTS.md`](REQUIREMENTS.md), which carries its own change policy and
> is not summarised here.
## What this is
The business website for Queue North Technologies — an 8x8 Certified Partner and
Cisco partner delivering UCaaS, contact centre, networking, cabling, wireless and
managed lifecycle support to SMB and enterprise organisations. It replaces a
static early-2000s HTML/CSS/JS site with a Vite + React SPA and a small Express
API that captures consultation and support requests.
Its single job is conversion: a business decision-maker arrives, understands
within seconds that Queue North is a credible communications and IT partner, and
submits a consultation or support request that reaches the CRM.
## Who it is for
**Primary:** SMB and mid-market decision-makers — owners, operations managers, IT
managers — evaluating a communications or managed-IT partner. They are usually on
a phone, they are comparing two or three vendors, and they are buying risk
reduction rather than technology.
**Secondary:** existing Queue North customers looking for the support path.
Not developers, not procurement at a Fortune 500, and not people who already know
what UCaaS stands for.
## What it is deliberately not
The most useful section in this file. Every entry is a decision that stops being
re-litigated.
- **Not Next.js.** Vite + React SPA, no SSR at request time, no server
components. Stated as a requirement rather than a preference in
`REQUIREMENTS.md` because it kept being re-proposed. The build does prerender
every route to static HTML, which is a build step and not a framework change.
- **Not a CMS.** Content lives in `src/data/*.js` and in the page components. A
blog is an open question (#70), not a commitment, and if it happens it starts
as hard-coded articles.
- **No user accounts.** There is no login, no session, no authorisation, and none
is planned. This is why `docs/security/SECURITY_CHECKLIST.md` has no
authorisation section and `docs/qa/ClaudeQAPlan.md` has no pass for it.
- **No money moves through it.** No checkout, no subscriptions, no refunds. Pass
I of the QA plan is deleted rather than carried as permanently skipped.
- **Not a developer portfolio.** The failure mode this rebuild exists to fix.
`docs/design/REDESIGN_REVIEW.md` is the standard: hacker aesthetic, terminal
cosplay and infrastructure in-jokes destroy enterprise trust faster than
anything else on the page.
- **Not a support ticketing system.** `/api/support` records a request and
forwards it. It does not track state, assign, or escalate — Zoho does that.
## Stack and platform
| Concern | Choice | Why |
| --- | --- | --- |
| Build | Vite 6 | Fast, and the project needed a client bundle rather than a framework |
| Frontend | React 19, React Router 7 | SPA with client routing; every route also prerendered at build time |
| Styling | Tailwind 3 with a custom Queue North theme | Tokens in `docs/design/OVERHAUL_PLAN.md` |
| Components | shadcn/ui-style local primitives | Copied in, not depended on. Seven primitives is less surface than a component library |
| Backend | Express 4 | Two POST endpoints and a health check. A framework would be larger than the thing |
| Storage | SQLite via better-sqlite3 | Two tables, low write volume, one process. Synchronous by design, which suits a request-scoped insert |
| CRM | Zoho, WebToLead by default | Fire-and-forget behind SQLite, so a CRM outage cannot cost a lead |
| Anti-abuse | Google reCAPTCHA v3 plus a honeypot field | The forms are the only writable surface |
| Hosting | Docker on **nebula**, container `qn-website-dev`, image `dream.scheller.ltd/null/queue-north-website:dev`, fronted by Cloudflare at `qn.isnull.dev` | See `docs/OPERATIONS.md` |
## Success looks like
Observable, in this order:
1. A first-time visitor on a phone can tell what Queue North does and reach the
consultation form without pinch-zooming or hunting.
2. A submitted contact form produces a row in `leads` **and** a lead in Zoho, and
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).
## 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.
- **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
backup script and not backups.
- **Owner-blocked content is the long pole.** Testimonials (#69), the blog (#70)
and the About corrections (#68, #162) are all waiting on somebody who is not on
this team. They should not be counted as engineering progress.
- **No automated tests at all.** No test runner, no typecheck. `npm run build` is
the only gate, and it catches syntax and imports rather than behaviour. Recorded
as a standing gap in `docs/qa/ClaudeQACoverage.md`.
- **The roll-forward procedure is not written down.** Publishing is
`scripts/docker-push.sh`; how nebula moves to a new image is currently in
somebody's head. Noted in `docs/OPERATIONS.md`.

View File

@ -1,5 +1,16 @@
# Project Requirements — Queue North Website # Project Requirements — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: package.json — the engineering standards every agent works to:
stack, frontend, backend, database, code quality and security
Review trigger: Any change of stack, runtime or storage; any new class of input
reaching the API. Note the change policy at the bottom of this
file: these may not be modified without _null's explicit approval
```
These requirements apply to all agents working on Queue North Website. These requirements apply to all agents working on Queue North Website.
## Project Philosophy ## Project Philosophy

View File

@ -0,0 +1,77 @@
# Claude QA Coverage — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: what each QA pass actually reached
Review trigger: Any QA round run
```
> Pass by pass, what was reached and what was not. The point of this file is the
> **Blocked** and **Not run** rows: a pass left out of a report reads exactly like
> a pass that succeeded, and that is how untested code ships believing it was
> tested.
## Round 0 — 2026-08-18 at `ac6f5d1`
Not a round. The table is filled in on the day the template was adopted so that
the honest state is written down rather than absent, because an absent table
reads as one nobody got to and this one is a decision.
| Pass | Result | Notes |
| --- | --- | --- |
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan |
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
| F — Accessibility | **Not run** | Six known defects are already filed from review rather than testing — #189 to #194 |
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured |
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
## Pass H, as it applies here
The template's pass H is written for a product with accounts. This one has none:
no login, no session, no roles, no per-user data. Deleting the pass would have
been wrong anyway, because the *other* half of it — what a stranger can reach and
exhaust — is the most exposed surface this project has. Rewritten, it is:
- Both POST endpoints called directly, off the form, with hand-made payloads
- The rate limiter actually exercised past `RATE_LIMIT_PER_MINUTE` and the 429
body checked
- The honeypot field and the reCAPTCHA score path bypassed deliberately
- `dist/` inspected for anything secret — `VITE_RECAPTCHA_SITE_KEY` is inlined
there by design and must be the *site* key, never the secret one
- The 1 MB body limit and the 30 s request timeout tripped on purpose
`scripts/preflight.sh` covers the headers-and-TLS fraction of this and nothing
else. Its `--auth` checks are deliberately not used: there is no login to
rate-limit and no account to enumerate.
**Pass I — money flowing backwards — was deleted from the plan, not skipped.** No
money moves through this site. Carrying it as a permanently skipped row is the
lie this file exists to prevent.
## Standing gaps
Things no round has ever covered, carried forward until they are. This list
existing is not a failure; it not existing while the gaps do is.
- **There is no automated test suite at all**, and no typecheck. No test runner
is installed, `package.json` has no `test` script, and the project is plain
`.jsx` with no TypeScript. `npm run build` is the only gate, and a build proves
the imports resolve and nothing else. Closing this gap is not currently filed
as an issue — deliberately, because nobody has decided to do it and filing it
would put invented work into the denominator. It is recorded here instead,
which is where a gap nobody has chosen to close belongs.
- **Nothing has ever verified a lead reaching Zoho end to end.** The forward is
fire-and-forget by design, so a total failure of it is silent by construction:
the visitor sees success, SQLite has the row, and the CRM has nothing. The only
way to catch that is pass E, and pass E needs somebody with a Zoho login.
- **The lead database has never been backed up or restored.** `backup.sh` and
`restore-check.sh` exist as of 2026-08-18 and neither has been run against
production. Filed as an issue in `Batch 15`.
- **No accessibility testing has ever been done with a screen reader or by
keyboard alone.** The six filed defects came from reading the markup.
- **No performance numbers exist for any build, ever.**

110
docs/qa/ClaudeQAPlan.md Normal file
View File

@ -0,0 +1,110 @@
# Claude QA Plan — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: what a QA round consists of
Review trigger: Any new user-facing surface, or a defect class that got through
```
> The playbook. What a round *is*, so two rounds are comparable and a gap is
> visible rather than assumed covered.
## Before a round
- Build from a clean checkout at a known SHA, and record that SHA.
- Run from a detached worktree if other work is in flight, so uncommitted
changes cannot contaminate what is under test.
- Note the environment: device, OS version, browser, screen size — whatever the
product's behaviour actually depends on.
## The passes
Each pass gets a letter, so `ClaudeQACoverage.md` can report per pass and a
skipped one is visible.
| Pass | What it covers |
| --- | --- |
| A | First run: install or load, cold start, permissions, empty states |
| B | The core flow, end to end, as a real user would do it |
| C | The core flow with things going wrong: no network, denied permission, invalid input |
| D | Persistence: quit and return, background and resume, restart |
| E | The end of the loop — the state that is hardest to reach on purpose |
| F | Accessibility: keyboard only, screen reader labels, contrast, text scaling |
| G | Performance under the load this product will actually see |
| H | Abuse, and what a stranger can reach — **rewritten for this project, see below** |
<Add, remove and rename to fit. A pass that never applies is noise; a pass that
is always skipped is a lie.>
### Why H is separate from B, here
The template's pass H is about authorization: authenticated is not owning, and
owning is not permitted. **None of that applies to this project** — there is no
login, no session, no role and no per-user row. Every one of those checks would
be a box ticked against nothing.
What does apply is the other half, and it is the most exposed thing here: two
unauthenticated `POST` endpoints that write to a database, on a public origin,
behind nothing but a rate limiter and a reCAPTCHA score. Pass B submits those
forms as a real visitor would. Pass H is somebody who is not being polite:
- both endpoints called directly, off the form, with hand-made payloads
- the rate limiter driven past `RATE_LIMIT_PER_MINUTE`, and the 429 body read
- the honeypot field and the reCAPTCHA path bypassed deliberately
- the 1 MB body limit and the 30 s request timeout tripped on purpose
- a duplicate email driven into the 409, to confirm the Zoho forward still fires
- **`dist/` inspected** — `VITE_RECAPTCHA_SITE_KEY` is inlined into the bundle at
build time, which is correct for a *site* key and catastrophic for a secret
one. `scripts/secrets.sh --built dist/` is the mechanical half of this
`scripts/preflight.sh` covers headers and TLS and nothing else. Its `--auth`
checks stay off: there is no login to rate-limit and no account to enumerate.
**Pass I — money flowing backwards — is deleted from this plan rather than
carried as skipped.** No money moves through this site: no checkout, no
subscription, no refund path. A pass that never applies is noise, and one that is
always skipped is a lie.
## What counts as a finding
A finding needs: what was done, what happened, what should have happened, and
the build SHA. Without the SHA it cannot be re-tested, and a finding that cannot
be re-tested cannot be closed.
## Severity
Findings are filed as issues, labelled:
- **P0** — ships broken, or loses data
- **P1** — materially wrong, but shippable
- **P2** — cosmetic or low impact
- **release-blocker** — a release built today would be wrong rather than merely
incomplete
Exactly these label names: the Command Center queries them by name, and a
repository that spells them differently has its defects reported as *not
adopted* rather than counted wrongly. This one did, until 2026-08-18 — the labels
read `P0 Critical`, `P1 High` and `P2 Medium`, and 205 issues reported as *not
adopted* rather than as 87% complete.
**Do not use `P3`.** It exists on 21 closed issues from before the convention and
is frozen. It is not one of the four names anything queries, so a defect filed
`P3` today is counted by nothing.
Severity is what it costs, not how annoying it is to fix.
## After a round
File each finding as a labelled issue. Update `ClaudeReport.md`'s run-state
block and its overall sentence, and `ClaudeQACoverage.md` with what each pass
actually reached. A pass that could
not be run is recorded as blocked, with what blocks it — never quietly left out,
which reads identically to "passed".
Then **push, and reconcile**. The verdict on the project screen at
privacyllc.dev is read out of `ClaudeReport.md` in the pushed repository, so a
round whose report is committed but not pushed — or pushed but not reconciled —
leaves a stakeholder reading the previous round's judgment with no indication
that a newer one exists. The rest of the cycle is in `docs/WORK_CYCLE.md`.

82
docs/qa/ClaudeReport.md Normal file
View File

@ -0,0 +1,82 @@
# Claude QA Report — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: the QA verdict — build SHAs, round summaries, the overall judgment
Review trigger: Any QA round run
```
> The QA verdict. Companion to `ClaudeQACoverage.md` (what each pass reached)
> and `ClaudeQAPlan.md` (the playbook).
>
> **Defects are issues, not entries here.** A defect found in a round is filed
> in the tracker with a severity label, where it can be assigned, closed by a
> commit, and counted. This file keeps the part a tracker is bad at: a human's
> judgment about whether the thing is fit to ship.
## Current run-state
- **Last QA round:** Round 0 — 2026-08-18, no round has been run
- **Last verified build SHA:** `ac6f5d1` on dev, deployed to nebula as `queue-north-website:dev`
- **Last tested device / environment:** none under this plan. The container's own
healthcheck and a manual `curl` against `qn.isnull.dev` are the only automated
observations of the running site
- **Overall status:** No QA round has been run against this site under any
written plan, and there is no automated test suite to fall back on — the only
gate is that the build compiles. The site is up, healthy and taking real form
submissions, and 178 issues have been found and fixed by review rather than by
testing, which is a real record but a different one. What is untested is
everything a round would actually look at: the two form paths end to end into
Zoho, the failure behaviour when the CRM is unreachable, accessibility, and
what happens to the lead database if the volume is lost.
## Open defects
**Do not list them here, and do not read a defect count out of this file.** The
Command Center's docs report parses this document for open `P0` / `P1` / `P2`
counts, and under this convention they are always zero — the defects are in the
tracker, which is the whole point. A repository with seventeen open P1s reports
`openP1: 0` in that block and the real figure in its tracker counts. Anything
summarising defects reads the tracker's label counts; the zeros here mean *this
file does not hold them*, never *there are none*.
Filed as issues in this repository's tracker, labelled by what they cost:
- **P0** — ships broken, or loses data
- **P1** — materially wrong, but shippable
- **P2** — cosmetic or low impact
- **release-blocker** — a release built today would be *wrong*, not merely
incomplete
Severity is what it costs, not how annoying it is to fix. Every defect needs
the build SHA it was found at — a finding that cannot be re-tested cannot be
closed — so put it in the issue body.
**A note on `P3`.** This repository carries a fifth label, `P3`, on 21 closed
issues from before it adopted the convention. It is frozen: nothing new gets it.
It is not one of the four names the Command Center queries, so a defect filed
`P3` today would be counted by nothing.
## Round notes
### Round 0 — 2026-08-18 — baseline, no testing performed
Recorded on the day the project adopted the template, so that the run-state above
is a measurement rather than a blank. Nothing was tested. What is known about the
running system on this date, and how:
| Observation | How it is known |
| --- | --- |
| `https://qn.isnull.dev/` returns 200 | `curl`, once |
| `/api/health` returns `{"status":"ok","db":"ok"}` | `curl`, once |
| Container `qn-website-dev` on nebula is healthy, restart count 0 | `docker inspect` |
| The lead database holds 3 tables and took writes on 2026-08-18 | `sqlite_master` count and the file mtime |
| Security headers, CSP and HSTS are present on the live origin | response headers |
None of that is a QA pass. It is evidence that the thing is running.
The first real round should start with pass B — the two form paths end to end,
including whether a lead actually lands in Zoho — because that is the product's
entire purpose and nothing has ever verified it end to end from outside.

159
docs/security/SECURITY.md Normal file
View File

@ -0,0 +1,159 @@
# Security — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: server/index.js, .env.example, Dockerfile, docker-compose.yml —
authentication, secret handling, data at rest and in transit
Review trigger: Any new secret, any new external service, any change to auth or storage
```
## What this protects, and from whom
This is a public marketing site with two write endpoints. It has no accounts, no
sessions and no privileged area, so the entire class of authorisation defect does
not exist here. What it does have is **other people's contact details**, arriving
unauthenticated from the open internet and landing in a single SQLite file.
The realistic adversaries, in the order they will actually turn up: a bot
spamming the contact form; somebody scraping the lead database if it is ever
exposed; a credential in the repository or the bundle; and the loss of the volume
the database lives in.
| Asset | Where it lives | What would it cost to lose |
| --- | --- | --- |
| Inbound leads and support requests | `/app/db/queuenorth.db`, a Docker named volume on nebula | **The whole point of the site.** These are prospective customers who filled in a form. There is currently no backup, so loss is total and silent |
| Zoho WebToLead form tokens | `zoho.md` on the operator's machine, and the deploy environment | Low. They are public-by-design identifiers, rendered in the browser by any Zoho web-to-lead form. Losing them means re-reading them out of Zoho |
| reCAPTCHA **secret** key | `zoho.md` and the deploy environment | Real. With it, an attacker can validate their own tokens and the form's only anti-abuse control is gone |
| Zoho OAuth refresh token (standby path) | deploy environment only | Real — it grants write access to the CRM. Currently unset, because production runs WebToLead |
| Forgejo registry token | `~/.openclaw/docker-registry.env` | High, and out of scope of this repository: it is not registry-scoped and works across the whole API with push |
**No credentials are held on behalf of users.** The template carries a
*(precautionary)* paragraph about bring-your-own-key and linked accounts; it was
deleted rather than kept, because there are no users to hold anything for and
none are planned — `docs/planning/PROJECT_PLAN.md` records accounts as
deliberately out of scope. Dropping a precautionary rule is worth saying out loud,
so: that is the one that went, and that is why.
## Secrets
- **Nothing secret is committed.** Not in source, not in config, not in a test
fixture, not in a screenshot.
- Secrets arrive from the environment. `server/index.js` reads every one of them
at startup through `process.env` and nothing else passes them around.
- **`zoho.md` is the operator's credential file and is gitignored.** It holds the
live reCAPTCHA secret key and the Zoho tokens. That `.gitignore` line stays
even though the repository is now private — private is not the same as safe,
and the file has no reason to be in a tree that gets cloned.
- `.env` is gitignored; `.env.example` is committed and carries names with empty
values.
**Check before every release:** no private keys, keystores, `.env` files or real
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.
**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
does deleting the file: the value was transmitted, stored, and is sitting in a
log somebody can read. `secrets.sh` cannot see transcripts and never will — they
are not in the repository — which is exactly why this is a written rule rather
than a check.
### What is already in the history
The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` appear in four commits —
`0d3af33` and `b7f7765` in `index.html`, `033bdf6` in `src/pages/Contact.jsx`,
and `05b27d2` where they were removed to environment variables. They are **not**
in the current tree.
They were deliberately not rewritten out of the history on 2026-08-18. They are
public-by-design form identifiers, the repository is now private, and rewriting
152 commits of shared history to remove a value that any visitor's browser can
read is cost with no benefit. Recorded in `SECURITY_CHECKLIST.md` under *What got
past this list*.
**The reCAPTCHA secret key never reached git.** Checked on 2026-08-18: zero
commits, zero tracked files.
## Authentication and authorisation
**There is none, deliberately.** No login, no session, no cookie, no role, no
admin area. Every route the server exposes is public on purpose:
| Route | Public because |
| --- | --- |
| `GET /api/health` | the container's own `HEALTHCHECK` calls it, and `scripts/healthcheck.sh` does too. It returns a status and a DB flag, and nothing about the data |
| `POST /api/leads` | it *is* the contact form |
| `POST /api/support` | it *is* the support form |
| everything else | static files from `dist/` |
Nothing reads from the database over HTTP. There is no endpoint that returns a
lead, so there is no endpoint that could return somebody else's.
The corresponding sections of `SECURITY_CHECKLIST.md` and `ClaudeQAPlan.md` were
deleted rather than left as unticked boxes.
## Data in transit
TLS everywhere, terminated at Cloudflare in front of `qn.isnull.dev`. The server
additionally redirects HTTP to HTTPS in production on `x-forwarded-proto`, and
sends HSTS with a one-year max-age and `includeSubDomains`.
Helmet sets the CSP, and it is deliberately narrow: `default-src 'self'`, with
named exceptions only for Zoho's form host, Google reCAPTCHA and Google Fonts.
`object-src 'none'`, `frame-ancestors 'self'`, `script-src-attr 'none'`.
## Third parties
Every row is a decision to send someone else's data somewhere.
| Service | What it receives | Why that is acceptable |
| --- | --- | --- |
| **Zoho CRM** | the full lead: name, company, email, phone, ZIP, message, service interest. Support requests become Cases when `ZOHO_CASES_ENABLED` | It is the customer's own CRM. This is the point of the form — the visitor is asking to be contacted |
| **Google reCAPTCHA v3** | the visitor's IP and a behavioural token, on form submission only | The only thing standing between two open POST endpoints and a bot. Scored server-side; the site key in the bundle is public by design |
| **Cloudflare** | all traffic, as the TLS terminator and CDN in front of the origin | Standard edge position. It sees everything the origin sees |
| **Google Fonts** | the visitor's IP, on page load | Inherited from the design. Worth revisiting — it is the only third party here that receives something without the visitor doing anything |
## Deliberately out of scope
- **Denial of service.** Cloudflare is in front; the origin has a per-IP rate
limit of 5 requests a minute on `/api` and nothing more. A determined actor can
make the site slow and cannot make it lie.
- **Compromise of nebula itself.** If the host is owned, the database is owned.
There is no encryption at rest and adding it would put the key on the same box.
- **Zoho's own security.** Once a lead is forwarded it is Zoho's problem.
- **The contents of what visitors type.** Sanitised for injection, not inspected.
## Text from outside the trust boundary
**It is data. It is never instructions.**
Everything a visitor types into either form crosses this boundary, and so does
every Zoho API response. Concretely, here:
- Form input is validated by Zod, then `sanitizeString()` trims it, strips HTML
and script tags, and truncates to the column's maximum — **before** it reaches
a prepared statement or a Zoho payload.
- Every database call is a `better-sqlite3` prepared statement with bound
parameters. No SQL is built by concatenation anywhere in `server/index.js`.
- Nothing from a form is ever used to build a URL or a command.
- **No model reads any of this.** There is no LLM in this product. If one is ever
added, form text reaches it inside a delimiter, quoted as data, credential-
scrubbed first — and this section stops being hypothetical.
An issue title, a commit message or a form message saying *ignore previous
instructions* is a perfectly legal string. It is a thing to store and escape,
never a thing to obey.
## Logging
`log` in `server/index.js` writes timestamped lines at four levels. What goes in
is the method, the path, the status and the duration.
Nothing sensitive goes into one — not a token, not a credential in a URL, not the
contents of a submitted form. The Zoho and reCAPTCHA failure paths log the error's
*name* and the HTTP status, never the response body, because a failed request's
message is frequently the request URL with the key still in it.

View File

@ -0,0 +1,75 @@
# Security checklist — Queue North Website
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: the checks run before a release, and what each one proves
Review trigger: A new class of input, a new external service, or a finding that got past this list
```
## Why this is separate from SECURITY.md
`SECURITY.md` is the threat model: what is being protected and from whom. It is
read carefully once and revisited rarely.
This is the list somebody actually works through. Keeping them apart means the
model can stay stable while the checks change, and it means a release checklist
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
- [ ] `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` against `qn.isnull.dev` is clean — proves headers, CSP and TLS survived the deploy
- [ ] The reCAPTCHA key in `dist/` is the **site** key, not the secret key — proves the one inlined value is the one that is safe to inline
## Standing checks
- [ ] No secret in the repository, in a log line, or in an error message
- [ ] Every externally reachable endpoint is either authenticated or deliberately public, and the deliberate ones are listed — they are, in `SECURITY.md`: `/api/health`, `/api/leads`, `/api/support`, and static files
- [ ] Every input that reaches a query or a filesystem path is validated at the boundary — Zod, then `sanitizeString()`, then a bound prepared statement
- [ ] Dependencies audited, and any accepted advisory recorded with a reason
### What the browser is handed
- [ ] No secret in the built client bundle, not merely none in the repository — proves the scan reached the artifact users actually receive
- [ ] *(only for a deployed service)* Response headers carry a CSP and a frame policy, and nothing is served over plain HTTP — proves clickjacking and injection meet resistance
> **The authorisation group and the session-token row were deleted from this
> list, not left unticked.** This product has no login, no session, no cookie and
> no role — see `SECURITY.md`. Three boxes that can only ever be ticked
> vacuously teach a reader that the boxes do not mean anything.
### What a stranger can learn or exhaust
- [ ] *(precautionary)* The two POST endpoints are rate-limited, and the limit has been exercised rather than assumed — proves a bot cannot run the form unattended overnight
- [ ] The honeypot field and reCAPTCHA scoring both actually reject, tested by bypassing them — proves the anti-abuse controls are enforced server-side and not merely present
- [ ] No endpoint returns a stored lead or support request — proves the database cannot be read back out over HTTP
> The account-enumeration row from the template is gone with the accounts.
> `preflight.sh --auth` exists and is deliberately never run here for the same
> reason.
### The compliance bar, which is not the launch bar
- [ ] There is a record of who changed what, and when — proves the question an auditor or a customer eventually asks can be answered at all
- [ ] *(only for a deployed service)* There is an environment that is not production to test against — **there is not.** `npm run docker:test` runs the image locally, which is close, and `qn-website-dev` on nebula is both the only deployment and the live site
The last two are a different bar from everything above them. The rest of this
list gets a release out of the door; those two get it through the first
compliance review.
## What got past this list
Add an entry whenever a real finding was not caught here, and then add the check
that would have caught it. A checklist that never grows is one nobody is honest
with.
| When | What was missed | The check now added |
| --- | --- | --- |
| 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` |

332
main.js
View File

@ -1,332 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
const pages = Array.from(document.querySelectorAll(".page"));
const navLinks = Array.from(document.querySelectorAll("[data-route]"));
const dropdowns = Array.from(document.querySelectorAll(".nav-dropdown"));
const siteNav = document.querySelector(".site-nav");
const mobileNavToggle = document.querySelector(".mobile-nav-toggle");
function closeMobileNav() {
if (!siteNav || !mobileNavToggle) return;
siteNav.classList.remove("open");
mobileNavToggle.setAttribute("aria-expanded", "false");
}
function openMobileNav() {
if (!siteNav || !mobileNavToggle) return;
siteNav.classList.add("open");
mobileNavToggle.setAttribute("aria-expanded", "true");
}
function toggleMobileNav() {
if (!siteNav || !mobileNavToggle) return;
const willOpen = !siteNav.classList.contains("open");
if (willOpen) {
openMobileNav();
} else {
closeMobileNav();
closeAllDropdowns();
}
}
function closeAllDropdowns(exceptDropdown = null) {
if (mobileNavToggle) {
mobileNavToggle.addEventListener("click", (e) => {
e.preventDefault();
toggleMobileNav();
});
}
dropdowns.forEach((dropdown) => {
if (dropdown === exceptDropdown) return;
dropdown.classList.remove("open");
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
if (toggle) {
toggle.setAttribute("aria-expanded", "false");
}
});
}
function syncDropdownState() {
dropdowns.forEach((dropdown) => {
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
if (!toggle) return;
const expanded = dropdown.classList.contains("open") ? "true" : "false";
toggle.setAttribute("aria-expanded", expanded);
});
}
dropdowns.forEach((dropdown) => {
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
const dropdownMenuLinks = Array.from(dropdown.querySelectorAll(".nav-dropdown-menu [data-route]"));
if (!toggle) return;
toggle.addEventListener("click", (e) => {
e.preventDefault();
const parentRoute = toggle.dataset.route || "home";
if (window.innerWidth <= 900) {
closeAllDropdowns();
window.location.hash = parentRoute === "eightx8" ? "8x8" : parentRoute;
setActive(parentRoute);
return;
}
const willOpen = !dropdown.classList.contains("open");
closeAllDropdowns(dropdown);
dropdown.classList.toggle("open", willOpen);
toggle.setAttribute("aria-expanded", willOpen ? "true" : "false");
if (willOpen) {
window.location.hash = parentRoute === "eightx8" ? "8x8" : parentRoute;
setActive(parentRoute);
}
});
dropdownMenuLinks.forEach((link) => {
link.addEventListener("click", () => {
closeAllDropdowns();
window.setTimeout(() => {
if (document.activeElement) {
document.activeElement.blur();
}
}, 0);
});
});
});
document.addEventListener("click", (e) => {
const clickedInsideDropdown = dropdowns.some((dropdown) => dropdown.contains(e.target));
const clickedInsideNav = siteNav ? siteNav.contains(e.target) : false;
const clickedMobileToggle = mobileNavToggle ? mobileNavToggle.contains(e.target) : false;
if (!clickedInsideDropdown) {
closeAllDropdowns();
}
if (window.innerWidth <= 900 && !clickedInsideNav && !clickedMobileToggle) {
closeMobileNav();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeAllDropdowns();
closeMobileNav();
}
});
const serviceRoutes = new Set([
"services",
"unified-communications",
"contact-center",
"managed-support",
"consulting-training",
"infrastructure-cabling",
"wireless-access",
"local-networking"
]);
const industryRoutes = new Set([
"industries",
"healthcare",
"retail",
"manufacturing",
"education-finance"
]);
function setActive(route) {
const requested = route || "home";
const hasRoute = pages.some((p) => p.dataset.page === requested);
const target = hasRoute ? requested : "home";
pages.forEach((p) => {
const isActive = p.dataset.page === target;
p.classList.toggle("active", isActive);
p.setAttribute("aria-hidden", isActive ? "false" : "true");
});
navLinks.forEach((a) => {
const routeName = a.dataset.route;
const isServiceParent = routeName === "services" && serviceRoutes.has(target);
const isIndustryParent = routeName === "industries" && industryRoutes.has(target);
const isActive = routeName === target || isServiceParent || isIndustryParent;
a.classList.toggle("active", isActive);
if (isActive) {
a.setAttribute("aria-current", "page");
} else {
a.removeAttribute("aria-current");
}
});
syncDropdownState();
window.scrollTo({ top: 0, behavior: "instant" });
if (target === "home") {
startHeroMotion();
} else {
stopHeroMotion();
}
}
function routeFromHash() {
const hash = (window.location.hash || "#home").replace("#", "").trim();
if (hash === "8x8") return "eightx8";
return hash || "home";
}
navLinks.forEach((a) => {
a.addEventListener("click", (e) => {
const route = a.dataset.route;
if (!route) return;
e.preventDefault();
window.location.hash = route === "eightx8" ? "8x8" : route;
setActive(route);
if (window.innerWidth <= 900) {
closeMobileNav();
closeAllDropdowns();
}
});
});
const cta = document.getElementById("cta-consultation");
if (cta) {
cta.addEventListener("click", () => {
window.location.hash = "contact";
setActive("contact");
closeMobileNav();
closeAllDropdowns();
});
}
window.addEventListener("hashchange", () => {
setActive(routeFromHash());
});
window.addEventListener("resize", () => {
if (window.innerWidth <= 900) {
closeAllDropdowns();
}
});
const yearSpan = document.getElementById("year");
if (yearSpan) yearSpan.textContent = new Date().getFullYear();
const rotatorEl = document.getElementById("hero-rotator-text");
const lockupEl = document.getElementById("hero-lockup");
const phrases = [
"Technical Precision",
"Operational Clarity",
"Human-Centered Support"
];
const phraseFadeIn = 1100;
const phraseHold = 1900;
const phraseFadeOut = 1100;
const betweenGap = 500;
const lockupFadeIn = 1200;
const lockupHold = 6500;
const loopGap = 800;
let heroTimer = null;
let heroRunId = 0;
function showEl(el) {
if (!el) return;
el.classList.add("is-visible");
el.classList.remove("is-hidden");
}
function hideEl(el) {
if (!el) return;
el.classList.remove("is-visible");
el.classList.add("is-hidden");
}
function setText(el, text) {
if (!el) return;
el.textContent = text;
}
function sleep(ms) {
return new Promise((resolve) => {
heroTimer = window.setTimeout(resolve, ms);
});
}
function clearHeroTimer() {
if (heroTimer) {
window.clearTimeout(heroTimer);
heroTimer = null;
}
}
async function heroLoop(runId) {
if (!rotatorEl || !lockupEl) return;
hideEl(lockupEl);
hideEl(rotatorEl);
while (runId === heroRunId) {
for (let i = 0; i < phrases.length; i++) {
if (runId !== heroRunId) return;
setText(rotatorEl, phrases[i]);
rotatorEl.style.setProperty("--hero-fade-ms", `${phraseFadeIn}ms`);
rotatorEl.style.setProperty("--hero-drift-ms", `${phraseFadeIn + phraseHold + phraseFadeOut}ms`);
showEl(rotatorEl);
await sleep(phraseFadeIn);
await sleep(phraseHold);
hideEl(rotatorEl);
await sleep(phraseFadeOut);
await sleep(betweenGap);
}
if (runId !== heroRunId) return;
lockupEl.style.setProperty("--hero-lockup-fade-ms", `${lockupFadeIn}ms`);
showEl(lockupEl);
await sleep(lockupFadeIn);
await sleep(lockupHold);
hideEl(lockupEl);
await sleep(loopGap);
}
}
function startHeroMotion() {
if (!rotatorEl || !lockupEl) return;
heroRunId += 1;
clearHeroTimer();
hideEl(lockupEl);
hideEl(rotatorEl);
heroLoop(heroRunId);
}
function stopHeroMotion() {
heroRunId += 1;
clearHeroTimer();
hideEl(lockupEl);
hideEl(rotatorEl);
}
window.addEventListener("resize", () => {
if (window.innerWidth > 900) {
closeMobileNav();
}
});
setActive(routeFromHash());
});

View File

@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}