@getvitops/skills 0.1.0-alpha.1 → 0.1.0-alpha.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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @getvitops/skills
2
2
 
3
+ ## 0.1.0-alpha.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 6b5ea17: **Leaner scaffolded `AGENTS.md`, and skills named by scope.** The EmDash template's `AGENTS.md` went from 212 to about 70 lines and the static one from 141 to about 50: only always-true rules stay (source of truth, generated CSS, branches, `vp`/`vpr`, where to look), and task-specific guidance moved into skills loaded when the task matches.
8
+
9
+ - **Breaking, `@getvitops/skills`: skills are renamed by scope.** `custom-blocks` → `emdash-custom-blocks`, `content-and-deploys` → `emdash-content-and-deploys`, `contact-form` → `emdash-contact-form`, `csp` → `vitops-csp`, `see-the-page` → `vitops-see-the-page`, `ads` → `vitops-ads`. `emdash-*` skills only apply to an EmDash site; `vitops-*` apply anywhere, and both prefixes are reserved for toolkit skills. New: `emdash-building-pages` (blocks, nesting, page opener, widget areas, escape hatches, layout primitives, `site.chrome`) and `vitops-consent` (split out of `vitops-design-system`, whose description and body now cover styling and tokens only). Existing sites: run `vp install` so `skills experimental_sync` relinks, and replace the per-skill `.agents/skills/<name>` / `.claude/skills/<name>` lines in `.gitignore` with `.agents/skills/vitops-*`, `.agents/skills/emdash-*` and the same two under `.claude/skills/`. A site's own skill must not use those prefixes.
10
+ - **`vitops agents sync`:** the block lists only skills that apply (a project without `emdash` in its dependencies gets no `emdash-*` ones, and they are not linked), summaries are cut at a clause rather than with an ellipsis, the pointer says `vp exec vitops docs`, and the version stamp is gone so the file no longer changes on every CLI bump.
11
+ - **`@getvitops/create`:** both templates ship the trimmed `AGENTS.md`; the project-naming steps moved into the `setup` skill and EmDash 1.x behaviours, legal-page replacement and the audit prerequisites into `emdash-content-and-deploys`. Existing sites: nothing to change beyond the skill rename; delete the sections you no longer need.
12
+
13
+ - bb61a35: **Call tracking numbers: show an ad visitor a number that attributes the phone call.** Two additive config blocks, no CSS, no breaking change — all formats unaffected.
14
+
15
+ - **`site.ads.google.callTracking`** — Google Ads _website call conversions_. `{ label, number, cache? }` makes the consent-gated Google tag fetch a Google forwarding number (Canada supported) and swap it in for visitors who arrived from a Google ad. Google replaces text only, so the capture script also rewrites the matching `tel:` href. Needs `pixelId` and `site.tracking.enabled`; rejected on any other platform (only Google can put a number on the website — Microsoft's exist only inside the ad, in the US/UK/AU).
16
+ - **`site.tracking.phoneNumbers`** — per-platform numbers for every other platform: the real `number`, `sources` keyed by `site.ads` platform (plus `default-paid`), and an informational `provider`. You provision the numbers with a call-tracking service (Twilio, CallRail, …). The landing URL decides first, then the `_ac` cookie, so a visitor who declines `marketing` sees a tracking number on the landing page only. An organic visitor is never swapped, and the server-rendered HTML always carries the real number.
17
+ - The `tel:` tap beacon now carries which number was shown (`phoneSource`), and the conversion notification says so.
18
+ - `vitops ads lint` flags a call-tracking provider that is not in `site.legal.privacyPolicy.processors`, and notes that `callTracking` plus `conversionLabel.call` can count one call twice (make one secondary in Google Ads).
19
+ - `@getvitops/utils/tracking` exports the pure `selectPhoneNumber()` and helpers; the capture script mirrors it and `tracking.test.ts` pins the two.
20
+ - `@getvitops/skills` gains a **`vitops-ads`** skill (overview plus a `call-tracking.md` topic). The templates gitignore synced skills by prefix (`vitops-*`, `emdash-*`), so it needs no `.gitignore` line of its own.
21
+
22
+ `@getvitops/core`, `@getvitops/vite` and `@getvitops/emdash` — no consumer-facing change in these packages.
23
+
24
+ - 7069f93: **Agents now look at rendered pages with `playwright-cli`.** Both templates add `playwright` as a devDependency, run `playwright cli install --skills` from `prepare` (writing the upstream command reference to `.claude/skills/playwright-cli/`), ship `.playwright/cli.config.json`, allow `pnpm exec playwright cli`, and deny `mcp__claude-in-chrome__*` and `mcp__playwright__*` in `.claude/settings.json`. `@getvitops/skills` adds a short `see-the-page` skill (serve first, snapshot before screenshot, dark mode, mobile, no-JS) that defers to the upstream one. Existing sites: add `playwright` as a devDependency, extend `prepare`, and copy the settings. `vitops audit` now needs only `@axe-core/playwright lighthouse` added.
25
+
3
26
  ## 0.1.0-alpha.1
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -28,15 +28,20 @@ Claude Code is selected), records them in `skills-lock.json`, and relinks on eve
28
28
 
