Skip to content

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 07 Jul 19:30

What's Changed

Added

  • styleguide lint CLI subcommand. Reports metadata quality issues across templates/: unindexed templates (no parseable name:), dead styleguide: YAML content, broken usage: cross-references, unknown render: values, and empty description strings. --type=component|page|doc (default: all three), --format=text|json (default: text), reuses --templates/--pretty. Exit 0 clean, 1 warning/error findings present, 2 usage/internal error — a three-tier contract specific to lint (list/show keep their existing 0/1 codes). See docs/API.md § CLI and README.md § Command-line catalogue. The broken-usage-ref check and the catalogue itself now share one ComponentParser::normaliseUsage() helper, so a usage: value — comma-separated string as authored, or an already-array YAML value — is parsed into ids exactly once; /api/components//api/pages//api/docs emit the result as usage: string[] rather than the raw CSV.

  • New GET /styleguide/api/health endpoint. Reports per-file parse warnings (ComponentParser now catches \Throwable, not just YAML ParseException, so one pathological template no longer 500s the whole /api/components catalogue — it's skipped and recorded instead) plus component/page/doc counts. A separate endpoint rather than a _warnings field on the existing four, which each emit a bare JSON array with no additive slot for a sibling field.

  • ?theme=light|dark on the render endpoint. Implements the contract README.md/docs/API.md already documented but the code never enforced (doc drift closed). Whitelisted server-side (Router::whitelistTheme()) — anything other than the literal string dark resolves to light. Stamps class="dark" + a matching color-scheme on the rendered <html>; inert for projects without dark-mode CSS. SPA toolbar gained an iframe-theme toggle independent of the chrome theme.

  • Optional auth config key. callable(array $route): bool checked once per request before any dispatch; return false to respond 403 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 throws InvalidArgumentException at construction (fail loudly at boot instead of silently allowing every request), and an auth callable that throws is treated as a denial and logged via error_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>.twig sibling files. ComponentParser now globs a component/page/doc directory for styleguide.<id>.twig files (<id> matching [a-z0-9-]+) and surfaces them as an additive variants: [{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 YAML variants: map (<id>: <title-string \| {title?, label?, description?}>, label: a legacy alias for title:) 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 the list/show CLI commands, alongside the object's own sibling-detection flag, now named has_styleguide for casing consistency with body_class and the rest of the object. See docs/API.md § Component YAML metadata and § Component Twig file conventions.

  • ?variant=<id> on the render endpoint resolves the auto-discovered styleguide.<id>.twig sibling in place of the default styleguide.twig, for component/page/doc kinds. 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>) so Router::synthesizeEmbeddedRoute() can forward it across the iframe-embed swap. Absent, invalid, or unknown-but-well-formed values fall back silently to the default styleguide.twig<slug>.twig chain — never a 404 — so a bookmarked deep link survives a deleted/renamed variant file. Query-only, no cookie fallback (unlike theme): 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 default styleguide.twig body 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 its description when 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-fit minmax() 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 under sg-variant-columns in localStorage; a pre-existing sg-variant-layout value 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.

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-expanded keeps 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's motion-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 240ms cubic-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 CSS grid-template-rows 0fr↔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's src — 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's scoreEntry() — 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 @internal and 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 into dist/index.html instead 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 from frontend/ source (npm run build && git diff --exit-code dist/).
  • Helper registration no longer matches Twig exception text. tryAddFunction()/tryAddFilter() used to rethrow unless the LogicException message 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 to error_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 a Sec-Fetch-Dest: iframe request carrying no ?theme= of its own, so Router::synthesizeEmbeddedRoute() used to hardcode light, silently reverting the visitor's choice. The SPA toolbar's iframe-theme toggle now also writes an sg-iframe-theme=dark|light cookie (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-config JSON injection hardened against script-breakout XSS. dispatchSpa() now encodes with JSON_HEX_TAG in addition to its existing flags, so a consumer-controlled value containing a literal </script> can no longer terminate the #sg-config element early.
  • Render-time exceptions now return HTTP 500. A component/page/doc template that throws during render used to respond 200 OK with 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. Renderer now calls http_response_code(500) before returning the (still-visible) error markup. render404() is unaffected.
  • .map files now serve as application/json. AssetServer fell through to mime_content_type() for .map extensions (typically text/plain); explicit application/json; charset=utf-8 matches their actual content.
  • Foundations CSS glob picks the newest file when several match. resolveFoundationsCssUrl() used to return whatever glob() happened to list first when a stale dist/foundations.*.css from a previous build wasn't cleaned up; it now picks the newest by mtime and logs a warning via error_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 clientWidth and 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 set min-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 carry min-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's clientWidth — its padding-box width, which includes the wrapper's own p-3 — so computeTileGeometry()'s zoom fit against a cellWidth ~24px too generous; the resulting wrapperWidth (and the scaled iframe inside it) rendered past the wrapper's true content box and lost its right edge to the wrapper's overflow: hidden. registerCell() now measures the wrapper's content-box width (its clientWidth minus its own padding) instead, matching what its ResizeObserver already 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.md fixes. The /api/fields response shape documented an {id, type, name, fields} union that never existed — FieldsEndpoint::handle() has always emitted {component_id, component_name, fields} (matching README.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 to CHANGELOG-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.sh reached into window.Alpine.store(...), a global the Vue rewrite removed — it had been dead code since Phase 1. Deleted the script and its run.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.md added. 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's centrumocnichvad (Tailwind v3 + SCSS, dual CSS bundle), suys-static (Drupal-backed Twig, dead styleguide: YAML content), and bootstrap-base (Bootstrap 5, picsum.photos fixture images) migrations. Linked from README.md § lint — metadata quality report.
  • Sibling styleguide.twig is now documented as the official fixture convention (README.md § Fixtures & sample data); the styleguide: YAML key stays functional as a presence-only flag for backward compatibility, but content under it is flagged by lint (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