unoverse 0.1.171 → 0.1.173
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/package.json +1 -1
- package/vendor/base/lint/design/defs.mjs +1 -1
- package/vendor/base/lint/design/file.mjs +37 -37
- package/vendor/base/lint/design/index.mjs +50 -50
- package/vendor/base/lint/design/tokens.mjs +2 -2
- package/vendor/base/lint/design/vocabulary.mjs +52 -2
- package/vendor/base/lint/design/walk.mjs +52 -32
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Definition file formats: how
|
|
2
|
+
* Definition file formats: how a design definition is named, found and parsed.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors the server's fsCache.ts, deliberately. A definition is `.yaml` or `.json`, and
|
|
5
5
|
* the linter must agree with the loader about which file IS the definition, or it lints a
|
|
@@ -28,7 +28,7 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
|
28
28
|
? readdirSync(d).filter(isDefFile).map(defName)
|
|
29
29
|
: [];
|
|
30
30
|
};
|
|
31
|
-
// TEMPLATES: stateOrder lists LOCAL states + LAYOUTS in picker order (docs/design/
|
|
31
|
+
// TEMPLATES: stateOrder lists LOCAL states + LAYOUTS in picker order (docs.unoverse.ai/design/apps) —
|
|
32
32
|
// a layout name (the view a component enters) is a valid entry. COMPONENTS: states only.
|
|
33
33
|
const stateNames = dirNames("states");
|
|
34
34
|
const onDisk = new Set([...stateNames, ...(includeLayouts ? dirNames("layouts") : [])]);
|
|
@@ -39,7 +39,7 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
|
39
39
|
// legitimately omitted, so never warn on layouts.
|
|
40
40
|
for (const name of stateNames)
|
|
41
41
|
if (!order.includes(name))
|
|
42
|
-
report("warn", file, `states/${name}.json is not in stateOrder. It falls to the end of the picker; add it to lock the order (docs/design/
|
|
42
|
+
report("warn", file, `states/${name}.json is not in stateOrder. It falls to the end of the picker; add it to lock the order (docs.unoverse.ai/design/studio)`);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// ── lint one file ──
|
|
@@ -49,7 +49,7 @@ function lintFile(file) {
|
|
|
49
49
|
const src = readText(file);
|
|
50
50
|
|
|
51
51
|
// LAW 1 — tokens only (skip manifest + fixture; styles/ is never in a def home).
|
|
52
|
-
// Exempt: `appWidth` — the HOST-facing outer width (state-owned sizing, docs/design/
|
|
52
|
+
// Exempt: `appWidth` — the HOST-facing outer width (state-owned sizing, docs.unoverse.ai/design/apps).
|
|
53
53
|
// It is raw CSS the embed host applies to the app panel ("min(50vw, 760px)", "360px"),
|
|
54
54
|
// never a style the SDK resolves — token law governs the inside, not the envelope.
|
|
55
55
|
if (!isFixture(file) && !isHook(file) && !isManifest(file))
|
|
@@ -74,7 +74,7 @@ function lintFile(file) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
if (RAW_VALUE.test(code) && !/^\s*"?appWidth"?\s*:/.test(code))
|
|
77
|
-
report("error", file, `raw value. Token names only; add/scale a token in the org styles instead (LAW 1, docs/design/
|
|
77
|
+
report("error", file, `raw value. Token names only; add/scale a token in the org styles instead (LAW 1, docs.unoverse.ai/design/styles-and-tokens): ${code.trim()}`, i + 1);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
let json;
|
|
@@ -93,9 +93,9 @@ function lintFile(file) {
|
|
|
93
93
|
if (isAppPath(file)) {
|
|
94
94
|
// TEMPLATE manifest = the envelope. Requires binding + a resolvable root.
|
|
95
95
|
for (const req of ["name", "whenToUse"])
|
|
96
|
-
if (!json[req]) report("warn", file, `template manifest missing "${req}": ${req === "whenToUse" ? "the AI selects the app by it" : "the display name"} (docs/design/
|
|
96
|
+
if (!json[req]) report("warn", file, `template manifest missing "${req}": ${req === "whenToUse" ? "the AI selects the app by it" : "the display name"} (docs.unoverse.ai/design/apps)`);
|
|
97
97
|
if (!(json.binding && json.binding.workflow))
|
|
98
|
-
report("warn", file, `template manifest has no binding.workflow. The app owns its workflow binding (docs/design/
|
|
98
|
+
report("warn", file, `template manifest has no binding.workflow. The app owns its workflow binding (docs.unoverse.ai/design/apps)`);
|
|
99
99
|
// Two valid roots (definitions.ts:229): the STANDARD manifest-only form (root =
|
|
100
100
|
// layouts/<layout>), OR a `<name>` envelope OVERRIDE (its own root). Only the
|
|
101
101
|
// manifest-only form must resolve a layout; an envelope-form template supplies its own.
|
|
@@ -103,7 +103,7 @@ function lintFile(file) {
|
|
|
103
103
|
if (!hasEnvelope) {
|
|
104
104
|
const layoutName = json.layout ?? "main";
|
|
105
105
|
if (!defPath(join(root, "layouts"), layoutName))
|
|
106
|
-
report("error", file, `manifest.layout "${layoutName}" → layouts/${layoutName} does not exist (and no <name> envelope) (docs/design/
|
|
106
|
+
report("error", file, `manifest.layout "${layoutName}" → layouts/${layoutName} does not exist (and no <name> envelope) (docs.unoverse.ai/design/apps)`);
|
|
107
107
|
}
|
|
108
108
|
// THE TEMPLATE TREE (STATE_MODEL v2, checkpoint 2026-08-08): a manifest
|
|
109
109
|
// `states:` block declares the whole machine — validate the DECLARATION:
|
|
@@ -171,7 +171,7 @@ function lintFile(file) {
|
|
|
171
171
|
report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
|
|
172
172
|
}
|
|
173
173
|
} else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
|
|
174
|
-
// ONE STATE AT A TIME (docs/design/
|
|
174
|
+
// ONE STATE AT A TIME (docs.unoverse.ai/design/state): the active state is derived from the
|
|
175
175
|
// latest surfaced VIEW, so no two surfaces in one template may claim the same
|
|
176
176
|
// view — the active surface would be ambiguous.
|
|
177
177
|
{
|
|
@@ -182,7 +182,7 @@ function lintFile(file) {
|
|
|
182
182
|
const w = n.type === "ComponentSlot" ? n.select?.where : null;
|
|
183
183
|
if ((w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
|
|
184
184
|
if (claims.has(w.eq))
|
|
185
|
-
report("error", file, `two reaction surfaces claim the view "${w.eq}" (${claims.get(w.eq)} and ${from}). A template is in ONE state at a time; each view has exactly one surface (docs/design/
|
|
185
|
+
report("error", file, `two reaction surfaces claim the view "${w.eq}" (${claims.get(w.eq)} and ${from}). A template is in ONE state at a time; each view has exactly one surface (docs.unoverse.ai/design/state)`);
|
|
186
186
|
else claims.set(w.eq, from);
|
|
187
187
|
}
|
|
188
188
|
for (const v of Object.values(n)) if (v && typeof v === "object") collectClaims(v, from);
|
|
@@ -200,16 +200,16 @@ function lintFile(file) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
if (json.mode !== undefined && json.defaultState === undefined)
|
|
203
|
-
report("warn", file, `"mode" was renamed to "defaultState". Still read as a fallback, but rename it (docs/design/
|
|
203
|
+
report("warn", file, `"mode" was renamed to "defaultState". Still read as a fallback, but rename it (docs.unoverse.ai/design/state)`);
|
|
204
204
|
// `preview` — the per-state MOCK map ({ "<state>": ["course-card", …] }): each key
|
|
205
205
|
// must be a states/ file, each name a real component. A repeated name seeds
|
|
206
206
|
// several instances (a card rail).
|
|
207
207
|
if (json.preview !== undefined) {
|
|
208
208
|
if (!json.preview || typeof json.preview !== "object" || Array.isArray(json.preview))
|
|
209
|
-
report("error", file, `"preview" must be an object mapping state names to component-name arrays (docs/design/
|
|
209
|
+
report("error", file, `"preview" must be an object mapping state names to component-name arrays (docs.unoverse.ai/design/studio)`);
|
|
210
210
|
else {
|
|
211
211
|
// preview keys are per-LAYOUT (the component view Studio mocks) or a local state
|
|
212
|
-
// (docs/design/
|
|
212
|
+
// (docs.unoverse.ai/design/apps) — resolve against states/ ∪ layouts/, same as stateOrder.
|
|
213
213
|
const viewsIn = (sub) => {
|
|
214
214
|
const d = join(root, sub);
|
|
215
215
|
return existsSync(d)
|
|
@@ -228,7 +228,7 @@ function lintFile(file) {
|
|
|
228
228
|
const comps = componentNamesForFile(file);
|
|
229
229
|
for (const [state, list] of Object.entries(json.preview)) {
|
|
230
230
|
if (!states.has(state))
|
|
231
|
-
report("error", file, `preview."${state}". No states/${state}.json or layouts/${state}.json in this template (docs/design/
|
|
231
|
+
report("error", file, `preview."${state}". No states/${state}.json or layouts/${state}.json in this template (docs.unoverse.ai/design/studio)`);
|
|
232
232
|
// An OBJECT entry is authored TEMPLATE-STATE mock data (what the workflow
|
|
233
233
|
// would have echoed — comments, a discriminant, an anchor), merged verbatim
|
|
234
234
|
// when the state's pill is picked. Only the two shapes; anything else errors.
|
|
@@ -239,37 +239,37 @@ function lintFile(file) {
|
|
|
239
239
|
}
|
|
240
240
|
for (const c of list)
|
|
241
241
|
if (comps && !comps.has(String(c).toLowerCase()))
|
|
242
|
-
report("error", file, `preview."${state}" names unknown component "${c}". No match in
|
|
242
|
+
report("error", file, `preview."${state}" names unknown component "${c}". No match in design/marketplace/components/ or this org's components/ (org-privacy: another org's components are out of reach; lookup is case-insensitive)`);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
|
-
// Sizing is STATE-OWNED (docs/design/
|
|
246
|
+
// Sizing is STATE-OWNED (docs.unoverse.ai/design/apps): the layout root's `appWidth` is the core
|
|
247
247
|
// surface's constant width; a panel slot's `appWidth` slides out on top. Manifest
|
|
248
248
|
// width/focusWidth are DEAD — nothing reads them; there is no fallback.
|
|
249
249
|
for (const dep of ["width", "focusWidth"])
|
|
250
250
|
if (json[dep] !== undefined)
|
|
251
|
-
report("error", file, `manifest "${dep}" is dead. Nothing reads it. Sizing is state-owned: \`appWidth\` on the layout root (constant core width) or on a panel (slide-out width) (docs/design/
|
|
251
|
+
report("error", file, `manifest "${dep}" is dead. Nothing reads it. Sizing is state-owned: \`appWidth\` on the layout root (constant core width) or on a panel (slide-out width) (docs.unoverse.ai/design/apps)`);
|
|
252
252
|
} else {
|
|
253
253
|
// COMPONENT manifest = OPTIONAL spatial discovery. No binding. Mirrors the
|
|
254
254
|
// discovery-meta assertions in server/src/runtime/microapp-structure.test.ts.
|
|
255
255
|
const desc = typeof json.description === "string" ? json.description.trim() : "";
|
|
256
256
|
if (desc.length < 20)
|
|
257
|
-
report("error", file, `discovery manifest.description missing/too short. One line (≥20 chars) saying what the component IS (docs/design/
|
|
257
|
+
report("error", file, `discovery manifest.description missing/too short. One line (≥20 chars) saying what the component IS (docs.unoverse.ai/design/components)`);
|
|
258
258
|
else if (desc.length > 120)
|
|
259
|
-
report("error", file, `discovery manifest.description is ${desc.length} chars: it's the listing subtitle (≤120); move detail into whenToUse (docs/design/
|
|
259
|
+
report("error", file, `discovery manifest.description is ${desc.length} chars: it's the listing subtitle (≤120); move detail into whenToUse (docs.unoverse.ai/design/components)`);
|
|
260
260
|
const wtu = typeof json.whenToUse === "string" ? json.whenToUse.trim() : "";
|
|
261
261
|
if (wtu.length < 20)
|
|
262
|
-
report("error", file, `discovery manifest.whenToUse missing/too short. The utterance-shaped selection text findIntent ranks on (docs/design/
|
|
262
|
+
report("error", file, `discovery manifest.whenToUse missing/too short. The utterance-shaped selection text findIntent ranks on (docs.unoverse.ai/design/components)`);
|
|
263
263
|
else if (/\b(pick when|use (this|when)|when the user|the user (asks|wants|needs)|select (this|when))\b/i.test(wtu))
|
|
264
|
-
report("error", file, `discovery manifest.whenToUse is selector-shaped. Write the words the USER would say, not instructions about the user (docs/design/
|
|
264
|
+
report("error", file, `discovery manifest.whenToUse is selector-shaped. Write the words the USER would say, not instructions about the user (docs.unoverse.ai/design/components)`);
|
|
265
265
|
if (json.binding)
|
|
266
|
-
report("warn", file, `a component discovery manifest has no workflow. Drop "binding" (a component is streamed or node-hydrated) (docs/design/
|
|
267
|
-
// `lifetime` — OPTIONAL render lifetime (docs/design/
|
|
266
|
+
report("warn", file, `a component discovery manifest has no workflow. Drop "binding" (a component is streamed or node-hydrated) (docs.unoverse.ai/design/components)`);
|
|
267
|
+
// `lifetime` — OPTIONAL render lifetime (docs.unoverse.ai/design/state §Two lifetimes). Closed set:
|
|
268
268
|
// "turn" (default — the universal new-turn reset) | "conversation" (durable
|
|
269
269
|
// conversation-scoped surface: conversation-keyed instance, exempt from the
|
|
270
270
|
// new-turn reset, retired only by replacement, self-close, or a template swap).
|
|
271
271
|
if (json.lifetime !== undefined && json.lifetime !== "turn" && json.lifetime !== "conversation")
|
|
272
|
-
report("error", file, `manifest "lifetime" must be "turn" (default) or "conversation". Got ${JSON.stringify(json.lifetime)} (docs/design/
|
|
272
|
+
report("error", file, `manifest "lifetime" must be "turn" (default) or "conversation". Got ${JSON.stringify(json.lifetime)} (docs.unoverse.ai/design/state §Two lifetimes)`);
|
|
273
273
|
}
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
@@ -280,11 +280,11 @@ function lintFile(file) {
|
|
|
280
280
|
if (isEnvelope) {
|
|
281
281
|
// COMPONENT envelope (templates have no envelope — their manifest is it).
|
|
282
282
|
for (const req of ["kind", "name", "root"])
|
|
283
|
-
if (json[req] === undefined) report("error", file, `envelope missing "${req}" (docs/design/
|
|
283
|
+
if (json[req] === undefined) report("error", file, `envelope missing "${req}" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
284
284
|
if (json.kind && !["component", "template", "atom"].includes(json.kind))
|
|
285
285
|
report("error", file, `unknown kind "${json.kind}"`);
|
|
286
286
|
if (json.kind === "component" && !json.category)
|
|
287
|
-
report("warn", file, `component has no "category". Used to group it in the palette (docs/design/
|
|
287
|
+
report("warn", file, `component has no "category". Used to group it in the palette (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
288
288
|
if (json.root) walkNode(json.root, file, root);
|
|
289
289
|
|
|
290
290
|
// ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──
|
|
@@ -300,7 +300,7 @@ function lintFile(file) {
|
|
|
300
300
|
if (defPath(root, "manifest"))
|
|
301
301
|
for (const k of ["description", "whenToUse"])
|
|
302
302
|
if (json[k] !== undefined)
|
|
303
|
-
report("error", file, `envelope duplicates manifest meta "${k}". The discovery manifest is the single home (docs/design/
|
|
303
|
+
report("error", file, `envelope duplicates manifest meta "${k}". The discovery manifest is the single home (docs.unoverse.ai/design/components)`);
|
|
304
304
|
|
|
305
305
|
// deprecated bridge: a top-level `defaultState` triggers the component node APP_DATA emit
|
|
306
306
|
if (json.defaultState !== undefined)
|
|
@@ -317,7 +317,7 @@ function lintFile(file) {
|
|
|
317
317
|
.filter(([, v]) => !(v && typeof v === "object" && typeof v.input === "boolean"))
|
|
318
318
|
.map(([k]) => k);
|
|
319
319
|
if (undeclared.length)
|
|
320
|
-
report("error", file, `microapp props [${undeclared.join(", ")}] do not declare "input". Every prop says what fills it: input:true = a workflow streams it (it joins the node's configSchema); input:false = nothing does (a preview axis, or a literal the host passes via Ref with). Unflagged props default to INPUTS (docs/design/
|
|
320
|
+
report("error", file, `microapp props [${undeclared.join(", ")}] do not declare "input". Every prop says what fills it: input:true = a workflow streams it (it joins the node's configSchema); input:false = nothing does (a preview axis, or a literal the host passes via Ref with). Unflagged props default to INPUTS (docs.unoverse.ai/design/components)`);
|
|
321
321
|
|
|
322
322
|
// STATE MODEL v2 (UNOVERSE_STATE_MODEL §5): an authored `state.view` TREE is
|
|
323
323
|
// the component's state machine — the ONE object the scalar rule admits.
|
|
@@ -344,9 +344,9 @@ function lintFile(file) {
|
|
|
344
344
|
for (const [k, v] of Object.entries(json.state)) {
|
|
345
345
|
if (k === "view" && viewTree) continue;
|
|
346
346
|
if (Array.isArray(v) || (v && typeof v === "object"))
|
|
347
|
-
report("error", file, `state.${k} is an ${Array.isArray(v) ? "array" : "object"}. The state block is SCALAR view-state only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs/design/
|
|
347
|
+
report("error", file, `state.${k} is an ${Array.isArray(v) ? "array" : "object"}. The state block is SCALAR view-state only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs.unoverse.ai/design/components)`);
|
|
348
348
|
else if (typeof v === "string" && /^https?:\/\//.test(v))
|
|
349
|
-
report("error", file, `state.${k} is a URL. Content, not view-state; hardcode it in the layout (or props input:true if workflow-fed) (docs/design/
|
|
349
|
+
report("error", file, `state.${k} is a URL. Content, not view-state; hardcode it in the layout (or props input:true if workflow-fed) (docs.unoverse.ai/design/components)`);
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
if (hasLayouts) {
|
|
@@ -356,7 +356,7 @@ function lintFile(file) {
|
|
|
356
356
|
if (!raw.includes('"on":"view"') || !/"\$include":"layouts\//.test(raw))
|
|
357
357
|
report("error", file, `a v2 component (state.view tree) must root-Switch on "view" → $include layouts/<layout> (each state owns its layout; same-name by convention) (STATE_MODEL §5)`);
|
|
358
358
|
} else if (!raw.includes('"on":"defaultState"') || !/"\$include":"layouts\//.test(raw))
|
|
359
|
-
report("error", file, `a faced component's root must Switch on defaultState → $include layouts/<state> (legacy; or declare a v2 state.view tree and Switch on "view") (docs/design/
|
|
359
|
+
report("error", file, `a faced component's root must Switch on defaultState → $include layouts/<state> (legacy; or declare a v2 state.view tree and Switch on "view") (docs.unoverse.ai/design/components)`);
|
|
360
360
|
|
|
361
361
|
// ── face set ⇄ layouts/ cross-check (OPEN name set — inline/focused/<any>) ──
|
|
362
362
|
// The FACES are the root Switch's cases; Studio's face toggle and the render
|
|
@@ -375,7 +375,7 @@ function lintFile(file) {
|
|
|
375
375
|
const caseNames = Object.keys(cases).filter((k) => k !== "default");
|
|
376
376
|
// inline is the UNIVERSAL default face: an unknown/absent defaultState must
|
|
377
377
|
// render SOMETHING — require an `inline` case or an explicit `default`.
|
|
378
|
-
// EXCEPTION — a SURFACE-ONLY component (docs/design/
|
|
378
|
+
// EXCEPTION — a SURFACE-ONLY component (docs.unoverse.ai/design/components): its manifest
|
|
379
379
|
// declares a SURFACED arrival (defaultState naming one of its cases, not
|
|
380
380
|
// inline), and it deliberately renders NOTHING while unsurfaced (e.g. a
|
|
381
381
|
// rail card retired by a new turn). Then omitting inline/default is the
|
|
@@ -409,7 +409,7 @@ function lintFile(file) {
|
|
|
409
409
|
if (name !== "default" && layoutName !== expected)
|
|
410
410
|
report("error", file, viewTree
|
|
411
411
|
? `state "${name}" declares layout "${expected}" but its case includes layouts/${layoutName} — the tree's declaration and the case must agree (STATE_MODEL §5 rule 1)`
|
|
412
|
-
: `face case "${name}" includes layouts/${layoutName}. The layout FILENAME is the state name; rename one so they match (docs/design/
|
|
412
|
+
: `face case "${name}" includes layouts/${layoutName}. The layout FILENAME is the state name; rename one so they match (docs.unoverse.ai/design/components)`);
|
|
413
413
|
}
|
|
414
414
|
// v2: tree-declared layouts (including nested substates' and variants') are
|
|
415
415
|
// reachable by declaration — seed them so the orphan check knows them.
|
|
@@ -457,7 +457,7 @@ function lintFile(file) {
|
|
|
457
457
|
for (const lf of readdirSync(layoutsDir).filter(isDefFile)) {
|
|
458
458
|
const lname = defName(lf);
|
|
459
459
|
if (!usedLayouts.has(lname))
|
|
460
|
-
report("warn", file, `layouts/${lf} is not referenced by any Switch case. An orphan face is unreachable (add a case "${lname}" or delete the file) (docs/design/
|
|
460
|
+
report("warn", file, `layouts/${lf} is not referenced by any Switch case. An orphan face is unreachable (add a case "${lname}" or delete the file) (docs.unoverse.ai/design/components)`);
|
|
461
461
|
}
|
|
462
462
|
}
|
|
463
463
|
// v2 STRAGGLER NUDGE: inside a component that declares a tree, every
|
|
@@ -486,14 +486,14 @@ function lintFile(file) {
|
|
|
486
486
|
? (viewTree.initial ?? Object.keys(viewTree.states)[0])
|
|
487
487
|
: (mDefault ?? (hasStateBlock ? json.state.defaultState : undefined));
|
|
488
488
|
if (typeof arrival !== "string")
|
|
489
|
-
report("error", file, `a faced component must declare its base state — a v2 state.view tree \`initial\`, or (legacy) manifest.defaultState / state.defaultState (docs/design/
|
|
489
|
+
report("error", file, `a faced component must declare its base state — a v2 state.view tree \`initial\`, or (legacy) manifest.defaultState / state.defaultState (docs.unoverse.ai/design/components)`);
|
|
490
490
|
}
|
|
491
491
|
if (stateFiles.length) {
|
|
492
492
|
const order = Array.isArray(json.stateOrder) ? [...json.stateOrder].sort() : null;
|
|
493
493
|
if (!order || !order.length)
|
|
494
|
-
report("error", file, `has states/ but no stateOrder in the envelope (docs/design/
|
|
494
|
+
report("error", file, `has states/ but no stateOrder in the envelope (docs.unoverse.ai/design/components)`);
|
|
495
495
|
else if (JSON.stringify(order) !== JSON.stringify(stateFiles))
|
|
496
|
-
report("error", file, `stateOrder and states/*.json must name the same set (docs/design/
|
|
496
|
+
report("error", file, `stateOrder and states/*.json must name the same set (docs.unoverse.ai/design/components)`);
|
|
497
497
|
}
|
|
498
498
|
}
|
|
499
499
|
} else {
|
|
@@ -502,7 +502,7 @@ function lintFile(file) {
|
|
|
502
502
|
} else {
|
|
503
503
|
// bare partial (layouts/ states/ components/ blocks/, or an atom). A template
|
|
504
504
|
// layout's TOP-LEVEL node is the app's layout root — the one non-slot home for
|
|
505
|
-
// `appWidth` (state-owned sizing, docs/design/
|
|
505
|
+
// `appWidth` (state-owned sizing, docs.unoverse.ai/design/apps).
|
|
506
506
|
walkNode(json, file, root, null, /[\\/]layouts[\\/][^\\/]+\.(json|yaml)$/.test(file) && isAppPath(file));
|
|
507
507
|
}
|
|
508
508
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The design definitions linter, as a LIBRARY. Rules in, findings out.
|
|
3
3
|
*
|
|
4
4
|
* Sibling of ../nodes/ and deliberately SEPARATE: this one knows the closed primitive set,
|
|
5
5
|
* the closed style vocabulary, token-only values and Switch discriminants. Merging the two
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `yaml` in. Worth keeping now that it sits beside a package that does have dependencies.
|
|
10
10
|
*
|
|
11
11
|
* THE WHOLE BODY IS INSIDE `lintDefinitions`, on purpose. It was a script whose top-level
|
|
12
|
-
* constants derived from the
|
|
12
|
+
* constants derived from the design root, and two bare blocks that RAN RULES at import time.
|
|
13
13
|
* As a library that is wrong twice: findings would accumulate between runs, and rules would
|
|
14
14
|
* fire before anyone asked. Function scope makes both impossible rather than merely unlikely.
|
|
15
15
|
*
|
|
@@ -26,7 +26,7 @@ import { makeTokensForFile } from "./tokens.mjs";
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Lint every definition under `designRoot`. Prints nothing, exits nothing.
|
|
29
|
-
* A missing
|
|
29
|
+
* A missing design/ folder is REPORTED rather than thrown, so callers get one shape of answer.
|
|
30
30
|
*
|
|
31
31
|
* `options.overlay` maps an absolute path to text that STANDS IN for what is on disk.
|
|
32
32
|
* Studio's editor lints what the developer has typed before it is saved, and the only
|
|
@@ -44,19 +44,19 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
44
44
|
};
|
|
45
45
|
const candidates = designRoot ? [resolve(designRoot)] : [resolve("apps/unoverse/design"), resolve("design")];
|
|
46
46
|
|
|
47
|
-
/** The monorepo's shapes: the design system at `
|
|
47
|
+
/** The monorepo's shapes: the design system at `design/marketplace/`, or the legacy one
|
|
48
48
|
* with `components/` and `atoms/` loose at the root. */
|
|
49
49
|
const holdsDesignSystem = (p) =>
|
|
50
50
|
existsSync(join(p, "marketplace")) || existsSync(join(p, "components")) || existsSync(join(p, "atoms"));
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* A DEVELOPER'S
|
|
54
|
-
* finds nothing. `
|
|
53
|
+
* A DEVELOPER'S design/ HOLDS ONLY ORG FOLDERS, and looking for the monorepo's shapes there
|
|
54
|
+
* finds nothing. `design/<org>/{components,styles,apps}` is what Studio scaffolds and
|
|
55
55
|
* the only layout a developer ever has, because the design system is INSTALLED rather
|
|
56
|
-
* than authored (sync-starter.sh keeps `
|
|
56
|
+
* than authored (sync-starter.sh keeps `design/marketplace` out of a project on purpose).
|
|
57
57
|
*
|
|
58
58
|
* So a project could not be linted, and since publishing lints first, it could not be
|
|
59
|
-
* published either: "no
|
|
59
|
+
* published either: "no design/ folder here", naming the folder it was standing in.
|
|
60
60
|
*/
|
|
61
61
|
const holdsOrgs = (p) => {
|
|
62
62
|
if (!existsSync(p)) return false;
|
|
@@ -75,36 +75,36 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
75
75
|
});
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
78
|
+
const DESIGN_ROOT = candidates.find((p) => holdsDesignSystem(p) || holdsOrgs(p));
|
|
79
|
+
if (!DESIGN_ROOT)
|
|
80
80
|
return {
|
|
81
|
-
problems: [{ level: "error", file: candidates[0], msg: `no
|
|
81
|
+
problems: [{ level: "error", file: candidates[0], msg: `no design/ folder here (looked in: ${candidates.join(", ")})` }],
|
|
82
82
|
homes: [],
|
|
83
83
|
};
|
|
84
84
|
|
|
85
85
|
// ── tree layout ──
|
|
86
|
-
// New layout:
|
|
87
|
-
// org (
|
|
86
|
+
// New layout: design/marketplace/{atoms,components,styles} + one top-level folder per
|
|
87
|
+
// org (design/<org>/). Legacy layout: components/ + atoms/ at the root, orgs under design/orgs/.
|
|
88
88
|
// The DESIGN SYSTEM is the primary lint target; org folders get the SAME generic
|
|
89
89
|
// checks — nothing here may key on a specific org's name.
|
|
90
|
-
// WHERE THE DESIGN SYSTEM ACTUALLY IS. `
|
|
90
|
+
// WHERE THE DESIGN SYSTEM ACTUALLY IS. `design/marketplace/` exists in this monorepo and is
|
|
91
91
|
// deliberately absent from a developer's project (sync-starter.sh): the platform installs
|
|
92
92
|
// @unoverse-platform/marketplace, whose `definitions/` bundle carries components, atoms
|
|
93
93
|
// AND styles (bundle-defs.mjs). All three matter — the space-scale check reads
|
|
94
94
|
// styles/base/spacing, so a fallback finding only components would build a PARTIAL scale
|
|
95
95
|
// and reject valid steps, reporting false errors on correct work. Mirrors definitions.ts.
|
|
96
96
|
const DS = (() => {
|
|
97
|
-
const onDisk = join(
|
|
97
|
+
const onDisk = join(DESIGN_ROOT, "marketplace");
|
|
98
98
|
if (existsSync(onDisk)) return onDisk;
|
|
99
|
-
const nodesHome = resolve(
|
|
99
|
+
const nodesHome = resolve(DESIGN_ROOT, "..", "nodes");
|
|
100
100
|
for (const c of [
|
|
101
101
|
join(nodesHome, "marketplace", "definitions"),
|
|
102
|
-
join(resolve(
|
|
102
|
+
join(resolve(DESIGN_ROOT, "..", "plugins"), "node_modules", "@unoverse-platform", "marketplace", "definitions"),
|
|
103
103
|
])
|
|
104
104
|
if (existsSync(c)) return c;
|
|
105
|
-
return
|
|
105
|
+
return DESIGN_ROOT; // none anywhere: shared refs will not resolve, and the findings will say so
|
|
106
106
|
})();
|
|
107
|
-
const legacyOrgsDir = join(
|
|
107
|
+
const legacyOrgsDir = join(DESIGN_ROOT, "orgs");
|
|
108
108
|
const orgDirs = (() => {
|
|
109
109
|
if (existsSync(legacyOrgsDir))
|
|
110
110
|
return readdirSync(legacyOrgsDir)
|
|
@@ -112,16 +112,16 @@ const orgDirs = (() => {
|
|
|
112
112
|
.map((e) => join(legacyOrgsDir, e))
|
|
113
113
|
.filter((d) => statSync(d).isDirectory());
|
|
114
114
|
// THE LEGACY LAYOUT, where the design system IS the root: `components/` and `atoms/`
|
|
115
|
-
// sit directly in
|
|
115
|
+
// sit directly in design/, so listing children here would lint "components" as an org.
|
|
116
116
|
//
|
|
117
|
-
// NOT `DS ===
|
|
118
|
-
// guaranteed. DS falls back to
|
|
117
|
+
// NOT `DS === DESIGN_ROOT`, which was the same test until the design system stopped being
|
|
118
|
+
// guaranteed. DS falls back to DESIGN_ROOT when no design system is found ANYWHERE, which is the
|
|
119
119
|
// ordinary state of a developer's project — so that test read "this is the legacy
|
|
120
120
|
// layout" and returned no orgs, silently linting nothing at all.
|
|
121
|
-
if (existsSync(join(
|
|
122
|
-
return readdirSync(
|
|
121
|
+
if (existsSync(join(DESIGN_ROOT, "components")) || existsSync(join(DESIGN_ROOT, "atoms"))) return [];
|
|
122
|
+
return readdirSync(DESIGN_ROOT)
|
|
123
123
|
.filter((e) => !e.startsWith(".") && e !== "marketplace" && e !== "_schema")
|
|
124
|
-
.map((e) => join(
|
|
124
|
+
.map((e) => join(DESIGN_ROOT, e))
|
|
125
125
|
.filter((d) => statSync(d).isDirectory());
|
|
126
126
|
})();
|
|
127
127
|
|
|
@@ -290,7 +290,7 @@ const checkDimension = (file, where, key, v) => {
|
|
|
290
290
|
// or the scale drifts straight back to two vocabularies for the same number — which is
|
|
291
291
|
// the state the t-shirt aliases left it in, and the reason they were retired.
|
|
292
292
|
if (PAGE_WIDTH_KEYS.has(key) && layoutAlias.has(val))
|
|
293
|
-
report("error", file, `${where}.${key}: "${val}" has a name — use "${layoutAlias.get(val)}". A page-level width reads as what it IS; a bare step here puts two spellings on one value (docs/design/
|
|
293
|
+
report("error", file, `${where}.${key}: "${val}" has a name — use "${layoutAlias.get(val)}". A page-level width reads as what it IS; a bare step here puts two spellings on one value (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
294
294
|
// A shorthand ("auto auto 0 0" on `inset`) is a list of dimensions; each word is one.
|
|
295
295
|
for (const word of val.trim().split(/\s+/)) {
|
|
296
296
|
if (spaceSteps.has(word) || DIMENSION_LITERALS.has(word)) continue;
|
|
@@ -298,7 +298,7 @@ const checkDimension = (file, where, key, v) => {
|
|
|
298
298
|
// percentage value; LAW 1 owns those, and `calc()`/`%` are legitimate escape hatches.
|
|
299
299
|
if (/^\d/.test(word) && !/^\d+(\.\d+)?$/.test(word)) continue;
|
|
300
300
|
if (/[()%]/.test(word)) continue; // calc(), min(), clamp(), 50%
|
|
301
|
-
report("error", file, `${where}.${key}: "${word}" is not a step on the space scale. Invalid values fall through as broken CSS (auto sizing). Real steps: ${stepList()} (docs/design/
|
|
301
|
+
report("error", file, `${where}.${key}: "${word}" is not a step on the space scale. Invalid values fall through as broken CSS (auto sizing). Real steps: ${stepList()} (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
};
|
|
@@ -329,7 +329,7 @@ const checkToken = (file, where, key, v) => {
|
|
|
329
329
|
report(
|
|
330
330
|
"error",
|
|
331
331
|
file,
|
|
332
|
-
`${where}.${k}: "${val}" is not a ${bucket} token. Unknown names are handed to CSS verbatim and dropped — the style simply does not apply, with no error anywhere${extra}. Known: ${[...T[bucket]].sort().join(", ") || "none"} (docs/design/
|
|
332
|
+
`${where}.${k}: "${val}" is not a ${bucket} token. Unknown names are handed to CSS verbatim and dropped — the style simply does not apply, with no error anywhere${extra}. Known: ${[...T[bucket]].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`,
|
|
333
333
|
);
|
|
334
334
|
|
|
335
335
|
// The straightforward one-bucket keys (background/color/shadow/radius*/font/lineHeight).
|
|
@@ -354,9 +354,9 @@ const checkToken = (file, where, key, v) => {
|
|
|
354
354
|
const parts = v.trim().split(/\s+/);
|
|
355
355
|
const [w, c] = parts.length > 1 ? parts : [null, parts[0]];
|
|
356
356
|
if (w !== null && !T.borderWidth.has(w))
|
|
357
|
-
report("error", file, `${where}.${key}: "${w}" is not a border-width token (the leading word of "<width> <colour>"). Known: ${[...T.borderWidth].sort().join(", ") || "none"} (docs/design/
|
|
357
|
+
report("error", file, `${where}.${key}: "${w}" is not a border-width token (the leading word of "<width> <colour>"). Known: ${[...T.borderWidth].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
358
358
|
if (!LITERAL_VALUES.has(c) && !T.color.has(`border.${c}`) && !T.color.has(c))
|
|
359
|
-
report("error", file, `${where}.${key}: "${c}" resolves to no colour token (tried border.${c}, then ${c}). The border renders with no colour and nothing reports it (docs/design/
|
|
359
|
+
report("error", file, `${where}.${key}: "${c}" resolves to no colour token (tried border.${c}, then ${c}). The border renders with no colour and nothing reports it (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
360
360
|
return;
|
|
361
361
|
}
|
|
362
362
|
|
|
@@ -407,7 +407,7 @@ const dsComponentNames = new Set(
|
|
|
407
407
|
.filter((e) => !e.name.startsWith("."))
|
|
408
408
|
.map((e) => (e.isDirectory() ? e.name : defName(e.name)).toLowerCase()),
|
|
409
409
|
);
|
|
410
|
-
// The ORG tier's component names, per org (docs
|
|
410
|
+
// The ORG tier's component names, per org (docs.unoverse.ai/design/components):
|
|
411
411
|
// a Ref may also resolve an org component — bare from inside that org's own tree (the
|
|
412
412
|
// resolver's context rule), or org-qualified as `<org>/<name>` when two orgs share the
|
|
413
413
|
// name. Org-privacy holds: a file only ever reaches its OWN org's components, and a
|
|
@@ -449,7 +449,7 @@ const refResolves = (ref, file) => {
|
|
|
449
449
|
*
|
|
450
450
|
* Ref lookup above is case-insensitive on purpose, and the marketplace is not: an item is
|
|
451
451
|
* fetched as `items/<kind>/<key>.json` over HTTP, off a case-sensitive host. So a name that
|
|
452
|
-
* disagrees with itself resolves forever in `
|
|
452
|
+
* disagrees with itself resolves forever in `design/` and 404s the moment anyone installs it.
|
|
453
453
|
*
|
|
454
454
|
* That is not hypothetical. 2026-08-06: twelve atoms were unreachable from every universe
|
|
455
455
|
* because git held `Avatar.json` while the build wrote `avatar.json`. macOS is
|
|
@@ -457,7 +457,7 @@ const refResolves = (ref, file) => {
|
|
|
457
457
|
* served what git held. The error surfaced as `could not fetch atom/avatar (HTTP 404)` in
|
|
458
458
|
* the Installed view, months after the cause.
|
|
459
459
|
*
|
|
460
|
-
* Nothing in `
|
|
460
|
+
* Nothing in `design/` could have caught it, because `design/` was correct. What was missing was a
|
|
461
461
|
* rule that the key agrees with itself EXACTLY, which is what this checks:
|
|
462
462
|
*
|
|
463
463
|
* filename === `name:` === every Ref that points at it
|
|
@@ -494,7 +494,7 @@ const canonicalRef = (ref) => canonicalKeys.get(ref.toLowerCase()) ?? null;
|
|
|
494
494
|
* That silence has cost real screens. `form-toggle` declares `on` and `description`; a
|
|
495
495
|
* form wrote `props: { value: … }` and `with: { help: … }`, so the switch bound to nothing
|
|
496
496
|
* and the sub-line simply never appeared. Everything rendered, nothing was wrong to look
|
|
497
|
-
* at, and the control did not move. Nothing in
|
|
497
|
+
* at, and the control did not move. Nothing in design/ could catch it because every node was
|
|
498
498
|
* structurally valid.
|
|
499
499
|
*
|
|
500
500
|
* So the keys are checked against the atom's own `props` block. Read lazily and cached:
|
|
@@ -576,7 +576,7 @@ for (const orgDir of orgDirs) {
|
|
|
576
576
|
|
|
577
577
|
// ── component-name uniqueness (per tier, no marketplace shadowing) ──
|
|
578
578
|
// Names are unique WITHIN a home: two orgs may ship the same name (each addressed
|
|
579
|
-
// `unoverse://components/<org>/<name>` — docs
|
|
579
|
+
// `unoverse://components/<org>/<name>` — docs.unoverse.ai/design/components),
|
|
580
580
|
// but an org may never SHADOW a marketplace name, so a bare ref stays unambiguous:
|
|
581
581
|
// bare = the marketplace tier, or the single org that carries the name.
|
|
582
582
|
{
|
|
@@ -591,7 +591,7 @@ for (const orgDir of orgDirs) {
|
|
|
591
591
|
const name = (statSync(p).isDirectory() ? e : isDefFile(e) ? defName(e) : null)?.toLowerCase();
|
|
592
592
|
if (!name) continue;
|
|
593
593
|
if (out.has(name))
|
|
594
|
-
report("error", p, `component name "${name}" already exists at ${relative(
|
|
594
|
+
report("error", p, `component name "${name}" already exists at ${relative(DESIGN_ROOT, out.get(name))}. Names are UNIQUE within a home; rename one`);
|
|
595
595
|
else out.set(name, p);
|
|
596
596
|
}
|
|
597
597
|
return out;
|
|
@@ -601,7 +601,7 @@ for (const orgDir of orgDirs) {
|
|
|
601
601
|
for (const [name, p] of namesIn(join(orgDir, "components"))) {
|
|
602
602
|
const shadowed = marketplaceNames.get(name);
|
|
603
603
|
if (shadowed)
|
|
604
|
-
report("error", p, `component name "${name}" shadows the marketplace component at ${relative(
|
|
604
|
+
report("error", p, `component name "${name}" shadows the marketplace component at ${relative(DESIGN_ROOT, shadowed)}. An org may never shadow a marketplace name (bare refs must stay unambiguous); rename the org component`);
|
|
605
605
|
}
|
|
606
606
|
}
|
|
607
607
|
}
|
|
@@ -627,7 +627,7 @@ for (const orgDir of orgDirs) {
|
|
|
627
627
|
report(
|
|
628
628
|
"error",
|
|
629
629
|
mf,
|
|
630
|
-
`org "${org}" has ${defaults.length} apps with "default": true. An org has exactly ONE default app (its /mcp/${org} front door). Keep it on one manifest, remove it from the others (docs
|
|
630
|
+
`org "${org}" has ${defaults.length} apps with "default": true. An org has exactly ONE default app (its /mcp/${org} front door). Keep it on one manifest, remove it from the others (docs.unoverse.ai/design/apps §4b)`,
|
|
631
631
|
);
|
|
632
632
|
}
|
|
633
633
|
|
|
@@ -635,7 +635,7 @@ for (const orgDir of orgDirs) {
|
|
|
635
635
|
// A component may run server-side code at a platform fire point, and the safety of that
|
|
636
636
|
// rests on the manifest and the thing that runs agreeing. Both halves are checked here so
|
|
637
637
|
// a bad declaration is caught in the terminal and at publish, not by silence at run time.
|
|
638
|
-
// Mirrors server/tests/
|
|
638
|
+
// Mirrors apps/unoverse/server/tests/design/lifecycle-declaration.test.ts (UNOVERSE_AUTHORING.md §3c).
|
|
639
639
|
const KNOWN_LIFECYCLES = new Set(["onStart", "onEnterView"]);
|
|
640
640
|
const PHASES_WITH_LAYOUTS = new Set(["onEnterView"]); // phases that fire per VIEW
|
|
641
641
|
const PLATFORM_HANDLERS = new Set(["getDetail"]); // named handlers needing no file
|
|
@@ -648,7 +648,7 @@ const LATCH_KEYS = new Set(["title", "background", "color"]);
|
|
|
648
648
|
// silently disable every rule after it.
|
|
649
649
|
const credentialDefs = new Set();
|
|
650
650
|
{
|
|
651
|
-
const nodesHome = resolve(
|
|
651
|
+
const nodesHome = resolve(DESIGN_ROOT, "..", "nodes");
|
|
652
652
|
try {
|
|
653
653
|
for (const pkg of readdirSync(nodesHome)) {
|
|
654
654
|
try {
|
|
@@ -658,7 +658,7 @@ const credentialDefs = new Set();
|
|
|
658
658
|
}
|
|
659
659
|
}
|
|
660
660
|
} catch {
|
|
661
|
-
/* no nodes tree beside
|
|
661
|
+
/* no nodes tree beside design/: the rule cannot judge, so it stays quiet */
|
|
662
662
|
}
|
|
663
663
|
}
|
|
664
664
|
|
|
@@ -690,7 +690,7 @@ for (const orgDir of [DS, ...orgDirs]) {
|
|
|
690
690
|
|
|
691
691
|
for (const e of entries) {
|
|
692
692
|
if (!KNOWN_LIFECYCLES.has(e.phase))
|
|
693
|
-
report("error", mf, `lifecycle "${e.phase}" is not a phase the platform fires, so it would never run. Known: ${[...KNOWN_LIFECYCLES].join(", ")} (docs/unoverse/UNOVERSE_AUTHORING
|
|
693
|
+
report("error", mf, `lifecycle "${e.phase}" is not a phase the platform fires, so it would never run. Known: ${[...KNOWN_LIFECYCLES].join(", ")} (docs.unoverse.ai/unoverse/UNOVERSE_AUTHORING §3c)`);
|
|
694
694
|
if (e.layouts !== undefined && !PHASES_WITH_LAYOUTS.has(e.phase))
|
|
695
695
|
report("error", mf, `lifecycle "${e.phase}" declares layouts, but only ${[...PHASES_WITH_LAYOUTS].join(", ")} fires per view — the scope would be ignored`);
|
|
696
696
|
// WHAT RUNS is named by `handler`; the phase only says WHEN. A custom hook is a
|
|
@@ -759,7 +759,7 @@ for (const orgDir of [DS, ...orgDirs]) {
|
|
|
759
759
|
if (T) {
|
|
760
760
|
for (const k of ["background", "color"])
|
|
761
761
|
if (typeof latch[k] === "string" && latch[k].trim() && !T.color.has(latch[k]))
|
|
762
|
-
report("error", mf, `latch.${k}: "${latch[k]}" is not a colour token. Unknown names reach CSS verbatim and are dropped, so the pill renders unstyled with no error anywhere. Known: ${[...T.color].sort().join(", ") || "none"} (docs/design/
|
|
762
|
+
report("error", mf, `latch.${k}: "${latch[k]}" is not a colour token. Unknown names reach CSS verbatim and are dropped, so the pill renders unstyled with no error anywhere. Known: ${[...T.color].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
/**
|
|
@@ -791,13 +791,13 @@ function checkCondition(vw, file, where) {
|
|
|
791
791
|
if (typeof vw === "string") return; // bare truthy field
|
|
792
792
|
if (vw && typeof vw === "object" && !Array.isArray(vw)) {
|
|
793
793
|
if (typeof vw.field !== "string")
|
|
794
|
-
report("error", file, `${where}: condition needs a "field" (docs/design/
|
|
794
|
+
report("error", file, `${where}: condition needs a "field" (docs.unoverse.ai/design/state)`);
|
|
795
795
|
const extra = Object.keys(vw).filter((k) => !CONDITION_KEYS.has(k));
|
|
796
796
|
if (extra.length)
|
|
797
|
-
report("error", file, `${where}: illegal condition key(s) ${extra.join(", ")}. Only eq/ne/in/truthy exist; no and/or/arithmetic (derive in the node) (docs/design/
|
|
797
|
+
report("error", file, `${where}: illegal condition key(s) ${extra.join(", ")}. Only eq/ne/in/truthy exist; no and/or/arithmetic (derive in the node) (docs.unoverse.ai/design/components)`);
|
|
798
798
|
return;
|
|
799
799
|
}
|
|
800
|
-
report("error", file, `${where}: visibleWhen must be a field name or { field, eq|ne|in } (docs/design/
|
|
800
|
+
report("error", file, `${where}: visibleWhen must be a field name or { field, eq|ne|in } (docs.unoverse.ai/design/state)`);
|
|
801
801
|
}
|
|
802
802
|
|
|
803
803
|
// ── per-node structural walk ──
|
|
@@ -847,8 +847,8 @@ function appSizesForFile(file) {
|
|
|
847
847
|
return appSizesCache.get(home);
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
-
// The universal component names (
|
|
851
|
-
//
|
|
850
|
+
// The universal component names (design/marketplace/components/*, case-insensitive) — for
|
|
851
|
+
// validating an app manifest's `preview` map. null = the file is not under a design tree.
|
|
852
852
|
const componentNamesCache = new Map();
|
|
853
853
|
// Components an ORG's template may reference: the marketplace tier + that org's OWN
|
|
854
854
|
// components — never another org's (org-privacy). Cached per org.
|
|
@@ -873,7 +873,7 @@ function componentNamesForFile(file) {
|
|
|
873
873
|
// The run context: everything the extracted rule modules close over. Built here so it
|
|
874
874
|
// cannot outlive the run, and passed once rather than threaded as ten parameters.
|
|
875
875
|
const ctx = {
|
|
876
|
-
|
|
876
|
+
DESIGN_ROOT, DS, orgDirs, report, spaceSteps, stepList, checkDimension, checkToken, checkCondition,
|
|
877
877
|
appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists,
|
|
878
878
|
isFixture, isHook, isManifest, isAppPath, defRoot, readText,
|
|
879
879
|
};
|
|
@@ -881,7 +881,7 @@ function componentNamesForFile(file) {
|
|
|
881
881
|
const lintFile = makeLintFile({ ...ctx, walkNode });
|
|
882
882
|
|
|
883
883
|
for (const home of homes) for (const f of jsonFiles(home.dir)) lintFile(f);
|
|
884
|
-
return { problems, homes, designRoot:
|
|
884
|
+
return { problems, homes, designRoot: DESIGN_ROOT, designSystem: DS };
|
|
885
885
|
}
|
|
886
886
|
|
|
887
887
|
/** True when anything would fail a build. Warnings and hints inform, errors stop. */
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* the marketplace foundation's `base` + `semantic` first, then the org's ON TOP, per token
|
|
19
19
|
* — an org omits a token to inherit it. Themes are UNIONED rather than resolved one at a
|
|
20
20
|
* time, because a name that resolves under `dark` is legitimate work; per-theme parity is
|
|
21
|
-
* a different rule with its own guard (server/tests/
|
|
21
|
+
* a different rule with its own guard (apps/unoverse/server/tests/design/theme-contract.test.ts).
|
|
22
22
|
*
|
|
23
23
|
* UNREADABLE IS NOT EMPTY. A developer's project has no design system on disk — it is
|
|
24
24
|
* installed, not authored — so the foundation half cannot be read and only the org's own
|
|
@@ -88,7 +88,7 @@ function buildTokens(dsStyles, homeStyles) {
|
|
|
88
88
|
*
|
|
89
89
|
* - across a home's own themes, because a name only `dark` defines is real work, not a
|
|
90
90
|
* typo (whether every theme defines every name is a DIFFERENT rule, guarded by
|
|
91
|
-
* server/tests/
|
|
91
|
+
* apps/unoverse/server/tests/design/theme-contract.test.ts — this one must not double as a weaker copy);
|
|
92
92
|
* - the foundation's underneath, because the colour contract is the shared one every
|
|
93
93
|
* org is written against. An org that has no themes folder of its own would otherwise
|
|
94
94
|
* read as "no colours exist", and the rule would report every colour it uses.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The CLOSED SETS. This file is the linter's ground truth.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors
|
|
4
|
+
* Mirrors apps/unoverse/design/_schema/unoverse.schema.json and the server guards, and UNOVERSE_CONFORMANCE
|
|
5
5
|
* §5 makes keeping them equal a maintenance rule: "One source for each closed set... Keep
|
|
6
6
|
* them equal or they disagree silently."
|
|
7
7
|
*
|
|
@@ -10,12 +10,62 @@
|
|
|
10
10
|
* without extending the schema and the SDK in the same change.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
// ── ground truth (mirrors
|
|
13
|
+
// ── ground truth (mirrors design/_schema/unoverse.schema.json + server guards) ──
|
|
14
14
|
export const PRIMITIVES = new Set([
|
|
15
15
|
"Box", "Stack", "Row", "Column", "Each", "Switch", "ComponentSlot", "Timeline",
|
|
16
16
|
"Text", "Image", "Button", "Input", "Select", "Markdown", "Skeleton", "Icon", "Ref", "Orb",
|
|
17
17
|
]);
|
|
18
18
|
export const CONDITION_KEYS = new Set(["field", "eq", "ne", "in"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* WHAT EACH PRIMITIVE IS (UNOVERSE_CONFORMANCE §2a, UNOVERSE_PRIMITIVE_CONTRACT.md).
|
|
22
|
+
*
|
|
23
|
+
* Until 2026-08-23 nothing stated this. Every node shared one bag of 36 optional properties,
|
|
24
|
+
* so `{ type: "Image", bind: { value: photo } }` saved, linted, published and drew nothing —
|
|
25
|
+
* the silent class behind "the card streams its title but keeps its mock image".
|
|
26
|
+
*
|
|
27
|
+
* Merged from three sources: `core/types.ts` JSDoc (25 of 32 properties already named their
|
|
28
|
+
* owner in prose, where no tool could read it), the renderer (the only authority on which
|
|
29
|
+
* `bind` keys are consumed), and what shipped definitions actually use — types.ts was
|
|
30
|
+
* missing nine properties in live use, because YAML never meets a TypeScript interface.
|
|
31
|
+
*
|
|
32
|
+
* `packages/sdk/conformance/tools/build-primitive-schema.mjs` reads THIS to emit the JSON
|
|
33
|
+
* Schema halves, so the editor squiggle and the publish gate cannot disagree.
|
|
34
|
+
*/
|
|
35
|
+
export const UNIVERSAL_PROPS = new Set([
|
|
36
|
+
// read for every node before dispatch; `brief` rides the tree for the composer
|
|
37
|
+
"type", "style", "visibleWhen", "bind", "action", "analytics", "skeleton", "brief", "$include",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export const PRIMITIVE_PROPS = {
|
|
41
|
+
Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth"],
|
|
42
|
+
Stack: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
43
|
+
Row: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
44
|
+
Column: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
45
|
+
Text: ["value"],
|
|
46
|
+
Image: ["src", "alt"],
|
|
47
|
+
Markdown: ["value", "inheritLinkColor"],
|
|
48
|
+
Icon: ["icon", "name"],
|
|
49
|
+
Skeleton: ["variant"],
|
|
50
|
+
Orb: ["state", "size"],
|
|
51
|
+
Button: ["children", "value", "label", "icon", "disabledWhen"],
|
|
52
|
+
Input: ["placeholder", "inputType", "disabledWhen", "maxLength", "multiline", "rows",
|
|
53
|
+
"inputMode", "autoComplete", "advanceOnFill"],
|
|
54
|
+
Select: ["placeholder", "options", "disabledWhen"],
|
|
55
|
+
Each: ["template", "app", "items"],
|
|
56
|
+
Switch: ["on", "cases", "fallback"],
|
|
57
|
+
Ref: ["ref", "props", "with", "children", "name"],
|
|
58
|
+
ComponentSlot: ["select", "frame", "fallback", "pinned", "appWidth"],
|
|
59
|
+
Timeline: ["user", "assistant", "userData", "assistantData", "autoScroll"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Which `bind` keys each primitive's view actually reads. Absent = the view reads the whole
|
|
63
|
+
* bind object (Orb), so any key is legitimate. */
|
|
64
|
+
export const PRIMITIVE_BINDS = {
|
|
65
|
+
Text: ["value"], Image: ["src", "alt"], Markdown: ["value"], Icon: ["name"],
|
|
66
|
+
Button: ["label"], Input: ["value", "placeholder"], Select: ["options", "value"],
|
|
67
|
+
Each: ["items"],
|
|
68
|
+
};
|
|
19
69
|
// the portable style vocabulary — every key the SDK interpreter maps (sdk/style.ts).
|
|
20
70
|
// Each is a neutral intent every native renderer (iOS/Android/RN/Flutter) implements;
|
|
21
71
|
// an unknown key is a typo or a web-ism that renders nowhere.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
16
16
|
import { join, dirname, basename, relative, sep } from "node:path";
|
|
17
|
-
import { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS } from "./vocabulary.mjs";
|
|
17
|
+
import { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS, UNIVERSAL_PROPS, PRIMITIVE_PROPS, PRIMITIVE_BINDS } from "./vocabulary.mjs";
|
|
18
18
|
import { isDefFile, defName, defPath, readDef } from "./defs.mjs";
|
|
19
19
|
|
|
20
20
|
export function makeWalkNode(ctx) {
|
|
@@ -29,25 +29,45 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
29
29
|
const a = defPath(root, node.$include);
|
|
30
30
|
const b = join(root, node.$include);
|
|
31
31
|
if (!a && !existsSync(b))
|
|
32
|
-
report("error", file, `$include "${node.$include}" does not resolve under ${relative(process.cwd(), root)}/ (docs/design/
|
|
32
|
+
report("error", file, `$include "${node.$include}" does not resolve under ${relative(process.cwd(), root)}/ (docs.unoverse.ai/design/components)`);
|
|
33
33
|
return; // the included file is linted on its own
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const t = node.type;
|
|
37
37
|
if (typeof t !== "string")
|
|
38
|
-
report("error", file, `node without "type" (and no $include). Every node names a primitive (docs/design/
|
|
38
|
+
report("error", file, `node without "type" (and no $include). Every node names a primitive (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
39
39
|
else if (!PRIMITIVES.has(t))
|
|
40
|
-
report("error", file, `unknown primitive "${t}". The set is closed; compose, don't invent (docs/design/
|
|
40
|
+
report("error", file, `unknown primitive "${t}". The set is closed; compose, don't invent (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
41
|
+
|
|
42
|
+
// WHAT THIS PRIMITIVE IS (UNOVERSE_CONFORMANCE §2a). Before this existed, every node
|
|
43
|
+
// shared one bag of 36 optional properties, so a wrong field name was accepted by the
|
|
44
|
+
// schema AND the lint and only showed up as a blank on screen. A wrong name is never a
|
|
45
|
+
// judgment call, so this is an error rather than a warning.
|
|
46
|
+
else if (PRIMITIVE_PROPS[t]) {
|
|
47
|
+
const own = PRIMITIVE_PROPS[t];
|
|
48
|
+
for (const k of Object.keys(node))
|
|
49
|
+
if (!UNIVERSAL_PROPS.has(k) && !own.includes(k))
|
|
50
|
+
report("error", file, `${t} has no "${k}". It reads ${own.length ? own.map((o) => `"${o}"` ).join(", ") : "no properties of its own"} (docs.unoverse.ai/reference/primitives)`);
|
|
51
|
+
|
|
52
|
+
// `bind` maps a target onto a data field, and only some targets are read. Binding a
|
|
53
|
+
// target the view never looks at is the silent failure this whole rule exists for:
|
|
54
|
+
// the field streams in correctly and the element keeps drawing its preview default.
|
|
55
|
+
const reads = PRIMITIVE_BINDS[t];
|
|
56
|
+
if (reads && node.bind && typeof node.bind === "object" && !Array.isArray(node.bind))
|
|
57
|
+
for (const k of Object.keys(node.bind))
|
|
58
|
+
if (!reads.includes(k))
|
|
59
|
+
report("error", file, `${t} never reads "bind.${k}", so the value would arrive and render nothing. It reads ${reads.map((r) => `"bind.${r}"`).join(", ")} (docs.unoverse.ai/reference/primitives)`);
|
|
60
|
+
}
|
|
41
61
|
|
|
42
62
|
if (t === "Switch") {
|
|
43
63
|
if (typeof node.on !== "string" || !node.cases || typeof node.cases !== "object")
|
|
44
|
-
report("error", file, `Switch needs "on" (the discriminant field) + "cases" (docs/design/
|
|
64
|
+
report("error", file, `Switch needs "on" (the discriminant field) + "cases" (docs.unoverse.ai/design/state)`);
|
|
45
65
|
else
|
|
46
66
|
for (const [caseKey, branch] of Object.entries(node.cases)) {
|
|
47
67
|
const vw = branch && typeof branch === "object" ? branch.visibleWhen : undefined;
|
|
48
68
|
const guarded = typeof vw === "string" ? vw : vw && typeof vw === "object" ? vw.field : null;
|
|
49
69
|
if (guarded === node.on)
|
|
50
|
-
report("error", file, `Switch on "${node.on}" → case "${caseKey}" re-guards its own discriminant. A layer never guards itself; delete the visibleWhen (docs/design/
|
|
70
|
+
report("error", file, `Switch on "${node.on}" → case "${caseKey}" re-guards its own discriminant. A layer never guards itself; delete the visibleWhen (docs.unoverse.ai/design/components)`);
|
|
51
71
|
}
|
|
52
72
|
}
|
|
53
73
|
// Each: a `template` + a list — EITHER a literal `items:[]` (hardcoded content,
|
|
@@ -55,16 +75,16 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
55
75
|
if (t === "Each") {
|
|
56
76
|
const hasList = Array.isArray(node.items) || (node.bind && typeof node.bind === "object" && node.bind.items);
|
|
57
77
|
if (!node.template || !hasList)
|
|
58
|
-
report("error", file, `Each needs "template" + a list. Literal "items": [...] or "bind": { "items": "<field>" } (docs/design/
|
|
78
|
+
report("error", file, `Each needs "template" + a list. Literal "items": [...] or "bind": { "items": "<field>" } (docs.unoverse.ai/design/components)`);
|
|
59
79
|
}
|
|
60
80
|
if (t === "Ref") {
|
|
61
81
|
if (typeof node.ref !== "string")
|
|
62
|
-
report("error", file, `Ref needs "ref": "<atom name>" (docs/design/
|
|
82
|
+
report("error", file, `Ref needs "ref": "<atom name>" (docs.unoverse.ai/design/components)`);
|
|
63
83
|
else if (atomsDirExists && !refResolves(node.ref, file))
|
|
64
84
|
report("error", file, `Ref "${node.ref}". No matching atom, shared component, or own-org component (bare, or "<org>/<name>" for the file's OWN org only — org-privacy); lookup is case-insensitive by name`);
|
|
65
85
|
// RESOLVES IS NOT ENOUGH. Ref lookup ignores case; the marketplace fetches
|
|
66
86
|
// items/<kind>/<key>.json off a case-sensitive host. A Ref that differs only in case
|
|
67
|
-
// renders forever in
|
|
87
|
+
// renders forever in design/ and 404s on install (index.mjs, canonicalRef).
|
|
68
88
|
else if (canonicalRef) {
|
|
69
89
|
const exact = canonicalRef(node.ref);
|
|
70
90
|
if (exact && exact !== node.ref)
|
|
@@ -96,7 +116,7 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
96
116
|
file,
|
|
97
117
|
`Ref "${node.ref}" ${key === "props" ? "remaps" : "passes"} "${name}", which the atom does not declare. ` +
|
|
98
118
|
`Both \`props\` and \`with\` are matched BY NAME against the atom's own props, and an unknown key is ` +
|
|
99
|
-
`silently ignored: the element renders and does nothing. ${node.ref} declares: ${known} (docs/design/
|
|
119
|
+
`silently ignored: the element renders and does nothing. ${node.ref} declares: ${known} (docs.unoverse.ai/design/components)`,
|
|
100
120
|
);
|
|
101
121
|
}
|
|
102
122
|
}
|
|
@@ -109,9 +129,9 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
109
129
|
checkToken(file, t, "icon", node.icon);
|
|
110
130
|
if (t === "ComponentSlot") {
|
|
111
131
|
if (!node.select || typeof node.select !== "object")
|
|
112
|
-
report("error", file, `ComponentSlot needs "select" ({} for the conversation flow) (docs/design/
|
|
132
|
+
report("error", file, `ComponentSlot needs "select" ({} for the conversation flow) (docs.unoverse.ai/design/apps)`);
|
|
113
133
|
else if (node.select.from === "all" && !node.select.type && !node.select.where)
|
|
114
|
-
report("warn", file, `global ComponentSlot (from:"all") with no "type" and no "where". Selects OLDEST-first; a trap in a multi-turn surface. Filter by "where" (the reaction contract, §5b) or pin "type", unless the shell is deliberately catch-all (docs/design/
|
|
134
|
+
report("warn", file, `global ComponentSlot (from:"all") with no "type" and no "where". Selects OLDEST-first; a trap in a multi-turn surface. Filter by "where" (the reaction contract, §5b) or pin "type", unless the shell is deliberately catch-all (docs.unoverse.ai/design/apps)`);
|
|
115
135
|
// STATE-SELECTED UI (STATE_MODEL §5): a reaction surface reacts to the component's
|
|
116
136
|
// PUBLIC state — the `view` axis (`defaultState` = legacy alias) — never to a
|
|
117
137
|
// component's internal state (step/phase/…) which is private to the component.
|
|
@@ -122,16 +142,16 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
122
142
|
// claim exactly ONE view by `eq` — `ne`/`in`/bare selects make "which state is
|
|
123
143
|
// the template in?" ambiguous.
|
|
124
144
|
else if (node.select.where && (node.select.where.field === "view" || node.select.where.field === "defaultState") && typeof node.select.where.eq !== "string")
|
|
125
|
-
report("error", file, `a reaction surface claims exactly ONE view: select.where needs "eq": "<view>": ne/in/bare make the template's active state ambiguous (docs/design/
|
|
145
|
+
report("error", file, `a reaction surface claims exactly ONE view: select.where needs "eq": "<view>": ne/in/bare make the template's active state ambiguous (docs.unoverse.ai/design/state)`);
|
|
126
146
|
}
|
|
127
147
|
|
|
128
|
-
// STATE-OWNED SIZING (docs/design/
|
|
148
|
+
// STATE-OWNED SIZING (docs.unoverse.ai/design/apps): every PANEL states its width once via
|
|
129
149
|
// `appWidth`; the app is the sum of the open ones. A plain node = always open (the
|
|
130
150
|
// core chat column); a reaction-surface ComponentSlot = open while occupied; a
|
|
131
151
|
// visibleWhen pane = open while its condition matches.
|
|
132
152
|
if (node.appWidth !== undefined) {
|
|
133
153
|
if (typeof node.appWidth !== "string" || node.appWidth.trim() === "")
|
|
134
|
-
report("error", file, `"appWidth" must be a CSS width string ("360px", "min(50vw, 760px)") or a named app size ("chat", "rail", "panel") (docs/design/
|
|
154
|
+
report("error", file, `"appWidth" must be a CSS width string ("360px", "min(50vw, 760px)") or a named app size ("chat", "rail", "panel") (docs.unoverse.ai/design/apps)`);
|
|
135
155
|
// `flex` is contract vocabulary, not a token: the surface takes the REMAINING
|
|
136
156
|
// host space (the SDK reports a full-width app while it is active).
|
|
137
157
|
// Any other bare name is a STANDARD SIZE — it must exist in the org's
|
|
@@ -140,17 +160,17 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
140
160
|
else if (node.appWidth !== "flex" && /^[a-z][a-z0-9-]*$/i.test(node.appWidth)) {
|
|
141
161
|
const sizes = appSizesForFile(file);
|
|
142
162
|
if (sizes && !(node.appWidth in sizes))
|
|
143
|
-
report("error", file, `"appWidth": "${node.appWidth}" names no app size. Use "flex", a CSS width, or a name from styles/semantic/app-sizes (known: ${Object.keys(sizes).join(", ") || "none"}) (docs/design/
|
|
163
|
+
report("error", file, `"appWidth": "${node.appWidth}" names no app size. Use "flex", a CSS width, or a name from styles/semantic/app-sizes (known: ${Object.keys(sizes).join(", ") || "none"}) (docs.unoverse.ai/design/apps)`);
|
|
144
164
|
}
|
|
145
165
|
if (t === "ComponentSlot" && !(node.select && node.select.where))
|
|
146
|
-
report("error", file, `"appWidth" on a ComponentSlot without select.where. Only a reaction surface can slide out; the flow slot never sizes the app (docs/design/
|
|
166
|
+
report("error", file, `"appWidth" on a ComponentSlot without select.where. Only a reaction surface can slide out; the flow slot never sizes the app (docs.unoverse.ai/design/apps)`);
|
|
147
167
|
// ONE declaration per panel: the SDK sizes the frame FROM appWidth (width +
|
|
148
168
|
// flex: 0 0 auto) — a frame width/flex alongside it is dead duplication that
|
|
149
169
|
// can silently disagree.
|
|
150
170
|
if (t === "ComponentSlot" && node.frame && node.frame.style) {
|
|
151
171
|
for (const dup of ["width", "flex"])
|
|
152
172
|
if (node.frame.style[dup] !== undefined)
|
|
153
|
-
report("error", file, `panel frame declares style.${dup} alongside appWidth. The panel states its width ONCE; the SDK sizes the frame from appWidth. Remove the frame ${dup} (docs/design/
|
|
173
|
+
report("error", file, `panel frame declares style.${dup} alongside appWidth. The panel states its width ONCE; the SDK sizes the frame from appWidth. Remove the frame ${dup} (docs.unoverse.ai/design/apps)`);
|
|
154
174
|
}
|
|
155
175
|
}
|
|
156
176
|
|
|
@@ -161,7 +181,7 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
161
181
|
if (node.brief !== undefined) {
|
|
162
182
|
const b = node.brief;
|
|
163
183
|
if (typeof b !== "string" && (typeof b !== "object" || b === null || Array.isArray(b)))
|
|
164
|
-
report("error", file, `"brief" must be a string (the description) or { description, maxLength | minItems/maxItems } (docs/design/
|
|
184
|
+
report("error", file, `"brief" must be a string (the description) or { description, maxLength | minItems/maxItems } (docs.unoverse.ai/design/components)`);
|
|
165
185
|
else if (typeof b === "object") {
|
|
166
186
|
// description/maxLength/minItems/maxItems COMPILE into the tool inputSchema. `hydrate` is a
|
|
167
187
|
// NON-schema brief annotation (a hydration hook naming what to hydrate) — a valid brief key
|
|
@@ -172,24 +192,24 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
172
192
|
const BRIEF_KEYS = new Set(["description", "maxLength", "minItems", "maxItems", "hydrate", "optional"]);
|
|
173
193
|
for (const k of Object.keys(b))
|
|
174
194
|
if (!BRIEF_KEYS.has(k))
|
|
175
|
-
report("error", file, `brief.${k} is not part of the brief contract. Only description / maxLength / minItems / maxItems / optional (schema) or hydrate (hydration hook) are allowed (docs/design/
|
|
195
|
+
report("error", file, `brief.${k} is not part of the brief contract. Only description / maxLength / minItems / maxItems / optional (schema) or hydrate (hydration hook) are allowed (docs.unoverse.ai/design/components)`);
|
|
176
196
|
if (b.description !== undefined && typeof b.description !== "string")
|
|
177
|
-
report("error", file, `brief.description must be a string. It IS the schema field's description (docs/design/
|
|
197
|
+
report("error", file, `brief.description must be a string. It IS the schema field's description (docs.unoverse.ai/design/components)`);
|
|
178
198
|
if (b.optional !== undefined && typeof b.optional !== "boolean")
|
|
179
|
-
report("error", file, `brief.optional must be a boolean (docs/design/
|
|
199
|
+
report("error", file, `brief.optional must be a boolean (docs.unoverse.ai/design/components)`);
|
|
180
200
|
if (b.hydrate !== undefined && typeof b.hydrate !== "string")
|
|
181
|
-
report("error", file, `brief.hydrate must be a string (names the field/source to hydrate) (docs/design/
|
|
201
|
+
report("error", file, `brief.hydrate must be a string (names the field/source to hydrate) (docs.unoverse.ai/design/components)`);
|
|
182
202
|
for (const nk of ["maxLength", "minItems", "maxItems"])
|
|
183
203
|
if (b[nk] !== undefined && (typeof b[nk] !== "number" || b[nk] < 0 || !Number.isInteger(b[nk])))
|
|
184
|
-
report("error", file, `brief.${nk} must be a non-negative integer. It compiles to the native JSON Schema keyword (docs/design/
|
|
204
|
+
report("error", file, `brief.${nk} must be a non-negative integer. It compiles to the native JSON Schema keyword (docs.unoverse.ai/design/components)`);
|
|
185
205
|
if (typeof b.minItems === "number" && typeof b.maxItems === "number" && b.minItems > b.maxItems)
|
|
186
|
-
report("error", file, `brief.minItems (${b.minItems}) > maxItems (${b.maxItems}). No composition can satisfy this schema (docs/design/
|
|
206
|
+
report("error", file, `brief.minItems (${b.minItems}) > maxItems (${b.maxItems}). No composition can satisfy this schema (docs.unoverse.ai/design/components)`);
|
|
187
207
|
const bound = node.bind && (node.bind.value || node.bind.src);
|
|
188
208
|
const isEach = t === "Each" && node.bind && node.bind.items;
|
|
189
209
|
if (b.maxLength !== undefined && !bound)
|
|
190
|
-
report("warn", file, `brief.maxLength on a node with no bind. A length cap only compiles when the brief sits next to the bound field it governs (docs/design/
|
|
210
|
+
report("warn", file, `brief.maxLength on a node with no bind. A length cap only compiles when the brief sits next to the bound field it governs (docs.unoverse.ai/design/components)`);
|
|
191
211
|
if ((b.minItems !== undefined || b.maxItems !== undefined) && !isEach)
|
|
192
|
-
report("warn", file, `brief.minItems/maxItems on a non-Each node. Item counts only compile on the Each that binds the array (docs/design/
|
|
212
|
+
report("warn", file, `brief.minItems/maxItems on a non-Each node. Item counts only compile on the Each that binds the array (docs.unoverse.ai/design/components)`);
|
|
193
213
|
}
|
|
194
214
|
}
|
|
195
215
|
|
|
@@ -225,12 +245,12 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
225
245
|
widthCap = widthCap == null ? Number(mw) : Math.min(widthCap, Number(mw));
|
|
226
246
|
const hb = node.style.hideBelow;
|
|
227
247
|
if (typeof hb === "string" && /^\d+$/.test(hb) && widthCap != null && Number(hb) >= widthCap)
|
|
228
|
-
report("warn", file, `hideBelow "${hb}" ≥ an ancestor maxWidth "${widthCap}". The query can only be satisfied by the surrounding surface, so visibility depends on the host, not the card; lower the threshold below the card's own max width (docs/design/
|
|
248
|
+
report("warn", file, `hideBelow "${hb}" ≥ an ancestor maxWidth "${widthCap}". The query can only be satisfied by the surrounding surface, so visibility depends on the host, not the card; lower the threshold below the card's own max width (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
229
249
|
const checkKeys = (obj, where) => {
|
|
230
250
|
for (const k of Object.keys(obj)) {
|
|
231
251
|
if (k === "when") continue; // validated below
|
|
232
252
|
if (!STYLE_KEYS.has(k))
|
|
233
|
-
report("error", file, `${where}: unknown style key "${k}". The style vocabulary is closed (the cross-platform contract). Typo, or a web-ism that won't port (docs/design/
|
|
253
|
+
report("error", file, `${where}: unknown style key "${k}". The style vocabulary is closed (the cross-platform contract). Typo, or a web-ism that won't port (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
234
254
|
else if ((k === "hover" || k === "active") && obj[k] && typeof obj[k] === "object")
|
|
235
255
|
checkKeys(obj[k], `${where}.${k}`);
|
|
236
256
|
else if (DIMENSION_KEYS.has(k)) checkDimension(file, where, k, obj[k]);
|
|
@@ -244,15 +264,15 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
244
264
|
if (node.style.when !== undefined) {
|
|
245
265
|
const w = node.style.when;
|
|
246
266
|
if (!Array.isArray(w))
|
|
247
|
-
report("error", file, `${t}.style.when must be an array of { field, eq|ne|in, apply } entries (docs/design/
|
|
267
|
+
report("error", file, `${t}.style.when must be an array of { field, eq|ne|in, apply } entries (docs.unoverse.ai/design/state)`);
|
|
248
268
|
else
|
|
249
269
|
for (const e of w) {
|
|
250
270
|
if (!e || typeof e !== "object" || typeof e.field !== "string" || !e.apply)
|
|
251
|
-
report("error", file, `${t}.style.when entry needs "field" + "apply" (docs/design/
|
|
271
|
+
report("error", file, `${t}.style.when entry needs "field" + "apply" (docs.unoverse.ai/design/state)`);
|
|
252
272
|
else {
|
|
253
273
|
const extra = Object.keys(e).filter((k) => !CONDITION_KEYS.has(k) && k !== "apply");
|
|
254
274
|
if (extra.length)
|
|
255
|
-
report("error", file, `${t}.style.when: illegal key(s) ${extra.join(", ")}. Conditions are eq/ne/in/truthy only (docs/design/
|
|
275
|
+
report("error", file, `${t}.style.when: illegal key(s) ${extra.join(", ")}. Conditions are eq/ne/in/truthy only (docs.unoverse.ai/design/state)`);
|
|
256
276
|
if (typeof e.apply === "object") checkKeys(e.apply, `${t}.style.when.apply`);
|
|
257
277
|
}
|
|
258
278
|
}
|