29
29
  `experimental_sync` is a preview command and may change; pin the `skills` version.
30
30
 
31
- A skill the site installed or wrote itself is never replaced, so a site can override one by name.
31
+ A skill the site installed or wrote itself is never replaced, so a site can override one by name. The `vitops-` and `emdash-` prefixes are reserved for toolkit skills (the templates gitignore them by prefix), so name a site's own skill without them.
32
32
 
33
33
  ## Skills
34
34
 
35
- | Skill | Use it for |
36
- | --------------------- | ------------------------------------------------------------------------------ |
37
- | `custom-blocks` | Deciding whether a page section needs a new EmDash block type, and building it |
38
- | `content-and-deploys` | Where content vs code is the truth, prerendering, publish-triggered rebuilds |
39
- | `contact-form` | The `/api/track` route, no-JS behaviour, spam protection, privacy inventory |
40
- | `csp` | The Report-Only CSP and what to check before enforcing it |
35
+ Names carry scope: `emdash-*` only apply to an EmDash site, `vitops-*` to any site built with the toolchain. `vitops agents sync` lists only the ones that apply.
36
+
37
+ | Skill | Use it for |
38
+ | ---------------------------- | ------------------------------------------------------------------------------------------- |
39
+ | `emdash-custom-blocks` | Deciding whether a page section needs a new EmDash block type, and building it |
40
+ | `emdash-content-and-deploys` | Where content vs code is the truth, prerendering, publish-triggered rebuilds |
41
+ | `emdash-contact-form` | The `/api/track` route, no-JS behaviour, spam protection, privacy inventory |
42
+ | `vitops-csp` | The Report-Only CSP and what to check before enforcing it |
43
+ | `vitops-ads` | Ad pixels, conversions, attribution and call-tracking numbers (`site.ads`) |
44
+ | `vitops-see-the-page` | Looking at a rendered page with `playwright-cli` |
45
+ | `emdash-building-pages` | Authoring pages from `vitops_*` blocks: nesting, page opener, escape hatches, `site.chrome` |
41
46
 
42
47
  The `vitops-design-system` skill still ships with `@getvitops/cli` and is linked by `vitops agents`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/skills",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Agent skills for sites built with the Vitops toolchain. Installed as a dependency and linked into a project with `skills experimental_sync`, so they update with the package.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: emdash-building-pages
