@getvitops/generator 3.0.0 → 4.1.0

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/dist/index.mjs CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash } from "node:crypto";
6
6
  import { transform } from "lightningcss";
7
7
  import * as z from "zod/mini";
8
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
8
9
  import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
9
10
  import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
10
11
  import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
@@ -20,7 +21,7 @@ import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
20
21
  *
21
22
  * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
22
23
  * WordPress target needs. Nothing sets it anywhere else, which meant the dark
23
- * flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
24
+ * flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
24
25
  * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
25
26
  * clicking "Dark" changed an attribute no rule matched. Matching both makes the
26
27
  * component work on every target without changing what Bricks already does.
@@ -38,7 +39,7 @@ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
38
39
  *
39
40
  * Derived from `DARK_SEL`'s attributes rather than written out a third time, and
40
41
  * inverted: this must apply when NO explicit choice has been made. That is
41
- * exactly what `<color-scheme-toggle>`'s "System" position produces — it removes
42
+ * exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
42
43
  * `data-theme` entirely — so with this block present System resolves to the OS
43
44
  * instead of silently falling through to light, and needs no JS to do it.
44
45
  *
@@ -519,7 +520,7 @@ const DesignSystemBlock = desc(z.strictObject({
519
520
  "light",
520
521
  "dark",
521
522
  "system"
522
- ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
523
+ ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<wc-color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
523
524
  }), "The design system: named themes plus the system-wide facts (which theme, which appearance). A bare design system, or a bare theme map, is accepted as shorthand for `{ themes: … }`.");
