unoverse 0.1.166 → 0.1.168

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.166",
3
+ "version": "0.1.168",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -112,6 +112,12 @@ function lintFile(file) {
112
112
  // (layouts/<name>); every base substate needs its state file
113
113
  // (states/<name>); a name cannot be both; an authored stateOrder is
114
114
  // superseded and should be deleted.
115
+ // ONE DRAWINGS FOLDER (owner ruling 2026-08-22): templates work like
116
+ // components — every drawing lives in layouts/, the tree decides which state
117
+ // owns which. A states/ folder is the pre-unification anatomy: move its files
118
+ // into layouts/ and update $include paths.
119
+ if (existsSync(join(root, "states")))
120
+ report("error", file, `template carries a states/ folder — templates have ONE drawings folder. Move states/* into layouts/ and update every "$include: states/<name>" to "layouts/<name>" (anatomy unified with components, LAYERS §3)`);
115
121
  if (json.states !== undefined) {
116
122
  if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
117
123
  report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
@@ -124,19 +130,43 @@ function lintFile(file) {
124
130
  for (const [n, s] of Object.entries(json.states)) {
125
131
  if (s !== null && (typeof s !== "object" || Array.isArray(s)))
126
132
  report("error", file, `tree state "${n}" must be an object ({} is a complete state) (STATE_MODEL §5)`);
127
- if (n !== base && !defPath(join(root, "layouts"), n) && !defPath(join(root, "states"), n))
128
- report("error", file, `tree state "${n}" has no layouts/${n} and no states/${n}. A reaction state needs its drawing — a full arrangement (layouts/) or a STACKED overlay (states/, LAYERS §6) (STATE_MODEL §5)`);
133
+ if (n !== base && !defPath(join(root, "layouts"), n))
134
+ report("error", file, `tree state "${n}" has no layouts/${n}. Every state's drawing — a full arrangement or a stacked overlay — lives in layouts/, the template's ONE drawings folder (anatomy unified with components, LAYERS §3) (STATE_MODEL §5)`);
129
135
  const nested = s && typeof s === "object" ? s.states : undefined;
130
136
  if (nested && typeof nested === "object" && !Array.isArray(nested))
131
137
  for (const sub of Object.keys(nested)) {
132
138
  subs.push(sub);
133
- if (!defPath(join(root, "states"), sub))
134
- report("error", file, `substate "${sub}" (under "${n}") has no states/${sub} file — a contained substate is a state file the base includes (STATE_MODEL §5)`);
139
+ if (!defPath(join(root, "layouts"), `${n}-${sub}`))
140
+ report("error", file, `substate "${sub}" (under "${n}") has no layouts/${n}-${sub} file — a substate's drawing is OWNER-PREFIXED in layouts/ (the step-* convention: bare names are the app's screens, <owner>-<sub> their sub-drawings) (LAYERS §3)`);
135
141
  }
136
142
  }
137
143
  for (const sub of subs)
138
144
  if (names.includes(sub))
139
145
  report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
146
+ // THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
147
+ // either a SCREEN (a bare name declaring a top-level state) or a substate
148
+ // drawing (<owner>-<sub>, both declared). Anything else is an error — a
149
+ // shared part belongs in components/.
150
+ {
151
+ const ldir = join(root, "layouts");
152
+ if (existsSync(ldir)) {
153
+ const declaredSubs = new Map();
154
+ for (const [n2, s2] of Object.entries(json.states)) {
155
+ const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
156
+ if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
157
+ for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
158
+ }
159
+ for (const f of readdirSync(ldir).filter(isDefFile)) {
160
+ const nm = defName(f);
161
+ if (names.includes(nm) || declaredSubs.has(nm)) continue;
162
+ const owner = names.find((n2) => nm.startsWith(`${n2}-`));
163
+ if (owner)
164
+ report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
165
+ else
166
+ report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
167
+ }
168
+ }
169
+ }
140
170
  if (json.stateOrder !== undefined)
141
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)`);
142
172
  }
@@ -187,6 +217,14 @@ function lintFile(file) {
187
217
  : [];
188
218
  };
189
219
  const states = new Set([...viewsIn("states"), ...viewsIn("layouts")]);
220
+ // ANATOMY UNIFIED (2026-08-22): a substate's drawing lives in layouts/ under
221
+ // `<arrangement>-<substate>`, so the STATE's own name is no longer a filename.
222
+ // The resolver already looks both ways (definitions.ts, template tree); without
223
+ // the same lookup here a correctly-migrated template lints as though every one
224
+ // of its substates had vanished.
225
+ const declared = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? json.states : {};
226
+ for (const [parent, node] of Object.entries(declared))
227
+ for (const sub of Object.keys(node?.states ?? {})) if (states.has(`${parent}-${sub}`)) states.add(sub);
190
228
  const comps = componentNamesForFile(file);
191
229
  for (const [state, list] of Object.entries(json.preview)) {
192
230
  if (!states.has(state))
@@ -270,11 +308,16 @@ function lintFile(file) {
270
308
 
271
309
  // only components that ADOPTED the structure are held to the full discipline
272
310
  if (hasLayouts || stateFiles.length || hasStateBlock) {
273
- const nonInput = Object.entries(json.props ?? {})
274
- .filter(([, v]) => !(v && typeof v === "object" && v.input === true))
311
+ // Every prop declares what fills it. Was "must be input:true", from when props
312
+ // only held workflow data; shared components added props nothing streams in (the
313
+ // preview axis, literals passed via `Ref with`) — input:true there is false AND
314
+ // load-bearing, since configSchema derives from it. Silence is the error:
315
+ // unflagged props default to INPUTS.
316
+ const undeclared = Object.entries(json.props ?? {})
317
+ .filter(([, v]) => !(v && typeof v === "object" && typeof v.input === "boolean"))
275
318
  .map(([k]) => k);
276
- if (nonInput.length)
277
- report("error", file, `microapp props [${nonInput.join(", ")}] are not input:true. Hardcode content in the layout, or move mutable keys into the \`state\` block (docs/design/03)`);
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/03)`);
278
321
 
279
322
  // STATE MODEL v2 (UNOVERSE_STATE_MODEL §5): an authored `state.view` TREE is
280
323
  // the component's state machine — the ONE object the scalar rule admits.
@@ -407,8 +407,42 @@ 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
- // A valid Ref target if there's ANY resolvable home (atoms OR DS components).
411
- const refResolves = (ref) => atomNames.has(ref.toLowerCase()) || dsComponentNames.has(ref.toLowerCase());
410
+ // The ORG tier's component names, per org (docs/unoverse/UNOVERSE_COMPONENT_ORGS.md):
411
+ // a Ref may also resolve an org component — bare from inside that org's own tree (the
412
+ // resolver's context rule), or org-qualified as `<org>/<name>` when two orgs share the
413
+ // name. Org-privacy holds: a file only ever reaches its OWN org's components, and a
414
+ // design-system file reaches none.
415
+ const orgComponentNames = new Map(); // org dir basename -> Set(lower names)
416
+ for (const orgDir of orgDirs) {
417
+ const home = join(orgDir, "components");
418
+ const set = new Set();
419
+ if (existsSync(home))
420
+ for (const e of readdirSync(home)) {
421
+ if (e.startsWith(".")) continue;
422
+ const name = statSync(join(home, e)).isDirectory() ? e : isDefFile(e) ? defName(e) : null;
423
+ if (name) set.add(name.toLowerCase());
424
+ }
425
+ orgComponentNames.set(basename(orgDir), set);
426
+ }
427
+ const orgOfFile = (file) => {
428
+ const home = orgDirs.find((d) => file.startsWith(d + sep));
429
+ return home ? basename(home) : null;
430
+ };
431
+ // A valid Ref target if there's ANY resolvable home the FILE may reach: atoms, DS
432
+ // components, or (from inside an org tree) that org's own components — bare or
433
+ // `<org>/<name>`-qualified.
434
+ const refResolves = (ref, file) => {
435
+ const lower = ref.toLowerCase();
436
+ const fileOrg = file ? orgOfFile(file) : null;
437
+ const slash = lower.indexOf("/");
438
+ if (slash > 0) {
439
+ const org = lower.slice(0, slash);
440
+ if (org !== fileOrg) return false; // another org's, or a DS file naming any org
441
+ return orgComponentNames.get(org)?.has(lower.slice(slash + 1)) ?? false;
442
+ }
443
+ if (atomNames.has(lower) || dsComponentNames.has(lower)) return true;
444
+ return fileOrg ? (orgComponentNames.get(fileOrg)?.has(lower) ?? false) : false;
445
+ };
412
446
 
413
447
  /**
414
448
  * THE KEY IS ONE STRING, CASE AND ALL.
@@ -60,8 +60,8 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
60
60
  if (t === "Ref") {
61
61
  if (typeof node.ref !== "string")
62
62
  report("error", file, `Ref needs "ref": "<atom name>" (docs/design/03)`);
63
- else if (atomsDirExists && !refResolves(node.ref))
64
- report("error", file, `Ref "${node.ref}". No matching atom (rx/marketplace/atoms) or shared component (rx/marketplace/components); lookup is case-insensitive by name`);
63
+ else if (atomsDirExists && !refResolves(node.ref, file))
64
+ 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
65
  // RESOLVES IS NOT ENOUGH. Ref lookup ignores case; the marketplace fetches
66
66
  // items/<kind>/<key>.json off a case-sensitive host. A Ref that differs only in case
67
67
  // renders forever in rx/ and 404s on install (index.mjs, canonicalRef).
@@ -199,32 +199,17 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
199
199
  // key anymore — a component writes only its own slice; templates react via select.where.
200
200
  // (Legit panel/draft setTemplateValue writes a DIFFERENT key and is untouched.)
201
201
  //
202
- // The PLATFORM-DEFINED chrome keys a shared partial may write (see the exception below).
203
- // Add to this only for state the SDK itself publishes and the design system must be able
204
- // to answer — never for an app's own vocabulary, which is what template-local partials
205
- // are for.
206
- const SHARED_CHROME_KEYS = new Set(["latchDismissed"]);
207
202
  const scanAction = (a) => {
208
203
  if (!a || typeof a !== "object") return;
209
204
  if (a.type === "setTemplateValue") {
210
- // A COMPONENT writes only its own slice ANY template-state write from the
211
- // shared component home is the deprecated bridge (STATE_MODEL §5b). Template-
212
- // local partials (composer, suggestions) legitimately write template chrome.
213
- //
214
- // NARROW EXCEPTION (2026-08-13): a SHARED chrome partial may write the chrome keys
215
- // named below, and nothing else. The rule's target is the deprecated FOCUS bridge
216
- // a component reaching up to move the template's state — which is why its own note
217
- // above says a write to a DIFFERENT key is untouched. Some chrome is genuinely
218
- // universal and belongs in the design system rather than copied into every app's
219
- // folder: the composer's latch pill is one bar, shared, and its ✕ is the guest's only
220
- // exit from the latch (docs/MCP_COMPLETE_GUIDE.md §The Component Latch). The list is
221
- // an ALLOWLIST on purpose — it grants exactly the keys the platform defines and stays
222
- // an error for everything else, so this cannot widen into "shared components may
223
- // write template state".
224
- if (!isTemplatePath(file) && !(Array.isArray(a.values) && a.values.length && a.values.every((v) => v && SHARED_CHROME_KEYS.has(v.key))))
225
- report("error", file, `a component never writes template state: setTemplateValue is the deprecated bridge; change the view with setValue and let the template react via select.where (STATE_MODEL §5b)`);
226
- else if (Array.isArray(a.values) && a.values.some((v) => v && v.key === "defaultState"))
227
- report("warn", file, `setTemplateValue writing "defaultState" is the deprecated focus bridge. A component writes only its own slice; templates react via ComponentSlot.select.where (STATE_MODEL §5b)`);
205
+ // WHAT IS DEPRECATED IS THE FOCUS BRIDGE, not the action (2026-08-23, owner
206
+ // ruling). Writing `defaultState` to move the template is the bridge: a component
207
+ // reaching up to change which surface is shown, which the tree reacts to by name
208
+ // instead. Writing the template's own CHROME (openPanel, draft) is legitimate from
209
+ // anywhere a `Ref`-embedded component has no slice of its own, so template state
210
+ // is the only state in scope, and the shared chat chrome depends on it.
211
+ if (Array.isArray(a.values) && a.values.some((v) => v && v.key === "defaultState"))
212
+ report(isTemplatePath(file) ? "warn" : "error", file, `setTemplateValue writing "defaultState" is the deprecated focus bridge. Change the view with setValue and let the template react via ComponentSlot.select.where (STATE_MODEL §5b)`);
228
213
  }
229
214
  if (a.then) scanAction(a.then);
230
215
  };