3
+ description: Use when authoring or changing a page's layout or content on this EmDash site — composing `vitops_*` blocks, nesting sections, page openers and heroes, widget areas, block `theme`/`anchor`/`className`/`style`, heading levels, card grids, or the header (`site.chrome`, `<Masthead>`, mega menu panels).
4
+ ---
5
+
6
+ # Building pages
7
+
8
+ Reference, rendered from this project: `vp exec vitops docs blocks` (every block and field),
9
+ `vp exec vitops docs components` (every pattern, which tiers provide it and which block renders it),
10
+ `vp exec vitops docs classes` (class vocabulary), `vp exec vitops docs config` (`site.chrome`).
11
+
12
+ ## Blocks
13
+
14
+ - The editor's insert menu offers the `vitops_*` blocks.
15
+ - **Nesting.** A block type with a `portableText` field, such as `vitops_section`, holds other
16
+ blocks. A `vitops_*` block holds only scalar fields and repeaters (cards, gallery images,
17
+ accordion items, slides), so it can't. EmDash's built-in `columns` doesn't render `vitops_*`
18
+ children. For layout, nest in a section, use a `blocks` field, or hand-write the template with
19
+ `@getvitops/astro` components (`Subgrid`, `Cards`, `Carousel`, …).
20
+ - **Page opener.** Use `vitops_section` at heading level 1 with its button fields (`cta_label`,
21
+ `secondary_label`) and `stack: rhythm`; don't nest a `vitops_hero` or another section wrapper in a
22
+ section body, which renders two layers of `section > .centered`. `vitops_hero` is for a hero
23
+ that stands alone (it carries option cards).
24
+ - **Section, block or widget area?** Copy-and-edit content is an EmDash Section. The same content
25
+ everywhere (announcement bar, pre-footer call to action, sidebar, footer columns) is a **widget
26
+ area**: define it under Widgets in the admin and render it with `<VitopsWidgetArea name="…" />`
27
+ from `@getvitops/emdash/astro` in the layout, or in page content with the `vitops_widgetArea`
28
+ block. Page-specific content is blocks. An area can't hold another area, and prerendered pages
29
+ show an edit after the next deploy (like menus).
30
+ - **Escape hatches on every block:** `theme` (a backdrop from the site's colour roles, `surface` or
31
+ `brand-primary:solid`, with the readable text for it picked by the design system, so contrast is
32
+ not the editor's judgement), `anchor` (the `id`, for `#links`), `className` (extra classes; under
33
+ the tailwind format only ones the site already uses) and `style` (CSS custom properties only,
34
+ e.g. `--p-card: 2rem`; anything else is dropped). The same backdrop is available on a widget area
35
+ (`<VitopsWidgetArea theme="…">`) and on your own markup (`themeAttrs('surface')` from
36
+ `@getvitops/emdash/astro`, spread on the layout's `<body>`).
37
+ - Override a block per site with `<PortableText components={{ type: { 'vitops_cards': MyCards } }} />`;
38
+ hide one with `vitopsEmdash({ blocks: { exclude: [...] } })`. Before adding a block **type**, use
39
+ the `emdash-custom-blocks` skill: types can't be renamed or deleted once pages use them.
40
+ - **One `<h1>` per page.** The template's page routes render the title as an `<h1>`, and a hero's
41
+ heading level defaults to `h1`, so set a hero's level to `2` on those pages (or let the hero own
42
+ the `h1`). Use `p` for a display heading that is not part of the outline. `vitops emdash build`
43
+ warns on any page without exactly one `<h1>`.
44
+ - Labels: `badge` is static (status, count, category); `tag` may be editable or dismissable. Don't
45
+ invent `chip` or `pill`. The button class is `btn`.
46
+
47
+ ## Layout foundations — the answer, not a starting point
48
+
49
+ | About to write… | Write instead |
50
+ | ------------------------------------------------------- | --------------- |
51
+ | a `wrap`/`wrapper`/`container`/`inner` class | **`centered`** |
52
+ | `max-width` + `margin-inline: auto` | **`centered`** |
53
+ | `padding-block` on a section | **`region`** |
54
+ | margins between headings/paragraphs/lists | **`rhythm`** |
55
+ | `display: grid` + `grid-template-columns: repeat(n, …)` | **`subgrid`** |
56
+ | `repeat(auto-fit, minmax(…))` | **`grid-auto`** |
57
+ | `display: flex` + `gap` for a row of controls | **`cluster`** |
58
+
59
+ `centered` is a grid of _named tracks_: a child widens itself with `breakout`/`spotlight`/
60
+ `fullbleed` without the parent knowing, which a bespoke `.wrap` can't do.
61
+
62
+ - **More than one card means `subgrid`**, so headings and CTAs align across the set:
63
+ `<ul class="subgrid subgrid-cols-3" role="list">` with `<li class="card subgrid-card">`, or
64
+ `<Subgrid />` (renders the slot verbatim; author the `<li>`s).
65
+ - **A card that is a link:** `stretched-link` inside it is zero-JS but kills text selection;
66
+ `<Cards>` keeps selection and falls back to the same link with no JS. Never both. Never
67
+ `<li><a class="card">`: the `li` is the grid item, so the tranches don't align.
68
+ - `vpr lint:design` reports all of these and the pre-commit hook fails on them. Fix the class, not
69
+ the CSS.
70
+
71
+ ## The header: `site.chrome`
72
+
73
+ The header is configuration, not markup. `site.chrome.masthead` sets position
74
+ (`static|sticky|fixed`), `behavior` (`always|hide-on-scroll`), `surface`
75
+ (`solid|glass|transparent|transparent-until-scrolled`), `layout` (`full|inset|floating`), `height`,
76
+ `inset` and `elevation`; `site.chrome.nav` sets `compact` (`drawer|dropdown`), `drawerSide`,
77
+ breakpoints and depths. A `<Masthead>` prop overrides one field for a layout (a landing page can be
78
+ `position="fixed" surface="transparent-until-scrolled"`). A sticky masthead sets `--header-offset`
79
+ itself, so anchors clear it. Nav tiers are menus: `primary`, optional `primary-mobile` and
80
+ `primary-mega`. For the mega panel's featured column, create a widget area named
81
+ `mega-<item url slug>` (e.g. `mega-services`), or put `mega-area:<name>` in the menu item's CSS
82
+ classes.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: contact-form
2
+ name: emdash-contact-form
3
3
  description: How the contact form works on this site — the on-demand `/api/track` route, no-JS behaviour, where submissions go, spam protection and Turnstile, and the privacy-policy inventory. Use when adding or changing a form, fixing submissions that don't arrive, setting up notifications, CRM or Turnstile, or touching `src/pages/api/track.ts`.
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: content-and-deploys
2
+ name: emdash-content-and-deploys
3
3
  description: How content, builds and deploys fit together on this EmDash site — where each kind of data is the source of truth, why pages are prerendered, how a publish reaches the live site, and refreshing the dev database. Use for any deploy or build problem, stale or missing content, "my publish isn't live", scheduled posts, previews, 404s, server islands, or refreshing dev from prod.