524
525
  const LocaleSchema = z.object({
525
526
  name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
@@ -629,11 +630,23 @@ const DnsDomainSchema = z.object({
629
630
  nameservers: z.optional(z.array(z.string())),
630
631
  records: z.optional(z.array(DnsRecordSchema))
631
632
  });
633
+ /**
634
+ * One domain to onboard into Google Search Console via `vitops search setup`.
635
+ *
636
+ * The verification token is fetched live from the Site Verification API at run
637
+ * time and written to DNS — it is never stored here. What the config records is
638
+ * the *intent*: which domains to onboard, and who should co-own them.
639
+ */
640
+ const SearchConsoleSetupSchema = z.object({
641
+ delegatedOwners: desc(z.optional(z.array(z.email())), "Emails added as verification owners on the Site Verification web resource. Automated via the API — additive and idempotent, never removes an existing owner."),
642
+ fullUserGroup: desc(z.optional(z.email()), "A Google Group to grant Full-User access in Search Console. SURFACED AS A REMINDER only: Search Console exposes no user/permission API, so this step stays manual by design.")
643
+ });
632
644
  const RobotsSchema = z.string();
633
645
  const EnvironmentSchema = z.object({
634
646
  url: desc(z.url(), "Public origin of this environment."),
635
647
  api: desc(z.optional(z.url()), "API origin, when different from `url`."),
636
648
  analytics: desc(z.optional(z.boolean()), "Whether analytics fire in this environment."),
649
+ ads: desc(z.optional(z.boolean()), "Whether `site.ads` pixels fire in this environment. Defaults to `analytics`, then true — a preview deployment sending pageviews is survivable, one firing conversion pixels is not, so this can be turned off on its own."),
637
650
  robots: desc(z.optional(RobotsSchema), "Robots policy (e.g. \"noindex,nofollow\" for dev)."),
638
651
  variant: desc(z.optional(z.string()), "Active `abTesting.variants` key for this environment.")
639
652
  });
@@ -818,7 +831,7 @@ const TwitterSchema = z.object({
818
831
  image: z.optional(ImageRefSchema)
819
832
  });
820
833
  /**
821
- * What `vitops notify` needs to tell search engines a deploy happened.
834
+ * What `vitops search notify` needs to tell search engines a deploy happened.
822
835
  *
823
836
  * Scoped by what search engines actually accept, which is narrower than it looks:
824
837
  * Google exposes **no** "request indexing" API (the Search Console button is not in
@@ -832,7 +845,7 @@ const TwitterSchema = z.object({
832
845
  * (a key, a property, which pages matter), never what to say about it.
833
846
  */
834
847
  const IndexNowSchema = z.object({
835
- key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops notify --new-key`."),
848
+ key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops search notify --new-key`."),
836
849
  keyLocation: desc(z.optional(z.url()), "Absolute URL of the key file. Defaults to `<canonical>/<key>.txt`; set it only when the file lives elsewhere."),
837
850
  endpoint: desc(z.optional(z.url()), "IndexNow endpoint (default `https://api.indexnow.org/indexnow`). Any participating engine shares submissions with the rest, so one is normally enough.")
838
851
  });
@@ -843,8 +856,49 @@ const SearchConsoleSchema = z.object({
843
856
  const IndexingSchema = z.object({
844
857
  sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
845
858
  indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
846
- searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a service-account credential in `VITOPS_GSC_SERVICE_ACCOUNT` or `GOOGLE_APPLICATION_CREDENTIALS` at run time — never put the key in this file."),
847
- priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
859
+ searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a credential at run time — either a service account in `VITOPS_GSC_SERVICE_ACCOUNT` / `GOOGLE_APPLICATION_CREDENTIALS`, or the user OAuth credential `vitops search setup` uses (`VITOPS_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` / `_REFRESH_TOKEN`). The service account wins when both are set. Never put a key in this file."),
860
+ priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops search notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
861
+ });
862
+ /**
863
+ * The ad platforms `vitops ads` can link a site to.
864
+ *
865
+ * A closed enum, mirroring `AD_PROVIDER_KEYS` in `@getvitops/utils/ads` — that
866
+ * module owns what each platform *can do* (whether it verifies a domain, what its
867
+ * tag is, which click ID it stamps), this owns what a config may *say*. Mirrored
868
+ * rather than derived because a `z.enum` needs literals to reach the published JSON
869
+ * Schema; `config.test.ts` asserts the two lists are the same set, so a provider
870
+ * added to one and not the other fails the build rather than silently getting no
871
+ * verification step and no tag.
872
+ */
873
+ const AD_PROVIDERS = [
874
+ "google",
875
+ "meta",
876
+ "linkedin",
877
+ "reddit",
878
+ "tiktok",
879
+ "microsoft",
880
+ "pinterest",
881
+ "snapchat"
882
+ ];
883
+ const AdProviderSchema = z.enum(AD_PROVIDERS);
884
+ /**
885
+ * One ad property this site is linked to.
886
+ *
887
+ * Facts, not prose — the same rule the legal block follows. This records *which*
888
+ * account and *which* tag id, and the platform capability table decides what can be
889
+ * done about them. It is also what finally lets the rest of the toolchain see an ad
890
+ * pixel: the generated cookie notice discloses its cookies, `vitops ads lint`
891
+ * checks its click ID is captured, and the consent gate is handed the cookie names
892
+ * to clear on revoke. A pixel pasted into a template is invisible to all three.
893
+ */
894
+ const AdPropertySchema = z.object({
895
+ accountId: desc(z.optional(z.string()), "The advertising account as the platform shows it — Google Ads customer ID, Meta ad account, LinkedIn partner ID, Reddit advertiser ID. For LinkedIn this is also the Insight Tag id; everywhere else the tag id is `pixelId`."),
896
+ pixelId: desc(z.optional(z.string()), "The tag/pixel ID the browser snippet initialises — Meta pixel, TikTok pixel, Reddit pixel, Microsoft UET tag ID, Pinterest tag ID, Snap pixel, or the Google Ads conversion ID (`AW-…`, which is NOT the customer ID)."),
897
+ conversionLabel: desc(z.optional(z.string()), "Google Ads only: the conversion action label paired with the `AW-…` conversion ID."),
898
+ domain: desc(z.optional(z.string()), "Bare hostname to verify with this platform. Defaults to the host of `domains.canonical` — set it only when the ad account is verified against a different domain."),
899
+ domainVerification: desc(z.optional(z.string()), "The verification token from the platform UI, for the platforms that verify by DNS TXT (Meta, TikTok, Pinterest, Snapchat). `vitops ads setup` prompts for it and writes it here on first run. NOT a secret — it is published in DNS, and the platform fetching it back is the ownership proof, exactly like the IndexNow key. A value containing `=` is used as the whole record, which is the escape hatch if a platform changes its prefix."),
900
+ category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the tag waits on (default `marketing`). An ad pixel is advertising by default; `analytics` is for a site using one purely for its own measurement."),
901
+ enabled: desc(z.optional(z.boolean()), "Set false to keep the property on record without emitting its tag.")
848
902
  });
849
903
  /**
850
904
  * Which template set the generated legal documents are written against. This is
@@ -856,6 +910,20 @@ const IndexingSchema = z.object({
856
910
  const JURISDICTIONS = ["ca"];
857
911
  const JurisdictionSchema = z.enum(JURISDICTIONS);
858
912
  /**
913
+ * The country each template set frames cross-border transfers as *leaving*.
914
+ *
915
+ * A jurisdiction's own country is not a transfer, so this is what filters the
916
+ * disclosure lists — without it, `country: "Canada"` renders the incoherent
917
+ * "outside of Canada, including Canada".
918
+ *
919
+ * It belongs to the **jurisdiction**, not to `organization.address`: a `ca` policy
920
+ * says "outside of Canada" even for a business incorporated elsewhere. (The
921
+ * address drives a different sentence — the terms' governing-law clause, via
922
+ * `governingCountry`.) `satisfies` makes a new jurisdiction unable to forget it,
923
+ * the same guard style as `TEMPLATES`.
924
+ */
925
+ const JURISDICTION_COUNTRIES = { ca: "Canada" };
926
+ /**
859
927
  * A third party that receives personal information. The generator derives the
860
928
  * ones it can see (analytics IDs, Turnstile, the deploy platform); this is for
861
929
  * the rest, which no other part of the config implies.
@@ -863,10 +931,16 @@ const JurisdictionSchema = z.enum(JURISDICTIONS);
863
931
  * Deliberately facts, not prose: the config records *what is true*, the template
864
932
  * owns *how it is said*. That keeps a policy correct when the wording changes.
865
933
  */
934
+ const ProcessorStorageSchema = z.object({
935
+ country: desc(z.string(), "A country or bloc where this provider stores or processes the information, as it should read in a sentence (e.g. \"Canada\", \"the United States\", \"the European Union\"). Naming the jurisdiction the policy is written for is meaningful: it says this information does not leave it."),
936
+ scope: desc(z.optional(z.string()), "Which information is held there, as a noun phrase that reads after \"in the case of\" (e.g. \"account and sign-in information\", \"mailbox contents\"). Omit when this location holds everything the provider receives — a scoped entry claims LESS than an unscoped one, so omitting it is the safe default.")
937
+ });
866
938
  const ProcessorSchema = z.object({
867
939
  name: desc(z.string(), "The provider, as a reader would recognise it (e.g. \"Stripe\")."),
868
940
  purpose: desc(z.string(), "Why they receive it, as a noun phrase that reads after \"for\" (e.g. \"payment processing\")."),
869
- country: desc(z.optional(z.string()), "Where they process it, as it should read in a sentence (e.g. \"the United States\"). Feeds the cross-border-transfer disclosure."),
941
+ country: desc(z.optional(z.string()), "Shorthand for the common case where one country is both where they store it and whose laws reach it: asserts BOTH `storage: [{ country }]` AND `operatorCountry`. Reads inside a sentence (e.g. \"the United States\"). When the two differ — a Canadian region operated by a US company — state `storage` and `operatorCountry` instead; setting this alongside either is rejected."),
942
+ storage: desc(z.optional(z.array(ProcessorStorageSchema)), "Where the information actually rests. Feeds the \"stored or processed outside of <jurisdiction>\" disclosure and nothing else. Several entries are allowed, each optionally scoped to a category of information — which is what makes a Canadian-region tenant holding identity data in the US expressible."),
943
+ operatorCountry: desc(z.optional(z.string()), "The jurisdiction that can compel this provider to hand the information over — where it is established, or from which it is controlled. Reads after \"the laws of\" (e.g. \"the United States\"); a bloc is acceptable. A SEPARATE fact from `storage`, because privacy law cares about foreign *access*, not only foreign storage: a Canadian-region service run by a US company is `storage: [{ country: \"Canada\" }]` with `operatorCountry: \"the United States\"`."),
870
944
  privacyUrl: z.optional(z.url())
871
945
  });
872
946
  const SiteSectionSchema = z.object({
@@ -878,6 +952,7 @@ const SiteSectionSchema = z.object({
878
952
  })), "Canonical domain + redirecting aliases."),
879
953
  dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
880
954
  cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
955
+ searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file."),
881
956
  environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
882
957
  abTesting: desc(z.optional(z.object({
883
958
  enabled: z.boolean(),
@@ -904,7 +979,7 @@ const SiteSectionSchema = z.object({
904
979
  bingSiteVerification: z.optional(z.string()),
905
980
  openGraph: z.optional(OpenGraphSchema),
906
981
  twitter: z.optional(TwitterSchema),
907
- indexing: desc(z.optional(IndexingSchema), "How `vitops notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
982
+ indexing: desc(z.optional(IndexingSchema), "How `vitops search notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
908
983
  })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
909
984
  analytics: desc(z.optional(z.object({
910
985
  googleAnalyticsId: z.optional(z.string()),
@@ -917,11 +992,20 @@ const SiteSectionSchema = z.object({
917
992
  cookies: desc(z.optional(z.boolean()), "Default false — Matomo runs cookieless (`disableCookies`), which is what lets the cookie notice state positively that it sets none.")
918
993
  })), "Matomo instance.")
919
994
  })), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`). Which provider is set is also what the generated privacy policy and cookie notice disclose — see `legal`."),
920
- notifications: desc(z.optional(z.object({ email: z.optional(z.email()) })), "Where site notifications (e.g. form submissions) are sent."),
995
+ notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
996
+ provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
997
+ to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
998
+ from: desc(z.optional(z.email()), "Sender. Defaults to `noreply@<domains.canonical>`. Its domain must be onboarded (`wrangler email sending enable <domain>`) or every send fails."),
999
+ fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
1000
+ replyTo: desc(z.optional(z.email()), "Reply-To header."),
1001
+ binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
1002
+ })])), "Where conversion notifications are e-mailed. A bare address is shorthand for `{ provider: \"cloudflare\", to }`.") })), "Where site notifications (form submissions, tracked calls) are sent. TODO: `sms` and `persist` channels — only `email` is implemented."),
921
1003
  tracking: desc(z.optional(z.object({
922
- enabled: z.optional(z.boolean()),
923
- platforms: z.optional(z.array(z.string()))
924
- })), "Marketing/conversion tracking toggles per platform."),
1004
+ enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
1005
+ category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the `_ac` cookie waits on (default `marketing`). It is a 90-day identifier tying a visitor to the ad that brought them, so it is only written once this category is granted — and asking for it is what raises the banner."),
1006
+ platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
1007
+ })), "Ad-click attribution for conversion tracking."),
1008
+ ads: desc(z.optional(z.partialRecord(AdProviderSchema, AdPropertySchema)), "Ad properties this site is linked to, keyed by platform. Read by `vitops ads setup` (ensure the DNS verification record), `vitops ads tags` (emit the consent-gated pixel), and the generated cookie notice (disclose what each pixel stores). Credentials never live here — the DNS write uses CLOUDFLARE_API_TOKEN from the environment."),
925
1009
  security: desc(z.optional(z.object({ turnstile: z.optional(z.object({ siteKey: desc(z.optional(z.string()), "Cloudflare Turnstile site key (public).") })) })), "Security integrations (bot protection)."),
926
1010
  legal: desc(z.optional(z.object({
927
1011
  jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
@@ -1141,6 +1225,27 @@ function validateConfig(input) {
1141
1225
  bp
1142
1226
  ], `template "${tpl}" is not in site.templates`));
1143
1227
  const privacy = site.legal?.privacyPolicy;
1228
+ /**
1229
+ * `country` is shorthand for BOTH `storage` and `operatorCountry`, so combining it
1230
+ * with either is genuinely ambiguous — is the shorthand narrowing the explicit
1231
+ * fact, or adding to it? Both readings are plausible and they make *contradictory
1232
+ * legal claims*, so this is rejected rather than resolved by a silent rule.
1233
+ *
1234
+ * Checked unconditionally, not gated on `privacy.enabled`: the fact is wrong
1235
+ * either way, and a document turned on later shouldn't surface it for the first
1236
+ * time. Here rather than as a `z.refine` on the schema because parse-time
1237
+ * rejection would make `resolveConfig` throw on the `vitops legal` path, which
1238
+ * has no formatting for a zod issue — `validateConfig` is where every other
1239
+ * cross-field legal requirement already lives, and it prints `path: message`.
1240
+ */
1241
+ for (const [i, p] of (privacy?.processors ?? []).entries()) if (p.country != null && (p.storage != null || p.operatorCountry != null)) errors.push(issue([
1242
+ "site",
1243
+ "legal",
1244
+ "privacyPolicy",
1245
+ "processors",
1246
+ i,
1247
+ "country"
1248
+ ], `processor "${p.name}": \`country\` is shorthand for BOTH \`storage\` and \`operatorCountry\`, so combining it with either makes the narrower claim ambiguous — drop \`country\` and state the two facts, or drop the two and keep the shorthand`));
1144
1249
  if (typeof privacy?.privacyOfficer === "string" && !has(org?.locations, privacy.privacyOfficer)) errors.push(issue([
1145
1250
  "site",
1146
1251
  "legal",
@@ -1175,6 +1280,18 @@ function validateConfig(input) {
1175
1280
  "keyLocation"
1176
1281
  ], "site.seo.indexing.indexNow needs site.domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
1177
1282
  }
1283
+ for (const [provider, entry] of Object.entries(site.ads ?? {})) {
1284
+ if (entry == null) continue;
1285
+ const platform = AD_PLATFORMS[provider];
1286
+ const at = [
1287
+ "site",
1288
+ "ads",
1289
+ provider
1290
+ ];
1291
+ if (entry.domainVerification != null && platform.verification.method === "none") errors.push(issue([...at, "domainVerification"], `${platform.name} has no domain verification — ${platform.verification.reason ?? "nothing verifies a domain there"}. Remove the token; linking is the tag and the account id.`));
1292
+ if (entry.domainVerification != null && entry.domain == null && site.domains?.canonical == null) errors.push(issue([...at, "domain"], `site.ads.${provider}.domainVerification needs a domain to put the record on — set site.domains.canonical, or state site.ads.${provider}.domain explicitly`));
1293
+ if (entry.accountId == null && entry.pixelId == null) errors.push(issue(at, `site.ads.${provider} identifies nothing — set accountId (the advertising account) and/or pixelId (the tag: ${platform.tag.needs} is what its snippet needs)`));
1294
+ }
1178
1295
  if (errors.length) return {
1179
1296
  ok: false,
1180
1297
  data: void 0,
@@ -1753,6 +1870,590 @@ function checkContrast(fr, numericOf, surfaceBg) {
1753
1870
  return failures;
1754
1871
  }
1755
1872
 
1873
+ //#endregion
1874
+ //#region src/tiers.ts
1875
+ const c = (classes, partial, generated = false) => ({
1876
+ ...partial ? { partial } : {},
1877
+ classes,
1878
+ generated
1879
+ });
1880
+ /**
1881
+ * Shorthand for the usual `@getvitops/astro/components/<name>.astro` export.
1882
+ *
1883
+ * Not every component lives under `components/` — `CookieConsent.astro` is exported
1884
+ * from the package root — so those are written out in full. The specifier must be
1885
+ * one a consumer can actually import: `tiers.test.ts` checks each against
1886
+ * `packages/astro/package.json`'s `exports`, because a wrong one is a copy-pasteable
1887
+ * import that fails only in the consumer's project.
1888
+ */
1889
+ const a = (name, wraps) => [{
1890
+ component: `@getvitops/astro/components/${name}.astro`,
1891
+ wraps
1892
+ }];
1893
+ const TIERS = {
1894
+ tree: {
1895
+ css: c([
1896
+ "tree",
1897
+ "tree__item",
1898
+ "tree__content",
1899
+ "tree__toggle",
1900
+ "tree__label"
1901
+ ], "patterns/tree.css", true),
1902
+ wc: {
1903
+ tag: "wc-tree",
1904
+ registered: true,
1905
+ adds: "filter, expand/collapse all, hash deep-linking"
1906
+ },
1907
+ astro: a("Tree", "wc"),
1908
+ use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
1909
+ },
1910
+ carousel: {
1911
+ css: c(["carousel"], "patterns/carousel.css", true),
1912
+ wc: {
1913
+ tag: "wc-carousel",
1914
+ registered: true,
1915
+ adds: "cloned slides for a seamless loop, autoplay, snap nav"
1916
+ },
1917
+ bricks: "vitops-carousel",
1918
+ use: "`<wc-carousel>` around your slides; each child is a slide. Works unenhanced as a scroll-snap strip."
1919
+ },
1920
+ entries: {
1921
+ css: c([
1922
+ "entries__table",
1923
+ "entries__scroll",
1924
+ "entries__nav"
1925
+ ], "patterns/table.css"),
1926
+ wc: {
1927
+ tag: "wc-entries",
1928
+ registered: true,
1929
+ adds: "parses heading + `<dl>` pairs into a table above a breakpoint"
1930
+ },
1931
+ bricks: "vitops-entries",
1932
+ use: "`<wc-entries>` around `<h3>` + `<dl>` pairs. The tier-2 exemplar: semantic pairs with no JS, a table with it."
1933
+ },
1934
+ copy: {
1935
+ css: c([
1936
+ "copy",
1937
+ "copy__button",
1938
+ "copy__icon",
1939
+ "copy__tooltip"
1940
+ ], "patterns/copy.css"),
1941
+ wc: {
1942
+ tag: "wc-copy",
1943
+ registered: true,
1944
+ adds: "clipboard write plus success feedback"
1945
+ },
1946
+ bricks: "vitops-copy-button",
1947
+ use: "`<wc-copy>` around the content and a `[data-copy]` button."
1948
+ },
1949
+ dismissable: {
1950
+ css: c(["banner__close", "notification__close"], "patterns/banner.css"),
1951
+ wc: {
1952
+ tag: "wc-dismissable",
1953
+ registered: true,
1954
+ adds: "fade-out + removal, optional auto-dismiss"
1955
+ },
1956
+ bricks: "vitops-dismissable",
1957
+ use: "`<wc-dismissable>` around any banner/notification containing a `[data-dismiss]` control."
1958
+ },
1959
+ "split-panel": {
1960
+ css: c([
1961
+ "splitter",
1962
+ "splitter__panel",
1963
+ "splitter__handle"
1964
+ ], "patterns/splitter.css"),
1965
+ wc: {
1966
+ tag: "wc-split-panel",
1967
+ registered: true,
1968
+ adds: "draggable divider between two panels"
1969
+ },
1970
+ bricks: "vitops-split-panel",
1971
+ use: "`<wc-split-panel>` with two children. Unenhanced they simply stack."
1972
+ },
1973
+ "image-compare": {
1974
+ css: c([
1975
+ "image-compare",
1976
+ "image-compare__before",
1977
+ "image-compare__handle"
1978
+ ], "patterns/splitter.css"),
1979
+ wc: {
1980
+ tag: "wc-image-compare",
1981
+ registered: true,
1982
+ adds: "drag handle revealing the before/after image"
1983
+ },
1984
+ bricks: "vitops-image-compare",
1985
+ use: "`<wc-image-compare>` with two images. Both are visible without JS."
1986
+ },
1987
+ marquee: {
1988
+ css: c([
1989
+ "marquee",
1990
+ "marquee__content",
1991
+ "marquee__item"
1992
+ ], "patterns/marquee.css"),
1993
+ wc: {
1994
+ tag: "wc-marquee",
1995
+ registered: true,
1996
+ adds: "fills the track with enough copies to scroll seamlessly"
1997
+ },
1998
+ use: "`<wc-marquee>` around one `.marquee__content`. Scrolls via CSS alone; JS only removes the seam."
1999
+ },
2000
+ "multi-field": {
2001
+ css: c([
2002
+ "form-group",
2003
+ "input-group",
2004
+ "fieldset"
2005
+ ], "patterns/forms.css"),
2006
+ wc: {
2007
+ tag: "wc-multi-field",
2008
+ registered: true,
2009
+ adds: "add/remove repeating field rows"
2010
+ },
2011
+ bricks: "vitops-multi-field",
2012
+ use: "`<wc-multi-field>` around a field template. One row submits fine with no JS."
2013
+ },
2014
+ "color-scheme-toggle": {
2015
+ css: c([], "patterns/icon.css"),
2016
+ wc: {
2017
+ tag: "wc-color-scheme-toggle",
2018
+ registered: true,
2019
+ adds: "segmented light/dark/system control; persists the choice (consent-gated)"
2020
+ },
2021
+ bricks: "vitops-color-scheme-toggle",
2022
+ use: "`<wc-color-scheme-toggle></wc-color-scheme-toggle>`. Renamed from `<color-scheme-toggle>` in 4.0. Shadow DOM, so it is absent rather than broken without JS."
2023
+ },
2024
+ consent: {
2025
+ css: c([
2026
+ "consent",
2027
+ "consent__body",
2028
+ "consent__options",
2029
+ "consent__actions"
2030
+ ], "patterns/consent.css"),
2031
+ wc: {
2032
+ tag: "wc-consent",
2033
+ registered: false,
2034
+ bundle: "@getvitops/core/consent",
2035
+ adds: "the permission gate itself — activates `type=\"text/plain\"` tags on grant"
2036
+ },
2037
+ astro: [{
2038
+ component: "@getvitops/astro/CookieConsent.astro",
2039
+ wraps: "wc"
2040
+ }],
2041
+ use: "`<CookieConsent />`. Its own Lit-free bundle so a site needing consent does not download a rendering framework."
2042
+ },
2043
+ "theme-editor": {
2044
+ css: c([
2045
+ "ed-panel",
2046
+ "ed-launch",
2047
+ "ed-input"
2048
+ ], "patterns/theme-editor.css"),
2049
+ wc: {
2050
+ tag: "wc-theme-editor",
2051
+ registered: false,
2052
+ bundle: "@getvitops/core/editor",
2053
+ adds: "live `:root` token overrides, export as CSS or a config patch"
2054
+ },
2055
+ use: "`<wc-theme-editor manifest=\"…\">`. TOOLING, not a page pattern — the one deliberate tier-2 exception, quarantined in its own opt-in bundle."
2056
+ },
2057
+ "color-wheel": {
2058
+ css: c([]),
2059
+ wc: {
2060
+ tag: "wc-color-wheel",
2061
+ registered: false,
2062
+ adds: "hue/chroma wheel input",
2063
+ bundle: "(none — editor-v2 track, not yet bundled)"
2064
+ },
2065
+ use: "Not shipped. Registered but absent from every bundle, so the tag is inert in a consumer project."
2066
+ },
2067
+ "oklch-color-picker": {
2068
+ css: c([]),
2069
+ wc: {
2070
+ tag: "wc-oklch-color-picker",
2071
+ registered: false,
2072
+ adds: "OKLCH L/C/H picker",
2073
+ bundle: "(none — editor-v2 track, not yet bundled)"
2074
+ },
2075
+ use: "Not shipped. See `color-wheel`."
2076
+ },
2077
+ "icon-picker": {
2078
+ css: c([], "patterns/icon.css"),
2079
+ wc: {
2080
+ tag: "wc-icon-picker",
2081
+ registered: false,
2082
+ adds: "browses the semantic icon map for the configured set",
2083
+ bundle: "(none — editor-v2 track, not yet bundled)"
2084
+ },
2085
+ use: "Not shipped. See `color-wheel`."
2086
+ },
2087
+ "typography-config": {
2088
+ css: c([]),
2089
+ wc: {
2090
+ tag: "wc-typography-config",
2091
+ registered: false,
2092
+ adds: "type role editor",
2093
+ bundle: "(none — editor-v2 track, not yet bundled)"
2094
+ },
2095
+ use: "Not shipped. See `color-wheel`."
2096
+ },
2097
+ details: {
2098
+ css: c([
2099
+ "details",
2100
+ "details-trigger",
2101
+ "details-icon"
2102
+ ], "patterns/details.css", true),
2103
+ astro: a("Details", "css"),
2104
+ use: "`<Details>` — or hand-write `<details>`. No JS at either tier; `::details-content` animates in CSS."
2105
+ },
2106
+ drawer: {
2107
+ css: c([
2108
+ "drawer",
2109
+ "drawer--start",
2110
+ "drawer--end"
2111
+ ], "patterns/drawer.css", true),
2112
+ astro: a("Drawer", "css"),
2113
+ use: "`<Drawer>` — `<dialog class=\"drawer\">` plus an Invoker Commands trigger. No JS."
2114
+ },
2115
+ popover: {
2116
+ css: c([
2117
+ "popover",
2118
+ "popover-trigger",
2119
+ "popover-anchor"
2120
+ ], "patterns/popover.css", true),
2121
+ astro: a("Popover", "css"),
2122
+ use: "`<Popover>` — native Popover API + CSS Anchor Positioning. No JS."
2123
+ },
2124
+ navshell: {
2125
+ css: c([
2126
+ "navshell",
2127
+ "navshell__bar",
2128
+ "navshell__panel",
2129
+ "navshell-toggle"
2130
+ ], "patterns/navshell.css"),
2131
+ astro: [...a("NavShell", "css"), ...a("NavShellToggle", "css")],
2132
+ use: "`<NavShell>` with a `nav` slot, plus `<NavShellToggle>` if the toggle must live outside the shell."
2133
+ },
2134
+ subgrid: {
2135
+ css: c([
2136
+ "subgrid",
2137
+ "subgrid-card",
2138
+ "pricing-grid"
2139
+ ], "patterns/subgrid.css"),
2140
+ astro: a("Subgrid", "css"),
2141
+ use: "`<Subgrid>` — emits `<ul class=\"grid\">` with row-subgrid children so cards align across tracks."
2142
+ },
2143
+ card: {
2144
+ css: c(["card"], void 0, true),
2145
+ astro: a("Cards", "css"),
2146
+ use: "`<Cards>` for a list, or the `.card` class directly. Generated pattern: has roles and states."
2147
+ },
2148
+ icon: {
2149
+ css: c([
2150
+ "icon",
2151
+ "icon-button",
2152
+ "icon-mask"
2153
+ ], "patterns/icon.css"),
2154
+ astro: a("Icon", "css"),
2155
+ bricks: "vitops-icon",
2156
+ use: "`<Icon name=\"menu\" />`. Names are semantic and resolve per configured set; a name containing `:` passes through."
2157
+ },
2158
+ split: {
2159
+ css: c([
2160
+ "split",
2161
+ "split-1-2",
2162
+ "split-reverse"
2163
+ ], "layout.css"),
2164
+ bricks: "vitops-split",
2165
+ use: "`.split` plus a ratio class. Pure CSS; `md-split-1-2` for the responsive form."
2166
+ },
2167
+ centered: {
2168
+ css: c([
2169
+ "centered",
2170
+ "spotlight",
2171
+ "breakout",
2172
+ "fullbleed"
2173
+ ], "layout.css"),
2174
+ bricks: "vitops-centered",
2175
+ use: "`.centered` as a track grid. Children land in `measure` — give it a track child to lay out inside."
2176
+ },
2177
+ sitenav: {
2178
+ css: c([
2179
+ "sitenav",
2180
+ "sitenav__link",
2181
+ "sitenav--drawer-start"
2182
+ ], "patterns/sitenav.css"),
2183
+ bricks: "vitops-sitenav",
2184
+ use: "`.sitenav` with `--bp-*` and a drawer direction. `<details>` + Invoker Commands, no JS."
2185
+ },
2186
+ "split-link": {
2187
+ css: c([
2188
+ "split-link",
2189
+ "split-link__toggle",
2190
+ "split-link__panel"
2191
+ ], "patterns/split-link.css"),
2192
+ bricks: "vitops-split-link",
2193
+ use: "`.split-link` — a link plus a disclosure toggle. No JS."
2194
+ },
2195
+ btn: {
2196
+ css: c(["btn"], void 0, true),
2197
+ use: "`<button>` gets it with no class; `.btn` carries it to other tags. `fill: false`, so states drive `color`."
2198
+ },
2199
+ cta: {
2200
+ css: c(["cta", "cta-danger"], void 0, true),
2201
+ use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
2202
+ },
2203
+ link: {
2204
+ css: c(["link", "skip-link"], "patterns/anchor-link.css", true),
2205
+ use: "`<a>` gets it at zero specificity; `.link` for other tags."
2206
+ },
2207
+ badge: {
2208
+ css: c(["badge", "badge-indicator"], "patterns/tag.css", true),
2209
+ use: "`.badge` — a STATIC label. Use `tag` if it is dismissable or editable."
2210
+ },
2211
+ tag: {
2212
+ css: c([
2213
+ "tag",
2214
+ "tag__remove",
2215
+ "tag__icon",
2216
+ "tag-list"
2217
+ ], "patterns/tag.css", true),
2218
+ use: "`.tag` — an editable/dismissable label. Its group is `label`, not `tag`."
2219
+ },
2220
+ status: {
2221
+ css: c(["status", "status-dot--pulse"], "patterns/tag.css", true),
2222
+ use: "`.status` with a role variant."
2223
+ },
2224
+ tooltip: {
2225
+ css: c(["tooltip-trigger"], "patterns/tooltip.css", true),
2226
+ use: "`.tooltip-trigger` — CSS only, anchor-positioned."
2227
+ },
2228
+ dialog: {
2229
+ css: c([
2230
+ "dialog__header",
2231
+ "dialog__body",
2232
+ "dialog__footer"
2233
+ ], "patterns/dialog.css", true),
2234
+ use: "Native `<dialog>` plus the `.dialog__*` parts. No JS beyond `showModal()`."
2235
+ },
2236
+ dropdown: {
2237
+ css: c(["dropdown--show-on-hover", "dropdown-menu"], "patterns/dropdown.css", true),
2238
+ use: "`.dropdown` — Popover API based."
2239
+ },
2240
+ notification: {
2241
+ css: c([
2242
+ "notification",
2243
+ "notification__title",
2244
+ "notification__actions"
2245
+ ], "patterns/notification.css", true),
2246
+ use: "`.notification` with a role variant; wrap in `<wc-dismissable>` to make it dismissable."
2247
+ },
2248
+ lightbox: {
2249
+ css: c(["lightbox", "lightbox-dialog__content"], "patterns/lightbox.css", true),
2250
+ use: "`.lightbox` thumbnails plus a `<dialog>`."
2251
+ },
2252
+ comment: {
2253
+ css: c([
2254
+ "comment",
2255
+ "comment__author",
2256
+ "comment-thread"
2257
+ ], "patterns/comment.css", true),
2258
+ use: "`.comment` inside `.comment-thread`."
2259
+ },
2260
+ tabs: {
2261
+ css: c(["tabs"], void 0, true),
2262
+ use: "Generated pattern only; no structural partial and no component yet."
2263
+ },
2264
+ nav: {
2265
+ css: c([
2266
+ "menu",
2267
+ "navbar",
2268
+ "nav-collapse",
2269
+ "drawer-nav"
2270
+ ], "patterns/nav.css", true),
2271
+ use: "`.menu` / `.navbar`. See `navbar` and `navshell` for the newer shells."
2272
+ },
2273
+ navbar: {
2274
+ css: c([
2275
+ "navbar",
2276
+ "navbar--sticky",
2277
+ "nav-items"
2278
+ ], "patterns/navbar.css"),
2279
+ use: "`.navbar` with `--start/--center/--end`."
2280
+ },
2281
+ banner: {
2282
+ css: c([
2283
+ "banner",
2284
+ "banner__content",
2285
+ "banner__action"
2286
+ ], "patterns/banner.css", true),
2287
+ use: "`.banner`; wrap in `<wc-dismissable>` for a close button that works."
2288
+ },
2289
+ table: {
2290
+ css: c(["table", "table-wrapper"], "patterns/table.css", true),
2291
+ use: "`.table` inside `.table-wrapper`. See `entries` for the responsive-to-table pattern."
2292
+ },
2293
+ list: {
2294
+ css: c(["facet-list", "filtered-list"], "patterns/list.css", true),
2295
+ use: "`.facet-list` / `.filtered-list`."
2296
+ },
2297
+ "pull-quote": {
2298
+ css: c(["pull-quote", "pull-quote__attribution"], "patterns/pull-quote.css", true),
2299
+ use: "`.pull-quote`. Note the type role `quote` is separate."
2300
+ },
2301
+ combobox: {
2302
+ css: c([
2303
+ "combobox",
2304
+ "combobox__listbox",
2305
+ "combobox__option"
2306
+ ], "patterns/combobox.css", true),
2307
+ use: "CSS shell only — the Lit component is a TODO, so it is not interactive yet."
2308
+ },
2309
+ forms: {
2310
+ css: c([
2311
+ "form-group",
2312
+ "input-group",
2313
+ "fieldset",
2314
+ "custom-check"
2315
+ ], "patterns/forms.css", true),
2316
+ use: "Wrap text controls in `.form-group` or `.input-group` — a bare `<input>` gets browser defaults."
2317
+ },
2318
+ cluster: {
2319
+ css: c([
2320
+ "cluster",
2321
+ "cluster-between",
2322
+ "cluster-start"
2323
+ ], "patterns/cluster.css"),
2324
+ use: "`.cluster` plus an alignment variant."
2325
+ },
2326
+ code: {
2327
+ css: c([], "patterns/code.css"),
2328
+ use: "Element selectors on `pre`/`code`; no class needed."
2329
+ },
2330
+ grouped: {
2331
+ css: c([
2332
+ "group",
2333
+ "group-inline",
2334
+ "bordered"
2335
+ ], "patterns/grouped.css"),
2336
+ use: "`.group-inline` / `.group-block` to join adjacent controls."
2337
+ },
2338
+ "horizontal-scroll": {
2339
+ css: c(["horizontal-scroll"], "patterns/horizontal-scroll.css"),
2340
+ use: "`.horizontal-scroll` for a snap strip."
2341
+ },
2342
+ masonry: {
2343
+ css: c(["masonry", "masonry__item"], "patterns/masonry.css"),
2344
+ use: "`.masonry` — CSS columns based."
2345
+ },
2346
+ media: {
2347
+ css: c([
2348
+ "media",
2349
+ "media__figure",
2350
+ "tile"
2351
+ ], "patterns/media.css"),
2352
+ use: "`.media` for a figure/body pair; `.tile` for the stacked form."
2353
+ },
2354
+ overlay: {
2355
+ css: c(["overlay"], "patterns/overlay.css"),
2356
+ use: "`.overlay` for a scrim."
2357
+ },
2358
+ reveal: {
2359
+ css: c([
2360
+ "reveal",
2361
+ "reveal-fade",
2362
+ "reveal-h"
2363
+ ], "patterns/reveal.css"),
2364
+ use: "`.reveal` — scroll-driven, no JS. Hosts the `interpolate-size` that `<details>` animation needs."
2365
+ },
2366
+ "scroll-based": {
2367
+ css: c([
2368
+ "parallax",
2369
+ "scroll-progress",
2370
+ "scroll-spy"
2371
+ ], "patterns/scroll-based.css"),
2372
+ use: "Scroll-timeline driven; no JS."
2373
+ },
2374
+ "scroll-target": {
2375
+ css: c(["scroll-target-group", "toc-sidebar"], "patterns/scroll-target.css"),
2376
+ use: "`.scroll-target-group` for `:target-current` highlighting."
2377
+ },
2378
+ separator: {
2379
+ css: c([
2380
+ "separator",
2381
+ "separator__text",
2382
+ "separator__ornament"
2383
+ ], "patterns/separator.css"),
2384
+ use: "`.separator`, optionally with text or an ornament."
2385
+ },
2386
+ stack: {
2387
+ css: c(["stack", "stack__card"], "patterns/stack.css"),
2388
+ use: "`.stack` for overlapping cards."
2389
+ },
2390
+ sticky: {
2391
+ css: c([
2392
+ "sticky",
2393
+ "sticky-header",
2394
+ "sticky-nav"
2395
+ ], "patterns/sticky.css"),
2396
+ use: "`.sticky-header` etc. Pure CSS."
2397
+ },
2398
+ svg: {
2399
+ css: c([
2400
+ "svg-container",
2401
+ "svg-shape",
2402
+ "svg-animated"
2403
+ ], "patterns/svg.css"),
2404
+ use: "`.svg-*` helpers for inline SVG."
2405
+ },
2406
+ "text-effects": {
2407
+ css: c([
2408
+ "typing",
2409
+ "text-highlight",
2410
+ "text-gradient-animate",
2411
+ "text-stagger"
2412
+ ], "patterns/text-effects.css"),
2413
+ use: "`.typing`, `.text-highlight`, … CSS animation only."
2414
+ }
2415
+ };
2416
+ /** Pattern names, sorted — a stable order for docs and tests. */
2417
+ const TIER_NAMES = Object.keys(TIERS).sort();
2418
+ /** Every `wc-*` tag the manifest knows, whether shipped in `elements.js` or not. */
2419
+ function tierTags() {
2420
+ return Object.entries(TIERS).filter(([, e]) => e.wc).map(([pattern, e]) => ({
2421
+ tag: e.wc.tag,
2422
+ registered: e.wc.registered,
2423
+ pattern
2424
+ }));
2425
+ }
2426
+ /**
2427
+ * The patterns one tier provides, in `TIER_NAMES` order.
2428
+ *
2429
+ * Every renderer goes through this rather than filtering `TIERS` itself: the docs
2430
+ * bundle projects all four tiers into one table for agents, the docsite projects
2431
+ * each tier as its own page for humans, and they must agree about which patterns
2432
+ * are in a tier. Five copies of `.filter(e => e.wc)` would agree until one of them
2433
+ * didn't.
2434
+ *
2435
+ * `css` needs a predicate rather than a presence check, which is the non-obvious
2436
+ * part. `TierEntry.css` is required, so *every* entry has one — including the
2437
+ * editor-v2 track, whose entries carry `c([])`: no partial, no classes, not
2438
+ * generated. Treating the field's presence as membership would list four patterns
2439
+ * on the CSS page with nothing to show for them, and `color-wheel` genuinely has
2440
+ * no CSS. The other three tiers are optional fields, so presence is the test.
2441
+ */
2442
+ function tierPatterns(tier) {
2443
+ const has = (e) => {
2444
+ switch (tier) {
2445
+ case "css": return !!e.css.partial || e.css.classes.length > 0 || e.css.generated;
2446
+ case "wc": return !!e.wc;
2447
+ case "astro": return !!e.astro?.length;
2448
+ case "bricks": return !!e.bricks;
2449
+ }
2450
+ };
2451
+ return TIER_NAMES.filter((name) => has(TIERS[name])).map((name) => ({
2452
+ name,
2453
+ entry: TIERS[name]
2454
+ }));
2455
+ }
2456
+
1756
2457
  //#endregion
1757
2458
  //#region src/docs.ts
1758
2459
  /**
@@ -1770,13 +2471,13 @@ const ORDER = [
1770
2471
  "split",
1771
2472
  "centered",
1772
2473
  "carousel",
1773
- "color-scheme-toggle",
2474
+ "wc-color-scheme-toggle",
1774
2475
  "wc-copy",
1775
2476
  "dismissable",
1776
2477
  "entries",
1777
2478
  "image-compare",
1778
2479
  "sitenav",
1779
- "wc-multifield",
2480
+ "wc-multi-field",
1780
2481
  "split-link",
1781
2482
  "split-panel"
1782
2483
  ];
@@ -2515,26 +3216,26 @@ function childEntries(node) {
2515
3216
  }];
2516
3217
  return [];
2517
3218
  }
2518
- function renderSchemaNode(label, node, required, depth, out) {
3219
+ function renderSchemaNode(label, node, required, depth, out, maxDepth = 3) {
2519
3220
  const indent = " ".repeat(depth);
2520
3221
  const meta = `(${typeLabel(node)}${required ? ", required" : ""})`;
2521
3222
  out.push(`${indent}- \`${label}\` ${meta}${node.description ? ` — ${node.description}` : ""}`);
2522
- if (depth >= 3) return;
3223
+ if (depth >= maxDepth) return;
2523
3224
  if (node.anyOf && !node.anyOf.every(isPrimitive)) {
2524
3225
  for (const variant of node.anyOf) {
2525
3226
  out.push(`${indent} - *one of*${variant.description ? ` — ${variant.description}` : ""}`);
2526
- for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out);
3227
+ for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out, maxDepth);
2527
3228
  }
2528
3229
  return;
2529
3230
  }
2530
- for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out);
3231
+ for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out, maxDepth);
2531
3232
  }
2532
3233
  /**
2533
3234
  * One `## <key>` section per top-level property: its description, then its
2534
3235
  * fields as a bullet tree. Shared by both references so the two cannot drift in
2535
3236
  * presentation — only in which schema they are pointed at.
2536
3237
  */
2537
- function schemaSections(schema, heading = "##") {
3238
+ function schemaSections(schema, heading = "##", maxDepth = 3) {
2538
3239
  const required = schema.required ?? [];
2539
3240
  const sections = [];
2540
3241
  for (const [key, node] of Object.entries(schema.properties ?? {})) {
@@ -2542,12 +3243,102 @@ function schemaSections(schema, heading = "##") {
2542
3243
  const parts = [`${heading} \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`];
2543
3244
  if (node.description) parts.push("", node.description);
2544
3245
  const bullets = [];
2545
- for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets);
3246
+ for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets, maxDepth);
2546
3247
  if (bullets.length) parts.push("", ...bullets);
2547
3248
  sections.push(parts.join("\n"));
2548
3249
  }
2549
3250
  return sections;
2550
3251
  }
3252
+ const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({
3253
+ "&": "&amp;",
3254
+ "<": "&lt;",
3255
+ ">": "&gt;",
3256
+ "\"": "&quot;"
3257
+ })[c]);
3258
+ /** Sentinel for a lifted code span. Cannot occur in authored schema prose. */
3259
+ const CODE_SLOT = "\0";
3260
+ /**
3261
+ * The inline markdown the schema's `desc()` text actually uses, as HTML. Closed
3262
+ * on purpose, exactly like the legal renderer's subset: these strings are
3263
+ * authored in `schema.ts`, so the set is knowable rather than guessed at.
3264
+ *
3265
+ * Escaping happens before any markup is emitted, so a description containing `<`
3266
+ * cannot inject tags — several do (`<canonical>/<key>.txt`). Exported because the
3267
+ * only safe place for this is one tested function; a caller doing its own escaping
3268
+ * is how one of them eventually doesn't.
3269
+ *
3270
+ * **Code spans are lifted out FIRST, and that is load-bearing rather than tidy.**
3271
+ * `colors.utilities` describes the target families as `` `bg-*` ``, `` `text-*` ``,
3272
+ * `` `border-*` `` — literal asterisk WILDCARDS. Run emphasis over the raw string
3273
+ * and the `*` closing `bg-*` pairs with the one closing `text-*`, wrapping the
3274
+ * text between two unrelated utilities in `<em>` and eating both asterisks. The
3275
+ * prose then silently describes families that don't exist. Lifting code spans
3276
+ * first makes emphasis structurally unable to see inside them.
3277
+ */
3278
+ const renderInlineMarkdown = (s) => {
3279
+ const spans = [];
3280
+ return escapeHtml(s.replace(/`([^`]+)`/g, (_, body) => {
3281
+ spans.push(body);
3282
+ return `${CODE_SLOT}${spans.length - 1}${CODE_SLOT}`;
3283
+ })).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+)\*/g, "<em>$1</em>").replace(new RegExp(`${CODE_SLOT}(\\d+)${CODE_SLOT}`, "g"), (_, i) => `<code>${escapeHtml(spans[Number(i)] ?? "")}</code>`);
3284
+ };
3285
+ /**
3286
+ * Walk a JSON Schema into tree DATA.
3287
+ *
3288
+ * The same walk that produces the `authoring.md` / `config.md` bullet lists,
3289
+ * returned as a node model instead of a string. Returning data rather than markup
3290
+ * is deliberate and follows `roleColorUtilities()`: the *shape* of the tree is one
3291
+ * decision, and each medium renders it — markdown for agents, an accessible
3292
+ * `<details>` tree for the docs site. An HTML-emitting version here would be a
3293
+ * second source of the markup contract for `<wc-tree>` to drift from.
3294
+ *
3295
+ * Ids are dotted paths so a field can be linked to directly. Map-value and
3296
+ * array-item pseudo-labels are skipped in the path: `site.dns.records` reads
3297
+ * better than `site.dns.<domain>.records`, and neither `<domain>` nor `[items]`
3298
+ * is a real key.
3299
+ */
3300
+ function schemaTreeNodes(schema, opts = {}) {
3301
+ const { idPrefix = "", maxDepth = 12, prune = [] } = opts;
3302
+ const pruned = new Set(prune);
3303
+ const isPseudo = (label) => label === "<name>" || label === "[items]";
3304
+ const join = (a, b) => a ? `${a}.${b}` : b;
3305
+ /**
3306
+ * Paths already claimed. An `anyOf` branch has no key of its own, so sibling
3307
+ * branches sharing a field name resolve to the same dotted path
3308
+ * (`site.templates.type` exists in all three template variants). Rather than
3309
+ * invent a branch index that no real config document contains, the first
3310
+ * occurrence keeps the id — which is what `getElementById` would have picked
3311
+ * anyway, now true instead of accidental.
3312
+ */
3313
+ const seen = /* @__PURE__ */ new Set();
3314
+ const build = (label, n, required, path, depth) => {
3315
+ const addressable = path !== "" && !isPseudo(label) && !seen.has(path);
3316
+ if (addressable) seen.add(path);
3317
+ const variants = n.anyOf && !n.anyOf.every(isPrimitive) ? n.anyOf : [];
3318
+ const children = [];
3319
+ if (depth < maxDepth && !pruned.has(path)) {
3320
+ for (const v of variants) children.push({
3321
+ label: "one of",
3322
+ type: typeLabel(v),
3323
+ required: false,
3324
+ variant: true,
3325
+ ...v.description ? { description: v.description } : {},
3326
+ children: childEntries(v).map((c) => build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 2))
3327
+ });
3328
+ if (!variants.length) for (const c of childEntries(n)) children.push(build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 1));
3329
+ }
3330
+ return {
3331
+ label,
3332
+ type: typeLabel(n),
3333
+ required,
3334
+ ...n.description ? { description: n.description } : {},
3335
+ ...addressable ? { id: path } : {},
3336
+ children
3337
+ };
3338
+ };
3339
+ const required = schema.required ?? [];
3340
+ return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
3341
+ }
2551
3342
  function renderAuthoring() {
2552
3343
  const sections = schemaSections(jsonSchema);
2553
3344
  return `${frontmatter({
@@ -2592,7 +3383,7 @@ function renderConfig() {
2592
3383
  const required = schema.required ?? [];
2593
3384
  const sections = Object.entries(props).map(([key, node]) => {
2594
3385
  const head = [`## \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`, ...node.description ? ["", node.description] : []].join("\n");
2595
- const fields = schemaSections(node, "###").join("\n\n");
3386
+ const fields = schemaSections(node, "###", key === "designSystem" ? 0 : 3).join("\n\n");
2596
3387
  if (key === "designSystem") return [
2597
3388
  head,
2598
3389
  "The token set. Its `themes.<name>` entries are full design systems — every field of\none is in [authoring.md](authoring.md). Only the wrapper is listed here.",
@@ -2618,7 +3409,7 @@ function renderConfig() {
2618
3409
 
2619
3410
  The document that describes a **whole project**: the token set, the company, and the
2620
3411
  published site. It is the input every command that needs more than tokens is anchored
2621
- to — \`vitops legal\`, \`vitops icons\`, \`vitops indexing\` — and it can also stand in for
3412
+ to — \`vitops legal\`, \`vitops icons\`, \`vitops search\` — and it can also stand in for
2622
3413
  a \`design-system.json\` anywhere the toolchain takes one, since it carries a full
2623
3414
  \`designSystem\`.
2624
3415
 
@@ -2911,14 +3702,14 @@ Raw hue steps (\`--color-<hue>-<step>\`) are the exception — they are fixed va
2911
3702
  ignores the appearance.
2912
3703
 
2913
3704
  Two attributes, one flip: \`data-brx-theme\` is Bricks' own (Bricks sets it on the
2914
- WordPress target), \`data-theme\` is what the shipped \`<color-scheme-toggle>\` writes on
3705
+ WordPress target), \`data-theme\` is what the shipped \`<wc-color-scheme-toggle>\` writes on
2915
3706
  \`<html>\`, so the toggle works on every other target. Set either.
2916
3707
 
2917
3708
  The OS preference is a **second, opt-in block**. Set
2918
3709
  \`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
2919
3710
  \`@media (prefers-color-scheme: dark)\`, under
2920
3711
  \`${SYSTEM_DARK_SEL}\` — i.e. whenever no explicit choice has been made. That is what makes
2921
- \`<color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
3712
+ \`<wc-color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
2922
3713
  so without this block it fell through to light), and it gives a no-JS page the OS
2923
3714
  appearance, which the toggle alone never could. An explicit light choice still wins.
2924
3715
 
@@ -3128,6 +3919,138 @@ token guaranteed legible over a surface in both appearances. \`default_role\` co
3128
3919
  bare, unsuffixed pattern. States re-apply per variant with the variant's role.
3129
3920
  `;
3130
3921
  }
3922
+ /**
3923
+ * All four tiers in ONE doc — the agent-facing projection of `TIERS`.
3924
+ *
3925
+ * The docsite projects each tier as its own page, because a human arrives already
3926
+ * knowing which stack they are in ("show me the Astro components"). An agent
3927
+ * arrives the other way round: it knows the *pattern* it needs and has to be told
3928
+ * which tiers offer it and which call to make. Four docs would mean an agent
3929
+ * fetching `components/astro` learns `<Tree />` exists and never learns it must not
3930
+ * wrap it in `<wc-tree>` — the composition only exists between the tiers, so the
3931
+ * projection that serves agents is the one that keeps them together.
3932
+ *
3933
+ * `ds` is read for one thing: whether each config-authored pattern is actually
3934
+ * declared in THIS consumer's `patterns.items`. `TIERS.css.generated` records that a
3935
+ * pattern is *of that kind* against this repo's reference config; it cannot know
3936
+ * that a given consumer dropped `card`. Reporting a class that this config emits no
3937
+ * CSS for is the failure worth preventing here.
3938
+ */
3939
+ function renderComponentsConcept(ds) {
3940
+ const declared = new Set(Object.keys(ds.patterns?.items ?? {}));
3941
+ const cell = (s) => s.replace(/\|/g, "\\|");
3942
+ const code = (s) => `\`${s}\``;
3943
+ const list = (xs) => xs.map(code).join(", ");
3944
+ const tiersOf = (name) => [
3945
+ "css",
3946
+ "wc",
3947
+ "astro",
3948
+ "bricks"
3949
+ ].filter((t) => tierPatterns(t).some((p) => p.name === name));
3950
+ const overview = TIER_NAMES.map((name) => {
3951
+ const e = TIERS[name];
3952
+ return `| ${code(name)} | ${tiersOf(name).join(" · ")} | ${cell(e.use)} |`;
3953
+ }).join("\n");
3954
+ const wc = tierPatterns("wc").map(({ name, entry }) => {
3955
+ const w = entry.wc;
3956
+ const ships = w.registered ? code("elements.js") : w.bundle ? code(w.bundle) : "nothing";
3957
+ return `| ${code(`<${w.tag}>`)} | ${code(name)} | ${ships} | ${cell(w.adds)} |`;
3958
+ }).join("\n");
3959
+ const astro = tierPatterns("astro").flatMap(({ name, entry }) => entry.astro.map((a) => `| ${code(a.component)} | ${code(name)} | ${a.wraps === "wc" ? `the ${code(`<${entry.wc.tag}>`)} tag, fallback inside` : "tier-1 markup — no web component"} |`)).join("\n");
3960
+ const bricks = tierPatterns("bricks").map(({ name, entry }) => `| ${code(entry.bricks)} | ${code(name)} |`).join("\n");
3961
+ const css = tierPatterns("css").map(({ name, entry }) => {
3962
+ const c = entry.css;
3963
+ const config = !c.generated ? "—" : declared.has(name) ? "declared" : "**absent from this config — no CSS emitted**";
3964
+ return `| ${code(name)} | ${c.partial ? code(c.partial) : "—"} | ${c.classes.length ? list(c.classes) : "—"} | ${config} |`;
3965
+ }).join("\n");
3966
+ const hooks = [...new Set(Object.values(BASE_HOOK))].map((sfx) => code(`--${sfx}-<pattern>`)).join(", ");
3967
+ const unshipped = tierPatterns("wc").filter(({ entry }) => !entry.wc.registered && entry.wc.bundle?.startsWith("(none")).map(({ entry }) => code(`<${entry.wc.tag}>`)).join(", ");
3968
+ return `${frontmatter({
3969
+ type: "Design Concept",
3970
+ title: "Vitops components — which tier provides a pattern, and what to write",
3971
+ description: "Every UI pattern across the four tiers (CSS classes, wc-* web components, Astro components, Bricks elements): which tiers provide it, which call to make, and how the tiers compose.",
3972
+ resource: DS_PATH,
3973
+ tags: [
3974
+ "components",
3975
+ "web-components",
3976
+ "astro",
3977
+ "bricks",
3978
+ "tiers",
3979
+ "design-system"
3980
+ ]
3981
+ })}
3982
+
3983
+ # Components
3984
+
3985
+ A pattern is provided by up to four tiers, and they **compose** rather than compete:
3986
+
3987
+ 1. **CSS framework classes** — every pattern expressible in pure HTML/CSS. Reach here first.
3988
+ 2. **Web components** (${code("<wc-*>")}, Lit) — only where a pattern genuinely benefits from
3989
+ progressive enhancement. The slotted markup is the fallback and must be usable with no JS;
3990
+ the element parses and augments it in place.
3991
+ 3. **Astro components** — authoring conveniences that emit the correct markup. They must not
3992
+ require runtime JS. Where a pattern has a web component, the Astro component emits that tag
3993
+ **with the accessible fallback inside it**.
3994
+ 4. **Bricks elements** — the same patterns as WordPress/Bricks Builder elements.
3995
+
3996
+ ## Choosing
3997
+
3998
+ **Use the highest-numbered tier available for your stack, and write only its call.** In Astro
3999
+ that is the Astro component; in Bricks the element; anywhere else the classes, plus the
4000
+ ${code("<wc-*>")} tag when one exists.
4001
+
4002
+ The trap is composing two tiers by hand. When ${code("wraps")} below says the Astro component
4003
+ emits the tag, that one call is the whole composition —
4004
+ ${code("<wc-tree><Tree /></wc-tree>")} nests two elements on one tree. A tier-3 component that
4005
+ would need runtime JS is in the wrong tier: build a web component instead and have the wrapper
4006
+ emit its tag.
4007
+
4008
+ ## Every pattern
4009
+
4010
+ | Pattern | Tiers | What to write |
4011
+ | --- | --- | --- |
4012
+ ${overview}
4013
+
4014
+ ## Web components
4015
+
4016
+ Shipped as feature-detected, deferred ES-module bundles. ${code("elements.js")} carries the
4017
+ registered set; ${code("<wc-consent>")} and ${code("<wc-theme-editor>")} ship separately, the
4018
+ first so a site needing consent does not download a rendering framework, the second because it
4019
+ is tooling and opt-in per consumer.
4020
+
4021
+ | Tag | Pattern | Ships in | What JS adds over the fallback |
4022
+ | --- | --- | --- | --- |
4023
+ ${wc}
4024
+
4025
+ ${unshipped ? `${unshipped} are **registered but in no bundle** (the editor-v2 track). The tags are inert in a consumer project — they are listed so that "the tag does nothing" is documented rather than discovered.` : ""}
4026
+
4027
+ ## Astro components
4028
+
4029
+ | Component | Pattern | Emits |
4030
+ | --- | --- | --- |
4031
+ ${astro}
4032
+
4033
+ ## Bricks elements
4034
+
4035
+ Controls, defaults and seeded children for each are in [the elements reference](../bricks/elements.md).
4036
+
4037
+ | Element | Pattern |
4038
+ | --- | --- |
4039
+ ${bricks}
4040
+
4041
+ ## CSS
4042
+
4043
+ ${code("patterns.items")} patterns get the full token cascade — ${code("base")} declarations,
4044
+ ${code("states")}, role variants and override hooks (${hooks}); see
4045
+ [component patterns](patterns.md). A structural partial has none of those. The class lists below
4046
+ are representative, not exhaustive — [the class vocabulary](../css/classes.md) states the naming
4047
+ rules that generate them all.
4048
+
4049
+ | Pattern | Partial | Classes | ${code("patterns.items")} |
4050
+ | --- | --- | --- | --- |
4051
+ ${css}
4052
+ `;
4053
+ }
3131
4054
  function renderTopIndex() {
3132
4055
  return `${INDEX_NOTE}
3133
4056
 
@@ -3142,7 +4065,7 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
3142
4065
  * [Authoring reference](authoring.md) - every design-system.json field, generated from the JSON Schema
3143
4066
  * [Config reference](config.md) - every field of the three-section config (designSystem / organization / site), generated from the JSON Schema
3144
4067
  * [Output formats](formats.md) - tailwind vs css vs bricks vs design: what's emitted, what the platform provides, which utilities Tailwind owns
3145
- * [Concepts](concepts/) - the colour system, type/space scales, and pattern CSS architecture
4068
+ * [Concepts](concepts/) - the colour system, type/space scales, pattern CSS architecture, the four component tiers, icons, and the consent / tracking / search / legal subsystems
3146
4069
  * [CSS framework](css/) - the class vocabulary (colour, type, space, layout, animation, component patterns), stated as naming rules
3147
4070
  * [Bricks Builder](bricks/) - custom elements and how to style them
3148
4071
  `;
@@ -3204,7 +4127,7 @@ astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it b
3204
4127
  *every icon in a set* unless given an \`include\` map — which is why projects end up
3205
4128
  hand-maintaining one.
3206
4129
 
3207
- The \`icons\` option on \`getvitops()\` derives it by scanning your source, merged with
4130
+ The \`icons\` option on \`vitops()\` derives it by scanning your source, merged with
3208
4131
  whatever you declare. On a static build **no include is passed at all**: there is
3209
4132
  nothing to trim there, and a list could only drop a glyph the scan couldn't see.
3210
4133
 
@@ -3259,6 +4182,491 @@ Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
3259
4182
  is no markup to hang an \`<svg>\` on.
3260
4183
  `;
3261
4184
  }
4185
+ function renderConsentConcept() {
4186
+ return `${frontmatter({
4187
+ type: "Design Concept",
4188
+ title: "Vitops consent — a demand-driven permission gate",
4189
+ description: "How the consent gate works: inert `type=\"text/plain\"` tags, demand-driven prompting, three-valued choices, and the invariants that make the gate real rather than a promise.",
4190
+ resource: CONFIG_PATH,
4191
+ tags: [
4192
+ "consent",
4193
+ "cookies",
4194
+ "privacy",
4195
+ "gdpr",
4196
+ "analytics"
4197
+ ]
4198
+ })}
4199
+
4200
+ # The consent gate
4201
+
4202
+ \`@getvitops/core/consent\` is a **general permission gate, not an analytics feature**. Anything
4203
+ marked \`data-consent="<category>"\` waits on one visitor choice — a third-party tag, an A/B
4204
+ assignment, a personalisation cookie, an embedded map. It carries no Lit and is a separate
4205
+ bundle from \`elements.js\`, because consent is a legal requirement: a site that needs it must
4206
+ not be made to download a rendering framework first.
4207
+
4208
+ Categories are \`necessary\`, \`analytics\`, \`marketing\`, \`preferences\`.
4209
+
4210
+ ## Gating something
4211
+
4212
+ A gated script renders **inert**, with its URL parked on \`data-src\`:
4213
+
4214
+ \`\`\`html
4215
+ <script
4216
+ type="text/plain"
4217
+ data-vitops-tag
4218
+ data-consent="analytics"
4219
+ data-consent-cookies="_ga,_ga_*"
4220
+ data-src="https://example.com/analytics.js"
4221
+ ><\/script>
4222
+ \`\`\`
4223
+
4224
+ ⚠️ **\`type="text/plain"\` is what makes the gate real. Never give a gated tag a live \`src\`.**
4225
+ The browser neither parses the body nor fetches the library, so an undecided visitor's page
4226
+ issues **no third-party request at all**. A gate that instead loads a script and politely asks
4227
+ it not to track is a promise, not a gate.
4228
+
4229
+ \`data-consent-cookies\` is written by whoever emitted the tag, because that is who knows what
4230
+ the provider sets. It is what a revoke clears.
4231
+
4232
+ ## Demand-driven: two different ideas
4233
+
4234
+ **Offered** categories are a build-time fact — which rows \`<CookieConsent />\` renders. The
4235
+ default is deliberately generous; a hidden row costs nothing.
4236
+
4237
+ **Demanded** categories are a runtime fact — what something has actually asked for and the
4238
+ visitor hasn't answered. The banner appears only when a demand is outstanding, so **a site
4239
+ whose only provider is cookieless never interrupts anyone.**
4240
+
4241
+ Demand is registered by:
4242
+
4243
+ - a gated element **reaching its loading strategy** — so an \`idle\` tag asks after \`load\`, off
4244
+ the LCP path, and an \`interaction\` tag asks only once the visitor acts; or
4245
+ - an explicit \`require()\` / \`request()\`.
4246
+
4247
+ ## The runtime API
4248
+
4249
+ \`window.vitopsConsent\`, plus a \`vitops:consent\` \`CustomEvent\` on \`document\`.
4250
+
4251
+ | Call | Does |
4252
+ | --- | --- |
4253
+ | \`granted(cat)\` | Is it granted? **Passive — does not prompt.** |
4254
+ | \`require(cat)\` | Declare a need *now* and report whether it's granted. **This is what raises the banner.** Synchronous. |
4255
+ | \`request(cat)\` | \`require()\`, but resolves once the visitor answers *this* category. |
4256
+ | \`needed()\` | Is a prompt warranted? True only if something demanded an unanswered category. |
4257
+ | \`demanded()\` | What has asked, this page view. |
4258
+ | \`set(patch)\` | Record a decision for exactly these categories. |
4259
+ | \`open()\` | Re-show the banner without discarding the current choice. |
4260
+ | \`reset()\` | Forget the choice and re-prompt. |
4261
+
4262
+ ⚠️ If you want a side effect to be *possible*, you must \`require()\` it. Calling \`granted()\`
4263
+ and doing nothing else is a **permanent no-op** on any site where nothing else demands that
4264
+ category: it is never offered, never granted, and your write never happens — silently.
4265
+
4266
+ ## An absent gate means "store freely"
4267
+
4268
+ \`consent.js\` and \`elements.js\` are both deferred with no ordering between them, so a component
4269
+ can upgrade and be clicked before the gate exists. \`<Head />\` therefore emits a synchronous
4270
+ inline **stub** that answers \`false\` and queues; the runtime replays the queue on load.
4271
+
4272
+ That makes the absence of \`window.vitopsConsent\` meaningful: it reliably means **this site has
4273
+ no gate**. Read it as *store freely* — never as *denied*.
4274
+
4275
+ Because the stub is not the full API, listen for the \`vitops:consent\` event rather than calling
4276
+ \`subscribe()\`, which the stub does not have.
4277
+
4278
+ ## Three-valued, and why it matters
4279
+
4280
+ The cookie (\`vitops_consent\`, v2) records each category as \`true\` / \`false\` / **\`null\`**.
4281
+ "Not asked" is a third value, not a synonym for "declined" — and only \`null\` can be
4282
+ re-prompted. That is what lets a \`preferences\` demand arrive *after* an \`analytics\` prompt was
4283
+ already answered.
4284
+
4285
+ Three consequences:
4286
+
4287
+ - **Nothing is stored until the visitor chooses.** Absence of the cookie is *undecided*, and
4288
+ undecided denies everything but \`necessary\`. If merely showing the banner wrote state, the
4289
+ banner would be the thing it asks permission for.
4290
+ - **A corrupt or wrong-version cookie re-prompts.** It does not read as "decided, all denied" —
4291
+ that safe-looking read strands a visitor who wants to opt in with no way to say so.
4292
+ - **A patch must cover exactly the categories a showing put on screen.** \`<wc-consent>\` builds
4293
+ its own patch rather than calling \`acceptAll()\`, because "Accept" on a preferences-only
4294
+ prompt that also granted analytics would be consent nobody gave. \`acceptAll()\` /
4295
+ \`rejectAll()\` mean *literally every* optional category — widening a patch to "all" is the
4296
+ easy version of this bug.
4297
+
4298
+ ## Revoking reloads
4299
+
4300
+ An already-executing tracker cannot be unloaded. Clearing its cookies only stops it identifying
4301
+ the visitor *next* time, while the running instance keeps reporting until the document goes
4302
+ away — so a revoke clears the named cookies and **reloads**. (\`reloadOnRevoke = false\` defers
4303
+ that to the next navigation.)
4304
+
4305
+ ## The notice must match what loads
4306
+
4307
+ The same config facts drive the **generated cookie notice** — see
4308
+ [legal.md](legal.md). \`site.analytics.clarityId\` is what makes the notice name Clarity; the
4309
+ same provider in \`vitops({ analytics })\` is what makes the tag load. The Astro integration
4310
+ **warns when the two disagree**, because a site running a tag its own notice omits is a
4311
+ compliance defect, not a documentation gap.
4312
+
4313
+ ## Reference consumer
4314
+
4315
+ \`<wc-color-scheme-toggle>\` applies the chosen scheme **immediately** and gates only the
4316
+ \`localStorage\` write. Nothing about honouring a visitor's click needs permission; remembering
4317
+ it does.
4318
+ `;
4319
+ }
4320
+ function renderTrackingConcept() {
4321
+ return `${frontmatter({
4322
+ type: "Design Concept",
4323
+ title: "Vitops conversion tracking — ad attribution and notifications",
4324
+ description: "How an ad click becomes a notified conversion: the `_ac` cookie, consent-demanding capture, the pure notification planner, and the Cloudflare email channel.",
4325
+ resource: CONFIG_PATH,
4326
+ tags: [
4327
+ "tracking",
4328
+ "attribution",
4329
+ "conversions",
4330
+ "notifications",
4331
+ "consent"
4332
+ ]
4333
+ })}
4334
+
4335
+ # Conversion tracking
4336
+
4337
+ A visitor arrives on an ad carrying a click ID; \`<Tracking />\` captures it into the
4338
+ first-party **\`_ac\`** cookie; when they later submit a form or tap a \`tel:\` link,
4339
+ \`createConversionRoute()\` reads the cookie back and notifies whoever the config names.
4340
+
4341
+ \`\`\`json
4342
+ {
4343
+ "site": {
4344
+ "tracking": { "enabled": true, "category": "marketing" },
4345
+ "notifications": { "email": "leads@example.com" }
4346
+ }
4347
+ }
4348
+ \`\`\`
4349
+
4350
+ A bare address is shorthand for \`{ provider: "cloudflare", to }\`. The recipient otherwise
4351
+ falls back to the primary location's email, and the sender to \`noreply@<domains.canonical>\`.
4352
+
4353
+ ## Where each piece lives
4354
+
4355
+ | Layer | Module | Why there |
4356
+ | --- | --- | --- |
4357
+ | Attribution vocabulary + cookie | \`@getvitops/utils/tracking\` | Needed on **both** sides of the wire |
4358
+ | Plan / render / send | \`@getvitops/utils/notify\` | Pure planner, I/O sender |
4359
+ | Capture script, \`<Tracking />\`, route factory | \`@getvitops/astro\` | Beside the analytics components |
4360
+
4361
+ Both utils entries are **separate subpaths** because they are the only modules that run in a
4362
+ **Worker** rather than at build time. Keeping them off the package index is what stops a
4363
+ conversion endpoint pulling \`sharp\` into its bundle. Neither may use a Node builtin.
4364
+
4365
+ ## The capture demands consent
4366
+
4367
+ \`_ac\` is a 90-day identifier tying a visitor to an ad, so it waits on \`marketing\` (override
4368
+ with \`site.tracking.category\`).
4369
+
4370
+ ⚠️ The script calls **\`require()\`**, not \`granted()\` — \`require()\` is what *raises the
4371
+ banner*. A passive \`granted()\` here is a permanent no-op: nothing else on a page demands
4372
+ \`marketing\`, so it is never offered, never granted, and \`_ac\` is never written — silently, on
4373
+ every gated site. The integration adds \`marketing\` to the offered categories when tracking is
4374
+ on, so there is a row for the category the script will ask about.
4375
+
4376
+ **Only an arrival that carried something asks.** The demand is guarded on the URL actually
4377
+ holding a click ID or UTM, so an organic visitor — who has nothing to attribute — is never
4378
+ interrupted. That is demand-driven consent applied to attribution.
4379
+
4380
+ **The capture is synchronous; only the write waits.** Reading the query string is not storage
4381
+ and needs no permission; *keeping* it does. The click ID is in the URL only on the landing
4382
+ page, so deferring the read would lose it outright.
4383
+
4384
+ The marker element carries \`data-consent\` but deliberately **not** \`data-vitops-tag\`: the
4385
+ scan never tries to "activate" it (it is ungated by design), while the revoke path — which
4386
+ queries \`[data-consent="…"]\` — still finds it and clears \`_ac\`.
4387
+
4388
+ ## The event is the abstraction
4389
+
4390
+ \`ConversionEvent\` is the *fact*; how it reads belongs to the channel. That is what lets an SMS
4391
+ channel render 160 characters from the same event an email renders in full.
4392
+
4393
+ **The plan is pure and says why anything is skipped.** \`planNotifications\` touches no network
4394
+ and no binding, so a misconfigured site can be told exactly why no notification will arrive — a
4395
+ silently unsent conversion notification is indistinguishable from no conversion.
4396
+
4397
+ ## The email channel
4398
+
4399
+ Cloudflare Email Sending's **current** binding — structured
4400
+ \`env.EMAIL.send({ to, from, subject, html, text })\`, not the legacy \`EmailMessage\` plus
4401
+ hand-built MIME. The binding is **passed in, never imported**, so utils takes no Cloudflare
4402
+ dependency.
4403
+
4404
+ Only transient codes are retried. \`E_SENDER_NOT_VERIFIED\` and friends are surfaced
4405
+ **verbatim**, because nothing here can check whether the sending domain was onboarded — run:
4406
+
4407
+ \`\`\`sh
4408
+ wrangler email sending enable <domain>
4409
+ \`\`\`
4410
+
4411
+ A generic "send failed" would hide the one thing worth knowing.
4412
+
4413
+ Only \`email\` is implemented. \`sms\` and \`persist\` are a planned seam
4414
+ (\`NotificationsConfig\` plus a sender with \`sendEmail\`'s signature); one channel is not enough
4415
+ to know what the abstraction should be.
4416
+
4417
+ ## Disclosure
4418
+
4419
+ \`_ac\` is disclosed by the generated cookie notice as a first-party cookie. It has to be stated
4420
+ explicitly: no provider table would ever name a first-party cookie, so a site running
4421
+ attribution alongside a **cookieless** analytics provider would otherwise be described as
4422
+ setting no cookies at all. See [legal.md](legal.md).
4423
+ `;
4424
+ }
4425
+ function renderSearchConcept() {
4426
+ return `${frontmatter({
4427
+ type: "Design Concept",
4428
+ title: "Vitops search — Search Console onboarding and deploy notification",
4429
+ description: "What search engines actually accept, why the Indexing API is deliberately not wired, and how `vitops search setup` and `vitops search notify` work.",
4430
+ resource: CONFIG_PATH,
4431
+ tags: [
4432
+ "seo",
4433
+ "search-console",
4434
+ "indexnow",
4435
+ "sitemap",
4436
+ "indexing"
4437
+ ]
4438
+ })}
4439
+
4440
+ # Search
4441
+
4442
+ Two commands, both anchored to a full config:
4443
+
4444
+ - **\`vitops search notify\`** — tell search engines a deploy happened (from \`site.seo.indexing\`).
4445
+ - **\`vitops search setup\`** — onboard domains into Search Console (from \`site.searchConsole\`).
4446
+
4447
+ ## Start from what engines actually accept
4448
+
4449
+ The obvious assumption is wrong, and every design decision here follows from that:
4450
+
4451
+ - **Google exposes no "request indexing" API.** The button in the Search Console UI is not in
4452
+ the Search Console API or anywhere else, and the **URL Inspection API is read-only**.
4453
+ - **The sitemap ping endpoint was removed in June 2023.** \`google.com/ping?sitemap=\` is a no-op.
4454
+ - **The Indexing API is scoped to \`JobPosting\` / \`BroadcastEvent\`.** It accepts other URLs and
4455
+ discards them; general use violates its terms, with *your own* GCP project on the line.
4456
+ ⚠️ **Deliberately not wired. Do not add it.**
4457
+ - **Google does not participate in IndexNow.** Bing, Yandex, Naver, Seznam and Yep do.
4458
+
4459
+ So \`search notify\` does every sanctioned thing and then **verifies**: resubmit the sitemap,
4460
+ ping IndexNow, and \`--check\` inspects \`priorityUrls\` and exits non-zero on one Google hasn't
4461
+ indexed. That last part is what actually replaces the manual Search Console visit.
4462
+
4463
+ ⚠️ Never describe this as making Google re-index faster. It cannot.
4464
+
4465
+ ## \`search notify\`
4466
+
4467
+ - **The pure/I-O split is the point.** The planner decides everything — which URLs, which
4468
+ channels, why each was skipped — and touches no network, no filesystem, no clock. That is what
4469
+ makes \`--dry\` a *complete* account of a run rather than an approximation.
4470
+ - **\`lastmod\` is not a nice-to-have, it is the mechanism.** The changed-URL diff compares each
4471
+ sitemap entry's \`<lastmod>\` against a stored snapshot. With no lastmod the diff can see pages
4472
+ appear and disappear but **never see one change** — so an edited page is never resubmitted, and
4473
+ the command looks healthy while doing less than it appears to. It counts lastmod-less entries
4474
+ and says so every run.
4475
+
4476
+ \`gitLastmod()\` in \`@getvitops/astro\` derives real dates from \`git log\`. It is an exported
4477
+ helper rather than a \`sitemap\` option because it shells out to git and returns **nothing**
4478
+ from a shallow CI clone (\`fetch-depth: 1\`). It leaves an unmatched URL alone rather than
4479
+ stamping the build time: Google weighs lastmod only while it stays consistent with what
4480
+ actually changed, so a site that stamps every page every deploy teaches it to distrust the
4481
+ field site-wide.
4482
+ - **The \`noindex\` gate reads the environment, so the URLs must too.** The run is refused
4483
+ entirely when the resolved environment's \`robots\` contains \`noindex\` — submitting a staging
4484
+ host to IndexNow publishes it to several engines and invites them to crawl it, which a later
4485
+ directive does not undo. Origins therefore derive from \`site.environments[env].url\` **before**
4486
+ \`domains.canonical\`: the canonical is the *production* origin, so deriving from it while
4487
+ notifying staging would submit production URLs the gate would not catch.
4488
+ - **Verify the IndexNow key file before submitting.** A submission whose key file is unreachable
4489
+ returns \`403\`, but one whose key file is *reachable and stale* is accepted with \`202\` and then
4490
+ silently discarded. Only a prior GET distinguishes "submitted" from "submitted and ignored".
4491
+ The key is **not a secret** — the engine fetching it back is the ownership proof — so it lives
4492
+ in the config, and the Astro integration writes \`public/<key>.txt\` from it.
4493
+ - **Write the snapshot last, and only on success.** Writing it eagerly records URLs as notified
4494
+ that never were; because the next run diffs against it, one transient \`503\` would drop those
4495
+ pages from every future run — silently and permanently. A corrupt or absent snapshot reads as
4496
+ "submit everything, and say so", never as "nothing changed".
4497
+
4498
+ ## \`search setup\`
4499
+
4500
+ Automates the otherwise-manual DNS-paste / wait / verify / add-property dance.
4501
+ \`site.searchConsole\` is keyed by bare hostname (mirroring \`site.dns\`):
4502
+
4503
+ \`\`\`json
4504
+ {
4505
+ "site": {
4506
+ "searchConsole": {
4507
+ "example.com": {
4508
+ "delegatedOwners": ["dev@example.com"],
4509
+ "fullUserGroup": "seo@example.com"
4510
+ }
4511
+ }
4512
+ }
4513
+ }
4514
+ \`\`\`
4515
+
4516
+ Per domain it ensures the apex verification TXT in Cloudflare, verifies ownership via the Site
4517
+ Verification API (\`DNS_TXT\`), adds the \`sc-domain:\` property, then adds any
4518
+ \`delegatedOwners\`. The verification token is fetched live and written to DNS — never stored in
4519
+ the config.
4520
+
4521
+ - **Idempotency lives in the planner.** A step whose desired state already holds resolves to
4522
+ \`skip\`, so re-running an onboarded domain is a no-op **by construction**, not because the
4523
+ executors check twice. \`--check\` reports drift, exits non-zero and mutates nothing;
4524
+ \`--dry\` prints the plan and stops.
4525
+ - **DNS is only ever created, never edited or deleted.** The command never removes a record, so
4526
+ the Cloudflare executor simply has no update or delete verb.
4527
+ - **Verification retries with backoff, then reports PENDING — not failed.** \`DNS_TXT\`
4528
+ verification fails until the record propagates, which is slow, not broken. A domain still
4529
+ unverified after the last attempt is \`PENDING\`, and its property and owner steps are skipped
4530
+ for that run. Re-run later.
4531
+ - **Search Console has no user/permission API.** Adding a Google Group as a **Full User** is
4532
+ genuinely un-automatable, so \`fullUserGroup\` is surfaced as a **reminder** in the summary,
4533
+ never attempted. That is distinct from \`delegatedOwners\`, which *are* automated — those are
4534
+ Site Verification web-resource owners (an additive union, so an existing owner is never
4535
+ dropped), a different concept from Search Console property users. Don't conflate them.
4536
+
4537
+ ## Credentials
4538
+
4539
+ Always from the environment; never in the config. There is **one** token exchange and **two
4540
+ grants**, tracking where each command runs:
4541
+
4542
+ | Grant | Env vars | Used by |
4543
+ | --- | --- | --- |
4544
+ | **Service account** (JWT bearer) | \`VITOPS_GSC_SERVICE_ACCOUNT\` (inline JSON) or \`GOOGLE_APPLICATION_CREDENTIALS\` (path) | \`search notify\` — never expires, right for CI |
4545
+ | **User OAuth** (refresh token) | \`VITOPS_GOOGLE_CLIENT_ID\` / \`_CLIENT_SECRET\` / \`_REFRESH_TOKEN\` | \`search setup\` — **required** |
4546
+
4547
+ Cloudflare uses \`CLOUDFLARE_API_TOKEN\` (a \`Zone:DNS:Edit\` token; the standard "Edit zone DNS"
4548
+ template also carries the \`Zone:Read\` the zone-by-name lookup needs).
4549
+
4550
+ \`search setup\` requires user OAuth because **verifying a site makes the caller an owner** of
4551
+ the property, and that should be a person, not a project robot. Note a refresh token can be
4552
+ revoked, and for an OAuth client still in *Testing* publishing status Google expires it after
4553
+ **7 days** — fine for a one-time human setup, bad for a deploy step.
4554
+
4555
+ **\`search notify\` accepts either**, preferring the service account when both are set. Search
4556
+ Console does not care which identity calls it, and someone who has run \`search setup\` already
4557
+ holds a Google credential.
4558
+
4559
+ ⚠️ Do not add \`googleapis\` — an enormous dependency for a handful of REST endpoints in a CLI
4560
+ that installs into every consumer project. The JWT is minted with ~30 lines of \`node:crypto\`.
4561
+ `;
4562
+ }
4563
+ function renderLegalConcept() {
4564
+ return `${frontmatter({
4565
+ type: "Design Concept",
4566
+ title: "Vitops legal documents — generated policy, terms and cookie notice",
4567
+ description: "How the privacy policy, terms of service and cookie notice are derived from config facts, why the provider table exists, and the four delivery paths.",
4568
+ resource: CONFIG_PATH,
4569
+ tags: [
4570
+ "legal",
4571
+ "privacy",
4572
+ "cookies",
4573
+ "pipeda",
4574
+ "compliance"
4575
+ ]
4576
+ })}
4577
+
4578
+ # Legal documents
4579
+
4580
+ A privacy policy, terms of service and cookie notice, rendered from a full config: the company
4581
+ from \`organization\`, what the site actually *does* from \`site\`.
4582
+
4583
+ It is a **sibling of the docs generator, not a \`generate()\` format** — structurally, not
4584
+ stylistically. \`generate()\` is keyed to a design system, so a "legal format" would be a format
4585
+ that ignores its own input.
4586
+
4587
+ ## The governing rule
4588
+
4589
+ **The config records facts; the template owns prose.**
4590
+
4591
+ Nothing in the derivation writes a sentence a lawyer would review, and no template invents a
4592
+ fact. That is what lets wording be corrected without touching your config, and your provider
4593
+ change land without touching prose.
4594
+
4595
+ ⚠️ It also means **the fix for a wrong policy is a corrected config.** Hand-editing the output
4596
+ is overwritten by the next build.
4597
+
4598
+ ## What derives from what
4599
+
4600
+ - **The provider table is what makes derivation possible.** A policy naming Plausible while the
4601
+ site runs GA is a compliance defect, not a typo — so the provider comes from *which analytics
4602
+ ID is set*, whether \`site.security.turnstile.siteKey\` exists, and what
4603
+ \`site.deployment.platform\` says. Never from a hand-maintained string.
4604
+
4605
+ It covers only what the schema can imply. Everything else — payment, CRM, mail — is declared
4606
+ in \`site.legal.privacyPolicy.processors\` and flows through the same pipeline.
4607
+
4608
+ - **\`cookies: []\` is meaningfully different from \`undefined\`.** It *asserts* a provider is
4609
+ cookieless (Plausible), which the cookie notice states **positively** rather than omitting.
4610
+
4611
+ - **"Stored in" is not the same fact as "reachable by".** A processor's \`storage\` is where the
4612
+ information rests; \`operatorCountry\` is the jurisdiction that can compel the provider to hand
4613
+ it over. Privacy law turns on foreign **access**, not merely foreign storage, so the two get
4614
+ separate sentences — an Azure tenant in a Canadian region never moves the data and is still
4615
+ subject to US law. \`country\` is shorthand asserting both, and combining it with either is
4616
+ **rejected** rather than resolved by a silent rule: whether it narrows or adds is a
4617
+ contradiction between two legal claims, not a formatting choice.
4618
+
4619
+ A \`storage\` entry may be \`scope\`d to a category, which is what makes a Canadian tenant
4620
+ holding identity data abroad expressible. A country in the policy's own jurisdiction is not a
4621
+ transfer and is filtered out — that is what stopped \`country: "Canada"\` rendering "outside of
4622
+ Canada, including Canada". Only what the config states is asserted: hosts like Cloudflare
4623
+ declare an \`operatorCountry\` and **no** storage, because anycast means the config cannot know
4624
+ which region served a request, and "we don't know" is a fact.
4625
+
4626
+ - **Form templates are the PII inventory.** \`site.templates\` entries of type \`form\` are the
4627
+ only place the config says what personal information the site actually collects, so the
4628
+ disclosed list derives from their fields. \`hidden\` fields and honeypots are **excluded** —
4629
+ neither is visitor-supplied, and describing them as collected would be untrue.
4630
+
4631
+ - **First-party cookies are declared, not detected.** The attribution cookie \`_ac\` is disclosed
4632
+ this way; see [tracking.md](tracking.md).
4633
+
4634
+ ## The markdown subset is closed
4635
+
4636
+ We author every template, so the renderer is exactly as capable as they are: \`#\`/\`##\`/\`###\`,
4637
+ \`- \` bullets, \`> \` quote, \`**strong**\`, \`\\\`code\\\`\`. **An unsupported construct is an error,
4638
+ not a silent degrade** — that is what stops a literal \`| --- |\` reaching a published page.
4639
+
4640
+ Portable Text maps the \`> \` quote to a banner block and **drops the \`# \` heading**, which is
4641
+ EmDash's own title field.
4642
+
4643
+ ## Jurisdictions
4644
+
4645
+ Adding one is: author three templates, add one enum member, add one registry key. The two are
4646
+ checked against each other at compile time, so skipping either **fails to compile** rather than
4647
+ rendering against the wrong body of law.
4648
+
4649
+ ⚠️ Only **\`ca\` (PIPEDA)** ships. Its prose names the Office of the Privacy Commissioner of
4650
+ Canada and frames transfers as "outside of Canada" — **do not reuse it for another
4651
+ jurisdiction.**
4652
+
4653
+ ## Delivery: one renderer, four consumers
4654
+
4655
+ | Consumer | How |
4656
+ | --- | --- |
4657
+ | **any stack** | \`vitops legal [--doc <name>] [--format md\\|html\\|portable-text] [--out <dir>]\` — stdout without \`--out\`. Hugo, Eleventy or a hand-built WordPress theme need no integration code. |
4658
+ | **WordPress** | \`generate({ site })\` also emits \`dist/legal/*.html\`; \`[vitops_legal doc="privacy"]\` renders one. \`doc\` is matched against a fixed allowlist, because it lands in a filesystem read. |
4659
+ | **Astro** | \`vitops({ legal: { input, out } })\` — a sibling of \`css\`, not a widening of it. Regenerates on config change; writes markdown to a content collection. No route injection. |
4660
+ | **EmDash** | \`--format portable-text\`, pasted into the admin. |
4661
+
4662
+ The CLI is the load-bearing one: it is the surface every consumer has regardless of stack.
4663
+
4664
+ ## The review banner
4665
+
4666
+ Every document opens with a **non-optional** review banner. These are rendered from a template
4667
+ by a build tool; the one failure mode with real consequences is a consumer publishing one as-is.
4668
+ `;
4669
+ }
3262
4670
  function renderConceptsIndex() {
3263
4671
  return `${INDEX_NOTE}
3264
4672
 
@@ -3269,7 +4677,12 @@ function renderConceptsIndex() {
3269
4677
  * [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
3270
4678
  * [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
3271
4679
  * [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
4680
+ * [Components](components.md) - which of the four tiers provides each pattern, and the call to make
3272
4681
  * [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
4682
+ * [Consent gate](consent.md) - inert gated tags, demand-driven prompting, three-valued choices
4683
+ * [Conversion tracking](tracking.md) - ad-click attribution, the \`_ac\` cookie, conversion notifications
4684
+ * [Search](search.md) - what engines accept, Search Console onboarding, deploy notification
4685
+ * [Legal documents](legal.md) - policy/terms/cookie notice derived from config facts
3273
4686
  `;
3274
4687
  }
3275
4688
  function renderCssIndex() {
@@ -3318,7 +4731,12 @@ function generateDocs(ds, assetsDir) {
3318
4731
  "concepts/color.md": renderColorConcept(ds),
3319
4732
  "concepts/scales.md": renderScalesConcept(ds),
3320
4733
  "concepts/patterns.md": renderPatternsConcept(ds),
4734
+ "concepts/components.md": renderComponentsConcept(ds),
3321
4735
  "concepts/icons.md": renderIconsConcept(),
4736
+ "concepts/consent.md": renderConsentConcept(),
4737
+ "concepts/tracking.md": renderTrackingConcept(),
4738
+ "concepts/search.md": renderSearchConcept(),
4739
+ "concepts/legal.md": renderLegalConcept(),
3322
4740
  "css/index.md": renderCssIndex(),
3323
4741
  "css/classes.md": renderCssClasses(ds),
3324
4742
  "bricks/index.md": renderBricksIndex(),
@@ -3757,6 +5175,26 @@ across every pattern. Focus rings are never removed, only restyled.
3757
5175
  //#endregion
3758
5176
  //#region src/legal/providers.ts
3759
5177
  /**
5178
+ * What the config already tells us about third parties that receive personal
5179
+ * information.
5180
+ *
5181
+ * A privacy policy's riskiest sentence is the one naming service providers: if
5182
+ * it says "Plausible" while the site runs Google Analytics, the document is a
5183
+ * compliance defect, not a typo. The site config already records which analytics
5184
+ * ID is set, whether Turnstile is deployed and where the site is hosted — so the
5185
+ * sentence derives from those signals rather than from a hand-maintained string
5186
+ * that drifts the moment someone swaps providers.
5187
+ *
5188
+ * Presence of the key is the signal, matching how `analytics` already works
5189
+ * (provider identity is implied by which ID is set, not by an enum).
5190
+ *
5191
+ * This table is deliberately small and covers only what the schema can imply.
5192
+ * Everything else — payment processors, CRMs, mail senders — is declared by the
5193
+ * consumer in `legal.privacyPolicy.processors`, which flows through the same
5194
+ * pipeline. This is not a directory of the web; it's the set of providers the
5195
+ * toolchain itself can vouch for.
5196
+ */
5197
+ /**
3760
5198
  * The providers the config can imply. Keyed for testability — a test can assert
3761
5199
  * a given config resolves to a given key without matching on prose.
3762
5200
  */
@@ -3810,28 +5248,28 @@ const KNOWN_PROCESSORS = {
3810
5248
  turnstile: {
3811
5249
  name: "Cloudflare Turnstile",
3812
5250
  purpose: "bot protection on forms",
3813
- country: "the United States",
5251
+ operatorCountry: "the United States",
3814
5252
  privacyUrl: "https://www.cloudflare.com/privacypolicy/",
3815
5253
  cookies: ["cf_clearance"]
3816
5254
  },
3817
5255
  cloudflare: {
3818
5256
  name: "Cloudflare",
3819
5257
  purpose: "website hosting and content delivery",
3820
- country: "the United States",
5258
+ operatorCountry: "the United States",
3821
5259
  privacyUrl: "https://www.cloudflare.com/privacypolicy/",
3822
5260
  cookies: []
3823
5261
  },
3824
5262
  vercel: {
3825
5263
  name: "Vercel",
3826
5264
  purpose: "website hosting",
3827
- country: "the United States",
5265
+ operatorCountry: "the United States",
3828
5266
  privacyUrl: "https://vercel.com/legal/privacy-policy",
3829
5267
  cookies: []
3830
5268
  },
3831
5269
  netlify: {
3832
5270
  name: "Netlify",
3833
5271
  purpose: "website hosting",
3834
- country: "the United States",
5272
+ operatorCountry: "the United States",
3835
5273
  privacyUrl: "https://www.netlify.com/privacy/",
3836
5274
  cookies: []
3837
5275
  }
@@ -3872,9 +5310,81 @@ function detectProcessorKeys(cfg) {
3872
5310
  * an explicit declaration is a statement of fact we have no grounds to drop.
3873
5311
  */
3874
5312
  function resolveProcessors(cfg) {
3875
- const detected = detectProcessorKeys(cfg).map((k) => matomoCountry(KNOWN_PROCESSORS[k], cfg.site));
5313
+ const detected = detectProcessorKeys(cfg).map((k) => matomoLocation(KNOWN_PROCESSORS[k], cfg.site));
3876
5314
  const declared = cfg.site.legal?.privacyPolicy?.processors ?? [];
3877
- return [...detected, ...declared];
5315
+ return [
5316
+ ...detected,
5317
+ ...adProcessors(cfg),
5318
+ ...declared
5319
+ ].map(desugarCountry);
5320
+ }
5321
+ /**
5322
+ * The ad platforms `site.ads` links this site to, as processors.
5323
+ *
5324
+ * Derived from `AD_PLATFORMS` rather than written out here, because the cookie
5325
+ * names, the privacy URL and the opt-out are the *same facts* `vitops ads tags`
5326
+ * writes into `data-consent-cookies` — and the one failure that matters is those
5327
+ * two disagreeing. A pixel that sets `_fbp` while the notice omits it is a
5328
+ * compliance defect, and a revoke that clears a cookie the notice never mentioned
5329
+ * is the same defect seen from the other end.
5330
+ *
5331
+ * `enabled: false` keeps a property on record without emitting its tag, so nothing
5332
+ * is set and nothing is disclosed. Everything else with an id is live.
5333
+ */
5334
+ function adProcessors(cfg) {
5335
+ const ads = cfg.site.ads ?? {};
5336
+ return AD_PROVIDER_KEYS.filter((provider) => {
5337
+ const entry = ads[provider];
5338
+ return entry != null && entry.enabled !== false && (entry.pixelId ?? entry.accountId) != null;
5339
+ }).map((provider) => {
5340
+ const platform = AD_PLATFORMS[provider];
5341
+ return {
5342
+ name: platform.name,
5343
+ purpose: "advertising measurement and retargeting",
5344
+ operatorCountry: platform.operatorCountry,
5345
+ privacyUrl: platform.tag.privacyUrl,
5346
+ cookies: platform.tag.cookies,
5347
+ ...platform.tag.optOut ? { optOut: platform.tag.optOut } : {}
5348
+ };
5349
+ });
5350
+ }
5351
+ /**
5352
+ * `country` → both facts it has always asserted.
5353
+ *
5354
+ * The sentence it fed claimed storage *and* legal reach in one breath ("may be
5355
+ * stored or processed in … including X. As a result … access requests from
5356
+ * governments … in those jurisdictions"). So expanding it to storage alone would
5357
+ * silently retract half of a claim consumers have already published. Expanding to
5358
+ * both is also what keeps the rendered output byte-identical for every config that
5359
+ * used the shorthand.
5360
+ *
5361
+ * `country` is left in place — it is public API and `PolicyVars.countries` still
5362
+ * reports it. When the explicit fields are present they win; `validateConfig`
5363
+ * rejects that combination, so it is unreachable through a validated config, but
5364
+ * `resolveProcessors` is exported and needs a defined answer regardless.
5365
+ */
5366
+ function desugarCountry(p) {
5367
+ if (!p.country) return p;
5368
+ return {
5369
+ ...p,
5370
+ storage: p.storage ?? [{ country: p.country }],
5371
+ operatorCountry: p.operatorCountry ?? p.country
5372
+ };
5373
+ }
5374
+ /**
5375
+ * Processors the config discloses but places nowhere.
5376
+ *
5377
+ * Such a processor cannot appear in the transfer disclosure — there is nothing true
5378
+ * to say about it — and today it vanishes from that section in silence, which is the
5379
+ * tidy-looking failure. Names only, and deliberately NOT on `PolicyVars`: that type
5380
+ * is the facts a template renders from, and a document must not editorialise about
5381
+ * its own gaps. The CLI reports these on stderr instead.
5382
+ *
5383
+ * Not a `validateConfig` error either: a bare `{ name, purpose }` validates today,
5384
+ * so rejecting it would make this a breaking change.
5385
+ */
5386
+ function processorsMissingLocation(cfg) {
5387
+ return resolveProcessors(cfg).filter((p) => !p.operatorCountry && !p.storage?.length).map((p) => p.name);
3878
5388
  }
3879
5389
  /**
3880
5390
  * Matomo is the one provider whose location the config actually knows.
@@ -3884,13 +5394,17 @@ function resolveProcessors(cfg) {
3884
5394
  * country. So the cross-border transfer sentence is asserted only for the case we
3885
5395
  * can see. Naming a transfer that isn't happening is the same class of error as
3886
5396
  * omitting one that is — the config records facts, and "we don't know" is a fact.
5397
+ *
5398
+ * This sets `storage`, not `operatorCountry`: Matomo Cloud genuinely holds the data
5399
+ * in the EU. InnoCraft is New Zealand-based, but nothing in the config sees that, so
5400
+ * no operator jurisdiction is asserted — the same rule, applied to the other fact.
3887
5401
  */
3888
- function matomoCountry(processor, site) {
5402
+ function matomoLocation(processor, site) {
3889
5403
  if (processor.name !== "Matomo") return processor;
3890
5404
  const url = site.analytics?.matomo?.url ?? "";
3891
5405
  return /\.matomo\.cloud/i.test(url) ? {
3892
5406
  ...processor,
3893
- country: "the European Union"
5407
+ storage: [{ country: "the European Union" }]
3894
5408
  } : processor;
3895
5409
  }
3896
5410
 
@@ -4041,21 +5555,43 @@ function derivePolicyVars(cfg) {
4041
5555
  "improve our Site to better meet your needs"
4042
5556
  ]);
4043
5557
  const ab = site.abTesting;
5558
+ /**
5559
+ * A site offering the `preferences` category stores at least one setting the
5560
+ * visitor chose — `<wc-color-scheme-toggle>` writes `vitops-color-scheme` to local
5561
+ * storage, gated on exactly this category. The banner asking about it while the
5562
+ * notice never mentions it is the disclosure gap `providers.ts` exists to
5563
+ * prevent, just on the browser-storage side rather than the third-party one.
5564
+ */
5565
+ const hasPreferences = consent?.enabled === true && !!consent.categories?.includes("preferences");
5566
+ /**
5567
+ * `site.tracking.enabled` writes the `_ac` cookie: the click ID from the ad a
5568
+ * visitor followed, kept for 90 days so a later enquiry can be attributed to
5569
+ * the campaign that produced it. It is first-party and never leaves the site,
5570
+ * which is exactly why nothing in the provider table would ever mention it —
5571
+ * and why it has to be stated here instead. A notice that lists every
5572
+ * third-party cookie while omitting the one the site sets itself is the same
5573
+ * disclosure defect, pointed inward.
5574
+ */
5575
+ const hasAdTracking = site.tracking?.enabled === true;
4044
5576
  const cookieTracked = uniq([
4045
5577
  "your IP address",
4046
5578
  "the type of web browser and operating system used",
4047
5579
  "the pages of the Site visited",
5580
+ hasPreferences ? "display settings you have chosen, such as light or dark appearance" : void 0,
5581
+ hasAdTracking ? "which advertisement or campaign brought you to our Site" : void 0,
4048
5582
  ab?.enabled ? "which version of a page you were shown" : void 0,
4049
5583
  hasSessionReplay ? "how you moved through and interacted with a page" : void 0
4050
5584
  ]);
4051
5585
  const cookiePurposes = uniq([
4052
5586
  "keep the Site functioning as you move between pages",
4053
5587
  hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
5588
+ hasPreferences ? "remember display settings you choose, between visits" : void 0,
5589
+ hasAdTracking ? "attribute an enquiry to the advertisement that brought you here, for up to 90 days" : void 0,
4054
5590
  hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
4055
5591
  ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
4056
5592
  site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
4057
5593
  ]);
4058
- const cookieTool = consent?.enabled ? consent.type === "opt-in" ? "You may also choose which categories of cookies to allow using the consent banner shown when you first visit our Site, and you may change that choice at any time." : "You may also withdraw your consent to non-essential cookies at any time using the consent controls on our Site." : void 0;
5594
+ const cookieTool = consent?.enabled ? consent.type === "opt-in" ? "You may also choose which categories of cookies to allow using the consent banner, which appears when something on the Site first needs your permission, and you may change that choice at any time." : "You may also withdraw your consent to non-essential cookies at any time using the consent controls on our Site." : void 0;
4059
5595
  const usePurposes = uniq([
4060
5596
  "respond to your inquiries",
4061
5597
  "supply you with requested products or services",
@@ -4071,7 +5607,38 @@ function derivePolicyVars(cfg) {
4071
5607
  ...processors.filter((p) => p.optOut).map((p) => `You may opt out of ${p.name} directly at ${p.optOut}.`)
4072
5608
  ]);
4073
5609
  const optOutOptions = uniq(["You may unsubscribe from any commercial e-mail we send using the \"unsubscribe\" link in that e-mail.", ...cookieOptOutOptions]);
4074
- const countries = list(uniq(processors.map((p) => p.country)));
5610
+ /**
5611
+ * The cross-border disclosure, as two facts rather than one flattened list.
5612
+ *
5613
+ * Order matters here — each step narrows what the next may claim:
5614
+ *
5615
+ * 1. Anything in this jurisdiction is not a transfer at all, so it is filtered
5616
+ * out. Without this, `country: "Canada"` rendered "outside of Canada,
5617
+ * including Canada".
5618
+ * 2. An unscoped storage country is the BROAD claim; a scoped one covering the
5619
+ * same country would only narrow it, so it is dropped as redundant.
5620
+ * 3. An operator country already named as storage adds no fact — "US law reaches
5621
+ * information stored in the US" is a sentence, not information.
5622
+ *
5623
+ * Steps 2 and 3 are what make the rendered output byte-identical for every config
5624
+ * that used `country` (which desugars to both facts).
5625
+ *
5626
+ * No geography is inferred — "France" is not resolved into "the European Union".
5627
+ * Only what the config states is asserted, which is the `matomoLocation` rule.
5628
+ */
5629
+ const jurisdictionCountry = JURISDICTION_COUNTRIES[legal?.jurisdiction ?? "ca"];
5630
+ const norm = (c) => c.trim().toLowerCase().replace(/^the\s+/, "");
5631
+ const foreign = (c) => norm(c) !== norm(jurisdictionCountry);
5632
+ const allStorage = processors.flatMap((p) => p.storage ?? []);
5633
+ const unscoped = uniq(allStorage.filter((s) => !s.scope && foreign(s.country)).map((s) => s.country));
5634
+ const storageCountries = list(unscoped);
5635
+ const named = new Set(unscoped.map(norm));
5636
+ const scopedStorage = [...new Map(allStorage.filter((s) => s.scope && foreign(s.country) && !named.has(norm(s.country))).map((s) => [`${norm(s.country)}|${s.scope}`, {
5637
+ country: s.country,
5638
+ scope: s.scope
5639
+ }])).values()];
5640
+ const operatorCountries = list(uniq(processors.map((p) => p.operatorCountry)).filter((c) => foreign(c) && !named.has(norm(c))));
5641
+ const countries = list(uniq(processors.flatMap((p) => [...(p.storage ?? []).map((s) => s.country), p.operatorCountry])));
4075
5642
  const address = contact?.address ?? org?.address;
4076
5643
  return {
4077
5644
  businessName,
@@ -4093,6 +5660,15 @@ function derivePolicyVars(cfg) {
4093
5660
  optOutOptions,
4094
5661
  cookieOptOutOptions,
4095
5662
  processors,
5663
+ firstPartyCookies: hasAdTracking ? [{
5664
+ name: "_ac",
5665
+ purpose: "records the advertisement or campaign you arrived from, so an enquiry can be attributed to it",
5666
+ retention: "90 days"
5667
+ }] : [],
5668
+ jurisdictionCountry,
5669
+ storageCountries: storageCountries || void 0,
5670
+ scopedStorage,
5671
+ operatorCountries: operatorCountries || void 0,
4096
5672
  countries: countries || void 0,
4097
5673
  consentModel: consent?.enabled ? consent.type ?? "opt-in" : "none",
4098
5674
  cookieCategories: consent?.categories ?? []
@@ -4383,18 +5959,28 @@ If you have questions about our use of cookies, please contact us at ${v.mailing
4383
5959
  * "This site sets no cookies" is a real and increasingly common answer — a
4384
5960
  * cookieless analytics provider is a selling point, not an omission. Saying it
4385
5961
  * plainly beats an empty section that reads as an oversight.
5962
+ *
5963
+ * But it is only true if the site sets none of its **own**, which is why
5964
+ * `firstPartyCookies` is checked alongside the processors: a site running
5965
+ * attribution with a cookieless analytics provider sets `_ac` and no third-party
5966
+ * cookie at all, and the old wording would have declared it cookie-free.
4386
5967
  */
4387
5968
  function providersSection$1(v) {
4388
5969
  const setting = v.processors.filter((p) => p.cookies?.length);
4389
5970
  const cookieless = v.processors.filter((p) => p.cookies?.length === 0);
4390
- if (setting.length === 0) return `## Cookies Set on This Site
4391
-
4392
- Beyond the cookies strictly necessary to serve and secure the Site, we do not set cookies.${cookieless.length ? ` The third party ${cookieless.length === 1 ? "service" : "services"} we use on this Site — ${list(cookieless.map((p) => p.name))} — ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.` : ""}
4393
- `;
5971
+ const own = v.firstPartyCookies;
5972
+ const ownSection = own.length ? `\n### Cookies We Set Ourselves\n\n${bullets(own.map((c) => `\`${c.name}\` — ${c.purpose}. Kept for ${c.retention}.`))}\n` : "";
5973
+ if (setting.length === 0) {
5974
+ const named = cookieless.length ? ` The third party ${cookieless.length === 1 ? "service" : "services"} we use on this Site — ${list(cookieless.map((p) => p.name))} — ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.` : "";
5975
+ return `## Cookies Set on This Site
5976
+
5977
+ ${own.length ? `Beyond the cookies strictly necessary to serve and secure the Site, no third party sets cookies through this Site.${named}` : `Beyond the cookies strictly necessary to serve and secure the Site, we do not set cookies.${named}`}
5978
+ ${ownSection}`;
5979
+ }
4394
5980
  return `## Cookies Set on This Site
4395
5981
 
4396
5982
  ${bullets(setting.map((p) => `**${p.name}** (${p.purpose}) — ${(p.cookies ?? []).map((c) => `\`${c}\``).join(", ")}${p.privacyUrl ? `. Privacy policy: ${p.privacyUrl}` : ""}`))}
4397
- ${cookieless.length ? `\nWe also use ${list(cookieless.map((p) => p.name))}, which ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.\n` : ""}`;
5983
+ ${cookieless.length ? `\nWe also use ${list(cookieless.map((p) => p.name))}, which ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.\n` : ""}${ownSection}`;
4398
5984
  }
4399
5985
  /** Only meaningful when a consent tool actually offers the categories. */
4400
5986
  function categoriesSection(v) {
@@ -4410,6 +5996,23 @@ ${bullets(v.cookieCategories)}
4410
5996
 
4411
5997
  //#endregion
4412
5998
  //#region src/legal/templates/privacy.ca.ts
5999
+ /**
6000
+ * Privacy policy — Canada (PIPEDA).
6001
+ *
6002
+ * Ported from the pandoc prototype at `packages/core/legal-templates/`. Two
6003
+ * things changed in the port and both are load-bearing:
6004
+ *
6005
+ * 1. `${var}` is now real TypeScript interpolation rather than a pandoc Lua
6006
+ * filter, so an unresolved variable is a compile error. The filter left
6007
+ * unknown keys as the literal text `${key}` — which would ship a live
6008
+ * privacy policy containing `${countries}`.
6009
+ * 2. Lists are built from arrays, not by splicing a string into a fixed bullet.
6010
+ * An empty derived value used to produce a stray `- ; and` bullet.
6011
+ *
6012
+ * The prose is jurisdiction-specific: it names the Office of the Privacy
6013
+ * Commissioner of Canada and frames transfers as "outside of Canada". Do not
6014
+ * reuse it for another jurisdiction — add a sibling template instead.
6015
+ */
4413
6016
  function privacyCa(v) {
4414
6017
  return `# Privacy Policy
4415
6018
  ${updatedLine(v)}
@@ -4511,15 +6114,46 @@ ${bullets(v.processors.map((p) => `${p.name}, for ${p.purpose}${p.privacyUrl ? `
4511
6114
 
4512
6115
  We remain responsible for personal information in the possession of our service providers.`;
4513
6116
  }
4514
- /** Omitted entirely when nothing leaves the country — a false claim otherwise. */
6117
+ /**
6118
+ * Omitted entirely when nothing leaves the country — a false claim otherwise.
6119
+ *
6120
+ * Two clauses, because storage and legal reach are two facts and the OPC's concern
6121
+ * is foreign **access**, not merely foreign storage. A provider can hold data in
6122
+ * Canada and still be compellable abroad (Azure in a Canadian region), and a
6123
+ * provider can hold only one category of data abroad (a Canadian tenant whose
6124
+ * identity data sits in the US). One sentence could state neither.
6125
+ *
6126
+ * The closer is shared: it is true of whichever clauses appear.
6127
+ */
4515
6128
  function transferSection(v) {
4516
- if (!v.countries) return "";
6129
+ const clauses = [storageClause(v), operatorClause(v)].filter(Boolean);
6130
+ if (clauses.length === 0) return "";
4517
6131
  return `
4518
6132
  ### We May Transfer Personal Information to Other Countries
4519
6133
 
4520
- Some or all of the personal information we collect may be stored or processed in jurisdictions outside of Canada, including ${v.countries}. As a result, this information may be subject to access requests from governments, courts or law enforcement in those jurisdictions according to laws in those jurisdictions.
6134
+ ${clauses.join("\n\n")}
6135
+
6136
+ As a result, this information may be subject to access requests from governments, courts or law enforcement in those jurisdictions according to laws in those jurisdictions.
4521
6137
  `;
4522
6138
  }
6139
+ /**
6140
+ * Where the information rests.
6141
+ *
6142
+ * A scoped location gets its own sentence rather than joining the "including …"
6143
+ * list: folded in, a reader would take it to cover everything the provider holds,
6144
+ * which is a broader claim than the config made.
6145
+ */
6146
+ function storageClause(v) {
6147
+ const scoped = list(v.scopedStorage.map((s) => `${s.country}, in the case of ${s.scope}`));
6148
+ if (v.storageCountries) return `Some or all of the personal information we collect may be stored or processed in jurisdictions outside of Canada, including ${v.storageCountries}.${scoped ? ` Certain categories of personal information are held outside of Canada even where the rest is not: ${scoped}.` : ""}`;
6149
+ if (scoped) return `Certain categories of the personal information we collect are stored or processed outside of Canada: ${scoped}.`;
6150
+ return "";
6151
+ }
6152
+ /** The Azure-in-Canada case: the information never moves, and foreign law still reaches it. */
6153
+ function operatorClause(v) {
6154
+ if (!v.operatorCountries) return "";
6155
+ return `Some of the service providers we use are established in, or controlled from, jurisdictions outside of Canada, including ${v.operatorCountries}. Personal information those providers handle on our behalf may be subject to the laws of those jurisdictions even when it is stored in Canada.`;
6156
+ }
4523
6157
 
4524
6158
  //#endregion
4525
6159
  //#region src/legal/templates/terms.ca.ts
@@ -6385,5 +8019,5 @@ function defaultConfig() {
6385
8019
  }
6386
8020
 
6387
8021
  //#endregion
6388
- export { BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, spriteId, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
8022
+ export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
6389
8023
  //# sourceMappingURL=index.mjs.map