Changelog
Release notes for the @getvitops/* packages — what changed, what broke, and how to migrate.
Release notes for the @getvitops/* packages — what changed, what broke, and how to migrate.
@getvitops/core, generator, utils, cli, vite and astro share one version: they are
released together and are only supported in matching versions (the generator embeds a snapshot of
core’s CSS and web-component bundles, and the Astro integration copies the installed core’s
bundles into your public/ — mixing versions can leave the CSS and the components disagreeing).
@getvitops/emdash and @getvitops/create version independently.
Per-package detail — including every release before 0.7.0 — ships with each package:
node_modules/@getvitops/<pkg>/CHANGELOG.md.
7.0.0 — 2026-08-11
A gallery/lightbox split, <wc-tree> gaining real keyboard navigation, and a backlog of
smaller features (<wc-counter>, itemListGraph(), GBP location fields, an ADC/gcloud
login path for vitops search) all landing in one release.
Only @getvitops/core, generator, utils and astro carry consumer-facing change here.
@getvitops/cli and vite ship with no consumer-facing change in this release — they are
bumped because the six share one version.
Breaking
.lightbox(the thumbnail grid) is now.gallery(all formats), so it composes with the dialog it opens (.lightbox-dialog, unchanged) instead of sharing its name. Not reachable byvitops lint --fix(that renames colour tokens, not classes) — migrate by hand:.lightbox→.gallery,.lightbox__item→.gallery__item,.lightbox__thumb→.gallery__thumb.<wc-gallery>(@getvitops/core/elements) and<Gallery />(@getvitops/astro) are new, adding prev/next, arrow keys, swipe, an announced counter, and a thumbnail → full-imagestartViewTransitionmorph on top of a zero-JS native<dialog>fallback.<Carousel />’sCarouselSlide.srcalso now acceptsImageMetadata, purely additively.<wc-tree>now unwraps every branch’s<details>/<summary>into a plain, JS-owned structure with realrole="tree"/role="treeitem"semantics and roving-tabindex keyboard navigation (arrow keys, Home/End, type-ahead), replacing native tab-through-everything. The no-JS fallback is unaffected — still native nested<details>. Two things break for a consumer that reached into the enhanced DOM: a captured<details>/<summary>reference (tree-togglereplaces it) and tab-order expectations (arrow keys replace tabbing between rows).patterns/tree.cssgains.tree__summary(all formats); visually unchanged.radii.pill/--br-pillis gone (css/bricksonly — tailwind never emitted it and already ships its own).999pxis a shape, not a config-editable design decision, so it’s now therounded-fullutility (plus a newrounded-none), matching Tailwind’s naming. A consumer overriding--br-pilldirectly should override the specific hook instead —--br-switch-track,--br-countor--br-badge-indicator— each of which already existed and now falls back straight to the literal..badgeis unaffected; it already resolved to999pxand still does, now through its own override. Not reachable byvitops lint --fix.
Added
vitops searchnow works from agcloudlogin (Application Default Credentials), not just the explicitVITOPS_GOOGLE_*vars — removes the footgun where a self-created OAuth client sits in Testing status and Google expires its refresh token after 7 days. Needs a newsite.google.projectfield (required for a user credential, refused for a service account) so API usage attributes to the right Cloud project per site; an ADC credential missing it is now refused before any request, naming the file and the project gcloud recorded for it.<wc-counter>— animates a number from a start value to the value already in its own fallback text, on intersection, formatted throughIntl.NumberFormatso a grouped1,284never animates through ungrouped intermediates. CSS drives the interpolation and easing curve (data-easingmaps onto the realanimation.csstokens) so nothing duplicates them in JS.patterns/counter.css(all formats);<Counter />in@getvitops/astro.itemListGraph()(@getvitops/utils) — a pure schema.orgItemListJSON-LD builder, extracted from<Carousel />’s inline object literal so any consumer can build a validItemListfor content outside the six Google carousel rich-result types.<Carousel />’sCarouselItem['type']now accepts any string as a result — additive, existing callers unaffected.- Four new
organization.locations.<slug>fields —hoursSpecial,photos,sameAs(the location’s own listing URLs) andlistings(external ids keyed by platform:google/bing/apple) — what GBP, Bing Places and Apple Business Connect actually need, surfaced through the newlocalBusinessGraph()builder (@getvitops/utils, a sibling oforganizationGraph()and friends).<LocalBusiness />’s prop type is now that builder’s options type, not a local interface that had drifted from it.
Fixed
<wc-carousel>’s dot-nav arrow keys now follow writing direction — previously index-based and backwards in RTL. Two fast arrow presses no longer land on the same dot twice.- An
ADCfile inGOOGLE_APPLICATION_CREDENTIALSno longer hard-exitsvitops search— that variable carries both service-account and gcloud-login credentials, and only the former was handled; it’s now discriminated ontype, the field Google’s own libraries switch on. @getvitops/core’s build now minifies the CSS/HTML inside its Litcss/htmltemplate literals, which a JS minifier can’t see through —elements.jspreviously shipped that markup and styling byte-for-byte unminified. No behaviour change, smaller bundle.
6.0.0 — 2026-08-08
The carousel, reworked so that it actually works outside Chromium.
Only @getvitops/core, generator and astro changed. utils, cli and vite carry no
consumer-facing change — they are bumped because the six share one version. @getvitops/emdash
0.3.6 rewires its carousel block onto the new Astro component and raises its @getvitops/astro
peer to >=6.0.0.
::scroll-button(), ::scroll-marker and ::scroll-marker-group are still Chromium-only — not
Baseline, Safari landing, Firefox flagged — and the pattern was written as though they were
universal. In Firefox that meant a carousel with no arrows, no dots, and (because .carousel set
scrollbar-width: none) nothing at all telling the visitor the strip scrolled. There is no usable
polyfill: the only candidate is the spec author’s flackr/carousel prototype, which is unpublished,
targets the superseded syntax (::scroll-left-button, :checked) and only re-parses inline
<style> elements, so it would never see a linked stylesheet.
So the framework stops assuming, and states which branch is live in the stylesheet: one
@supports sets --carousel-native-nav: 1, and <wc-carousel> reads that custom property back
rather than re-running CSS.supports. The condition is therefore written once. It also answers the
better question — not “does this engine know the pseudo-element” but “did my stylesheet’s native
branch apply” — so a page that loads elements.js without the framework CSS gets working controls
instead of none.
Breaking
.carouselis now a shell;.carousel__trackis the scroller (all formats). The hint and the fallback controls need somewhere that isn’t the scrollport, and the shell lets them beposition: absoluteagainst it — replacingposition: fixed+anchor-name/position-area, which pinned the autoplay button to a viewport corner in any engine without anchor positioning (the oddbird polyfill supports neitherposition-areanor pseudo-element anchors, so it never rescued it). Migration: wrap the slides in<ul class="carousel__track">. The 5.x single-element form still has a CSS path (:not(:has(> .carousel__track))) and the element promotes it at runtime, but it is deprecated — it has nowhere to put the hint, and author CSS matching.carousel > .slidebreaks once the element promotes.- Looping is opt-in:
<wc-carousel loop>. It was unconditional. Cloning triples the markup, duplicates every image element and makes the scrollbar report 3× the real content, which is a bad default for the majority of carousels that never wanted an infinite strip.autoplayimpliesloop. Migration: add the attribute (orloopon<Carousel />). [part='autoplay']→.carousel__autoplay, aliased for one release.partonly crosses a shadow boundary and this element is light DOM, so it was an arbitrary attribute selector wearing a platform name. The button is also appended to the shell now, not into the grid track where it formed a slide-width column.- The host role is
group, notregion.regionis a landmark, so a page with four carousels grew four landmark entries.<Carousel landmark />opts back in where the carousel really is a page-level landmark. carousel--auto-pagesdegrades to a plain multicol strip where::columnis missing, rather than being emulated. Reimplementing column fragmentation in JS is a layout engine; the strip still scrolls and snaps.
Not reachable by vitops lint --fix: .carousel → .carousel__track is a structural change, not
a rename, so no token rewrite can express it. The migration is the wrapper element above.
Changed
- The scrollbar is visible by default (
scrollbar-width: thin, wasnone) and a “Scroll for more” hint renders above the strip. Both dim once the visitor scrolls — via a named scroll-progress timeline hoisted withtimeline-scopewhere available, and adata-scrolledattribute the element sets otherwise. (Notscroll-state(): its queries match descendants of the scroll container, and the hint is deliberately not one.) Opt out with.carousel--no-scrollbarandhint={false}. This is a visual change on every existing carousel. global.cssgains a narrow media reset —max-inline-size: 100%onimg/svg/video/canvas/iframe/embed/objectandblock-size: autoonimg/video, both in:where()and invitops.baseso they lose to everything. Deliberately withoutdisplay: blockonimg: that is the half of the classic reset that breaks inline images in prose,.icon > img, marquee logo runs and.rhythm’s sibling spacing.
Added
@getvitops/astro/components/Carousel.astro— the tier-3 wrapper, emitting semantic<ul>/<li>/<figure>markup, a focusable scroller, and the right per-slide image priorities: the first slideeager/highas the LCP candidate, the restlazy/low/async.loading="lazy"is honoured for images clipped by a horizontal scroll container, so off-screen slides defer with no JavaScript — nodata-src+ IntersectionObserver to fail. The EmDashvitops.carouselblock is now a thin adapter over it.- Real prev/next buttons and dot navigation built by
<wc-carousel>where the pseudo-elements are absent, carrying the classescarousel.cssstyles with the same declarations as the native path. A guard (carousel-parity.test.ts) asserts the two blocks stay identical — they cannot be one selector list, because a list containing an unparseable pseudo-element is dropped wholesale. carousel--markers-below,carousel--no-scrollbar, and--carousel-slide-aspect/--carousel-media-fit/--carousel-button-size/--carousel-hint-*.
Fixed
All formats.
::scroll-button()glyphs sat off-centre in their circles.global.css’s reset is*, *::before, *::after, which does not reach a::scroll-button()— so the pseudo wascontent-boxcarrying Chrome’s UApadding: 1px 6px, andinline-size: 44px+aspect-ratio: 1sized a 56×46 ellipse.line-height: normallet the glyph ride its own half-leading on top of that.- The button hover state was a no-op, setting the same
--color-bg-surface-mutedthe base rule set; the token migration had flattened two distinct oklch literals onto one variable. - Slide heights varied with each image’s aspect ratio —
carousel.csshad noimgrule andglobal.cssno media reset, so one portrait photo set the row height for a strip of landscapes. - Navigation was physical, not logical.
::scroll-button(left|right)andscrollLeft/offsetLeftarithmetic put “previous” on the wrong side in RTL and pointed the chevron the wrong way;hasScrollednever fired there at all, becausescrollLeftis negative in an RTL scroller. - Cloned slides duplicated their
ids, and cloned images kept the LCP candidate’seager/fetchpriority="high". scrollBy(clientWidth)overshot whenever--carousel-slide-sizewas not100%; stepping now scrolls a specific slide into view, which also honoursscroll-padding-inlineand snap alignment.index.html’s carousel demo set--carousel-columns: 3withoutcarousel--auto-pages, so it did nothing.
5.0.0 — 2026-08-08
Two themes. Dangling token references and contradictory config are now build failures rather
than things that resolve to nothing in the browser. And the framework states which layout
patterns are foundational, and reports it when you reach past them — from repeated downstream
reports that sites were inventing a .wrap class instead of using .centered, and that
.subgrid was going essentially unused.
Read the Breaking section if you have a config with hand-written var(--…) references, or CSS
that absolutely positions inside a .subgrid-card.
Breaking
-
validate()resolves everyvar(--…)a config authors — inpatterns.defaults/radii/groups/items.*.{base,overrides,states},typography.rolesandshadows— against the tokens that config actually emits, and a reference to a token that does not exist now fails the build. One downstream config shipped a.ctawhosecolorfell back toinheriton a brand fill (unreadable text on every filled button), and two of its four dead references had been dead for several releases with nothing reporting them. Anchored to the namespaces the generator owns (--color-*,--shadow-*,--z-tier-*,--surface-glass,--overlay); a reference carrying a fallback is never flagged.vitops lint --fixis the migration — it rewrites pre-1.0 colour-grammar references in a single pass, because several of those renames rotate and applying them sequentially compounds them. -
A genuine contradiction between
site.*config andvitops({ … })options fails the build, naming both sides. These were two hand-synced declarations of the same fact with nothing comparing them, so a site could ship a cookie notice naming categories its banner never offered. An absent option is not a contradiction — it takes the config’s value. -
A surface-shaped role declared chromatic now warns, and
--surface-glass/--overlay/.glassare no longer emitted whensurfaceis chromatic (they read--color-bg-surface, which a chromatic role does not emit, so they pointed at nothing). (css/bricks; the tailwind format emits its own equivalents from@theme.) -
.subgrid-cardnow setsposition: relative, so astretched-linkinside it needs no extra class. If you were absolutely positioning a descendant of a.subgrid-cardagainst an ancestor outside the card, it now resolves against the card — move the positioning context explicitly. (all formats.) Not reachable byvitops lint --fix: which ancestor you meant is not something the linter can know. -
<Subgrid />renders its slot verbatim — author the<li>items yourself.<Subgrid> - <article class="card">…</article> + <li class="card subgrid-card">…</li> </Subgrid>It used to parse the slotted HTML and rebuild each child as an
<li>, carrying over onlyclassandstyle. That existed solely because a<ul>may contain nothing else, and it cost more than it bought: the child’s tag was discarded,id/data-*/aria-*were silently dropped, andhrefcould not survive at all. Nothing is copied now, so nothing is lost.aspicks the container (ulby default,ol,div) and all other props are forwarded — they were previously discarded,roleincluded. -
<Cards />no longer addsclass="card"to slotted children, since it no longer parses them — write the class on the item. It now emits<wc-cards>around a<Subgrid>, and like<Tree />it emits its own element, so do not wrap it in<wc-cards>yourself. Its previousrole="list"was passed to<Subgrid>and silently discarded, so it never took effect. -
<Subgrid />and<Tree />emitrole="list", as does the Brickssitenavelement.list-style: nonestops Safari + VoiceOver announcing a<ul>as a list, so every marker-less framework list was quietly losing the semantics its<ul>was chosen for. If you hand-write.subgrid,.list,.facet-list,.nav-items,.collapse-menuor.treemarkup, addrole="list"yourself — the framework cannot add it to markup it does not render, and each partial now says so at thelist-stylereset. (Markup only; no CSS change.)
Added
-
A Foundations section at the top of the class reference, stating six substitutions as temptation → what to write instead (
wrap/max-width+auto→centered; section padding →region; prose margins →rhythm;repeat(n, …)→subgrid;repeat(auto-fit, …)→grid-auto;flex+gap→cluster)..subgrid,.cluster,.regionand.grid-autowere absent from that reference entirely —vitops docs classesis the doc an agent fetches to decide which class to apply, and the framework’s answer for a set of cards was not in it, which is most of why it went unused. The same table now ships in the agent skill and in the@getvitops/createtemplate’sAGENTS.md, since a pointer to a doc only helps someone who already suspects there is something to look up. (Documentation; no CSS change.) -
Two answers for “the whole card is a link”, because one cannot exist.
<li><a class="card">is the shape that gets reached for, and it is wrong in a way that renders fine — the<li>is the grid item, so the anchor is an ordinary block inside it and the tranches within it never reach the parent’s shared row lines, so the alignment.subgridexists for silently does not happen. Putting the anchor in the grid’s place (<ul><a></ul>) is invalid HTML, so there had been no correct shape to reach for.There are now two, and they trade against each other, because no CSS-only technique can make a whole card clickable and leave its text selectable — a transparent overlay necessarily receives the pointer-drag:
Want Use zero JS, whole card clickable .stretched-link— text not selectableselectable text and a clickable card <Cards>/<wc-cards>— needs JS.stretched-linkgoes on a link inside the card; its::aftercovers the card. Ships with.relativefor the bare-.cardcase and.raisedfor anything that must sit above the overlay. Never layer the two — the overlay wins, so selection is lost and the JS never runs;vitops lintreports the combination. (all formats.) -
<wc-cards>— a new tier-2 element (inelements.js, emitted by<Cards>). It adds no overlay and instead distinguishes a click from the end of a drag, so the card’s text stays selectable. Its fallback is the card’s own link, fully usable with no JS; it adds notabindexand norole, so keyboard order and list semantics are untouched; and the pointer cursor is applied by the element, so the affordance never appears without the behaviour. It takes nohref— it forwards to the card’s real link, which also inheritstarget,rel,downloadand modifier-key handling rather than reimplementing them. -
Three new
vitops lintreuse rules and a markup pass. The.centeredrule previously fired only when the hand-written CSS already referenced--width-measure— precisely the author who was never going to hand-roll a container. It now also catches a page-scalemax-width(≥ 48rem, or achreading measure) with auto margins, and a container-shaped class name (wrap,wrapper,container,inner,shell, …) with any width cap, reading the cap out ofmin()/clamp(). New rules report a hand-writtenrepeat()grid as.subgrid/.grid-auto, a repeated card set laid out without either (a loop rendering a card, or three or more cards written out), the broken<li><a class="card">shape, and.stretched-linklayered inside<wc-cards>. The markup pass is the only check that can see this drift at all — a card list built from utility classes contains no bad class and no hand-written CSS. All aresuggestionseverity and do not fail a build without--strict. They found five real instances in this repo’s own docs site. -
vitops lint [files...]takes explicit paths instead of scanning--src, so it can be wired into a pre-commit hook —vp’sstagedkey appends the staged files to whatever it runs. Unreadable and non-source paths are skipped rather than fatal. The@getvitops/createemdash template now wires it with--strictand adds alint:designscript. -
--helpworks on every subcommand.vitops lint --helpused to exit non-zero withUnknown option '--help'; it is now answered before dispatch, and a drift guard fails the build if a command has no documented options. -
@getvitops/astro/trackingre-exports@getvitops/utils/tracking(plusTRACKING_ENDPOINT), so the documented conversion flow works with one install — under strict pnpm, app code cannot resolve a transitive dependency, and the obvious workaround drags the integration’s Node builtins toward a Worker bundle. -
sendEmailtakestimeoutMs(default 10s).binding.send()had no deadline, so one hung attempt hung the request indefinitely. A timeout is retried like any other transient failure.
Fixed
packages/generator/src/docs.tscontained a literal NUL byte (CODE_SLOTwritten as the raw character rather than an escape), so libmagic classified it asdataand grep/ripgrep treated the largest doc emitter in the repo as binary and silently skipped it — every search for a string in it returned nothing, with no error and nothing to notice..raisedreplaces the advice to use.relativeabove astretched-linkoverlay, which could not work: a positioned element atz-index: autodoes not rise above an explicitz-indexregardless of DOM order, so a second link or button in the card stayed underneath and unclickable..raisedsets both, against the same--z-tier-raisedtoken the overlay now uses in place of a hard-coded1. (all formats.)createConversionRoute’s example was broken on every supported Astro — it read the binding offlocals.runtime.env, removed in Astro v6, while the package peers on>= 7, and called a helper that does not exist. Now usesimport { env } from 'cloudflare:workers'./api/trackis exported asTRACKING_ENDPOINT, and the build warns when tracking is on and no route answers it. It was a bare literal inside the inlined capture script: name the route anything else and everytel:beacon 404s with nothing erroring.require()warns when the page has no<wc-consent>— the demand can never be granted, so whatever waited on it never ran, silently.describeEventno longer reports “from Unknown” for forms with no field literally namedname; it falls back throughfirst_name/last_name,full_name, then the email address.vitops search notifywarns when the sitemap’s URLs aren’t on the configured origin. A route collision served a valid sitemap listing another site’s pages and the run reported a healthy submission.vitops agentsno longer writes a path it guessed — it finds the config by shape and interpolates the resolved path into every emitted command, rather than writing “tokens live indesign-system.json” into a project whose tokens are incompany.json.vitops search setup --dryandvitops ads setup --dryrun without credentials. Both also now mention that the Cloudflare token needsZone:ReadalongsideZone:DNS:Edit.vitops legalnames the files it wrote and flags new ones, and prints the review reminder on the stdout path, where it was missing entirely.vitops initwrites a versioned$schema(./node_modules/@getvitops/generator/schema.json) instead of an unpinned unpkg URL that resolved to whatever was newest.favicon.backgroundColorandfavicon.nameare described accurately, and the missing-background warning now fires for a dark opaque mark, not only a transparent source.
No consumer-facing change in this package
@getvitops/vite— no source changes at all in 5.0.0; it is majored only because the toolchain shares one version. No code edit of yours is required. The generator changes it runs do still apply, so a config with a danglingvar(--…)now fails your build through this plugin.@getvitops/utils— bumped by thefixedgroup, but not empty: it carries thesendEmailtimeout and the indexing-origin warning above. No CSS moved and no code edit is required.@getvitops/emdash0.3.5 — a dependency bump to@getvitops/utils@5.0.0and nothing else.
Also
- Astro and Bricks are documented as the same tier, having been published as tier 3 and
tier 4 in
vitops docs components, the shipped agent skill and the docs site. Both are platform wrappers generating HTML from the classes and elements of tiers 1 and 2 — siblings chosen by platform, neither above the other, and no project uses both. The cause was a projection axis read as a hierarchy:Tierhas four keys because there are four tables to render, and the number was derived from that key order, so the last key became “tier 4”. The advice beside it inherited the error, saying to use “the highest-numbered tier your stack has”. Which patterns each tier provides is unchanged. (Documentation only.)
4.1.0 — 2026-08-06
Ad platforms become something the toolchain can see: a site.ads block, a vitops ads command that
verifies your domains and emits the pixels, an <Ads /> component, and a cookie notice that
discloses them. Plus a privacy-policy correction that separates where a provider stores
information from whose law can reach it — read that section if you have already published a
policy.
Added
-
site.ads— the ad properties a site is linked to, keyed by platform (google,meta,linkedin,reddit,tiktok,microsoft,pinterest,snapchat). Account id, tag id, domain-verification token, consent category. This is the gap the rest of the feature exists to close: a pixel pasted into a template was invisible to the toolchain — it set_fbpon a site whose generated cookie notice never mentioned it, whose consent gate never cleared it on revoke, and whose attribution (for LinkedIn and Pinterest) never captured the click ID at all.{ "site": { "ads": { "meta": { "pixelId": "123456789", "domainVerification": "abc123" }, "google": { "accountId": "123-456-7890", "pixelId": "AW-987654321" }, }, }, } -
vitops ads setupensures each platform’s domain-verification DNS record, in Cloudflare viaCLOUDFLARE_API_TOKEN— created only, never edited or deleted. Idempotent: a re-run of a linked property is all skips.--dryprints the plan;--checkreports drift and exits non-zero.Only four platforms verify a domain at all — Meta, TikTok, Pinterest and Snapchat, by apex DNS TXT. Google Ads, LinkedIn, Reddit and Microsoft Ads have none: linking there is the tag and the account id, and the run says so rather than skipping in silence. No platform Marketing API is called — Meta’s needs a system-user token, Google’s an approved developer token with your own account on the line — so the final “Verify” click is surfaced as a reminder.
-
It prompts for the verification token. The token does not exist until someone opens the platform UI, so the first run asks for it, naming the exact UI path, and writes it to your config. It is not a secret: it is published in DNS, and the platform fetching it back is the ownership proof — the same reasoning that puts the IndexNow key in the config. Prompting needs a TTY; with
--dry,--check,--no-promptor in CI you get a named error instead and the run never hangs.--no-writekeeps the answer out of the file. -
vitops ads tagsprints each pixel as an inert, consent-gated<script>—type="text/plain"with the library URL ondata-src, so an undecided visitor’s page issues no third-party request. For Bricks, WordPress, Eleventy: any stack without the Astro integration. -
vitops ads lintreports what is invisible at runtime: a click ID the platform stamps that attribution does not capture, a pixel whilesite.trackingis off, a property with no tag id. -
<Ads />in@getvitops/astro, plusenvironments.<env>.ads(defaults toanalytics, then true). A sibling of<Analytics />rather than part of it: ad properties come from the site config, state their own consent category (marketingunless said otherwise) rather than having one derived, and switch per environment separately — a preview deployment sending pageviews is survivable, one firing conversion pixels is not. Both components now render through a shared<GatedTags />, so the inert markup has one implementation. -
li_fat_id(LinkedIn) andepik(Pinterest) join the click-ID capture vocabulary.
Fixed
- A privacy policy no longer conflates storage with legal reach. A processor carried one
country, flat-deduped into a single sentence claiming both — and privacy law turns on the second: the OPC’s concern is foreign access, not merely foreign storage. Newstorage[](each entry optionallyscoped) andoperatorCountry;countrystill works as shorthand asserting both. Combining the shorthand with either explicit field is now rejected, because whether it narrows or adds are two readings making contradictory legal claims. A country in the policy’s own jurisdiction is no longer a “transfer” — that is the incoherent “outside of Canada, including Canada” fix. A processor with no location at all is now reported on stderr instead of vanishing from the disclosure in silence.
Changes to text you may have already published
- Adding
site.adschanges your cookie notice. Each configured pixel is now named with its cookies and its opt-out, derived from the same table that writesdata-consent-cookies— so the notice and the revoke cannot disagree. Re-generate and re-read. - Cloudflare, Cloudflare Turnstile, Vercel and Netlify now assert operator jurisdiction and no
storage. They previously claimed “stored or processed in the United States”, which is a claim
about the wrong fact — Cloudflare is anycast, and Workers/R2 have residency controls the config
cannot see. A site whose only foreign element is its host or Turnstile stops claiming foreign
storage and discloses foreign legal reach instead. If you pin a US region, say so: declare a
processor with
storage: [{ "country": "the United States" }]. - A site running LinkedIn or Pinterest ads starts attributing conversions it previously recorded as organic, because the click ID is captured now.
- Everything else renders identically. Google Analytics, Tag Manager, Clarity, Plausible, Matomo
Cloud and any processor declared with
countryproduce the same words as before.
Migration
None required — site.ads is optional and <Ads /> is opt-in. To adopt:
- Add your ad accounts under
site.ads. CLOUDFLARE_API_TOKEN=… npx vitops ads setup --dry, then without--dryto create the records (it will prompt for any verification token you have not fetched yet).- Add
<Ads />beside<Analytics />in your layout’s<head>, or pastenpx vitops ads tagsinto your template. - Re-generate your legal documents and re-read the cookie notice.
New exports: AD_PROVIDERS + the SiteAdProperty/AdProvider types from @getvitops/generator,
the whole @getvitops/utils/ads subpath, and resolveAds + the GatedTag type from
@getvitops/astro.
4.0.0 — 2026-08-05
Consent that only interrupts a visitor when something actually needs permission, Search Console
onboarding, a documented account of which tier provides each component, and a fix to three web
components that silently never enhanced on a client-side navigation. Read the Breaking list:
two element renames touch existing markup, the consent cookie is re-prompted once, and
vitops indexing is now vitops search notify with no alias.
Breaking
-
Consent is now demand-driven. The banner appears when something actually needs permission, not on every first visit. Enabling the gate used to interrupt every new visitor regardless of what the site did — including sites whose only analytics provider was cookieless, where there was genuinely nothing to consent to.
Those are now two separate facts: the build decides what the banner can ask (which rows the markup carries), the runtime decides what it does ask. A gated tag registers its demand when it reaches its loading strategy, so an
idletag asks afterloadand aninteractiontag asks only once the visitor acts.- The cookie schema is v1 → v2. A choice is now recorded per category as granted / declined / not yet asked. That third state is what lets a later demand ask about a category an earlier prompt never showed — accepting an analytics banner no longer silently declines preferences. Every stored v1 choice re-prompts once, because a v1 cookie asserted a definite answer for categories the visitor was never asked about.
ConsentApi:needed()now means “something demanded a category the visitor hasn’t answered”, not “there is no cookie yet” — a custom banner calling it stays hidden until something asks.ConsentState.decidedis gone; usedecidedFor(state, category)orundecidedCategories(state). New:require(),request(),demanded().- If you gate anything yourself, call
require(), notgranted().granted()is a passive read: on a site where nothing else demands that category, it is never offered, never granted, and your write never happens — silently, forever.require()is what raises the banner. - Theme-toggle persistence waits on
preferenceswhen the site has a consent gate. The scheme still applies immediately; only thelocalStoragewrite waits. Sites without consent enabled are unaffected.
-
Every custom element now carries the
wc-prefix, and two renames affect existing markup:<color-scheme-toggle>→<wc-color-scheme-toggle>, and<wc-multifield>→<wc-multi-field>(3.0 shipped that inconsistency by accident). Update your templates.An unknown element name is not an error — the browser treats it as an inert
HTMLUnknownElement— so a missed rename is a control that renders and never works, with nothing in the console. The Bricks element keys, custom properties and events are unchanged, so elements already placed on a Bricks page keep working. -
vitops indexing→vitops search notify. No alias; same flags, same behaviour. Update any CI or deploy scripts. It is grouped undersearchwith the newsearch setup. -
Two renamed exports in
@getvitops/utils. Both did the same job under the same name from different subpaths, which forced an alias at every call site:indexing’sgetAccessToken→serviceAccountToken,onboarding’s →refreshTokenGrant. NewgoogleAccessToken(credential)accepts either identity.
Added
-
vitops search setuponboards domains into Google Search Console as domain properties — otherwise a manual DNS-paste / wait / verify / add-property dance. Per domain in the newsite.searchConsoleblock it ensures the apex verification TXT in Cloudflare, verifies ownership (DNS_TXT, retried with backoff while DNS propagates — still-unverified is reported PENDING, not failed), adds thesc-domain:property, and adds anydelegatedOwners. Idempotent, with--checkand--dry. DNS records are only ever created, never edited or deleted. -
vitops search notifyaccepts either Google credential, preferring the service account when both are set. The two halves ofvitops searchpreviously demanded two unrelated Google setups — five environment variables — when Search Console does not care which identity calls it. -
vitops docs components— which of the four tiers provides each pattern, and the call to write. The class reference listed pattern names and the elements reference listed Bricks controls, but nothing said thattreeis also a web component and an Astro component. The cost is silent: you hand-write markup a component already emits, or you wrap a component that already emits its own tag. The docs site projects the same manifest as four per-tier pages under /components. -
<wc-tree>— filter, expand/collapse all and hash deep-linking over a nested<details>tree. Deep-linking is the part worth knowing: a node inside a closed<details>has no layout box, so the browser’s own fragment navigation finds nothing and silently stays at the top of the page; the element opens the target’s ancestors first.<Tree items={…} />and itsTreeItemtype are new in@getvitops/astro, and emit the tag themselves — don’t add your own wrapper. -
schemaTreeNodes()walks a JSON Schema into tree data, so agents get markdown and a site gets an accessible disclosure tree from one walk. The config reference is now one filterable page instead of two overlapping markdown ones. -
Concept docs for four subsystems —
vitops docs consent | tracking | search | legal. Each documents rules that fail silently when broken: a gated tag given a livesrcfetches the third party anyway; a sitemap with no<lastmod>makes edited pages undetectable, sosearch notifylooks healthy while resubmitting nothing; a stale IndexNow key file returns202and is then discarded.
Fixed
-
<wc-entries>,<wc-carousel>and<wc-marquee>silently never enhanced when inserted dynamically. All three parsed their slotted markup inconnectedCallbackand returned early when they found nothing — but an element upgraded during insertion is connected before its children exist (measured: zero children on aninnerHTMLwrite). So on an Astro view-transition swap, a client-side navigation or a cloned template, the carousel never cloned its slides, the entries never built their table, and the marquee never took over from the CSS-only path. Nothing errored; the un-enhanced fallback just stayed on screen, which is why it went unnoticed. Initialisation now retries once the insertion completes, guarded so a retry can’t double-apply. -
.treeindent was 41px per level against a 24px design, measured in a browser — three compounding leaks, each invisible in the CSS. A 9-deep tree spent 382px on indent and left its deepest label 162px wide; now 24px per level and 653px of label. The subtlest cause:details.cssgives every non-summary child of a<details>an inline margin, and its selector takes:is()specificity under CSS nesting (0-1-1), so.tree { margin: 0 }at 0-1-0 lost to it. The indent is also fluid now, since a tree’s depth is a property of the data. -
Leaf rows now align with their branch siblings. A branch spends a toggle column on its chevron before its label; a leaf has none, so every leaf label sat one full column (24px) inside its siblings and nothing at a given depth lined up.
-
Schema descriptions no longer eat their own wildcards.
renderInlineMarkdown()lifts code spans out before applying emphasis, which is load-bearing rather than tidy:colors.utilitiesdescribes its families as`bg-*`,`text-*`,`border-*`— literal asterisk wildcards. Run emphasis over the raw string and the*closingbg-*pairs with the one closingtext-*, italicising the text between two unrelated utilities and eating both asterisks, leaving prose that names families which don’t exist. -
config.mdclaimed “only the wrapper is listed here” underdesignSystemand then emitted the entire token schema anyway —themes.<name>is a design system, so the walk descended into it. It now stops at the wrapper and delegates to the authoring reference as it always said it did. -
<wc-consent>builds its own consent patch rather than callingacceptAll(), so “Accept” on a preferences-only prompt no longer grants analytics the visitor was never shown.
3.0.0 — 2026-08-04
Navigation shells, a top-layer animation driver, and a run of fixes to things that had never worked. Read the Breaking list before upgrading: two custom elements are renamed, and two spacing/animation changes are visible on every existing site.
Breaking
-
The site config is now a three-section
Config:designSystem,organization,site. The flat shape held the company and the deployment as peers, so no single noun described it and a second site sharing the same company had no way to say so. Now several sites can carry oneorganizationand differ only insite.designSystemstays at the root — it is what tells a config apart from a baredesign-system.json, so moving it would have turned a total discriminator into a guess. The fields already underorganizationstay put. Everything else moves:- →
site:defaultLocale,locales,domains,dns,cloudflare,environments,abTesting,fonts,tags,postTypes,galleries,testimonials,templates,navigation,seo,analytics,notifications,tracking,security,legal,icons,favicon,deployment - →
organization:contact,primaryLocation,locations,services,links
You don’t have to apply that list by hand.
vitops validaterecognises a pre-3.0 flat config and names every move; it short-circuits before the schema would bury you in a dozenunrecognized_keyserrors, because a failure that says “unknown key: analytics” teaches nobody where it went.Renamed exports from
@getvitops/generator:SiteConfigSchema→ConfigSchema,SiteConfig→Config,validateSite→validateConfig,resolveSiteConfig→resolveConfig,isSiteConfig→isConfig,siteJsonSchema→configJsonSchema,SITE_SCHEMA_URL→CONFIG_SCHEMA_URL,SiteValidationResult→ConfigValidationResult;ResolvedInput.siteis nowResolvedInput.config. Added:OrganizationConfig,SiteSection.The published schema moves with them:
@getvitops/generator/site.schema.json→@getvitops/generator/config.schema.json. Update the$schemakey in your config.Option names are unchanged.
vitops({ site: { input } }), the Vite plugin’ssiteandgenerate({ site })all still point at the config file. - →
-
The Astro integration is now
vitops(), notgetvitops(). It is a default export, so the name is yours to choose and nothing breaks on upgrade — but every example now readsimport vitops from '@getvitops/astro', matching the Vite plugin, which has always beenvitops. The package scope (@getvitops/*) and the internalvirtual:getvitops/*module ids are unchanged; neither is an import name.import vitops from '@getvitops/astro'; export default defineConfig({ integrations: [vitops({ css: { input: 'site.json' } })] }); -
<copy-button>→<wc-copy>,<multi-field>→<wc-multifield>. Update your markup. Unchanged: the Bricks element keys (vitops-copy-button,vitops-multi-field), so elements already placed on a Bricks page keep working; the--multi-field-*custom properties; and themulti-field-*events. -
.rhythmnow gives every non-heading block heading-spacing before a heading. The pairs used to enumerate the other side (p, laterp, pre, blockquote, table, dl, ul, ol), so a heading after anything not on that list fell through to paragraph-to-paragraph spacing. They are now defined by what a heading is —h1–h6plus thefont-display/font-title/font-headingtype roles — and inverted to:not(<heading>) + <heading>.This is the change most likely to be visible on an existing site: expect a little more space above headings that follow a code block, table, figure-like
<div>or component. To keep the old spacing somewhere specific, set--rhythm-p-hon that container. -
<details>animates open and closed whereprefers-reduced-motionallows. It was previously instant on purpose — the transition used to deadlock the disclosure shut, which is a content-loss bug rather than a missing animation. Re-verified on Chrome 149 with a real click. If you extend this pattern, keep bothblock-sizeandcontent-visibilityin the transition list; dropping the latter reproduces the deadlock. -
Drawers and dialogs animate on close. Both drove their entry with
animation:on[open], which by construction plays once, so the close was instant. Both now use the top-layer driver.nav.css’s.drawer-menutiming moves from a hand-set 0.4s/0.7s to--animation-duration/--custom-ease-out. -
popover.cssno longer pins[popover]:popover-open { opacity: 1 }. It never animated anything on its own, but at (0,1,0) and imported afteranimation.cssit outranked.transition’s opacity and made every top-layer fade impossible. Removing it is what lets the driver work — do not re-add it.
Fixed
-
.sitenav--bp-smdropdowns could never open. Its desktop block had drifted onto an older markup shape, selecting.sitenav__disclosure > .sitenav__submenuwhere the submenu is the disclosure’s sibling.md/lg/xlwere byte-identical and onlysmhad rotted — exactly the drift four hand-maintained parallel blocks invite. Now one shared block behind a style query, 583 → 361 lines. -
Hover dropdowns rendered off-screen. A closed popover is not in the top layer but keeps the UA’s
position: fixed, and.dropdown--show-on-hoverreset onlyinset— so the panel faded in around 3300px from its trigger, which reads as “hover does nothing”. Now anchored to the trigger, and it follows on scroll. Same fix for.split-link--show-on-hover. -
Horizontal overflow on narrow screens.
.centered > *floors atmin-inline-size: 0;bodygetsoverflow-wrap: break-word;patterns/code.cssgains theprerules it never had (max-inline-size: 100%,overflow-x: auto,overscroll-behavior-x: contain) and gives inline<code>overflow-wrap: anywhere.Worth knowing if you hit this class of bug: a scroll container does not zero its min-content contribution in Chrome. A single
<pre>reported 797px of min-content inside a 390px viewport, and any ancestor sized underfit-contentresolved to that rather than the available width. -
.split-<a>-<b>publishes--_flex-direction: row. Only the.flex-*utilities did, soclass="split flex-col md-split-1-2"left the variable readingcolumnat every width — andpatterns/grouped.cssresolves its collapsed-border axis from a style query on it, so a nested.groupedcollapsed on the wrong axis with nothing to indicate why. -
.toc-layoutusesminmax(0, 1fr)rather than a bare1fr, whose automatic minimum is min-content.
Added
-
A generated config authoring reference.
vitops docs configprints it; it ships asconfig.mdin the OKF bundle and as Config reference on the docs site. Every field of all three sections is rendered by walking the published JSON Schema — the same helper that renders thedesign-system.jsonreference — so it cannot document a field validation does not accept, and the two references cannot drift apart in presentation. ItsdesignSystemsection lists only the wrapper and links to the design-system reference rather than duplicating it.The docs site also picks up the icons concept doc, which the bundle has had but the site’s page list never included.
-
navshell— a nav aside beside content, collapsing to a toggle and drawer. Available as thenavshellpattern and as<NavShell>/<NavShellToggle>from@getvitops/astro.It nests, which is the point: a site nav wrapping an on-this-page nav, each promoting at its own breakpoint. That works through a style query on an inherited flag rather than one copied block per breakpoint, and its content column is a container, so an inner shell measures the space it actually has instead of the viewport. The toggle can live outside the shell — in a site header, say — with
toggle="external". -
navbar— extracted fromnav.css, where.navbarexisted only as half of that file’s drawer⇄navbar pair, plus--start/--center/--end,__spacerand--sticky(.navbar-stickyis aliased). -
A top-layer animation driver.
animation.cssgains the fourth driver alongsideanimate-view/animate-scroll/animate-trigger/transition, and every effect gains anopen-<fx>state variant in all three formats. Overlays now state where they start rather than owning a keyframe, so effects compose:class="drawer drawer--right open-fade-in"slides and fades. Applied at zero specificity with identity defaults, so a popover that sets no effect vars is unchanged. -
One scrim.
--scrim/--scrim-filterand.no-scriminpopover.css, replacing 18::backdropblocks across seven partials..drawer--modelessis aliased to.no-scrim. -
<wc-marquee>— clones the content enough times to cover the track, so every gap matches including the seam. CSS alone cannot do this: it has to pad each copy to the track width, which puts the slack at the end of every copy. The CSS-only.marqueeis unchanged and still works without the element;--marquee-gapis new. -
--width-navjoins--width-measure/--width-breakout/--width-spotlight. -
.skip-linkinpatterns/anchor-link.css, using a clip rather than the-100vwidiom, which overflows in RTL and ignores the scrollbar gutter.
Notes
patterns/nav.cssis legacy. Its header promised a Lit nav component that was never written and is not planned — the house pattern is native (popovertarget+[popover],<details>). Usenavbar,sitenavornavshell. Removal is a later change.scroll-target.css’s.is-currentno longer claims to be a JS scroll-spy fallback. There is no such code.:target-currentis the only working highlight, which today means Chrome; set.is-currentyourself if you need it sooner.elements.jsgains one element (wc-marquee), so the shared bundle is slightly larger for every consumer. Shipping only the components a page uses is tracked inTODO.md.- If you register a custom property, its
initial-valuemust be computationally independent —16remis not, so@propertydrops the whole rule silently.navshelluses an inline fallback.
2.1.0 — 2026-08-04
Also: @getvitops/emdash 0.3.1.
Two new commands for the parts of shipping a site that were still hand-rolled per project: encoding video, and telling search engines a deploy happened. Nothing breaks — both are additive, and neither runs unless you configure it.
Added
-
vitops media— raw video in, web-ready assets out. Each source in araw/directory becomes a VP9/WebM, an H.264/MP4 fallback and a JPG poster, with the directory structure preserved so you can import them like any other asset and let your bundler content-hash them.vitops media --raw raw --out src/assets/processedAlso an Astro integration option (
media: { raw, out }, running in the same pass as your CSS), a Vite plugin option, andprocessMedia()from@getvitops/utils/media.Runs are cached on source content plus encode settings, in
.vitops/media-manifest.json— a 24 MB clip that took 88 seconds the first time takes 0.14 seconds the second. Commit the outputs and the manifest: a fresh CI clone has neither and would re-encode from scratch, and ffmpeg output isn’t byte-reproducible across versions, so a CI re-encode would rewrite every video on any toolchain bump.ffmpegis an external tool, not an npm dependency — install it yourself. The command fails without it rather than skipping, because a page referencing a video that was never encoded is broken, not degraded. -
vitops indexing— the end-of-deploy Search Console visit, automated. Readsseo.indexingfrom your site config, diffs your sitemap against the previous run, pings IndexNow with what changed, and re-submits your sitemap through the Search Console API.--checkthen inspects yourpriorityUrlsand exits non-zero on a page Google hasn’t indexed, so a scheduled CI job catches a page that quietly falls out of the index.vitops indexing --dry # print the plan, make no requests vitops indexing # submit vitops indexing --check # a day or two later: did Google actually index them?Know the ceiling. Google exposes no API that requests indexing — the button in Search Console isn’t available anywhere, URL Inspection is read-only, and the sitemap ping endpoint was removed in 2023. IndexNow reaches Bing, Yandex, Naver, Seznam and Yep; Google doesn’t participate. So this automates every sanctioned step and then verifies the outcome; it does not make Google re-index on demand, and nothing can. Google’s Indexing API is deliberately not wired — it’s scoped to job postings and livestreams, and using it for ordinary pages violates its terms.
An environment whose
robotspolicy saysnoindexis refused outright, so pointing this at staging can’t publish it to a search engine. Persist.vitops/between runs (a CI cache), or every run submits everything. -
gitLastmod()for real sitemap dates (@getvitops/astro).@astrojs/sitemapemits no<lastmod>, which means a crawler is told your pages exist but never that one changed — and it’s what letsvitops indexingsubmit a handful of URLs instead of all of them.import vitops, { gitLastmod } from '@getvitops/astro'; vitops({ sitemap: { serialize: await gitLastmod() } });It derives each date from the source file’s last commit and leaves a page alone rather than guessing (dynamic routes, ambiguous slugs, shallow clones): an inaccurate
lastmodis worse than none, because Google stops trusting the field site-wide. Needsfetch-depth: 0in CI. -
New
@getvitops/utilssubpaths:./mediaand./indexing. Separate entry points so importing the content helpers doesn’t drag in an encoder or a network client.
Fixed
-
Releases were broken since
apps/portalwas extracted. The changesetsignorelist still namedportal, and changesets errors rather than warning on an entry it can’t resolve — sochangeset status,versionandpublishall exited non-zero. -
A peer-dependent no longer takes a spurious major bump.
@getvitops/emdashpeers on@getvitops/astro >=2.0.0, and changesets bumps peer-dependents as major regardless of whether the new version actually leaves that range. An astro 2.0.0 → 2.1.0 minor was queuing emdash 0.3.0 → 1.0.0, announcing a breaking change that didn’t exist. It now takes a major only when astro genuinely exits>=2.0.0, which is what the peer range was written to express.
2.0.0 — 2026-08-03
Also: @getvitops/emdash 0.3.0, @getvitops/create 0.4.0.
Three things that were quietly not working now work, and each one needed a breaking change to fix. Utilities never actually beat patterns; the framework assumed a border-box reset it didn’t ship; and the site config had nowhere to put a system-wide fact. Alongside them: an icon system with one semantic vocabulary across icon sets, webfont loading through Astro’s Fonts API, analytics with a consent gate, and every config-taking surface now accepting the larger site config that embeds a design system.
If you are upgrading a real site, read Breaking in full — two of the three change how existing markup renders, and neither announces itself.
Breaking
-
Cascade layers are assigned by what a rule is, not which file it lives in. The three-layer order (
vitops.base → components → utilities) has existed for a while and its entire purpose is to letclass="card bg-danger-muted"work without!important. But layers were chosen per partial, andlayout.cssheld both structural patterns and roughly three quarters of the framework’s utilities — so.m-*,.flex-*,.items-*,.justify-*,.text-*and the split ratios were shelved below the patterns they exist to override and silently did nothing.utilities.csshad the mirror-image problem, holding the.revealcomponent family.Both are split, and a shared
LAYER_CONTRACTasserts the classification across formats so the halves cannot drift. Migration: combinations likeclass="table text-center",class="banner items-start"andclass="media items-center"now take effect. If you wrote one, never noticed it did nothing, and preferred the old result — remove the utility. To keep a pattern winning, write the rule in your own stylesheet: unlayered CSS still beats all three layers.Removed: the bare
<bp>-splitclasses (sm-split…xl-split).@utilitycannot live in a cascade layer, so@md:splitwas impossible and the css/bricks counterpart went with it. Usemd-flex-row, which says the same thing in every format. -
The framework ships a border-box reset. In
vitops.base, so unlayered consumer CSS still wins, and the opt-out is one rule:*, *::before, *::after { box-sizing: content-box }. Thetailwindformat is unaffected — preflight already did this. The reason it stopped being optional:.split’s ratio is a flex basis, which sizes the border box, so under content-box a padded column came out wider than its sibling by exactly its padding. Stating the assumption once beats every pattern re-asserting it and the ones that forget being quietly wrong. -
designSystemin a site config is now an object. The map moves underthemes, anddefaultTheme+defaultColorSchememove inside it;respectSystemPreferenceis gone, sincedefaultColorScheme: "system"says the same thing and the incoherent combination is no longer expressible. Migration is automatic at runtime — all three spellings are accepted — butsite.schema.jsonis published to a stable URL, so an editor pinned to$schemawill flag the old shape.This is what made
<color-scheme-toggle>’s System position work. It had always shipped three segments and one did nothing: it removes the theme attribute, and with noprefers-color-schemeblock in the emitted CSS the page fell through to light on every machine. Opt in withdefaultColorScheme: "system"(+303 B gzipped); it is opt-in because switching it on visibly flips an existing site dark for dark-OS visitors. -
@getvitops/emdashnow depends on the toolchain. Its blocks render from the generated SVG sprite, so against an older toolchainvitops.actionLinkrendered an empty box — no error, nothing to grep. It hard-depends on@getvitops/utils(the editor’s icon list is now derived fromiconMap, not copied) and peers on@getvitops/astro>=2.0.0. An unmet-peer warning on upgrade is this change working.
Added
- An icon system. Icons are named by meaning and resolved per configured set —
menubecomesfa7-solid:bars,lucide:menuorph:list— so swapping sets is a config edit. A name containing:passes through untouched. Three delivery paths:astro-icon,astro-iconset, and a build-time SVG sprite (icons.sprite) for Bricks, EmDash and plain HTML —<use href>, no JS, no icon-API call. Theincludemap that keeps an SSR bundle from shipping an entire icon set is derived by scanning your source, not hand-maintained. getvitops({ fonts })loads webfonts through Astro’s Fonts API instead of hand-rolled tags, andfontsindesign-system.jsonis now documented as what it always was: stacks only, loading nothing.<Analytics />and a general-purpose consent gate. GA4, Clarity, Matomo and Plausible, every tag off the critical path (strategydefaults to'idle') and, when it sets cookies, only after consent. The gate is a sibling of analytics, not part of it — anything markeddata-consent="<category>"waits on the same choice.- Any config-taking surface accepts a site config.
generate(), the Vite plugin, the Astro integration and every CLI command take adesign-system.jsonor the largercompany.jsonthat embeds one, told apart by shape rather than filename. A site config also supplies the site-level facts — colour scheme, legal documents, icon sprite, fonts — so the path is declared once. Newtheme/siteEnvoptions select a theme and apply an A/B variant. vitops lintcatches reinvention, not just typos: hand-written CSS that re-implements a framework primitive. Findings gained a severity, so an advisory rule can’t break your CI on first run.gap-*utilities over the fluid space scale,text-{wrap,nowrap,balance,pretty},.splitstacking +.split-reverse, andtext-wrapas a stated decision on every type role.
Fixed
- A filled CTA’s text turned dark on hover.
:where()zeroes the element, but a pseudo appended outside it does not — so:where(a, .link):hoverwas 0-1-0, tied with.cta-<role>, and won on source order. Every<a class="cta">flipped to the link colour mid-hover. - JS and scroll-timeline detection moved into CSS, so no-JS visitors stop getting invisible content, and animation families that never animated now do.
validate()rejects what the published JSON Schema already rejected — it was lenient where the schema was strict, so a config could passvitops validateand fail in an editor.vitops validatealso routes on the file’s shape: pointed at a site config it used to report oneunrecognized_keysand nothing about the file’s real contents.- A chroma-0 seed produces an actual neutral instead of a pink one, and non-monotonic ramps are now rejected rather than silently producing a scale that doesn’t darken.
- Maskable favicons are composited opaque, so they stop rendering as a logo in a black box.
- Three tailwind-only divergences:
container-name: bodywas missing (so the TOC patterns were stuck narrow),grid-autolost its list-margin reset, and.stickywas being deleted outright because its name collides with a Tailwind utility. @getvitops/createtemplates tracklatest. They pinned@getvitops/astro: ^0.7.0and scaffolded projects a full major behind through the whole 1.0 release;vite/vite-pluswerecatalog:, which cannot resolve outside this monorepo at all, so the scaffold succeeded and the first install in it failed.
1.0.0 — 2026-08-02
The colour system is rebuilt. Every colour token and utility class is renamed; this is the
breaking change the 1.0 is for. Alongside it: a fourth output format that emits an agent-facing
DESIGN.md, generated legal documents, and a base-typography binding that also fixes the theme
editor’s dead Body controls.
Breaking
-
Colour moves to a target-prefixed grammar over one shared lightness ladder.
--color-<target>-<role>[-<variant>], target ∈bgtexticonborder, and the class name is the token name minus--color-.What it replaces: two axes shared one namespace — functional planes (
--<role>-bg-muted) and appearance-relative stops (--color-<role>-muted) — arbitrated by a “plane wins” rule. The result wasn’t a scale. On the shipped palettebg-ui-accent-x-mutedandbg-ui-accent-mutedboth resolved to step 100, barebg-ui-accentwas lighter than both, and--color-<role>-mutedwas unreachable through anybg-class. With the target inside the token name there is nothing left to arbitrate.Ramps now sit on a fixed lightness ladder (50 → L 0.98 … 950 → L 0.21); only chroma and hue vary, so a step means the same lightness in every hue. Authored colours — a
seed, ananchorsentry, atonesvalue — are still pinned verbatim at their nearest step, the only steps allowed off the ladder, and warn past ~0.03 L.Migration: the full before/after table is in
node_modules/@getvitops/generator/CHANGELOG.md. The two that catch people:bg-<role>on a chromatic role becomesbg-<role>-x-muted(a chromatic role has no bare background — say how loud you mean), and thesurfacenames rotate value-preservingly — the page is nowbg-surface-muted, the card isbg-surface.vitops lintreports role classes that no longer resolve, with suggestions derived from what the generator actually emits. -
Contrast is enforced at build time, not only in tests. Text ≥ APCA Lc 75 on its primary background, ≥ 60 on secondary planes, icons and surface boundaries ≥ 45, in both appearances. A violation now throws out of
generate— a palette that used to build and read badly now fails loudly.text-<role>-x-muted(placeholder) and-xx-muted(disabled) are exempt. -
Two
tonesclaiming the same step is now an error rather than one silently overwriting the other. Use the record form (tones: { "600": "…", "700": "…" }) to resolve it.
Added
-
--format design— a fourth format emitting oneDESIGN.mdand no CSS: the brief in google-labs-code/design.md format, YAML token front matter plus a prose body. It’s what you hand a coding agent or a Figma import that has never heard of the toolchain;vitops docsstays the richer reference for those who have it. Run it with--out .— the file conventionally sits at a repo root besideAGENTS.md.Role tokens are emitted as
{colors.<hue>-<step>}references rather than flattened hexes, so the role → ramp lineage survives the export; flattening them is exactly what breaks dark mode downstream.StylesheetFormat(Exclude<Format, 'design'>) is new on the public API, and is what@getvitops/astro’scss.formatandvitops lint --formatnow take — passingdesignwhere a stylesheet is expected is a type error rather than a missing-file build failure. -
vitops legal— privacy policy, terms of service and cookie notice rendered from a site config, as markdown, an HTML fragment or EmDash Portable Text. The documents are derived: the analytics vendor they name is the one whose ID you set, the personal information they list is what your forms collect. A provider swap updates the policy on the next build. Delivered per stack — the CLI anywhere,dist/legal/*.htmlplus a[vitops_legal]shortcode on Bricks, a content collection on Astro. It is not legal advice, and it is only as true as your config; every document opens with a review banner saying so. -
typography.headingscan bind base page typography to a type role — map"body"to your prose role and the generator emitsbody { font-family: var(--body-ff, …); … }, so prose inherits the role instead of a hand-writtenbody { line-height: … }block each consumer had to author. If you keep such a block, drop the properties the role now owns: restating them shadows the role’s tokens, and unlayered CSS wins. -
icon-<role>— a non-text colour tier, so a glyph can run more vivid than text.iconjoinsbg/text/borderas a default utility family. Plus--color-border-focus, the focus-ring tone. -
A pattern’s fill can be undone.
background/background-colorjoinBASE_HOOK, so.cardemitsbackground: var(--bg-card, …)and a flat border-only card isstyle="--bg-card: transparent; --ds-card: none"rather than an inline override. Thecssandbricksformats also gainbg-transparent/bg-inherit(Tailwind ships both itself). -
<Seo />for non-EmDash Astro sites —<title>, description, canonical, Open Graph, Twitter cards, robots,article:*,hreflang, verification tokens. Site defaults in the integration, per-page overrides as props. It owns<title>and the description meta, so remove yours when you adopt it. On an EmDash site use<EmDashHead>instead. -
An opt-in
sitemapoption on the Astro integration, registering@astrojs/sitemap(an optional peer) and linking the result from<Head />. It needs thesiteconfig option and lists prerendered routes only. On EmDash, leave it off — EmDash serves its own. -
An optional
metakey ({ name, description }) indesign-system.json, supplying the brand name and Overview paragraph toDESIGN.md. No other format reads it.
Fixed
-
Theme-editor typography edits that previewed live and vanished on save. The design manifest’s
reverseIndexonly mapped hooks a role explicitly declared, while the editor renders a control for every hook — so editing--body-lsor--body-tton a role that omitted it updated the page and then silently dropped out of thedesign-system.jsonpatch. Every hook of every role is now indexed, and pattern backgrounds (--bg-card,--bg-btn,--bg-status) are tunable in the browser alongside their geometry. -
validate()warns when a shadow value can’t survivedrop-shadow(). A--shadow-<name>token feeds bothbox-shadowandfilter: drop-shadow(…). A spread radius, a second layer orinsetinvalidates the whole filter, so.drop-shadow-<name>rendered nothing while the token still looked correct everywhere it was authored. -
The
virtual:getvitops/headtype declaration was missing theeditorfield<Head />already reads — a type error in consumer projects that don’t setskipLibCheck. -
validateSiterejects a privacy policy with no contact ordomains.canonical, both of which are interpolated into sentences that would otherwise render blank.
Docs
Every Astro example now binds the integration as vitops (import vitops from '@getvitops/astro'). The default export is unchanged — configs binding it as getvitops keep
working. The generator and CLI reference pages now document the design format, and the
scaffolded emdash template moves onto the new colour tokens.
0.9.0 — 2026-07-31
Format parity. The three outputs had quietly drifted apart — the same markup meant different
things depending on which one you built. Most of this release is closing that, plus the dead
references and unrendered components the investigation turned up. Prompted by a report from a
14-page consumer site built on the tailwind format.
One change can surprise you: the css/bricks bundle is now layered, so your own CSS
overrides the framework by default. If you ship a reset, read the migration under Changed.
Added
-
vitops lint— reports framework classes in your source that resolve to nothing. An unknown utility class is indistinguishable from a working one: nothing errors, the element just never gets the style. Format-aware (md-flex-rowis real incss/bricksand inert intailwind), and it only judges classes anchored to your own config — a palette hue, a role, a type role, a shadow — so it stays silent on Tailwind’s utilities and your own class names.vitops lint --format tailwind --src src -
validate()warns when a required role is missing.colors.rolesis an open map, but the shipped component CSS referencesbrand-primary,danger,neutral,surface,ui-primaryandwarningwith no fallback. Omitting one leaves those components uncoloured, silently. -
<wc-theme-editor>no longer dims the page it is editing, so you can judge colour changes against the real design rather than through a scrim.
Changed
-
The
css/bricksbundle now ships cascade layers. Previously every colour utility was emitted before every pattern and both sat at0-1-0, so the pattern won on source order:class="card bg-danger-muted"left the card on--surface-bghere while tinting it correctly intailwind. The bundle now emits@layer vitops.base, vitops.components, vitops.utilities;, so a utility overrides a pattern in every format. No rule changed — 1482 rules before, 1482 after — only precedence.What this changes for you: unlayered CSS beats every cascade layer regardless of specificity, so your own stylesheet, an Astro scoped
<style>, or a Bricks-authored class now overrides the framework with no!important. That is the intended override story.Migration, only if you ship a reset. An unlayered reset will now beat the framework component rules it used to lose to — a bare
p { margin: 0 }defeats.rhythm. Put it in a layer and declare the order before the stylesheet loads:<style> @layer my.reset, vitops.base, vitops.components, vitops.utilities; </style> <link rel="stylesheet" href="/styles.css" />Declaring it after the link makes
my.reseta new name introduced later — which sorts last, i.e. highest priority — and the reset wins anyway. This repo’sindex.htmlshows the change.The
tailwindformat is byte-identical: its utilities are@utilitydefinitions, which Tailwind already layers correctly and which are what makehover:/@md:variants work.Known gap:
layout.cssmixes structural rules with utilities in one partial, so it sits invitops.componentswhole and its utility half (.m-*,.flex,.split-*) still can’t override a pattern.
Fixed
- The
tailwindformat was missing 87 role colour classes.bg-<role>-x-muted,bg-<role>-bold,bg-<role>-x-bold,text-<role>-bold,text-<role>-x-boldandborder-<role>-{muted,x-muted,x-bold}existed in thecss/bricksoutputs and silently did nothing intailwind. No test built the tailwind format, so nothing caught it. All three formats now render from one emitter and a parity test holds them to the same vocabulary, permitting only four documented differences. No class changes meaning;css/bricksoutput is unchanged. colors.utilitiesis now honoured in thetailwindformat (it was hardcoded to bg/text/border). For raw hue scales it stays a floor rather than a ceiling — those are@themecolours and Tailwind derives every colour family from them on demand.- The
tailwindformat stripped component container queries. The pass that drops the framework’s pre-expandedmd-*utilities matched every@container (min-width: …)block, including component behaviour — so.sitenav--bp-{sm,md,lg,xl}were removed and the nav never left its mobile layout. vitops initandvp createscaffolded broken configs. Both still referenced--color-surface-xl/--color-surface-xxl, aliases from the named-step scale removed in 0.6, giving acardwith no background and an invalid default border. The EmDash template also carried thepatterns.radii.cardcollision thatvalidate()warns about..text-revealrendered invisible text. Its gradient read two custom properties with no defaults; when they were unset thevar()substitution failed,backgroundbecame invalid at computed-value time, and the pairedcolor: transparentleft nothing to see..borderedsilently fell back tocurrentColorthrough a reference to a token the generator never emitted.vitops docs/vitops agentsnow surface config warnings (on stderr, so pipingdocsis unaffected). They discarded them, unlikegenerateandvalidate.- Contrast is checked against every background plane a role emits (
bg,bg-muted,bg-bold), not onlybg— body text on acardwas previously unguaranteed. SubgridandCardsrendered as unstyled lists, in every format. They drew their geometry with Tailwind utilities that no framework CSS layer defines, so undercss/bricksthey had no layout at all — and undertailwindtoo, because Tailwind v4 is JIT and does not scannode_modules: a class only a shipped component references is never generated. Cards laid out atgrid-row: auto— visually plausible, quietly wrong. Now drawn with framework CSS.<details>disclosures never opened. The.detailspattern animatedblock-sizefrom a collapsed state that<details>itself controls, so the content stayed at zero height.tailwindcssand@tailwindcss/viteare optional peer dependencies of@getvitops/astro, not dependencies. Installing the integration no longer pulls Tailwind into projects using thecssorbricksformat.
Docs
- Raw scale classes are frozen and do not remap in dark mode — now stated, with a migration
table to the role equivalents. The dark-mode guarantee only ever covered functional role
tokens, and nothing said so; a consumer site hardcoded
data-brx-theme="dark"and filled up with latent light-mode bugs. - Roles are extensible over a required core — the schema description and class reference read as a closed enumeration, which is why that consumer forked their own colour layer instead of adding a role.
md:vs@md:vsmd-in the tailwind format —@md:uses the framework’s breakpoints,md:works but uses Tailwind’s (which differ:sm:is 40rem,@sm:is 30rem),md-is silently inert. Plus: registering--container-*also re-points Tailwind’smax-w-*scale.- The
css/bricksbundles carry a/*!banner pointing atnpx vitops docs classes. The previous plain comment was stripped by the minifier and never reached the file.
0.8.0 — 2026-07-27
One new feature — a live theme editor — plus repairs to things shipped in 0.7.0 that didn’t work outside this repo.
Added
-
<wc-theme-editor>— tune the whole design system in the browser, with no rebuild. Palette, semantic roles, type roles, spacing, layout, pattern geometry, radii and shadows, layered as:rootcustom-property overrides and exportable as CSS or as adesign-system.jsonpatch. On a dev server running@getvitops/vite, Save to source writes the patch back through validate → write → regenerate; on a static build the probe fails and the button isn’t rendered.It ships as a separate, opt-in bundle —
@getvitops/core/editor, ~13 kB, no Lit — and is never registered inelements.js, so a page that doesn’t ask for it pays nothing. Enable withgetvitops({ editor: true }).This is a deliberate exception to the framework’s rule that web components must progressively enhance accessible no-JS markup: it’s tooling, not a page pattern, and a live editor has no no-JS fallback to enhance. It’s quarantined rather than excused — don’t read it as precedent for behaviour JS in a
<wc-*>element. -
validate()returnswarnings: string[]for configs that parse and generate but won’t behave as authored, andvitops validateprints them. First case: apatterns.radiikey named after a pattern collides on--br-<name>(the example config hits this withradii.card).
Breaking
-
body { margin: 0 }is now part of the framework. The UA’s 8px margin offset every full-bleed surface — sticky headers andbg-*bands rendered inset, with a sliver of canvas around them, because the framework owns page gutters through.centered’s--gutter. This is the only UA reset the framework makes; it still deliberately ships no general reset (no globalbox-sizingchange), which would silently reflow existing layouts. Migration: drop anybody { margin: 0 }you added to compensate; add your own padding if you relied on the inset. -
.ctadefaults to theui-primaryrole instead ofbrand-primary. The three tiers of one interaction family had split colour lineage —:where(button, .btn)and:where(a, .link)resolved toui-primarywhile.ctaalone usedbrand-primary, so the focus ring changed colour depending on which tier you tabbed onto. Migration: none if the two roles share a hue (true of the example config). If they differ and you want the old colour, use the new.cta-brand-primaryvariant —brand-primarywas added tocta.roles, so a brand-coloured CTA is reachable rather than unavailable.
Fixed
- Dark mode worked only under Bricks. The dark block was emitted under
:root[data-brx-theme="dark"]alone — Bricks’ own attribute, which nothing else sets — while the shipped<color-scheme-toggle>writesdata-theme. Clicking “Dark” changed an attribute no rule matched. Both are now matched. (“System” still resolves to light; there is deliberately noprefers-color-schemeblock, since adding one would flip every existing site dark for dark-OS users.) - The colour scheme now persists across navigations, via
localStorage, and<Head />applies it before first paint so pages don’t render light and flip. Previously the choice was per-page state and the toggle even cleared it on unmount. @getvitops/emdash@0.2.1reported version0.2.0from its plugin descriptor — a hand-maintained literal thatchangeset versiondoesn’t touch. It’s now derived from package.json, andvp run releaseruns the test suite that catches this before publishing.generateIconInclude()is reachable. The semantic icon mapping (declare names + sets, get the build-timeincludemap) lived in a package path that was never exported, so nothing could call it. It moved to@getvitops/utils, which@getvitops/astrore-exports. Unresolvable semantic names now throw at build time naming every offender, where they were skipped silently.design-manifest.jsonreverse-index paths. Numeric colour steps mapped to the hue’sseed(which regenerates the whole ramp, collapsing every step onto one path); they now map toanchors.<n>.--br-<name>resolved topatterns.radii.<name>even when a pattern owned the variable.@getvitops/create’s emdash template pinned@getvitops/astro: ^0.4.0, a range that stopped resolving when astro joined the fixed group at 0.7.0.
0.7.0 — 2026-07-27
Breaking
-
A bare
<button>is no longer a filled brand-primary button. Actions now split into two tiers named by intent:.ctais persuasion (filled, bolder, roomier, lifts on hover) and bare<button>/.btnis affordance — it signals only that something is interactive, with no fill, nofont-weight: 600and no shadow.Migration: add
class="cta"to any button that should stay prominent — submit buttons, hero actions, anything driving a conversion. Dialog closes, toolbar buttons, icon buttons and toggles should keep the new default. To restore the old look globally, pointpatterns.items.btnin yourdesign-system.jsonback at the previous filled base.Why:
<button>the element means “interactive control”, not “primary action” — and a CTA is usually an<a>, because it navigates. Making the CTA a class is what finally lets it go on a link. The framework was also fighting its old default: fourteen component partials existed partly to undo the fill. -
chipis retired as vocabulary. The two small-label patterns now split by behaviour, not size:badgeis a static label (status, count, category),tagis an editable and/or dismissable one (e.g. entries in a filter list).Migration:
.chip-list→.tag-list. Its__chip/__chip-removesub-parts are removed — replace<span class="chip-list__chip">x <button class="chip-list__chip-remove">with<span class="tag">x <button class="tag__remove">, since a tag list is a list of tags. Items change appearance:.tagis outlined where the old chip was filled with--color-surface-muted. Tokens--*-chip-list→--*-tag-list; the redundantradii.chipprimitive is gone (use--br-tag). -
The small-label pattern group is renamed
tag→label, so group tokens are now--{p,br,b,ds,fs}-label. Migration: if you set any--*-tagexpecting the group value, switch to--*-label. Why: thetagpattern and thetaggroup compiled to the same variables, so the pattern’s override hook shadowed the group token and its-groupalias was unreachable.--*-tagis now free as thetagpattern’s own hook. -
@getvitops/astrojumps 0.4.2 → 0.7.0. It now shares the toolchain version instead of tracking its own line. The number changed; the package did not — 0.7.0 is the direct successor to 0.4.2, with no API change implied by the jump.Migration: update the version range, nothing else. Install
@getvitops/astroat the same version as your@getvitops/cli/@getvitops/generator.Why: astro depends on core, generator, utils and vite, and was already being bumped on every single toolchain release by its dependency updates — so its separate version line cost the same churn while making “which astro works with cli 0.6?” a question you had to answer yourself. Now the versions match by construction.
Added
.cta— the persuasion tier, with.cta-{success,danger,warning,info}role variants. A class, so it works on any element.:where(button, .btn)and:where(a, .link)— a pattern may now set bothelementandclass, emitting one zero-specificity rule. The element gets the styling with no class needed, the class carries it to any other tag, and any explicit class overrides it without!important.fill: true|falseon a pattern — states whether states and role variants drivebackground-color(pluson-solidtext) orcolor, instead of inferring it from the pattern’s name and base declarations. Existing configs are unaffected; the old inference is the fallback.- Every published package now ships its
CHANGELOG.mdin the npm tarball, so per-package history is readable atnode_modules/@getvitops/<pkg>/CHANGELOG.md(and on unpkg/jsdelivr) without needing repository access. - This file: curated, toolchain-level release notes covering all packages at once.
Fixed
- Role variants on element patterns were emitted at specificity 0-1-1 (
button.danger), outranking any plain class. They now emit as:where(button, .btn).danger, .btn-danger— both at class specificity, and reachable from a non-<button>host. - Pattern geometry now resolves through the group alias layer (
--br-btn-group: var(--br-control)) instead of hard-codingvar(--br-control, …)into each rule, so the whole cascade —--p-btn→--p-btn-group→--p-control→--p-default— is live custom properties you can inspect and edit in the browser. Applies tobtn,cta,badge,tag,cardandstatus. Computed values are unchanged. - The
linkpattern declareddefault_role: "brand-primary"while hard-coding aui-primarybase colour, so hovering shifted hue instead of intensifying. Itsdefault_roleis nowui-primary. @getvitops/astro’sFormRendererdefaulted its submit button toclass="btn btn-primary"— a class that never existed and a role that is not emitted. It now defaults to.cta.- The Tailwind bundle is no longer assembled during
css/bricksbuilds, where it was computed and discarded (it also read every framework partial off disk).