4
4
  ---
5
5
 
@@ -108,3 +108,23 @@ A collection with no `urlPattern` is listed as `/<collection>/<slug>`, so the si
108
108
  search engines to 404s while the site serves `/<slug>`. `vitops emdash build` (check-prerender) warns
109
109
  when the sitemap lists URLs the build has no page for. Set the pattern on the collection (the seed's
110
110
  `urlPattern`, or Content types in the admin); a database made before the seed set it needs the admin fix.
111
+
112
+ ## EmDash 1.x behaviours
113
+
114
+ - Passkeys are bound to the hostname they were created on. After a domain cutover use **Settings →
115
+ General → Change domain**, then **Continue on**.
116
+ - Link entries by `entry.data.slug`, never `entry.id` (multilingual sites prefix `id` with a locale).
117
+ - Submit `/sitemap.xml` to search engines, not a per-collection sitemap.
118
+ - Clear a stuck D1 migration lock with `vp exec emdash migrate --take-over-lock <id>` (needs
119
+ `--expected-target-fingerprint` non-interactively).
120
+
121
+ ## Legal pages and the audit
122
+
123
+ - The sample `/terms` and `/privacy` pages are short placeholders. To replace them, fill in
124
+ `site.json`'s `organization`/`site.legal`, run
125
+ `vp exec vitops legal --format portable-text --out ./legal`, and paste each document into the page
126
+ in the admin. The output still needs review before launch.
127
+ - `vitops audit` needs optional packages and a browser, checked before it starts a server:
128
+ `vp add -D @axe-core/playwright lighthouse && vp exec playwright install chromium`
129
+ (`playwright` itself is already a devDependency). Its page list is the built sitemap, then the
130
+ seed's menus.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: custom-blocks
2
+ name: emdash-custom-blocks
3
3
  description: Decide whether a page section needs a new EmDash block type, and if so how to name, build, version and retire it. Use before creating, renaming or deleting any block type, any `src/components/blocks/*` renderer, or when asked to "add a block", "make an editable section" or "add a component the editors can place".
