@elitedcs/ghl-mcp 3.83.0 → 3.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/dist/index.js +136 -88
- package/package.json +2 -6
- package/skills/blueprint/README.md +5 -3
- package/skills/blueprint/SKILL.md +6 -6
- package/src/command-os/design/rules/check-static.mjs +0 -238
- package/src/command-os/design/rules/fixtures/claude-default.html +0 -55
- package/src/command-os/design/rules/fixtures/external-css.html +0 -7
- package/src/command-os/design/rules/fixtures/generator-run-1.html +0 -31
- package/src/command-os/design/rules/fixtures/house-clean.html +0 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.83.
|
|
3
|
+
"version": "3.83.2",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 250 tools — full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,11 +15,6 @@
|
|
|
15
15
|
"src/command-os/design/rules/anti-default.json",
|
|
16
16
|
"src/command-os/design/rules/web.json",
|
|
17
17
|
"src/command-os/design/rules/anti-default-contract.mjs",
|
|
18
|
-
"src/command-os/design/rules/check-static.mjs",
|
|
19
|
-
"src/command-os/design/rules/fixtures/claude-default.html",
|
|
20
|
-
"src/command-os/design/rules/fixtures/house-clean.html",
|
|
21
|
-
"src/command-os/design/rules/fixtures/generator-run-1.html",
|
|
22
|
-
"src/command-os/design/rules/fixtures/external-css.html",
|
|
23
18
|
"dist/index.js",
|
|
24
19
|
"dist/capture-helper.js",
|
|
25
20
|
"templates/action-schemas.json",
|
|
@@ -37,6 +32,7 @@
|
|
|
37
32
|
],
|
|
38
33
|
"scripts": {
|
|
39
34
|
"build": "node src/command-os/assessment/build-app.mjs && esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
40
36
|
"setup": "node setup-wizard.mjs",
|
|
41
37
|
"catalogue": "node scripts/export-command-os-catalogue.mjs",
|
|
42
38
|
"manifest:write": "node scripts/write-package-manifest.mjs",
|
|
@@ -17,11 +17,13 @@ The skill produces the reviewable build plan and the two-part checklist, and sto
|
|
|
17
17
|
|
|
18
18
|
## What you get back
|
|
19
19
|
|
|
20
|
-
- A build plan for the account: pipeline and stages, custom fields, tags, the intake form,
|
|
20
|
+
- A build plan for the account: pipeline and stages, custom fields, tags, the intake form, email and SMS sequences, and the workflows that wire them together. Blueprint does not build a funnel, a page or a website.
|
|
21
21
|
- Two checklists: what Blueprint will build automatically once you approve, and what only you can do in the GHL UI or with an outside service (A2P, Stripe, calendar, phone number, sending domain), in the order you need to do it.
|
|
22
22
|
|
|
23
23
|
Every reference in the plan is a name, not a raw GoHighLevel ID. That is what stops the silent-failure bug where one dead ID skips every action under it and the workflow still shows green.
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Pages and funnels are a separate job
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Blueprint sets up the account. It does not build a funnel, a page or a website of any kind, and a plan that asks for one is refused rather than half-built. GoHighLevel's own page builder was not good enough to hand a client, so it was taken out.
|
|
28
|
+
|
|
29
|
+
When you do want pages, that is its own job after the account exists: a **custom site you host** on Cloudflare or Vercel and wire back to your sub-account. It is for technically-capable users. You need your own host account and CLI, a GHL Private Integration token you store as a host secret, and comfort with a one-time command-line deploy. The product generates the site, hands you the exact verified wiring values, and runs a live verification gate, but it never deploys for you and never holds your keys.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: blueprint
|
|
3
|
-
description: Turn a GHL Command client intake into a complete, reviewable GoHighLevel account build plan — the GHL Command Blueprint. Detects Agency OS for a deeper brief (else uses the built-in intake form), selects an industry preset, generates a structured build plan (pipeline, fields, tags, calendar, form,
|
|
3
|
+
description: Turn a GHL Command client intake into a complete, reviewable GoHighLevel account build plan — the GHL Command Blueprint. Detects Agency OS for a deeper brief (else uses the built-in intake form), selects an industry preset, generates a structured build plan (pipeline, fields, tags, calendar, form, email/SMS, workflows) with symbolic refs not real IDs. Blueprint does not build funnels, pages or websites of any kind, and renders a two-part approval checklist (what GHL Command builds automatically vs. what you must do manually, in order). The plan stops at the human approve gate; GHL-native staging then runs via apply_build_plan. Pages and funnels are a separate job after the account exists, on hosting the member owns. Triggers on build my Blueprint, run Blueprint, GHL Command Blueprint, build a client account, watch it build a client, new client account, client onboarding build, build plan, account build spec, external funnel, host my own funnel.
|
|
4
4
|
compatibility: Claude Code, Claude Cowork, Claude.ai
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ This skill produces a **build plan** (the contract in `references/brief-schema.m
|
|
|
17
17
|
```
|
|
18
18
|
Detect brief source ─► Source the brief (§2A) ─► Select preset ─► Fill skeleton ─► Derive handoffs ─► Generate copy ─► Assemble plan (per-funnel target) ─► Quality gate ─► Render approval view
|
|
19
19
|
│
|
|
20
|
-
|
|
20
|
+
pages/funnels: SEPARATE, after the account ──────────────────────────────────────────────────────────────────────────────────►│ stage GHL side (apply_build_plan) ─► generate + inject site ─► deploy preview→confirm→promote ─► verify_funnel gate ─► done
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Read `references/preset-format.md` once; it defines the tokens (`{{...}}`), `conditionalOn`, `fillFrom`, and `copyDirection` you will resolve. Read `references/agency-os-detection.md` for the brief-source decision. Read `references/approval-view.md` for the final render. Read `references/external-funnel.md` only when a funnel is `target: "external"` (the capability gate + site gen/deploy + verify lane).
|
|
@@ -80,7 +80,7 @@ Default for REVIEW: **outlines** (`subject` + `bodyOutline` + `mergeTags` for em
|
|
|
80
80
|
|
|
81
81
|
## STEP 7 — Assemble the plan (§5)
|
|
82
82
|
|
|
83
|
-
Emit a §5-conforming object: `schemaVersion`, `planId`, `briefId`, `preset`, `summary`, then the object arrays (`pipelines`, `customFields`, `tags`, `customValues`, `calendars`, `forms`, `
|
|
83
|
+
Emit a §5-conforming object: `schemaVersion`, `planId`, `briefId`, `preset`, `summary`, then the object arrays (`pipelines`, `customFields`, `tags`, `customValues`, `calendars`, `forms`, `emails`, `sms`, `workflows`, `handoffs`) — **never a `funnels` key, not even an empty one: the plan schema forbids it and `apply_build_plan` halts on it**, `buildOrder`, and an empty `idMap`. Keep strictly to contract fields (the MCP validators may be strict). Record provenance (preset id + version, brief source) in the `summary` text, not as an extra field. Use GHL-correct enums: `dataType` ∈ {TEXT, LARGE_TEXT, NUMERICAL, PHONE, **MONETORY**, CHECKBOX, SINGLE_OPTIONS, MULTIPLE_OPTIONS, FLOAT, DATE, TEXTBOX_LIST, FILE_UPLOAD, SIGNATURE}; `calendarType` ∈ {round_robin, event, class_booking, collective, service_booking}.
|
|
84
84
|
|
|
85
85
|
Workflow actions stay **logical** (type + key params + refs). Do not emit GHL-native action JSON — the executor expands logical actions via `action-schemas.json` and owns the failure-prone shapes (`internal_update_opportunity` node-level discriminator, `internal_notification`/task-notification nesting, `remove_from_workflow` dual id, `wait` `startAfter`, `attachments:[]`, `html` vs `body`, node `next`/`parentKey` chaining). All object pointers are refs. Max 40 actions per workflow; split longer flows into linked + exit workflows.
|
|
86
86
|
|
|
@@ -90,7 +90,7 @@ Workflow actions stay **logical** (type + key params + refs). Do not emit GHL-na
|
|
|
90
90
|
|
|
91
91
|
**Branching + appointment-relative actions.** `find_opportunity` is the only branching action and MUST be the last action in its workflow (its found/notFound branches do not rejoin). `wait_appointment` ("N before the appointment") only works in a workflow with an `appointment` trigger. The MCP validator enforces both, plus unique object names within each type and a 40-node cap (a workflow containing a `find_opportunity` branch cannot be auto-split).
|
|
92
92
|
|
|
93
|
-
**
|
|
93
|
+
**Blueprint builds no funnel, page or website.** GoHighLevel's own page builder was not good enough to hand a client, so it was removed from installation: the plan schema forbids a `funnels` key and `apply_build_plan` halts on one. Never ask which way to build a funnel and never put one in the plan. If the brief asks for pages or a funnel, say plainly that Blueprint sets up the account and that pages are a separate job afterwards, on hosting they own, wired back to the account. That lane is STEP 10 and it runs only after the account exists and the plan is approved.
|
|
94
94
|
|
|
95
95
|
## STEP 8 — Quality gate (run on yourself before showing anything)
|
|
96
96
|
|
|
@@ -115,9 +115,9 @@ Invite edits (rename / drop / reorder / adjust). Re-render after edits. End by s
|
|
|
115
115
|
|
|
116
116
|
**Workflows default to DRAFT — prompt before publishing.** `apply_build_plan` builds workflows DRAFT unless `publishWorkflows:true`. Before any workflow goes live, ASK the operator: "Publish these now, or leave them DRAFT for review?" Pass `publishWorkflows:true` only on an explicit yes. Workflows gated by an unmet handoff (e.g. SMS workflows behind `handoff.a2p`) never auto-publish even when opted in; pass satisfied handoffs in `metHandoffs` to lift their gate. If any funnel is `target: "external"`, also run STEP 10 after approval.
|
|
117
117
|
|
|
118
|
-
## STEP 10 —
|
|
118
|
+
## STEP 10 — Pages and funnels: a separate job, after the account
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
Only when the member asks for pages after the account is built. Never part of the plan. Run the lane in `references/external-funnel.md`. Summary:
|
|
121
121
|
1. **Stage the GHL side.** Confirm the active sub-account, then `apply_build_plan` `mode:"dry_run"` → show the report → on the operator's go, `mode:"execute"`. Capture the returned `externalWiring` bundle (verified custom-field IDs, `bookingUrl`, trigger tag) and the speed-to-lead workflow id. If `externalWiring.unresolved` is non-empty, stop and re-run execute — never wire a form to an unresolved field.
|
|
122
122
|
2. **Generate + inject the site.** Generate the site with `frontend-design` from the page outlines + brand. Inject the lead form keyed by the bundle's **verified `fieldId`s** (custom fields under `custom: {<fieldId>: value}`, never name-guessed; option values must match GHL option values), the `bookingUrl` into the booking CTA, a honeypot, and Turnstile for production. Fail closed (never a silent success).
|
|
123
123
|
3. **Deploy — the user runs every command; the product deploys nothing and never handles the token.** Scaffold `wrangler.toml` (vars only); the user sets `GHL_PIT` as a host secret themselves. Deploy to a preview URL → confirm → promote to production. Never silently clobber a live deployment.
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Proof harness for anti-default.json.
|
|
3
|
-
//
|
|
4
|
-
// This is NOT the verifier. The real verifier (design-studio-plan.md 3.6) renders the page
|
|
5
|
-
// in the automation Chrome profile and reads computed styles. This runs only the subset of
|
|
6
|
-
// rules that can be decided from the source text of a single self-contained HTML file, with
|
|
7
|
-
// no browser and no dependencies. Its job is to prove the ruleset fires on a slop fixture
|
|
8
|
-
// and stays silent on an honest one.
|
|
9
|
-
//
|
|
10
|
-
// Usage: node check-static.mjs [file.html ...] (no args = all four fixtures)
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { dirname, join, basename, resolve } from 'node:path';
|
|
15
|
-
import { assertAntiDefaultContract } from './anti-default-contract.mjs';
|
|
16
|
-
|
|
17
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const RULES = JSON.parse(readFileSync(join(here, 'anti-default.json'), 'utf8'));
|
|
19
|
-
assertAntiDefaultContract(RULES, JSON.parse(readFileSync(join(here, 'web.json'), 'utf8')));
|
|
20
|
-
const byId = Object.fromEntries(RULES.rules.map(r => [r.id, r]));
|
|
21
|
-
|
|
22
|
-
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
23
|
-
const lum = hex => {
|
|
24
|
-
const h = hex.replace('#', '');
|
|
25
|
-
const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
|
|
26
|
-
const [r, g, b] = [0, 2, 4].map(i => parseInt(f.slice(i, i + 2), 16));
|
|
27
|
-
return 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
|
28
|
-
};
|
|
29
|
-
const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
30
|
-
|
|
31
|
-
function parse(html) {
|
|
32
|
-
const css = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map(m => m[1]).join('\n');
|
|
33
|
-
const body = (html.match(/<body[^>]*>([\s\S]*)<\/body>/i) || [, html])[1];
|
|
34
|
-
const strip = s => s.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
|
35
|
-
const text = strip(body).replace(/ /g, ' ').replace(/[ \t]+/g, ' ');
|
|
36
|
-
const attrs = [...html.matchAll(/\b(alt|aria-label|title|placeholder)\s*=\s*"([^"]*)"/gi)].map(m => m[2]).join(' \n ');
|
|
37
|
-
const docTitle = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ''])[1];
|
|
38
|
-
const heroBlock = (body.match(/<section[^>]*class="[^"]*hero[^"]*"[\s\S]*?<\/section>/i) || [''])[0];
|
|
39
|
-
const heroText = strip(heroBlock);
|
|
40
|
-
const headings = [...body.matchAll(/<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/gi)].map(m => strip(m[1]));
|
|
41
|
-
const rule = sel => {
|
|
42
|
-
const re = new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\{([^}]*)\\}', 'i');
|
|
43
|
-
return (css.match(re) || [, ''])[1];
|
|
44
|
-
};
|
|
45
|
-
const vars = Object.fromEntries([...css.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map(m => [m[1], m[2].trim()]));
|
|
46
|
-
const resolve = v => (v || '').replace(/var\((--[\w-]+)\)/g, (_, n) => vars[n] || '');
|
|
47
|
-
const cssPresent = css.trim().length > 0;
|
|
48
|
-
const externalCss = /<link[^>]+rel\s*=\s*["']?stylesheet/i.test(html);
|
|
49
|
-
return { css, body, text, attrs, heroText, headings, rule, resolve, cssPresent, externalCss,
|
|
50
|
-
all: text + '\n' + attrs + '\n' + docTitle };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// A check returns one of three things, never two:
|
|
54
|
-
// null the check ran and the page is clean
|
|
55
|
-
// U the check could not decide from source (the subject was not visible here)
|
|
56
|
-
// text the check ran and the page trips the rule, with what was measured
|
|
57
|
-
// The third state is the whole point. "The source does not show it" is NOT
|
|
58
|
-
// "the page does not do it": styles can live in an external sheet, a utility
|
|
59
|
-
// framework, or JS. A harness without U reports an unreadable page as clean.
|
|
60
|
-
const U = '\u0000UNDECIDABLE';
|
|
61
|
-
|
|
62
|
-
// Checks that cannot be decided at all when no inline CSS is present.
|
|
63
|
-
const CSS_DEPENDENT = new Set(['AD01','AD02','AD03','AD10','AD11','AD12','AD15','AD16','AD20',
|
|
64
|
-
'AD27','AD28','AD29','AD42','AD43','AD44','AD59','AD60','AD61','AD62','AD63','AD64','AD65','AD66','AD69']);
|
|
65
|
-
|
|
66
|
-
// id -> (doc) => null | U | string
|
|
67
|
-
const CHECKS = {
|
|
68
|
-
AD01: d => { const f = (d.rule('body').match(/font-family\s*:\s*([^;]+)/i) || [, ''])[1]
|
|
69
|
-
+ ',' + (d.rule('h1, h2, h3').match(/font-family\s*:\s*([^;]+)/i) || [, ''])[1];
|
|
70
|
-
const hit = ['Inter', 'Space Grotesk', 'Roboto', 'Arial', 'Helvetica'].filter(n => new RegExp(`(^|[,'"\\s])${n}\\b`, 'i').test(f));
|
|
71
|
-
return hit.length ? `display/body face = ${hit.join(', ')}` : null; },
|
|
72
|
-
AD02: d => { const m = d.rule('body').match(/font-size\s*:\s*(\d+(?:\.\d+)?)px/i);
|
|
73
|
-
if (!m) return U;
|
|
74
|
-
return +m[1] < 18 ? `body font-size = ${m[1]}px (floor 18px)` : null; },
|
|
75
|
-
AD03: d => { const b = d.rule('body'); const hex = v => (d.resolve(v || '').match(/#[0-9a-f]{3,6}\b/i) || [])[0];
|
|
76
|
-
const fg = hex((b.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i) || [])[1]);
|
|
77
|
-
const bg = hex((b.match(/background(?:-color)?\s*:\s*([^;]+)/i) || [])[1]);
|
|
78
|
-
if (!fg || !bg) return U;
|
|
79
|
-
const r = contrast(fg, bg); return r < 7 ? `body ${fg} on ${bg} = ${r.toFixed(2)}:1 (floor 7.0:1)` : null; },
|
|
80
|
-
AD10: d => /text-transform\s*:\s*uppercase/i.test(d.rule('body')) ? 'body text-transform: uppercase' : null,
|
|
81
|
-
AD11: d => /Fraunces|Instrument[_ ]Serif/i.test(d.css) ? 'reflex serif in the stack' : null,
|
|
82
|
-
AD12: d => { const b = d.rule('body'); if (!b.trim()) return U;
|
|
83
|
-
const val = re => d.resolve((b.match(re) || [])[1] || '').trim();
|
|
84
|
-
const fgRaw = val(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
|
|
85
|
-
const bgRaw = val(/background(?:-color)?\s*:\s*([^;]+)/i);
|
|
86
|
-
if (!fgRaw || !bgRaw) return U;
|
|
87
|
-
const isBlack = v => /^(#0{3}|#0{6}|black)$/i.test(v), isWhite = v => /^(#f{3}|#f{6}|white)$/i.test(v);
|
|
88
|
-
const hRaw = d.resolve((d.rule('h1, h2, h3').match(/color\s*:\s*([^;]+)/i) || [])[1] || '').trim();
|
|
89
|
-
if (isBlack(fgRaw) && isWhite(bgRaw)) return `pure ${fgRaw} on ${bgRaw}`;
|
|
90
|
-
if (isWhite(fgRaw) && isBlack(bgRaw)) return `pure ${fgRaw} on ${bgRaw}`;
|
|
91
|
-
if (isBlack(hRaw) && isWhite(bgRaw)) return `pure ${hRaw} heading on ${bgRaw}`;
|
|
92
|
-
return null; },
|
|
93
|
-
AD15: d => /(?:\.hero|header)[^{]*\{[^}]*background[^;}]*(linear|radial)-gradient/i.test(d.css) ? 'gradient on the hero ground' : null,
|
|
94
|
-
AD16: d => /background-clip\s*:\s*text/i.test(d.css) && /gradient/i.test(d.css) ? 'background-clip: text over a gradient' : null,
|
|
95
|
-
AD20: d => {
|
|
96
|
-
let decided = false;
|
|
97
|
-
const sels = [...d.css.matchAll(/([^{}]+)\{([^}]*)\}/g)]
|
|
98
|
-
.filter(m => /(\bbutton|\.cta\b|\.btn\b|\[type=["']?submit)/i.test(m[1]));
|
|
99
|
-
for (const [, sel, block] of sels) {
|
|
100
|
-
const fg = d.resolve((block.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i) || [, ''])[1]).trim();
|
|
101
|
-
const bg = d.resolve((block.match(/background(?:-color)?\s*:\s*([^;]+)/i) || [, ''])[1]).trim();
|
|
102
|
-
const hex = v => (v.match(/#[0-9a-f]{3,8}/i) || [])[0];
|
|
103
|
-
if (!hex(fg) || !hex(bg)) continue;
|
|
104
|
-
const r = contrast(hex(fg), hex(bg));
|
|
105
|
-
if (r < 4.5) return `CTA label ${hex(fg)} on ${hex(bg)} = ${r.toFixed(2)}:1 (floor 4.5:1) on "${sel.trim()}"`;
|
|
106
|
-
decided = true;
|
|
107
|
-
}
|
|
108
|
-
if (!decided && /<button|type=["']?submit|class="[^"]*\b(cta|btn)\b/i.test(d.body)) return U;
|
|
109
|
-
return null; },
|
|
110
|
-
AD27: d => { const m = [...d.css.matchAll(/border-radius\s*:\s*(\d+)px/gi)].map(x => +x[1]).filter(v => v >= 24);
|
|
111
|
-
return m.length ? `border-radius ${m.join('px, ')}px (cards cap at 16px)` : null; },
|
|
112
|
-
AD28: d => { const hit = [...d.css.matchAll(/\{([^}]*)\}/g)].filter(b =>
|
|
113
|
-
/border\s*:\s*1px/i.test(b[1]) && /box-shadow\s*:[^;]*?\b(\d+)px\s+(\d+)px/i.test(b[1]) &&
|
|
114
|
-
+(b[1].match(/box-shadow\s*:[^;]*?\b\d+px\s+(\d+)px/i) || [, 0])[1] >= 16);
|
|
115
|
-
return hit.length ? `1px border + ${(hit[0][1].match(/box-shadow\s*:[^;]*?\b\d+px\s+(\d+)px/i))[1]}px shadow blur on one element` : null; },
|
|
116
|
-
AD29: d => { const z = [...d.css.matchAll(/z-index\s*:\s*(\d+)/gi)].map(x => +x[1]).filter(v => v >= 999);
|
|
117
|
-
return z.length ? `z-index ${z.join(', ')}` : null; },
|
|
118
|
-
AD42: d => { const m = d.css.match(/border-(?:left|right)\s*:\s*(\d+)px/i);
|
|
119
|
-
return m && +m[1] > 1 ? `${m[1]}px side-stripe border` : null; },
|
|
120
|
-
AD43: d => /backdrop-filter\s*:[^;]*blur/i.test(d.css) ? 'backdrop-filter: blur' : null,
|
|
121
|
-
AD44: d => /repeating-(linear|radial)-gradient/i.test(d.css) ? 'repeating-gradient background' : null,
|
|
122
|
-
AD45: d => /feTurbulence|feDisplacementMap|\b(sketch|doodle|squiggle|hand-drawn)\b/i.test(d.body + d.css) ? 'sketchy-SVG marker' : null,
|
|
123
|
-
AD46: d => { const e = d.headings.filter(h => /\p{Extended_Pictographic}/u.test(h));
|
|
124
|
-
return e.length ? `emoji in heading: ${e[0].trim().slice(0, 40)}` : null; },
|
|
125
|
-
AD48: d => { const bad = d.text.split('\n').map(l => (l.match(/·/g) || []).length).filter(n => n > 1);
|
|
126
|
-
return bad.length ? `${Math.max(...bad)} middle dots on one line` : null; },
|
|
127
|
-
AD50: d => !d.heroText.trim() ? U : /(^|\s)(↓\s*)?scroll( to (explore|discover|begin|walk))?\b/i.test(d.heroText) ? 'scroll cue in the hero' : null,
|
|
128
|
-
AD53: d => { if (!d.heroText.trim()) return U; const m = d.heroText.match(/\bv\d+\.\d+(\.\d+)?\b|\b(beta|alpha|early access|invite[- ]only|preview|waitlist)\b/i);
|
|
129
|
-
return m ? `hero status label "${m[0]}"` : null; },
|
|
130
|
-
AD59: d => /cursor\s*:\s*url\(/i.test(d.css) ? 'custom cursor image' : null,
|
|
131
|
-
AD60: d => /transition\s*:\s*all\b/i.test(d.css) ? 'transition: all' : null,
|
|
132
|
-
AD61: d => { const m = [...d.css.matchAll(/scale\((0(?:\.\d+)?)\)/gi)].map(x => +x[1]).filter(v => v <= 0.5);
|
|
133
|
-
return m.length ? `entry from scale(${m[0]})` : null; },
|
|
134
|
-
AD62: d => { const m = d.css.match(/(?:transition|animation)[^;{}]*\bease-in\b(?!-out)/i);
|
|
135
|
-
return m ? 'ease-in timing on an interactive element' : null; },
|
|
136
|
-
AD63: d => { const m = [...d.css.matchAll(/(?:transition|animation)[^;{}]*?(\d{3,5})ms/gi)].map(x => +x[1]).filter(v => v > 300);
|
|
137
|
-
return m.length ? `${Math.max(...m)}ms UI duration (budget 300ms)` : null; },
|
|
138
|
-
AD64: d => { const m = d.css.match(/transition\s*:[^;{}]*\b(width|height|top|left|right|bottom|margin|padding)\b/i);
|
|
139
|
-
return m ? `animating layout property "${m[1]}"` : null; },
|
|
140
|
-
AD65: d => (/transition|@keyframes|animation\s*:/i.test(d.css) && !/prefers-reduced-motion/i.test(d.css))
|
|
141
|
-
? 'motion present, no prefers-reduced-motion block' : null,
|
|
142
|
-
AD66: d => { const hov = [...d.css.matchAll(/:hover\s*\{([^}]*)\}/gi)].filter(m => /transform\s*:/i.test(m[1]));
|
|
143
|
-
if (!hov.length) return null;
|
|
144
|
-
const gated = /@media\s*\(hover:\s*hover\)[^{]*\{[\s\S]*?:hover\s*\{[^}]*transform/i.test(d.css);
|
|
145
|
-
return gated ? null : 'hover transform not gated by @media (hover: hover)'; },
|
|
146
|
-
AD69: d => { const off = /outline\s*:\s*(none|0)\b/i.test(d.css);
|
|
147
|
-
return off && !/:focus-visible/i.test(d.css) ? 'outline removed with no :focus-visible replacement' : null; },
|
|
148
|
-
AD70: d => { const inputs = [...d.body.matchAll(/<(input|textarea)\b[^>]*>/gi)].map(m => m[0])
|
|
149
|
-
.filter(t => /placeholder\s*=/.test(t) && !/aria-label/.test(t))
|
|
150
|
-
.filter(t => { const id = (t.match(/\bid\s*=\s*"([^"]+)"/i) || [])[1];
|
|
151
|
-
return !id || !new RegExp(`<label[^>]*for\\s*=\\s*"${id}"`, 'i').test(d.body); });
|
|
152
|
-
return inputs.length ? `${inputs.length} placeholder-as-label field(s)` : null; },
|
|
153
|
-
AD74: d => { const n = (d.all.match(/[—–]/g) || []).length;
|
|
154
|
-
return n ? `${n} em/en dash character(s) in rendered text or attributes` : null; },
|
|
155
|
-
AD75: d => { const m = d.all.match(/\b(john|jane|joe|jack)\s+(doe|smith|su|chan)\b|\blorem ipsum\b/i); return m ? `"${m[0]}"` : null; },
|
|
156
|
-
AD76: d => { const m = d.all.match(/\b(acme|nexus|smartflow|cloudly|innovatech|globex|initech)\b/i); return m ? `"${m[0]}"` : null; },
|
|
157
|
-
AD77: d => { const m = d.all.match(/\b(99\.9{1,2}%|100%|10x|50%|1,?234,?567)\b/i); return m ? `"${m[0]}"` : null; },
|
|
158
|
-
AD78: d => { const m = d.all.match(/\b(elevate|seamless(ly)?|unleash|supercharge|streamline|empower(s|ing)?|world[- ]class|next[- ]gen(eration)?|revolutioni[sz]e|cutting[- ]edge|game[- ]chang\w*)\b/i); return m ? `"${m[0]}"` : null; },
|
|
159
|
-
AD79: d => { const m = d.all.match(/\b(quietly (in use|trusted)|from the field|field notes|on our desks|currently on the bench|loose plates)\b/i); return m ? `"${m[0]}"` : null; },
|
|
160
|
-
AD80: d => { const m = d.all.match(/\b(stage|step|phase|pass)\s*(one|two|three|four|0?\d)\b/i); return m ? `"${m[0]}"` : null; },
|
|
161
|
-
AD81: d => { const m = d.all.match(/\b\w+ theat(er|re)\b|\bnot just (a |an )?\w+,? it'?s\b|\bactually \w+, not\b/i); return m ? `"${m[0]}"` : null; },
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
export const implemented = Object.keys(CHECKS);
|
|
165
|
-
|
|
166
|
-
// Keep source coverage separate from canonical findings: an undecided check is
|
|
167
|
-
// information even if another check of the same canonical rule finds a failure.
|
|
168
|
-
export function gradeStatic(html) {
|
|
169
|
-
const doc = parse(html);
|
|
170
|
-
const results = implemented.map(id => {
|
|
171
|
-
const value = CSS_DEPENDENT.has(id) && !doc.cssPresent ? U : CHECKS[id](doc);
|
|
172
|
-
return { sourceRuleId: id, ruleId: byId[id].duplicateOf || id,
|
|
173
|
-
check: byId[id].check, method: 'static-harness',
|
|
174
|
-
state: value === U ? 'UNDECIDABLE' : value === null ? 'CLEAN' : 'FINDING',
|
|
175
|
-
detail: value === U ? 'Could not decide from source; rendered evidence is required.' : value };
|
|
176
|
-
});
|
|
177
|
-
const group = rows => {
|
|
178
|
-
const merged = new Map();
|
|
179
|
-
for (const row of rows) {
|
|
180
|
-
const prior = merged.get(row.ruleId);
|
|
181
|
-
merged.set(row.ruleId, { ruleId: row.ruleId, check: row.check, method: row.method,
|
|
182
|
-
state: row.state, sourceRuleIds: [...(prior?.sourceRuleIds || []), row.sourceRuleId],
|
|
183
|
-
detail: prior ? `${prior.detail}; ${row.detail}` : row.detail });
|
|
184
|
-
}
|
|
185
|
-
return [...merged.values()];
|
|
186
|
-
};
|
|
187
|
-
const findings = group(results.filter(row => row.state === 'FINDING'));
|
|
188
|
-
const undecided = group(results.filter(row => row.state === 'UNDECIDABLE'));
|
|
189
|
-
const undecidableCount = results.filter(row => row.state === 'UNDECIDABLE').length;
|
|
190
|
-
const judged = RULES.rules.filter(rule => rule.check === 'judged').map(rule => ({
|
|
191
|
-
ruleId: rule.id, check: 'judged', state: 'ASSERTED',
|
|
192
|
-
detail: `${rule.name}. Check: ${rule.howMeasured} Guidance: ${rule.rewrite} This rule requires model judgment and is never scored.`,
|
|
193
|
-
}));
|
|
194
|
-
return { findings, undecided, judged, results, decidedCount: results.length - undecidableCount,
|
|
195
|
-
undecidableCount, litCount: results.filter(row => row.state === 'FINDING').length,
|
|
196
|
-
externalCssUnread: doc.externalCss && !doc.cssPresent };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function main(args = process.argv.slice(2)) {
|
|
200
|
-
const targets = args.length ? args
|
|
201
|
-
: ['claude-default.html', 'house-clean.html', 'generator-run-1.html', 'external-css.html']
|
|
202
|
-
.map(f => join(here, 'fixtures', f));
|
|
203
|
-
console.log(`anti-default.json ${RULES.rules.length} rules (${RULES.rules.filter(r => r.check === 'measured').length} measured, ` +
|
|
204
|
-
`${RULES.rules.filter(r => r.check === 'proxy').length} proxy, ${RULES.rules.filter(r => r.check === 'judged').length} judged)`);
|
|
205
|
-
console.log(`static harness ${implemented.length} source-decidable checks; per-page coverage is reported below. The rest need the rendered DOM.\n`);
|
|
206
|
-
|
|
207
|
-
let exit = 0;
|
|
208
|
-
for (const file of targets) {
|
|
209
|
-
const report = gradeStatic(readFileSync(file, 'utf8'));
|
|
210
|
-
const { findings, undecided, judged, litCount, decidedCount, undecidableCount } = report;
|
|
211
|
-
const name = basename(file);
|
|
212
|
-
const lit = new Set(findings.flatMap(row => row.sourceRuleIds));
|
|
213
|
-
// These are fixture expectations, not a claim that the page passed design review.
|
|
214
|
-
const EXPECT = {
|
|
215
|
-
'claude-default.html': { bar: 'must trip >= 5', ok: () => litCount >= 5 },
|
|
216
|
-
'house-clean.html': { bar: 'must trip 0 with nothing undecided',
|
|
217
|
-
ok: () => litCount === 0 && undecidableCount === 0 },
|
|
218
|
-
'generator-run-1.html': { bar: 'must trip AD02 + AD20, and nothing else',
|
|
219
|
-
ok: () => lit.has('AD02') && lit.has('AD20') && litCount === 2 },
|
|
220
|
-
'external-css.html': { bar: 'must decide nothing it cannot see',
|
|
221
|
-
ok: () => litCount === 0 && undecidableCount === 26 &&
|
|
222
|
-
[...CSS_DEPENDENT, 'AD50', 'AD53'].every(id => report.results.some(row => row.sourceRuleId === id && row.state === 'UNDECIDABLE')) },
|
|
223
|
-
};
|
|
224
|
-
const spec = EXPECT[name];
|
|
225
|
-
const ok = spec?.ok();
|
|
226
|
-
if (spec && !ok) exit = 1;
|
|
227
|
-
console.log(`${spec ? (ok ? 'PASS' : 'FAIL') : 'REPORT'} ${name} ${litCount} lit, ${decidedCount} decided, ` +
|
|
228
|
-
`${undecidableCount} undecidable (${spec?.bar || 'no fixture bar set'})`);
|
|
229
|
-
for (const finding of findings) console.log(` ${finding.ruleId} ${finding.detail}`);
|
|
230
|
-
for (const row of undecided) console.log(` ${row.ruleId} UNDECIDABLE ${row.detail} (source: ${row.sourceRuleIds.join(' ')})`);
|
|
231
|
-
if (report.externalCssUnread) console.log(' Styles are in an external sheet this harness never reads.');
|
|
232
|
-
for (const rule of judged) console.log(` ${rule.ruleId} JUDGED ${rule.detail}`);
|
|
233
|
-
console.log('');
|
|
234
|
-
}
|
|
235
|
-
return exit;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en"><head><meta charset="utf-8"><title>Nexus — Elevate Your Workflow</title>
|
|
3
|
-
<style>
|
|
4
|
-
:root { --brand: #7c3aed; }
|
|
5
|
-
body { font-family: Inter, sans-serif; color: #666666; background: #ffffff; font-size: 16px; margin: 0; }
|
|
6
|
-
h1, h2, h3 { font-family: 'Space Grotesk', sans-serif; color: #000000; }
|
|
7
|
-
.hero { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding-top: 180px; padding-bottom: 120px; text-align: center; }
|
|
8
|
-
.hero h1 { font-size: 112px; letter-spacing: -0.06em; background: linear-gradient(90deg,#fff,#c4b5fd); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
9
|
-
.eyebrow { text-transform: uppercase; letter-spacing: 0.18em; font-size: 12px; }
|
|
10
|
-
.card { border: 1px solid #e5e7eb; box-shadow: 0 8px 32px rgba(124,58,237,0.25); border-radius: 32px; padding: 24px; background: #fff; }
|
|
11
|
-
.card::before { content: ""; }
|
|
12
|
-
.rail { border-left: 4px solid var(--brand); padding-left: 16px; }
|
|
13
|
-
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brand); display: inline-block; }
|
|
14
|
-
.stripes { background: repeating-linear-gradient(45deg, #f3f4f6 0 8px, #fff 8px 16px); }
|
|
15
|
-
.sticky { position: fixed; z-index: 9999; }
|
|
16
|
-
button { transition: all 420ms ease-in; outline: none; cursor: url(/cursor.png), auto; }
|
|
17
|
-
button:hover { transform: scale(1.05); }
|
|
18
|
-
.fade { animation: pop 600ms ease-in forwards; }
|
|
19
|
-
@keyframes pop { from { transform: scale(0); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
|
20
|
-
.grow { transition: height 500ms linear, padding 500ms linear; }
|
|
21
|
-
</style></head>
|
|
22
|
-
<body>
|
|
23
|
-
<nav><span class="dot"></span> Product · Pricing · Docs · Company · Blog</nav>
|
|
24
|
-
<section class="hero">
|
|
25
|
-
<p class="eyebrow">v0.6 · INVITE-ONLY PREVIEW</p>
|
|
26
|
-
<h1>Elevate Your Workflow</h1>
|
|
27
|
-
<p>The seamless, world-class platform that empowers teams to unleash their potential — built for the next generation of work.</p>
|
|
28
|
-
<button>Get Started</button> <button>Start Now</button>
|
|
29
|
-
<p class="eyebrow">BUILD. SHIP. SCALE.</p>
|
|
30
|
-
<p>Lisbon 14:23 · 18°C</p>
|
|
31
|
-
<p>↓ Scroll to explore</p>
|
|
32
|
-
</section>
|
|
33
|
-
<section class="stripes">
|
|
34
|
-
<p class="eyebrow">01 · Features</p>
|
|
35
|
-
<h2>Why teams choose Nexus 🚀</h2>
|
|
36
|
-
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px">
|
|
37
|
-
<div class="card"><h3>Fast</h3><p>Lorem ipsum dolor sit amet, built to scale.</p></div>
|
|
38
|
-
<div class="card"><h3>Secure</h3><p>Lorem ipsum dolor sit amet, built to scale.</p></div>
|
|
39
|
-
<div class="card"><h3>Simple</h3><p>Lorem ipsum dolor sit amet, built to scale.</p></div>
|
|
40
|
-
</div>
|
|
41
|
-
</section>
|
|
42
|
-
<section>
|
|
43
|
-
<p class="eyebrow">02 · Proof</p>
|
|
44
|
-
<h2>Quietly in use at</h2>
|
|
45
|
-
<p>99.99% uptime · 10x faster · trusted by 50% of the Fortune 500</p>
|
|
46
|
-
<blockquote>Productivity theater is over. — Jane Doe, VP of Operations</blockquote>
|
|
47
|
-
</section>
|
|
48
|
-
<section class="rail">
|
|
49
|
-
<p class="eyebrow">03 · Process</p>
|
|
50
|
-
<h2>How it works</h2>
|
|
51
|
-
<p>Stage 1: Install. Stage 2: Configure. Stage 3: Ship.</p>
|
|
52
|
-
<form><input type="email" placeholder="Enter your work email"></form>
|
|
53
|
-
</section>
|
|
54
|
-
<footer><p>v1.4.2 · Build 0048 · last sync 4s ago · main</p></footer>
|
|
55
|
-
</body></html>
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
<!doctype html><html><head><meta charset="utf-8"><title>Adversary</title>
|
|
2
|
-
<link rel="stylesheet" href="/styles.css"></head>
|
|
3
|
-
<body class="text-[13px] text-gray-400">
|
|
4
|
-
<main><h1 class="text-6xl">Headline</h1>
|
|
5
|
-
<p>Body copy that is 13px and low contrast, but you cannot see that from here.</p>
|
|
6
|
-
<button class="bg-gray-100 text-white">Book an assessment</button></main>
|
|
7
|
-
</body></html>
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en"><head><meta charset="utf-8"><title>Generator run 1, reproduction</title>
|
|
3
|
-
<!--
|
|
4
|
-
Reproduction of the two failures design-run's standalone generator shipped on Jerry's own
|
|
5
|
-
brand (3 passes, 26 minutes, reported by the ghl-command-mcp lane 2026-09-08):
|
|
6
|
-
13px body text, and a call-to-action at roughly 1.15:1.
|
|
7
|
-
This fixture exists to answer one question: would anti-default.json have caught them?
|
|
8
|
-
It is deliberately clean in every other respect, so anything else that lights up is a false positive.
|
|
9
|
-
-->
|
|
10
|
-
<style>
|
|
11
|
-
:root { --ink: #232833; --body: #4a5163; --ground: #ffffff; --line: #e3e3df; --ease-out: cubic-bezier(0.23, 1, 0.32, 1); }
|
|
12
|
-
body { font-family: "Sora", system-ui, sans-serif; color: var(--body); background: var(--ground);
|
|
13
|
-
font-size: 13px; line-height: 1.55; margin: 0; }
|
|
14
|
-
h1 { font-family: "Sora", system-ui, sans-serif; color: var(--ink); font-size: 46px; line-height: 1.15; letter-spacing: -0.02em; }
|
|
15
|
-
main { max-width: 66ch; margin: 0 auto; padding: 48px 24px; }
|
|
16
|
-
.cta { background: #efefef; color: #ffffff; border: 0; border-radius: 6px;
|
|
17
|
-
min-height: 48px; min-width: 48px; padding: 14px 28px; font-size: 17px;
|
|
18
|
-
transition: transform 160ms var(--ease-out); }
|
|
19
|
-
.cta:active { transform: scale(0.97); }
|
|
20
|
-
.cta:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
|
21
|
-
@media (hover: hover) and (pointer: fine) { .cta:hover { transform: translateY(-1px); } }
|
|
22
|
-
label { display: block; color: var(--ink); font-size: 16px; margin-bottom: 6px; }
|
|
23
|
-
input { font-size: 17px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; width: 100%; }
|
|
24
|
-
@media (prefers-reduced-motion: reduce) { .cta { transition: none; } .cta:active { transform: none; } }
|
|
25
|
-
</style></head>
|
|
26
|
-
<body><main>
|
|
27
|
-
<h1>A shorter recovery, planned before you pay</h1>
|
|
28
|
-
<p>We map what is happening, then treat it in a fixed number of visits you agree to up front. No open-ended plans.</p>
|
|
29
|
-
<button class="cta">Book an assessment</button>
|
|
30
|
-
<form><label for="e">Your email</label><input id="e" type="email"></form>
|
|
31
|
-
</main></body></html>
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en"><head><meta charset="utf-8"><title>Copper Ridge Chiropractic</title>
|
|
3
|
-
<style>
|
|
4
|
-
:root {
|
|
5
|
-
--ink: #262b36; --body: #4a5163; --ground: #f7f7f5; --surface: #ffffff;
|
|
6
|
-
--accent: #1f5f4f; --line: #dcdcd6;
|
|
7
|
-
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
8
|
-
}
|
|
9
|
-
body { font-family: "Source Serif 4", Georgia, serif; color: var(--body); background: var(--ground);
|
|
10
|
-
font-size: 19px; line-height: 1.55; margin: 0; }
|
|
11
|
-
h1, h2 { font-family: "Sora", system-ui, sans-serif; color: var(--ink); line-height: 1.15; letter-spacing: -0.02em; }
|
|
12
|
-
h1 { font-size: 58px; }
|
|
13
|
-
h2 { font-size: 34px; }
|
|
14
|
-
main { max-width: 68ch; margin: 0 auto; padding: 0 24px; }
|
|
15
|
-
.hero { background: var(--surface); padding-top: 72px; padding-bottom: 64px; border-bottom: 1px solid var(--line); }
|
|
16
|
-
.cta { background: var(--accent); color: var(--surface); border: 0; border-radius: 6px;
|
|
17
|
-
min-height: 48px; min-width: 48px; padding: 14px 28px; font-size: 18px;
|
|
18
|
-
transition: transform 160ms var(--ease-out), background-color 160ms var(--ease-out); }
|
|
19
|
-
.cta:active { transform: scale(0.97); }
|
|
20
|
-
.cta:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
|
21
|
-
@media (hover: hover) and (pointer: fine) { .cta:hover { background: #17493c; } }
|
|
22
|
-
label { display: block; color: var(--ink); font-size: 17px; margin-bottom: 6px; }
|
|
23
|
-
input { font-size: 18px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; width: 100%; }
|
|
24
|
-
input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
25
|
-
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 28px; }
|
|
26
|
-
.split { display: grid; grid-template-columns: 1.4fr 1fr; gap: 40px; align-items: start; }
|
|
27
|
-
@media (prefers-reduced-motion: reduce) {
|
|
28
|
-
.cta { transition: background-color 160ms linear; }
|
|
29
|
-
.cta:active { transform: none; }
|
|
30
|
-
}
|
|
31
|
-
</style></head>
|
|
32
|
-
<body>
|
|
33
|
-
<nav><a href="/care">Care</a> <a href="/team">Our team</a> <a href="/visit">Plan a visit</a></nav>
|
|
34
|
-
<section class="hero"><main>
|
|
35
|
-
<h1>Back pain that has a plan behind it</h1>
|
|
36
|
-
<p>We map what is actually happening in your spine, then treat it in a fixed number of visits you agree to up front.</p>
|
|
37
|
-
<button class="cta">Book an assessment</button>
|
|
38
|
-
</main></section>
|
|
39
|
-
<main>
|
|
40
|
-
<section class="split">
|
|
41
|
-
<div>
|
|
42
|
-
<h2>What the first visit covers</h2>
|
|
43
|
-
<p>Ninety minutes. A movement screen, imaging review if you have it, and a written plan with the visit count on it before you pay for anything.</p>
|
|
44
|
-
</div>
|
|
45
|
-
<div class="panel">
|
|
46
|
-
<p>Dr. Amara Okonkwo has treated 1,340 patients in Copper Ridge since 2019.</p>
|
|
47
|
-
</div>
|
|
48
|
-
</section>
|
|
49
|
-
<section>
|
|
50
|
-
<h2>Book a first visit</h2>
|
|
51
|
-
<form>
|
|
52
|
-
<label for="email">Your email</label>
|
|
53
|
-
<input id="email" type="email" name="email">
|
|
54
|
-
<button class="cta" type="submit">Request a time</button>
|
|
55
|
-
</form>
|
|
56
|
-
</section>
|
|
57
|
-
</main>
|
|
58
|
-
<footer><main><p>Copper Ridge Chiropractic, 118 Wexler Ave. (406) 555-0173</p></main></footer>
|
|
59
|
-
</body></html>
|