@matterfact/embed 0.7.0 → 0.9.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/README.md +171 -1
- package/dist/chunk-6EM7T2JV.js +816 -0
- package/dist/chunk-6EM7T2JV.js.map +1 -0
- package/dist/{chunk-4Q2ROXLR.js → chunk-BKKXHSYU.js} +2 -2
- package/dist/chunk-FEXG4LQJ.js +3 -0
- package/dist/chunk-FEXG4LQJ.js.map +7 -0
- package/dist/chunk-NWNMS34P.js +2 -0
- package/dist/chunk-URGQBG4I.js +800 -0
- package/dist/chunk-URGQBG4I.js.map +1 -0
- package/dist/context-ACFBWIFH.js +3 -0
- package/dist/{context-YX2KXFLI.js.map → context-ACFBWIFH.js.map} +1 -1
- package/dist/{context-WU2F5CBC.js → context-U2HJJN2S.js} +4 -2
- package/dist/embed.js +1 -1
- package/dist/embed.js.map +4 -4
- package/dist/index.cjs +1006 -159
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +192 -32
- package/dist/index.d.ts +192 -32
- package/dist/index.js +537 -130
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +1042 -165
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +81 -1
- package/dist/react.d.ts +81 -1
- package/dist/react.js +566 -132
- package/dist/react.js.map +1 -1
- package/dist/{snapshot-JOGZWESK.js → snapshot-4GXT6PKZ.js} +2 -2
- package/dist/{snapshot-MUXE7KXX.js → snapshot-Y75SCGCM.js} +3 -3
- package/dist/{snapshot-MUXE7KXX.js.map → snapshot-Y75SCGCM.js.map} +1 -1
- package/examples/embed-demo/README.md +37 -0
- package/examples/embed-demo/src/App.tsx +55 -21
- package/examples/embed-demo/src/HoistDemo.tsx +390 -0
- package/examples/embed-demo/src/main.tsx +7 -0
- package/examples/embed-demo/src/mockXH.ts +492 -0
- package/examples/embed-demo/src/placement.tsx +43 -0
- package/examples/embed-demo/src/styles.css +147 -2
- package/examples/embed-demo/vite.config.ts +2 -2
- package/package.json +1 -1
- package/dist/chunk-7I37ZFAJ.js +0 -2
- package/dist/chunk-AXKXNYRT.js +0 -381
- package/dist/chunk-AXKXNYRT.js.map +0 -1
- package/dist/chunk-D7R6WVHG.js +0 -2
- package/dist/chunk-D7R6WVHG.js.map +0 -7
- package/dist/chunk-RS6RMZ77.js +0 -396
- package/dist/chunk-RS6RMZ77.js.map +0 -1
- package/dist/context-YX2KXFLI.js +0 -3
- /package/dist/{chunk-4Q2ROXLR.js.map → chunk-BKKXHSYU.js.map} +0 -0
- /package/dist/{chunk-7I37ZFAJ.js.map → chunk-NWNMS34P.js.map} +0 -0
- /package/dist/{context-WU2F5CBC.js.map → context-U2HJJN2S.js.map} +0 -0
- /package/dist/{snapshot-JOGZWESK.js.map → snapshot-4GXT6PKZ.js.map} +0 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A self-contained, in-memory mock of the pieces of `window.XH` the embed SDK's Hoist
|
|
3
|
+
* adapter reads — see `@matterfact/embed`'s `src/adapters/hoist-runtime.ts` and
|
|
4
|
+
* `adapters/HOIST-CONTRACT.md` for the real, confirmed shapes this mirrors. Demo-only:
|
|
5
|
+
* nothing here is imported BY the SDK, and the SDK's adapter source is never imported
|
|
6
|
+
* HERE — this just populates `window.XH` with fake-but-shape-accurate data so the exact
|
|
7
|
+
* same automatic detection that would fire on a real XH Hoist app fires here too, with
|
|
8
|
+
* zero host integration code (see App.tsx / HoistDemo.tsx: nothing calls into the SDK's
|
|
9
|
+
* Hoist adapter directly — `<MatterfactAgent>` just finds `window.XH` on its own).
|
|
10
|
+
*
|
|
11
|
+
* Opt-in via `?hoist=1` (see `isHoistModeEnabled`) and installed once, eagerly, from
|
|
12
|
+
* main.tsx — BEFORE React renders anything. That ordering matters: `<MatterfactAgent>`'s
|
|
13
|
+
* mount effect reads `window.XH` synchronously (the SDK's `start()` calls
|
|
14
|
+
* `advertiseTools()` at the end of mount), so installing the mock any later — e.g. from
|
|
15
|
+
* inside a React effect — risks losing the very first tool advertisement to a race.
|
|
16
|
+
*
|
|
17
|
+
* The "app" this pretends to be is the SAME Meridian Capital growth-sleeve portfolio
|
|
18
|
+
* App.tsx hand-declares context for, just read out of fake `GridModel` / `ChartModel` /
|
|
19
|
+
* `TabContainerModel` instances instead of a `getPageContext` callback — so toggling
|
|
20
|
+
* `?hoist=1` is a true side-by-side comparison of the two context mechanisms, not two
|
|
21
|
+
* unrelated demos.
|
|
22
|
+
*
|
|
23
|
+
* Site map (two nav levels, like a real Hoist app):
|
|
24
|
+
* Site → Holdings, Research (top-level tabs)
|
|
25
|
+
* Company · <TICKER> → Thesis, Financials, Ownership (per-holding sub-views)
|
|
26
|
+
* Route names deliberately reuse the exact `default.company.ticker.*` shape
|
|
27
|
+
* HOIST-CONTRACT.md confirms against the real app — including the literal
|
|
28
|
+
* `.company.ticker.` substring `readSitemap` matches to group-label a nav level
|
|
29
|
+
* "Company · <TICKER>" instead of "Site".
|
|
30
|
+
*/
|
|
31
|
+
import { useSyncExternalStore } from 'react';
|
|
32
|
+
|
|
33
|
+
declare global {
|
|
34
|
+
interface Window {
|
|
35
|
+
/** Set only when the mock is installed — see installMockXH(). */
|
|
36
|
+
XH?: unknown;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Params = Record<string, string>;
|
|
41
|
+
|
|
42
|
+
/* ─────────────────────────────── portfolio data ────────────────────────── */
|
|
43
|
+
|
|
44
|
+
export interface Holding {
|
|
45
|
+
ticker: string;
|
|
46
|
+
weight: string;
|
|
47
|
+
day: string;
|
|
48
|
+
ytd: string;
|
|
49
|
+
dayNeg?: boolean;
|
|
50
|
+
ytdNeg?: boolean;
|
|
51
|
+
thesis: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HOLDINGS: Holding[] = [
|
|
55
|
+
{
|
|
56
|
+
ticker: 'NVDA',
|
|
57
|
+
weight: '9.4%',
|
|
58
|
+
day: '+2.8%',
|
|
59
|
+
ytd: '+41%',
|
|
60
|
+
thesis:
|
|
61
|
+
'AI datacenter demand is still outrunning supply, and the next-gen accelerator pipeline looks deep.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
ticker: 'MSFT',
|
|
65
|
+
weight: '8.1%',
|
|
66
|
+
day: '+0.9%',
|
|
67
|
+
ytd: '+22%',
|
|
68
|
+
thesis:
|
|
69
|
+
'Copilot attach rate is quietly the bigger seat-expansion story, more than Azure growth alone.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
ticker: 'UNH',
|
|
73
|
+
weight: '6.7%',
|
|
74
|
+
day: '−1.2%',
|
|
75
|
+
ytd: '−8%',
|
|
76
|
+
dayNeg: true,
|
|
77
|
+
ytdNeg: true,
|
|
78
|
+
thesis:
|
|
79
|
+
'MLR pressure from utilization normalization — watching for a trough in the next two prints.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
ticker: 'V',
|
|
83
|
+
weight: '5.9%',
|
|
84
|
+
day: '+0.4%',
|
|
85
|
+
ytd: '+11%',
|
|
86
|
+
thesis:
|
|
87
|
+
'Cross-border volume recovery continues; take-rate mix is the swing factor to watch.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
ticker: 'LLY',
|
|
91
|
+
weight: '5.2%',
|
|
92
|
+
day: '+1.7%',
|
|
93
|
+
ytd: '+34%',
|
|
94
|
+
thesis:
|
|
95
|
+
'GLP-1 ramp remains supply constrained — capacity buildout is the real next-year catalyst.',
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const OWNERSHIP: Record<
|
|
100
|
+
string,
|
|
101
|
+
{ holder: string; shares: string; pct: string }[]
|
|
102
|
+
> = {
|
|
103
|
+
NVDA: [
|
|
104
|
+
{ holder: 'Vanguard Group', shares: '812.4M', pct: '8.3%' },
|
|
105
|
+
{ holder: 'BlackRock', shares: '694.1M', pct: '7.1%' },
|
|
106
|
+
{ holder: 'State Street', shares: '331.8M', pct: '3.4%' },
|
|
107
|
+
],
|
|
108
|
+
MSFT: [
|
|
109
|
+
{ holder: 'Vanguard Group', shares: '651.2M', pct: '8.7%' },
|
|
110
|
+
{ holder: 'BlackRock', shares: '556.9M', pct: '7.5%' },
|
|
111
|
+
{ holder: 'State Street', shares: '267.3M', pct: '3.6%' },
|
|
112
|
+
],
|
|
113
|
+
UNH: [
|
|
114
|
+
{ holder: 'Vanguard Group', shares: '68.9M', pct: '7.6%' },
|
|
115
|
+
{ holder: 'BlackRock', shares: '58.2M', pct: '6.4%' },
|
|
116
|
+
{ holder: 'Berkshire Hathaway', shares: '5.0M', pct: '0.6%' },
|
|
117
|
+
],
|
|
118
|
+
V: [
|
|
119
|
+
{ holder: 'Vanguard Group', shares: '134.7M', pct: '6.5%' },
|
|
120
|
+
{ holder: 'BlackRock', shares: '114.2M', pct: '5.5%' },
|
|
121
|
+
{ holder: 'State Street', shares: '58.9M', pct: '2.8%' },
|
|
122
|
+
],
|
|
123
|
+
LLY: [
|
|
124
|
+
{ holder: 'Vanguard Group', shares: '84.3M', pct: '9.0%' },
|
|
125
|
+
{ holder: 'BlackRock', shares: '71.6M', pct: '7.6%' },
|
|
126
|
+
{ holder: 'State Street', shares: '34.1M', pct: '3.6%' },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Indexed (base-100) series, not real prices — only min/max/last ever reach the
|
|
131
|
+
// agent (see hoist.ts's seriesSummary), so the exact values just need to tell a
|
|
132
|
+
// story consistent with each holding's day/ytd sign.
|
|
133
|
+
const PRICE_SERIES: Record<string, number[]> = {
|
|
134
|
+
NVDA: [100, 104, 109, 107, 115, 122, 119, 128, 135, 141],
|
|
135
|
+
MSFT: [100, 101, 103, 106, 105, 109, 112, 115, 118, 122],
|
|
136
|
+
UNH: [100, 98, 95, 97, 93, 90, 92, 89, 91, 88],
|
|
137
|
+
V: [100, 100.5, 102, 101, 103, 104, 106, 105, 108, 111],
|
|
138
|
+
LLY: [100, 105, 103, 110, 116, 121, 118, 126, 130, 134],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function getHoldings(): Holding[] {
|
|
142
|
+
return HOLDINGS;
|
|
143
|
+
}
|
|
144
|
+
export function getOwnershipRows(
|
|
145
|
+
ticker: string,
|
|
146
|
+
): { holder: string; shares: string; pct: string }[] {
|
|
147
|
+
return OWNERSHIP[ticker] ?? OWNERSHIP.NVDA;
|
|
148
|
+
}
|
|
149
|
+
export function getPriceSeries(ticker: string): number[] {
|
|
150
|
+
return PRICE_SERIES[ticker] ?? PRICE_SERIES.NVDA;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ───────────────────────── GridModel / ChartModel shapes ───────────────── */
|
|
154
|
+
// Mirrors the exact fields hoist-runtime.ts reads (readGrid/readColumns/readChart) —
|
|
155
|
+
// see HOIST-CONTRACT.md. `record()` matches the `{get(field), data}` shape its
|
|
156
|
+
// `readRow` expects.
|
|
157
|
+
|
|
158
|
+
interface LeafColumn {
|
|
159
|
+
field: string;
|
|
160
|
+
colId: string;
|
|
161
|
+
headerName: string;
|
|
162
|
+
}
|
|
163
|
+
interface GroupColumn {
|
|
164
|
+
headerName: string;
|
|
165
|
+
children: LeafColumn[];
|
|
166
|
+
}
|
|
167
|
+
type Column = LeafColumn | GroupColumn;
|
|
168
|
+
const isGroup = (c: Column): c is GroupColumn => 'children' in c;
|
|
169
|
+
const leafColumns = (cols: Column[]): LeafColumn[] =>
|
|
170
|
+
cols.flatMap((c) => (isGroup(c) ? c.children : [c]));
|
|
171
|
+
|
|
172
|
+
function record<T extends object>(
|
|
173
|
+
data: T,
|
|
174
|
+
): { get: (f: string) => unknown; data: T } {
|
|
175
|
+
return { get: (f: string) => (data as Record<string, unknown>)[f], data };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// One ColumnGroup ("Performance") with two leaf children — exercises the
|
|
179
|
+
// group-qualified header path (readColumns' collectHeaders), same as the real
|
|
180
|
+
// app's "5Y" group in HOIST-CONTRACT.md.
|
|
181
|
+
const HOLDINGS_COLUMNS: Column[] = [
|
|
182
|
+
{ field: 'ticker', colId: 'ticker', headerName: 'Ticker' },
|
|
183
|
+
{
|
|
184
|
+
headerName: 'Performance',
|
|
185
|
+
children: [
|
|
186
|
+
{ field: 'day', colId: 'day', headerName: 'Day' },
|
|
187
|
+
{ field: 'ytd', colId: 'ytd', headerName: 'YTD' },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
{ field: 'weight', colId: 'weight', headerName: 'Weight' },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
function holdingsGrid(selection: string | null) {
|
|
194
|
+
const records = HOLDINGS.map((h) => record(h));
|
|
195
|
+
return {
|
|
196
|
+
constructor: { name: 'GridModel' },
|
|
197
|
+
xhId: 'holdingsGrid',
|
|
198
|
+
title: 'Holdings',
|
|
199
|
+
columns: HOLDINGS_COLUMNS,
|
|
200
|
+
getVisibleLeafColumns: () => leafColumns(HOLDINGS_COLUMNS),
|
|
201
|
+
selectedRecords: records.filter((r) => r.data.ticker === selection),
|
|
202
|
+
store: { records, allCount: records.length },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const OWNERSHIP_COLUMNS: Column[] = [
|
|
207
|
+
{ field: 'holder', colId: 'holder', headerName: 'Holder' },
|
|
208
|
+
{ field: 'shares', colId: 'shares', headerName: 'Shares' },
|
|
209
|
+
{ field: 'pct', colId: 'pct', headerName: '% Owned' },
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
function ownershipGrid(ticker: string) {
|
|
213
|
+
const rows = getOwnershipRows(ticker);
|
|
214
|
+
const records = rows.map((r) => record(r));
|
|
215
|
+
return {
|
|
216
|
+
constructor: { name: 'GridModel' },
|
|
217
|
+
xhId: `ownershipGrid-${ticker}`,
|
|
218
|
+
title: `${ticker} — top holders`,
|
|
219
|
+
columns: OWNERSHIP_COLUMNS,
|
|
220
|
+
getVisibleLeafColumns: () => leafColumns(OWNERSHIP_COLUMNS),
|
|
221
|
+
selectedRecords: [],
|
|
222
|
+
store: { records, allCount: records.length },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const DAY_MS = 86_400_000;
|
|
227
|
+
const SERIES_START = Date.UTC(2026, 0, 5);
|
|
228
|
+
|
|
229
|
+
function financialsChart(ticker: string) {
|
|
230
|
+
const values = getPriceSeries(ticker);
|
|
231
|
+
return {
|
|
232
|
+
constructor: { name: 'ChartModel' },
|
|
233
|
+
xhId: `financialsChart-${ticker}`,
|
|
234
|
+
title: `${ticker} — indexed price`,
|
|
235
|
+
// Confirmed shape: [timestamp, value] tuples, not bare numbers (HOIST-CONTRACT.md).
|
|
236
|
+
series: [
|
|
237
|
+
{
|
|
238
|
+
name: ticker,
|
|
239
|
+
data: values.map((v, i) => [SERIES_START + i * DAY_MS, v]),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ─────────────────────────────── route tree ─────────────────────────────── */
|
|
246
|
+
// Walked by the adapter's routeIndex() exactly like a real router5 tree (see
|
|
247
|
+
// hoist-runtime.ts / HOIST-CONTRACT.md's confirmed `XH.router.rootNode` shape).
|
|
248
|
+
|
|
249
|
+
const ROUTE_TREE = {
|
|
250
|
+
children: [
|
|
251
|
+
{
|
|
252
|
+
name: 'default',
|
|
253
|
+
children: [
|
|
254
|
+
{ name: 'holdings', children: [] },
|
|
255
|
+
{ name: 'research', children: [] },
|
|
256
|
+
{
|
|
257
|
+
name: 'company',
|
|
258
|
+
children: [
|
|
259
|
+
{
|
|
260
|
+
name: 'ticker',
|
|
261
|
+
children: [
|
|
262
|
+
{ name: 'thesis', children: [] },
|
|
263
|
+
{ name: 'financials', children: [] },
|
|
264
|
+
{ name: 'ownership', children: [] },
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function buildPath(name: string, params: Params): string {
|
|
275
|
+
const ticker = encodeURIComponent(params.ticker ?? '');
|
|
276
|
+
const table: Record<string, string> = {
|
|
277
|
+
default: '/',
|
|
278
|
+
'default.holdings': '/holdings',
|
|
279
|
+
'default.research': '/research',
|
|
280
|
+
'default.company': '/company',
|
|
281
|
+
'default.company.ticker': `/company/${ticker}`,
|
|
282
|
+
'default.company.ticker.thesis': `/company/${ticker}/thesis`,
|
|
283
|
+
'default.company.ticker.financials': `/company/${ticker}/financials`,
|
|
284
|
+
'default.company.ticker.ownership': `/company/${ticker}/ownership`,
|
|
285
|
+
};
|
|
286
|
+
return table[name] ?? '';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The inverse of buildPath, for landing on a deep link or a browser back/forward —
|
|
290
|
+
* see the popstate listener in installMockXH(). */
|
|
291
|
+
function routeFromPath(pathname: string): { name: string; params: Params } {
|
|
292
|
+
const segs = pathname.split('/').filter(Boolean);
|
|
293
|
+
if (segs[0] === 'research') return { name: 'default.research', params: {} };
|
|
294
|
+
if (segs[0] === 'company' && segs[1]) {
|
|
295
|
+
const ticker = decodeURIComponent(segs[1]);
|
|
296
|
+
const leaf = segs[2];
|
|
297
|
+
const name =
|
|
298
|
+
leaf === 'financials'
|
|
299
|
+
? 'default.company.ticker.financials'
|
|
300
|
+
: leaf === 'ownership'
|
|
301
|
+
? 'default.company.ticker.ownership'
|
|
302
|
+
: 'default.company.ticker.thesis';
|
|
303
|
+
return { name, params: { ticker } };
|
|
304
|
+
}
|
|
305
|
+
return { name: 'default.holdings', params: {} }; // '/', '/holdings', or unrecognized
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ─────────────────────────────── nav (TabContainers) ────────────────────── */
|
|
309
|
+
|
|
310
|
+
function topSegment(routeName: string): string {
|
|
311
|
+
return routeName.split('.')[1] ?? '';
|
|
312
|
+
}
|
|
313
|
+
function leafSegment(routeName: string): string {
|
|
314
|
+
const parts = routeName.split('.');
|
|
315
|
+
return parts[parts.length - 1];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function siteTabs(routeName: string) {
|
|
319
|
+
return {
|
|
320
|
+
constructor: { name: 'TabContainerModel' },
|
|
321
|
+
xhId: 'siteTabs',
|
|
322
|
+
activeTabId: topSegment(routeName),
|
|
323
|
+
tabs: [
|
|
324
|
+
{ id: 'holdings', title: 'Holdings' },
|
|
325
|
+
{ id: 'research', title: 'Research' },
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Always mounted (not just while inside a company sub-route) — so the agent can
|
|
331
|
+
// jump straight into the currently-focused company's Ownership/Financials/Thesis
|
|
332
|
+
// from anywhere, the same way a persistent detail-panel TabContainer would in a
|
|
333
|
+
// real Hoist app. `store.params.ticker` is the "currently focused company" and is
|
|
334
|
+
// always set (defaults to the first holding).
|
|
335
|
+
function companyTabs(routeName: string) {
|
|
336
|
+
return {
|
|
337
|
+
constructor: { name: 'TabContainerModel' },
|
|
338
|
+
xhId: 'companyTabs',
|
|
339
|
+
activeTabId: routeName.startsWith('default.company.ticker')
|
|
340
|
+
? leafSegment(routeName)
|
|
341
|
+
: '',
|
|
342
|
+
tabs: [
|
|
343
|
+
{ id: 'thesis', title: 'Thesis' },
|
|
344
|
+
{ id: 'financials', title: 'Financials' },
|
|
345
|
+
{ id: 'ownership', title: 'Ownership' },
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ─────────────────────────────── the store ───────────────────────────────
|
|
351
|
+
* useSyncExternalStore requires getSnapshot() to return a value that changes
|
|
352
|
+
* (by Object.is) exactly when the underlying data changes, and stays stable
|
|
353
|
+
* otherwise — so `state` is REPLACED wholesale on every change, never mutated
|
|
354
|
+
* in place. */
|
|
355
|
+
|
|
356
|
+
interface StoreState {
|
|
357
|
+
name: string;
|
|
358
|
+
params: Params;
|
|
359
|
+
/** Holdings-grid row selection, by ticker — independent of `params.ticker`
|
|
360
|
+
* (the focused company for the nav/drill-in), same as a real grid letting
|
|
361
|
+
* you select a row without navigating away. */
|
|
362
|
+
selection: string | null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let state: StoreState = {
|
|
366
|
+
name: 'default.holdings',
|
|
367
|
+
params: { ticker: HOLDINGS[0].ticker },
|
|
368
|
+
selection: null,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const listeners = new Set<() => void>();
|
|
372
|
+
function setState(patch: Partial<StoreState>): void {
|
|
373
|
+
state = { ...state, ...patch };
|
|
374
|
+
for (const l of listeners) l();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function toTicker(v: unknown, fallback: string): string {
|
|
378
|
+
return typeof v === 'string' && v ? v : fallback;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Navigate the mock — the SAME function whether the call came from the agent's
|
|
383
|
+
* `hoist.navigate` tool (assigned as `XH.navigate` below) or a plain click in
|
|
384
|
+
* HoistDemo.tsx, so there is exactly one code path for "the page changed" and a
|
|
385
|
+
* human click is indistinguishable from an agent-driven one.
|
|
386
|
+
*
|
|
387
|
+
* Also updates the real URL via history.pushState — which, by the time any
|
|
388
|
+
* navigation can actually happen, has already been monkey-patched by the SDK's
|
|
389
|
+
* own context.ts (watchNavigation, installed when <MatterfactAgent> mounts) — so
|
|
390
|
+
* an agent-driven navigate here also republishes host.context/host.tools exactly
|
|
391
|
+
* like a real SPA route change, with no extra wiring on our part.
|
|
392
|
+
*/
|
|
393
|
+
export function mockNavigate(
|
|
394
|
+
name: string,
|
|
395
|
+
params: Record<string, unknown> = {},
|
|
396
|
+
): void {
|
|
397
|
+
const ticker = toTicker(params.ticker, state.params.ticker);
|
|
398
|
+
setState({ name, params: { ticker } });
|
|
399
|
+
const path = buildPath(name, { ticker }) || '/';
|
|
400
|
+
if (path !== location.pathname) {
|
|
401
|
+
// Preserve ?hoist=1 (and anything else in the query string) across
|
|
402
|
+
// navigation — pushState only touches the path.
|
|
403
|
+
history.pushState(null, '', path + location.search);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function selectHolding(ticker: string): void {
|
|
408
|
+
setState({ selection: state.selection === ticker ? null : ticker });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function useMockXH(): StoreState {
|
|
412
|
+
return useSyncExternalStore(
|
|
413
|
+
(onChange) => {
|
|
414
|
+
listeners.add(onChange);
|
|
415
|
+
return () => listeners.delete(onChange);
|
|
416
|
+
},
|
|
417
|
+
() => state,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* ─────────────────────────────── window.XH ───────────────────────────────── */
|
|
422
|
+
|
|
423
|
+
function getModels(className: string): unknown[] {
|
|
424
|
+
switch (className) {
|
|
425
|
+
case 'TabContainerModel':
|
|
426
|
+
return [siteTabs(state.name), companyTabs(state.name)];
|
|
427
|
+
case 'GridModel':
|
|
428
|
+
if (state.name === 'default.holdings')
|
|
429
|
+
return [holdingsGrid(state.selection)];
|
|
430
|
+
if (state.name === 'default.company.ticker.ownership')
|
|
431
|
+
return [ownershipGrid(state.params.ticker)];
|
|
432
|
+
return [];
|
|
433
|
+
case 'ChartModel':
|
|
434
|
+
if (state.name === 'default.company.ticker.financials')
|
|
435
|
+
return [financialsChart(state.params.ticker)];
|
|
436
|
+
return [];
|
|
437
|
+
default:
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const XH = {
|
|
443
|
+
// A getter, not a plain property: hoist-runtime.ts reads `XH.routerState`
|
|
444
|
+
// fresh on every call, so this just has to reflect the CURRENT `state` live.
|
|
445
|
+
get routerState() {
|
|
446
|
+
return {
|
|
447
|
+
name: state.name,
|
|
448
|
+
path: buildPath(state.name, state.params) || location.pathname,
|
|
449
|
+
params: state.params,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
router: { buildPath, rootNode: ROUTE_TREE },
|
|
453
|
+
getModels,
|
|
454
|
+
navigate: mockNavigate,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
export function isHoistModeEnabled(): boolean {
|
|
458
|
+
if (typeof window === 'undefined') return false;
|
|
459
|
+
return new URLSearchParams(location.search).get('hoist') === '1';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let installed = false;
|
|
463
|
+
|
|
464
|
+
/** Install the mock onto `window.XH`. Idempotent; call once, eagerly, before
|
|
465
|
+
* React renders (see main.tsx) — never from inside a component effect. */
|
|
466
|
+
export function installMockXH(): void {
|
|
467
|
+
if (typeof window === 'undefined' || installed) return;
|
|
468
|
+
installed = true;
|
|
469
|
+
window.XH = XH;
|
|
470
|
+
|
|
471
|
+
// Land on whatever the URL already says (a deep link or a reload), not always
|
|
472
|
+
// the Holdings default.
|
|
473
|
+
if (location.pathname !== '/') {
|
|
474
|
+
const initial = routeFromPath(location.pathname);
|
|
475
|
+
state = {
|
|
476
|
+
...state,
|
|
477
|
+
name: initial.name,
|
|
478
|
+
params: { ticker: initial.params.ticker ?? state.params.ticker },
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Keep XH.routerState in sync on browser back/forward. The FORWARD direction
|
|
483
|
+
// (mockNavigate) already calls history.pushState itself, above; this is the
|
|
484
|
+
// other half.
|
|
485
|
+
window.addEventListener('popstate', () => {
|
|
486
|
+
const r = routeFromPath(location.pathname);
|
|
487
|
+
setState({
|
|
488
|
+
name: r.name,
|
|
489
|
+
params: { ticker: r.params.ticker ?? state.params.ticker },
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which PLACEMENT a demo page embeds the agent with — the two patterns we ship:
|
|
5
|
+
*
|
|
6
|
+
* 'panel' — `inline`: the host owns the box, the widget fills it and draws no
|
|
7
|
+
* chrome of its own. What a product with its own side panel uses.
|
|
8
|
+
* 'corner' — the widget owns everything: an edge tab (or a floating circle),
|
|
9
|
+
* opening into a side panel it positions and sizes itself.
|
|
10
|
+
*
|
|
11
|
+
* Shared rather than per-page on purpose. The switch first existed only in App.tsx,
|
|
12
|
+
* so when the Hoist demo arrived it hardcoded `inline` and the widget-owned placement
|
|
13
|
+
* was simply unreachable there — a demo that can only show one of the two things it
|
|
14
|
+
* exists to demonstrate. One hook and one control means the next page can't drift.
|
|
15
|
+
*/
|
|
16
|
+
export type Placement = 'panel' | 'corner';
|
|
17
|
+
|
|
18
|
+
export function usePlacement(initial: Placement = 'corner') {
|
|
19
|
+
return useState<Placement>(initial);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function PlacementSwitch({
|
|
23
|
+
placement,
|
|
24
|
+
onChange,
|
|
25
|
+
}: {
|
|
26
|
+
placement: Placement;
|
|
27
|
+
onChange: (p: Placement) => void;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="placement" role="group" aria-label="Agent placement">
|
|
31
|
+
{(['corner', 'panel'] as const).map((p) => (
|
|
32
|
+
<button
|
|
33
|
+
key={p}
|
|
34
|
+
className={placement === p ? 'seg on' : 'seg'}
|
|
35
|
+
aria-pressed={placement === p}
|
|
36
|
+
onClick={() => onChange(p)}
|
|
37
|
+
>
|
|
38
|
+
{p === 'corner' ? 'Corner' : 'Panel'}
|
|
39
|
+
</button>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|