Compare commits

...

9 commits

Author SHA1 Message Date
efaa3d0e79 docs(vendoring): name what source becomes, and why push must precede re-sync
The doc argued that the first real re-sync is what makes source_commit
truthful, but never said what the restored script actually writes: source is
the repo name (not a path, not a machine-local absolute path) and source_commit
is git rev-parse HEAD in the source checkout.

Naming the second half makes an ordering constraint visible that was implicit:
a re-sync run before this repo's commit reaches the public mirror stamps two
public consumer repos with a hash that exists on one machine. That is the
c1b7bad defect again with a fresher date, so the check to run first is written
out.
2026-08-27 00:49:46 +02:00
7448cec304 feat(sync): restore sync-design-system.mjs with an explicit source boundary
The script that produced the existing vendored copies was deleted with the
monorepo layout in 2026-06 and recovered from catalog's history
(e84dffd^:scripts/sync-design-system.mjs). It could not be restored unchanged:
it walks its source directory and copies everything, and this repo's root is no
longer a directory holding only the design system. Run verbatim it vendored 109
files instead of 27, STATE.md and the whole of .git/ among them - and STATE.md
is gitignored precisely because this repo's remote is public.

The boundary is an explicit allowlist (DELIVERED_FILES) rather than a dist/
directory: one definition, read directly by the tests, with no copy step to go
stale. The allowlist's own failure mode - a new design-system file forgotten
from the list ships nothing, and --check stays green because it hashes a
consumer's tree against that consumer's own MANIFEST - is closed by scanning
the source for unlisted root-level *.css, fonts/* and schemas/* and refusing to
run.

tests/sync-source-boundary.test.mjs pins both directions. It was red on the
verbatim script for the right reason (STATE.md present in the target, with
tokens.css as the known-positive control) before the boundary was written.

--target is the consumer's repo root; the script appends
playground/vendor/playground-design-system/ itself. --check verified read-only
against both consumers: MANIFEST OK (27 files) for ms-ai-architect and
llm-security alike. No re-sync was run, so both MANIFEST.json files are
untouched and the stale source_commit is still theirs to inherit at the first
real re-sync.

README and docs/vendoring-and-re-sync.md now publish the command, and warn that
a plain diff/cmp against this repo reports all nine stylesheets as drifted
because the sync injects a generated-header line the source does not have -
17/27 raw, 26/27 with line 1 stripped. Found by ms-ai-architect, re-measured
here.
2026-08-27 00:45:58 +02:00
c4253027ed docs(vendoring): record re-sync decision and measured consumer drift
Vendoring stands: 26/27 vendored files are byte-identical in both consumers 3.5 months on, with no local drift. No re-sync command is published yet - the script is recoverable in full from catalog history and is already polyrepo-ready, but restoring it unchanged would copy this repo's root wholesale, STATE.md included.

Also records that MANIFEST.json's source_commit c1b7bad is not merely unreachable but false: the vendored tree carries components-tier4-project-view.css, which did not exist in that commit. Verified with git cat-file -e plus a known-positive control on components-tier3.css in the same commit.

Verification was find/git cat-file/SHA-256 comparison, not grep - the full log is in docs/vendoring-and-re-sync.md.
2026-08-27 00:17:19 +02:00
0fa22b9f83 docs: document theme-bootstrap script in README Quick start
Both ms-ai-architect and llm-security run an identical FOUC-prevention
script (localStorage read, matchMedia fallback, HTML-attribute default)
before their stylesheet links, keyed per plugin. The shared README's
Quick start example didn't show it, so a reader copying the example
verbatim would get a flash-of-wrong-theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RwqsYBS9ZuEEAMSURJURiR
2026-08-18 17:11:10 +02:00
2b67a93ce3 docs: fix stale vendor paths in README (Directory layout + Quick start)
The shared/ root and index.html-based Quick start example never matched
how ms-ai-architect and llm-security actually vendor this repo
(<plugin>/playground/vendor/playground-design-system/), and the
components-tier4-project-view.css stylesheet (added in v0.6.0) was
missing from both the directory tree and the Quick start includes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RwqsYBS9ZuEEAMSURJURiR
2026-08-18 17:07:11 +02:00
dcd7b41209 docs: add SECURITY.md
Vulnerability disclosure policy with contact address and canonical
Forgejo URL, per org-wide AAA+ C-axis (trust) rollout.
2026-08-16 21:14:30 +02:00
92b55e0155 docs: add Table of Contents to README
README has 15 H2 sections and no navigational aid before this point.
Anchors verified mechanically against actual headings (15/15 match, 0 broken).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiiyJydBvq1qPbHB4Qg8Mz
2026-08-16 16:16:38 +02:00
4464f12144 docs: bring repo up to repo-standard (LICENSE, Non-goals, description sync)
The repo-standard gate reported 5 ERROR and 1 WARN for this shared-asset:

- FILE-MISSING / LICENSE-CLAIMED-ABSENT: README cited MIT with no LICENSE
  file backing it. Added the MIT text used by the sibling repos.
- HEADING-MISSING: added `## Non-goals` so a reader can answer "is this for
  me?" without reading the component tables.
- README-DESC + DESC-TOO-LONG: the forge description was 181 codepoints
  (bound 180) and claimed a "generated manifest" that does not exist in this
  repo. Rewrote it to 165 codepoints covering only what is here, and made the
  README opening line identical so description == README holds.
- README-H1 (WARN): title-cased H1 was drift, not branding — CHANGELOG.md
  already uses the repo name. Aligned to `# playground-design-system`.

Also corrected the stale `**Version:** 0.1` line on the first screen; the
CHANGELOG is at 0.6.0. It now points at CHANGELOG.md instead of restating a
number that has to be maintained twice.

Gate re-run: 0 ERROR, 0 WARN, 10 OK, 1 SKIP (VERSION-UNAVAILABLE — this is a
CSS asset with no package manifest, so there is no version to compare).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143xZ1etAuoHVEngJQmDEqe
2026-08-03 21:52:25 +02:00
3838d55990 chore(gitignore): add session/local-state baseline (polyrepo split) 2026-06-18 10:21:14 +02:00
7 changed files with 914 additions and 41 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
STATE.md
REMEMBER.md
ROADMAP.md
TODO.md
NEXT-SESSION-PROMPT*.local.md
*.local.md
*.local.json
*.local.sh
.DS_Store
.claude/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kjell Tore Guttormsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

189
README.md
View file

@ -1,8 +1,37 @@
# Playground Design System
# playground-design-system
A shared design system for plugin Playgrounds — visual self-service UIs that complement terminal slash-commands. Built for Norwegian public sector with WCAG 2.1 AA compliance, Aksel/Digdir-aligned aesthetics, and self-contained HTML deployment.
Vendored, framework-free CSS design system for plugin playgrounds — design tokens, four component tiers, self-hosted fonts, and file:// operation with no build step.
**Version:** 0.1 (Phase 1 — 2026-05-02)
Playgrounds are the visual self-service UIs that complement a plugin's terminal slash-commands. This system gives them one shared visual language: Aksel/Digdir-aligned aesthetics for Norwegian public-sector users, WCAG 2.1 AA contrast and focus handling, and pages that open straight from disk with no server.
**Version:** 0.6.0 — see [CHANGELOG.md](CHANGELOG.md).
## Table of Contents
- [Non-goals](#non-goals)
- [Provenance](#provenance)
- [Directory layout](#directory-layout)
- [Quick start](#quick-start)
- [Vendoring and re-sync](#vendoring-and-re-sync)
- [Design principles](#design-principles)
- [Token system](#token-system)
- [Component reference](#component-reference)
- [Schemas](#schemas)
- [Theming](#theming)
- [Print mode](#print-mode)
- [Known limitations](#known-limitations)
- [Self-hosted fonts](#self-hosted-fonts)
- [Versioning](#versioning)
- [License](#license)
- [Contributing](#contributing)
## Non-goals
- **Not a JavaScript component library.** The system is CSS-only. Interactivity (tab state, sidepanel toggling, live-meter updates) is wired by each Playground in vanilla JS.
- **Not a general-purpose web UI kit.** Scope is the Playgrounds in this marketplace; Aksel/Digdir conventions and domain visuals (NS 5814 matrices, EU AI Act tiers) are baked in rather than configurable.
- **No build step, bundler or npm package.** Files are vendored and linked with plain `<link>` tags. Nothing is compiled, and nothing is published to a package registry.
- **No icon set.** Iconography is left to each Playground so the shared system stays small.
- **Not open to pull requests.** Solo project — see [Contributing](#contributing).
## Provenance
@ -13,28 +42,31 @@ Integration into the marketplace (file organization, path normalization, README
## Directory layout
```
shared/
├── playground-design-system/ # The design system (this directory)
│ ├── README.md # This file
│ ├── tokens.css # CSS custom properties (Aksel/Digdir-aligned)
│ ├── base.css # Reset, typography, primitives, focus, print
│ ├── components.css # Tier 1: radar, matrix, findings-browser, critique-card, wizard, live-meter
│ ├── components-tier2.css # Tier 2: decision-tree, traffic-lights, diff-review, treemap, distribution, command-pipeline, pyramide, pipeline-cockpit, verdict-pill+risk-meter, codepoint-reveal, small-multiples, OWASP badges
│ ├── components-tier3.css # Tier 3 wave 1: pair-before-after, AI Act timeline, 3-track entry, FRIA rights-matrix, capability-matrix, parallel-agent-status, ErrorSummary, GuidePanel
│ ├── components-tier3-supplement.css # Tier 3 wave 2 (12): toxic-flow, fleet-overview, kanban Keep/Review/Remove, maturity-ladder, classify-and-transform, cycle-ribbon, persistent-antipattern, suppressed-signals, ExpansionCard, ReadMore, FormProgress, Aspirational-vs-Committed
├── fonts.css # @font-face declarations for self-hosted fonts
├── fonts/ # Self-hosted woff2 + license attribution
│ ├── Inter-{Regular,Medium,SemiBold,Bold}.woff2
│ ├── JetBrainsMono-{Regular,Medium,SemiBold}.woff2
│ ├── SourceSerif4-{Regular,Semibold}.woff2
│ └── LICENSES.md # All three are SIL OFL 1.1
├── print.css # A4 print stylesheet with B/W severity patterns
│ └── schemas/ # Cross-plugin JSON schemas
playground-design-system/ # This repo
├── README.md # This file
├── tokens.css # CSS custom properties (Aksel/Digdir-aligned)
├── base.css # Reset, typography, primitives, focus, print
├── components.css # Tier 1: radar, matrix, findings-browser, critique-card, wizard, live-meter
├── components-tier2.css # Tier 2: decision-tree, traffic-lights, diff-review, treemap, distribution, command-pipeline, pyramide, pipeline-cockpit, verdict-pill+risk-meter, codepoint-reveal, small-multiples, OWASP badges
├── components-tier3.css # Tier 3 wave 1: pair-before-after, AI Act timeline, 3-track entry, FRIA rights-matrix, capability-matrix, parallel-agent-status, ErrorSummary, GuidePanel
├── components-tier3-supplement.css # Tier 3 wave 2 (12): toxic-flow, fleet-overview, kanban Keep/Review/Remove, maturity-ladder, classify-and-transform, cycle-ribbon, persistent-antipattern, suppressed-signals, ExpansionCard, ReadMore, FormProgress, Aspirational-vs-Committed
├── components-tier4-project-view.css # Tier 4: project-view arketype
├── fonts.css # @font-face declarations for self-hosted fonts
├── fonts/ # Self-hosted woff2 + license attribution
│ ├── Inter-{Regular,Medium,SemiBold,Bold}.woff2
│ ├── JetBrainsMono-{Regular,Medium,SemiBold}.woff2
│ ├── SourceSerif4-{Regular,Semibold}.woff2
│ └── LICENSES.md # All three are SIL OFL 1.1
├── print.css # A4 print stylesheet with B/W severity patterns
├── schemas/ # Cross-plugin JSON schemas
│ ├── finding.schema.json # Used by llm-security, config-audit, ultraplan-review, ms-ai-review
│ ├── okr-set.schema.json # Used by OKR plugin
│ └── ros-threat.schema.json # Used by ms-ai-architect ROS workflow
└── playground-examples/ # Showcase + reference scenarios
├── scripts/
│ └── sync-design-system.mjs # Vendors the delivered files into a consumer (see Vendoring and re-sync)
├── tests/
│ └── sync-source-boundary.test.mjs # Pins what the sync may and may not copy
└── playground-examples/ # Showcase + reference scenarios (this repo's own demos)
├── index.html # System showcase (browse all components)
├── ros-lier-kommune.html # Scenario A — ms-ai-architect ROS report
├── okr-baerum.html # Scenario B — OKR live writer
@ -58,6 +90,10 @@ shared/
└── ros-data.js # Scenario A mock data
```
Consumers vendor this repo (its CSS/fonts/schemas, not `playground-examples/`) into their own
Playground at `<plugin>/playground/vendor/playground-design-system/` — copied in, not a git
submodule or shared symlink. See [Quick start](#quick-start).
## Quick start
To use the design system from a plugin's Playground:
@ -67,16 +103,41 @@ To use the design system from a plugin's Playground:
<html lang="nb" data-theme="light">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../shared/playground-design-system/tokens.css">
<link rel="stylesheet" href="../../shared/playground-design-system/base.css">
<link rel="stylesheet" href="../../shared/playground-design-system/components.css">
<link rel="stylesheet" href="../../shared/playground-design-system/components-tier2.css">
<!-- Theme bootstrap. Must run before the stylesheets parse to avoid a
flash-of-wrong-theme (FOUC). Priority order:
1) saved choice (localStorage '<plugin-name>-theme')
2) OS preference via matchMedia('(prefers-color-scheme: dark)')
3) the HTML attribute default ('light' above)
Sets both data-theme + colorScheme for native form controls and scrollbars.
Wrapped in try/catch — file:// + private mode can block localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('<plugin-name>-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage unavailable */ }
if (!theme && window.matchMedia) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<!-- Optional: include components-tier3.css for Tier 3 wave 1 components -->
<!-- Optional: include components-tier3-supplement.css for Tier 3 wave 2 (12 additional components) -->
<!-- Optional: include components-tier4-project-view.css for the Tier 4 project-view arketype -->
<!-- Optional: only include print.css if scenario produces a printable A4 report -->
<link rel="stylesheet" href="../../shared/playground-design-system/print.css">
<link rel="stylesheet" href="vendor/playground-design-system/print.css">
<!-- Self-hosted fonts (no external requests) -->
<link rel="stylesheet" href="../../shared/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
</head>
<body>
<header class="app-header">
@ -93,7 +154,77 @@ To use the design system from a plugin's Playground:
</html>
```
The relative path `../../shared/playground-design-system/` assumes the plugin's Playground lives at `plugins/{plugin-name}/playground/index.html`. Adjust the prefix to match your plugin's structure.
The relative path `vendor/playground-design-system/` assumes the plugin's Playground HTML lives at
`<plugin-name>/playground/<plugin-name>-playground.html`, with the design system vendored (copied
in) at `<plugin-name>/playground/vendor/playground-design-system/`. Adjust the prefix to match your
plugin's structure.
The theme-bootstrap script is not part of the vendored CSS — copy it into each Playground's own
`<head>`, replacing `<plugin-name>-theme` with a key unique to that plugin (both `ms-ai-architect`
and `llm-security` follow this pattern today). The `data-theme-toggle` button's own click handler
(written per Playground, not vendored) is what writes that key via `localStorage.setItem` when the
reader switches theme — the bootstrap script only reads it back on the next load. Without the
bootstrap script, the page renders in the HTML attribute's default theme for one frame before
JavaScript applies the saved/OS preference — the FOUC it exists to prevent.
## Vendoring and re-sync
Vendoring remains the recommended consumer model. Measured 2026-08-27 across both consumers
(`ms-ai-architect`, `llm-security`): 26 of 27 vendored files are byte-identical to this repo's
current content three and a half months after their last sync, and neither copy has been edited
locally. The only file that differs is this README.
### Re-sync
From a checkout of this repo, with the consumer repo checked out alongside it:
```sh
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
```
That rewrites `<consumer-repo>/playground/vendor/playground-design-system/` — the 27 delivered
files plus a regenerated `MANIFEST.json`. `--target` is the consumer's **repo root**; the script
appends `playground/vendor/playground-design-system/` itself. `--source <dir>` selects a
different checkout of this repo to read from (default: the one the script lives in). Node 16.7+,
no npm dependencies, no install step.
The script refuses to overwrite a vendored file that was edited locally since the last sync;
`--force` overrides. It copies an explicit allowlist (`DELIVERED_FILES` in the script), not the
repo root — `STATE.md`, `.git/`, `docs/`, `playground-examples/`, `LICENSE` and `SECURITY.md`
stay here. `tests/sync-source-boundary.test.mjs` pins that boundary in both directions: nothing
outside the allowlist reaches a consumer, and a new design-system file that is missing *from* the
allowlist fails the sync loudly instead of silently not shipping.
```sh
node --test tests/sync-source-boundary.test.mjs
```
### Checking a vendored copy
Integrity, from the consumer side, needs no source checkout:
```sh
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo> --check
```
It re-hashes the vendored tree against that copy's own `MANIFEST.json`, then prints
`MANIFEST OK (27 files, source_commit ...)` and exits 0, or lists the differing files and exits 2.
It reads only — `--check` writes nothing to the consumer.
**Without the script, do not compare a vendored copy to this repo with a plain `diff` or `cmp`.**
The sync injects `/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` as line 1 of every
`.css`, and the source has no such line, so a raw comparison reports **all nine stylesheets as
drifted** when none of them is. Measured 2026-08-27 against `ms-ai-architect`: raw `cmp` matches
17 of 27 files; strip line 1 from the vendored `.css` files and it is 26 of 27, the only real
difference being this README. `--check` is immune, because it hashes the vendored tree against
`MANIFEST.json` — which was built after the header was injected — and never against the source.
`MANIFEST.json`'s `source` and `source_commit` fields in the existing copies still describe the
old monorepo layout, and the recorded commit is not merely unreachable but inaccurate. Leave them
alone: the next real re-sync rewrites both truthfully. Do not repair them by hand — rewriting only
the path would turn an obviously stale pointer into a plausible false one. See
[docs/vendoring-and-re-sync.md](docs/vendoring-and-re-sync.md) for the full decision and the
measurements behind it.
## Design principles

31
SECURITY.md Normal file
View file

@ -0,0 +1,31 @@
# Security policy
## Reporting a vulnerability
Report privately to <security@fromaitochitta.com> - do not open a
public issue.
Canonical repository: https://git.fromaitochitta.com/open/playground-design-system
Please include the affected version or commit, a minimal reproduction,
and the impact you see. We acknowledge every report within 5 working
days, agree a fix and disclosure timeline with the reporter, and aim to
disclose within 90 days of the initial report.
## Response process
1. Acknowledge within 5 working days.
2. Triage and confirm severity within 10 working days.
3. Develop and test a fix.
4. Publish an advisory and credit the reporter unless they prefer
to remain anonymous.
## Supported versions
This repository has not yet reached a 1.0 release. Only the latest
tagged 0.x version (currently 0.6.0, see [CHANGELOG.md](CHANGELOG.md))
is supported; security fixes land on `main` and are released as the
next 0.x tag.
## Advisories
No advisories have been published for this repository to date.

View file

@ -0,0 +1,200 @@
# Vendoring and re-sync
**Status:** decision recorded 2026-08-27; the re-sync script was restored the
same day with an explicit source boundary. See
[Current state](#current-state) for the published command.
This document answers three questions raised by a consumer repo on 2026-08-26:
whether a current re-sync path exists, whether vendoring is still the
recommended consumer model, and where `MANIFEST.json`'s `source` field should
point now that the marketplace is a polyrepo.
Every number below was measured against ground truth on 2026-08-27, not
recalled. The measurements are listed in [Verification log](#verification-log)
so a reader can re-run them.
## Background
Until 2026-06-18 the marketplace was a monorepo. This design system lived at
`shared/playground-design-system/`, and `scripts/sync-design-system.mjs`
vendored it into each plugin's `playground/vendor/playground-design-system/`.
Commit `e84dffd` ("thin catalog to manifest + docs (polyrepo migration
complete)") deleted both paths in the same change. This repo was extracted as a
sibling with the CSS files at its root and a fresh 17-commit history.
Consumer documentation kept pointing at the monorepo layout, so the instructions
it gave named a directory and a script that no longer existed anywhere.
## Decision
### 1. Is vendoring still the recommended model? Yes.
This is settled first, because the other two answers depend on it.
The measurement supports keeping it. Three and a half months after the last
sync, in both consumers, **26 of 27 vendored files are byte-identical to this
repo's current content**, and each `MANIFEST.json` still matches its own files
exactly — no consumer has edited a vendored file locally. The one file that
differs is this repo's own `README.md`, which documents the system rather than
being part of what it delivers.
Vendoring did what it promised: the consumers stayed standalone and kept
rendering correctly when the source moved out from under them. What broke was
the *documentation pointing at the mechanism*, not the mechanism.
Nothing replaces it. A git submodule or an npm package would contradict the
Non-goals in [README.md](../README.md): no build step, no bundler, no package
registry, and a Playground that opens from `file://` with only the CSS files
alongside it.
### 2. Is there a current re-sync path? Yes, since 2026-08-27.
`scripts/sync-design-system.mjs` was absent from the entire marketplace tree.
It survived in full in the `catalog` repo's history, which carries the
pre-migration monorepo history (977 commits), and was read back with:
```sh
git -C <marketplace>/catalog show e84dffd^:scripts/sync-design-system.mjs
```
It is 225 lines, has no npm dependencies, and — this matters — it was **already
prepared for the polyrepo split before the split happened**. It accepts
`--source <dir>` and `--target <dir>`, both of which override the hardcoded
monorepo paths, and its own comment says the `--target` flag exists "so the
script still works once `plugins/<name>` no longer exists in-repo
(post-migration / extracted repo)". It also has a `--check` mode that re-hashes
a consumer's vendored tree against its committed `MANIFEST.json` and exits
non-zero on drift, needing no source at all.
So restoring it is recovery, not new development, and it belongs in **this
repo** — the mechanism vendors *from* the design system, so the design system
owns it.
**It could not be restored unchanged.** The script walked its source directory
and copied everything it found. In the monorepo, `shared/playground-design-system/`
contained only the design system. This repo's root contains the design system
*plus* the repo apparatus: `STATE.md`, `LICENSE`, `SECURITY.md`, `.git/`,
`docs/`, and `playground-examples/`. That is not a hypothetical: run verbatim
against this repo's root, the recovered script vendored **109 files instead of
27**, `STATE.md` and the whole of `.git/` among them — and `STATE.md` is
deliberately gitignored because this repo's remote is public.
The boundary chosen is an **explicit allowlist** (`DELIVERED_FILES` in the
script) rather than a `dist/` directory. A `dist/` would duplicate every
stylesheet and insert a copy step that can silently go stale; the allowlist
names the delivered set in one place that the tests read directly, so there is
no second definition to drift from.
An allowlist has one failure mode of its own: a file added to the design system
but not to the list would silently stop shipping, and `--check` would never
notice, because it hashes a consumer's tree against that consumer's own
`MANIFEST.json` and never against the source. This repo has already lived
through that shape once — `components-tier4-project-view.css` was added in
v0.6.0 and was missing from the README's own directory tree until 2026-08-18.
So the script scans the source for design-system files the allowlist does not
name (root-level `*.css`, plus everything under `fonts/` and `schemas/`) and
refuses to run, naming the file. Both directions are covered by
`tests/sync-source-boundary.test.mjs`.
### 3. Where should `MANIFEST.json`'s `source` point? Here — but not yet.
This is not the consumer's business alone: the field is written by this repo's
script and describes this repo's source, so the value is ours to define.
The important finding is that `source_commit` is not merely unreachable — **it
is false**. Both consumers record
`source_commit: c1b7bad3899c5cfe9ff90663003609b018aa79a0`, but the vendored tree
contains `components-tier4-project-view.css`, and that file demonstrably did not
exist in `c1b7bad`. The sync labelled its output with the marketplace's `HEAD`
while copying the working tree, which at that moment held a v0.6.0 file that had
not been committed yet.
The consequence for repair: **do not fix the field by translating the path.**
Rewriting `shared/playground-design-system/` to this repo's name while keeping
`c1b7bad` would turn an obviously stale pointer into a plausible false one, and
a plausible false pointer is worse — it stops inviting the check that would
catch it.
The correct order is: restore the script with a source boundary, run one
re-sync, and let the script write a truthful manifest in which `source_commit`
is this repo's `HEAD` — reachable from this repo, unlike `c1b7bad`. Until then,
a visibly stale field is the more honest state.
Concretely, the restored script writes `source: "playground-design-system"`
the repo name, not a path inside it and not the absolute path of whatever
checkout happened to be read, since either would be a machine-local string in a
committed file. `source_commit` comes from `git rev-parse HEAD` in the source
checkout. **That makes the push order load-bearing: run the first re-sync only
after this repo's commit is on the public mirror**, or the manifest gets a hash
that exists on one machine — the same unreachable-pointer defect as `c1b7bad`,
with a fresher date on it. Check with
`git -C <this repo> rev-parse HEAD` against `git -C <this repo> rev-parse origin/main`
before syncing.
One correction to the premise this question arrived with: the lineage is **not**
severed. `c1b7bad` is unreachable from this repo and from either consumer, but
it is alive in `catalog`. "Nobody can compute how far behind a vendored copy is"
does not hold — it was computed, and the answer is one file.
## Current state
Re-sync, run from a checkout of this repo with the consumer checked out
alongside it:
```sh
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
```
Integrity check, which needs no source checkout and writes nothing to the
consumer:
```sh
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo> --check
```
`--target` is the consumer's **repo root**; the script appends
`playground/vendor/playground-design-system/` itself. Pointing `--target` at
the vendor directory instead makes `--check` look one level too deep, find no
`MANIFEST.json`, and report drift that does not exist.
One trap worth naming for anyone checking by hand instead. The sync injects
`/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` as line 1 of
every `.css`, and the source has no such line, so a plain `diff` or `cmp`
against this repo reports **all nine stylesheets as drifted** when none of them
is: raw `cmp` matches 17 of 27 files, and stripping line 1 from the vendored
`.css` files puts it back at 26 of 27 with this repo's README the only real
difference. `--check` is immune, because it hashes the vendored tree against
`MANIFEST.json`, which was built after the header was injected. This was found
by `ms-ai-architect` on their own copy and re-measured here (see the
[Verification log](#verification-log)).
`MANIFEST.json` should still be left alone. This session restored the script
but ran no re-sync, so the fields described in § 3 above are unchanged and the
reasoning there still holds: the first real re-sync is what rewrites them
truthfully.
## Verification log
Measured 2026-08-27 against ground truth. `<marketplace>` is the polyrepo root.
| Claim | How it was checked | Result |
|---|---|---|
| `sync-design-system.mjs` absent from the marketplace | `find` over the tree, maxdepth 5, `.git`/`node_modules` pruned | 0 hits. Denominator: the same query returned 836 `.mjs` files; known-positive control found `catalog/scripts/check-versions.mjs`, so the query can find. |
| `shared/` absent | `find -type d -name 'shared*'`, maxdepth 3 | 0 hits |
| Two consumers vendor the system | `find -type d -name playground-design-system`, maxdepth 4 | `ms-ai-architect` and `llm-security`, 27 files + `MANIFEST.json` each |
| `c1b7bad` unreachable from this repo | `git cat-file -t` | fatal: could not get object info. Known-positive control on this repo's `HEAD` returned `commit`. |
| `c1b7bad` reachable from `catalog` | `git -C catalog cat-file -t` | `commit` — 2026-05-15, monorepo history, 977 commits |
| The monorepo commit held both deleted paths | `git -C catalog ls-tree -r c1b7bad` | `shared/playground-design-system/` (26 files) and `scripts/sync-design-system.mjs` both present |
| Both paths died in the migration | `git -C catalog log --diff-filter=D` | `e84dffd`, 2026-06-18, deleted both |
| `source_commit` is false | `git -C catalog cat-file -e c1b7bad:shared/playground-design-system/components-tier4-project-view.css` | path does not exist in `c1b7bad`. Known-positive control on `components-tier3.css` in the same commit succeeded. |
| No local drift in either consumer | SHA-256 of each vendored file vs its own `MANIFEST.json` | both `MANIFEST OK`, 27/27 |
| Drift vs this repo | SHA-256 vs this repo's root, generated-header stripped from `.css` | 26/27 identical in both consumers; `README.md` the only difference |
| The recovered script supports polyrepo | read of `git show e84dffd^:scripts/sync-design-system.mjs` | `--source`, `--target`, `--check` all present and honoured in `main()` |
| The verbatim script leaks this repo's apparatus | ran the recovered script unchanged, `--source <repo root>`, into a temp target | 109 files vendored instead of 27; `STATE.md`, `.git`, `.gitignore`, `docs`, `playground-examples`, `LICENSE`, `SECURITY.md` all present. `tests/sync-source-boundary.test.mjs` red on this build, with `tokens.css` present as the known-positive control proving the assertions read the right path. |
| The allowlisted script does not leak | `node --test tests/sync-source-boundary.test.mjs` after the boundary was added | 2/2 pass: 27 files + `MANIFEST.json`, none of the seven apparatus entries present |
| An unlisted design-system file fails the sync | test adds `components-tier5.css` to a complete synthetic source | non-zero exit, filename named in stderr. Known-positive control: the same source without the extra file syncs cleanly. |
| The published `--check` command works against both consumers | `node scripts/sync-design-system.mjs <name> --target ../<name> --check` | `MANIFEST OK (27 files, source_commit c1b7bad3899c5cfe9ff90663003609b018aa79a0)`, exit 0, for `ms-ai-architect` and `llm-security` alike |
| A raw `cmp` misreports the CSS as drifted | `cmp` per file vs this repo's root, then `cmp` with line 1 stripped | raw: 17/27 match, the 10 differing being 9 `.css` + `README.md`; line-1-stripped: 26/27, `README.md` alone. Independently re-measured here; matches `ms-ai-architect`'s finding exactly. |
Not verified: whether `catalog`'s history is itself mirrored anywhere off this
machine. The recovery path above assumes a local `catalog` checkout.

View file

@ -0,0 +1,342 @@
#!/usr/bin/env node
/**
* sync-design-system.mjs
*
* Vendors this design system into a consumer's
* playground/vendor/playground-design-system/ tree.
*
* Usage:
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--force]
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> --check
*
* Each consumer keeps its own pinned copy so it stays standalone.
* MANIFEST.json records SHA-256 per file + source commit + sync date.
* Drift detection refuses overwrite if a vendored file was modified
* locally after sync; pass --force to overwrite anyway.
*
* Source boundary: only the files named in DELIVERED_FILES are vendored.
* This repo's root holds the design system *and* the repo apparatus
* (STATE.md, .git/, docs/, playground-examples/, LICENSE, SECURITY.md).
* STATE.md is gitignored because this repo's remote is public; copying it
* into a consumer would publish it. Walking the source tree, which is what
* this script did while the system lived in its own directory inside the
* marketplace monorepo, is therefore no longer safe.
*
* No npm dependencies. Node 16.7+.
*/
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SOURCE_LABEL = 'playground-design-system';
const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n';
/**
* The delivered system: exactly what a consumer vendors. Everything else in
* this repo is apparatus and stays here.
*
* Adding a file to the design system means adding it here. Forgetting to is
* caught by assertSourceBoundary() below rather than shipping silently, since
* --check hashes a consumer's tree against its own MANIFEST and would stay
* green forever on a file that was never copied.
*/
export const DELIVERED_FILES = [
'CHANGELOG.md',
'README.md',
'base.css',
'components-tier2.css',
'components-tier3-supplement.css',
'components-tier3.css',
'components-tier4-project-view.css',
'components.css',
'fonts.css',
'fonts/Inter-Bold.woff2',
'fonts/Inter-Medium.woff2',
'fonts/Inter-Regular.woff2',
'fonts/Inter-SemiBold.woff2',
'fonts/JetBrainsMono-Medium.woff2',
'fonts/JetBrainsMono-Regular.woff2',
'fonts/JetBrainsMono-SemiBold.woff2',
'fonts/LICENSE-Inter.txt',
'fonts/LICENSE-JetBrainsMono.txt',
'fonts/LICENSE-SourceSerif4.md',
'fonts/LICENSES.md',
'fonts/SourceSerif4-Regular.woff2',
'fonts/SourceSerif4-Semibold.woff2',
'print.css',
'schemas/finding.schema.json',
'schemas/okr-set.schema.json',
'schemas/ros-threat.schema.json',
'tokens.css',
];
// Where an added design-system file would plausibly land. Scanned against the
// allowlist so an omission is loud. Deliberately narrow: root-level *.md and
// the repo apparatus are excluded by design, not by oversight.
const SCANNED_DIRS = ['fonts', 'schemas'];
function parseArgs(argv) {
const args = { plugin: null, force: false, source: null, target: null, check: false };
const rest = argv.slice(2);
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '--force') args.force = true;
else if (a === '--check') args.check = true;
else if (a === '--source' || a === '--target') {
const v = rest[++i];
if (!v) throw new Error(`${a} requires a directory argument`);
args[a === '--source' ? 'source' : 'target'] = v;
} else if (a.startsWith('--source=')) args.source = a.slice('--source='.length);
else if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
else if (a.startsWith('--')) {
throw new Error(`Unknown flag: ${a}`);
} else if (!args.plugin) {
args.plugin = a;
} else {
throw new Error(`Unexpected positional arg: ${a}`);
}
}
if (!args.plugin) {
throw new Error('Missing consumer name. Usage: node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--check] [--force]');
}
if (!args.target) {
throw new Error('Missing --target. The marketplace no longer holds plugins/<name>, so the consumer repo root must be given explicitly.');
}
return args;
}
// The consumer (vendor target) repo root. The vendored tree goes below it at
// playground/vendor/playground-design-system/.
function resolvePluginDir(args) {
return path.resolve(args.target);
}
async function sha256(filePath) {
const buf = await fs.readFile(filePath);
return createHash('sha256').update(buf).digest('hex');
}
async function walk(dir, base = dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const out = [];
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await walk(full, base)));
} else if (e.isFile()) {
out.push(path.relative(base, full));
}
}
return out;
}
async function readJsonIfExists(p) {
try {
return JSON.parse(await fs.readFile(p, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') return null;
throw e;
}
}
/**
* Both directions of the boundary: every delivered file must exist in the
* source, and no design-system file may exist in the source without being
* delivered.
*/
async function assertSourceBoundary(sourceDir) {
const delivered = new Set(DELIVERED_FILES);
const missing = [];
for (const rel of DELIVERED_FILES) {
try {
await fs.access(path.join(sourceDir, rel));
} catch {
missing.push(rel);
}
}
if (missing.length) {
throw new Error(
`Source is missing ${missing.length} delivered file(s):\n` +
missing.map(f => ` - ${f}`).join('\n'),
);
}
const candidates = [];
for (const e of await fs.readdir(sourceDir, { withFileTypes: true })) {
if (e.isFile() && e.name.endsWith('.css')) candidates.push(e.name);
}
for (const dir of SCANNED_DIRS) {
const full = path.join(sourceDir, dir);
try {
for (const rel of await walk(full)) candidates.push(path.join(dir, rel));
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
const unlisted = candidates.filter(rel => !delivered.has(rel)).sort();
if (unlisted.length) {
throw new Error(
`Source holds ${unlisted.length} design-system file(s) the allowlist does not name:\n` +
unlisted.map(f => ` - ${f}`).join('\n') +
'\nAdd them to DELIVERED_FILES, or move them out of the delivered tree.',
);
}
}
async function copyDelivered(sourceDir, targetDir) {
for (const rel of DELIVERED_FILES) {
const dest = path.join(targetDir, rel);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(path.join(sourceDir, rel), dest);
}
}
async function detectDrift(targetDir, prevManifest) {
if (!prevManifest || !prevManifest.files) return [];
const drifted = [];
for (const [rel, prevHash] of Object.entries(prevManifest.files)) {
const tgt = path.join(targetDir, rel);
try {
const cur = await sha256(tgt);
if (cur !== prevHash) drifted.push(rel);
} catch (e) {
if (e.code === 'ENOENT') drifted.push(`${rel} (missing)`);
else throw e;
}
}
return drifted;
}
async function injectGeneratedHeader(targetDir, files) {
for (const rel of files) {
if (!rel.endsWith('.css')) continue;
const p = path.join(targetDir, rel);
const content = await fs.readFile(p, 'utf8');
if (content.startsWith(GENERATED_HEADER)) continue;
await fs.writeFile(p, GENERATED_HEADER + content, 'utf8');
}
}
async function buildManifest(targetDir, files, sourceCommit, sourceLabel) {
const fileHashes = {};
for (const rel of files.sort()) {
fileHashes[rel] = await sha256(path.join(targetDir, rel));
}
return {
generated_by: 'scripts/sync-design-system.mjs',
do_not_edit: true,
source: sourceLabel,
source_commit: sourceCommit,
sync_date: new Date().toISOString(),
file_count: files.length,
files: fileHashes,
};
}
function getCurrentCommit(cwd) {
try {
return execSync('git rev-parse HEAD', {
cwd: cwd || REPO_ROOT,
encoding: 'utf8',
}).trim();
} catch {
return 'unknown';
}
}
// --check: re-hash a consumer's vendored tree against its committed
// MANIFEST.json and exit non-zero on drift. No source needed — one command in
// a clean clone. Reads only; it never writes to the consumer.
async function runCheck(args) {
const pluginDir = resolvePluginDir(args);
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
const manifestPath = path.join(targetDir, 'MANIFEST.json');
const manifest = await readJsonIfExists(manifestPath);
if (!manifest) {
console.error(`MANIFEST DRIFT: no MANIFEST.json at ${manifestPath}`);
process.exit(2);
}
const drifted = await detectDrift(targetDir, manifest);
if (drifted.length) {
console.error(`MANIFEST DRIFT: ${drifted.length} vendored file(s) differ from MANIFEST.json:`);
for (const f of drifted) console.error(` - ${f}`);
process.exit(2);
}
console.log(`MANIFEST OK (${manifest.file_count} files, source_commit ${manifest.source_commit})`);
}
async function main() {
const args = parseArgs(process.argv);
if (args.check) {
await runCheck(args);
return;
}
const pluginDir = resolvePluginDir(args);
const sourceDir = args.source ? path.resolve(args.source) : REPO_ROOT;
try {
const stat = await fs.stat(pluginDir);
if (!stat.isDirectory()) throw new Error('not a directory');
} catch {
throw new Error(`Consumer directory not found: ${pluginDir}`);
}
try {
await fs.access(sourceDir);
} catch {
throw new Error(`Source directory missing: ${sourceDir}`);
}
await assertSourceBoundary(sourceDir);
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
const manifestPath = path.join(targetDir, 'MANIFEST.json');
const prevManifest = await readJsonIfExists(manifestPath);
const drifted = await detectDrift(targetDir, prevManifest);
if (drifted.length && !args.force) {
console.error(`Refusing sync: ${drifted.length} vendored file(s) drifted from previous MANIFEST:`);
for (const f of drifted) console.error(` - ${f}`);
console.error('Pass --force to overwrite local changes.');
process.exit(2);
}
if (drifted.length && args.force) {
console.warn(`--force: overwriting ${drifted.length} drifted file(s).`);
}
await fs.mkdir(path.dirname(targetDir), { recursive: true });
await fs.rm(targetDir, { recursive: true, force: true });
await fs.mkdir(targetDir, { recursive: true });
await copyDelivered(sourceDir, targetDir);
const files = await walk(targetDir);
await injectGeneratedHeader(targetDir, files);
const sourceCommit = getCurrentCommit(sourceDir);
const finalFiles = await walk(targetDir);
const manifest = await buildManifest(targetDir, finalFiles, sourceCommit, SOURCE_LABEL);
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
console.log(`Synced ${SOURCE_LABEL}${targetDir}`);
console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`);
console.log(` Source commit: ${sourceCommit}`);
console.log(` Sync date: ${manifest.sync_date}`);
}
// Importable: the tests read DELIVERED_FILES from here so the allowlist has
// exactly one definition.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(err => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
}

View file

@ -0,0 +1,136 @@
/**
* Source-boundary tests for scripts/sync-design-system.mjs.
*
* The design system used to live in its own directory inside the marketplace
* monorepo, so the sync script could copy its whole source tree. After the
* polyrepo split the source tree is this repo's root, which also holds
* STATE.md (gitignored, must never reach a public mirror), .git/, docs/ and
* playground-examples/. These tests pin the boundary: only the delivered
* files may be vendored.
*
* Run with: node --test tests/
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { pathToFileURL } from 'node:url';
const execFileAsync = promisify(execFile);
const REPO_ROOT = path.resolve(import.meta.dirname, '..');
const SCRIPT = path.join(REPO_ROOT, 'scripts', 'sync-design-system.mjs');
const VENDOR_SUBPATH = path.join('playground', 'vendor', 'playground-design-system');
const DELIVERED_COUNT = 27;
// --target is the PLUGIN root; the script appends playground/vendor/... itself,
// and refuses to run if the plugin root does not already exist.
async function makePluginDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pds-sync-test-'));
return dir;
}
async function listFiles(dir) {
const out = [];
async function walk(current) {
const entries = await fs.readdir(current, { withFileTypes: true });
for (const e of entries) {
const full = path.join(current, e.name);
if (e.isDirectory()) await walk(full);
else out.push(path.relative(dir, full));
}
}
await walk(dir);
return out.sort();
}
async function exists(p) {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
test('sync copies only the delivered files, never the repo apparatus', async (t) => {
const pluginDir = await makePluginDir();
t.after(() => fs.rm(pluginDir, { recursive: true, force: true }));
await execFileAsync('node', [
SCRIPT, 'test-plugin',
'--source', REPO_ROOT,
'--target', pluginDir,
]);
const vendorDir = path.join(pluginDir, VENDOR_SUBPATH);
// Known-positive control: the assertions below are worthless unless this
// proves the sync actually wrote to the path being inspected.
assert.ok(
await exists(path.join(vendorDir, 'tokens.css')),
'tokens.css must be vendored — without it the leak assertions prove nothing',
);
// The leak this test exists for. STATE.md is gitignored precisely because
// this repo's remote is public; vendoring it into a consumer publishes it.
for (const leak of ['STATE.md', '.git', 'playground-examples', 'docs', 'LICENSE', 'SECURITY.md', '.gitignore']) {
assert.equal(
await exists(path.join(vendorDir, leak)),
false,
`${leak} must not be vendored`,
);
}
const files = await listFiles(vendorDir);
assert.equal(
files.length,
DELIVERED_COUNT + 1,
`expected ${DELIVERED_COUNT} delivered files + MANIFEST.json, got ${files.length}`,
);
const manifest = JSON.parse(await fs.readFile(path.join(vendorDir, 'MANIFEST.json'), 'utf8'));
assert.equal(manifest.file_count, DELIVERED_COUNT);
});
test('sync refuses to run when the source holds a design-system file the allowlist does not name', async (t) => {
const pluginDir = await makePluginDir();
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pds-sync-src-'));
t.after(() => Promise.all([
fs.rm(pluginDir, { recursive: true, force: true }),
fs.rm(sourceDir, { recursive: true, force: true }),
]));
// A minimal but complete source: every delivered file present, empty.
const { DELIVERED_FILES } = await import(pathToFileURL(SCRIPT).href);
for (const rel of DELIVERED_FILES) {
const p = path.join(sourceDir, rel);
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, '', 'utf8');
}
// Baseline: the complete source syncs cleanly. Known-positive control for
// the failure asserted below.
await execFileAsync('node', [SCRIPT, 'test-plugin', '--source', sourceDir, '--target', pluginDir]);
// A new stylesheet added to the design system but not to the allowlist is
// the failure mode this repo already lived through once
// (components-tier4-project-view.css, added v0.6.0, missing from docs until
// 2026-08-18). It must be loud, not silent: --check hashes target against
// MANIFEST and would stay green forever.
await fs.writeFile(path.join(sourceDir, 'components-tier5.css'), '', 'utf8');
await assert.rejects(
execFileAsync('node', [SCRIPT, 'test-plugin', '--source', sourceDir, '--target', pluginDir]),
(err) => {
assert.match(err.stderr, /components-tier5\.css/);
return true;
},
'an unlisted stylesheet in the source must fail the sync',
);
});