@adia-ai/adia-ui-forge 0.8.55 → 0.8.57
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/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/__init__.py +5 -0
- package/commands/demo-audit.md +1 -1
- package/commands/gen-ui-review.md +1 -1
- package/commands/package-release.md +1 -1
- package/commands/site-deployment.md +1 -1
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/prompts/demo-audit.md +1 -1
- package/prompts/gen-ui-review.md +1 -1
- package/prompts/package-release.md +1 -1
- package/prompts/site-deployment.md +1 -1
- package/scripts/forge-lint.mjs +168 -0
- package/scripts/lint-rules.generated.mjs +1700 -0
- package/scripts/site-postwrite-derivation-gate +23 -127
- package/skills/a2ui-maintenance/SKILL.md +1 -1
- package/skills/a2ui-maintenance/references/data-model-reactivity.md +49 -29
- package/skills/a2ui-maintenance/references/pipeline-overview.md +58 -22
- package/skills/a2ui-maintenance/references/surface-lifecycle.md +14 -7
- package/skills/component-md-authoring/SKILL.md +116 -0
- package/skills/component-md-authoring/agents/openai.yaml +3 -0
- package/skills/demo-audit/references/visual-probe-triage.md +4 -1
- package/skills/gen-ui-review/SKILL.md +4 -1
- package/skills/gen-ui-review/references/loop-protocol.md +6 -5
- package/skills/package-release/references/changelog-discipline.md +6 -3
- package/skills/package-release/references/cut-procedure.md +61 -14
- package/skills/package-release/references/gates-catalog.md +6 -2
- package/skills/package-release/references/recovery-paths.md +6 -3
- package/skills/package-release/scripts/gate-roster.mjs +10 -7
- package/skills/package-release/scripts/release-pack.mjs +289 -17
- package/skills/primitive-authoring/references/anti-patterns.md +2 -2
- package/skills/primitive-authoring/references/api-contract.md +17 -4
- package/skills/primitive-authoring/references/authoring-cycle.md +1 -1
- package/skills/primitive-authoring/references/code-style.md +1 -1
- package/skills/primitive-authoring/references/common-gotchas.md +6 -6
- package/skills/primitive-authoring/references/form-control-sizing.md +22 -7
- package/skills/primitive-authoring/references/token-contract.md +5 -0
- package/skills/primitive-authoring/references/yaml-contract.md +132 -14
- package/skills/site-docs-authoring/SKILL.md +7 -9
- package/hooks/hooks.json +0 -44
- package/scripts/forge-lint +0 -315
|
@@ -516,6 +516,75 @@ const EVAL_HEALTH_CHAIN_NUMBERS = [16, 27, 28]; // eval:diff zettel → eval:dif
|
|
|
516
516
|
// for a dedicated/idle host.
|
|
517
517
|
const PREFLIGHT_CONCURRENCY = Number(process.env.PREFLIGHT_CONCURRENCY) || 4;
|
|
518
518
|
|
|
519
|
+
// gh#2195 — gate 4 flake-retry, scoped to the solo phase only.
|
|
520
|
+
//
|
|
521
|
+
// gh#2195's own diagnosis found two mechanisms behind "test:unit:serial
|
|
522
|
+
// flakes under host load": a real wall-clock timing race in one test (fixed
|
|
523
|
+
// separately, PR #2290) and genuine 45s timeouts under contention on the
|
|
524
|
+
// same shared, multi-tenant host an operator or agent session runs
|
|
525
|
+
// everything else on. There is no separate "release pre-flight runner" with
|
|
526
|
+
// its own core count to recalibrate worker-cap.mjs against — Phase 1 above
|
|
527
|
+
// already runs gate 4 solo, with no sibling PRE-FLIGHT gate competing, so
|
|
528
|
+
// what is left is contention from OTHER, unrelated concurrent processes on
|
|
529
|
+
// that same host (other agents' builds, other `npm run check` runs) that no
|
|
530
|
+
// single process's static worker-cap formula can see or size against.
|
|
531
|
+
//
|
|
532
|
+
// A file that fails once under that kind of contention and then passes
|
|
533
|
+
// clean on an immediate, unmodified re-run was never proving a code
|
|
534
|
+
// regression — gate 4 exists to prove one, and a transient scheduling stall
|
|
535
|
+
// isn't that. A file that fails twice in a row almost certainly is a real
|
|
536
|
+
// regression (a genuine bug reproduces deterministically; host contention
|
|
537
|
+
// does not), so this retries AT MOST ONCE, and only the files gate 4 itself
|
|
538
|
+
// named as failed — never the whole suite, never a second retry. Scoped to
|
|
539
|
+
// gate 4's solo phase alone: every other gate, and every OTHER invocation of
|
|
540
|
+
// `npm run test:unit:serial` (CI, local dev, `npm run check`), stays
|
|
541
|
+
// single-shot and strict — this never masks a real regression anywhere else.
|
|
542
|
+
const FAILED_TEST_FILE_RE = /^ FAIL\s+(\S+)/gm;
|
|
543
|
+
|
|
544
|
+
// Pure — extracts the deduped list of failed test file paths from vitest's
|
|
545
|
+
// default-reporter output. Its "Failed Tests" section prints a leading
|
|
546
|
+
// ` FAIL <file> > ...` line per failing test — the same line is emitted for
|
|
547
|
+
// an assertion failure and for a per-test timeout alike.
|
|
548
|
+
function extractFailedTestFiles(text) {
|
|
549
|
+
const files = new Set();
|
|
550
|
+
for (const m of (text || '').matchAll(FAILED_TEST_FILE_RE)) files.add(m[1]);
|
|
551
|
+
return [...files];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Runs the solo gate; on failure, extracts which file(s) it named as failed
|
|
555
|
+
// and — only if at least one was parseable — retries gate 4 scoped to
|
|
556
|
+
// exactly those files, once. An unparseable failure (e.g. vitest itself
|
|
557
|
+
// crashed before printing a summary) fails closed: no retry, same behavior
|
|
558
|
+
// as before this change.
|
|
559
|
+
async function runSoloGateWithRetry(soloGate, args) {
|
|
560
|
+
const first = await runGateCaptured(soloGate, args);
|
|
561
|
+
printGateResult(first);
|
|
562
|
+
if (first.ok) return first;
|
|
563
|
+
|
|
564
|
+
const failedFiles = extractFailedTestFiles(`${first.stdout || ''}\n${first.stderr || ''}`);
|
|
565
|
+
if (failedFiles.length === 0) {
|
|
566
|
+
console.error('\n Gate 4 failed with no parseable failed-file list — not retrying (fail closed, gh#2195).');
|
|
567
|
+
return first;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
console.log(`\n Gate 4 failed on ${failedFiles.length} file(s) — retrying once, scoped to just those files (host-contention tolerance, gh#2195):`);
|
|
571
|
+
for (const f of failedFiles) console.log(` - ${f}`);
|
|
572
|
+
const retryGate = {
|
|
573
|
+
n: soloGate.n,
|
|
574
|
+
cmd: `${soloGate.cmd} -- ${failedFiles.map((f) => `"${f}"`).join(' ')}`,
|
|
575
|
+
what: `${soloGate.what} — retry of ${failedFiles.length} flaky file(s)`,
|
|
576
|
+
};
|
|
577
|
+
const retry = await runGateCaptured(retryGate, args);
|
|
578
|
+
printGateResult(retry);
|
|
579
|
+
|
|
580
|
+
if (retry.ok) {
|
|
581
|
+
console.log(`\n ✓ gate 4 passed on retry — treating the first failure as host contention, not a regression (gh#2195).`);
|
|
582
|
+
return { ...retry, note: `passed on retry after 1 failure (gh#2195 host-contention tolerance); first attempt failed: ${failedFiles.join(', ')}` };
|
|
583
|
+
}
|
|
584
|
+
console.error('\n Gate 4 failed again on retry — a real regression, not a flake. Aborting.');
|
|
585
|
+
return { ...retry, note: `failed again on retry (gh#2195); first attempt failed: ${failedFiles.join(', ')}` };
|
|
586
|
+
}
|
|
587
|
+
|
|
519
588
|
// Resolves one gate's actual command string — the same versionArg/
|
|
520
589
|
// targetVersionArg/resume branching step3PreFlight has always applied.
|
|
521
590
|
// Returns null for a versionArg gate deferred by an in-flight resume (Step
|
|
@@ -656,8 +725,7 @@ async function step3PreFlight(args) {
|
|
|
656
725
|
const poolGates = GATE_ROSTER.filter((g) => g.n !== SOLO_GATE_NUMBER && !chainNumbers.has(g.n));
|
|
657
726
|
|
|
658
727
|
console.log(`\n-- Phase 1/2: gate ${SOLO_GATE_NUMBER} solo (test:unit:serial) --`);
|
|
659
|
-
const soloResult = await
|
|
660
|
-
printGateResult(soloResult);
|
|
728
|
+
const soloResult = await runSoloGateWithRetry(soloGate, args);
|
|
661
729
|
if (!soloResult.ok) {
|
|
662
730
|
console.error('\n Pre-flight aborted at Phase 1 — fix the gate failure before proceeding.');
|
|
663
731
|
console.error(' See references/gates-catalog.md for failure-mode → recovery.');
|
|
@@ -799,10 +867,11 @@ function step4PromoteAndBump(args) {
|
|
|
799
867
|
sh('npm install --package-lock-only --no-audit --no-fund', args);
|
|
800
868
|
sh('npm run check:lockstep', args);
|
|
801
869
|
|
|
802
|
-
// Step 4d.5 — regenerate the derived genui catalog (gh#617
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
// check:genui-catalog failed in CI one
|
|
870
|
+
// Step 4d.5 — regenerate the derived genui catalog (gh#617; gh#2211/ADR-0093
|
|
871
|
+
// repartitioned): each catalog's catalogId embeds the lockstep version
|
|
872
|
+
// (`adia.<scope>@X.Y.Z`), so the bump above just invalidated all five.
|
|
873
|
+
// The v0.8.26 cut skipped this and check:genui-catalog failed in CI one
|
|
874
|
+
// push later. Step 5 stages all five files.
|
|
806
875
|
console.log('\n Step 4d.5 — regenerate derived genui catalog (catalogId carries the lockstep version):');
|
|
807
876
|
sh('node scripts/build/derive-genui-catalog.mjs', args);
|
|
808
877
|
|
|
@@ -907,6 +976,39 @@ function step4PromoteAndBump(args) {
|
|
|
907
976
|
}
|
|
908
977
|
}
|
|
909
978
|
|
|
979
|
+
// Pure so the selftest can lock the parse without a real git tree (gh#2473,
|
|
980
|
+
// same "pure so selftest can lock the transform" shape as bump.mjs's
|
|
981
|
+
// bumpPinnedRef). Takes `git status --porcelain` output, returns the tracked
|
|
982
|
+
// paths Step 5.6's guard should block on — worktree status (porcelain
|
|
983
|
+
// column Y, index 1) 'M' or 'D'; 'A'/'?' (new/untracked) are out of scope,
|
|
984
|
+
// see the guard's own comment. Handles the two porcelain path shapes a
|
|
985
|
+
// plain `line.slice(3)` mishandles: C-style-quoted paths (git quotes any
|
|
986
|
+
// path containing a space, or a rename's `->` separator surrounded by
|
|
987
|
+
// spaces) and rename/copy entries (`R old -> new`, `C old -> new`),
|
|
988
|
+
// where the file that actually needs staging is the NEW path.
|
|
989
|
+
function unstagedTrackedLeftovers(porcelainOutput) {
|
|
990
|
+
const leftover = [];
|
|
991
|
+
for (const line of porcelainOutput.split('\n')) {
|
|
992
|
+
if (line.length <= 3 || !/[MD]/.test(line[1])) continue;
|
|
993
|
+
let rest = line.slice(3).trim();
|
|
994
|
+
// Rename/copy: "old -> new" (both sides may independently be quoted) —
|
|
995
|
+
// the worktree-dirty path to report is the NEW (post-rename) side.
|
|
996
|
+
const arrow = rest.indexOf(' -> ');
|
|
997
|
+
if (arrow !== -1) rest = rest.slice(arrow + 4).trim();
|
|
998
|
+
// git C-quotes a path containing a space, a quote, or a control char as
|
|
999
|
+
// a double-quoted, backslash-escaped string — strip the wrapper and
|
|
1000
|
+
// undo the escaping rather than reporting the literal quoted form.
|
|
1001
|
+
if (rest.length >= 2 && rest[0] === '"' && rest[rest.length - 1] === '"') {
|
|
1002
|
+
rest = rest
|
|
1003
|
+
.slice(1, -1)
|
|
1004
|
+
.replace(/\\"/g, '"')
|
|
1005
|
+
.replace(/\\\\/g, '\\');
|
|
1006
|
+
}
|
|
1007
|
+
leftover.push(rest);
|
|
1008
|
+
}
|
|
1009
|
+
return leftover;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
910
1012
|
// ── Step 5 — Commit ──────────────────────────────────────────────
|
|
911
1013
|
function step5Commit(args) {
|
|
912
1014
|
if (args.mode === 'handoff') {
|
|
@@ -956,14 +1058,18 @@ function step5Commit(args) {
|
|
|
956
1058
|
`docs/ops/releases/v${args.version}.md`,
|
|
957
1059
|
// gh#617: Step 4d.5 regenerates these post-bump (catalogId embeds the
|
|
958
1060
|
// lockstep version); unstaged, check:genui-catalog fails in CI (v0.8.26).
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
'
|
|
966
|
-
'packages/genui/adia-catalog/adia
|
|
1061
|
+
// gh#2211 (ADR-0093): the old base/pack pair retired in favor of five
|
|
1062
|
+
// opt-out-scoped catalogs. catalog-data.js is the same derivation's sixth
|
|
1063
|
+
// output (the browser-safe ES-module projection of all five) —
|
|
1064
|
+
// check-genui-catalog.mjs verifies its freshness too, but this list never
|
|
1065
|
+
// carried it (found live during the v0.8.33 cut: the JSON catalogs staged
|
|
1066
|
+
// and committed fine, catalog-data.js sat modified-uncommitted, CI caught
|
|
1067
|
+
// the drift a fresh checkout exposes that a local working tree doesn't).
|
|
1068
|
+
'packages/genui/adia-catalog/adia.core.json',
|
|
1069
|
+
'packages/genui/adia-catalog/adia.navigation.json',
|
|
1070
|
+
'packages/genui/adia-catalog/adia.data.json',
|
|
1071
|
+
'packages/genui/adia-catalog/adia.agent.json',
|
|
1072
|
+
'packages/genui/adia-catalog/adia.shells.json',
|
|
967
1073
|
'packages/genui/adia-catalog/catalog-data.js',
|
|
968
1074
|
// gh#1361: Step 4d.5b regenerates the catalog tier index post-bump (it
|
|
969
1075
|
// derives from the catalog derive-genui-catalog.mjs just refreshed) and,
|
|
@@ -1015,12 +1121,54 @@ function step5Commit(args) {
|
|
|
1015
1121
|
// Codex `agents/openai.yaml` glob above is.
|
|
1016
1122
|
files.push(`${dir}/plugin.yaml`, `${dir}/__init__.py`, `${dir}/prompts/*.md`);
|
|
1017
1123
|
}
|
|
1018
|
-
|
|
1124
|
+
// gh#2473: bump.mjs's PINNED_REFS also rewrites the factory README's
|
|
1125
|
+
// OWN prose echo of the generation-MCP pin (`server pinned:
|
|
1126
|
+
// @adia-ai/mcp@X` — see bump.mjs's A2UI_MCP_PIN comment,
|
|
1127
|
+
// check:plugin-count-claims' "factory README generation-MCP pin"
|
|
1128
|
+
// equality check) — same invariant-8 scope as .mcp.json, but this
|
|
1129
|
+
// list only ever staged .mcp.json + hermes-mcp.yaml, so the README
|
|
1130
|
+
// edit rode along unstaged/uncommitted every cut until Step 5.6
|
|
1131
|
+
// (below) started catching it.
|
|
1132
|
+
if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`, `${dir}/hermes-mcp.yaml`, `${dir}/README.md`);
|
|
1019
1133
|
return files;
|
|
1020
1134
|
}),
|
|
1021
1135
|
];
|
|
1022
1136
|
sh(`git add ${releaseFiles.join(' ')}`, args);
|
|
1023
1137
|
sh('git diff --cached --stat | tail -3', args);
|
|
1138
|
+
|
|
1139
|
+
// Step 5.6 — unstaged-tracked-files guard (gh#2473). Numbered 5.6, not
|
|
1140
|
+
// 5.5: cut-procedure.md's own §Step 5.5 already names a DIFFERENT,
|
|
1141
|
+
// pre-existing manual freshness trip-wire (components.mjs --verify +
|
|
1142
|
+
// check:chunks-fresh + check:embeddings-fresh) that this script doesn't
|
|
1143
|
+
// automate — reusing "5.5" here would collide with that doc section for
|
|
1144
|
+
// an operator reading both. This guard slots before §Step 5.7 (the
|
|
1145
|
+
// release PR), the next free number in the doc's own sequence.
|
|
1146
|
+
//
|
|
1147
|
+
// The release commit's file allowlist above has drifted from what
|
|
1148
|
+
// bump.mjs / cut-hygiene actually edit more than once (gh#1198, gh#1899,
|
|
1149
|
+
// gh#1954, gh#2473 itself) — a step upstream starts writing a new file
|
|
1150
|
+
// and nobody remembers to add it here, so it rides through Step 5
|
|
1151
|
+
// uncommitted and either ships a half-bumped release commit or gets
|
|
1152
|
+
// discovered only when a later gate (or a human diff) trips over it.
|
|
1153
|
+
// Fail loud, right here, rather than trusting the allowlist is
|
|
1154
|
+
// exhaustive: after staging everything this list knows about, anything
|
|
1155
|
+
// STILL modified-in-the-worktree on a tracked path means the allowlist
|
|
1156
|
+
// missed a file this cut's bump/hygiene pass touched.
|
|
1157
|
+
if (args.dry) {
|
|
1158
|
+
console.log(" [dry] git status --porcelain (Step 5.6 guard: no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched)");
|
|
1159
|
+
} else {
|
|
1160
|
+
const leftover = unstagedTrackedLeftovers(shQuiet('git status --porcelain', args));
|
|
1161
|
+
if (leftover.length > 0) {
|
|
1162
|
+
console.error(`\nERROR: ${leftover.length} tracked file(s) modified by this cut are still unstaged after Step 5's allowlist ran:`);
|
|
1163
|
+
for (const f of leftover) console.error(` ✗ ${f}`);
|
|
1164
|
+
console.error('\n bump.mjs (or a cut-hygiene step) edited these but Step 5\'s `releaseFiles`');
|
|
1165
|
+
console.error(' allowlist does not know about them — add the missing path(s) and re-run,');
|
|
1166
|
+
console.error(' or `git add` + `git commit --amend` them onto the release commit by hand.');
|
|
1167
|
+
process.exit(1);
|
|
1168
|
+
}
|
|
1169
|
+
console.log(" ✓ no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1024
1172
|
if (!args.commitMessageFile) {
|
|
1025
1173
|
console.error('ERROR: --commit-message-file required for cut/from-scratch mode');
|
|
1026
1174
|
process.exit(2);
|
|
@@ -1403,9 +1551,120 @@ async function main() {
|
|
|
1403
1551
|
// must never proceed to tag/push/publish on its own). A fixture temp dir
|
|
1404
1552
|
// supplies the operator-authored content files (--commit-message-file /
|
|
1405
1553
|
// --gh-notes-file) so nothing here depends on real repo state.
|
|
1406
|
-
function selftest() {
|
|
1554
|
+
async function selftest() {
|
|
1407
1555
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'release-pack-selftest-'));
|
|
1408
1556
|
try {
|
|
1557
|
+
// gh#2195 — gate-4 retry: extractFailedTestFiles() must parse vitest's
|
|
1558
|
+
// default-reporter ` FAIL <file> > ...` lines, and
|
|
1559
|
+
// runSoloGateWithRetry() must retry exactly once, scoped to exactly
|
|
1560
|
+
// those files, accepting a clean retry as a host-contention flake but
|
|
1561
|
+
// never masking a failure that reproduces, and never retrying at all
|
|
1562
|
+
// when the failure has no parseable failed-file list.
|
|
1563
|
+
{
|
|
1564
|
+
const sample = [
|
|
1565
|
+
' FAIL packages/a/b.test.js > describe > one',
|
|
1566
|
+
' FAIL packages/a/b.test.js > describe > two',
|
|
1567
|
+
' FAIL packages/c/d.test.js > describe > three',
|
|
1568
|
+
].join('\n');
|
|
1569
|
+
const files = extractFailedTestFiles(sample);
|
|
1570
|
+
if (files.length !== 2 || !files.includes('packages/a/b.test.js') || !files.includes('packages/c/d.test.js')) {
|
|
1571
|
+
console.error(`selftest FAIL: extractFailedTestFiles() expected 2 deduped files, got: ${JSON.stringify(files)}`);
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const flakyMarker = path.join(tmp, 'flaky-invocation-count');
|
|
1576
|
+
const flakyScript = path.join(tmp, 'flaky-once.mjs');
|
|
1577
|
+
fs.writeFileSync(flakyScript, `
|
|
1578
|
+
import { writeFileSync } from 'node:fs';
|
|
1579
|
+
const isRetry = process.argv.slice(2).includes('--');
|
|
1580
|
+
if (!isRetry) {
|
|
1581
|
+
writeFileSync(${JSON.stringify(flakyMarker)}, '1');
|
|
1582
|
+
console.log(' FAIL packages/fake/flaky.test.js > describe > flaky test');
|
|
1583
|
+
console.error('Error: Test timed out in 45000ms.');
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1586
|
+
console.log(' retry ran clean');
|
|
1587
|
+
process.exit(0);
|
|
1588
|
+
`);
|
|
1589
|
+
const flakyGate = { n: 4, cmd: `node "${flakyScript}"`, what: 'selftest flaky gate' };
|
|
1590
|
+
const flakyResult = await runSoloGateWithRetry(flakyGate, {});
|
|
1591
|
+
if (!flakyResult.ok || !flakyResult.note || !flakyResult.note.includes('packages/fake/flaky.test.js')) {
|
|
1592
|
+
console.error(`selftest FAIL: runSoloGateWithRetry() must accept a clean retry as a flake; got: ${JSON.stringify(flakyResult)}`);
|
|
1593
|
+
process.exit(1);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const alwaysFailsScript = path.join(tmp, 'always-fails.mjs');
|
|
1597
|
+
fs.writeFileSync(alwaysFailsScript, `
|
|
1598
|
+
console.log(' FAIL packages/fake/broken.test.js > describe > broken test');
|
|
1599
|
+
process.exit(1);
|
|
1600
|
+
`);
|
|
1601
|
+
const alwaysFailsGate = { n: 4, cmd: `node "${alwaysFailsScript}"`, what: 'selftest always-fails gate' };
|
|
1602
|
+
const alwaysFailsResult = await runSoloGateWithRetry(alwaysFailsGate, {});
|
|
1603
|
+
if (alwaysFailsResult.ok) {
|
|
1604
|
+
console.error('selftest FAIL: runSoloGateWithRetry() must NOT mask a failure that reproduces on retry');
|
|
1605
|
+
process.exit(1);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const sentinel = path.join(tmp, 'unparseable-retried');
|
|
1609
|
+
const unparseableScript = path.join(tmp, 'unparseable-fails.mjs');
|
|
1610
|
+
fs.writeFileSync(unparseableScript, `
|
|
1611
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
1612
|
+
const sentinel = ${JSON.stringify(sentinel)};
|
|
1613
|
+
writeFileSync(sentinel, existsSync(sentinel) ? 'RETRIED' : 'FIRST-RUN');
|
|
1614
|
+
console.error('Segmentation fault (core dumped)');
|
|
1615
|
+
process.exit(139);
|
|
1616
|
+
`);
|
|
1617
|
+
const unparseableGate = { n: 4, cmd: `node "${unparseableScript}"`, what: 'selftest unparseable-crash gate' };
|
|
1618
|
+
const unparseableResult = await runSoloGateWithRetry(unparseableGate, {});
|
|
1619
|
+
if (unparseableResult.ok) {
|
|
1620
|
+
console.error('selftest FAIL: an unparseable gate-4 failure must never be treated as passing');
|
|
1621
|
+
process.exit(1);
|
|
1622
|
+
}
|
|
1623
|
+
if (fs.readFileSync(sentinel, 'utf8') !== 'FIRST-RUN') {
|
|
1624
|
+
console.error('selftest FAIL: an unparseable failure (no FAIL lines) must fail closed — no retry');
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// gh#2473 — Step 5.6's guard: unstagedTrackedLeftovers() must find a
|
|
1630
|
+
// plain modified/deleted tracked file, unquote a C-quoted path
|
|
1631
|
+
// (spaces), and report the NEW side of a rename/copy — all from a
|
|
1632
|
+
// fixture porcelain string, independent of `--dry` and independent of
|
|
1633
|
+
// any real git tree (the `--mode cut --dry` subprocess run below can
|
|
1634
|
+
// only ever prove the code path executes and prints a fixed success
|
|
1635
|
+
// string; it can't reach the `else` branch at all, since `sh()`'s dry
|
|
1636
|
+
// short-circuit never runs `git status`). This exercises the exact
|
|
1637
|
+
// logic Step 5.6 runs for real.
|
|
1638
|
+
{
|
|
1639
|
+
const porcelain = [
|
|
1640
|
+
' M packages/plugins/adia-ui-factory/README.md',
|
|
1641
|
+
' D packages/plugins/adia-ui-factory/hermes-mcp.yaml',
|
|
1642
|
+
'M packages/already/staged.json', // staged-only (X=M, Y=space) — NOT a leftover
|
|
1643
|
+
'?? packages/new/untracked.json', // untracked — out of scope, NOT a leftover
|
|
1644
|
+
' M "packages/has space/quoted file.md"',
|
|
1645
|
+
'RM packages/old-name.md -> packages/new-name.md', // renamed AND further worktree-modified (Y=M) — a leftover
|
|
1646
|
+
'R packages/clean-rename-old.md -> packages/clean-rename-new.md', // renamed, no further worktree change (Y=space) — NOT a leftover
|
|
1647
|
+
].join('\n');
|
|
1648
|
+
const leftover = unstagedTrackedLeftovers(porcelain);
|
|
1649
|
+
const want = [
|
|
1650
|
+
'packages/plugins/adia-ui-factory/README.md',
|
|
1651
|
+
'packages/plugins/adia-ui-factory/hermes-mcp.yaml',
|
|
1652
|
+
'packages/has space/quoted file.md',
|
|
1653
|
+
'packages/new-name.md',
|
|
1654
|
+
];
|
|
1655
|
+
const gotJson = JSON.stringify(leftover);
|
|
1656
|
+
const wantMissing = want.filter((w) => !leftover.includes(w));
|
|
1657
|
+
const wantExtra = leftover.filter((l) => !want.includes(l));
|
|
1658
|
+
if (wantMissing.length > 0 || wantExtra.length > 0 || leftover.length !== want.length) {
|
|
1659
|
+
console.error(`selftest FAIL: unstagedTrackedLeftovers() expected ${JSON.stringify(want)}, got ${gotJson}`);
|
|
1660
|
+
process.exit(1);
|
|
1661
|
+
}
|
|
1662
|
+
if (unstagedTrackedLeftovers('').length !== 0) {
|
|
1663
|
+
console.error('selftest FAIL: unstagedTrackedLeftovers() must return [] on empty porcelain output (the clean-tree case Step 5.6 hits on every normal cut)');
|
|
1664
|
+
process.exit(1);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1409
1668
|
const commitFile = path.join(tmp, 'commit.txt');
|
|
1410
1669
|
const notesFile = path.join(tmp, 'notes.md');
|
|
1411
1670
|
fs.writeFileSync(commitFile, 'selftest fixture commit message\n');
|
|
@@ -1462,6 +1721,16 @@ function selftest() {
|
|
|
1462
1721
|
'packages/plugins/adia-ui-factory/__init__.py',
|
|
1463
1722
|
'packages/plugins/adia-ui-factory/hermes-mcp.yaml',
|
|
1464
1723
|
'packages/plugins/adia-ui-factory/prompts/*.md',
|
|
1724
|
+
// gh#2473 — Step 5's staging pathspec must carry the factory README's
|
|
1725
|
+
// own prose echo of the generation-MCP pin (bump.mjs's A2UI_MCP_PIN
|
|
1726
|
+
// README target), or it rides unstaged/uncommitted past the release
|
|
1727
|
+
// commit the same way .mcp.json itself did before gh#1198.
|
|
1728
|
+
'packages/plugins/adia-ui-factory/README.md',
|
|
1729
|
+
// gh#2473 — Step 5.6's unstaged-tracked-files guard must actually run
|
|
1730
|
+
// (as a no-op) on a clean --dry plan. Its real leftover-detection
|
|
1731
|
+
// logic is unit-tested directly above via unstagedTrackedLeftovers();
|
|
1732
|
+
// this marker only proves the --dry code path reaches its console.log.
|
|
1733
|
+
"no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched",
|
|
1465
1734
|
];
|
|
1466
1735
|
for (const marker of need) {
|
|
1467
1736
|
if (!out.includes(marker)) {
|
|
@@ -1597,7 +1866,10 @@ function fileURLToPathCompat(url) {
|
|
|
1597
1866
|
const topArgv = process.argv.slice(2);
|
|
1598
1867
|
if (topArgv[0] === 'selftest') {
|
|
1599
1868
|
rl.close();
|
|
1600
|
-
selftest()
|
|
1869
|
+
selftest().catch((e) => {
|
|
1870
|
+
console.error(e);
|
|
1871
|
+
process.exit(1);
|
|
1872
|
+
});
|
|
1601
1873
|
} else {
|
|
1602
1874
|
main().catch((e) => {
|
|
1603
1875
|
// sh()/shQuiet() failures already printed the failing command + its
|
|
@@ -23,8 +23,8 @@ closable: { type: Boolean, default: true, reflect: true }
|
|
|
23
23
|
| --- | --- |
|
|
24
24
|
| `modal-ui[closable]` (default `true`) | `modal-ui[permanent]` (default `false`) |
|
|
25
25
|
| `skeleton-ui[animate]` | `skeleton-ui[static]` |
|
|
26
|
-
| `stream-ui[cursor]` | `stream-ui[
|
|
27
|
-
| `chart-ui[average]` | `chart-ui[
|
|
26
|
+
| `stream-ui[cursor]` | `stream-ui[noCursor]` |
|
|
27
|
+
| `chart-ui[average]` | `chart-ui[noAverage]` |
|
|
28
28
|
| `toggle-group-ui[multiple]` | `toggle-group-ui[single]` |
|
|
29
29
|
| `swiper-ui[pause-on-hover]` | `swiper-ui[noPauseOnHover]` |
|
|
30
30
|
|
|
@@ -66,8 +66,8 @@ AdiaUI conventions require `default: false` on Boolean props in the standard sha
|
|
|
66
66
|
| --- | --- | --- |
|
|
67
67
|
| Modal can be dismissed | `closable` | `permanent` |
|
|
68
68
|
| Skeleton animates | `animate` | `static` |
|
|
69
|
-
| Stream shows blinking cursor | `cursor` | `
|
|
70
|
-
| Chart shows average line | `average` | `
|
|
69
|
+
| Stream shows blinking cursor | `cursor` | `noCursor` |
|
|
70
|
+
| Chart shows average line | `average` | `noAverage` |
|
|
71
71
|
| Toggle group allows multi-select | `multiple` | `single` |
|
|
72
72
|
| Swiper pauses on hover | `pause-on-hover` | `noPauseOnHover` |
|
|
73
73
|
|
|
@@ -253,9 +253,22 @@ Reserve `error` for validation state (`[error]` on form inputs matches ARIA patt
|
|
|
253
253
|
<toggle-group-ui single>...</toggle-group-ui>
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
+
**[historical, 2026-08-31, ADR-0056 amendment]** `<toggle-group-ui>` /
|
|
257
|
+
`<toggle-option-ui>` were cut outright in `0.8.43` (gh#1617), before the
|
|
258
|
+
migration this ADR's Decision 2 anticipated ever ran — moot, not
|
|
259
|
+
falsified. `segmented-ui`/`segment-ui` absorbed the role
|
|
260
|
+
(`segmented.yaml`'s `multiple` prop description names the absorption
|
|
261
|
+
directly, gh#1369/#1363 C1). The example above stays as written because it
|
|
262
|
+
illustrates the Boolean-false naming rule against a real historical prop
|
|
263
|
+
name, not because `<toggle-group-ui>` still exists — `segmented-ui`
|
|
264
|
+
deliberately does NOT follow this same polarity (`[multiple]` is
|
|
265
|
+
positive-polarity by design, matching `select-ui[multiple]`; there is no
|
|
266
|
+
`segmented-ui[single]` opt-out), so it is not a drop-in replacement
|
|
267
|
+
example for this rule.
|
|
268
|
+
|
|
256
269
|
## Selection-item state — a declared, reflected `selected` prop, never a private `data-*` stamp
|
|
257
270
|
|
|
258
|
-
A **selection-item primitive** (one selectable option inside a selection-group parent — `segment-ui` in `segmented-ui
|
|
271
|
+
A **selection-item primitive** (one selectable option inside a selection-group parent — `segment-ui` in `segmented-ui`; historically also `toggle-option-ui` in `toggle-group-ui`, cut in `0.8.43`/gh#1617 — see the dated note below) exposes its current state as a declared, reflected `selected: Boolean` prop, documented as parent-managed, and styled via `[selected]`:
|
|
259
272
|
|
|
260
273
|
```javascript
|
|
261
274
|
// child — the declared API surface
|
|
@@ -286,7 +299,7 @@ Rules:
|
|
|
286
299
|
- **The parent's `value` is the single source of truth**; the parent writes `selected` on its children (`segmented.class.js:146,149` sets/removes the attribute; `toggle-group.class.js:104-111` assigns `opt.selected`). Authors and generated markup drive the group's `value`, never `[selected]` on a child (`toggle-group.yaml`'s rules block says so in so many words).
|
|
287
300
|
- **No private `data-*` stamp for API-conceptual state.** `data-selected` hides the state from the yaml SoT, the catalog, and generative authoring — a consumer or the A2UI grammar cannot express "this option is selected" against a stamp that no schema declares. `toggle-group-ui` used to stamp `data-selected` + style `:scope[data-selected]`; it converged onto `segment-ui`'s mechanism (`toggle-option.yaml:38-46`, `toggle-group.css:14`, `toggle-group.test.js:64` asserts the stamp is gone).
|
|
288
301
|
- **Parent-stamped ARIA stays as-is — ARIA is wiring, not API.** `segment-ui` derives `aria-checked` from `selected` (`segment.class.js:42`); `toggle-option-ui` derives `aria-pressed` (`toggle-group.class.js:65`). The reflected prop is the API; the ARIA attribute follows it.
|
|
289
|
-
- **Reference implementation:** `segment-ui` (`segment.yaml:32-36`, `segment.css:7`)
|
|
302
|
+
- **Reference implementation:** `segment-ui` (`segment.yaml:32-36`, `segment.css:7`) is the current, live reference. **[historical, 2026-08-31, ADR-0056 amendment]** `toggle-option-ui`/`toggle-group-ui` were the converged second instance at ratification time, but both were cut outright in `0.8.43` (gh#1617), before any migration ran — `segmented-ui`/`segment-ui` absorbed the role. A new selection-item primitive copies `segment-ui`'s shape directly; there is no second live instance to extend by analogy anymore (the divergence gh#1303 describes was between `segment-ui` and the now-deleted `toggle-option-ui`).
|
|
290
303
|
|
|
291
304
|
Migration note: removing `data-selected` was a breaking change for external CSS that targeted it, so it shipped in two halves — the additive `selected` prop in `0.8.39`, the stamp removal on the `0.8.40` breaking wave with a migration-guide entry (`packages/web-components/CHANGELOG.md`; the ADR text names the wave `0.9.0` — it shipped as `0.8.40`).
|
|
292
305
|
|
|
@@ -31,7 +31,7 @@ These rules are the distilled lessons from a 5-iteration audit. Each one corresp
|
|
|
31
31
|
|
|
32
32
|
### API / Attributes
|
|
33
33
|
|
|
34
|
-
1. **Boolean defaults are `false`.** If the expected default is "on," rename: `closable` → `permanent`, `animate` → `static`, `cursor` → `
|
|
34
|
+
1. **Boolean defaults are `false`.** If the expected default is "on," rename: `closable` → `permanent`, `animate` → `static`, `cursor` → `noCursor`, `average` → `noAverage`, `pause-on-hover` → `noPauseOnHover`. `no-*` is the canonical negation prefix (ADR-0063 decision 1) — a new rename lands on `no-*` directly, never `hide-*`.
|
|
35
35
|
|
|
36
36
|
2. **No magic-value sentinels in numeric props.** Indeterminate = `null`, not `-1`. Consumers branch on `value == null`, which is explicit.
|
|
37
37
|
|
|
@@ -239,7 +239,7 @@ Every Boolean prop on a component must default to `false`. If the expected defau
|
|
|
239
239
|
| --------------------------------- | ------------------------------ |
|
|
240
240
|
| `<drawer-ui permanent>` | `<drawer-ui closable>` |
|
|
241
241
|
| `<chart-ui static>` | `<chart-ui animate>` |
|
|
242
|
-
| `<cursor-ui
|
|
242
|
+
| `<cursor-ui no-cursor>` | `<cursor-ui cursor>` |
|
|
243
243
|
| `<carousel-ui no-pause-on-hover>` | `<carousel-ui pause-on-hover>` |
|
|
244
244
|
|
|
245
245
|
Default behavior is the absent attribute. Attributes exist to opt OUT or carry a value — never to opt INTO the expected default.
|
|
@@ -16,7 +16,7 @@ Composite authors: read §§1–5 BEFORE Phase 3 sketch. Anyone adding an async
|
|
|
16
16
|
4. [minmax(min, 1fr) inside repeat() fighting container queries](#4-minmaxmin-1fr-inside-repeat-fighting-container-queries)
|
|
17
17
|
5. [Nested `<!-- ... -->` inside design-plan canonical-sketch fenced blocks](#5-nested----inside-design-plan-canonical-sketch-fenced-blocks)
|
|
18
18
|
6. [Async load/render function completing out of order](#6-async-loadrender-function-completing-out-of-order--a-guard-at-the-checkpoint-isnt-enough)
|
|
19
|
-
7. [Minting a wrapper-shaped component before its registry.js entry lands](#7-minting-a-wrapper-shaped-component-before-its-registryjs-entry-lands--
|
|
19
|
+
7. [Minting a wrapper-shaped component before its registry.js entry lands](#7-minting-a-wrapper-shaped-component-before-its-registryjs-entry-lands--the-transpiler-silently-deletes-the-node-not-just-mis-types-it)
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -123,15 +123,15 @@ async #loadContent(route) {
|
|
|
123
123
|
|
|
124
124
|
---
|
|
125
125
|
|
|
126
|
-
## 7. Minting a wrapper-shaped component before its registry.js entry lands —
|
|
126
|
+
## 7. Minting a wrapper-shaped component before its registry.js entry lands — the transpiler silently deletes the node, not just mis-types it
|
|
127
127
|
|
|
128
|
-
**Pattern**: a tag is gated first and only, for `*-ui` tags, by `packages/gen-ui/a2ui/registry.js`'s hand-maintained `registry` map — inverted into `reverseRegistry` at `transpiler-maps.js`'s module init, consulted first thing in `compose/transpiler/transpiler.js:149-150`. `registry.js` is hand-edited, not generated by `node scripts/build/components.mjs` (that script writes sidecars/prop-catalog data, consumed only for prop-extraction fidelity on tags the transpiler ALREADY resolved — `transpiler-maps.js:22-26`); a runtime `registerType()` call doesn't rescue a stale row either — `reverseRegistry` is a one-time init snapshot, not live.
|
|
128
|
+
**Pattern**: a tag is gated first and only, for `*-ui` tags, by `packages/gen-ui/a2ui/registry.js`'s hand-maintained `registry` map — inverted into `reverseRegistry` at `transpiler-maps.js`'s module init, consulted first thing in `compose/transpiler/transpiler.js:149-150`. `registry.js` is hand-edited, not generated by `node scripts/build/components.mjs` (that script writes sidecars/prop-catalog data, consumed only for prop-extraction fidelity on tags the transpiler ALREADY resolved — `transpiler-maps.js:22-26`); a runtime `registerType()` call doesn't rescue a stale row either — `reverseRegistry` is a one-time init snapshot, not live. Transpile a demo using a component minted in the SAME change, before its `registry.js` line lands (e.g. the chunk harvester, `node scripts/build/harvest-chunks.mjs`, or any other engine-transpiler consumer), and the tag falls through to `transpiler.js`'s "Unknown → Column" branch (line 180-183) — same mechanism as gh#535's toolbar-group breakage, which at least rendered visibly-wrong. A NEW component is usually wrapper-shaped (one child, author-defined attributes like `anchor="bottom"` the transpiler doesn't map to any real A2UI prop). That shape trips a SECOND, separate rule right after — "single-child container chains flatten" (`transpiler.js:282-285`): a retyped Column with exactly one child and zero recognized props is discarded outright, and its child is spliced directly into the PARENT's children in its place. The wrapper's own id and node are never pushed to the tree at all — not visible-but-wrong, just gone. The row is then internally self-consistent (content hash matches source) so `check:chunks-fresh` reports clean.
|
|
129
129
|
|
|
130
|
-
**Example
|
|
130
|
+
**Example (historical — the illustrating consumer has since retired):** minting `anchor-bar-ui` (gh#495, PR #569) and regenerating the `bulk-action-toolbar` pattern's site-a2ui row (site-a2ui itself retired 2026-08-31, ADR-0072 Decision 2 / gh#2410 — the underlying registry-gating hazard below is unchanged, only that particular consumer is gone) before the worktree's `registry.js` entry for it existed. Git-verified on the pre-fix commit (`ebf71832d`): the converted artifact contained zero occurrences of `pat-bulk-float-bar` (the anchor-bar-ui's own authored id) anywhere — not retyped-and-visible, genuinely absent — while its single child (the toolbar content) survived, reparented one level up. The site-a2ui freshness gate of the day reported clean regardless, for the exact reason the Detector below still explains.
|
|
131
131
|
|
|
132
|
-
**Detector**: none generic —
|
|
132
|
+
**Detector**: none generic — a same-source freshness check can't catch this (the artifact IS fresh relative to its source, it transpiled correctly against a registry that was itself incomplete). The only catch is rendering the actual consuming surface and confirming the new tag's node count is nonzero, or re-running the transpile after `registry.js` is updated and diffing the output for the new component name. A non-wrapper-shaped new component (multiple children, or attributes that happen to map to real props) is lower-risk here — it survives as a visible-but-wrong Column, the gh#535 class, which at least has a visual tell.
|
|
133
133
|
|
|
134
|
-
**Fix**: the `registry.js` entry is what gates resolution — land it (not just run `components.mjs`, which is necessary for prop fidelity but not sufficient to avoid the retype) before
|
|
134
|
+
**Fix**: the `registry.js` entry is what gates resolution — land it (not just run `components.mjs`, which is necessary for prop fidelity but not sufficient to avoid the retype) before transpiling anything that uses the new tag. When gating a dispatched agent's PR that did this out of order, re-run the transpile on the merged tree and confirm the tag actually appears in the output — never trust a freshness gate's green alone for a surface touching a component minted in the same change.
|
|
135
135
|
|
|
136
136
|
**Generalizes to**: any hand-maintained resolution map (not build-generated) that a later regeneration step reads through — regenerating before the map is updated produces an internally-consistent-but-wrong artifact that passes a same-source freshness check; if the misresolved shape also happens to trip a downstream simplification/collapse rule, the failure escalates from "renders wrong" to "renders nothing," with no visual tell at all.
|
|
137
137
|
|
|
@@ -20,10 +20,24 @@ posture flip.
|
|
|
20
20
|
|
|
21
21
|
## Where legibility floors live
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
**[retired 2026-08-28, ADR-0095]** The `--*-min-width: 20ch`-class floor
|
|
24
|
+
described below is no longer permitted anywhere, including on
|
|
25
|
+
`:scope[inline]`. A primitive never floors its own inline-size independent
|
|
26
|
+
of its own geometry — the only permitted floor is
|
|
27
|
+
`min-inline-size: var(--<component>-height)` (a square minimum). ADR-0095
|
|
28
|
+
Decision Class 1 removes the 20ch-class field-width floor for
|
|
29
|
+
`input-ui`/`select-ui`/`combobox-ui` (plus `--input-label-min-width`,
|
|
30
|
+
`--date-range-picker-trigger-min-width`) and replaces it with the square
|
|
31
|
+
floor, generalizing the `button-ui` keep-square pattern already named below
|
|
32
|
+
to the field-width components.
|
|
33
|
+
|
|
34
|
+
*(Historical, pre-ADR-0095 rule — superseded by the retirement above, kept
|
|
35
|
+
only for context on what the old convention was.)* A
|
|
36
|
+
`--*-min-width: 20ch`-class floor (the gh#781/gh#1633 pattern on
|
|
37
|
+
`input-ui`/`select-ui`/`combobox-ui`) used to belong on `:scope[inline]`
|
|
38
|
+
only, never on the plain `:scope`. In the fill state the container owns the
|
|
39
|
+
width, all the way down — no exception, no re-added floor. Two floor
|
|
40
|
+
classes are
|
|
27
41
|
explicitly NOT sizing-posture floors and stay in both states: inner
|
|
28
42
|
`min-width: 0` flex unclampers (the ellipsis-reset machinery) and
|
|
29
43
|
`min-width: var(--*-height)` squareness floors (`button-ui`'s keep-square
|
|
@@ -35,9 +49,10 @@ posture, and also stay in both states.
|
|
|
35
49
|
|
|
36
50
|
- Plain `:scope` (no `[inline]`): block-level, `width: auto` in block flow,
|
|
37
51
|
`width: 100%` in flex/grid parents. No `min-width` legibility floor here.
|
|
38
|
-
- `:scope[inline]`: inline-level display, shrink-wrap sizing
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
- `:scope[inline]`: inline-level display, shrink-wrap sizing. Per ADR-0095,
|
|
53
|
+
no `--*-min-width: 20ch`-class floor moves here any more — a floor-carrier
|
|
54
|
+
component uses the square minimum (`min-inline-size:
|
|
55
|
+
var(--<component>-height)`) instead, in both states.
|
|
41
56
|
- A component that wants to own its line but still hug has no attribute for
|
|
42
57
|
that state (ADR-0077 Decision item 6, accepted consequence) — tell the
|
|
43
58
|
consumer to use explicit sizing (`width: fit-content` or an explicit
|
|
@@ -154,6 +154,11 @@ ADR-0053/0054 don't cover. Any new attribute follows these:
|
|
|
154
154
|
opt-out. `chart-legend[static]` retires to `[interactive]` (inverted
|
|
155
155
|
default). Applies to single-purpose toggles only — `noodles`'s
|
|
156
156
|
`editable`/`readonly` pair is two orthogonal booleans and stays.
|
|
157
|
+
- **One spelling survives a documented alias; the alias retires
|
|
158
|
+
everywhere it appeared** — never a deprecation window. `alert[dismissible]`
|
|
159
|
+
retired (`[closable]` survives, the implemented prop); `card`'s style
|
|
160
|
+
enum's duplicate `outline` member retired (`outlined` survives, the
|
|
161
|
+
ADR-0044 style-axis token).
|
|
157
162
|
- **Two sanctioned multi-value encodings, never a hand-rolled delimited
|
|
158
163
|
string:** (a) `multiple` boolean + a structured option model (`select`'s
|
|
159
164
|
`options`/`<option>` shape); (b) a native array-typed JS property,
|