4
4
  ---
5
5
 
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: vitops-ads
3
+ description: How ad platforms, pixels, conversions and attribution work on this site — `site.ads`, the consent-gated tags, click-ID capture into `_ac`, `vitops ads setup|tags|lint`, and the `tel:`/form conversions they report. Also call tracking and forwarding numbers (Google website call conversions, per-platform CallRail/Twilio numbers). Use when adding or changing a Google/Meta/LinkedIn/TikTok/Microsoft/Reddit/Pinterest/Snapchat pixel, verifying a domain with an ad platform, debugging conversions that arrive unattributed, or showing different phone numbers to ad visitors.
4
+ ---
5
+
6
+ # Ads
7
+
8
+ An ad platform is recorded as facts in `site.json` under `site.ads.<platform>`; the toolchain turns
9
+ those facts into the tag, the cookie-notice disclosure, the consent behaviour and the lint checks. A
10
+ pixel pasted into a template is invisible to all four, so don't.
11
+
12
+ - **Platforms:** `google` `meta` `linkedin` `reddit` `tiktok` `microsoft` `pinterest` `snapchat`. Each
13
+ entry names the account (`accountId`), the tag (`pixelId`; for Google the `AW-…` conversion ID, not
14
+ the customer ID) and, where the platform verifies a domain, the token (`domainVerification`).
15
+ - **Tags are inert until consent.** `<Ads />` renders each as `type="text/plain"` with its URL on
16
+ `data-src`, so an undecided visitor's page makes no third-party request. Never give an ad tag a live
17
+ `src`. Category is `marketing` unless `category: "analytics"`. `environments.<env>.ads: false`
18
+ switches every tag off for that environment (a preview must not fire conversion pixels).
19
+ - **Click IDs are captured into `_ac`** by `<Tracking />` once `site.tracking.enabled` is true. It
20
+ reads the landing URL synchronously, but only _writes_ the 90-day cookie after `marketing` is
21
+ granted, and only an arrival that carried a click ID or UTM raises the banner. With tracking off,
22
+ every conversion from an ad arrives unattributed — it looks exactly like organic.
23
+ - **Conversions** are `tel:` taps, `mailto:` taps and form posts. For Google, `conversionLabel`
24
+ takes one label or `{ call, email, form }`, and each is its own action in Google Ads (the category —
25
+ "Phone call lead", "Contact", "Submit lead form" — is set there, not in code).
26
+ `reporting: "server"` + `conversionActionId` uploads from the conversion route instead of the browser.
27
+ - **Domain verification:** only Meta, TikTok, Pinterest and Snapchat verify, by an apex DNS TXT record.
28
+ Google Ads, LinkedIn, Reddit and Microsoft have none — linking is the tag and the account ID.
29
+
30
+ ## Commands
31
+
32
+ - `vitops ads setup` — creates the verification TXT (needs `CLOUDFLARE_API_TOKEN`), asks for a missing
33
+ token and writes it back to `site.json`. It never edits or deletes a DNS record, and calls no
34
+ platform API; the final "Verify" click stays manual (it prints a reminder).
35
+ - `vitops ads tags` — prints the inert, gated `<script>` markup for stacks that aren't Astro.
36
+ - `vitops ads lint` — reports gaps nothing shows at runtime: a click ID that isn't captured, tracking
37
+ turned off, an undeclared call-tracking processor. Run it before launch and when adding a platform.
38
+
39
+ ## Topics
40
+
41
+ - [call-tracking.md](call-tracking.md) — showing ad visitors a forwarding number: Google's website
42
+ call conversions, per-platform numbers from a call-tracking provider, what each platform can and
43
+ can't do, consent timing, double counting, privacy policy, and how to test. Read when the task
44
+ involves phone numbers, `tel:` links or "calls from ads".
45
+
46
+ See also the `emdash-contact-form` skill (where form conversions land and what the notification says) and the
47
+ `vitops-csp` skill (origins a new tag needs before the CSP is enforced).
48
+
49
+ ## Conversion tracking details
50
+
51
+ `vitops docs tracking`. `site.tracking.enabled` captures ad click IDs and UTMs from the
52
+ landing URL into the first-party `_ac` cookie; a conversion route reads it back and notifies
53
+ via `site.notifications`. Points worth holding:
54
+
55
+ - `_ac` is a 90-day identifier, so it waits on `marketing` (or `site.tracking.category`) and
56
+ the capture **`require()`s** it — see above.
57
+ - Only an arrival actually carrying a click ID or UTM asks; organic visitors are never
58
+ interrupted.
59
+ - The read is synchronous and the _write_ is what waits — the click ID exists only in the
60
+ landing URL, so it cannot be deferred.
61
+ - Only the `email` channel (Cloudflare Email Sending) is implemented. Its sending domain must
62
+ be onboarded with `wrangler email sending enable <domain>` or every send fails; the tool
63
+ surfaces that error verbatim rather than as "send failed".
64
+ - `--dry`-style planning is pure and reports **why** anything was skipped. A silently unsent
65
+ notification is indistinguishable from no conversion, so never leave one unexplained.
@@ -0,0 +1,106 @@
1
+ # Call tracking numbers
2
+
3
+ Goal: attribute a real phone call to the ad that caused it, not just the `tel:` tap. That means
4
+ showing an ad visitor a different number than everyone else, then reading which number was dialled.
5
+
6
+ ## What each platform can do
7
+
8
+ | Platform | Number on the website? |
9
+ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
10
+ | **Google Ads** | Yes — _website call conversions_. Google supplies the forwarding number. Canada is supported (local, toll-free). |
11
+ | **Microsoft Ads** | No. Forwarding numbers exist only inside the ad's call extension, and only in the US, UK and Australia. |
12
+ | Meta, LinkedIn, TikTok, Reddit, Pinterest, Snapchat | No platform-provided numbers at all. |
13
+
14
+ For everything except Google the only route is a number the client provisions with a call-tracking
15
+ provider (Twilio, CallRail, CallScaler, …), forwarding to the real line, shown to that platform's
16
+ visitors. That is `site.tracking.phoneNumbers`. Re-check Google's and Microsoft's availability pages
17
+ before promising a client either: country lists change.
18
+
19
+ ## Rules that hold for both routes
20
+
21
+ - **The served HTML always carries the real number.** Swapping happens in the browser only, so
22
+ crawlers, structured data, no-JS visitors and local-SEO listings all see one consistent number, and
23
+ `organization.phone` stays the truth. Never render a tracking number server-side.
24
+ - **Only matching links and marked text are swapped.** A `tel:` link is swapped when its digits equal
25
+ the configured number (href and any text that is that number; a link labelled "Call us" only has its
26
+ href changed). Other text is swapped only inside an element with `data-vitops-phone`. Write the
27
+ number on the page in the same digits as the config — `613-555-0100`, `(613) 555-0100` and
28
+ `+1 613 555 0100` all match; a number written with a typo never does and is silently left alone.
29
+ - **An organic visitor is never swapped.** Nobody sees a tracking number they weren't sent to.
30
+ - **Needs `site.tracking.enabled: true`.** The capture script does the swapping; validation rejects
31
+ either feature without it.
32
+
33
+ ## Google: `site.ads.google.callTracking`
34
+
35
+ ```json
36
+ "ads": { "google": {
37
+ "pixelId": "AW-123456789",
38
+ "callTracking": { "label": "AbC_dEf-1", "number": "613-555-0100" }
39
+ } }
40
+ ```
41
+
42
+ - `label` belongs to a **separate** Google Ads conversion action of type _Calls from a phone number on
43
+ your website_, not the `conversionLabel.call` action (that one counts taps).
44
+ - Output is `gtag('config', 'AW-…/<label>', { phone_conversion_number, phone_conversion_callback })`,
45
+ inside the same consent-gated `marketing` tag, so nothing loads or swaps before consent.
46
+ - Google replaces displayed **text** only. The callback hands the result to the capture script, which
47
+ rewrites the `tel:` href. Without that, the visible number would be Google's and the tap would dial
48
+ the real one.
49
+ - Google decides who is shown a number (ad click, remembered for a while in a cookie; `cache: false`
50
+ turns that cache off). Only one number per page can be tracked this way.
51
+ - **Double counting:** one call can count as the tap action and the call action. In Google Ads make
52
+ one secondary. `vitops ads lint` reminds you when both are set.
53
+ - **Verification:** with a swapped number Google can't verify the domain by scanning the page. Link
54
+ Search Console. A tag installed through Google Tag Manager can't verify it either, so don't move this
55
+ tag into GTM.
56
+ - Test: after granting marketing consent, load a landing URL with `#google-wcc-debug` appended and
57
+ confirm the number and the `tel:` href both changed. Set the action's minimum call length in
58
+ Google Ads.
59
+ - Not available on the Display Network.
60
+
61
+ ## Everyone else: `site.tracking.phoneNumbers`
62
+
63
+ ```json
64
+ "tracking": { "enabled": true, "phoneNumbers": {
65
+ "number": "613-555-0100",
66
+ "provider": "callrail",
67
+ "sources": { "meta": "613-555-0101", "microsoft": "613-555-0102", "default-paid": "613-555-0199" }
68
+ } }
69
+ ```
70
+
71
+ - `sources` keys are the `site.ads` platform names plus `default-paid`, the fallback for a paid visit
72
+ (any click ID, or `utm_medium` of `cpc`/`ppc`/`paid…`) from a platform with no number of its own.
73
+ Unknown keys fail validation. A `google` key is rejected while `callTracking` is set — two numbers
74
+ would compete for one visitor.
75
+ - Which source wins: the **landing URL** first (click ID, then a known `utm_source` such as
76
+ `facebook`→meta, `bing`→microsoft), then the stored `_ac` cookie. A fresh Meta click beats an old
77
+ stored Google ID.
78
+ - **Consent timing:** on the landing page the number is chosen from the URL, which is not storage and
79
+ needs no permission. On later pages it comes from `_ac`, which exists only if `marketing` was
80
+ granted. A visitor who declines therefore sees a tracking number on the landing page only — say so
81
+ to the client; it is a deliberate consequence of not storing without consent.
82
+ - The tap beacon carries which number was dialled, so the conversion notification says e.g. "Number
83
+ shown: meta tracking number". The call itself is attributed by the provider's own reporting.
84
+ - The decision lives in `@getvitops/utils/tracking`'s `selectPhoneNumber()` (pure, tested). The
85
+ capture script `packages/astro/src/scripts/tracking.ts` mirrors it because it is inlined into the
86
+ document; `tracking.test.ts` pins the two, so change them together.
87
+
88
+ ## Privacy policy
89
+
90
+ A call-tracking provider records every caller's number and often a recording, so it is a processor the
91
+ generated policy must name. State it in `site.legal.privacyPolicy.processors` (the toolchain has no
92
+ known-provider entry for these yet). `phoneNumbers.provider` is informational and exists so `vitops ads
93
+ lint` can flag a provider missing from that list. Call recording needs its own caller notice; that
94
+ belongs in the provider's settings, not here.
95
+
96
+ ## CSP
97
+
98
+ If the CSP is enforced, Google's number fetch needs Google origins in `script-src`/`connect-src`;
99
+ watch the Report-Only console on a landing page with `#google-wcc-debug` for the exact ones. See the
100
+ `vitops-csp` skill.
101
+
102
+ ## Verifying a change
103
+
104
+ - `vitops validate` / `vitops ads lint` after editing `site.json`.
105
+ - In a browser: load `/?fbclid=test` and confirm the Meta number appears and the `tel:` href matches;
106
+ load `/` and confirm the real number; load with JavaScript disabled and confirm the real number.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: vitops-consent
3
+ description: Use before gating, adding or changing any non-essential third-party tag, cookie, embed or `localStorage` write on a site with the consent gate (`<wc-consent>`, `data-consent`, `window.vitopsConsent`), or when a consent banner does not appear or a tag loads before consent.
4
+ ---
5
+
6
+ # Consent and third-party tags
7
+
8
+ If the site has a consent gate, **every** non-essential third party goes through it. Run
9
+ `vitops docs consent` before wiring one up — the failure modes here are silent and legal.
10
+ The four that bite:
11
+
12
+ - **A gated tag must render inert.** `type="text/plain"` + `data-consent="<category>"` +
13
+ the URL on `data-src` (never `src`), plus `data-consent-cookies` naming what it sets so
14
+ a revoke can clear them. Given a live `src` the browser fetches the library immediately
15
+ and the gate is decorative — an undecided visitor's page must issue **no** third-party
16
+ request.
17
+ - **Use `require()`, not `granted()`, when you want permission.** `granted()` is passive:
18
+ it never raises the banner, so on a site where nothing else demands that category it is a
19
+ **permanent silent no-op** and your write never happens. `require()` registers the demand,
20
+ which is what makes the banner appear. `request()` is the promise form.
21
+ - **No `window.vitopsConsent` means the site has no gate — store freely.** It does not mean
22
+ denied. (A synchronous stub in `<head>` covers the window before the runtime loads, so
23
+ absence is reliable. Listen for the `vitops:consent` event rather than calling
24
+ `subscribe()`, which the stub lacks.)
25
+ - **Patch exactly the categories you put on screen.** `acceptAll()` / `rejectAll()` mean
26
+ literally every optional category — using one to answer a single-category prompt records
27
+ consent nobody gave. "Not asked" is a third value (`null`), distinct from declined, and
28
+ only it can be re-prompted.
29
+
30
+ The banner is **demand-driven**: it appears when something actually asks, not on first visit.
31
+ A site whose only analytics provider is cookieless never interrupts anyone. Don't "fix" a
32
+ missing banner by forcing it to show — find out what should have demanded a category.
33
+
34
+ Whatever loads must match what the **generated cookie notice** discloses; the Astro
35
+ integration warns when they disagree, and that is a compliance defect, not a doc nit.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: csp
2
+ name: vitops-csp
3
3
  description: The Content Security Policy this site ships (Report-Only) and what to check before enforcing it. Use when changing `public/_headers`, adding an embed, tracking snippet or third-party script, turning the CSP on, or seeing CSP reports in the console.
