@iloveagents/foundry-web-ui 0.29.1 → 0.31.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.
- package/dist/components/ag-ui-runtime-provider.d.ts +8 -3
- package/dist/components/ag-ui-runtime-provider.js +76 -33
- package/dist/components/assistant-chat.d.ts +1 -15
- package/dist/components/assistant-chat.js +4 -5
- package/dist/components/chat-bubble.js +3 -4
- package/dist/components/chat-context-items.d.ts +6 -0
- package/dist/components/chat-context-items.js +25 -0
- package/dist/components/chat-context.js +10 -2
- package/dist/components/chat-empty-state.js +1 -1
- package/dist/components/chat-header.js +2 -1
- package/dist/components/composer-add-menu.d.ts +0 -19
- package/dist/components/composer-add-menu.js +2 -10
- package/dist/components/context-badges.d.ts +0 -14
- package/dist/components/context-badges.js +2 -29
- package/dist/components/context-bar.d.ts +1 -21
- package/dist/components/context-bar.js +3 -74
- package/dist/components/focus-chat-pane.js +1 -1
- package/dist/components/markdown-text.js +6 -4
- package/dist/components/sidebar.js +51 -17
- package/dist/components/tool-panel.js +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/lib/ag-ui-adapter.d.ts +3 -0
- package/dist/lib/ag-ui-adapter.js +75 -11
- package/dist/lib/auth-provider.js +1 -1
- package/dist/lib/chat-runs-store.d.ts +40 -0
- package/dist/lib/chat-runs-store.js +173 -0
- package/dist/lib/merge-chat-state.d.ts +3 -0
- package/dist/lib/merge-chat-state.js +21 -0
- package/dist/lib/nav-config.js +47 -20
- package/dist/lib/theme-runtime.d.ts +23 -0
- package/dist/lib/theme-runtime.js +210 -30
- package/dist/lib/theme-store.d.ts +3 -1
- package/dist/lib/theme-store.js +3 -2
- package/dist/lib/use-new-conversation.js +4 -8
- package/dist/styles.css +14 -0
- package/dist/workbench/chat-header.js +4 -3
- package/dist/workbench/conversation-history.d.ts +1 -1
- package/dist/workbench/conversation-history.js +75 -19
- package/dist/workbench/running-chats-menu.d.ts +3 -0
- package/dist/workbench/running-chats-menu.js +20 -0
- package/dist/workbench/selection-bridge.js +2 -5
- package/dist/workbench/use-workbench-state.d.ts +1 -2
- package/dist/workbench/use-workbench-state.js +15 -18
- package/dist/workbench/welcome-intro.js +1 -1
- package/dist/workbench/workbench-styles.js +54 -6
- package/dist/workbench/workbench.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { agentStateStore, citationStore, streamingStatusStore, } from "@iloveagents/foundry-agent";
|
|
3
|
+
export const isChatRunActive = (run) => run.status === "running" || run.status === "waiting";
|
|
4
|
+
/** Browser-session activity. Message persistence remains owned by the history adapter. */
|
|
5
|
+
export const useChatRuns = create(() => ({ foregroundId: null, runs: {}, deletedThreads: {} }));
|
|
6
|
+
const agentStates = new Map();
|
|
7
|
+
const appProjections = new Map();
|
|
8
|
+
export function getChatStateSnapshot(threadId) {
|
|
9
|
+
return { state: agentStates.get(threadId)?.state, projection: appProjections.get(threadId) };
|
|
10
|
+
}
|
|
11
|
+
export function retainChatProjection(threadId, projection) {
|
|
12
|
+
appProjections.set(threadId, structuredClone(projection));
|
|
13
|
+
}
|
|
14
|
+
export function publishChatAgentState(threadId, state, patch) {
|
|
15
|
+
if (useChatRuns.getState().deletedThreads[threadId])
|
|
16
|
+
return;
|
|
17
|
+
agentStates.set(threadId, { state, patch });
|
|
18
|
+
if (isForegroundChat(threadId))
|
|
19
|
+
agentStateStore.getState().setAgentState(state, patch);
|
|
20
|
+
}
|
|
21
|
+
const signals = new Map();
|
|
22
|
+
export function isForegroundChat(threadId) {
|
|
23
|
+
const foreground = useChatRuns.getState().foregroundId;
|
|
24
|
+
return foreground === null || foreground === threadId;
|
|
25
|
+
}
|
|
26
|
+
export function selectChatRun(threadId) {
|
|
27
|
+
useChatRuns.setState({ foregroundId: threadId });
|
|
28
|
+
citationStore.getState().selectThread(threadId);
|
|
29
|
+
const stored = agentStates.get(threadId);
|
|
30
|
+
if (stored)
|
|
31
|
+
agentStateStore.getState().setAgentState(stored.state, stored.patch);
|
|
32
|
+
else
|
|
33
|
+
agentStateStore.getState().reset();
|
|
34
|
+
streamingStatusStore.setState(signals.get(threadId) ?? {
|
|
35
|
+
streamingStatus: { status: "idle" },
|
|
36
|
+
runStartedAt: null,
|
|
37
|
+
lastSignalAt: null,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Call after a successful conversation deletion so session activity cannot restore it. */
|
|
41
|
+
export function removeChatRun(threadId) {
|
|
42
|
+
const run = useChatRuns.getState().runs[threadId];
|
|
43
|
+
if (run && isChatRunActive(run))
|
|
44
|
+
run.stop();
|
|
45
|
+
useChatRuns.setState((state) => {
|
|
46
|
+
const runs = { ...state.runs };
|
|
47
|
+
delete runs[threadId];
|
|
48
|
+
return { runs, deletedThreads: { ...state.deletedThreads, [threadId]: true } };
|
|
49
|
+
});
|
|
50
|
+
citationStore.getState().clear(threadId);
|
|
51
|
+
signals.delete(threadId);
|
|
52
|
+
agentStates.delete(threadId);
|
|
53
|
+
appProjections.delete(threadId);
|
|
54
|
+
if (useChatRuns.getState().foregroundId === threadId) {
|
|
55
|
+
agentStateStore.getState().reset();
|
|
56
|
+
streamingStatusStore.setState({
|
|
57
|
+
streamingStatus: { status: "idle" },
|
|
58
|
+
runStartedAt: null,
|
|
59
|
+
lastSignalAt: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function beginChatRun(run) {
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
useChatRuns.setState((s) => ({
|
|
66
|
+
runs: {
|
|
67
|
+
...s.runs,
|
|
68
|
+
[run.threadId]: { ...run, status: "running", startedAt },
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
signals.set(run.threadId, {
|
|
72
|
+
streamingStatus: { status: "thinking" },
|
|
73
|
+
runStartedAt: startedAt,
|
|
74
|
+
lastSignalAt: startedAt,
|
|
75
|
+
});
|
|
76
|
+
touchChatRun(run.threadId);
|
|
77
|
+
}
|
|
78
|
+
export function touchChatRun(threadId, status) {
|
|
79
|
+
const previous = signals.get(threadId);
|
|
80
|
+
if (!previous)
|
|
81
|
+
return;
|
|
82
|
+
const next = {
|
|
83
|
+
...previous,
|
|
84
|
+
lastSignalAt: Date.now(),
|
|
85
|
+
...(status ? { streamingStatus: status } : {}),
|
|
86
|
+
};
|
|
87
|
+
signals.set(threadId, next);
|
|
88
|
+
if (isForegroundChat(threadId))
|
|
89
|
+
streamingStatusStore.setState(next);
|
|
90
|
+
}
|
|
91
|
+
export function updateChatRun(threadId, status, error) {
|
|
92
|
+
useChatRuns.setState((s) => {
|
|
93
|
+
const run = s.runs[threadId];
|
|
94
|
+
if (!run)
|
|
95
|
+
return s;
|
|
96
|
+
return {
|
|
97
|
+
runs: {
|
|
98
|
+
...s.runs,
|
|
99
|
+
[threadId]: {
|
|
100
|
+
...run,
|
|
101
|
+
status,
|
|
102
|
+
...(error ? { error } : {}),
|
|
103
|
+
...(["completed", "failed", "cancelled"].includes(status)
|
|
104
|
+
? { finishedAt: Date.now() }
|
|
105
|
+
: {}),
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
if (status !== "running" && status !== "waiting") {
|
|
111
|
+
signals.delete(threadId);
|
|
112
|
+
if (isForegroundChat(threadId))
|
|
113
|
+
streamingStatusStore.setState({
|
|
114
|
+
streamingStatus: { status: "idle" },
|
|
115
|
+
runStartedAt: null,
|
|
116
|
+
lastSignalAt: null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Browser tools need the conversation in front, so they cannot act on another chat's page. */
|
|
121
|
+
export async function waitForForegroundChat(threadId, signal) {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
if (isForegroundChat(threadId))
|
|
124
|
+
return;
|
|
125
|
+
updateChatRun(threadId, "waiting");
|
|
126
|
+
await new Promise((resolve, reject) => {
|
|
127
|
+
const cleanup = () => {
|
|
128
|
+
unsubscribe();
|
|
129
|
+
signal.removeEventListener("abort", aborted);
|
|
130
|
+
};
|
|
131
|
+
const aborted = () => {
|
|
132
|
+
cleanup();
|
|
133
|
+
reject(signal.reason ?? new DOMException("Stopped", "AbortError"));
|
|
134
|
+
};
|
|
135
|
+
const unsubscribe = useChatRuns.subscribe(() => {
|
|
136
|
+
if (isForegroundChat(threadId)) {
|
|
137
|
+
cleanup();
|
|
138
|
+
resolve();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
142
|
+
if (signal.aborted)
|
|
143
|
+
aborted();
|
|
144
|
+
else if (isForegroundChat(threadId)) {
|
|
145
|
+
cleanup();
|
|
146
|
+
resolve();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
updateChatRun(threadId, "running");
|
|
150
|
+
}
|
|
151
|
+
export function warnBeforeChatUnload(event) {
|
|
152
|
+
if (!Object.values(useChatRuns.getState().runs).some(isChatRunActive))
|
|
153
|
+
return;
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
event.returnValue = "";
|
|
156
|
+
}
|
|
157
|
+
/** Dispose browser-owned runs when the authenticated runtime is torn down. */
|
|
158
|
+
export function disposeChatRuns() {
|
|
159
|
+
for (const run of Object.values(useChatRuns.getState().runs)) {
|
|
160
|
+
if (isChatRunActive(run))
|
|
161
|
+
run.stop();
|
|
162
|
+
}
|
|
163
|
+
signals.clear();
|
|
164
|
+
agentStates.clear();
|
|
165
|
+
appProjections.clear();
|
|
166
|
+
citationStore.setState({ results: [], byThread: {}, threadId: null });
|
|
167
|
+
useChatRuns.setState({ foregroundId: null, runs: {}, deletedThreads: {} });
|
|
168
|
+
streamingStatusStore.setState({
|
|
169
|
+
streamingStatus: { status: "idle" },
|
|
170
|
+
runStartedAt: null,
|
|
171
|
+
lastSignalAt: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Apply changes in the visible UI to retained server state, preserving server
|
|
2
|
+
* updates when the corresponding UI value has not changed since the last turn. */
|
|
3
|
+
export declare function mergeChatState(server: Record<string, unknown>, previous: Record<string, unknown> | undefined, current: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Apply changes in the visible UI to retained server state, preserving server
|
|
2
|
+
* updates when the corresponding UI value has not changed since the last turn. */
|
|
3
|
+
export function mergeChatState(server, previous, current) {
|
|
4
|
+
const result = { ...server };
|
|
5
|
+
for (const key of new Set([...Object.keys(previous ?? {}), ...Object.keys(current)])) {
|
|
6
|
+
const before = previous?.[key];
|
|
7
|
+
const after = current[key];
|
|
8
|
+
if (previous && JSON.stringify(before) === JSON.stringify(after))
|
|
9
|
+
continue;
|
|
10
|
+
if (!(key in current)) {
|
|
11
|
+
delete result[key];
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
result[key] =
|
|
16
|
+
object(before) && object(after) && object(server[key])
|
|
17
|
+
? mergeChatState(server[key], before, after)
|
|
18
|
+
: after;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
package/dist/lib/nav-config.js
CHANGED
|
@@ -92,23 +92,40 @@ export const DEFAULT_NAV_CONFIG = [
|
|
|
92
92
|
// },
|
|
93
93
|
];
|
|
94
94
|
function findExactNavItem(items, path) {
|
|
95
|
+
const target = new URL(path, "http://navigation.local");
|
|
96
|
+
let best = null;
|
|
97
|
+
let specificity = -1;
|
|
95
98
|
for (const item of items) {
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const route = new URL(item.to, target.origin);
|
|
100
|
+
if (route.pathname.replace(/\/+$/, "") === target.pathname.replace(/\/+$/, "") &&
|
|
101
|
+
[...route.searchParams].every(([key, value]) => target.searchParams.getAll(key).includes(value)) &&
|
|
102
|
+
route.searchParams.size > specificity) {
|
|
103
|
+
best = item;
|
|
104
|
+
specificity = route.searchParams.size;
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
const nested = item.children && findExactNavItem(item.children, path);
|
|
107
|
+
if (nested) {
|
|
108
|
+
const nestedSpecificity = new URL(nested.to, target.origin).searchParams.size;
|
|
109
|
+
if (nestedSpecificity > specificity) {
|
|
110
|
+
best = nested;
|
|
111
|
+
specificity = nestedSpecificity;
|
|
112
|
+
}
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
|
-
return
|
|
115
|
+
return best;
|
|
106
116
|
}
|
|
107
117
|
function findBestPrefixNavItem(items, path, currentBest = null) {
|
|
108
118
|
let best = currentBest;
|
|
119
|
+
const target = new URL(path, "http://navigation.local");
|
|
109
120
|
for (const item of items) {
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
const route = new URL(item.to, target.origin);
|
|
122
|
+
const pathname = route.pathname.replace(/\/+$/, "");
|
|
123
|
+
const scope = route.searchParams.size;
|
|
124
|
+
if (target.pathname.startsWith(pathname + "/") &&
|
|
125
|
+
[...route.searchParams].every(([key, value]) => target.searchParams.getAll(key).includes(value)) &&
|
|
126
|
+
(pathname.length > (best?.length ?? 0) ||
|
|
127
|
+
(pathname.length === best?.length && scope > best.scope))) {
|
|
128
|
+
best = { item, length: pathname.length, scope };
|
|
112
129
|
}
|
|
113
130
|
if (item.children) {
|
|
114
131
|
best = findBestPrefixNavItem(item.children, path, best);
|
|
@@ -117,22 +134,29 @@ function findBestPrefixNavItem(items, path, currentBest = null) {
|
|
|
117
134
|
return best;
|
|
118
135
|
}
|
|
119
136
|
export function findNavItem(config, path) {
|
|
120
|
-
//
|
|
137
|
+
// Prefer matching scope parameters; unrelated table filters do not change the page.
|
|
138
|
+
let exact = null;
|
|
139
|
+
let specificity = -1;
|
|
121
140
|
for (const group of config) {
|
|
122
|
-
const base = {
|
|
123
|
-
group: group.label,
|
|
124
|
-
groupMeta: group.meta,
|
|
125
|
-
groupDescription: group.description,
|
|
126
|
-
groupInstructions: group.contextInstructions,
|
|
127
|
-
};
|
|
128
141
|
const match = findExactNavItem(group.items, path);
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
const score = match ? new URL(match.to, "http://navigation.local").searchParams.size : -1;
|
|
143
|
+
if (match && score > specificity) {
|
|
144
|
+
specificity = score;
|
|
145
|
+
exact = {
|
|
146
|
+
group: group.label,
|
|
147
|
+
groupMeta: group.meta,
|
|
148
|
+
groupDescription: group.description,
|
|
149
|
+
groupInstructions: group.contextInstructions,
|
|
150
|
+
item: match,
|
|
151
|
+
};
|
|
131
152
|
}
|
|
132
153
|
}
|
|
154
|
+
if (exact)
|
|
155
|
+
return exact;
|
|
156
|
+
const pathname = new URL(path, "http://navigation.local").pathname;
|
|
133
157
|
// Pass 2: group-level `to` match (e.g. /spaces matches Workspaces group)
|
|
134
158
|
for (const group of config) {
|
|
135
|
-
if (group.to && group.to ===
|
|
159
|
+
if (group.to && group.to === pathname) {
|
|
136
160
|
return {
|
|
137
161
|
group: group.label,
|
|
138
162
|
groupMeta: group.meta,
|
|
@@ -145,6 +169,7 @@ export function findNavItem(config, path) {
|
|
|
145
169
|
// Pass 3: longest-prefix match (for dynamic/parameterized routes)
|
|
146
170
|
let best = null;
|
|
147
171
|
let bestLen = 0;
|
|
172
|
+
let bestScope = -1;
|
|
148
173
|
for (const group of config) {
|
|
149
174
|
const base = {
|
|
150
175
|
group: group.label,
|
|
@@ -153,9 +178,11 @@ export function findNavItem(config, path) {
|
|
|
153
178
|
groupInstructions: group.contextInstructions,
|
|
154
179
|
};
|
|
155
180
|
const match = findBestPrefixNavItem(group.items, path);
|
|
156
|
-
if (match &&
|
|
181
|
+
if (match &&
|
|
182
|
+
(match.length > bestLen || (match.length === bestLen && match.scope > bestScope))) {
|
|
157
183
|
best = { ...base, item: match.item };
|
|
158
184
|
bestLen = match.length;
|
|
185
|
+
bestScope = match.scope;
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
return best;
|
|
@@ -3,6 +3,15 @@ import type { ThemeMode } from "./theme-store.js";
|
|
|
3
3
|
export interface ThemeColorSlots {
|
|
4
4
|
background?: string;
|
|
5
5
|
foreground?: string;
|
|
6
|
+
/** Page and document headings, independent of body text and action colours. */
|
|
7
|
+
heading?: string;
|
|
8
|
+
/** Heading colours inside authored content, independent of application chrome. */
|
|
9
|
+
contentHeading1?: string;
|
|
10
|
+
contentHeading2?: string;
|
|
11
|
+
contentHeading3?: string;
|
|
12
|
+
contentHeading4?: string;
|
|
13
|
+
contentHeading5?: string;
|
|
14
|
+
contentHeading6?: string;
|
|
6
15
|
card?: string;
|
|
7
16
|
cardForeground?: string;
|
|
8
17
|
popover?: string;
|
|
@@ -161,6 +170,20 @@ export interface ResolvedThemeRuntime {
|
|
|
161
170
|
variables: CSSProperties;
|
|
162
171
|
sources: ThemeLayer[];
|
|
163
172
|
}
|
|
173
|
+
export interface ThemeSlot {
|
|
174
|
+
path: readonly string[];
|
|
175
|
+
label: string;
|
|
176
|
+
group: string;
|
|
177
|
+
kind: "color" | "font" | "length" | "text" | "image";
|
|
178
|
+
cssVariable?: string;
|
|
179
|
+
mode?: "light" | "dark";
|
|
180
|
+
/** Lower values appear first in authoring tools. */
|
|
181
|
+
impact: number;
|
|
182
|
+
/** Human-readable source used when this value is not explicitly set. */
|
|
183
|
+
derivedFrom?: string;
|
|
184
|
+
}
|
|
185
|
+
/** The same allowlists power sanitization and authoring: supported slots cannot disappear from editors. */
|
|
186
|
+
export declare const THEME_SLOTS: readonly ThemeSlot[];
|
|
164
187
|
export declare function mergeThemeDefinitions(...definitions: (ThemeDefinition | null | undefined)[]): ThemeDefinition;
|
|
165
188
|
export declare function getEffectiveThemeMode(mode: ThemeMode): "light" | "dark";
|
|
166
189
|
export declare const FOUNDRY_VIOLET_THEME: ThemeDefinition;
|
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
const COLOR_KEYS = [
|
|
2
|
+
"primary",
|
|
3
|
+
"primaryForeground",
|
|
4
|
+
"heading",
|
|
5
|
+
"contentHeading1",
|
|
6
|
+
"contentHeading2",
|
|
7
|
+
"contentHeading3",
|
|
8
|
+
"contentHeading4",
|
|
9
|
+
"contentHeading5",
|
|
10
|
+
"contentHeading6",
|
|
2
11
|
"background",
|
|
3
12
|
"foreground",
|
|
13
|
+
"accent",
|
|
14
|
+
"accentForeground",
|
|
4
15
|
"card",
|
|
5
16
|
"cardForeground",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"primaryForeground",
|
|
10
|
-
"secondary",
|
|
11
|
-
"secondaryForeground",
|
|
17
|
+
"border",
|
|
18
|
+
"ring",
|
|
19
|
+
"input",
|
|
12
20
|
"muted",
|
|
13
21
|
"mutedForeground",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
22
|
+
"secondary",
|
|
23
|
+
"secondaryForeground",
|
|
24
|
+
"popover",
|
|
25
|
+
"popoverForeground",
|
|
16
26
|
"destructive",
|
|
17
27
|
"destructiveForeground",
|
|
18
|
-
"border",
|
|
19
|
-
"input",
|
|
20
|
-
"ring",
|
|
21
28
|
];
|
|
22
29
|
const SIDEBAR_KEYS = [
|
|
30
|
+
"sidebarSelected",
|
|
31
|
+
"sidebarSelectedForeground",
|
|
23
32
|
"sidebar",
|
|
24
33
|
"sidebarForeground",
|
|
25
34
|
"sidebarPrimary",
|
|
26
35
|
"sidebarPrimaryForeground",
|
|
36
|
+
"sidebarIcon",
|
|
27
37
|
"sidebarAccent",
|
|
28
38
|
"sidebarAccentForeground",
|
|
29
|
-
"sidebarSelected",
|
|
30
|
-
"sidebarSelectedForeground",
|
|
31
|
-
"sidebarIcon",
|
|
32
39
|
"sidebarBorder",
|
|
33
40
|
"sidebarRing",
|
|
34
41
|
];
|
|
@@ -87,6 +94,100 @@ const BRANDING_KEYS = [
|
|
|
87
94
|
"darkIconUrl",
|
|
88
95
|
"logoHeight",
|
|
89
96
|
];
|
|
97
|
+
const SURFACE_IMPACT = Object.fromEntries(COLOR_KEYS.map((key, index) => [key, index]));
|
|
98
|
+
const SURFACE_DERIVATIONS = {
|
|
99
|
+
heading: "foreground",
|
|
100
|
+
contentHeading1: "interface heading",
|
|
101
|
+
contentHeading2: "interface heading + foreground",
|
|
102
|
+
contentHeading3: "foreground",
|
|
103
|
+
contentHeading4: "foreground",
|
|
104
|
+
contentHeading5: "foreground",
|
|
105
|
+
contentHeading6: "foreground",
|
|
106
|
+
card: "background",
|
|
107
|
+
cardForeground: "foreground",
|
|
108
|
+
popover: "card",
|
|
109
|
+
popoverForeground: "foreground",
|
|
110
|
+
secondary: "background + primary",
|
|
111
|
+
secondaryForeground: "foreground",
|
|
112
|
+
muted: "background + foreground",
|
|
113
|
+
mutedForeground: "foreground + background",
|
|
114
|
+
accent: "background + primary",
|
|
115
|
+
accentForeground: "foreground",
|
|
116
|
+
border: "foreground + background",
|
|
117
|
+
input: "border",
|
|
118
|
+
ring: "primary",
|
|
119
|
+
};
|
|
120
|
+
const cssName = (key) => `--${key.replace(/[A-Z0-9]/g, (part) => `-${part.toLowerCase()}`)}`;
|
|
121
|
+
const slotLabel = (key) => ({
|
|
122
|
+
heading: "Interface headings",
|
|
123
|
+
contentHeading1: "H1",
|
|
124
|
+
contentHeading2: "H2",
|
|
125
|
+
contentHeading3: "H3",
|
|
126
|
+
contentHeading4: "H4",
|
|
127
|
+
contentHeading5: "H5",
|
|
128
|
+
contentHeading6: "H6",
|
|
129
|
+
sidebarSelected: "Selected background",
|
|
130
|
+
sidebarSelectedForeground: "Selected text",
|
|
131
|
+
})[key] ?? key.replace(/([a-z])([A-Z0-9])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
|
132
|
+
/** The same allowlists power sanitization and authoring: supported slots cannot disappear from editors. */
|
|
133
|
+
export const THEME_SLOTS = [
|
|
134
|
+
...["light", "dark"].flatMap((mode) => [
|
|
135
|
+
...COLOR_KEYS.map((key) => ({
|
|
136
|
+
path: [mode, key],
|
|
137
|
+
label: slotLabel(key),
|
|
138
|
+
group: key.startsWith("contentHeading") ? "Content" : "Surfaces",
|
|
139
|
+
kind: "color",
|
|
140
|
+
cssVariable: cssName(key),
|
|
141
|
+
mode,
|
|
142
|
+
impact: SURFACE_IMPACT[key],
|
|
143
|
+
derivedFrom: SURFACE_DERIVATIONS[key],
|
|
144
|
+
})),
|
|
145
|
+
...[
|
|
146
|
+
["sidebar", SIDEBAR_KEYS, "Navigation"],
|
|
147
|
+
["status", STATUS_KEYS, "Signals"],
|
|
148
|
+
["charts", CHART_KEYS, "Charts"],
|
|
149
|
+
["app", APP_KEYS, "Code & workflows"],
|
|
150
|
+
].flatMap(([section, keys, group]) => keys.map((key, impact) => ({
|
|
151
|
+
path: [section, mode, key],
|
|
152
|
+
label: slotLabel(key),
|
|
153
|
+
group,
|
|
154
|
+
kind: "color",
|
|
155
|
+
cssVariable: cssName(key),
|
|
156
|
+
mode,
|
|
157
|
+
impact,
|
|
158
|
+
derivedFrom: group === "Navigation"
|
|
159
|
+
? "page colours"
|
|
160
|
+
: group === "Signals" && String(key).startsWith("info")
|
|
161
|
+
? "primary action"
|
|
162
|
+
: group === "Code & workflows"
|
|
163
|
+
? "page and signal colours"
|
|
164
|
+
: undefined,
|
|
165
|
+
}))),
|
|
166
|
+
]),
|
|
167
|
+
...TYPOGRAPHY_KEYS.map((key) => ({
|
|
168
|
+
path: ["typography", key],
|
|
169
|
+
label: slotLabel(key),
|
|
170
|
+
group: "Typography & shape",
|
|
171
|
+
kind: "font",
|
|
172
|
+
cssVariable: `--font-${key.replace("Font", "")}`,
|
|
173
|
+
impact: key === "headingFont" ? 0 : key === "bodyFont" ? 1 : 3,
|
|
174
|
+
})),
|
|
175
|
+
...RADIUS_KEYS.map((key) => ({
|
|
176
|
+
path: ["radius", key],
|
|
177
|
+
label: "Corner radius",
|
|
178
|
+
group: "Typography & shape",
|
|
179
|
+
kind: "length",
|
|
180
|
+
cssVariable: cssName(key),
|
|
181
|
+
impact: 2,
|
|
182
|
+
})),
|
|
183
|
+
...BRANDING_KEYS.map((key) => ({
|
|
184
|
+
path: ["branding", key],
|
|
185
|
+
label: slotLabel(key),
|
|
186
|
+
group: "Brand",
|
|
187
|
+
kind: key.endsWith("Url") ? "image" : key === "logoHeight" ? "length" : "text",
|
|
188
|
+
impact: BRANDING_KEYS.indexOf(key),
|
|
189
|
+
})),
|
|
190
|
+
];
|
|
90
191
|
function cleanObject(value, keys) {
|
|
91
192
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
92
193
|
return undefined;
|
|
@@ -165,6 +266,13 @@ function toCssVars(colors, sidebar, status, charts, typography, radius, app) {
|
|
|
165
266
|
return {
|
|
166
267
|
["--background"]: colors.background,
|
|
167
268
|
["--foreground"]: colors.foreground,
|
|
269
|
+
["--heading"]: colors.heading ?? colors.foreground,
|
|
270
|
+
["--content-heading-1"]: colors.contentHeading1,
|
|
271
|
+
["--content-heading-2"]: colors.contentHeading2,
|
|
272
|
+
["--content-heading-3"]: colors.contentHeading3,
|
|
273
|
+
["--content-heading-4"]: colors.contentHeading4,
|
|
274
|
+
["--content-heading-5"]: colors.contentHeading5,
|
|
275
|
+
["--content-heading-6"]: colors.contentHeading6,
|
|
168
276
|
["--card"]: colors.card,
|
|
169
277
|
["--card-foreground"]: colors.cardForeground,
|
|
170
278
|
["--popover"]: colors.popover,
|
|
@@ -251,7 +359,7 @@ function defaultSidebar(colors) {
|
|
|
251
359
|
sidebarAccentForeground: colors.accentForeground,
|
|
252
360
|
sidebarSelected: colors.muted,
|
|
253
361
|
sidebarSelectedForeground: colors.foreground,
|
|
254
|
-
sidebarIcon:
|
|
362
|
+
sidebarIcon: "color-mix(in srgb, var(--sidebar-foreground) 60%, transparent)",
|
|
255
363
|
sidebarBorder: colors.border,
|
|
256
364
|
sidebarRing: colors.ring,
|
|
257
365
|
};
|
|
@@ -322,8 +430,8 @@ function defaultApp(mode, colors) {
|
|
|
322
430
|
workflowDiagramSkippedFill: "color-mix(in srgb, var(--muted) 80%, var(--background) 20%)",
|
|
323
431
|
workflowDiagramSkippedStroke: "color-mix(in srgb, var(--muted-foreground) 36%, transparent)",
|
|
324
432
|
workflowDiagramSkippedText: colors.mutedForeground,
|
|
325
|
-
workflowDiagramGlow: "
|
|
326
|
-
workflowDiagramShell: "
|
|
433
|
+
workflowDiagramGlow: "color-mix(in srgb, var(--primary) 18%, transparent)",
|
|
434
|
+
workflowDiagramShell: "color-mix(in srgb, var(--primary) 6%, transparent)",
|
|
327
435
|
};
|
|
328
436
|
}
|
|
329
437
|
return {
|
|
@@ -344,8 +452,8 @@ function defaultApp(mode, colors) {
|
|
|
344
452
|
workflowDiagramSkippedFill: "color-mix(in srgb, var(--muted) 65%, var(--background) 35%)",
|
|
345
453
|
workflowDiagramSkippedStroke: "color-mix(in srgb, var(--muted-foreground) 22%, transparent)",
|
|
346
454
|
workflowDiagramSkippedText: colors.mutedForeground,
|
|
347
|
-
workflowDiagramGlow: "
|
|
348
|
-
workflowDiagramShell: "
|
|
455
|
+
workflowDiagramGlow: "color-mix(in srgb, var(--primary) 18%, transparent)",
|
|
456
|
+
workflowDiagramShell: "color-mix(in srgb, var(--primary) 4%, transparent)",
|
|
349
457
|
};
|
|
350
458
|
}
|
|
351
459
|
export const FOUNDRY_VIOLET_THEME = {
|
|
@@ -437,42 +545,114 @@ export function getThemePreset(name) {
|
|
|
437
545
|
return FOUNDRY_VIOLET_THEME;
|
|
438
546
|
return FOUNDRY_VIOLET_THEME;
|
|
439
547
|
}
|
|
548
|
+
function deriveSurfaceColors(resolved, explicit) {
|
|
549
|
+
// The preset remains the safe fallback for an entirely unthemed product.
|
|
550
|
+
// Only values depending on an authored colour should move. A sparse edit
|
|
551
|
+
// to an unrelated slot must not repaint preset surfaces.
|
|
552
|
+
const changed = new Set(Object.keys(explicit ?? {}).filter((key) => Boolean(explicit?.[key])));
|
|
553
|
+
if (!explicit || changed.size === 0)
|
|
554
|
+
return { colors: resolved, changed };
|
|
555
|
+
const next = { ...resolved, ...explicit };
|
|
556
|
+
const setDerived = (key, value, sources) => {
|
|
557
|
+
if (!explicit[key] && value && sources.some((source) => changed.has(source))) {
|
|
558
|
+
next[key] = value;
|
|
559
|
+
changed.add(key);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
setDerived("heading", next.foreground, ["foreground"]);
|
|
563
|
+
setDerived("contentHeading1", next.heading, ["heading"]);
|
|
564
|
+
setDerived("contentHeading2", `color-mix(in srgb, ${next.heading} 78%, ${next.foreground} 22%)`, [
|
|
565
|
+
"heading",
|
|
566
|
+
"foreground",
|
|
567
|
+
]);
|
|
568
|
+
setDerived("contentHeading3", next.foreground, ["foreground"]);
|
|
569
|
+
setDerived("contentHeading4", next.foreground, ["foreground"]);
|
|
570
|
+
setDerived("contentHeading5", next.foreground, ["foreground"]);
|
|
571
|
+
setDerived("contentHeading6", next.foreground, ["foreground"]);
|
|
572
|
+
setDerived("card", next.background, ["background"]);
|
|
573
|
+
setDerived("cardForeground", next.foreground, ["foreground"]);
|
|
574
|
+
setDerived("popover", next.card, ["card"]);
|
|
575
|
+
setDerived("popoverForeground", next.foreground, ["foreground"]);
|
|
576
|
+
setDerived("secondary", `color-mix(in srgb, ${next.background} 94%, ${next.primary} 6%)`, [
|
|
577
|
+
"background",
|
|
578
|
+
"primary",
|
|
579
|
+
]);
|
|
580
|
+
setDerived("secondaryForeground", next.foreground, ["foreground"]);
|
|
581
|
+
setDerived("muted", `color-mix(in srgb, ${next.background} 97%, ${next.foreground} 3%)`, [
|
|
582
|
+
"background",
|
|
583
|
+
"foreground",
|
|
584
|
+
]);
|
|
585
|
+
setDerived("mutedForeground", `color-mix(in srgb, ${next.foreground} 62%, ${next.background} 38%)`, ["foreground", "background"]);
|
|
586
|
+
setDerived("accent", `color-mix(in srgb, ${next.background} 90%, ${next.primary} 10%)`, [
|
|
587
|
+
"background",
|
|
588
|
+
"primary",
|
|
589
|
+
]);
|
|
590
|
+
setDerived("accentForeground", next.foreground, ["foreground"]);
|
|
591
|
+
setDerived("border", `color-mix(in srgb, ${next.foreground} 14%, ${next.background} 86%)`, [
|
|
592
|
+
"foreground",
|
|
593
|
+
"background",
|
|
594
|
+
]);
|
|
595
|
+
setDerived("input", next.border, ["border"]);
|
|
596
|
+
setDerived("ring", next.primary, ["primary"]);
|
|
597
|
+
return { colors: next, changed };
|
|
598
|
+
}
|
|
440
599
|
export function resolveThemeRuntime(layers, mode) {
|
|
441
600
|
const effectiveMode = getEffectiveThemeMode(mode);
|
|
442
601
|
const basePreset = layers.find((layer) => layer.basePreset && layer.basePreset.trim())?.basePreset ??
|
|
443
602
|
"foundry-violet";
|
|
444
|
-
const
|
|
603
|
+
const customDefinition = mergeThemeDefinitions(...layers.map((layer) => layer.definition));
|
|
604
|
+
const definitions = [getThemePreset(basePreset), customDefinition];
|
|
445
605
|
const definition = mergeThemeDefinitions(...definitions);
|
|
446
|
-
const colors = {
|
|
447
|
-
|
|
606
|
+
const { colors, changed } = deriveSurfaceColors({ ...(effectiveMode === "dark" ? definition.dark : definition.light) }, effectiveMode === "dark" ? customDefinition.dark : customDefinition.light);
|
|
607
|
+
const explicitSidebar = effectiveMode === "dark" ? customDefinition.sidebar?.dark : customDefinition.sidebar?.light;
|
|
608
|
+
const presetSidebar = effectiveMode === "dark"
|
|
609
|
+
? getThemePreset(basePreset).sidebar?.dark
|
|
610
|
+
: getThemePreset(basePreset).sidebar?.light;
|
|
611
|
+
const sidebarSources = {
|
|
612
|
+
sidebar: "secondary",
|
|
613
|
+
sidebarForeground: "foreground",
|
|
614
|
+
sidebarPrimary: "primary",
|
|
615
|
+
sidebarPrimaryForeground: "primaryForeground",
|
|
616
|
+
sidebarAccent: "accent",
|
|
617
|
+
sidebarAccentForeground: "accentForeground",
|
|
618
|
+
sidebarSelected: "muted",
|
|
619
|
+
sidebarSelectedForeground: "foreground",
|
|
620
|
+
sidebarIcon: null,
|
|
621
|
+
sidebarBorder: "border",
|
|
622
|
+
sidebarRing: "ring",
|
|
448
623
|
};
|
|
624
|
+
const derivedSidebar = defaultSidebar(colors);
|
|
449
625
|
const sidebar = {
|
|
450
|
-
...
|
|
451
|
-
...
|
|
626
|
+
...derivedSidebar,
|
|
627
|
+
...presetSidebar,
|
|
628
|
+
...Object.fromEntries(Object.keys(sidebarSources)
|
|
629
|
+
.filter((key) => sidebarSources[key] && changed.has(sidebarSources[key]))
|
|
630
|
+
.map((key) => [key, derivedSidebar[key]])),
|
|
631
|
+
...explicitSidebar,
|
|
452
632
|
};
|
|
453
633
|
const status = {
|
|
454
634
|
...defaultStatus(effectiveMode, colors),
|
|
455
|
-
...(effectiveMode === "dark" ?
|
|
635
|
+
...(effectiveMode === "dark" ? customDefinition.status?.dark : customDefinition.status?.light),
|
|
456
636
|
};
|
|
457
637
|
const charts = {
|
|
458
638
|
...defaultCharts(effectiveMode),
|
|
459
|
-
...(effectiveMode === "dark" ?
|
|
639
|
+
...(effectiveMode === "dark" ? customDefinition.charts?.dark : customDefinition.charts?.light),
|
|
460
640
|
};
|
|
461
641
|
const typography = {
|
|
462
642
|
...FOUNDRY_VIOLET_THEME.typography,
|
|
463
|
-
...
|
|
643
|
+
...customDefinition.typography,
|
|
464
644
|
};
|
|
465
645
|
const radius = {
|
|
466
646
|
...FOUNDRY_VIOLET_THEME.radius,
|
|
467
|
-
...
|
|
647
|
+
...customDefinition.radius,
|
|
468
648
|
};
|
|
469
649
|
const app = {
|
|
470
650
|
...defaultApp(effectiveMode, colors),
|
|
471
|
-
...(effectiveMode === "dark" ?
|
|
651
|
+
...(effectiveMode === "dark" ? customDefinition.app?.dark : customDefinition.app?.light),
|
|
472
652
|
};
|
|
473
653
|
const branding = {
|
|
474
654
|
...FOUNDRY_VIOLET_THEME.branding,
|
|
475
|
-
...
|
|
655
|
+
...customDefinition.branding,
|
|
476
656
|
};
|
|
477
657
|
return {
|
|
478
658
|
mode: effectiveMode,
|