@jxsuite/studio 0.31.1 → 0.33.0

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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Context-manager.js — Token budget management for the AI assistant
3
+ *
4
+ * Estimates token usage and trims conversation history before each send to keep the
5
+ * context window within safe limits (ADR docs/ai-assistant-decision.md §11.4).
6
+ *
7
+ * Strategy (MVP): keep the system prompt + the most recent messages, dropping the oldest
8
+ * messages when the estimated total exceeds the configured token budget. Dropped messages
9
+ * are replaced with a single summary note so the model knows history was truncated.
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import type { createChatState } from "@jxsuite/ai/chat-state";
15
+
16
+ /** Shape of a single entry returned by `chatState.toMessagesArray()`. */
17
+ interface MessageArrayEntry {
18
+ role: string;
19
+ content?: string | null;
20
+ tool_calls?: {
21
+ function?: { name?: string; arguments?: string };
22
+ }[];
23
+ }
24
+
25
+ // ─── Constants ──────────────────────────────────────────────────────────────
26
+
27
+ /** Rough heuristic: average 4 characters per token. */
28
+ const CHARS_PER_TOKEN = 4;
29
+
30
+ /**
31
+ * Approximate context windows (in tokens) per model id, longest-prefix matched. Used to derive a
32
+ * model-aware budget instead of a flat cap (ADR §14.2). Conservative fallback for unknown models.
33
+ */
34
+ const MODEL_CONTEXT_WINDOWS: [string, number][] = [
35
+ ["gpt-4o", 128_000],
36
+ ["gpt-4.1", 1_000_000],
37
+ ["gpt-4-turbo", 128_000],
38
+ ["gpt-4", 8192],
39
+ ["gpt-3.5", 16_385],
40
+ ["o1", 200_000],
41
+ ["o3", 200_000],
42
+ ["claude", 200_000],
43
+ ];
44
+
45
+ /** Fallback window for models not in the table above. */
46
+ const DEFAULT_CONTEXT_WINDOW = 32_000;
47
+
48
+ /** Fraction of the window we'll actually fill before trimming. */
49
+ const BUDGET_FRACTION = 0.8;
50
+
51
+ /** Fraction of the window at which we surface a "context getting large" warning. */
52
+ const WARN_FRACTION = 0.5;
53
+
54
+ /**
55
+ * Resolve the context window (tokens) for a model id via longest-prefix match.
56
+ *
57
+ * @param {string | undefined} model
58
+ * @returns {number}
59
+ */
60
+ function contextWindowFor(model: string | undefined): number {
61
+ if (!model) {
62
+ return DEFAULT_CONTEXT_WINDOW;
63
+ }
64
+ const id = model.toLowerCase();
65
+ let best = 0;
66
+ let window = DEFAULT_CONTEXT_WINDOW;
67
+ for (const [prefix, size] of MODEL_CONTEXT_WINDOWS) {
68
+ if (id.startsWith(prefix) && prefix.length > best) {
69
+ best = prefix.length;
70
+ window = size;
71
+ }
72
+ }
73
+ return window;
74
+ }
75
+
76
+ /** Messages to keep at the tail (most recent), regardless of budget. */
77
+ const KEEP_RECENT = 20;
78
+
79
+ /**
80
+ * Minimum number of user or tool messages we must preserve beyond the recent window so the model
81
+ * retains conversation grounding.
82
+ */
83
+ const MIN_USER_TURNS = 3;
84
+
85
+ // ─── Helpers ────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Estimate token count from a string or array of messages.
89
+ *
90
+ * @param {string | object[]} input
91
+ * @returns {number}
92
+ */
93
+ function estimateTokens(input: string | MessageArrayEntry[]): number {
94
+ if (typeof input === "string") {
95
+ return Math.ceil(input.length / CHARS_PER_TOKEN);
96
+ }
97
+ let total = 0;
98
+ for (const msg of input) {
99
+ total += estimateTokens(msg.content || "");
100
+ // Tool calls carry extra tokens for the function name + arguments.
101
+ if (msg.tool_calls) {
102
+ for (const tc of msg.tool_calls) {
103
+ total +=
104
+ estimateTokens(tc.function?.name || "") +
105
+ estimateTokens(tc.function?.arguments || "") +
106
+ 4; // Framing overhead
107
+ }
108
+ }
109
+ // Each message has ~4 tokens of framing overhead (role, etc.)
110
+ total += 4;
111
+ }
112
+ return total;
113
+ }
114
+
115
+ // ─── Trim ───────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Trim chat history so the total estimated token count stays within the budget. Returns trimming
119
+ * metadata, or null if no trimming was needed.
120
+ *
121
+ * Side effect: drops messages from chatState.messages (in-place via splice) and may insert a
122
+ * summary note.
123
+ *
124
+ * @param {ReturnType<typeof import("@jxsuite/ai/chat-state").createChatState>} chatState
125
+ * @param {string} systemPrompt
126
+ * @returns {{ estimatedTokens: number; droppedCount: number } | null}
127
+ */
128
+ export function trimContext(
129
+ chatState: ReturnType<typeof createChatState>,
130
+ systemPrompt: string,
131
+ ): { estimatedTokens: number; droppedCount: number } | null {
132
+ const window = contextWindowFor(chatState.model);
133
+ const maxTokens = Math.floor(window * BUDGET_FRACTION);
134
+ const warnTokens = Math.floor(window * WARN_FRACTION);
135
+
136
+ const systemTokens = estimateTokens(systemPrompt);
137
+ const allMessages = chatState.messages;
138
+ const messageTokens = estimateTokens(chatState.toMessagesArray());
139
+ const total = systemTokens + messageTokens;
140
+
141
+ if (total <= maxTokens) {
142
+ chatState.setTokenCount(total);
143
+ // Warn once the conversation crosses half the window, even before we trim.
144
+ chatState.setContextWarning(total >= warnTokens);
145
+ return { estimatedTokens: total, droppedCount: 0 };
146
+ }
147
+
148
+ // Must trim. How many messages to drop?
149
+ // Keep the most recent KEEP_RECENT messages.
150
+ // But also ensure we keep at least MIN_USER_TURNS worth of user/tool context.
151
+ let keepFrom = Math.max(0, allMessages.length - KEEP_RECENT);
152
+
153
+ // Walk backward from keepFrom to find MIN_USER_TURNS user/tool messages to preserve.
154
+ let preservedTurns = 0;
155
+ for (let i = allMessages.length - 1; i >= keepFrom; i--) {
156
+ const { role } = allMessages[i]!;
157
+ if (role === "user" || role === "tool") {
158
+ preservedTurns += 1;
159
+ }
160
+ }
161
+
162
+ // If we don't have enough turns in the recent window, extend backward.
163
+ if (preservedTurns < MIN_USER_TURNS) {
164
+ for (let i = keepFrom - 1; i >= 0 && preservedTurns < MIN_USER_TURNS; i--) {
165
+ keepFrom = i;
166
+ const { role } = allMessages[i]!;
167
+ if (role === "user" || role === "tool") {
168
+ preservedTurns += 1;
169
+ }
170
+ }
171
+ }
172
+
173
+ // Never drop the system message (which is at index 0 if role === "system").
174
+ // For the Jx assistant, there is no system message in the messages array —
175
+ // The system prompt is passed separately. So keepFrom is safe.
176
+
177
+ if (keepFrom <= 0) {
178
+ // Can't trim further without losing everything. Warn but don't truncate.
179
+ chatState.setTokenCount(total);
180
+ chatState.setContextWarning(true);
181
+ return { estimatedTokens: total, droppedCount: 0 };
182
+ }
183
+
184
+ const droppedCount = keepFrom;
185
+ allMessages.splice(0, keepFrom);
186
+
187
+ // Insert a summary note so the model knows context was trimmed.
188
+ allMessages.unshift({
189
+ id: `ctx_summary_${Date.now()}`,
190
+ role: "user",
191
+ content: `[Earlier conversation truncated. ${droppedCount} messages dropped to stay within token budget. The most recent ${allMessages.length - 1} messages are preserved.]`,
192
+ timestamp: Date.now(),
193
+ });
194
+
195
+ const newTotal = systemTokens + estimateTokens(chatState.toMessagesArray());
196
+ chatState.setTokenCount(newTotal);
197
+ chatState.setContextWarning(true);
198
+
199
+ return { estimatedTokens: newTotal, droppedCount };
200
+ }
@@ -0,0 +1,183 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Document-assistant.js — Stack B (canonical) document AI assistant session
4
+ *
5
+ * Wires the @jxsuite/ai infrastructure (chat-state, proxy streaming client, tool registry) to
6
+ * the active Jx document via `transactDoc()`-backed tools, and drives the error-correction
7
+ * agent loop. See docs/ai-assistant-decision.md.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import { createChatState, createProxyStreamingClient, createToolRegistry } from "@jxsuite/ai";
13
+ import type { ProjectConfig } from "@jxsuite/schema/types";
14
+ import { getPlatform } from "../platform";
15
+ import { activeTab, workspace } from "../workspace/workspace";
16
+ import { toRaw } from "../reactivity";
17
+ import { componentRegistry } from "../files/components";
18
+ import { registerAiTools } from "./ai-tools";
19
+ import { runAgentLoop } from "./tool-executor";
20
+ import { buildSystemPrompt } from "./ai-system-prompt";
21
+ import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
22
+ import { trimContext } from "./context-manager";
23
+ import { renderCheck } from "./render-critic";
24
+ import { openFileInTab } from "../files/files";
25
+
26
+ const PERSIST_KEY_PREFIX = "jx-ai-chat-history";
27
+ const MAX_PERSIST_MESSAGES = 50;
28
+
29
+ /**
30
+ * Project-scoped localStorage key (uses the project root) so conversations don't bleed across
31
+ * projects (ADR §11.5 / §14.2). Falls back to a shared key when no project is open.
32
+ *
33
+ * @returns {string}
34
+ */
35
+ function persistKey() {
36
+ return workspace.projectRoot
37
+ ? `${PERSIST_KEY_PREFIX}:${workspace.projectRoot}`
38
+ : PERSIST_KEY_PREFIX;
39
+ }
40
+
41
+ /**
42
+ * Create a document-assistant session bound to the currently active tab.
43
+ *
44
+ * @returns {{
45
+ * chatState: ReturnType<typeof createChatState>;
46
+ * sendMessage: (text: string) => Promise<void>;
47
+ * stop: () => void;
48
+ * newChat: () => void;
49
+ * }}
50
+ */
51
+ export function createDocumentAssistant() {
52
+ const chatState = createChatState({ model: getModel() });
53
+
54
+ const toolRegistry = createToolRegistry();
55
+ registerAiTools(toolRegistry, {
56
+ getTab: () => activeTab.value,
57
+ saveFile: async (relPath: string, content: string) => {
58
+ const plat = getPlatform();
59
+ await plat.writeFile(relPath, content);
60
+ },
61
+ renderCheck: renderCheck as (
62
+ doc: unknown,
63
+ ) => Promise<{ ok: true } | { ok: false; error: string }>,
64
+ openDocument: openFileInTab,
65
+ projectStyle: (workspace.projectConfig as ProjectConfig | null)?.style as
66
+ | Record<string, string>
67
+ | undefined,
68
+ });
69
+
70
+ let controller: AbortController | null = null;
71
+
72
+ function buildPrompt() {
73
+ const tab = activeTab.value;
74
+ return buildSystemPrompt({
75
+ document: tab ? toRaw(tab.doc.document) : undefined,
76
+ projectConfig: (workspace.projectConfig as ProjectConfig | null) || undefined,
77
+ components: componentRegistry.length > 0 ? componentRegistry : undefined,
78
+ projectRoot: workspace.projectRoot || undefined,
79
+ });
80
+ }
81
+
82
+ async function sendMessage(text: string) {
83
+ if (!text.trim() || chatState.status === "streaming") {
84
+ return;
85
+ }
86
+
87
+ chatState.sendMessage(text);
88
+
89
+ // Trim context before streaming to keep the conversation within token limits.
90
+ const trimmed = trimContext(chatState, buildPrompt());
91
+ if (trimmed) {
92
+ chatState.setTokenCount(trimmed.estimatedTokens);
93
+ }
94
+
95
+ // Persist after trimming so the saved history reflects what's actually sent.
96
+ persistChat(chatState);
97
+
98
+ try {
99
+ const plat = getPlatform();
100
+ const chatUrl = await Promise.resolve(plat.aiChatUrl());
101
+ // Re-read the persisted model each send: the session is constructed once at module load
102
+ // (before the user sets a key/model), so the picker's choice must be picked up here.
103
+ chatState.setModel(getModel());
104
+ const streamingClient = createProxyStreamingClient({
105
+ chatUrl,
106
+ model: chatState.model,
107
+ // Sent as X-Api-Key; the proxy falls back to the server's OPENAI_API_KEY when empty.
108
+ apiKey: getOpenAiKey() || undefined,
109
+ // Optional OpenAI-compatible endpoint override; empty uses the proxy default.
110
+ baseUrl: getBaseUrl() || undefined,
111
+ });
112
+
113
+ controller = new AbortController();
114
+ await runAgentLoop({
115
+ chatState,
116
+ streamingClient,
117
+ toolRegistry,
118
+ systemPrompt: buildPrompt(),
119
+ signal: controller.signal,
120
+ getTab: () => activeTab.value,
121
+ });
122
+ } catch (error) {
123
+ /*
124
+ * Synchronous failure (e.g. platform not registered, network unreachable before the
125
+ * stream starts). Set the error so the panel can display it.
126
+ */
127
+ chatState.setError(error instanceof Error ? error.message : String(error));
128
+ } finally {
129
+ controller = null;
130
+ }
131
+ }
132
+
133
+ function stop() {
134
+ controller?.abort();
135
+ chatState.cancelStream();
136
+ }
137
+
138
+ function newChat() {
139
+ stop();
140
+ chatState.clearChat();
141
+ persistChat(chatState);
142
+ }
143
+
144
+ // ── Persistence ───────────────────────────────────────────────────────
145
+
146
+ /** Persist recent messages to localStorage (non-blocking). */
147
+ function persistChat(cs: ReturnType<typeof createChatState>) {
148
+ try {
149
+ const msgs = cs.messages.slice(-MAX_PERSIST_MESSAGES);
150
+ localStorage.setItem(persistKey(), JSON.stringify(msgs));
151
+ } catch {
152
+ // Storage full or unavailable — not critical.
153
+ }
154
+ }
155
+
156
+ /** Restore messages from a previous session, if any. */
157
+ function restoreChat() {
158
+ try {
159
+ const raw = localStorage.getItem(persistKey());
160
+ if (!raw) {
161
+ return;
162
+ }
163
+ const msgs = JSON.parse(raw) as typeof chatState.messages;
164
+ if (!Array.isArray(msgs) || msgs.length === 0) {
165
+ return;
166
+ }
167
+ // Push into chat state (skips streaming state)
168
+ for (const m of msgs) {
169
+ chatState.messages.push({
170
+ ...m,
171
+ id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
172
+ });
173
+ }
174
+ } catch {
175
+ // Corrupt or empty — ignore.
176
+ }
177
+ }
178
+
179
+ // Restore once on creation.
180
+ restoreChat();
181
+
182
+ return { chatState, sendMessage, stop, newChat };
183
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Jx-validate.js — cached Jx document schema validation for the AI assistant.
3
+ *
4
+ * Uses `@jxsuite/schema`'s pre-generated `schema.json` (the committed output of `generateSchema()`)
5
+ * as the eval signal for the agent loop (ADR docs/ai-assistant-decision.md §6b). We import the
6
+ * generated JSON rather than the generator itself: `generateSchema()` pulls in `@webref/*` + Node
7
+ * builtins (node:path/process) and would crash a browser bundle. The ajv validator is compiled once
8
+ * per session — `@jxsuite/schema`'s `validateDocument` recompiles on every call, which is too slow
9
+ * to run after every mutation in the loop.
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import schema from "@jxsuite/schema/schema.json";
15
+
16
+ type ValidateFn = ((doc: unknown) => boolean) & {
17
+ errors?: { instancePath?: string; message?: string }[] | null;
18
+ };
19
+
20
+ let _validate: ValidateFn | null = null;
21
+ let _loading: Promise<ValidateFn | null> | null = null;
22
+
23
+ /** Compile (once) and return the ajv validate function, or null if ajv is unavailable. */
24
+ function getValidator() {
25
+ if (_validate) {
26
+ return Promise.resolve(_validate);
27
+ }
28
+ if (!_loading) {
29
+ _loading = (async () => {
30
+ try {
31
+ // The generated schema is JSON Schema draft 2020-12, so it needs Ajv's 2020 build —
32
+ // The default export only knows draft-07 and throws on the 2020 meta-schema ref.
33
+ // Optional peer dependency: validation degrades to a no-op if ajv is absent or the
34
+ // Schema fails to compile.
35
+ const { default: Ajv2020 } = await import("ajv/dist/2020.js");
36
+ const { default: addFormats } = await import("ajv-formats");
37
+ const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true });
38
+ addFormats(ajv);
39
+ _validate = ajv.compile(schema as object);
40
+ return _validate;
41
+ } catch {
42
+ return null;
43
+ }
44
+ })();
45
+ }
46
+ return _loading;
47
+ }
48
+
49
+ /**
50
+ * Validate a Jx document against the schema.
51
+ *
52
+ * @param {unknown} doc
53
+ * @returns {Promise<string[]>} Formatted error strings; empty when valid or validation unavailable
54
+ */
55
+ export async function validateDoc(doc: unknown) {
56
+ const validate = await getValidator();
57
+ if (!validate) {
58
+ return [];
59
+ }
60
+ const valid = validate(doc);
61
+ if (valid) {
62
+ return [];
63
+ }
64
+ return (validate.errors || []).map((e) => `${e.instancePath || "(root)"}: ${e.message}`);
65
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Render-critic.js — Detached render probe for the AI agent loop
3
+ *
4
+ * After a mutation passes schema validation, this module attempts a lightweight headless render
5
+ * (buildScope → renderNode into a throwaway div) and reports render-time errors back through the
6
+ * same { ok, error } shape that applyAndValidate consumes. The model can then self-correct
7
+ * within the round budget instead of the user discovering a blank canvas.
8
+ *
9
+ * v1 scope: catches render throws only (missing $ref, malformed Function body, bad template
10
+ * expressions). Does NOT detect empty/zero-node output or deep-render custom element internals
11
+ * (connectedCallback doesn't fire on detached nodes).
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ import { buildScope, renderNode, setSkipServerFunctions } from "@jxsuite/runtime";
17
+ import type { JxDocument } from "@jxsuite/schema/types";
18
+ import { effectScope } from "../reactivity";
19
+
20
+ /**
21
+ * Translate a render-time error into an actionable message the model can fix.
22
+ *
23
+ * @param {Error} err
24
+ * @returns {string}
25
+ */
26
+ function translateRenderError(err: Error): string {
27
+ const msg = err.message || String(err);
28
+
29
+ if (msg.includes("is not defined") || msg.includes("Cannot read properties of")) {
30
+ return `Render error: ${msg}\n → Fix: A $ref or template expression references a name that doesn't exist in state. Check that all $ref paths and \${...} expressions match entries in the document's "state" object.`;
31
+ }
32
+
33
+ if (msg.includes("is not a function")) {
34
+ return `Render error: ${msg}\n → Fix: An event handler or computed value references something that isn't a function. Check $prototype: "Function" entries have a valid "body" string, and $ref event handlers point to function-typed state entries.`;
35
+ }
36
+
37
+ if (msg.includes("is not a constructor")) {
38
+ return `Render error: ${msg}\n → Fix: A $prototype entry references a class or constructor that couldn't be resolved. Check the import path and $export name.`;
39
+ }
40
+
41
+ return `Render error: ${msg}\n → Fix: The document is schema-valid but fails to render. Review the last change for typos in $ref paths, template expressions, or event handler definitions.`;
42
+ }
43
+
44
+ /**
45
+ * Attempt a detached render of a Jx document. Returns { ok: true } on success or { ok: false,
46
+ * error: string } with an actionable message on failure.
47
+ *
48
+ * @param {import("@jxsuite/schema/types").JxDocument} doc
49
+ * @returns {Promise<{ ok: true } | { ok: false; error: string }>}
50
+ */
51
+ export async function renderCheck(
52
+ doc: JxDocument,
53
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
54
+ const scope = effectScope();
55
+ try {
56
+ setSkipServerFunctions(true);
57
+
58
+ const state = await buildScope(doc, {});
59
+
60
+ const container = document.createElement("div");
61
+ scope.run(() => {
62
+ container.append(renderNode(doc, state));
63
+ });
64
+
65
+ return { ok: true };
66
+ } catch (error) {
67
+ return { ok: false, error: translateRenderError(error as Error) };
68
+ } finally {
69
+ // Dispose all effects created synchronously by renderNode. Effects from the async
70
+ // BuildScope (prototype/computed setup) escape the scope because Vue tracks the active
71
+ // Scope synchronously — they're GC-eligible once the throwaway state/container are
72
+ // Unreferenced. This bounded leak is acceptable for v1.
73
+ scope.stop();
74
+ }
75
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Token-lint.js — flags hard-coded values in a Jx document that match a project design token.
3
+ *
4
+ * Soft hints: findings are surfaced in tool-result summaries so the model can self-correct. Does
5
+ * NOT fail mutations — see docs/ai-assistant-premium-components-plan.md §5 Phase 4.
6
+ */
7
+
8
+ import type { JxMutableNode } from "@jxsuite/schema/types";
9
+
10
+ interface TokenLintFinding {
11
+ path: string;
12
+ property: string;
13
+ value: string;
14
+ suggestedToken: string;
15
+ }
16
+
17
+ /**
18
+ * Build a reverse lookup from token values to token names. Normalizes hex colors to lowercase for
19
+ * case-insensitive matching.
20
+ *
21
+ * @param {Record<string, string>} projectStyle
22
+ * @returns {Map<string, string>}
23
+ */
24
+ function buildTokenIndex(projectStyle: Record<string, string>) {
25
+ const index = new Map<string, string>();
26
+ for (const [key, value] of Object.entries(projectStyle)) {
27
+ if (!key.startsWith("--") || typeof value !== "string") {
28
+ continue;
29
+ }
30
+ const norm = value.toLowerCase().trim();
31
+ if (!index.has(norm)) {
32
+ index.set(norm, key);
33
+ }
34
+ }
35
+ return index;
36
+ }
37
+
38
+ /**
39
+ * Check whether a CSS value is already a token reference.
40
+ *
41
+ * @param {string} value
42
+ * @returns {boolean}
43
+ */
44
+ function isTokenRef(value: string) {
45
+ return typeof value === "string" && value.includes("var(--");
46
+ }
47
+
48
+ /**
49
+ * Check whether a CSS value is a template expression (dynamic).
50
+ *
51
+ * @param {string} value
52
+ * @returns {boolean}
53
+ */
54
+ function isTemplate(value: string) {
55
+ return typeof value === "string" && value.includes("${");
56
+ }
57
+
58
+ /**
59
+ * Scan a Jx document for hard-coded style values that match a project design token.
60
+ *
61
+ * @param {object} doc - The Jx document (or subtree)
62
+ * @param {Record<string, string>} projectStyle - The project.json style object
63
+ * @returns {TokenLintFinding[]}
64
+ */
65
+ export function flagHardcodedTokens(
66
+ doc: JxMutableNode | null | undefined,
67
+ projectStyle: Record<string, string> | null | undefined,
68
+ ): TokenLintFinding[] {
69
+ if (!doc || !projectStyle) {
70
+ return [];
71
+ }
72
+
73
+ const tokenIndex = buildTokenIndex(projectStyle);
74
+ if (tokenIndex.size === 0) {
75
+ return [];
76
+ }
77
+
78
+ const findings: TokenLintFinding[] = [];
79
+
80
+ function walk(node: unknown, pathPrefix: string) {
81
+ if (!node || typeof node !== "object") {
82
+ return;
83
+ }
84
+ const el = node as JxMutableNode;
85
+
86
+ if (el.style && typeof el.style === "object") {
87
+ for (const [prop, val] of Object.entries(el.style)) {
88
+ if (prop.startsWith("@")) {
89
+ continue;
90
+ }
91
+ if (typeof val !== "string") {
92
+ continue;
93
+ }
94
+ if (isTokenRef(val) || isTemplate(val)) {
95
+ continue;
96
+ }
97
+
98
+ const norm = val.toLowerCase().trim();
99
+ const token = tokenIndex.get(norm);
100
+ if (token) {
101
+ findings.push({
102
+ path: pathPrefix,
103
+ property: prop,
104
+ value: val,
105
+ suggestedToken: token,
106
+ });
107
+ }
108
+ }
109
+ }
110
+
111
+ if (Array.isArray(el.children)) {
112
+ for (let i = 0; i < el.children.length; i++) {
113
+ const child = el.children[i];
114
+ if (child && typeof child === "object") {
115
+ walk(child, `${pathPrefix} > ${child.tagName || `[${i}]`}`);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ const tag = doc.tagName || "root";
122
+ walk(doc, tag);
123
+ return findings;
124
+ }
125
+
126
+ /**
127
+ * Format findings as a human/model-readable hint string.
128
+ *
129
+ * @param {TokenLintFinding[]} findings
130
+ * @returns {string}
131
+ */
132
+ export function formatTokenHints(findings: TokenLintFinding[]) {
133
+ if (findings.length === 0) {
134
+ return "";
135
+ }
136
+ const lines = findings.map(
137
+ (f) => `- ${f.path} → ${f.property}: "${f.value}" — use var(${f.suggestedToken}) instead`,
138
+ );
139
+ return `Token hints (prefer design tokens over hard-coded values):\n${lines.join("\n")}`;
140
+ }