@weatherboard/gyde-design 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,6 +137,7 @@ against all five.
137
137
  | `agentdocs.mjs` | What an agent building the product reads before writing UI. |
138
138
  | `ruleindex.mjs` | Every rule as data — id, name, intent, fix shape. Describes; never decides. |
139
139
  | `selfgate.mjs` | Emits a scaffold and gates it. Gyde held to its own rules. |
140
+ | `floor.mjs` | Opt-in scoped adoption floors and their named structural exceptions. Fails closed on a scope that matches nothing. |
140
141
 
141
142
  ## Measured, on the three repositories it was built from
142
143
 
@@ -314,6 +315,87 @@ This command updates only `.gyde/design-system.md` and refuses to replace a
314
315
  handwritten file. It lists component names without guessing their props or
315
316
  claiming theme APIs that Gyde has not verified in that product.
316
317
 
318
+ ## A floor over a scope you have finished
319
+
320
+ The ledger answers *has this got worse?* That is the right instrument for a
321
+ repository carrying four thousand findings and the wrong one for the opposite
322
+ case: a product that has finished the work over a defined slice of its tree and
323
+ wants the finishing to stay finished. An empty ledger cannot say that — "nothing
324
+ was recorded here" is also what an unmeasured scope says.
325
+
326
+ So a product may declare a **floor**, and nothing more happens without one:
327
+
328
+ ```json
329
+ {
330
+ "design": {
331
+ "adoptionFloors": [
332
+ {
333
+ "scope": "apps/console/src",
334
+ "minimum": 100,
335
+ "exceptions": [
336
+ {
337
+ "file": "apps/console/src/frame/MediaFrame.tsx",
338
+ "element": "iframe",
339
+ "because": "A browser-owned element the design system cannot render or restyle from inside."
340
+ }
341
+ ]
342
+ }
343
+ ]
344
+ }
345
+ }
346
+ ```
347
+
348
+ | key | |
349
+ | --- | --- |
350
+ | `scope` | A path inside the repository. Not an npm scope — that is `design.scope`, a different key answering a different question. |
351
+ | `minimum` | A whole number, 0 to 100. The fraction of eligible visual elements under the scope that must be the system's. |
352
+ | `exceptions` | Elements excluded from the denominator. Each names one element, in one file, with one reason. |
353
+
354
+ `gyde-design gate` prints the figure with its numerator, its denominator and the
355
+ scope, and prints the exceptions underneath it rather than inside it. A working
356
+ example is in `examples/scoped-floor`.
357
+
358
+ **100% means zero.** The verdict is taken on the counts, never on the rendered
359
+ percentage: 999 of 1,000 floors to 99% and does not meet a floor of 100. That is
360
+ the whole reason this exists — a report reading "100%" over a denominator that
361
+ still holds a bespoke element is a lie that looks like an achievement.
362
+
363
+ **Exceptions cannot grow quietly.** One entry excuses one element. Until an
364
+ element is declared it is an ordinary bespoke element and it fails the floor, so
365
+ the seventh exception costs a diff a reviewer reads. And an exception that stops
366
+ matching anything is an **error**: a stale exemption is a line sitting open for
367
+ whatever lands under that name next, and it is the only way the list could
368
+ shrink invisibly. The same discipline the allowance ledger has — it may only
369
+ ever get shorter, and never by accident.
370
+
371
+ **Every path fails closed.** A malformed entry, an unknown key, a scope that
372
+ does not resolve, a scope that matches no markup files, a scope whose files make
373
+ no visual decision, an unreadable component barrel, an exception naming
374
+ something that is gone — each is an ERROR that names the cause, and none of them
375
+ is allowanceable or recordable as a baseline. The zero-file case is the one that
376
+ mattered most: a floor over an empty denominator would report success forever,
377
+ and a rule that matches nothing reads exactly like a rule that passes.
378
+
379
+ ### What a floor does not protect against
380
+
381
+ - **Gyde does not judge the exceptions.** It checks that each names a real
382
+ bespoke element and carries a reason somebody wrote. It cannot tell a true
383
+ reason from a plausible sentence. What it buys is that every one of them is
384
+ on the artefact, in the diff, with a name and a line number attached.
385
+ - **It measures markup, not rendering.** An element is the system's if its tag
386
+ is one of the design system's exports. A component that imports the system
387
+ and then restyles it at the call site still counts — that is the styling
388
+ layer's question, not this one.
389
+ - **The scope is a path, not a semantic boundary.** Code moved out from under
390
+ it leaves the denominator silently. The figure stays true and the coverage
391
+ shrinks; nothing here notices. The scope is printed beside the figure
392
+ precisely so that a reader can.
393
+ - **A small denominator is still a denominator.** A scope of two hundred files
394
+ where three carry markup reports three. Whether three is enough evidence is a
395
+ judgement Gyde does not make for you — which is why the number is never
396
+ printed without it.
397
+
398
+
317
399
  ## Not built yet