4
4
  ---
5
5
 
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: vitops-see-the-page
3
+ description: How to look at a rendered page of this site (layout, colour, dark mode, no-JS fallback, mobile width) with playwright-cli. Use whenever you need to see, screenshot or click through a page, verify a visual change, or check how a page behaves without JavaScript.
4
+ ---
5
+
6
+ # Seeing the rendered page
7
+
8
+ `playwright-cli` is the only way to look at a page here. Don't use the Claude-in-Chrome extension or a
9
+ browser MCP server; `.claude/settings.json` denies them. The full command reference is the upstream
10
+ `playwright-cli` skill (written by `prepare` into `.claude/skills/playwright-cli/`); this file only
11
+ covers what is specific to this site.
12
+
13
+ ## Steps
14
+
15
+ 1. **Serve the page first.** `vpr dev` for source changes, or `vpr build` then the preview command
16
+ for exactly what ships. On EmDash, run `vpr prepare-local-db` before the first build.
17
+ 2. **Open a named session:** `pnpm exec playwright cli -s=<task> open http://localhost:<port>/<path>`.
18
+ One session per task; `close` it when done.
19
+ 3. **`snapshot` first.** It's text, cheap, and gives element refs. `screenshot` only when the
20
+ question is visual (spacing, colour, overflow). Output files land in `.playwright-cli/`
21
+ (gitignored).
22
+ 4. **Dark mode:** set `data-theme="dark"` on `<html>` (the attribute `<wc-color-scheme-toggle>` writes)
23
+ rather than clicking through the toggle.
24
+ 5. **Mobile width:** `open --device="iPhone 15"` or `--mobile`.
25
+ 6. **No-JS fallback:** the web components must be usable without JavaScript. Open with a config
26
+ file whose `browser.contextOptions` sets `javaScriptEnabled: false` and check the slotted markup
27
+ is readable and operable.
28
+
29
+ ## Don't
30
+
31
+ - Don't sign in to a production admin or paste real credentials into a session.
32
+ - Don't leave sessions running (`close-all` if unsure).
33
+ - If the browser is missing: `pnpm exec playwright cli install-browser chromium`.