@unoverse-platform/studio 0.1.4 → 0.1.6
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/index.html +1 -0
- package/local/authoring.ts +101 -0
- package/local/catalog.ts +6 -0
- package/local/mcp.ts +260 -0
- package/local/plugin.ts +249 -7
- package/local/source.ts +230 -0
- package/package.json +15 -2
- package/public/favicon.svg +4 -0
- package/screens/AtomsView.tsx +42 -11
- package/screens/ComponentsView.tsx +175 -18
- package/screens/SourceView.tsx +784 -0
- package/screens/TemplatesView.tsx +16 -4
- package/screens/index.ts +3 -0
- package/screens/source/FileTree.tsx +131 -0
- package/screens/source/completion.ts +152 -0
- package/screens/source/highlight.ts +72 -0
- package/screens/source/inspect.ts +103 -0
- package/screens/source/picker.ts +89 -0
- package/src/host.ts +26 -1
- package/vendor/sdk/appEngine.tsx +1 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +101 -12
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/client.ts +33 -6
- package/vendor/sdk/lib/core/types.ts +5 -0
- package/vendor/sdk/lib/primitives.tsx +33 -4
- package/vendor/sdk/lib/render.tsx +23 -4
- package/vendor/sdk/lib/streamed.tsx +12 -47
- package/vendor/sdk/lib/template.tsx +3 -3
|
@@ -115,10 +115,22 @@ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
|
115
115
|
{ id: "full", label: "Full", w: null },
|
|
116
116
|
];
|
|
117
117
|
|
|
118
|
-
export function TemplatesView({
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
export function TemplatesView({
|
|
119
|
+
theme: defaultTheme,
|
|
120
|
+
themeName,
|
|
121
|
+
org = "all",
|
|
122
|
+
defaultLive = false,
|
|
123
|
+
}: {
|
|
124
|
+
theme: ResolvedTheme;
|
|
125
|
+
themeName: string;
|
|
126
|
+
org?: string;
|
|
127
|
+
/** Which mode the view opens in. Standalone Studio authors offline, so it stays MOCK;
|
|
128
|
+
* inside Canvas the universe is right there and a real render is what you came for. */
|
|
129
|
+
defaultLive?: boolean;
|
|
130
|
+
}) {
|
|
131
|
+
// MOCK (offline) vs LIVE (real data plane). A fresh store per mode keeps the two paths
|
|
132
|
+
// from bleeding into each other.
|
|
133
|
+
const [live, setLive] = useState(defaultLive);
|
|
122
134
|
const [templates, setTemplates] = useState<Def[]>([]);
|
|
123
135
|
const [components, setComponents] = useState<Def[]>([]);
|
|
124
136
|
const [selected, setSelected] = usePersistentState<string | null>("wb:templates:selected", null);
|
package/screens/index.ts
CHANGED
|
@@ -24,3 +24,6 @@ export { stateActivation } from "./states";
|
|
|
24
24
|
// its own screen (Canvas from MarketplaceView, local Studio from the node testing area),
|
|
25
25
|
// so it is shared even though neither shares the screen around it.
|
|
26
26
|
export { ConfigForm, defaultsFromSchema } from "./ConfigForm";
|
|
27
|
+
// The source view renders only where the host grants source access, so it is exported
|
|
28
|
+
// unconditionally and returns null in Canvas (docs/architecture/LOCAL_STUDIO.md).
|
|
29
|
+
export { SourceView } from "./SourceView";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FILE RAIL: what this definition is made of, as a tree.
|
|
3
|
+
*
|
|
4
|
+
* A component folder holds its envelope, a manifest, and `layouts/`, `states/` and
|
|
5
|
+
* `components/` partials, which is easily a dozen files. Opening all of them as tabs eats
|
|
6
|
+
* the width, pushes the actions off the edge and makes the list unreadable at exactly the
|
|
7
|
+
* moment it matters. A rail lists them in one narrow column and the editor keeps the rest.
|
|
8
|
+
*
|
|
9
|
+
* Folders group in the order a developer reads them: the envelope first, then the manifest,
|
|
10
|
+
* then the partial folders.
|
|
11
|
+
*/
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
|
|
14
|
+
export interface SourceFile {
|
|
15
|
+
path: string;
|
|
16
|
+
rel: string;
|
|
17
|
+
writable: boolean;
|
|
18
|
+
reason?: "installed" | "outside-project" | "not-a-file";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fileName = (rel: string) => rel.slice(rel.lastIndexOf("/") + 1);
|
|
22
|
+
/** A layout's filename IS its state name. The name-sync rule is what makes this safe. */
|
|
23
|
+
export const stateOf = (rel: string) => fileName(rel).replace(/\.(ya?ml|json)$/, "");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Group by folder, root files first, folders in reading order.
|
|
27
|
+
*
|
|
28
|
+
* LAYOUTS FOLLOW THE STATE ORDER, not the alphabet. A component's faces are declared in
|
|
29
|
+
* order by its root `Switch on defaultState`, and that order is what the state control in
|
|
30
|
+
* the toolbar shows. Sorting the same set alphabetically here made the rail and the
|
|
31
|
+
* toolbar disagree about a sequence they both take from one place.
|
|
32
|
+
*/
|
|
33
|
+
function group(files: SourceFile[], layoutOrder: string[]): { dir: string; files: SourceFile[] }[] {
|
|
34
|
+
const byDir = new Map<string, SourceFile[]>();
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const i = f.rel.lastIndexOf("/");
|
|
37
|
+
const dir = i === -1 ? "" : f.rel.slice(0, i);
|
|
38
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
39
|
+
byDir.get(dir)!.push(f);
|
|
40
|
+
}
|
|
41
|
+
const order = ["", "layouts", "states", "components", "blocks"];
|
|
42
|
+
const rank = (list: string[], name: string) => {
|
|
43
|
+
const i = list.indexOf(name);
|
|
44
|
+
return i === -1 ? list.length : i;
|
|
45
|
+
};
|
|
46
|
+
return [...byDir.entries()]
|
|
47
|
+
.sort(([a], [b]) => rank(order, a) - rank(order, b) || a.localeCompare(b))
|
|
48
|
+
.map(([dir, list]) => ({
|
|
49
|
+
dir,
|
|
50
|
+
files:
|
|
51
|
+
dir === "layouts" && layoutOrder.length
|
|
52
|
+
? [...list].sort(
|
|
53
|
+
(x, y) =>
|
|
54
|
+
rank(layoutOrder, stateOf(x.rel)) - rank(layoutOrder, stateOf(y.rel)) ||
|
|
55
|
+
x.rel.localeCompare(y.rel),
|
|
56
|
+
)
|
|
57
|
+
: list,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function FileTree({
|
|
62
|
+
files,
|
|
63
|
+
active,
|
|
64
|
+
dirtyPaths,
|
|
65
|
+
accent,
|
|
66
|
+
layoutOrder = [],
|
|
67
|
+
liveState,
|
|
68
|
+
onPick,
|
|
69
|
+
}: {
|
|
70
|
+
files: SourceFile[];
|
|
71
|
+
active: SourceFile | null;
|
|
72
|
+
/** Paths with unsaved edits, so the rail carries the same marker the editor does. */
|
|
73
|
+
dirtyPaths: Set<string>;
|
|
74
|
+
accent?: string;
|
|
75
|
+
/** The component's faces, in declared order. Layouts are listed to match. */
|
|
76
|
+
layoutOrder?: string[];
|
|
77
|
+
/** The view being rendered. Marked LIVE whether or not it is the file being read. */
|
|
78
|
+
liveState?: string;
|
|
79
|
+
onPick: (f: SourceFile) => void;
|
|
80
|
+
}) {
|
|
81
|
+
const groups = useMemo(() => group(files, layoutOrder), [files, layoutOrder]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* NO TITLE AND NO BACK CONTROL. The item list beside this rail already shows what is
|
|
85
|
+
* selected and is how you leave, and the toolbar names it again. A third copy of the
|
|
86
|
+
* name buys nothing and costs a row at the top of every file list.
|
|
87
|
+
*/
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex w-44 shrink-0 flex-col overflow-y-auto border-r border-gray-800 bg-[#171c24] py-1">
|
|
90
|
+
{groups.map(({ dir, files: list }) => (
|
|
91
|
+
<div key={dir || "."} className="mb-1">
|
|
92
|
+
{dir && (
|
|
93
|
+
<div className="px-2 py-0.5 text-[10px] uppercase tracking-wider text-gray-600">{dir}</div>
|
|
94
|
+
)}
|
|
95
|
+
{list.map((f) => {
|
|
96
|
+
/**
|
|
97
|
+
* TWO FACTS, TWO MARKS.
|
|
98
|
+
*
|
|
99
|
+
* `open` is the file in the editor, and `live` is the view on the preview.
|
|
100
|
+
* They coincide only while you happen to be reading the layout being drawn,
|
|
101
|
+
* and one mark for both meant opening `manifest.yaml` erased any sign of what
|
|
102
|
+
* the preview was showing. Open is a neutral highlight; live keeps the accent
|
|
103
|
+
* and the word, wherever it is in the list.
|
|
104
|
+
*/
|
|
105
|
+
const open = active?.path === f.path;
|
|
106
|
+
const live = dir === "layouts" && !!liveState && stateOf(f.rel) === liveState;
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
key={f.path}
|
|
110
|
+
onClick={() => onPick(f)}
|
|
111
|
+
title={f.path}
|
|
112
|
+
className={`flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] ${
|
|
113
|
+
open ? "bg-white/[0.09] text-gray-100" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
|
|
114
|
+
}`}
|
|
115
|
+
style={{
|
|
116
|
+
...(open ? { boxShadow: "inset 2px 0 0 #6b7280" } : {}),
|
|
117
|
+
...(live ? { color: accent ?? "#10b981" } : {}),
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<span className="min-w-0 flex-1 truncate">{fileName(f.rel)}</span>
|
|
121
|
+
{live && <span className="shrink-0 text-[9px] uppercase tracking-wide opacity-80">live</span>}
|
|
122
|
+
{dirtyPaths.has(f.path) && <span className="shrink-0 text-amber-400">●</span>}
|
|
123
|
+
{!f.writable && <span className="shrink-0 text-[9px] text-gray-600">RO</span>}
|
|
124
|
+
</button>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INTELLISENSE for definitions.
|
|
3
|
+
*
|
|
4
|
+
* The vocabulary is CLOSED, so a completion list here is not a hint, it is the whole legal
|
|
5
|
+
* set: sixteen primitives, a fixed style key set, and token names instead of raw values. A
|
|
6
|
+
* developer who accepts a completion cannot write something the linter will reject, which
|
|
7
|
+
* is a stronger promise than a text editor's schema hints can make.
|
|
8
|
+
*
|
|
9
|
+
* The lists come from the host (`/local/vocabulary`), which reads them from the SAME
|
|
10
|
+
* library the linter enforces and from the project's OWN resolved theme. So the tokens
|
|
11
|
+
* offered are the developer's real tokens, and a new token appears in completion the moment
|
|
12
|
+
* it exists.
|
|
13
|
+
*
|
|
14
|
+
* WHAT DECIDES WHICH LIST. Definitions are YAML or JSON with a shallow, predictable shape,
|
|
15
|
+
* so the key being typed is enough: `type:` wants a primitive, a key inside `style:` wants
|
|
16
|
+
* a style key, `background:` wants a colour token, `gap:` wants a space step. The context
|
|
17
|
+
* is read from the current line and the enclosing block, not from a full parse, because a
|
|
18
|
+
* document mid-keystroke is usually not parseable at all.
|
|
19
|
+
*/
|
|
20
|
+
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
|
21
|
+
import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
|
|
22
|
+
import { StateField, type EditorState } from "@codemirror/state";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HIDE THE `$schema` LINE.
|
|
26
|
+
*
|
|
27
|
+
* It exists for editors that need a schema pointer, and Studio is not one: it lints with
|
|
28
|
+
* the real rule library and completes from the actual vocabulary and the project's own
|
|
29
|
+
* tokens. So here it is noise in the worst position, the first line of every file, and its
|
|
30
|
+
* `../../../../` depth changes with where the file sits, which reads as a puzzle.
|
|
31
|
+
*
|
|
32
|
+
* HIDDEN, NOT REMOVED. The text stays in the document and is saved back untouched, so a
|
|
33
|
+
* developer's VS Code setup is unaffected. Line numbers keep counting it, so a lint finding
|
|
34
|
+
* on line 12 is still line 12 in any other editor.
|
|
35
|
+
*/
|
|
36
|
+
const schemaLine = /^\s*\$schema\s*:/;
|
|
37
|
+
|
|
38
|
+
/** A block decoration has to come from a StateField; from a plugin CodeMirror throws. */
|
|
39
|
+
const build = (state: EditorState): DecorationSet => {
|
|
40
|
+
const first = state.doc.line(1);
|
|
41
|
+
if (!schemaLine.test(first.text)) return Decoration.none;
|
|
42
|
+
return Decoration.set([Decoration.replace({ block: true }).range(first.from, first.to)]);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const hideSchemaLine = StateField.define<DecorationSet>({
|
|
46
|
+
create: build,
|
|
47
|
+
update: (deco, tr) => (tr.docChanged ? build(tr.state) : deco),
|
|
48
|
+
provide: (f) => EditorView.decorations.from(f),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export interface Vocabulary {
|
|
52
|
+
primitives: string[];
|
|
53
|
+
styleKeys: string[];
|
|
54
|
+
conditionKeys: string[];
|
|
55
|
+
dimensionKeys: string[];
|
|
56
|
+
tokens: { color: string[]; space: string[]; font: string[]; lineHeight: string[]; radius: string[]; shadow: string[] };
|
|
57
|
+
atoms: string[];
|
|
58
|
+
components: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Style keys whose value is a colour token. */
|
|
62
|
+
const COLOR_KEYS = new Set(["background", "color", "border", "borderTop", "borderRight", "borderBottom", "borderLeft", "outline", "radial"]);
|
|
63
|
+
|
|
64
|
+
const opt = (label: string, detail: string, type: string): Completion => ({ label, detail, type });
|
|
65
|
+
|
|
66
|
+
/** The indent of a line, in spaces. Tabs are not used in definitions. */
|
|
67
|
+
const indentOf = (line: string) => line.length - line.trimStart().length;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Is the cursor inside a `style:` block?
|
|
71
|
+
*
|
|
72
|
+
* Walk backwards to the nearest line whose indent is less than this one and read its key.
|
|
73
|
+
* A `style:` ancestor at any depth counts, which is what makes a nested node's style block
|
|
74
|
+
* behave the same as a root one.
|
|
75
|
+
*/
|
|
76
|
+
function enclosingKey(lines: string[], row: number, indent: number): string | null {
|
|
77
|
+
for (let i = row - 1; i >= 0; i--) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
80
|
+
const ind = indentOf(line);
|
|
81
|
+
if (ind < indent) {
|
|
82
|
+
const m = /^\s*-?\s*"?([A-Za-z_][\w.-]*)"?\s*:/.exec(line);
|
|
83
|
+
return m ? m[1] : null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The completion source.
|
|
91
|
+
*
|
|
92
|
+
* Returns null (rather than an empty list) wherever the vocabulary has nothing to say, so
|
|
93
|
+
* the editor falls through to its own word completion instead of showing an empty popup.
|
|
94
|
+
*/
|
|
95
|
+
export function unoverseCompletion(getVocab: () => Vocabulary | null) {
|
|
96
|
+
return (ctx: CompletionContext): CompletionResult | null => {
|
|
97
|
+
const vocab = getVocab();
|
|
98
|
+
if (!vocab) return null;
|
|
99
|
+
|
|
100
|
+
const line = ctx.state.doc.lineAt(ctx.pos);
|
|
101
|
+
const upto = line.text.slice(0, ctx.pos - line.from);
|
|
102
|
+
const doc = ctx.state.doc;
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
for (let i = 1; i <= doc.lines; i++) lines.push(doc.line(i).text);
|
|
105
|
+
const row = line.number - 1;
|
|
106
|
+
|
|
107
|
+
// ── a VALUE position: `key: <cursor>` ──────────────────────────────────────────
|
|
108
|
+
const atValue = /^\s*-?\s*"?([A-Za-z_][\w.-]*)"?\s*:\s*"?([\w.\-/]*)$/.exec(upto);
|
|
109
|
+
if (atValue) {
|
|
110
|
+
const [, key, typed] = atValue;
|
|
111
|
+
const from = ctx.pos - typed.length;
|
|
112
|
+
const list = (items: string[], detail: string, type = "constant"): CompletionResult => ({
|
|
113
|
+
from,
|
|
114
|
+
options: items.map((i) => opt(i, detail, type)),
|
|
115
|
+
validFor: /^[\w.\-/]*$/,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (key === "type") return list(vocab.primitives, "primitive", "class");
|
|
119
|
+
if (key === "ref") return list(vocab.atoms, "atom", "variable");
|
|
120
|
+
if (key === "select" || key === "component") return list(vocab.components, "component", "class");
|
|
121
|
+
if (COLOR_KEYS.has(key)) return list(vocab.tokens.color, "colour token");
|
|
122
|
+
if (key === "font") return list(vocab.tokens.font, "type scale");
|
|
123
|
+
if (key === "lineHeight") return list(vocab.tokens.lineHeight, "line height");
|
|
124
|
+
if (key === "radius" || key.startsWith("radius")) return list(vocab.tokens.radius, "radius token");
|
|
125
|
+
if (key === "shadow") return list(vocab.tokens.shadow, "shadow token");
|
|
126
|
+
if (vocab.dimensionKeys.includes(key)) return list(vocab.tokens.space, "space step");
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── a KEY position: the start of a line ────────────────────────────────────────
|
|
131
|
+
const atKey = /^\s*-?\s*"?([A-Za-z_][\w.-]*)?$/.exec(upto);
|
|
132
|
+
if (atKey) {
|
|
133
|
+
const typed = atKey[1] ?? "";
|
|
134
|
+
const from = ctx.pos - typed.length;
|
|
135
|
+
const parent = enclosingKey(lines, row, indentOf(line.text));
|
|
136
|
+
if (parent === "style" || parent === "hover")
|
|
137
|
+
return {
|
|
138
|
+
from,
|
|
139
|
+
options: vocab.styleKeys.map((k) => opt(k, "style key", "property")),
|
|
140
|
+
validFor: /^[\w-]*$/,
|
|
141
|
+
};
|
|
142
|
+
if (parent === "visibleWhen" || parent === "when")
|
|
143
|
+
return {
|
|
144
|
+
from,
|
|
145
|
+
options: vocab.conditionKeys.map((k) => opt(k, "condition", "property")),
|
|
146
|
+
validFor: /^[\w-]*$/,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE OVERLAY: draw a box over the rendered element the cursor is in.
|
|
3
|
+
*
|
|
4
|
+
* The preview renders inside a shadow root, so a plain `document.querySelectorAll` finds
|
|
5
|
+
* nothing: shadow DOM is exactly the boundary that hides it. The search descends through
|
|
6
|
+
* every open shadow root instead.
|
|
7
|
+
*
|
|
8
|
+
* The boxes are drawn in the LIGHT DOM, positioned from each element's viewport rect. That
|
|
9
|
+
* keeps the preview's own tree untouched, which matters because the thing being inspected
|
|
10
|
+
* is a live render and inserting nodes into it would change what is being inspected.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const LAYER_ID = "unoverse-inspect-layer";
|
|
14
|
+
|
|
15
|
+
/** Every element with this attribute value, descending through open shadow roots. */
|
|
16
|
+
function findAll(root: ParentNode, selector: string, out: Element[] = []): Element[] {
|
|
17
|
+
out.push(...Array.from(root.querySelectorAll(selector)));
|
|
18
|
+
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
19
|
+
const shadow = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
|
|
20
|
+
if (shadow) findAll(shadow, selector, out);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function layer(): HTMLElement {
|
|
26
|
+
let el = document.getElementById(LAYER_ID);
|
|
27
|
+
if (!el) {
|
|
28
|
+
el = document.createElement("div");
|
|
29
|
+
el.id = LAYER_ID;
|
|
30
|
+
el.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:60";
|
|
31
|
+
document.body.appendChild(el);
|
|
32
|
+
}
|
|
33
|
+
return el;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Remove any boxes currently drawn. */
|
|
37
|
+
export function clearHighlight(): void {
|
|
38
|
+
document.getElementById(LAYER_ID)?.replaceChildren();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Box every element rendered from `id`.
|
|
43
|
+
*
|
|
44
|
+
* Returns how many were found. Zero is a real answer rather than a failure: the node under
|
|
45
|
+
* the cursor may be in an inactive `Switch` case, hidden by `visibleWhen`, or in a file
|
|
46
|
+
* that is not part of what is on screen.
|
|
47
|
+
*/
|
|
48
|
+
export function highlightRendered(id: string | null, accent = "#3b82f6"): number {
|
|
49
|
+
const host = layer();
|
|
50
|
+
host.replaceChildren();
|
|
51
|
+
if (!id) return 0;
|
|
52
|
+
|
|
53
|
+
const els = findAll(document, `[data-unoverse-id="${CSS.escape(id)}"]`);
|
|
54
|
+
for (const el of els) {
|
|
55
|
+
const r = el.getBoundingClientRect();
|
|
56
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
57
|
+
const box = document.createElement("div");
|
|
58
|
+
box.style.cssText = [
|
|
59
|
+
"position:absolute",
|
|
60
|
+
`left:${r.left}px`,
|
|
61
|
+
`top:${r.top}px`,
|
|
62
|
+
`width:${r.width}px`,
|
|
63
|
+
`height:${r.height}px`,
|
|
64
|
+
`outline:2px solid ${accent}`,
|
|
65
|
+
`background:${accent}1f`,
|
|
66
|
+
"border-radius:2px",
|
|
67
|
+
"transition:all .08s ease-out",
|
|
68
|
+
].join(";");
|
|
69
|
+
host.appendChild(box);
|
|
70
|
+
}
|
|
71
|
+
return els.length;
|
|
72
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CURSOR TO ELEMENT: which rendered thing is the line under the cursor?
|
|
3
|
+
*
|
|
4
|
+
* The renderer stamps each node's path onto the element it draws (`data-unoverse-id`), and
|
|
5
|
+
* `stampPaths` is what assigns those paths. Both the host (before rendering) and the editor
|
|
6
|
+
* (to look one up) stamp the same tree the same way, which is why this is one function
|
|
7
|
+
* rather than two that must agree.
|
|
8
|
+
*
|
|
9
|
+
* The editor holds a SOURCE FILE, which is usually a partial: a layout is included into a
|
|
10
|
+
* face, so a path inside the file is not the path in the composed tree. `locateSubtree`
|
|
11
|
+
* bridges that by finding where the file's tree sits inside the composed one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Record each node's path on it as `$id`. Only objects with a `type` render. */
|
|
15
|
+
export function stampPaths(node: unknown, path: string): void {
|
|
16
|
+
if (!node || typeof node !== "object") return;
|
|
17
|
+
if (Array.isArray(node)) {
|
|
18
|
+
node.forEach((child, i) => stampPaths(child, `${path}.${i}`));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const n = node as Record<string, unknown>;
|
|
22
|
+
if (typeof n.type === "string") n.$id = path;
|
|
23
|
+
for (const [k, v] of Object.entries(n)) {
|
|
24
|
+
if (k !== "$id" && v && typeof v === "object") stampPaths(v, `${path}.${k}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Drop every `$`-prefixed key, so a file and the composed tree compare on content alone.
|
|
30
|
+
*
|
|
31
|
+
* Not just my own `$id`: a source file also carries `$schema` for editor support, and the
|
|
32
|
+
* server strips it when composing. Comparing `$id` only meant a layout file never matched
|
|
33
|
+
* the node it produces, and every line in it reported "not rendered".
|
|
34
|
+
*/
|
|
35
|
+
function withoutMeta(value: unknown): unknown {
|
|
36
|
+
if (Array.isArray(value)) return value.map(withoutMeta);
|
|
37
|
+
if (!value || typeof value !== "object") return value;
|
|
38
|
+
const out: Record<string, unknown> = {};
|
|
39
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
40
|
+
if (!k.startsWith("$")) out[k] = withoutMeta(v);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The composed path of the first node whose content matches `subtree`, or null.
|
|
47
|
+
* Definitions are small and the match is exact, so this is cheap and unambiguous.
|
|
48
|
+
*/
|
|
49
|
+
export function locateSubtree(composed: unknown, subtree: unknown): string | null {
|
|
50
|
+
const target = JSON.stringify(withoutMeta(subtree));
|
|
51
|
+
let found: string | null = null;
|
|
52
|
+
const walk = (node: unknown): void => {
|
|
53
|
+
if (found || !node || typeof node !== "object") return;
|
|
54
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
55
|
+
const n = node as Record<string, unknown>;
|
|
56
|
+
if (typeof n.type === "string" && typeof n.$id === "string" && JSON.stringify(withoutMeta(n)) === target) {
|
|
57
|
+
found = n.$id;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
Object.values(n).forEach(walk);
|
|
61
|
+
};
|
|
62
|
+
walk(composed);
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The innermost node containing `offset`. */
|
|
67
|
+
export function pathAtOffset(ranges: { path: string; from: number; to: number }[], offset: number): string | null {
|
|
68
|
+
let best: { path: string; span: number } | null = null;
|
|
69
|
+
for (const r of ranges) {
|
|
70
|
+
if (offset < r.from || offset > r.to) continue;
|
|
71
|
+
const span = r.to - r.from;
|
|
72
|
+
if (!best || span < best.span) best = { path: r.path, span };
|
|
73
|
+
}
|
|
74
|
+
return best?.path ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every node's path and source range in a parsed YAML document.
|
|
79
|
+
*
|
|
80
|
+
* A NODE IS ONE WHOSE `type` IS A PRIMITIVE. A prop declaration also carries a `type`
|
|
81
|
+
* (`type: string`), so accepting any string made every prop look like a node: the cursor
|
|
82
|
+
* anywhere in `props` reported a node that had of course rendered nothing. The primitive
|
|
83
|
+
* set is the discriminator, and it comes from the same vocabulary the linter enforces.
|
|
84
|
+
*
|
|
85
|
+
* Ranges are the parser's, so they are where a node actually starts and ends rather than a
|
|
86
|
+
* guess from indentation.
|
|
87
|
+
*/
|
|
88
|
+
export function nodeRanges(doc: unknown, primitives: Set<string>): { path: string; from: number; to: number }[] {
|
|
89
|
+
const out: { path: string; from: number; to: number }[] = [];
|
|
90
|
+
if (!primitives.size) return out;
|
|
91
|
+
const walk = (node: any, path: string): void => {
|
|
92
|
+
if (!node?.items || !Array.isArray(node.items)) return;
|
|
93
|
+
// A mapping's items are key/value pairs; a sequence's items are nodes.
|
|
94
|
+
node.items.forEach((item: any, i: number) => {
|
|
95
|
+
if (item?.key !== undefined && item?.value !== undefined) walk(item.value, `${path}.${String(item.key.value ?? item.key)}`);
|
|
96
|
+
else walk(item, `${path}.${i}`);
|
|
97
|
+
});
|
|
98
|
+
const isNode = node.items.some((it: any) => it?.key?.value === "type" && primitives.has(it?.value?.value));
|
|
99
|
+
if (isNode && Array.isArray(node.range)) out.push({ path, from: node.range[0], to: node.range[2] ?? node.range[1] });
|
|
100
|
+
};
|
|
101
|
+
walk((doc as { contents?: unknown }).contents, "root");
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PICK AN ELEMENT, LAND ON THE LINE. The inspector, the other way round.
|
|
3
|
+
*
|
|
4
|
+
* Hover the preview, the element under the pointer is boxed, click and the editor opens the
|
|
5
|
+
* file that drew it with the cursor on the node. The link is the `data-unoverse-id` the
|
|
6
|
+
* renderer stamps, so this needs no new machinery, only the reverse lookup.
|
|
7
|
+
*
|
|
8
|
+
* Two things the DOM makes awkward, both handled here:
|
|
9
|
+
*
|
|
10
|
+
* SHADOW ROOTS. The preview renders inside one, so `elementFromPoint` returns the HOST
|
|
11
|
+
* rather than what is under the pointer. Descending through each root gets the real one.
|
|
12
|
+
*
|
|
13
|
+
* NOT EVERY ELEMENT IS A NODE. A primitive renders one element, but that element has
|
|
14
|
+
* children of its own, so the pointer often lands on something unstamped. Walking up to
|
|
15
|
+
* the nearest ancestor carrying an id gives the node a developer means.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The deepest element under the point, descending through open shadow roots. */
|
|
19
|
+
export function deepElementFromPoint(x: number, y: number): Element | null {
|
|
20
|
+
let el = document.elementFromPoint(x, y);
|
|
21
|
+
for (;;) {
|
|
22
|
+
const shadow = (el as (Element & { shadowRoot?: ShadowRoot | null }) | null)?.shadowRoot;
|
|
23
|
+
const inner = shadow?.elementFromPoint(x, y);
|
|
24
|
+
if (!inner || inner === el) return el;
|
|
25
|
+
el = inner;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The nearest stamped ancestor, crossing shadow boundaries the way `closest` will not. */
|
|
30
|
+
export function stampedAncestor(el: Element | null): { el: Element; id: string } | null {
|
|
31
|
+
let cur: Element | null = el;
|
|
32
|
+
while (cur) {
|
|
33
|
+
const id = cur.getAttribute?.("data-unoverse-id");
|
|
34
|
+
if (id) return { el: cur, id };
|
|
35
|
+
cur = cur.parentElement ?? ((cur.getRootNode() as ShadowRoot).host ?? null);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SourceLocation {
|
|
41
|
+
/** Absolute path of the file that draws this node. */
|
|
42
|
+
path: string;
|
|
43
|
+
/** Character offset of the node in that file. */
|
|
44
|
+
from: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where each composed node is authored, across a component's files.
|
|
49
|
+
*
|
|
50
|
+
* Built by parsing every YAML file, finding where its tree sits in the composed one, and
|
|
51
|
+
* recording the composed id of each node against its offset in that file. It is built once
|
|
52
|
+
* when the picker is armed rather than on every hover, because a component has a dozen
|
|
53
|
+
* files and a hover has a frame.
|
|
54
|
+
*/
|
|
55
|
+
export function buildSourceMap(
|
|
56
|
+
files: { path: string; rel: string; text: string }[],
|
|
57
|
+
composedRoot: unknown,
|
|
58
|
+
primitives: Set<string>,
|
|
59
|
+
parse: (text: string) => unknown,
|
|
60
|
+
nodeRanges: (doc: unknown, primitives: Set<string>) => { path: string; from: number; to: number }[],
|
|
61
|
+
locateSubtree: (composed: unknown, subtree: unknown) => string | null,
|
|
62
|
+
toJS: (doc: unknown) => unknown,
|
|
63
|
+
): Map<string, SourceLocation> {
|
|
64
|
+
const map = new Map<string, SourceLocation>();
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
let doc: unknown;
|
|
67
|
+
try {
|
|
68
|
+
doc = parse(file.text);
|
|
69
|
+
} catch {
|
|
70
|
+
continue; // a file that does not parse simply contributes nothing
|
|
71
|
+
}
|
|
72
|
+
const js = toJS(doc) as Record<string, unknown> | null;
|
|
73
|
+
if (!js || typeof js !== "object") continue;
|
|
74
|
+
|
|
75
|
+
// An envelope's tree IS the composed root; a partial has to be located inside it.
|
|
76
|
+
const isEnvelope = !js.type && !!js.root;
|
|
77
|
+
const prefix = isEnvelope ? "root" : locateSubtree(composedRoot, js);
|
|
78
|
+
if (!prefix) continue;
|
|
79
|
+
const strip = isEnvelope ? "root.root".length : "root".length;
|
|
80
|
+
|
|
81
|
+
for (const r of nodeRanges(doc, primitives)) {
|
|
82
|
+
const id = `${prefix}${r.path.slice(strip)}`;
|
|
83
|
+
// FIRST WRITER WINS. A node's own file is reached before any file that includes it,
|
|
84
|
+
// so the deepest authored source keeps the entry.
|
|
85
|
+
if (!map.has(id)) map.set(id, { path: file.path, from: r.from });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useState } from "react";
|
|
15
15
|
import type { ResolvedTheme, UnoverseDefinition, UnoverseClient } from "@unoverse/sdk";
|
|
16
|
+
import { stampPaths } from "../screens/source/inspect";
|
|
16
17
|
|
|
17
18
|
/** Everything is same-origin: the Vite dev server serves both the app and the routes. */
|
|
18
19
|
export const UNOVERSE_BASE = "";
|
|
@@ -20,6 +21,17 @@ export const UNOVERSE_BASE = "";
|
|
|
20
21
|
/** No login offline. Kept so the shared screens can ask without branching on host. */
|
|
21
22
|
export const hasAuth = false;
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* SOURCE ACCESS: this host serves the files behind a definition, so the screens may show
|
|
26
|
+
* the source view. Local Studio reads the developer's own folder, so it can.
|
|
27
|
+
*
|
|
28
|
+
* The capability lives on the host for the reason `hasAuth` does: a shared screen must
|
|
29
|
+
* never branch on which app it is running in. Canvas leaves it false, so hosted surfaces
|
|
30
|
+
* render and never edit, by construction rather than by discipline
|
|
31
|
+
* (docs/architecture/LOCAL_STUDIO.md §The capability gate).
|
|
32
|
+
*/
|
|
33
|
+
export const hasSourceAccess = true;
|
|
34
|
+
|
|
23
35
|
/** Plain fetch. The bearer the Canvas host attaches has no meaning against a folder. */
|
|
24
36
|
export async function authedFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
25
37
|
return fetch(input, init);
|
|
@@ -82,10 +94,23 @@ class LocalUnoverseClient {
|
|
|
82
94
|
);
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Read a definition, and STAMP EVERY NODE WITH ITS PATH.
|
|
99
|
+
*
|
|
100
|
+
* The renderer copies a node's `$id` onto the element it draws, which is what lets the
|
|
101
|
+
* editor highlight the thing under the cursor. The id is the node's path in this
|
|
102
|
+
* COMPOSED tree (`root.children.0.children.1`), assigned here because the host is the
|
|
103
|
+
* last place that sees the tree before the renderer does.
|
|
104
|
+
*
|
|
105
|
+
* Local Studio only. A universe serves definitions to real users, who have no editor to
|
|
106
|
+
* link to and no business carrying authoring metadata.
|
|
107
|
+
*/
|
|
85
108
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
86
109
|
const r = await fetch(`/local/definition?uri=${encodeURIComponent(uri)}`);
|
|
87
110
|
if (!r.ok) throw new Error(`${uri}: ${(await r.json().catch(() => ({}))).error ?? r.status}`);
|
|
88
|
-
|
|
111
|
+
const def = (await r.json()) as UnoverseDefinition;
|
|
112
|
+
stampPaths((def as { root?: unknown }).root, "root");
|
|
113
|
+
return def;
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -208,6 +208,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
208
208
|
if (conn.resolveElicitation(state)) return;
|
|
209
209
|
conn.sendAction(t, {
|
|
210
210
|
componentNodeId: meta?.nodeId,
|
|
211
|
+
componentType: meta?.type,
|
|
211
212
|
// The component's OWN chatId is the TURN id — the waiting node execution is
|
|
212
213
|
// keyed `<turn>:<nodeId>`, not the conversation-level chatId.
|
|
213
214
|
componentChatId: meta?.chatId,
|