318
400
 
319
401
  Build-pipeline wiring for a compiled token layer — moot while the emitted tokens
package/agentdocs.mjs CHANGED
@@ -214,6 +214,17 @@ The design-system package supplies \`src/theme-bootstrap.js\` and exports
214
214
  replacement and repairs external attribute changes. Keep local route theme
215
215
  overrides on inner containers so they do not overwrite the global choice.
216
216
 
217
+ Declare each browser host in \`gyde.config.json\` under \`design.themeHosts\`.
218
+ For a standard HTML entry, use
219
+ \`{ "appPath": "apps/web", "adapter": "html-inline", "html": "index.html", "entry": "src/main.js" }\`.
220
+ The HTML head must contain the exact emitted bootstrap as a synchronous inline
221
+ script before styles or external scripts, and load \`/src/main.js\` as a module.
222
+ That module must import and call \`startThemeChoice\` from
223
+ \`${systemPackage}/theme-choice\` immediately at module top level after that
224
+ import. Gyde's gate checks these files and refuses
225
+ an unsupported adapter; a framework-owned head needs its own adapter. Library
226
+ packages without a browser host leave \`themeHosts\` absent or empty.
227
+
217
228
  The storage key is scoped to this design-system package. \`system\` is an
218
229
  explicit stored choice that removes \`data-theme\` from the document root and
219
230
  lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
package/cli.mjs CHANGED
@@ -49,6 +49,8 @@ import { buildUsage, guidance, formatUsage } from "./usage.mjs";
49
49
  import { record, gate, formatGate, adopt, trim, formatTrim, LEDGER_NOTE } from "./ratchet.mjs";
50
50
  import { loadRules } from "./rules.mjs";
51
51
  import { validateScopeShape, checkScopeAgreement, formatScope } from "./scope.mjs";
52
+ import { checkThemeHosts, formatThemeHosts } from "./themehost.mjs";
53
+ import { checkFloors, formatFloors, validateFloors, KEY as FLOORS_KEY } from "./floor.mjs";
52
54
 
53
55
  /**
54
56
  * The single source of what would be written.
@@ -221,6 +223,22 @@ function readConfig(root) {
221
223
  "A scope names the packages the emitted code imports, so a wrong one produces imports that resolve to nothing.");
222
224
  }
223
225
 
226
+ /**
227
+ * G-133. The schema half of the adoption floors, at the same one place.
228
+ *
229
+ * Here rather than in `gate` alone for the reason above and one more: a floor
230
+ * is the only configuration in this file that can make a gate PASS, by
231
+ * excluding an element from a denominator. A malformed one must therefore be
232
+ * refused everywhere the config is read, and must never be recoverable by
233
+ * falling back to a default — a floor Gyde half-understood would hold a line
234
+ * nobody drew.
235
+ */
236
+ const floors = validateFloors(design.adoptionFloors);
237
+ if (!floors.ok) {
238
+ throw new Error(`gyde.config.json: ${FLOORS_KEY} is malformed and Gyde will not guess at it:\n ` +
239
+ floors.errors.join("\n "));
240
+ }
241
+
224
242
  return { present: true, design };
225
243
  }
226
244
 
@@ -345,6 +363,13 @@ function cmdScan(root, config) {
345
363
  console.log(formatUsage(usage, found));
346
364
  }
347
365
 
366
+ // G-133. Reported wherever adoption is reported, and reported even when no
367
+ // floor is declared — "none declared" and "all held" are different facts.
368
+ console.log("");
369
+ console.log(formatFloors(checkFloors(root, design, {
370
+ systemNames: scanRoots ? new Set(scanRoots) : null,
371
+ })));
372
+
348
373
  if (!config.present) {
349
374
  console.log(
350
375
  "\nnote no gyde.config.json — every path was treated as product code.\n" +
@@ -617,7 +642,7 @@ function cmdGuidance(root, config, { write }) {
617
642
  * report the wrong one, confidently. So the evidence carries `why`, and every
618
643
  * path writes it.
619
644
  */
620
- function writeEvidence(root, result, { ok, why, clientBoundary = null, enforcement = null }) {
645
+ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforcement = null, floors = null }) {
621
646
  mkdirSync(join(root, ".gyde"), { recursive: true });
622
647
  writeFileSync(join(root, ".gyde", "last-verdict.json"), JSON.stringify({
623
648
  "//": "Written by `gyde-design gate`. Proof the gate ran, not just that a job went green.",
@@ -647,9 +672,24 @@ function writeEvidence(root, result, { ok, why, clientBoundary = null, enforceme
647
672
  rulesThatMatchedNothing: result ? result.coverage.filter((c) => !c.matched).map((c) => c.rule) : [],
648
673
  clientBoundary,
649
674
  enforcement,
675
+ /**
676
+ * G-133. The floors, with their denominators, so the action's summary can
677
+ * print the fraction rather than a percentage somebody has to trust.
678
+ */
679
+ floors,
650
680
  }, null, 2) + "\n");
651
681
  }
652
682
 
683
+ /** The floors as data: never a bare percentage, always the fraction and the scope. */
684
+ function floorEvidence(floors) {
685
+ if (!floors.declared) return null;
686
+ return floors.results.map((r) => ({
687
+ scope: r.scope, minimum: r.minimum, used: r.used, of: r.of, percent: r.percent,
688
+ exceptions: r.exceptions.length, bespoke: r.bespoke.length,
689
+ ok: r.ok, errors: r.errors,
690
+ }));
691
+ }
692
+
653
693
  /** The design system package's own `name`, read from disk. Null when there is no package there. */
654
694
  function systemPackageName(root, systemPath) {
655
695
  try {
@@ -688,7 +728,42 @@ function cmdGate(root, config, { recordNewRules = false, trimLedger = false } =
688
728
  return 1;
689
729
  }
690
730
 
731
+ // Host wiring is a binary runtime contract, never an allowance entry. Check
732
+ // before the first-baseline path so recording debt cannot turn a missing
733
+ // pre-paint script into a passing gate.
734
+ const themeHosts = checkThemeHosts(root, design, { packages: ws.packages });
735
+ console.log(formatThemeHosts(themeHosts));
736
+ if (themeHosts.errors.length) {
737
+ writeEvidence(root, null, { ok: false, why: "declared theme host wiring is incomplete" });
738
+ return 1;
739
+ }
740
+
691
741
  const roots = exportedComponents(root, systemPath);
742
+
743
+ /**
744
+ * G-133. Before the baseline path, for the same reason theme hosts are.
745
+ *
746
+ * A floor is a line the product drew over a scope it says is finished. It is
747
+ * not style debt, so it is never an allowance entry — and recording a
748
+ * baseline underneath a floor that could not be measured would turn "Gyde
749
+ * could not tell" into a committed number and then into a pass.
750
+ */
751
+ const floors = checkFloors(root, design, { systemNames: roots ? new Set(roots) : null });
752
+ if (floors.declared) {
753
+ console.log(formatFloors(floors));
754
+ console.log("");
755
+ }
756
+ if (!floors.ok) {
757
+ writeEvidence(root, null, {
758
+ ok: false,
759
+ why: floors.errors.length
760
+ ? `an adoption floor could not be measured: ${floors.errors[0]}`
761
+ : `an adoption floor was not held: ${floors.failures.map((f) => `${f.scope} at ${f.used}/${f.of}, floor ${f.minimum}%`).join("; ")}`,
762
+ floors: floorEvidence(floors),
763
+ });
764
+ return 1;
765
+ }
766
+
692
767
  const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
693
768
 
694
769
  // G-113. `--trim` on a repository with no ledger must NOT fall through to the
@@ -855,6 +930,7 @@ function cmdGate(root, config, { recordNewRules = false, trimLedger = false } =
855
930
 
856
931
  writeEvidence(root, result, {
857
932
  ok, why,
933
+ floors: floorEvidence(floors),
858
934
  clientBoundary: { crossings: cb.crossings.length, unresolved: cb.unresolved.length, blocking: cbBlocks },
859
935
  enforcement: {
860
936
  templateVersion,