@unoverse-platform/studio 0.1.23 → 0.1.25

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-platform/studio",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -186,16 +186,23 @@ export function DesignSystemView({
186
186
  bucket: "space",
187
187
  body: (
188
188
  <div className="space-y-2 rounded-xl p-6" style={{ background: raised, border: `1px solid ${borderC}` }}>
189
- {Object.entries(theme.space).map(([name, val]) => (
190
- <div key={name} className="flex items-center gap-4">
191
- <div className="w-20 shrink-0 text-[11px]" style={{ color: sub }}>
192
- {name}
193
- {isOverride("space", name) && <OverrideMark />}
189
+ {/* BY SIZE, not by load order. The scale is assembled from several token files
190
+ (the foundation's, then the org's), so `Object.entries` listed 1.5 after 200
191
+ and every later addition below that: a scale that reads as an unordered pile
192
+ rather than a scale. Sorting here is a DISPLAY concern; the token files stay
193
+ as they are. */}
194
+ {Object.entries(theme.space)
195
+ .sort(([, a], [, b]) => parseFloat(String(a)) - parseFloat(String(b)))
196
+ .map(([name, val]) => (
197
+ <div key={name} className="flex items-center gap-4">
198
+ <div className="w-20 shrink-0 text-[11px]" style={{ color: sub }}>
199
+ {name}
200
+ {isOverride("space", name) && <OverrideMark />}
201
+ </div>
202
+ <div className="h-3 rounded" style={{ width: val, background: accent }} />
203
+ <div className="text-[10px]" style={{ color: sub }}>{val}</div>
194
204
  </div>
195
- <div className="h-3 rounded" style={{ width: val, background: accent }} />
196
- <div className="text-[10px]" style={{ color: sub }}>{val}</div>
197
- </div>
198
- ))}
205
+ ))}
199
206
  </div>
200
207
  ),
201
208
  },
@@ -41,16 +41,18 @@ const chatIdFor = (app: string) => `demo-${app.toLowerCase()}-${SESSION_NONCE}`;
41
41
  // it opens the Gravity data plane (WS component stream + REST execute) against the
42
42
  // configured server/workflow/node; in MOCK mode it seeds a fake conversation.
43
43
  // These are editable in the toolbar (LIVE mode) and persisted across refreshes.
