@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.
Files changed (48) hide show
  1. package/dist/components/ag-ui-runtime-provider.d.ts +8 -3
  2. package/dist/components/ag-ui-runtime-provider.js +76 -33
  3. package/dist/components/assistant-chat.d.ts +1 -15
  4. package/dist/components/assistant-chat.js +4 -5
  5. package/dist/components/chat-bubble.js +3 -4
  6. package/dist/components/chat-context-items.d.ts +6 -0
  7. package/dist/components/chat-context-items.js +25 -0
  8. package/dist/components/chat-context.js +10 -2
  9. package/dist/components/chat-empty-state.js +1 -1
  10. package/dist/components/chat-header.js +2 -1
  11. package/dist/components/composer-add-menu.d.ts +0 -19
  12. package/dist/components/composer-add-menu.js +2 -10
  13. package/dist/components/context-badges.d.ts +0 -14
  14. package/dist/components/context-badges.js +2 -29
  15. package/dist/components/context-bar.d.ts +1 -21
  16. package/dist/components/context-bar.js +3 -74
  17. package/dist/components/focus-chat-pane.js +1 -1
  18. package/dist/components/markdown-text.js +6 -4
  19. package/dist/components/sidebar.js +51 -17
  20. package/dist/components/tool-panel.js +1 -1
  21. package/dist/index.d.ts +6 -5
  22. package/dist/index.js +5 -4
  23. package/dist/lib/ag-ui-adapter.d.ts +3 -0
  24. package/dist/lib/ag-ui-adapter.js +75 -11
  25. package/dist/lib/auth-provider.js +1 -1
  26. package/dist/lib/chat-runs-store.d.ts +40 -0
  27. package/dist/lib/chat-runs-store.js +173 -0
  28. package/dist/lib/merge-chat-state.d.ts +3 -0
  29. package/dist/lib/merge-chat-state.js +21 -0
  30. package/dist/lib/nav-config.js +47 -20
  31. package/dist/lib/theme-runtime.d.ts +23 -0
  32. package/dist/lib/theme-runtime.js +210 -30
  33. package/dist/lib/theme-store.d.ts +3 -1
  34. package/dist/lib/theme-store.js +3 -2
  35. package/dist/lib/use-new-conversation.js +4 -8
  36. package/dist/styles.css +14 -0
  37. package/dist/workbench/chat-header.js +4 -3
  38. package/dist/workbench/conversation-history.d.ts +1 -1
  39. package/dist/workbench/conversation-history.js +75 -19
  40. package/dist/workbench/running-chats-menu.d.ts +3 -0
  41. package/dist/workbench/running-chats-menu.js +20 -0
  42. package/dist/workbench/selection-bridge.js +2 -5
  43. package/dist/workbench/use-workbench-state.d.ts +1 -2
  44. package/dist/workbench/use-workbench-state.js +15 -18
  45. package/dist/workbench/welcome-intro.js +1 -1
  46. package/dist/workbench/workbench-styles.js +54 -6
  47. package/dist/workbench/workbench.js +1 -1
  48. 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
+ }
@@ -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
- if (item.to === path) {
97
- return item;
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
- if (item.children) {
100
- const nested = findExactNavItem(item.children, path);
101
- if (nested)
102
- return nested;
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 null;
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
- if (path.startsWith(item.to + "/") && item.to.length > (best?.length ?? 0)) {
111
- best = { item, length: item.to.length };
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
- // Pass 1: exact match on items
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
- if (match) {
130
- return { ...base, item: match };
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 === path) {
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 && match.length > bestLen) {
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
- "popover",
7
- "popoverForeground",
8
- "primary",
9
- "primaryForeground",
10
- "secondary",
11
- "secondaryForeground",
17
+ "border",
18
+ "ring",
19
+ "input",
12
20
  "muted",
13
21
  "mutedForeground",
14
- "accent",
15
- "accentForeground",
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: `color-mix(in srgb, ${colors.foreground} 60%, transparent)`,
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: "rgba(182, 148, 255, 0.18)",
326
- workflowDiagramShell: "rgba(182, 148, 255, 0.06)",
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: "rgba(124, 58, 237, 0.18)",
348
- workflowDiagramShell: "rgba(124, 58, 237, 0.04)",
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 definitions = [getThemePreset(basePreset), ...layers.map((layer) => layer.definition)];
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
- ...(effectiveMode === "dark" ? definition.dark : definition.light),
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
- ...defaultSidebar(colors),
451
- ...(effectiveMode === "dark" ? definition.sidebar?.dark : definition.sidebar?.light),
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" ? definition.status?.dark : definition.status?.light),
635
+ ...(effectiveMode === "dark" ? customDefinition.status?.dark : customDefinition.status?.light),
456
636
  };
457
637
  const charts = {
458
638
  ...defaultCharts(effectiveMode),
459
- ...(effectiveMode === "dark" ? definition.charts?.dark : definition.charts?.light),
639
+ ...(effectiveMode === "dark" ? customDefinition.charts?.dark : customDefinition.charts?.light),
460
640
  };
461
641
  const typography = {
462
642
  ...FOUNDRY_VIOLET_THEME.typography,
463
- ...definition.typography,
643
+ ...customDefinition.typography,
464
644
  };
465
645
  const radius = {
466
646
  ...FOUNDRY_VIOLET_THEME.radius,
467
- ...definition.radius,
647
+ ...customDefinition.radius,
468
648
  };
469
649
  const app = {
470
650
  ...defaultApp(effectiveMode, colors),
471
- ...(effectiveMode === "dark" ? definition.app?.dark : definition.app?.light),
651
+ ...(effectiveMode === "dark" ? customDefinition.app?.dark : customDefinition.app?.light),
472
652
  };
473
653
  const branding = {
474
654
  ...FOUNDRY_VIOLET_THEME.branding,
475
- ...definition.branding,
655
+ ...customDefinition.branding,
476
656
  };
477
657
  return {
478
658
  mode: effectiveMode,