v1.0.0
What's Changed
Added
-
styleguide lintCLI subcommand. Reports metadata quality issues acrosstemplates/: unindexed templates (no parseablename:), deadstyleguide:YAML content, brokenusage:cross-references, unknownrender:values, and emptydescriptionstrings.--type=component|page|doc(default: all three),--format=text|json(default: text), reuses--templates/--pretty. Exit0clean,1warning/error findings present,2usage/internal error — a three-tier contract specific tolint(list/showkeep their existing0/1codes). Seedocs/API.md§ CLI andREADME.md§ Command-line catalogue. Thebroken-usage-refcheck and the catalogue itself now share oneComponentParser::normaliseUsage()helper, so ausage:value — comma-separated string as authored, or an already-array YAML value — is parsed into ids exactly once;/api/components//api/pages//api/docsemit the result asusage: string[]rather than the raw CSV. -
New
GET /styleguide/api/healthendpoint. Reports per-file parse warnings (ComponentParsernow catches\Throwable, not just YAMLParseException, so one pathological template no longer 500s the whole/api/componentscatalogue — it's skipped and recorded instead) plus component/page/doc counts. A separate endpoint rather than a_warningsfield on the existing four, which each emit a bare JSON array with no additive slot for a sibling field. -
?theme=light|darkon the render endpoint. Implements the contractREADME.md/docs/API.mdalready documented but the code never enforced (doc drift closed). Whitelisted server-side (Router::whitelistTheme()) — anything other than the literal stringdarkresolves tolight. Stampsclass="dark"+ a matchingcolor-schemeon the rendered<html>; inert for projects without dark-mode CSS. SPA toolbar gained an iframe-theme toggle independent of the chrome theme. -
Optional
authconfig key.callable(array $route): boolchecked once per request before any dispatch; returnfalseto respond403 Forbidden(plain text) before SPA/render/API/asset handling runs.null(the default) preserves today's behaviour — no gating. A non-null, non-callable value throwsInvalidArgumentExceptionat construction (fail loudly at boot instead of silently allowing every request), and anauthcallable that throws is treated as a denial and logged viaerror_log()rather than letting the exception reach the response (fail closed). Documented alongside a recommendation to prefer web-server-level HTTP Basic Auth for publicly reachable deployments. -
Auto-discovered
styleguide.<variant>.twigsibling files.ComponentParsernow globs a component/page/doc directory forstyleguide.<id>.twigfiles (<id>matching[a-z0-9-]+) and surfaces them as an additivevariants: [{id, title, description}]field ([] when none exist — every pre-existing template keeps this BC default). The filesystem is canonical: an id with no matching file is silently dropped rather than fabricating a phantom variant. Display metadata (title/description) is authored directly in the sibling file's own first{# … #}comment — the same front-comment convention every component/page template already uses — with the component's YAMLvariants:map (<id>: <title-string \| {title?, label?, description?}>,label:a legacy alias fortitle:) kept only as a fallback for templates written before per-sibling annotations existed; a missing or malformed sibling annotation falls through to the map, then to the id, without ever failing the whole component. Ordered by id (== filename order). Passed through verbatim by/api/components,/api/pages,/api/docs, and thelist/showCLI commands, alongside the object's own sibling-detection flag, now namedhas_styleguidefor casing consistency withbody_classand the rest of the object. Seedocs/API.md§ Component YAML metadata and § Component Twig file conventions. -
?variant=<id>on the render endpoint resolves the auto-discoveredstyleguide.<id>.twigsibling in place of the defaultstyleguide.twig, forcomponent/page/dockinds. Whitelisted server-side against^[a-z0-9-]+$(Router::whitelistVariant()) on both the render endpoint and the SPA-shell deep links (/component/<slug>,/page/<slug>,/doc/<slug>) soRouter::synthesizeEmbeddedRoute()can forward it across the iframe-embed swap. Absent, invalid, or unknown-but-well-formed values fall back silently to the defaultstyleguide.twig→<slug>.twigchain — never a 404 — so a bookmarked deep link survives a deleted/renamed variant file. Query-only, no cookie fallback (unliketheme): a variant is scoped to one deep link, not a visitor preference worth persisting, so an in-iframe native navigation to a link without its own?variant=loses it on the swap — an accepted, documented gap. Composes independently with?theme=.?variant=<id>is deep-linkable via the SPA's own?variant=query param, silently reset on navigation to a different entry unless the incoming URL itself carries a valid one for that entry — see the variant grid bullet below for how the SPA surfaces this today. -
Variant grid — every variant as its own preview screen, tiled to fit the canvas (SPA-only; prototype). The toolbar pill switcher is gone, and so is the short-lived server-side stacked view: the render endpoint's
?variant=semantics are unchanged from the file-convention variants feature above (no?variant=→ the defaultstyleguide.twigbody only, single block, no headings;?variant=<id>→ isolate that one block). Instead, whenever the SPA's current entry has discovered variants and no?variant=is selected, the preview area becomes a responsive grid of independent<iframe>tiles — the default fixture first, then each discovered variant in order. Each tile gets a slim header (the variant's title, plus itsdescriptionwhen supplied). A deep-linked?variant=<id>still shows the classic single preview of just that variant (an unknown/removed id falls back to the grid, not a 404 and not a stale "selected" pill — there is no pill any more).- Device presets now apply per tile, with one shared scale readout in the toolbar. The toolbar's responsive-width preset dropdown (+ custom width + orientation toggle), previously hidden while the grid was active, now stays visible and functional: a fixed-width/fixed-height preset (Mobile, Tablet, …) renders every tile's iframe at exactly that preset's logical size, then scales the whole tile down (never up) to fit that tile's own measured cell width. Since the shared preset and (uniform cell widths) the resulting zoom are identical across every tile, the scale no longer repeats in each tile's header — the toolbar's viewport trigger label now shows it once instead (e.g.
Mobile 375 × 667 (84 %), the same(NN %)convention the classic single preview already used), and only when the shared zoom is below 100%. Full stays fluid (100% cell width, auto content height, no scaling) — the grid's original behavior. Drag-to-resize handles remain single-preview-only (the grid has none to hide). - Tile density — Auto | 1 | 2 | 3 | 4. A new toolbar dropdown (grid mode only, matching the device-preset trigger's own rounded-pill/icon/label/chevron shape) replaces the earlier rows/grid layout toggle with five density options — inspired by Histoire's single/grid story layout, extended to be preset-aware. "Auto" (the default) derives the
auto-fitminmax()column basis from the shared viewport preset's effective width instead of one fixed constant for every preset — a Desktop preset (1280 px) settles on far fewer tiles per row than Mobile (375 px) on the same canvas; Full keeps the original fixed 420px basis. "1"–"4" fix the column count exactly (repeat(N, minmax(0, 1fr))), ignoring the preset — "1" is the direct visual replacement for the old "rows" stacked layout (a single-column grid with the same subgrid header-height sharing renders identically). Persisted undersg-variant-columnsin localStorage; a pre-existingsg-variant-layoutvalue migrates once ("rows"→1,"grid"→"auto") and the legacy key is removed. Tiles no longer stretch to the tallest one in their row, and the pre-load placeholder height dropped from a visibly over-tall 320px to a much smaller 96px. - Click-to-isolate, with a subtle expand-icon hint. A variant tile's header is now a click/keyboard affordance that navigates straight to
?variant=<id>(the classic single, resizable preview) — no more re-selecting it from a dropdown. A small expand icon at the right edge of every clickable header (muted, revealed on hover and on keyboard focus) makes the click-through discoverable; the Default tile's header carries neither the affordance nor the icon — there is no route that shows it alone in the classic single preview without also satisfying the grid's own activation rule (any entry with variants and no?variant=selected always lands back on the grid). - Breadcrumb-based return to the grid, replacing the earlier "← All" toolbar back control. Once a variant is isolated, the toolbar breadcrumb gains a trailing Variant segment ("Section / Component name (slug) / Variant title") and its component-name crumb itself becomes a click/keyboard link back to the grid — standard breadcrumb semantics instead of a dedicated button. The crumb stays a plain, non-interactive label whenever no variant is isolated (nothing to go back to). The description bar (between the toolbar and the usage panel) also picks up variant context: while isolated, it shows the variant's own
description(prefixed with its title, styled like the grid's own tile-header titles) in place of the component/page's general description — replaced, not appended, since the variant's own description is the more specific of the two; a variant with no description of its own leaves the bar showing nothing rather than falling back to the general blurb.
- Device presets now apply per tile, with one shared scale readout in the toolbar. The toolbar's responsive-width preset dropdown (+ custom width + orientation toggle), previously hidden while the grid was active, now stays visible and functional: a fixed-width/fixed-height preset (Mobile, Tablet, …) renders every tile's iframe at exactly that preset's logical size, then scales the whole tile down (never up) to fit that tile's own measured cell width. Since the shared preset and (uniform cell widths) the resulting zoom are identical across every tile, the scale no longer repeats in each tile's header — the toolbar's viewport trigger label now shows it once instead (e.g.
Changed
- Sidebar prefix-tree groups drop the chevron. The component-section and Pages-section group rows (e.g. "Widget — 4") no longer render a rotating arrow glyph before the label — the count badge alone signals a group, and the label now sits flush at the same left padding as every flat sibling item instead of being indented out of line. The whole row is still the expand/collapse toggle;
aria-expandedkeeps the state programmatically discoverable now that the visual chevron cue is gone. - Light motion layer added across the SPA chrome, all gated behind
@media (prefers-reduced-motion: no-preference)(Tailwind'smotion-safe:variant, or a dedicated CSS keyframe for anything Tailwind can't express) so a reduced-motion preference gets an instant state change instead of a tween: the toolbar's hamburger button morphs its three bars into an X (~200ms) when the sidebar toggles; the mobile sidebar slide-over now uses a 240mscubic-bezier(0.32,0.72,0,1)easing plus a backdrop opacity fade (~200ms) instead of a hard show/hide; sidebar sections and prefix-groups expand/collapse via a CSSgrid-template-rows0fr↔1fr tween instead of snapping open/closed; the variant grid's tiles get a staggered entrance (opacity/translateY, ~180ms, 30ms stagger per tile) on first render; the command palette fades and scales in (~120ms) on open. - Preview navigation no longer flashes the previous entry's content. The preview
<iframe>is now keyed by its own src identity, so navigating to a different component/page/doc (or reloading, or toggling the iframe theme, or switching a variant) unmounts the old iframe and mounts a genuinely fresh one instead of just repointing one persistent element'ssrc— a real browser used to keep painting the old document until the new one finished loading. The pane's measured content height also resets immediately on navigation instead of holding the previous entry's height until the new document reports its own, which used to make the preview pane visibly jump. The variant grid applies the same fix per tile. - ⌘K/Ctrl+K now opens a command palette instead of focusing the sidebar's filter input. The palette (
SearchPalette.vue) ranks components/pages/docs by a new weighted multi-field score (lib/searchMatch.js'sscoreEntry()— name > id > category > description, exact > prefix > substring), groups results by section, and is fully keyboard-navigable (arrows with wraparound, Enter to open, Esc to close; a second ⌘K/Ctrl+K also closes it). The sidebar's own inline filter input is unchanged and still works as before. One behavior narrowed as a side effect: Escape-to-clear-the-filter used to be global (any focus state); it's now scoped to firing only while that filter input itself has focus, so it no longer fights with the palette's own Escape-to-close. - SPA chrome rewritten from Alpine.js 3 to Vue 3 + Pinia + vue-router (Phase 1 of the Styleguide 2.0 roadmap). 1:1 feature parity — no new user-facing behavior. The
dist/bundle is@internaland not covered by SemVer, but for transparency: every viewport preset/zoom/rotation, the sidebar prefix-tree grouping, search, locale switching, theme cycling, the fields drawer, and the usage/link cross-reference panels now ship with unit tests (Vitest) and a headless-browser parity suite (Playwright, running in CI for the first time — the previous Alpine-era browser suite was local-only). Styleguide::dispatchSpa()now injects a single<script id="sg-config" type="application/json">payload intodist/index.htmlinstead of 6 separate regex substitutions, and throws when that injection point is missing instead of silently shipping a half-patched shell.- New CI job asserts committed
dist/is byte-for-byte reproducible fromfrontend/source (npm run build && git diff --exit-code dist/). - Helper registration no longer matches Twig exception text.
tryAddFunction()/tryAddFilter()used to rethrow unless theLogicExceptionmessage contained the literal substring"already registered"— version-fragile and untested. They now always swallow (never crash a consumer's boot over a Twig-internal message change) and log toerror_log()when the message doesn't match the expected collision, so the rare genuine-misuse case still leaves a trace.
Fixed
- Iframe dark theme no longer resets on in-iframe navigation. A native link click inside dark-toggled render content (e.g. a nav link to another
/styleguide/page/...) re-issues aSec-Fetch-Dest: iframerequest carrying no?theme=of its own, soRouter::synthesizeEmbeddedRoute()used to hardcodelight, silently reverting the visitor's choice. The SPA toolbar's iframe-theme toggle now also writes ansg-iframe-theme=dark|lightcookie (path=/styleguide,SameSite=Lax); the router reads it as a fallback — through the same whitelist as the query param — whenever the request's own?theme=is absent. An explicit?theme=still wins over the cookie. #sg-configJSON injection hardened against script-breakout XSS.dispatchSpa()now encodes withJSON_HEX_TAGin addition to its existing flags, so a consumer-controlled value containing a literal</script>can no longer terminate the#sg-configelement early.- Render-time exceptions now return HTTP 500. A component/page/doc template that throws during render used to respond
200 OKwith the error markup embedded in the body — a health check or CI canary hitting/render/<kind>/<slug>couldn't distinguish a broken component from a working one.Renderernow callshttp_response_code(500)before returning the (still-visible) error markup.render404()is unaffected. .mapfiles now serve asapplication/json.AssetServerfell through tomime_content_type()for.mapextensions (typicallytext/plain); explicitapplication/json; charset=utf-8matches their actual content.- Foundations CSS glob picks the newest file when several match.
resolveFoundationsCssUrl()used to return whateverglob()happened to list first when a staledist/foundations.*.cssfrom a previous build wasn't cleaned up; it now picks the newest by mtime and logs a warning viaerror_log(). - Variant grid tiles no longer grid-blow-out to 100% zoom for any fixed-width preset wider than the tile's own cell. Real browsers (unlike the jsdom-based unit tests, which stub
clientWidthand never actually lay anything out) default a CSS grid item's automatic minimum size to its content's min-content size — the tile card and its content-area wrapper had neither setmin-width: 0, so a scaled-down device preset's own fixed-width iframe kept forcing its ancestor tracks to grow back to the iframe's full logical size, measuring that inflated width right back into the next zoom calculation and permanently pinning it at 100%. The "scale down to fit this tile's own measured cell width, never up" behavior documented since the original device-presets feature never actually engaged in a real browser as a result. Both the tile card and its content-area wrapper now carrymin-w-0. - A fixed-width preset's scaled tile screen no longer overflows and gets clipped on its right edge.
VariantGrid.vue's per-tile cell-width measurement read the content-area wrapper'sclientWidth— its padding-box width, which includes the wrapper's ownp-3— socomputeTileGeometry()'s zoom fit against a cellWidth ~24px too generous; the resultingwrapperWidth(and the scaled iframe inside it) rendered past the wrapper's true content box and lost its right edge to the wrapper'soverflow: hidden.registerCell()now measures the wrapper's content-box width (itsclientWidthminus its own padding) instead, matching what itsResizeObserveralready reported on every subsequent tick — both the initial synchronous read and every resize now agree on the same number, and the scaled screen renders centered with equal left/right gaps and no cropped edge.
Documentation
docs/API.mdfixes. The/api/fieldsresponse shape documented an{id, type, name, fields}union that never existed —FieldsEndpoint::handle()has always emitted{component_id, component_name, fields}(matchingREADME.md's copy, which was already correct). Also documented three bundled Twig filters (cachebust,format_date,custom_price_format) that shipped since 0.1.3/0.3.8 but were never added to the § Twig functions & filters table.- CHANGELOG archived. Entries older than
[0.4.0]moved toCHANGELOG-archive.md(byte-for-byte relocation, nothing edited) to keep the active changelog focused on recent series. - Stale e2e Layer B removed.
tests/e2e/smoke-browser.shreached intowindow.Alpine.store(...), a global the Vue rewrite removed — it had been dead code since Phase 1. Deleted the script and itsrun.sh/CI wiring; the Playwright suite (tests/e2e/playwright/styleguide.spec.js) already covers the same behaviour and, unlike its predecessor, runs in CI. docs/MIGRATION.mdadded. Step-by-step guide for replacing a bespoke, hand-rolled styleguide with this package: the common bootstrap/config/rewrite/lint/cleanup steps once, then per-project deltas for the fleet'scentrumocnichvad(Tailwind v3 + SCSS, dual CSS bundle),suys-static(Drupal-backed Twig, deadstyleguide:YAML content), andbootstrap-base(Bootstrap 5,picsum.photosfixture images) migrations. Linked fromREADME.md§lint— metadata quality report.- Sibling
styleguide.twigis now documented as the official fixture convention (README.md§ Fixtures & sample data); thestyleguide:YAML key stays functional as a presence-only flag for backward compatibility, but content under it is flagged bylint(dead-styleguide-content).
Pull Requests
- #63 — Styleguide 2.0: Vue 3 SPA rewrite, backend robustness, adoption tooling, file-convention variants
Full Changelog: v0.6.5...v1.0.0