44
- // Dev workbench serves /api + /ws/gravity + /stream on the merged uWS at :4205
45
- // (the retired gateway was :4100). Prod uses the page origin.
46
- const CONN_DEFAULTS = { server: "localhost:4205", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
44
+ // The dev universe serves /mcp + /api + /ws/gravity + /stream on the merged uWS at
45
+ // :4105 — since the workbench retired, uWS binds the public port DIRECTLY in dev, same
46
+ // as prod (the retired workbench was :4205, the retired gateway :4100). Prod uses the
47
+ // page origin.
48
+ const CONN_DEFAULTS = { server: "localhost:4105", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
47
49
  const CONN_KEY = "unoverse.workbench.conn";
48
50
 
49
51
  function loadConn(): typeof CONN_DEFAULTS {
50
52
  try {
51
53
  const saved = JSON.parse(localStorage.getItem(CONN_KEY) ?? "{}");
52
- // Migration: drop the retired gateway port so the new default applies.
53
- if (saved.server === "localhost:4100") delete saved.server;
54
+ // Migration: drop retired ports (gateway :4100, workbench :4205) so the new default applies.
55
+ if (saved.server === "localhost:4100" || saved.server === "localhost:4205") delete saved.server;
54
56
  return { ...CONN_DEFAULTS, ...saved };
55
57
  } catch {
56
58
  return { ...CONN_DEFAULTS };
@@ -175,6 +175,10 @@ export interface ResolvedTheme {
175
175
  radius: Record<string, string>;
176
176
  shadow: Record<string, string>;
177
177
  borderWidth: Record<string, string>;
178
+ /** Font weights (theme.weight ← font.weight.*) — SERVED, like every other token. The
179
+ * SDK held these as a literal four-name map, so a weight an org defined in its own
180
+ * tokens was unreachable and rendered as nothing at all. */
181
+ weight: Record<string, string | number>;
178
182
  /** Line-height tokens (theme.lineHeight ← font.lineHeight) — tight/snug/normal/relaxed. */
179
183
  lineHeight: Record<string, string | number>;
180
184
  text: Record<string, Record<string, string | number>>;
@@ -184,6 +188,12 @@ export interface ResolvedTheme {
184
188
  * width blocks `appWidth` references ("chat"/"rail"/"panel" → raw host-facing CSS).
185
189
  * Resolved by the SDK like every other token — one pipeline. */
186
190
  appSize?: Record<string, string>;
191
+ /** Grid behaviour (theme.grid ← rx/styles/semantic/grid) — `stackBelow`, the width
192
+ * at which a columns grid stacks. Served: the SDK holds no threshold of its own. */
193
+ grid?: Record<string, string>;
194
+ /** PAGE WIDTHS by name (theme.layout ← rx/styles/semantic/layout) — resolved by `dim()`
195
+ * alongside the space scale, so `maxWidth: "reading"` works like any dimension. */
196
+ layout?: Record<string, string>;
187
197
  /** Named keyframes (theme.keyframes ← rx/styles/semantic/keyframes.json), injected
188
198
  * once per render root. Lets any definition animate via `style.animation` — no SDK edit. */
189
199
  keyframes: Record<string, Record<string, Record<string, string>>>;
@@ -92,17 +92,37 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
92
92
  // the COMPONENT (not the viewport) is mobile-width. Correct for SDUI: a component reacts to
93
93
  // its slot, not the page. Generic vocab, value served; the SDK authors none.
94
94
  if (s?.hideBelow) {
95
- const w = theme.space[s.hideBelow as string] ?? (s.hideBelow as string);
95
+ const w = theme.space[s.hideBelow as string] ?? theme.layout?.[s.hideBelow as string] ?? (s.hideBelow as string);
96
96
  const cls = `uno-hidebelow-${hashStr(String(w))}`;
97
97
  classes.push(cls);
98
98
  styleEls.push(<style key="hideBelow">{`@container (max-width:${w}){.${cls}{display:none !important}}`}</style>);
99
99
  }
100
+ // `span` STACKS. A child covering several of its grid's columns must give that up when
101
+ // the grid runs out of room, or four quarter-width cards keep being quarters in a 300px
102
+ // rail and the content is crushed. Below the threshold every spanning child takes the
103
+ // whole row (`1 / -1`), which IS the stack: one per row, full width, in source order.
104
+ //
105
+ // The query targets the GRID (style.ts marks it `container-type: inline-size`), so this
106
+ // reacts to the space the grid actually has, not the viewport. A component in a narrow
107
+ // panel on a wide screen stacks correctly, which a viewport breakpoint cannot do.
108
+ //
109
+ // The threshold is SERVED (`theme.grid.stackBelow`), overridable per grid with
110
+ // `style.stackBelow`. The SDK holds no width of its own.
111
+ if (s?.span) {
112
+ const raw = (s.stackBelow as string) ?? theme.grid?.stackBelow ?? "";
113
+ const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
114
+ if (w) {
115
+ const cls = `uno-stack-${hashStr(String(w))}`;
116
+ classes.push(cls);
117
+ styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}{grid-column:1 / -1 !important}}`}</style>);
118
+ }
119
+ }
100
120
  // `hideAbove` — the mirror: hide when the container is WIDER than the width. Together
101
121
  // these are the whole layout-switch mechanism for space-responsive components: the
102
122
  // compact layout carries hideAbove, the full layout hideBelow, and the container's
103
123
  // width (granted by the template's surface) picks the rendering — never a state key.
104
124
  if (s?.hideAbove) {
105
- const w = theme.space[s.hideAbove as string] ?? (s.hideAbove as string);
125
+ const w = theme.space[s.hideAbove as string] ?? theme.layout?.[s.hideAbove as string] ?? (s.hideAbove as string);
106
126
  const cls = `uno-hideabove-${hashStr(String(w))}`;
107
127
  classes.push(cls);
108
128
  styleEls.push(<style key="hideAbove">{`@container (min-width:${w}){.${cls}{display:none !important}}`}</style>);
@@ -430,11 +450,60 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
430
450
  const field = node.bind?.value;
431
451
  const [local, setLocal] = useState("");
432
452
  const value = field != null ? String(data[field] ?? "") : local;
453
+ // GENERIC constraints on what the field ACCEPTS — the same class of fact as
454
+ // `multiline`/`rows`, never a layout and never a named UX. `maxLength` caps the
455
+ // length; `inputMode: "numeric"` asks the platform for a digit keypad AND limits
456
+ // the value to 0-9. An OTP box is `maxLength: 1` + `inputMode: "numeric"` in a
457
+ // DEFINITION — the SDK knows nothing about OTP, verification, or six boxes.
458
+ const maxLength = (node as { maxLength?: number }).maxLength;
459
+ const numeric = (node as { inputMode?: string }).inputMode === "numeric";
460
+ // `autoComplete` — the browser's autofill hint, passed straight through. Without one,
461
+ // Chrome GUESSES from shape: a row of short numeric boxes reads as a card/ID number and
462
+ // it offers to save the digits. The definition says what the field IS (a one-time code,
463
+ // a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
464
+ // no list of values and no idea what a one-time code is.
465
+ const autoComplete = (node as { autoComplete?: string }).autoComplete;
466
+ // `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
467
+ // a desktop keyboard types straight past it — so the digit rule is applied here.
468
+ const accept = (t: string) => {
469
+ const kept = numeric ? t.replace(/[^0-9]/g, "") : t;
470
+ return maxLength != null ? kept.slice(0, maxLength) : kept;
471
+ };
472
+ // CONTROLLED: report the change as an "input" event carrying WHICH field changed.
473
+ // The component dispatcher writes that field into this instance's slice (local two-way
474
+ // binding, §2e-3); the template composer's channel reads `text` and ignores `field`
475
+ // (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
476
+ // `advanceOnFill` — when this field is FULL, focus moves to the next field that opted
477
+ // in; Backspace in an empty one moves back. A generic fact about a field that is one
478
+ // of a SEQUENCE, opted into per field so an ordinary `maxLength` text box never yanks
479
+ // focus away the moment it fills. The SDK still knows nothing about codes or six boxes:
480
+ // it walks the marked peers in document order and moves focus one step.
481
+ const advanceOnFill = (node as { advanceOnFill?: boolean }).advanceOnFill === true;
482
+ const el = useRef<HTMLInputElement>(null);
483
+ const stepFocus = (dir: 1 | -1) => {
484
+ const self = el.current;
485
+ if (!self) return;
486
+ // Scope the walk to this render's ROOT — the SDK renders inside a shadow root, so
487
+ // `document` would miss the peers entirely (and, on a page with two instances,
488
+ // could reach the wrong ones).
489
+ const root = self.getRootNode() as Document | ShadowRoot;
490
+ const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input[data-uno-advance]"));
491
+ const next = peers[peers.indexOf(self) + dir];
492
+ if (!next) return;
493
+ next.focus();
494
+ next.select();
495
+ };
433
496
  // CONTROLLED: report the change as an "input" event carrying WHICH field changed.
434
497
  // The component dispatcher writes that field into this instance's slice (local two-way
435
498
  // binding, §2e-3); the template composer's channel reads `text` and ignores `field`
436
499
  // (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
437
- const setValue = (t: string) => (field != null ? onAction?.("input", { text: t, field }) : setLocal(t));
500
+ const setValue = (raw: string) => {
501
+ const t = accept(raw);
502
+ // Move BEFORE the store round-trip: focus is DOM state, independent of the re-render,
503
+ // so a fast typist's next keystroke lands in the next field rather than this one.
504
+ if (advanceOnFill && maxLength != null && t.length >= maxLength) stepFocus(1);
505
+ return field != null ? onAction?.("input", { text: t, field }) : setLocal(t);
506
+ };
438
507
  const submit = () => {
439
508
  const t = value.trim();
440
509
  if (!t) return;
@@ -452,6 +521,7 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
452
521
  disabled={disabled}
453
522
  value={value}
454
523
  placeholder={node.placeholder}
524
+ maxLength={maxLength}
455
525
  rows={(node as { rows?: number }).rows ?? 3}
456
526
  onChange={(e) => setValue(e.target.value)}
457
527
  onKeyDown={(e) => {
@@ -465,12 +535,24 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
465
535
  }
466
536
  return (
467
537
  <input
538
+ ref={el}
468
539
  style={style}
469
540
  disabled={disabled}
470
541
  value={value}
471
542
  placeholder={node.placeholder}
543
+ maxLength={maxLength}
544
+ inputMode={numeric ? "numeric" : undefined}
545
+ autoComplete={autoComplete}
546
+ data-uno-advance={advanceOnFill ? "" : undefined}
472
547
  onChange={(e) => setValue(e.target.value)}
473
548
  onKeyDown={(e) => {
549
+ // Backspace in an ALREADY-empty field steps back, so correcting a sequence never
550
+ // needs the mouse. A field with content just deletes it (the default).
551
+ if (advanceOnFill && e.key === "Backspace" && value === "") {
552
+ e.preventDefault();
553
+ stepFocus(-1);
554
+ return;
555
+ }
474
556
  if (e.key === "Enter" && !e.shiftKey) {
475
557
  e.preventDefault();
476
558
  submit();
@@ -7,7 +7,6 @@
7
7
  import type { CSSProperties } from "react";
8
8
  import { resolveValue, matches, type ResolvedTheme, type StyleVariant } from "./core";
9
9
 
10
- const WEIGHTS: Record<string, number> = { normal: 400, medium: 500, semibold: 600, bold: 700 };
11
10
 
12
11
  // Resolution cache: style objects come from CACHED definitions, so they are
13
12
  // referentially stable across renders — resolving the same (style, theme) pair per
@@ -76,7 +75,10 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
76
75
  const space = (v: string) => theme.space[v] ?? v;
77
76
  // Resolve a dimension: a `space.*` scale step (e.g. "8" → 2rem) → its value; `full` → 100%;
78
77
  // else pass through. ONE scale for spacing AND element sizes (Tailwind-style w-5/p-5).
79
- const dim = (v: unknown) => theme.space[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
78
+ // A dimension is a scale step ("8") OR a named PAGE WIDTH ("reading"). Both live on
79
+ // the same scale — `layout.*` are aliases onto it — so one lookup order covers both.
80
+ const dim = (v: unknown) =>
81
+ theme.space[v as string] ?? theme.layout?.[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
80
82
 
81
83
  if (s.width != null) css.width = dim(s.width);
82
84
  if (s.height != null) css.height = dim(s.height);
@@ -114,12 +116,23 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
114
116
  if (s.columns) {
115
117
  css.display = "grid";
116
118
  css.gridTemplateColumns = `repeat(${s.columns}, minmax(0, 1fr))`;
119
+ // THE GRID IS ITS OWN CONTAINER, so a child's `span` can collapse against the grid's
120
+ // width rather than the page's. A grid cannot query itself, but its children can query
121
+ // it — which is exactly the direction the stacking rule runs (primitives.tsx). Without
122
+ // this a grid would need a `container` ancestor to stack, and would silently not stack
123
+ // wherever one was missing.
124
+ (css as Record<string, unknown>).containerType = "inline-size";
117
125
  // Pack rows at the top. Without this, a columns-grid inside a flex:1 panel
118
126
  // inherits CSS's default align-content:stretch and distributes the panel's
119
127
  // leftover height BETWEEN rows — the phantom vertical gap between card rows.
120
128
  css.alignContent = "start";
121
129
  }
122
130
  if (s.cursor) css.cursor = s.cursor as CSSProperties["cursor"];
131
+ // `span` — how many of the parent grid's columns this child covers: on a 12-column
132
+ // grid, 6 is a half and 3 is a quarter. The grid owns the gaps, so spans never overflow
133
+ // the way percentage widths in a flex row do. Below the stacking width the child takes
134
+ // the full row instead (the rule injected in primitives.tsx).
135
+ if (s.span) css.gridColumn = `span ${s.span}`;
123
136
  if (s.align) css.alignItems = s.align === "start" ? "flex-start" : (s.align as CSSProperties["alignItems"]);
124
137
  if (s.justify) css.justifyContent = s.justify as CSSProperties["justifyContent"];
125
138
  // Explicit text alignment (inherited by children) — needed because a Button's
@@ -168,7 +181,12 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
168
181
  if (s.overflow) css.overflow = s.overflow as CSSProperties["overflow"];
169
182
  if (s.fit) css.objectFit = s.fit as CSSProperties["objectFit"];
170
183
  if (s.font && theme.text[s.font as string]) Object.assign(css, theme.text[s.font as string]);
171
- if (s.weight) css.fontWeight = WEIGHTS[s.weight as string] ?? (s.weight as CSSProperties["fontWeight"]);
184
+ // `weight` — a SERVED token like every other value here. This was the one scale the
185
+ // SDK owned outright: a literal four-name map, which meant the foundation's own
186
+ // `light`/`black` were unreachable and an org that added a weight to its tokens got
187
+ // nothing at all (the unknown word reached CSS, CSS dropped the declaration, and the
188
+ // text kept whatever it inherited — silently). A raw number still passes through.
189
+ if (s.weight) css.fontWeight = (theme.weight[s.weight as string] ?? s.weight) as CSSProperties["fontWeight"];
172
190
  // `lineHeight` — override the text style's line-height (e.g. tighten a single-line
173
191
  // card title). Applied AFTER `font` so it wins. Resolves a lineHeight token (tight/…).
174
192
  if (s.lineHeight != null) css.lineHeight = (theme.lineHeight[s.lineHeight as string] ?? s.lineHeight) as CSSProperties["lineHeight"];
@@ -12,6 +12,12 @@ type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
12
12
  let root: Root | null = null;
13
13
  let current: HostConfig | null = null;
14
14
 
15
+ // Stable identity, module-level: the connection hook keys its lifecycle on the
16
+ // `getAccessToken` reference (connection.tsx effect deps), and the host re-posts
17
+ // config on every iframe load — an inline closure here would recycle the live
18
+ // /stream session on each repost.
19
+ const readToken = () => current?.token ?? null;
20
+
15
21
  function render() {
16
22
  if (!current) return;
17
23
  const el = document.getElementById("root")!;
@@ -23,15 +29,21 @@ function render() {
23
29
  templateId={current.templateId}
24
30
  userId={current.userId}
25
31
  conversationId={current.conversationId}
26
- getAccessToken={() => current?.token ?? null}
32
+ // Only claim auth when the host actually sent a token. A fabricated accessor on a
33
+ // tokenless boot made the SDK skip its guest-id substitution (connection.tsx), so
34
+ // the session registered a non-guest id and the public-entry gate refused it.
35
+ // No token ⇒ no accessor ⇒ the SDK mints/persists `guest-<uuid>` per the contract
36
+ // (UNOVERSE_MCP_TEMPLATE_PROTOCOL §5, AUTH_TOKEN_FLOW Run Authorization).
37
+ getAccessToken={current.token ? readToken : undefined}
27
38
  />,
28
39
  );
29
40
  }
30
41
 
31
42
  function apply(cfg: HostConfig) {
32
- const first = !current;
33
43
  current = { ...current, ...cfg };
34
- if (first) render(); // mount once; token refresh just updates `current` (getAccessToken reads it)
44
+ // Re-render on every apply so a token appearing/disappearing takes effect; a same-token
45
+ // refresh changes nothing (readToken is stable, values equal), so no connection churn.
46
+ render();
35
47
  }
36
48
 
37
49
  declare global {