@getvitops/generator 3.0.0 → 4.0.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
@@ -20,7 +20,7 @@ import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
20
20
  *
21
21
  * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
22
22
  * WordPress target needs. Nothing sets it anywhere else, which meant the dark
23
- * flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
23
+ * flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
24
24
  * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
25
25
  * clicking "Dark" changed an attribute no rule matched. Matching both makes the
26
26
  * component work on every target without changing what Bricks already does.
@@ -38,7 +38,7 @@ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
38
38
  *
39
39
  * Derived from `DARK_SEL`'s attributes rather than written out a third time, and
40
40
  * 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
41
+ * exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
42
42
  * `data-theme` entirely — so with this block present System resolves to the OS
43
43
  * instead of silently falling through to light, and needs no JS to do it.
44
44
  *
@@ -519,7 +519,7 @@ const DesignSystemBlock = desc(z.strictObject({
519
519
  "light",
520
520
  "dark",
521
521
  "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.")
522
+ ])), "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
523
  }), "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
524
  const LocaleSchema = z.object({
525
525
  name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
@@ -629,6 +629,17 @@ const DnsDomainSchema = z.object({
629
629
  nameservers: z.optional(z.array(z.string())),
630
630
  records: z.optional(z.array(DnsRecordSchema))
631
631
  });
632
+ /**
633
+ * One domain to onboard into Google Search Console via `vitops search setup`.
634
+ *
635
+ * The verification token is fetched live from the Site Verification API at run
636
+ * time and written to DNS — it is never stored here. What the config records is
637
+ * the *intent*: which domains to onboard, and who should co-own them.
638
+ */
639
+ const SearchConsoleSetupSchema = z.object({
640
+ 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."),
641
+ 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.")
642
+ });
632
643
  const RobotsSchema = z.string();
633
644
  const EnvironmentSchema = z.object({
634
645
  url: desc(z.url(), "Public origin of this environment."),
@@ -818,7 +829,7 @@ const TwitterSchema = z.object({
818
829
  image: z.optional(ImageRefSchema)
819
830
  });
820
831
  /**
821
- * What `vitops notify` needs to tell search engines a deploy happened.
832
+ * What `vitops search notify` needs to tell search engines a deploy happened.
822
833
  *
823
834
  * Scoped by what search engines actually accept, which is narrower than it looks:
824
835
  * Google exposes **no** "request indexing" API (the Search Console button is not in
@@ -832,7 +843,7 @@ const TwitterSchema = z.object({
832
843
  * (a key, a property, which pages matter), never what to say about it.
833
844
  */
834
845
  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`."),
846
+ 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
847
  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
848
  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
849
  });
@@ -843,8 +854,8 @@ const SearchConsoleSchema = z.object({
843
854
  const IndexingSchema = z.object({
844
855
  sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
845
856
  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.")
857
+ 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."),
858
+ 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.")
848
859
  });
849
860
  /**
850
861
  * Which template set the generated legal documents are written against. This is
@@ -878,6 +889,7 @@ const SiteSectionSchema = z.object({
878
889
  })), "Canonical domain + redirecting aliases."),
879
890
  dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
880
891
  cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
892
+ 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
893
  environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
882
894
  abTesting: desc(z.optional(z.object({
883
895
  enabled: z.boolean(),
@@ -904,7 +916,7 @@ const SiteSectionSchema = z.object({
904
916
  bingSiteVerification: z.optional(z.string()),
905
917
  openGraph: z.optional(OpenGraphSchema),
906
918
  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.")
919
+ 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
920
  })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
909
921
  analytics: desc(z.optional(z.object({
910
922
  googleAnalyticsId: z.optional(z.string()),
@@ -917,11 +929,19 @@ const SiteSectionSchema = z.object({
917
929
  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
930
  })), "Matomo instance.")
919
931
  })), "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."),
932
+ notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
933
+ provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
934
+ to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
935
+ 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."),
936
+ fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
937
+ replyTo: desc(z.optional(z.email()), "Reply-To header."),
938
+ binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
939
+ })])), "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
940
  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."),
941
+ enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
942
+ 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."),
943
+ platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
944
+ })), "Ad-click attribution for conversion tracking."),
925
945
  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
946
  legal: desc(z.optional(z.object({
927
947
  jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
@@ -1753,6 +1773,590 @@ function checkContrast(fr, numericOf, surfaceBg) {
1753
1773
  return failures;
1754
1774
  }
1755
1775
 
1776
+ //#endregion
1777
+ //#region src/tiers.ts
1778
+ const c = (classes, partial, generated = false) => ({
1779
+ ...partial ? { partial } : {},
1780
+ classes,
1781
+ generated
1782
+ });
1783
+ /**
1784
+ * Shorthand for the usual `@getvitops/astro/components/<name>.astro` export.
1785
+ *
1786
+ * Not every component lives under `components/` — `CookieConsent.astro` is exported
1787
+ * from the package root — so those are written out in full. The specifier must be
1788
+ * one a consumer can actually import: `tiers.test.ts` checks each against
1789
+ * `packages/astro/package.json`'s `exports`, because a wrong one is a copy-pasteable
1790
+ * import that fails only in the consumer's project.
1791
+ */
1792
+ const a = (name, wraps) => [{
1793
+ component: `@getvitops/astro/components/${name}.astro`,
1794
+ wraps
1795
+ }];
1796
+ const TIERS = {
1797
+ tree: {
1798
+ css: c([
1799
+ "tree",
1800
+ "tree__item",
1801
+ "tree__content",
1802
+ "tree__toggle",
1803
+ "tree__label"
1804
+ ], "patterns/tree.css", true),
1805
+ wc: {
1806
+ tag: "wc-tree",
1807
+ registered: true,
1808
+ adds: "filter, expand/collapse all, hash deep-linking"
1809
+ },
1810
+ astro: a("Tree", "wc"),
1811
+ use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
1812
+ },
1813
+ carousel: {
1814
+ css: c(["carousel"], "patterns/carousel.css", true),
1815
+ wc: {
1816
+ tag: "wc-carousel",
1817
+ registered: true,
1818
+ adds: "cloned slides for a seamless loop, autoplay, snap nav"
1819
+ },
1820
+ bricks: "vitops-carousel",
1821
+ use: "`<wc-carousel>` around your slides; each child is a slide. Works unenhanced as a scroll-snap strip."
1822
+ },
1823
+ entries: {
1824
+ css: c([
1825
+ "entries__table",
1826
+ "entries__scroll",
1827
+ "entries__nav"
1828
+ ], "patterns/table.css"),
1829
+ wc: {
1830
+ tag: "wc-entries",
1831
+ registered: true,
1832
+ adds: "parses heading + `<dl>` pairs into a table above a breakpoint"
1833
+ },
1834
+ bricks: "vitops-entries",
1835
+ use: "`<wc-entries>` around `<h3>` + `<dl>` pairs. The tier-2 exemplar: semantic pairs with no JS, a table with it."
1836
+ },
1837
+ copy: {
1838
+ css: c([
1839
+ "copy",
1840
+ "copy__button",
1841
+ "copy__icon",
1842
+ "copy__tooltip"
1843
+ ], "patterns/copy.css"),
1844
+ wc: {
1845
+ tag: "wc-copy",
1846
+ registered: true,
1847
+ adds: "clipboard write plus success feedback"
1848
+ },
1849
+ bricks: "vitops-copy-button",
1850
+ use: "`<wc-copy>` around the content and a `[data-copy]` button."
1851
+ },
1852
+ dismissable: {
1853
+ css: c(["banner__close", "notification__close"], "patterns/banner.css"),
1854
+ wc: {
1855
+ tag: "wc-dismissable",
1856
+ registered: true,
1857
+ adds: "fade-out + removal, optional auto-dismiss"
1858
+ },
1859
+ bricks: "vitops-dismissable",
1860
+ use: "`<wc-dismissable>` around any banner/notification containing a `[data-dismiss]` control."
1861
+ },
1862
+ "split-panel": {
1863
+ css: c([
1864
+ "splitter",
1865
+ "splitter__panel",
1866
+ "splitter__handle"
1867
+ ], "patterns/splitter.css"),
1868
+ wc: {
1869
+ tag: "wc-split-panel",
1870
+ registered: true,
1871
+ adds: "draggable divider between two panels"
1872
+ },
1873
+ bricks: "vitops-split-panel",
1874
+ use: "`<wc-split-panel>` with two children. Unenhanced they simply stack."
1875
+ },
1876
+ "image-compare": {
1877
+ css: c([
1878
+ "image-compare",
1879
+ "image-compare__before",
1880
+ "image-compare__handle"
1881
+ ], "patterns/splitter.css"),
1882
+ wc: {
1883
+ tag: "wc-image-compare",
1884
+ registered: true,
1885
+ adds: "drag handle revealing the before/after image"
1886
+ },
1887
+ bricks: "vitops-image-compare",
1888
+ use: "`<wc-image-compare>` with two images. Both are visible without JS."
1889
+ },
1890
+ marquee: {
1891
+ css: c([
1892
+ "marquee",
1893
+ "marquee__content",
1894
+ "marquee__item"
1895
+ ], "patterns/marquee.css"),
1896
+ wc: {
1897
+ tag: "wc-marquee",
1898
+ registered: true,
1899
+ adds: "fills the track with enough copies to scroll seamlessly"
1900
+ },
1901
+ use: "`<wc-marquee>` around one `.marquee__content`. Scrolls via CSS alone; JS only removes the seam."
1902
+ },
1903
+ "multi-field": {
1904
+ css: c([
1905
+ "form-group",
1906
+ "input-group",
1907
+ "fieldset"
1908
+ ], "patterns/forms.css"),
1909
+ wc: {
1910
+ tag: "wc-multi-field",
1911
+ registered: true,
1912
+ adds: "add/remove repeating field rows"
1913
+ },
1914
+ bricks: "vitops-multi-field",
1915
+ use: "`<wc-multi-field>` around a field template. One row submits fine with no JS."
1916
+ },
1917
+ "color-scheme-toggle": {
1918
+ css: c([], "patterns/icon.css"),
1919
+ wc: {
1920
+ tag: "wc-color-scheme-toggle",
1921
+ registered: true,
1922
+ adds: "segmented light/dark/system control; persists the choice (consent-gated)"
1923
+ },
1924
+ bricks: "vitops-color-scheme-toggle",
1925
+ 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."
1926
+ },
1927
+ consent: {
1928
+ css: c([
1929
+ "consent",
1930
+ "consent__body",
1931
+ "consent__options",
1932
+ "consent__actions"
1933
+ ], "patterns/consent.css"),
1934
+ wc: {
1935
+ tag: "wc-consent",
1936
+ registered: false,
1937
+ bundle: "@getvitops/core/consent",
1938
+ adds: "the permission gate itself — activates `type=\"text/plain\"` tags on grant"
1939
+ },
1940
+ astro: [{
1941
+ component: "@getvitops/astro/CookieConsent.astro",
1942
+ wraps: "wc"
1943
+ }],
1944
+ use: "`<CookieConsent />`. Its own Lit-free bundle so a site needing consent does not download a rendering framework."
1945
+ },
1946
+ "theme-editor": {
1947
+ css: c([
1948
+ "ed-panel",
1949
+ "ed-launch",
1950
+ "ed-input"
1951
+ ], "patterns/theme-editor.css"),
1952
+ wc: {
1953
+ tag: "wc-theme-editor",
1954
+ registered: false,
1955
+ bundle: "@getvitops/core/editor",
1956
+ adds: "live `:root` token overrides, export as CSS or a config patch"
1957
+ },
1958
+ use: "`<wc-theme-editor manifest=\"…\">`. TOOLING, not a page pattern — the one deliberate tier-2 exception, quarantined in its own opt-in bundle."
1959
+ },
1960
+ "color-wheel": {
1961
+ css: c([]),
1962
+ wc: {
1963
+ tag: "wc-color-wheel",
1964
+ registered: false,
1965
+ adds: "hue/chroma wheel input",
1966
+ bundle: "(none — editor-v2 track, not yet bundled)"
1967
+ },
1968
+ use: "Not shipped. Registered but absent from every bundle, so the tag is inert in a consumer project."
1969
+ },
1970
+ "oklch-color-picker": {
1971
+ css: c([]),
1972
+ wc: {
1973
+ tag: "wc-oklch-color-picker",
1974
+ registered: false,
1975
+ adds: "OKLCH L/C/H picker",
1976
+ bundle: "(none — editor-v2 track, not yet bundled)"
1977
+ },
1978
+ use: "Not shipped. See `color-wheel`."
1979
+ },
1980
+ "icon-picker": {
1981
+ css: c([], "patterns/icon.css"),
1982
+ wc: {
1983
+ tag: "wc-icon-picker",
1984
+ registered: false,
1985
+ adds: "browses the semantic icon map for the configured set",
1986
+ bundle: "(none — editor-v2 track, not yet bundled)"
1987
+ },
1988
+ use: "Not shipped. See `color-wheel`."
1989
+ },
1990
+ "typography-config": {
1991
+ css: c([]),
1992
+ wc: {
1993
+ tag: "wc-typography-config",
1994
+ registered: false,
1995
+ adds: "type role editor",
1996
+ bundle: "(none — editor-v2 track, not yet bundled)"
1997
+ },
1998
+ use: "Not shipped. See `color-wheel`."
1999
+ },
2000
+ details: {
2001
+ css: c([
2002
+ "details",
2003
+ "details-trigger",
2004
+ "details-icon"
2005
+ ], "patterns/details.css", true),
2006
+ astro: a("Details", "css"),
2007
+ use: "`<Details>` — or hand-write `<details>`. No JS at either tier; `::details-content` animates in CSS."
2008
+ },
2009
+ drawer: {
2010
+ css: c([
2011
+ "drawer",
2012
+ "drawer--start",
2013
+ "drawer--end"
2014
+ ], "patterns/drawer.css", true),
2015
+ astro: a("Drawer", "css"),
2016
+ use: "`<Drawer>` — `<dialog class=\"drawer\">` plus an Invoker Commands trigger. No JS."
2017
+ },
2018
+ popover: {
2019
+ css: c([
2020
+ "popover",
2021
+ "popover-trigger",
2022
+ "popover-anchor"
2023
+ ], "patterns/popover.css", true),
2024
+ astro: a("Popover", "css"),
2025
+ use: "`<Popover>` — native Popover API + CSS Anchor Positioning. No JS."
2026
+ },
2027
+ navshell: {
2028
+ css: c([
2029
+ "navshell",
2030
+ "navshell__bar",
2031
+ "navshell__panel",
2032
+ "navshell-toggle"
2033
+ ], "patterns/navshell.css"),
2034
+ astro: [...a("NavShell", "css"), ...a("NavShellToggle", "css")],
2035
+ use: "`<NavShell>` with a `nav` slot, plus `<NavShellToggle>` if the toggle must live outside the shell."
2036
+ },
2037
+ subgrid: {
2038
+ css: c([
2039
+ "subgrid",
2040
+ "subgrid-card",
2041
+ "pricing-grid"
2042
+ ], "patterns/subgrid.css"),
2043
+ astro: a("Subgrid", "css"),
2044
+ use: "`<Subgrid>` — emits `<ul class=\"grid\">` with row-subgrid children so cards align across tracks."
2045
+ },
2046
+ card: {
2047
+ css: c(["card"], void 0, true),
2048
+ astro: a("Cards", "css"),
2049
+ use: "`<Cards>` for a list, or the `.card` class directly. Generated pattern: has roles and states."
2050
+ },
2051
+ icon: {
2052
+ css: c([
2053
+ "icon",
2054
+ "icon-button",
2055
+ "icon-mask"
2056
+ ], "patterns/icon.css"),
2057
+ astro: a("Icon", "css"),
2058
+ bricks: "vitops-icon",
2059
+ use: "`<Icon name=\"menu\" />`. Names are semantic and resolve per configured set; a name containing `:` passes through."
2060
+ },
2061
+ split: {
2062
+ css: c([
2063
+ "split",
2064
+ "split-1-2",
2065
+ "split-reverse"
2066
+ ], "layout.css"),
2067
+ bricks: "vitops-split",
2068
+ use: "`.split` plus a ratio class. Pure CSS; `md-split-1-2` for the responsive form."
2069
+ },
2070
+ centered: {
2071
+ css: c([
2072
+ "centered",
2073
+ "spotlight",
2074
+ "breakout",
2075
+ "fullbleed"
2076
+ ], "layout.css"),
2077
+ bricks: "vitops-centered",
2078
+ use: "`.centered` as a track grid. Children land in `measure` — give it a track child to lay out inside."
2079
+ },
2080
+ sitenav: {
2081
+ css: c([
2082
+ "sitenav",
2083
+ "sitenav__link",
2084
+ "sitenav--drawer-start"
2085
+ ], "patterns/sitenav.css"),
2086
+ bricks: "vitops-sitenav",
2087
+ use: "`.sitenav` with `--bp-*` and a drawer direction. `<details>` + Invoker Commands, no JS."
2088
+ },
2089
+ "split-link": {
2090
+ css: c([
2091
+ "split-link",
2092
+ "split-link__toggle",
2093
+ "split-link__panel"
2094
+ ], "patterns/split-link.css"),
2095
+ bricks: "vitops-split-link",
2096
+ use: "`.split-link` — a link plus a disclosure toggle. No JS."
2097
+ },
2098
+ btn: {
2099
+ css: c(["btn"], void 0, true),
2100
+ use: "`<button>` gets it with no class; `.btn` carries it to other tags. `fill: false`, so states drive `color`."
2101
+ },
2102
+ cta: {
2103
+ css: c(["cta", "cta-danger"], void 0, true),
2104
+ use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
2105
+ },
2106
+ link: {
2107
+ css: c(["link", "skip-link"], "patterns/anchor-link.css", true),
2108
+ use: "`<a>` gets it at zero specificity; `.link` for other tags."
2109
+ },
2110
+ badge: {
2111
+ css: c(["badge", "badge-indicator"], "patterns/tag.css", true),
2112
+ use: "`.badge` — a STATIC label. Use `tag` if it is dismissable or editable."
2113
+ },
2114
+ tag: {
2115
+ css: c([
2116
+ "tag",
2117
+ "tag__remove",
2118
+ "tag__icon",
2119
+ "tag-list"
2120
+ ], "patterns/tag.css", true),
2121
+ use: "`.tag` — an editable/dismissable label. Its group is `label`, not `tag`."
2122
+ },
2123
+ status: {
2124
+ css: c(["status", "status-dot--pulse"], "patterns/tag.css", true),
2125
+ use: "`.status` with a role variant."
2126
+ },
2127
+ tooltip: {
2128
+ css: c(["tooltip-trigger"], "patterns/tooltip.css", true),
2129
+ use: "`.tooltip-trigger` — CSS only, anchor-positioned."
2130
+ },
2131
+ dialog: {
2132
+ css: c([
2133
+ "dialog__header",
2134
+ "dialog__body",
2135
+ "dialog__footer"
2136
+ ], "patterns/dialog.css", true),
2137
+ use: "Native `<dialog>` plus the `.dialog__*` parts. No JS beyond `showModal()`."
2138
+ },
2139
+ dropdown: {
2140
+ css: c(["dropdown--show-on-hover", "dropdown-menu"], "patterns/dropdown.css", true),
2141
+ use: "`.dropdown` — Popover API based."
2142
+ },
2143
+ notification: {
2144
+ css: c([
2145
+ "notification",
2146
+ "notification__title",
2147
+ "notification__actions"
2148
+ ], "patterns/notification.css", true),
2149
+ use: "`.notification` with a role variant; wrap in `<wc-dismissable>` to make it dismissable."
2150
+ },
2151
+ lightbox: {
2152
+ css: c(["lightbox", "lightbox-dialog__content"], "patterns/lightbox.css", true),
2153
+ use: "`.lightbox` thumbnails plus a `<dialog>`."
2154
+ },
2155
+ comment: {
2156
+ css: c([
2157
+ "comment",
2158
+ "comment__author",
2159
+ "comment-thread"
2160
+ ], "patterns/comment.css", true),
2161
+ use: "`.comment` inside `.comment-thread`."
2162
+ },
2163
+ tabs: {
2164
+ css: c(["tabs"], void 0, true),
2165
+ use: "Generated pattern only; no structural partial and no component yet."
2166
+ },
2167
+ nav: {
2168
+ css: c([
2169
+ "menu",
2170
+ "navbar",
2171
+ "nav-collapse",
2172
+ "drawer-nav"
2173
+ ], "patterns/nav.css", true),
2174
+ use: "`.menu` / `.navbar`. See `navbar` and `navshell` for the newer shells."
2175
+ },
2176
+ navbar: {
2177
+ css: c([
2178
+ "navbar",
2179
+ "navbar--sticky",
2180
+ "nav-items"
2181
+ ], "patterns/navbar.css"),
2182
+ use: "`.navbar` with `--start/--center/--end`."
2183
+ },
2184
+ banner: {
2185
+ css: c([
2186
+ "banner",
2187
+ "banner__content",
2188
+ "banner__action"
2189
+ ], "patterns/banner.css", true),
2190
+ use: "`.banner`; wrap in `<wc-dismissable>` for a close button that works."
2191
+ },
2192
+ table: {
2193
+ css: c(["table", "table-wrapper"], "patterns/table.css", true),
2194
+ use: "`.table` inside `.table-wrapper`. See `entries` for the responsive-to-table pattern."
2195
+ },
2196
+ list: {
2197
+ css: c(["facet-list", "filtered-list"], "patterns/list.css", true),
2198
+ use: "`.facet-list` / `.filtered-list`."
2199
+ },
2200
+ "pull-quote": {
2201
+ css: c(["pull-quote", "pull-quote__attribution"], "patterns/pull-quote.css", true),
2202
+ use: "`.pull-quote`. Note the type role `quote` is separate."
2203
+ },
2204
+ combobox: {
2205
+ css: c([
2206
+ "combobox",
2207
+ "combobox__listbox",
2208
+ "combobox__option"
2209
+ ], "patterns/combobox.css", true),
2210
+ use: "CSS shell only — the Lit component is a TODO, so it is not interactive yet."
2211
+ },
2212
+ forms: {
2213
+ css: c([
2214
+ "form-group",
2215
+ "input-group",
2216
+ "fieldset",
2217
+ "custom-check"
2218
+ ], "patterns/forms.css", true),
2219
+ use: "Wrap text controls in `.form-group` or `.input-group` — a bare `<input>` gets browser defaults."
2220
+ },
2221
+ cluster: {
2222
+ css: c([
2223
+ "cluster",
2224
+ "cluster-between",
2225
+ "cluster-start"
2226
+ ], "patterns/cluster.css"),
2227
+ use: "`.cluster` plus an alignment variant."
2228
+ },
2229
+ code: {
2230
+ css: c([], "patterns/code.css"),
2231
+ use: "Element selectors on `pre`/`code`; no class needed."
2232
+ },
2233
+ grouped: {
2234
+ css: c([
2235
+ "group",
2236
+ "group-inline",
2237
+ "bordered"
2238
+ ], "patterns/grouped.css"),
2239
+ use: "`.group-inline` / `.group-block` to join adjacent controls."
2240
+ },
2241
+ "horizontal-scroll": {
2242
+ css: c(["horizontal-scroll"], "patterns/horizontal-scroll.css"),
2243
+ use: "`.horizontal-scroll` for a snap strip."
2244
+ },
2245
+ masonry: {
2246
+ css: c(["masonry", "masonry__item"], "patterns/masonry.css"),
2247
+ use: "`.masonry` — CSS columns based."
2248
+ },
2249
+ media: {
2250
+ css: c([
2251
+ "media",
2252
+ "media__figure",
2253
+ "tile"
2254
+ ], "patterns/media.css"),
2255
+ use: "`.media` for a figure/body pair; `.tile` for the stacked form."
2256
+ },
2257
+ overlay: {
2258
+ css: c(["overlay"], "patterns/overlay.css"),
2259
+ use: "`.overlay` for a scrim."
2260
+ },
2261
+ reveal: {
2262
+ css: c([
2263
+ "reveal",
2264
+ "reveal-fade",
2265
+ "reveal-h"
2266
+ ], "patterns/reveal.css"),
2267
+ use: "`.reveal` — scroll-driven, no JS. Hosts the `interpolate-size` that `<details>` animation needs."
2268
+ },
2269
+ "scroll-based": {
2270
+ css: c([
2271
+ "parallax",
2272
+ "scroll-progress",
2273
+ "scroll-spy"
2274
+ ], "patterns/scroll-based.css"),
2275
+ use: "Scroll-timeline driven; no JS."
2276
+ },
2277
+ "scroll-target": {
2278
+ css: c(["scroll-target-group", "toc-sidebar"], "patterns/scroll-target.css"),
2279
+ use: "`.scroll-target-group` for `:target-current` highlighting."
2280
+ },
2281
+ separator: {
2282
+ css: c([
2283
+ "separator",
2284
+ "separator__text",
2285
+ "separator__ornament"
2286
+ ], "patterns/separator.css"),
2287
+ use: "`.separator`, optionally with text or an ornament."
2288
+ },
2289
+ stack: {
2290
+ css: c(["stack", "stack__card"], "patterns/stack.css"),
2291
+ use: "`.stack` for overlapping cards."
2292
+ },
2293
+ sticky: {
2294
+ css: c([
2295
+ "sticky",
2296
+ "sticky-header",
2297
+ "sticky-nav"
2298
+ ], "patterns/sticky.css"),
2299
+ use: "`.sticky-header` etc. Pure CSS."
2300
+ },
2301
+ svg: {
2302
+ css: c([
2303
+ "svg-container",
2304
+ "svg-shape",
2305
+ "svg-animated"
2306
+ ], "patterns/svg.css"),
2307
+ use: "`.svg-*` helpers for inline SVG."
2308
+ },
2309
+ "text-effects": {
2310
+ css: c([
2311
+ "typing",
2312
+ "text-highlight",
2313
+ "text-gradient-animate",
2314
+ "text-stagger"
2315
+ ], "patterns/text-effects.css"),
2316
+ use: "`.typing`, `.text-highlight`, … CSS animation only."
2317
+ }
2318
+ };
2319
+ /** Pattern names, sorted — a stable order for docs and tests. */
2320
+ const TIER_NAMES = Object.keys(TIERS).sort();
2321
+ /** Every `wc-*` tag the manifest knows, whether shipped in `elements.js` or not. */
2322
+ function tierTags() {
2323
+ return Object.entries(TIERS).filter(([, e]) => e.wc).map(([pattern, e]) => ({
2324
+ tag: e.wc.tag,
2325
+ registered: e.wc.registered,
2326
+ pattern
2327
+ }));
2328
+ }
2329
+ /**
2330
+ * The patterns one tier provides, in `TIER_NAMES` order.
2331
+ *
2332
+ * Every renderer goes through this rather than filtering `TIERS` itself: the docs
2333
+ * bundle projects all four tiers into one table for agents, the docsite projects
2334
+ * each tier as its own page for humans, and they must agree about which patterns
2335
+ * are in a tier. Five copies of `.filter(e => e.wc)` would agree until one of them
2336
+ * didn't.
2337
+ *
2338
+ * `css` needs a predicate rather than a presence check, which is the non-obvious
2339
+ * part. `TierEntry.css` is required, so *every* entry has one — including the
2340
+ * editor-v2 track, whose entries carry `c([])`: no partial, no classes, not
2341
+ * generated. Treating the field's presence as membership would list four patterns
2342
+ * on the CSS page with nothing to show for them, and `color-wheel` genuinely has
2343
+ * no CSS. The other three tiers are optional fields, so presence is the test.
2344
+ */
2345
+ function tierPatterns(tier) {
2346
+ const has = (e) => {
2347
+ switch (tier) {
2348
+ case "css": return !!e.css.partial || e.css.classes.length > 0 || e.css.generated;
2349
+ case "wc": return !!e.wc;
2350
+ case "astro": return !!e.astro?.length;
2351
+ case "bricks": return !!e.bricks;
2352
+ }
2353
+ };
2354
+ return TIER_NAMES.filter((name) => has(TIERS[name])).map((name) => ({
2355
+ name,
2356
+ entry: TIERS[name]
2357
+ }));
2358
+ }
2359
+
1756
2360
  //#endregion
1757
2361
  //#region src/docs.ts
1758
2362
  /**
@@ -1770,13 +2374,13 @@ const ORDER = [
1770
2374
  "split",
1771
2375
  "centered",
1772
2376
  "carousel",
1773
- "color-scheme-toggle",
2377
+ "wc-color-scheme-toggle",
1774
2378
  "wc-copy",
1775
2379
  "dismissable",
1776
2380
  "entries",
1777
2381
  "image-compare",
1778
2382
  "sitenav",
1779
- "wc-multifield",
2383
+ "wc-multi-field",
1780
2384
  "split-link",
1781
2385
  "split-panel"
1782
2386
  ];
@@ -2515,26 +3119,26 @@ function childEntries(node) {
2515
3119
  }];
2516
3120
  return [];
2517
3121
  }
2518
- function renderSchemaNode(label, node, required, depth, out) {
3122
+ function renderSchemaNode(label, node, required, depth, out, maxDepth = 3) {
2519
3123
  const indent = " ".repeat(depth);
2520
3124
  const meta = `(${typeLabel(node)}${required ? ", required" : ""})`;
2521
3125
  out.push(`${indent}- \`${label}\` ${meta}${node.description ? ` — ${node.description}` : ""}`);
2522
- if (depth >= 3) return;
3126
+ if (depth >= maxDepth) return;
2523
3127
  if (node.anyOf && !node.anyOf.every(isPrimitive)) {
2524
3128
  for (const variant of node.anyOf) {
2525
3129
  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);
3130
+ for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out, maxDepth);
2527
3131
  }
2528
3132
  return;
2529
3133
  }
2530
- for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out);
3134
+ for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out, maxDepth);
2531
3135
  }
2532
3136
  /**
2533
3137
  * One `## <key>` section per top-level property: its description, then its
2534
3138
  * fields as a bullet tree. Shared by both references so the two cannot drift in
2535
3139
  * presentation — only in which schema they are pointed at.
2536
3140
  */
2537
- function schemaSections(schema, heading = "##") {
3141
+ function schemaSections(schema, heading = "##", maxDepth = 3) {
2538
3142
  const required = schema.required ?? [];
2539
3143
  const sections = [];
2540
3144
  for (const [key, node] of Object.entries(schema.properties ?? {})) {
@@ -2542,12 +3146,102 @@ function schemaSections(schema, heading = "##") {
2542
3146
  const parts = [`${heading} \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`];
2543
3147
  if (node.description) parts.push("", node.description);
2544
3148
  const bullets = [];
2545
- for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets);
3149
+ for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets, maxDepth);
2546
3150
  if (bullets.length) parts.push("", ...bullets);
2547
3151
  sections.push(parts.join("\n"));
2548
3152
  }
2549
3153
  return sections;
2550
3154
  }
3155
+ const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({
3156
+ "&": "&amp;",
3157
+ "<": "&lt;",
3158
+ ">": "&gt;",
3159
+ "\"": "&quot;"
3160
+ })[c]);
3161
+ /** Sentinel for a lifted code span. Cannot occur in authored schema prose. */
3162
+ const CODE_SLOT = "\0";
3163
+ /**
3164
+ * The inline markdown the schema's `desc()` text actually uses, as HTML. Closed
3165
+ * on purpose, exactly like the legal renderer's subset: these strings are
3166
+ * authored in `schema.ts`, so the set is knowable rather than guessed at.
3167
+ *
3168
+ * Escaping happens before any markup is emitted, so a description containing `<`
3169
+ * cannot inject tags — several do (`<canonical>/<key>.txt`). Exported because the
3170
+ * only safe place for this is one tested function; a caller doing its own escaping
3171
+ * is how one of them eventually doesn't.
3172
+ *
3173
+ * **Code spans are lifted out FIRST, and that is load-bearing rather than tidy.**
3174
+ * `colors.utilities` describes the target families as `` `bg-*` ``, `` `text-*` ``,
3175
+ * `` `border-*` `` — literal asterisk WILDCARDS. Run emphasis over the raw string
3176
+ * and the `*` closing `bg-*` pairs with the one closing `text-*`, wrapping the
3177
+ * text between two unrelated utilities in `<em>` and eating both asterisks. The
3178
+ * prose then silently describes families that don't exist. Lifting code spans
3179
+ * first makes emphasis structurally unable to see inside them.
3180
+ */
3181
+ const renderInlineMarkdown = (s) => {
3182
+ const spans = [];
3183
+ return escapeHtml(s.replace(/`([^`]+)`/g, (_, body) => {
3184
+ spans.push(body);
3185
+ return `${CODE_SLOT}${spans.length - 1}${CODE_SLOT}`;
3186
+ })).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>`);
3187
+ };
3188
+ /**
3189
+ * Walk a JSON Schema into tree DATA.
3190
+ *
3191
+ * The same walk that produces the `authoring.md` / `config.md` bullet lists,
3192
+ * returned as a node model instead of a string. Returning data rather than markup
3193
+ * is deliberate and follows `roleColorUtilities()`: the *shape* of the tree is one
3194
+ * decision, and each medium renders it — markdown for agents, an accessible
3195
+ * `<details>` tree for the docs site. An HTML-emitting version here would be a
3196
+ * second source of the markup contract for `<wc-tree>` to drift from.
3197
+ *
3198
+ * Ids are dotted paths so a field can be linked to directly. Map-value and
3199
+ * array-item pseudo-labels are skipped in the path: `site.dns.records` reads
3200
+ * better than `site.dns.<domain>.records`, and neither `<domain>` nor `[items]`
3201
+ * is a real key.
3202
+ */
3203
+ function schemaTreeNodes(schema, opts = {}) {
3204
+ const { idPrefix = "", maxDepth = 12, prune = [] } = opts;
3205
+ const pruned = new Set(prune);
3206
+ const isPseudo = (label) => label === "<name>" || label === "[items]";
3207
+ const join = (a, b) => a ? `${a}.${b}` : b;
3208
+ /**
3209
+ * Paths already claimed. An `anyOf` branch has no key of its own, so sibling
3210
+ * branches sharing a field name resolve to the same dotted path
3211
+ * (`site.templates.type` exists in all three template variants). Rather than
3212
+ * invent a branch index that no real config document contains, the first
3213
+ * occurrence keeps the id — which is what `getElementById` would have picked
3214
+ * anyway, now true instead of accidental.
3215
+ */
3216
+ const seen = /* @__PURE__ */ new Set();
3217
+ const build = (label, n, required, path, depth) => {
3218
+ const addressable = path !== "" && !isPseudo(label) && !seen.has(path);
3219
+ if (addressable) seen.add(path);
3220
+ const variants = n.anyOf && !n.anyOf.every(isPrimitive) ? n.anyOf : [];
3221
+ const children = [];
3222
+ if (depth < maxDepth && !pruned.has(path)) {
3223
+ for (const v of variants) children.push({
3224
+ label: "one of",
3225
+ type: typeLabel(v),
3226
+ required: false,
3227
+ variant: true,
3228
+ ...v.description ? { description: v.description } : {},
3229
+ children: childEntries(v).map((c) => build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 2))
3230
+ });
3231
+ 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));
3232
+ }
3233
+ return {
3234
+ label,
3235
+ type: typeLabel(n),
3236
+ required,
3237
+ ...n.description ? { description: n.description } : {},
3238
+ ...addressable ? { id: path } : {},
3239
+ children
3240
+ };
3241
+ };
3242
+ const required = schema.required ?? [];
3243
+ return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
3244
+ }
2551
3245
  function renderAuthoring() {
2552
3246
  const sections = schemaSections(jsonSchema);
2553
3247
  return `${frontmatter({
@@ -2592,7 +3286,7 @@ function renderConfig() {
2592
3286
  const required = schema.required ?? [];
2593
3287
  const sections = Object.entries(props).map(([key, node]) => {
2594
3288
  const head = [`## \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`, ...node.description ? ["", node.description] : []].join("\n");
2595
- const fields = schemaSections(node, "###").join("\n\n");
3289
+ const fields = schemaSections(node, "###", key === "designSystem" ? 0 : 3).join("\n\n");
2596
3290
  if (key === "designSystem") return [
2597
3291
  head,
2598
3292
  "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 +3312,7 @@ function renderConfig() {
2618
3312
 
2619
3313
  The document that describes a **whole project**: the token set, the company, and the
2620
3314
  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
3315
+ to — \`vitops legal\`, \`vitops icons\`, \`vitops search\` — and it can also stand in for
2622
3316
  a \`design-system.json\` anywhere the toolchain takes one, since it carries a full
2623
3317
  \`designSystem\`.
2624
3318
 
@@ -2911,14 +3605,14 @@ Raw hue steps (\`--color-<hue>-<step>\`) are the exception — they are fixed va
2911
3605
  ignores the appearance.
2912
3606
 
2913
3607
  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
3608
+ WordPress target), \`data-theme\` is what the shipped \`<wc-color-scheme-toggle>\` writes on
2915
3609
  \`<html>\`, so the toggle works on every other target. Set either.
2916
3610
 
2917
3611
  The OS preference is a **second, opt-in block**. Set
2918
3612
  \`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
2919
3613
  \`@media (prefers-color-scheme: dark)\`, under
2920
3614
  \`${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,
3615
+ \`<wc-color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
2922
3616
  so without this block it fell through to light), and it gives a no-JS page the OS
2923
3617
  appearance, which the toggle alone never could. An explicit light choice still wins.
2924
3618
 
@@ -3128,6 +3822,138 @@ token guaranteed legible over a surface in both appearances. \`default_role\` co
3128
3822
  bare, unsuffixed pattern. States re-apply per variant with the variant's role.
3129
3823
  `;
3130
3824
  }
3825
+ /**
3826
+ * All four tiers in ONE doc — the agent-facing projection of `TIERS`.
3827
+ *
3828
+ * The docsite projects each tier as its own page, because a human arrives already
3829
+ * knowing which stack they are in ("show me the Astro components"). An agent
3830
+ * arrives the other way round: it knows the *pattern* it needs and has to be told
3831
+ * which tiers offer it and which call to make. Four docs would mean an agent
3832
+ * fetching `components/astro` learns `<Tree />` exists and never learns it must not
3833
+ * wrap it in `<wc-tree>` — the composition only exists between the tiers, so the
3834
+ * projection that serves agents is the one that keeps them together.
3835
+ *
3836
+ * `ds` is read for one thing: whether each config-authored pattern is actually
3837
+ * declared in THIS consumer's `patterns.items`. `TIERS.css.generated` records that a
3838
+ * pattern is *of that kind* against this repo's reference config; it cannot know
3839
+ * that a given consumer dropped `card`. Reporting a class that this config emits no
3840
+ * CSS for is the failure worth preventing here.
3841
+ */
3842
+ function renderComponentsConcept(ds) {
3843
+ const declared = new Set(Object.keys(ds.patterns?.items ?? {}));
3844
+ const cell = (s) => s.replace(/\|/g, "\\|");
3845
+ const code = (s) => `\`${s}\``;
3846
+ const list = (xs) => xs.map(code).join(", ");
3847
+ const tiersOf = (name) => [
3848
+ "css",
3849
+ "wc",
3850
+ "astro",
3851
+ "bricks"
3852
+ ].filter((t) => tierPatterns(t).some((p) => p.name === name));
3853
+ const overview = TIER_NAMES.map((name) => {
3854
+ const e = TIERS[name];
3855
+ return `| ${code(name)} | ${tiersOf(name).join(" · ")} | ${cell(e.use)} |`;
3856
+ }).join("\n");
3857
+ const wc = tierPatterns("wc").map(({ name, entry }) => {
3858
+ const w = entry.wc;
3859
+ const ships = w.registered ? code("elements.js") : w.bundle ? code(w.bundle) : "nothing";
3860
+ return `| ${code(`<${w.tag}>`)} | ${code(name)} | ${ships} | ${cell(w.adds)} |`;
3861
+ }).join("\n");
3862
+ 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");
3863
+ const bricks = tierPatterns("bricks").map(({ name, entry }) => `| ${code(entry.bricks)} | ${code(name)} |`).join("\n");
3864
+ const css = tierPatterns("css").map(({ name, entry }) => {
3865
+ const c = entry.css;
3866
+ const config = !c.generated ? "—" : declared.has(name) ? "declared" : "**absent from this config — no CSS emitted**";
3867
+ return `| ${code(name)} | ${c.partial ? code(c.partial) : "—"} | ${c.classes.length ? list(c.classes) : "—"} | ${config} |`;
3868
+ }).join("\n");
3869
+ const hooks = [...new Set(Object.values(BASE_HOOK))].map((sfx) => code(`--${sfx}-<pattern>`)).join(", ");
3870
+ const unshipped = tierPatterns("wc").filter(({ entry }) => !entry.wc.registered && entry.wc.bundle?.startsWith("(none")).map(({ entry }) => code(`<${entry.wc.tag}>`)).join(", ");
3871
+ return `${frontmatter({
3872
+ type: "Design Concept",
3873
+ title: "Vitops components — which tier provides a pattern, and what to write",
3874
+ 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.",
3875
+ resource: DS_PATH,
3876
+ tags: [
3877
+ "components",
3878
+ "web-components",
3879
+ "astro",
3880
+ "bricks",
3881
+ "tiers",
3882
+ "design-system"
3883
+ ]
3884
+ })}
3885
+
3886
+ # Components
3887
+
3888
+ A pattern is provided by up to four tiers, and they **compose** rather than compete:
3889
+
3890
+ 1. **CSS framework classes** — every pattern expressible in pure HTML/CSS. Reach here first.
3891
+ 2. **Web components** (${code("<wc-*>")}, Lit) — only where a pattern genuinely benefits from
3892
+ progressive enhancement. The slotted markup is the fallback and must be usable with no JS;
3893
+ the element parses and augments it in place.
3894
+ 3. **Astro components** — authoring conveniences that emit the correct markup. They must not
3895
+ require runtime JS. Where a pattern has a web component, the Astro component emits that tag
3896
+ **with the accessible fallback inside it**.
3897
+ 4. **Bricks elements** — the same patterns as WordPress/Bricks Builder elements.
3898
+
3899
+ ## Choosing
3900
+
3901
+ **Use the highest-numbered tier available for your stack, and write only its call.** In Astro
3902
+ that is the Astro component; in Bricks the element; anywhere else the classes, plus the
3903
+ ${code("<wc-*>")} tag when one exists.
3904
+
3905
+ The trap is composing two tiers by hand. When ${code("wraps")} below says the Astro component
3906
+ emits the tag, that one call is the whole composition —
3907
+ ${code("<wc-tree><Tree /></wc-tree>")} nests two elements on one tree. A tier-3 component that
3908
+ would need runtime JS is in the wrong tier: build a web component instead and have the wrapper
3909
+ emit its tag.
3910
+
3911
+ ## Every pattern
3912
+
3913
+ | Pattern | Tiers | What to write |
3914
+ | --- | --- | --- |
3915
+ ${overview}
3916
+
3917
+ ## Web components
3918
+
3919
+ Shipped as feature-detected, deferred ES-module bundles. ${code("elements.js")} carries the
3920
+ registered set; ${code("<wc-consent>")} and ${code("<wc-theme-editor>")} ship separately, the
3921
+ first so a site needing consent does not download a rendering framework, the second because it
3922
+ is tooling and opt-in per consumer.
3923
+
3924
+ | Tag | Pattern | Ships in | What JS adds over the fallback |
3925
+ | --- | --- | --- | --- |
3926
+ ${wc}
3927
+
3928
+ ${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.` : ""}
3929
+
3930
+ ## Astro components
3931
+
3932
+ | Component | Pattern | Emits |
3933
+ | --- | --- | --- |
3934
+ ${astro}
3935
+
3936
+ ## Bricks elements
3937
+
3938
+ Controls, defaults and seeded children for each are in [the elements reference](../bricks/elements.md).
3939
+
3940
+ | Element | Pattern |
3941
+ | --- | --- |
3942
+ ${bricks}
3943
+
3944
+ ## CSS
3945
+
3946
+ ${code("patterns.items")} patterns get the full token cascade — ${code("base")} declarations,
3947
+ ${code("states")}, role variants and override hooks (${hooks}); see
3948
+ [component patterns](patterns.md). A structural partial has none of those. The class lists below
3949
+ are representative, not exhaustive — [the class vocabulary](../css/classes.md) states the naming
3950
+ rules that generate them all.
3951
+
3952
+ | Pattern | Partial | Classes | ${code("patterns.items")} |
3953
+ | --- | --- | --- | --- |
3954
+ ${css}
3955
+ `;
3956
+ }
3131
3957
  function renderTopIndex() {
3132
3958
  return `${INDEX_NOTE}
3133
3959
 
@@ -3142,7 +3968,7 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
3142
3968
  * [Authoring reference](authoring.md) - every design-system.json field, generated from the JSON Schema
3143
3969
  * [Config reference](config.md) - every field of the three-section config (designSystem / organization / site), generated from the JSON Schema
3144
3970
  * [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
3971
+ * [Concepts](concepts/) - the colour system, type/space scales, pattern CSS architecture, the four component tiers, icons, and the consent / tracking / search / legal subsystems
3146
3972
  * [CSS framework](css/) - the class vocabulary (colour, type, space, layout, animation, component patterns), stated as naming rules
3147
3973
  * [Bricks Builder](bricks/) - custom elements and how to style them
3148
3974
  `;
@@ -3204,7 +4030,7 @@ astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it b
3204
4030
  *every icon in a set* unless given an \`include\` map — which is why projects end up
3205
4031
  hand-maintaining one.
3206
4032
 
3207
- The \`icons\` option on \`getvitops()\` derives it by scanning your source, merged with
4033
+ The \`icons\` option on \`vitops()\` derives it by scanning your source, merged with
3208
4034
  whatever you declare. On a static build **no include is passed at all**: there is
3209
4035
  nothing to trim there, and a list could only drop a glyph the scan couldn't see.
3210
4036
 
@@ -3259,6 +4085,476 @@ Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
3259
4085
  is no markup to hang an \`<svg>\` on.
3260
4086
  `;
3261
4087
  }
4088
+ function renderConsentConcept() {
4089
+ return `${frontmatter({
4090
+ type: "Design Concept",
4091
+ title: "Vitops consent — a demand-driven permission gate",
4092
+ 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.",
4093
+ resource: CONFIG_PATH,
4094
+ tags: [
4095
+ "consent",
4096
+ "cookies",
4097
+ "privacy",
4098
+ "gdpr",
4099
+ "analytics"
4100
+ ]
4101
+ })}
4102
+
4103
+ # The consent gate
4104
+
4105
+ \`@getvitops/core/consent\` is a **general permission gate, not an analytics feature**. Anything
4106
+ marked \`data-consent="<category>"\` waits on one visitor choice — a third-party tag, an A/B
4107
+ assignment, a personalisation cookie, an embedded map. It carries no Lit and is a separate
4108
+ bundle from \`elements.js\`, because consent is a legal requirement: a site that needs it must
4109
+ not be made to download a rendering framework first.
4110
+
4111
+ Categories are \`necessary\`, \`analytics\`, \`marketing\`, \`preferences\`.
4112
+
4113
+ ## Gating something
4114
+
4115
+ A gated script renders **inert**, with its URL parked on \`data-src\`:
4116
+
4117
+ \`\`\`html
4118
+ <script
4119
+ type="text/plain"
4120
+ data-vitops-tag
4121
+ data-consent="analytics"
4122
+ data-consent-cookies="_ga,_ga_*"
4123
+ data-src="https://example.com/analytics.js"
4124
+ ><\/script>
4125
+ \`\`\`
4126
+
4127
+ ⚠️ **\`type="text/plain"\` is what makes the gate real. Never give a gated tag a live \`src\`.**
4128
+ The browser neither parses the body nor fetches the library, so an undecided visitor's page
4129
+ issues **no third-party request at all**. A gate that instead loads a script and politely asks
4130
+ it not to track is a promise, not a gate.
4131
+
4132
+ \`data-consent-cookies\` is written by whoever emitted the tag, because that is who knows what
4133
+ the provider sets. It is what a revoke clears.
4134
+
4135
+ ## Demand-driven: two different ideas
4136
+
4137
+ **Offered** categories are a build-time fact — which rows \`<CookieConsent />\` renders. The
4138
+ default is deliberately generous; a hidden row costs nothing.
4139
+
4140
+ **Demanded** categories are a runtime fact — what something has actually asked for and the
4141
+ visitor hasn't answered. The banner appears only when a demand is outstanding, so **a site
4142
+ whose only provider is cookieless never interrupts anyone.**
4143
+
4144
+ Demand is registered by:
4145
+
4146
+ - a gated element **reaching its loading strategy** — so an \`idle\` tag asks after \`load\`, off
4147
+ the LCP path, and an \`interaction\` tag asks only once the visitor acts; or
4148
+ - an explicit \`require()\` / \`request()\`.
4149
+
4150
+ ## The runtime API
4151
+
4152
+ \`window.vitopsConsent\`, plus a \`vitops:consent\` \`CustomEvent\` on \`document\`.
4153
+
4154
+ | Call | Does |
4155
+ | --- | --- |
4156
+ | \`granted(cat)\` | Is it granted? **Passive — does not prompt.** |
4157
+ | \`require(cat)\` | Declare a need *now* and report whether it's granted. **This is what raises the banner.** Synchronous. |
4158
+ | \`request(cat)\` | \`require()\`, but resolves once the visitor answers *this* category. |
4159
+ | \`needed()\` | Is a prompt warranted? True only if something demanded an unanswered category. |
4160
+ | \`demanded()\` | What has asked, this page view. |
4161
+ | \`set(patch)\` | Record a decision for exactly these categories. |
4162
+ | \`open()\` | Re-show the banner without discarding the current choice. |
4163
+ | \`reset()\` | Forget the choice and re-prompt. |
4164
+
4165
+ ⚠️ If you want a side effect to be *possible*, you must \`require()\` it. Calling \`granted()\`
4166
+ and doing nothing else is a **permanent no-op** on any site where nothing else demands that
4167
+ category: it is never offered, never granted, and your write never happens — silently.
4168
+
4169
+ ## An absent gate means "store freely"
4170
+
4171
+ \`consent.js\` and \`elements.js\` are both deferred with no ordering between them, so a component
4172
+ can upgrade and be clicked before the gate exists. \`<Head />\` therefore emits a synchronous
4173
+ inline **stub** that answers \`false\` and queues; the runtime replays the queue on load.
4174
+
4175
+ That makes the absence of \`window.vitopsConsent\` meaningful: it reliably means **this site has
4176
+ no gate**. Read it as *store freely* — never as *denied*.
4177
+
4178
+ Because the stub is not the full API, listen for the \`vitops:consent\` event rather than calling
4179
+ \`subscribe()\`, which the stub does not have.
4180
+
4181
+ ## Three-valued, and why it matters
4182
+
4183
+ The cookie (\`vitops_consent\`, v2) records each category as \`true\` / \`false\` / **\`null\`**.
4184
+ "Not asked" is a third value, not a synonym for "declined" — and only \`null\` can be
4185
+ re-prompted. That is what lets a \`preferences\` demand arrive *after* an \`analytics\` prompt was
4186
+ already answered.
4187
+
4188
+ Three consequences:
4189
+
4190
+ - **Nothing is stored until the visitor chooses.** Absence of the cookie is *undecided*, and
4191
+ undecided denies everything but \`necessary\`. If merely showing the banner wrote state, the
4192
+ banner would be the thing it asks permission for.
4193
+ - **A corrupt or wrong-version cookie re-prompts.** It does not read as "decided, all denied" —
4194
+ that safe-looking read strands a visitor who wants to opt in with no way to say so.
4195
+ - **A patch must cover exactly the categories a showing put on screen.** \`<wc-consent>\` builds
4196
+ its own patch rather than calling \`acceptAll()\`, because "Accept" on a preferences-only
4197
+ prompt that also granted analytics would be consent nobody gave. \`acceptAll()\` /
4198
+ \`rejectAll()\` mean *literally every* optional category — widening a patch to "all" is the
4199
+ easy version of this bug.
4200
+
4201
+ ## Revoking reloads
4202
+
4203
+ An already-executing tracker cannot be unloaded. Clearing its cookies only stops it identifying
4204
+ the visitor *next* time, while the running instance keeps reporting until the document goes
4205
+ away — so a revoke clears the named cookies and **reloads**. (\`reloadOnRevoke = false\` defers
4206
+ that to the next navigation.)
4207
+
4208
+ ## The notice must match what loads
4209
+
4210
+ The same config facts drive the **generated cookie notice** — see
4211
+ [legal.md](legal.md). \`site.analytics.clarityId\` is what makes the notice name Clarity; the
4212
+ same provider in \`vitops({ analytics })\` is what makes the tag load. The Astro integration
4213
+ **warns when the two disagree**, because a site running a tag its own notice omits is a
4214
+ compliance defect, not a documentation gap.
4215
+
4216
+ ## Reference consumer
4217
+
4218
+ \`<wc-color-scheme-toggle>\` applies the chosen scheme **immediately** and gates only the
4219
+ \`localStorage\` write. Nothing about honouring a visitor's click needs permission; remembering
4220
+ it does.
4221
+ `;
4222
+ }
4223
+ function renderTrackingConcept() {
4224
+ return `${frontmatter({
4225
+ type: "Design Concept",
4226
+ title: "Vitops conversion tracking — ad attribution and notifications",
4227
+ description: "How an ad click becomes a notified conversion: the `_ac` cookie, consent-demanding capture, the pure notification planner, and the Cloudflare email channel.",
4228
+ resource: CONFIG_PATH,
4229
+ tags: [
4230
+ "tracking",
4231
+ "attribution",
4232
+ "conversions",
4233
+ "notifications",
4234
+ "consent"
4235
+ ]
4236
+ })}
4237
+
4238
+ # Conversion tracking
4239
+
4240
+ A visitor arrives on an ad carrying a click ID; \`<Tracking />\` captures it into the
4241
+ first-party **\`_ac\`** cookie; when they later submit a form or tap a \`tel:\` link,
4242
+ \`createConversionRoute()\` reads the cookie back and notifies whoever the config names.
4243
+
4244
+ \`\`\`json
4245
+ {
4246
+ "site": {
4247
+ "tracking": { "enabled": true, "category": "marketing" },
4248
+ "notifications": { "email": "leads@example.com" }
4249
+ }
4250
+ }
4251
+ \`\`\`
4252
+
4253
+ A bare address is shorthand for \`{ provider: "cloudflare", to }\`. The recipient otherwise
4254
+ falls back to the primary location's email, and the sender to \`noreply@<domains.canonical>\`.
4255
+
4256
+ ## Where each piece lives
4257
+
4258
+ | Layer | Module | Why there |
4259
+ | --- | --- | --- |
4260
+ | Attribution vocabulary + cookie | \`@getvitops/utils/tracking\` | Needed on **both** sides of the wire |
4261
+ | Plan / render / send | \`@getvitops/utils/notify\` | Pure planner, I/O sender |
4262
+ | Capture script, \`<Tracking />\`, route factory | \`@getvitops/astro\` | Beside the analytics components |
4263
+
4264
+ Both utils entries are **separate subpaths** because they are the only modules that run in a
4265
+ **Worker** rather than at build time. Keeping them off the package index is what stops a
4266
+ conversion endpoint pulling \`sharp\` into its bundle. Neither may use a Node builtin.
4267
+
4268
+ ## The capture demands consent
4269
+
4270
+ \`_ac\` is a 90-day identifier tying a visitor to an ad, so it waits on \`marketing\` (override
4271
+ with \`site.tracking.category\`).
4272
+
4273
+ ⚠️ The script calls **\`require()\`**, not \`granted()\` — \`require()\` is what *raises the
4274
+ banner*. A passive \`granted()\` here is a permanent no-op: nothing else on a page demands
4275
+ \`marketing\`, so it is never offered, never granted, and \`_ac\` is never written — silently, on
4276
+ every gated site. The integration adds \`marketing\` to the offered categories when tracking is
4277
+ on, so there is a row for the category the script will ask about.
4278
+
4279
+ **Only an arrival that carried something asks.** The demand is guarded on the URL actually
4280
+ holding a click ID or UTM, so an organic visitor — who has nothing to attribute — is never
4281
+ interrupted. That is demand-driven consent applied to attribution.
4282
+
4283
+ **The capture is synchronous; only the write waits.** Reading the query string is not storage
4284
+ and needs no permission; *keeping* it does. The click ID is in the URL only on the landing
4285
+ page, so deferring the read would lose it outright.
4286
+
4287
+ The marker element carries \`data-consent\` but deliberately **not** \`data-vitops-tag\`: the
4288
+ scan never tries to "activate" it (it is ungated by design), while the revoke path — which
4289
+ queries \`[data-consent="…"]\` — still finds it and clears \`_ac\`.
4290
+
4291
+ ## The event is the abstraction
4292
+
4293
+ \`ConversionEvent\` is the *fact*; how it reads belongs to the channel. That is what lets an SMS
4294
+ channel render 160 characters from the same event an email renders in full.
4295
+
4296
+ **The plan is pure and says why anything is skipped.** \`planNotifications\` touches no network
4297
+ and no binding, so a misconfigured site can be told exactly why no notification will arrive — a
4298
+ silently unsent conversion notification is indistinguishable from no conversion.
4299
+
4300
+ ## The email channel
4301
+
4302
+ Cloudflare Email Sending's **current** binding — structured
4303
+ \`env.EMAIL.send({ to, from, subject, html, text })\`, not the legacy \`EmailMessage\` plus
4304
+ hand-built MIME. The binding is **passed in, never imported**, so utils takes no Cloudflare
4305
+ dependency.
4306
+
4307
+ Only transient codes are retried. \`E_SENDER_NOT_VERIFIED\` and friends are surfaced
4308
+ **verbatim**, because nothing here can check whether the sending domain was onboarded — run:
4309
+
4310
+ \`\`\`sh
4311
+ wrangler email sending enable <domain>
4312
+ \`\`\`
4313
+
4314
+ A generic "send failed" would hide the one thing worth knowing.
4315
+
4316
+ Only \`email\` is implemented. \`sms\` and \`persist\` are a planned seam
4317
+ (\`NotificationsConfig\` plus a sender with \`sendEmail\`'s signature); one channel is not enough
4318
+ to know what the abstraction should be.
4319
+
4320
+ ## Disclosure
4321
+
4322
+ \`_ac\` is disclosed by the generated cookie notice as a first-party cookie. It has to be stated
4323
+ explicitly: no provider table would ever name a first-party cookie, so a site running
4324
+ attribution alongside a **cookieless** analytics provider would otherwise be described as
4325
+ setting no cookies at all. See [legal.md](legal.md).
4326
+ `;
4327
+ }
4328
+ function renderSearchConcept() {
4329
+ return `${frontmatter({
4330
+ type: "Design Concept",
4331
+ title: "Vitops search — Search Console onboarding and deploy notification",
4332
+ description: "What search engines actually accept, why the Indexing API is deliberately not wired, and how `vitops search setup` and `vitops search notify` work.",
4333
+ resource: CONFIG_PATH,
4334
+ tags: [
4335
+ "seo",
4336
+ "search-console",
4337
+ "indexnow",
4338
+ "sitemap",
4339
+ "indexing"
4340
+ ]
4341
+ })}
4342
+
4343
+ # Search
4344
+
4345
+ Two commands, both anchored to a full config:
4346
+
4347
+ - **\`vitops search notify\`** — tell search engines a deploy happened (from \`site.seo.indexing\`).
4348
+ - **\`vitops search setup\`** — onboard domains into Search Console (from \`site.searchConsole\`).
4349
+
4350
+ ## Start from what engines actually accept
4351
+
4352
+ The obvious assumption is wrong, and every design decision here follows from that:
4353
+
4354
+ - **Google exposes no "request indexing" API.** The button in the Search Console UI is not in
4355
+ the Search Console API or anywhere else, and the **URL Inspection API is read-only**.
4356
+ - **The sitemap ping endpoint was removed in June 2023.** \`google.com/ping?sitemap=\` is a no-op.
4357
+ - **The Indexing API is scoped to \`JobPosting\` / \`BroadcastEvent\`.** It accepts other URLs and
4358
+ discards them; general use violates its terms, with *your own* GCP project on the line.
4359
+ ⚠️ **Deliberately not wired. Do not add it.**
4360
+ - **Google does not participate in IndexNow.** Bing, Yandex, Naver, Seznam and Yep do.
4361
+
4362
+ So \`search notify\` does every sanctioned thing and then **verifies**: resubmit the sitemap,
4363
+ ping IndexNow, and \`--check\` inspects \`priorityUrls\` and exits non-zero on one Google hasn't
4364
+ indexed. That last part is what actually replaces the manual Search Console visit.
4365
+
4366
+ ⚠️ Never describe this as making Google re-index faster. It cannot.
4367
+
4368
+ ## \`search notify\`
4369
+
4370
+ - **The pure/I-O split is the point.** The planner decides everything — which URLs, which
4371
+ channels, why each was skipped — and touches no network, no filesystem, no clock. That is what
4372
+ makes \`--dry\` a *complete* account of a run rather than an approximation.
4373
+ - **\`lastmod\` is not a nice-to-have, it is the mechanism.** The changed-URL diff compares each
4374
+ sitemap entry's \`<lastmod>\` against a stored snapshot. With no lastmod the diff can see pages
4375
+ appear and disappear but **never see one change** — so an edited page is never resubmitted, and
4376
+ the command looks healthy while doing less than it appears to. It counts lastmod-less entries
4377
+ and says so every run.
4378
+
4379
+ \`gitLastmod()\` in \`@getvitops/astro\` derives real dates from \`git log\`. It is an exported
4380
+ helper rather than a \`sitemap\` option because it shells out to git and returns **nothing**
4381
+ from a shallow CI clone (\`fetch-depth: 1\`). It leaves an unmatched URL alone rather than
4382
+ stamping the build time: Google weighs lastmod only while it stays consistent with what
4383
+ actually changed, so a site that stamps every page every deploy teaches it to distrust the
4384
+ field site-wide.
4385
+ - **The \`noindex\` gate reads the environment, so the URLs must too.** The run is refused
4386
+ entirely when the resolved environment's \`robots\` contains \`noindex\` — submitting a staging
4387
+ host to IndexNow publishes it to several engines and invites them to crawl it, which a later
4388
+ directive does not undo. Origins therefore derive from \`site.environments[env].url\` **before**
4389
+ \`domains.canonical\`: the canonical is the *production* origin, so deriving from it while
4390
+ notifying staging would submit production URLs the gate would not catch.
4391
+ - **Verify the IndexNow key file before submitting.** A submission whose key file is unreachable
4392
+ returns \`403\`, but one whose key file is *reachable and stale* is accepted with \`202\` and then
4393
+ silently discarded. Only a prior GET distinguishes "submitted" from "submitted and ignored".
4394
+ The key is **not a secret** — the engine fetching it back is the ownership proof — so it lives
4395
+ in the config, and the Astro integration writes \`public/<key>.txt\` from it.
4396
+ - **Write the snapshot last, and only on success.** Writing it eagerly records URLs as notified
4397
+ that never were; because the next run diffs against it, one transient \`503\` would drop those
4398
+ pages from every future run — silently and permanently. A corrupt or absent snapshot reads as
4399
+ "submit everything, and say so", never as "nothing changed".
4400
+
4401
+ ## \`search setup\`
4402
+
4403
+ Automates the otherwise-manual DNS-paste / wait / verify / add-property dance.
4404
+ \`site.searchConsole\` is keyed by bare hostname (mirroring \`site.dns\`):
4405
+
4406
+ \`\`\`json
4407
+ {
4408
+ "site": {
4409
+ "searchConsole": {
4410
+ "example.com": {
4411
+ "delegatedOwners": ["dev@example.com"],
4412
+ "fullUserGroup": "seo@example.com"
4413
+ }
4414
+ }
4415
+ }
4416
+ }
4417
+ \`\`\`
4418
+
4419
+ Per domain it ensures the apex verification TXT in Cloudflare, verifies ownership via the Site
4420
+ Verification API (\`DNS_TXT\`), adds the \`sc-domain:\` property, then adds any
4421
+ \`delegatedOwners\`. The verification token is fetched live and written to DNS — never stored in
4422
+ the config.
4423
+
4424
+ - **Idempotency lives in the planner.** A step whose desired state already holds resolves to
4425
+ \`skip\`, so re-running an onboarded domain is a no-op **by construction**, not because the
4426
+ executors check twice. \`--check\` reports drift, exits non-zero and mutates nothing;
4427
+ \`--dry\` prints the plan and stops.
4428
+ - **DNS is only ever created, never edited or deleted.** The command never removes a record, so
4429
+ the Cloudflare executor simply has no update or delete verb.
4430
+ - **Verification retries with backoff, then reports PENDING — not failed.** \`DNS_TXT\`
4431
+ verification fails until the record propagates, which is slow, not broken. A domain still
4432
+ unverified after the last attempt is \`PENDING\`, and its property and owner steps are skipped
4433
+ for that run. Re-run later.
4434
+ - **Search Console has no user/permission API.** Adding a Google Group as a **Full User** is
4435
+ genuinely un-automatable, so \`fullUserGroup\` is surfaced as a **reminder** in the summary,
4436
+ never attempted. That is distinct from \`delegatedOwners\`, which *are* automated — those are
4437
+ Site Verification web-resource owners (an additive union, so an existing owner is never
4438
+ dropped), a different concept from Search Console property users. Don't conflate them.
4439
+
4440
+ ## Credentials
4441
+
4442
+ Always from the environment; never in the config. There is **one** token exchange and **two
4443
+ grants**, tracking where each command runs:
4444
+
4445
+ | Grant | Env vars | Used by |
4446
+ | --- | --- | --- |
4447
+ | **Service account** (JWT bearer) | \`VITOPS_GSC_SERVICE_ACCOUNT\` (inline JSON) or \`GOOGLE_APPLICATION_CREDENTIALS\` (path) | \`search notify\` — never expires, right for CI |
4448
+ | **User OAuth** (refresh token) | \`VITOPS_GOOGLE_CLIENT_ID\` / \`_CLIENT_SECRET\` / \`_REFRESH_TOKEN\` | \`search setup\` — **required** |
4449
+
4450
+ Cloudflare uses \`CLOUDFLARE_API_TOKEN\` (a \`Zone:DNS:Edit\` token; the standard "Edit zone DNS"
4451
+ template also carries the \`Zone:Read\` the zone-by-name lookup needs).
4452
+
4453
+ \`search setup\` requires user OAuth because **verifying a site makes the caller an owner** of
4454
+ the property, and that should be a person, not a project robot. Note a refresh token can be
4455
+ revoked, and for an OAuth client still in *Testing* publishing status Google expires it after
4456
+ **7 days** — fine for a one-time human setup, bad for a deploy step.
4457
+
4458
+ **\`search notify\` accepts either**, preferring the service account when both are set. Search
4459
+ Console does not care which identity calls it, and someone who has run \`search setup\` already
4460
+ holds a Google credential.
4461
+
4462
+ ⚠️ Do not add \`googleapis\` — an enormous dependency for a handful of REST endpoints in a CLI
4463
+ that installs into every consumer project. The JWT is minted with ~30 lines of \`node:crypto\`.
4464
+ `;
4465
+ }
4466
+ function renderLegalConcept() {
4467
+ return `${frontmatter({
4468
+ type: "Design Concept",
4469
+ title: "Vitops legal documents — generated policy, terms and cookie notice",
4470
+ 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.",
4471
+ resource: CONFIG_PATH,
4472
+ tags: [
4473
+ "legal",
4474
+ "privacy",
4475
+ "cookies",
4476
+ "pipeda",
4477
+ "compliance"
4478
+ ]
4479
+ })}
4480
+
4481
+ # Legal documents
4482
+
4483
+ A privacy policy, terms of service and cookie notice, rendered from a full config: the company
4484
+ from \`organization\`, what the site actually *does* from \`site\`.
4485
+
4486
+ It is a **sibling of the docs generator, not a \`generate()\` format** — structurally, not
4487
+ stylistically. \`generate()\` is keyed to a design system, so a "legal format" would be a format
4488
+ that ignores its own input.
4489
+
4490
+ ## The governing rule
4491
+
4492
+ **The config records facts; the template owns prose.**
4493
+
4494
+ Nothing in the derivation writes a sentence a lawyer would review, and no template invents a
4495
+ fact. That is what lets wording be corrected without touching your config, and your provider
4496
+ change land without touching prose.
4497
+
4498
+ ⚠️ It also means **the fix for a wrong policy is a corrected config.** Hand-editing the output
4499
+ is overwritten by the next build.
4500
+
4501
+ ## What derives from what
4502
+
4503
+ - **The provider table is what makes derivation possible.** A policy naming Plausible while the
4504
+ site runs GA is a compliance defect, not a typo — so the provider comes from *which analytics
4505
+ ID is set*, whether \`site.security.turnstile.siteKey\` exists, and what
4506
+ \`site.deployment.platform\` says. Never from a hand-maintained string.
4507
+
4508
+ It covers only what the schema can imply. Everything else — payment, CRM, mail — is declared
4509
+ in \`site.legal.privacyPolicy.processors\` and flows through the same pipeline.
4510
+
4511
+ - **\`cookies: []\` is meaningfully different from \`undefined\`.** It *asserts* a provider is
4512
+ cookieless (Plausible), which the cookie notice states **positively** rather than omitting.
4513
+
4514
+ - **Form templates are the PII inventory.** \`site.templates\` entries of type \`form\` are the
4515
+ only place the config says what personal information the site actually collects, so the
4516
+ disclosed list derives from their fields. \`hidden\` fields and honeypots are **excluded** —
4517
+ neither is visitor-supplied, and describing them as collected would be untrue.
4518
+
4519
+ - **First-party cookies are declared, not detected.** The attribution cookie \`_ac\` is disclosed
4520
+ this way; see [tracking.md](tracking.md).
4521
+
4522
+ ## The markdown subset is closed
4523
+
4524
+ We author every template, so the renderer is exactly as capable as they are: \`#\`/\`##\`/\`###\`,
4525
+ \`- \` bullets, \`> \` quote, \`**strong**\`, \`\\\`code\\\`\`. **An unsupported construct is an error,
4526
+ not a silent degrade** — that is what stops a literal \`| --- |\` reaching a published page.
4527
+
4528
+ Portable Text maps the \`> \` quote to a banner block and **drops the \`# \` heading**, which is
4529
+ EmDash's own title field.
4530
+
4531
+ ## Jurisdictions
4532
+
4533
+ Adding one is: author three templates, add one enum member, add one registry key. The two are
4534
+ checked against each other at compile time, so skipping either **fails to compile** rather than
4535
+ rendering against the wrong body of law.
4536
+
4537
+ ⚠️ Only **\`ca\` (PIPEDA)** ships. Its prose names the Office of the Privacy Commissioner of
4538
+ Canada and frames transfers as "outside of Canada" — **do not reuse it for another
4539
+ jurisdiction.**
4540
+
4541
+ ## Delivery: one renderer, four consumers
4542
+
4543
+ | Consumer | How |
4544
+ | --- | --- |
4545
+ | **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. |
4546
+ | **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. |
4547
+ | **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. |
4548
+ | **EmDash** | \`--format portable-text\`, pasted into the admin. |
4549
+
4550
+ The CLI is the load-bearing one: it is the surface every consumer has regardless of stack.
4551
+
4552
+ ## The review banner
4553
+
4554
+ Every document opens with a **non-optional** review banner. These are rendered from a template
4555
+ by a build tool; the one failure mode with real consequences is a consumer publishing one as-is.
4556
+ `;
4557
+ }
3262
4558
  function renderConceptsIndex() {
3263
4559
  return `${INDEX_NOTE}
3264
4560
 
@@ -3269,7 +4565,12 @@ function renderConceptsIndex() {
3269
4565
  * [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
3270
4566
  * [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
3271
4567
  * [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
4568
+ * [Components](components.md) - which of the four tiers provides each pattern, and the call to make
3272
4569
  * [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
4570
+ * [Consent gate](consent.md) - inert gated tags, demand-driven prompting, three-valued choices
4571
+ * [Conversion tracking](tracking.md) - ad-click attribution, the \`_ac\` cookie, conversion notifications
4572
+ * [Search](search.md) - what engines accept, Search Console onboarding, deploy notification
4573
+ * [Legal documents](legal.md) - policy/terms/cookie notice derived from config facts
3273
4574
  `;
3274
4575
  }
3275
4576
  function renderCssIndex() {
@@ -3318,7 +4619,12 @@ function generateDocs(ds, assetsDir) {
3318
4619
  "concepts/color.md": renderColorConcept(ds),
3319
4620
  "concepts/scales.md": renderScalesConcept(ds),
3320
4621
  "concepts/patterns.md": renderPatternsConcept(ds),
4622
+ "concepts/components.md": renderComponentsConcept(ds),
3321
4623
  "concepts/icons.md": renderIconsConcept(),
4624
+ "concepts/consent.md": renderConsentConcept(),
4625
+ "concepts/tracking.md": renderTrackingConcept(),
4626
+ "concepts/search.md": renderSearchConcept(),
4627
+ "concepts/legal.md": renderLegalConcept(),
3322
4628
  "css/index.md": renderCssIndex(),
3323
4629
  "css/classes.md": renderCssClasses(ds),
3324
4630
  "bricks/index.md": renderBricksIndex(),
@@ -4041,21 +5347,43 @@ function derivePolicyVars(cfg) {
4041
5347
  "improve our Site to better meet your needs"
4042
5348
  ]);
4043
5349
  const ab = site.abTesting;
5350
+ /**
5351
+ * A site offering the `preferences` category stores at least one setting the
5352
+ * visitor chose — `<wc-color-scheme-toggle>` writes `vitops-color-scheme` to local
5353
+ * storage, gated on exactly this category. The banner asking about it while the
5354
+ * notice never mentions it is the disclosure gap `providers.ts` exists to
5355
+ * prevent, just on the browser-storage side rather than the third-party one.
5356
+ */
5357
+ const hasPreferences = consent?.enabled === true && !!consent.categories?.includes("preferences");
5358
+ /**
5359
+ * `site.tracking.enabled` writes the `_ac` cookie: the click ID from the ad a
5360
+ * visitor followed, kept for 90 days so a later enquiry can be attributed to
5361
+ * the campaign that produced it. It is first-party and never leaves the site,
5362
+ * which is exactly why nothing in the provider table would ever mention it —
5363
+ * and why it has to be stated here instead. A notice that lists every
5364
+ * third-party cookie while omitting the one the site sets itself is the same
5365
+ * disclosure defect, pointed inward.
5366
+ */
5367
+ const hasAdTracking = site.tracking?.enabled === true;
4044
5368
  const cookieTracked = uniq([
4045
5369
  "your IP address",
4046
5370
  "the type of web browser and operating system used",
4047
5371
  "the pages of the Site visited",
5372
+ hasPreferences ? "display settings you have chosen, such as light or dark appearance" : void 0,
5373
+ hasAdTracking ? "which advertisement or campaign brought you to our Site" : void 0,
4048
5374
  ab?.enabled ? "which version of a page you were shown" : void 0,
4049
5375
  hasSessionReplay ? "how you moved through and interacted with a page" : void 0
4050
5376
  ]);
4051
5377
  const cookiePurposes = uniq([
4052
5378
  "keep the Site functioning as you move between pages",
4053
5379
  hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
5380
+ hasPreferences ? "remember display settings you choose, between visits" : void 0,
5381
+ hasAdTracking ? "attribute an enquiry to the advertisement that brought you here, for up to 90 days" : void 0,
4054
5382
  hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
4055
5383
  ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
4056
5384
  site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
4057
5385
  ]);
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;
5386
+ 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
5387
  const usePurposes = uniq([
4060
5388
  "respond to your inquiries",
4061
5389
  "supply you with requested products or services",
@@ -4093,6 +5421,11 @@ function derivePolicyVars(cfg) {
4093
5421
  optOutOptions,
4094
5422
  cookieOptOutOptions,
4095
5423
  processors,
5424
+ firstPartyCookies: hasAdTracking ? [{
5425
+ name: "_ac",
5426
+ purpose: "records the advertisement or campaign you arrived from, so an enquiry can be attributed to it",
5427
+ retention: "90 days"
5428
+ }] : [],
4096
5429
  countries: countries || void 0,
4097
5430
  consentModel: consent?.enabled ? consent.type ?? "opt-in" : "none",
4098
5431
  cookieCategories: consent?.categories ?? []
@@ -4383,18 +5716,28 @@ If you have questions about our use of cookies, please contact us at ${v.mailing
4383
5716
  * "This site sets no cookies" is a real and increasingly common answer — a
4384
5717
  * cookieless analytics provider is a selling point, not an omission. Saying it
4385
5718
  * plainly beats an empty section that reads as an oversight.
5719
+ *
5720
+ * But it is only true if the site sets none of its **own**, which is why
5721
+ * `firstPartyCookies` is checked alongside the processors: a site running
5722
+ * attribution with a cookieless analytics provider sets `_ac` and no third-party
5723
+ * cookie at all, and the old wording would have declared it cookie-free.
4386
5724
  */
4387
5725
  function providersSection$1(v) {
4388
5726
  const setting = v.processors.filter((p) => p.cookies?.length);
4389
5727
  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
- `;
5728
+ const own = v.firstPartyCookies;
5729
+ const ownSection = own.length ? `\n### Cookies We Set Ourselves\n\n${bullets(own.map((c) => `\`${c.name}\` — ${c.purpose}. Kept for ${c.retention}.`))}\n` : "";
5730
+ if (setting.length === 0) {
5731
+ 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.` : "";
5732
+ return `## Cookies Set on This Site
5733
+
5734
+ ${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}`}
5735
+ ${ownSection}`;
5736
+ }
4394
5737
  return `## Cookies Set on This Site
4395
5738
 
4396
5739
  ${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` : ""}`;
5740
+ ${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
5741
  }
4399
5742
  /** Only meaningful when a consent tool actually offers the categories. */
4400
5743
  function categoriesSection(v) {
@@ -6385,5 +7728,5 @@ function defaultConfig() {
6385
7728
  }
6386
7729
 
6387
7730
  //#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 };
7731
+ 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, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
6389
7732
  //# sourceMappingURL=index.mjs.map