rastack 0.0.55 → 0.0.56

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.56](https://github.com/theserverkid/reactapistack/compare/v0.0.55...v0.0.56) (2026-07-18)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **deps:** pin TypeScript to 5.x, keep esbuild 0.28.1 ([#96](https://github.com/theserverkid/reactapistack/issues/96)) ([2a5984d](https://github.com/theserverkid/reactapistack/commit/2a5984d4d10d5af8cf5cc448cd4eca5348515589)), closes [#76](https://github.com/theserverkid/reactapistack/issues/76)
11
+
5
12
  ### [0.0.55](https://github.com/theserverkid/reactapistack/compare/v0.0.54...v0.0.55) (2026-07-16)
6
13
 
7
14
  ### [0.0.54](https://github.com/theserverkid/reactapistack/compare/v0.0.53...v0.0.54) (2026-07-16)
@@ -22,11 +22,11 @@ import {
22
22
  import type { RastackEngine } from "../provider/types";
23
23
  import {
24
24
  AdminResource,
25
- adminResources,
26
25
  changelistHeaders,
27
26
  findResource,
28
27
  } from "./adapters";
29
28
  import {
29
+ useDashboardStats,
30
30
  useDeleteRecord,
31
31
  useFilterModel,
32
32
  useResourceForm,
@@ -36,9 +36,22 @@ import { Sidebar } from "./ui/Sidebar";
36
36
  import { FilterBar } from "./ui/FilterBar";
37
37
  import { AdminStyles } from "./ui/styles";
38
38
  import {
39
+ BarChart,
40
+ CountUp,
41
+ MeterRow,
42
+ Reveal,
43
+ SegmentBar,
44
+ seriesColor,
45
+ } from "./ui/motion";
46
+ import { DetailsModal, type DetailRow } from "./ui/DetailsModal";
47
+ import {
48
+ IconActivity,
39
49
  IconArrowLeft,
50
+ IconArrowUpRight,
40
51
  IconChevronRight,
52
+ IconDatabase,
41
53
  IconInbox,
54
+ IconLayers,
42
55
  IconLink,
43
56
  IconPlus,
44
57
  IconTable,
@@ -122,58 +135,267 @@ function PageHead(props: {
122
135
  );
123
136
  }
124
137
 
125
- // -- index / overview -------------------------------------------------------
138
+ // -- index / Insight dashboard ----------------------------------------------
126
139
 
127
- function IndexScreen(props: { resources: AdminResource[] }): React.JSX.Element {
128
- const byApp = useMemo(() => {
129
- const map = new Map<string, AdminResource[]>();
130
- for (const r of adminResources(props.resources)) {
131
- const list = map.get(r.app) ?? [];
132
- list.push(r);
133
- map.set(r.app, list);
134
- }
135
- return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
136
- }, [props.resources]);
140
+ /** One KPI tile: an icon, an animated count, and a quiet sublabel. */
141
+ function StatTile(props: {
142
+ icon: React.ReactNode;
143
+ label: string;
144
+ value: number;
145
+ sub: string;
146
+ index: number;
147
+ accent?: string;
148
+ }): React.JSX.Element {
149
+ return (
150
+ <Reveal className="ras-stat" index={props.index}>
151
+ <div className="ras-stat-top">
152
+ <span className="ras-stat-ico" style={props.accent ? { color: props.accent } : undefined}>
153
+ {props.icon}
154
+ </span>
155
+ <span className="ras-stat-label">{props.label}</span>
156
+ </div>
157
+ <div className="ras-stat-value">
158
+ <CountUp value={props.value} delay={props.index * 80} />
159
+ </div>
160
+ <div className="ras-stat-sub">{props.sub}</div>
161
+ </Reveal>
162
+ );
163
+ }
164
+
165
+ function DashboardScreen(props: {
166
+ engine: RastackEngine;
167
+ resources: AdminResource[];
168
+ }): React.JSX.Element {
169
+ const stats = useDashboardStats(props.engine, props.resources);
170
+ const populated = useMemo(
171
+ () => stats.models.filter((m) => m.count > 0),
172
+ [stats.models],
173
+ );
174
+ const [details, setDetails] = useState<null | "apps" | "tables">(null);
175
+
176
+ const appRows: DetailRow[] = stats.byApp.map((a, i) => ({
177
+ label: a.app,
178
+ value: a.count,
179
+ color: seriesColor(i),
180
+ meta: `${a.models} ${a.models === 1 ? "table" : "tables"}`,
181
+ }));
182
+ const tableRows: DetailRow[] = stats.models.map((m, i) => ({
183
+ label: m.resource.label,
184
+ value: m.count,
185
+ color: seriesColor(i),
186
+ meta: m.resource.app,
187
+ }));
137
188
 
138
189
  return (
139
190
  <>
140
- <Topbar crumbs={[{ label: "Overview" }]} />
141
- <div className="ras-content">
191
+ <Topbar crumbs={[{ label: "Insight" }]} />
192
+ <div className="ras-content" key="dashboard">
142
193
  <PageHead
143
194
  eyebrow="React API Stack"
144
- title="Administration"
145
- subtitle="Browse and edit every model in the manifest — stored as Apache Iceberg tables on object storage."
195
+ title="Insight"
196
+ subtitle="Every model in the manifest, live — stored as Apache Iceberg tables on object storage."
146
197
  />
147
- {byApp.map(([app, models]) => (
148
- <div key={app} style={{ marginBottom: 28 }}>
149
- <div className="ras-nav-label" style={{ padding: "0 2px 10px" }}>
150
- {app}
151
- </div>
152
- <div className="ras-cardgrid">
153
- {models
154
- .slice()
155
- .sort((a, b) => a.model.localeCompare(b.model))
156
- .map((r) => (
157
- <Card
158
- key={r.key}
159
- className="ras-modelcard"
160
- onClick={() => go(`#/${r.app}/${r.model}/`)}
161
- >
162
- <div className="ras-mc-top">
163
- <span className="ras-mc-icon">
164
- <IconTable size={19} />
165
- </span>
166
- </div>
167
- <div className="ras-mc-name">{r.label}</div>
168
- <div className="ras-mc-go">
169
- View records <IconChevronRight size={14} />
170
- </div>
171
- </Card>
172
- ))}
198
+
199
+ {/* KPI strip */}
200
+ <div className="ras-statgrid">
201
+ <StatTile
202
+ index={0}
203
+ icon={<IconDatabase size={17} />}
204
+ label="Total records"
205
+ value={stats.totalRecords}
206
+ sub={stats.loading ? "counting…" : "across every table"}
207
+ />
208
+ <StatTile
209
+ index={1}
210
+ icon={<IconTable size={17} />}
211
+ label="Tables"
212
+ value={stats.modelCount}
213
+ sub={`${populated.length} with data`}
214
+ accent="var(--color-success)"
215
+ />
216
+ <StatTile
217
+ index={2}
218
+ icon={<IconLayers size={17} />}
219
+ label="Apps"
220
+ value={stats.appCount}
221
+ sub="namespaces in the schema"
222
+ accent="var(--color-warning)"
223
+ />
224
+ <StatTile
225
+ index={3}
226
+ icon={<IconLink size={17} />}
227
+ label="Relations"
228
+ value={stats.relationCount}
229
+ sub="foreign keys inferred"
230
+ accent="var(--color-accent)"
231
+ />
232
+ </div>
233
+
234
+ {/* Data distribution — the animated bar chart */}
235
+ <Reveal index={4}>
236
+ <Card className="ras-panel">
237
+ <div className="ras-panel-head">
238
+ <div>
239
+ <div className="ras-panel-title">
240
+ <IconActivity size={15} /> Data distribution
241
+ </div>
242
+ <div className="ras-panel-sub">Records per table</div>
243
+ </div>
244
+ <span className="ras-pill">{stats.modelCount} tables</span>
173
245
  </div>
174
- </div>
175
- ))}
246
+ {stats.loading ? (
247
+ <div className="ras-chart-skel" />
248
+ ) : stats.totalRecords === 0 ? (
249
+ <div className="ras-panel-empty">
250
+ No records yet — add data to any table to see it chart here.
251
+ </div>
252
+ ) : (
253
+ <BarChart
254
+ data={stats.models.map((m) => ({
255
+ label: m.resource.label,
256
+ value: m.count,
257
+ }))}
258
+ />
259
+ )}
260
+ </Card>
261
+ </Reveal>
262
+
263
+ {/* Two breakdown cards */}
264
+ <div className="ras-panel-row">
265
+ <Reveal index={5}>
266
+ <Card className="ras-panel">
267
+ <div className="ras-panel-head">
268
+ <div>
269
+ <div className="ras-panel-title">Records by app</div>
270
+ <div className="ras-panel-sub">Share of the warehouse</div>
271
+ </div>
272
+ {stats.byApp.length ? (
273
+ <button type="button" className="ras-details-btn" onClick={() => setDetails("apps")}>
274
+ Details
275
+ </button>
276
+ ) : null}
277
+ </div>
278
+ {stats.byApp.length && stats.totalRecords > 0 ? (
279
+ <>
280
+ <SegmentBar
281
+ segments={stats.byApp.map((a, i) => ({
282
+ label: a.app,
283
+ value: a.count,
284
+ color: seriesColor(i),
285
+ }))}
286
+ />
287
+ <div className="ras-legend">
288
+ {stats.byApp.map((a, i) => (
289
+ <div className="ras-legend-row" key={a.app}>
290
+ <span className="ras-legend-key">
291
+ <span className="ras-legend-dot" style={{ background: seriesColor(i) }} />
292
+ {a.app}
293
+ </span>
294
+ <span className="ras-legend-meta">
295
+ {a.models} {a.models === 1 ? "table" : "tables"}
296
+ </span>
297
+ <span className="ras-legend-val">
298
+ <CountUp value={a.count} delay={300 + i * 60} />
299
+ <span className="ras-legend-pct">
300
+ {Math.round((a.count / stats.totalRecords) * 100)}%
301
+ </span>
302
+ </span>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ </>
307
+ ) : (
308
+ <div className="ras-panel-empty">No data across any app yet.</div>
309
+ )}
310
+ </Card>
311
+ </Reveal>
312
+
313
+ <Reveal index={6}>
314
+ <Card className="ras-panel">
315
+ <div className="ras-panel-head">
316
+ <div>
317
+ <div className="ras-panel-title">Largest tables</div>
318
+ <div className="ras-panel-sub">By record count</div>
319
+ </div>
320
+ {populated.length ? (
321
+ <button type="button" className="ras-details-btn" onClick={() => setDetails("tables")}>
322
+ Details
323
+ </button>
324
+ ) : null}
325
+ </div>
326
+ {populated.length ? (
327
+ <div className="ras-meters">
328
+ {populated.slice(0, 5).map((m, i) => (
329
+ <a
330
+ key={m.resource.key}
331
+ href={`#/${m.resource.app}/${m.resource.model}/`}
332
+ className="ras-meter-link"
333
+ >
334
+ <MeterRow
335
+ label={m.resource.label}
336
+ value={m.count}
337
+ max={populated[0].count}
338
+ color={seriesColor(i)}
339
+ index={i}
340
+ />
341
+ </a>
342
+ ))}
343
+ </div>
344
+ ) : (
345
+ <div className="ras-panel-empty">Tables fill in as you add records.</div>
346
+ )}
347
+ </Card>
348
+ </Reveal>
349
+ </div>
350
+
351
+ {/* Model launcher */}
352
+ <Reveal index={7}>
353
+ <div className="ras-section-label">Browse tables</div>
354
+ </Reveal>
355
+ <div className="ras-cardgrid">
356
+ {stats.models.map((m, i) => (
357
+ <Reveal key={m.resource.key} index={8 + i} step={45}>
358
+ <Card
359
+ className="ras-modelcard"
360
+ onClick={() => go(`#/${m.resource.app}/${m.resource.model}/`)}
361
+ >
362
+ <div className="ras-mc-top">
363
+ <span className="ras-mc-icon">
364
+ <IconTable size={18} />
365
+ </span>
366
+ <span className="ras-mc-count">
367
+ {stats.loading ? "—" : <CountUp value={m.count} delay={i * 40} />}
368
+ </span>
369
+ </div>
370
+ <div className="ras-mc-name">{m.resource.label}</div>
371
+ <div className="ras-mc-meta">{m.resource.app}</div>
372
+ <div className="ras-mc-go">
373
+ View records <IconArrowUpRight size={14} />
374
+ </div>
375
+ </Card>
376
+ </Reveal>
377
+ ))}
378
+ </div>
176
379
  </div>
380
+
381
+ <DetailsModal
382
+ open={details === "apps"}
383
+ title="Records by app"
384
+ subtitle="Every app namespace by total records"
385
+ rows={appRows}
386
+ total={stats.totalRecords}
387
+ unit="records"
388
+ onClose={() => setDetails(null)}
389
+ />
390
+ <DetailsModal
391
+ open={details === "tables"}
392
+ title="Tables by record count"
393
+ subtitle="Every table in the manifest, ranked"
394
+ rows={tableRows}
395
+ total={stats.totalRecords}
396
+ unit="records"
397
+ onClose={() => setDetails(null)}
398
+ />
177
399
  </>
178
400
  );
179
401
  }
@@ -278,9 +500,14 @@ function ListScreen(props: {
278
500
  eyebrow={resource.app}
279
501
  title={resource.label}
280
502
  subtitle={
281
- table.query.isLoading
282
- ? "Loading…"
283
- : `${table.itemCount} ${table.itemCount === 1 ? "record" : "records"}`
503
+ table.query.isLoading ? (
504
+ "Loading…"
505
+ ) : (
506
+ <>
507
+ <CountUp value={table.itemCount} duration={650} />{" "}
508
+ {table.itemCount === 1 ? "record" : "records"}
509
+ </>
510
+ )
284
511
  }
285
512
  actions={
286
513
  <Button onClick={() => go(addHash)}>
@@ -402,19 +629,44 @@ function FormScreen(props: {
402
629
 
403
630
  // -- root -------------------------------------------------------------------
404
631
 
632
+ const COLLAPSE_KEY = "ras-admin-sidebar-collapsed";
633
+
634
+ /** Persisted sidebar collapse state; drives the grid-track transition. */
635
+ function useSidebarCollapsed(): [boolean, () => void] {
636
+ const [collapsed, setCollapsed] = useState(() => {
637
+ try {
638
+ return localStorage.getItem(COLLAPSE_KEY) === "1";
639
+ } catch {
640
+ return false;
641
+ }
642
+ });
643
+ const toggle = () =>
644
+ setCollapsed((c) => {
645
+ const next = !c;
646
+ try {
647
+ localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
648
+ } catch {
649
+ /* storage may be unavailable */
650
+ }
651
+ return next;
652
+ });
653
+ return [collapsed, toggle];
654
+ }
655
+
405
656
  export function AdminApp(props: {
406
657
  engine: RastackEngine;
407
658
  resources: AdminResource[];
408
659
  }): React.JSX.Element {
409
660
  const route = useRoute();
410
661
  const { engine, resources } = props;
662
+ const [collapsed, toggleCollapsed] = useSidebarCollapsed();
411
663
 
412
664
  const activeKey =
413
665
  route.name === "index" ? undefined : `${route.app}.${route.model}`;
414
666
 
415
667
  let screen: React.ReactNode;
416
668
  if (route.name === "index") {
417
- screen = <IndexScreen resources={resources} />;
669
+ screen = <DashboardScreen engine={engine} resources={resources} />;
418
670
  } else {
419
671
  const resource = findResource(resources, route.app, route.model);
420
672
  if (!resource) {
@@ -452,8 +704,13 @@ export function AdminApp(props: {
452
704
  return (
453
705
  <>
454
706
  <AdminStyles />
455
- <div className="ras-root">
456
- <Sidebar resources={resources} activeKey={activeKey} />
707
+ <div className={"ras-root" + (collapsed ? " is-collapsed" : "")}>
708
+ <Sidebar
709
+ resources={resources}
710
+ activeKey={activeKey}
711
+ collapsed={collapsed}
712
+ onToggleCollapse={toggleCollapsed}
713
+ />
457
714
  <main className="ras-main">{screen}</main>
458
715
  </div>
459
716
  </>
@@ -0,0 +1,125 @@
1
+ /**
2
+ * A backdrop-blur "Details" drill-down modal — the reference's "Details" affordance.
3
+ * Built to the design-engineer §4 spec, dependency-free:
4
+ *
5
+ * • overlay: fixed inset:0, translucent fill + backdrop-filter blur(16px)
6
+ * saturate(140%), fading opacity 0→1 over 400ms
7
+ * • content: transform scale(0.95) translate3d(0,10px,0) → identity over 400ms
8
+ * on cubic-bezier(0.16, 1, 0.3, 1)
9
+ * • execution order: overlay + shell mount → after stabilise, the internal
10
+ * horizontal bars animate width 0%→X% over 800ms cubic-bezier(0.4,0,0.2,1)
11
+ *
12
+ * All of it honours prefers-reduced-motion (via the stylesheet), and the bars
13
+ * carry a count-up value. Closes on Escape, backdrop click, or the close button.
14
+ */
15
+ import React, { useEffect, useState } from "react";
16
+ import { CountUp } from "./motion";
17
+ import { IconClose } from "./icons";
18
+
19
+ /** One ranked row in the modal: a labelled bar filling to `value / max`. */
20
+ export interface DetailRow {
21
+ label: string;
22
+ value: number;
23
+ color: string;
24
+ meta?: string;
25
+ }
26
+
27
+ export function DetailsModal(props: {
28
+ open: boolean;
29
+ title: string;
30
+ subtitle?: string;
31
+ rows: DetailRow[];
32
+ total: number;
33
+ unit?: string;
34
+ onClose: () => void;
35
+ }): React.JSX.Element | null {
36
+ const { open, onClose } = props;
37
+ // `armed` gates the width-fill: it flips true one tick after mount so the bars
38
+ // transition from 0 rather than appearing full (the §4 "secondary state" step).
39
+ const [armed, setArmed] = useState(false);
40
+
41
+ useEffect(() => {
42
+ if (!open) {
43
+ setArmed(false);
44
+ return;
45
+ }
46
+ const raf = requestAnimationFrame(() => setArmed(true));
47
+ const onKey = (e: KeyboardEvent) => {
48
+ if (e.key === "Escape") onClose();
49
+ };
50
+ window.addEventListener("keydown", onKey);
51
+ const prev = document.body.style.overflow;
52
+ document.body.style.overflow = "hidden";
53
+ return () => {
54
+ cancelAnimationFrame(raf);
55
+ window.removeEventListener("keydown", onKey);
56
+ document.body.style.overflow = prev;
57
+ };
58
+ }, [open, onClose]);
59
+
60
+ if (!open) return null;
61
+ const max = Math.max(1, ...props.rows.map((r) => r.value));
62
+
63
+ return (
64
+ <div
65
+ className="ras-modal-overlay"
66
+ onClick={onClose}
67
+ role="dialog"
68
+ aria-modal="true"
69
+ aria-label={props.title}
70
+ >
71
+ <div className="ras-modal" onClick={(e) => e.stopPropagation()}>
72
+ <div className="ras-modal-head">
73
+ <div>
74
+ <div className="ras-modal-title">{props.title}</div>
75
+ {props.subtitle ? <div className="ras-modal-sub">{props.subtitle}</div> : null}
76
+ </div>
77
+ <button type="button" className="ras-modal-x" onClick={onClose} aria-label="Close">
78
+ <IconClose size={16} />
79
+ </button>
80
+ </div>
81
+
82
+ <div className="ras-modal-rows">
83
+ {props.rows.map((row, i) => {
84
+ const pct = Math.max(1.5, (row.value / max) * 100);
85
+ const share = props.total > 0 ? Math.round((row.value / props.total) * 100) : 0;
86
+ return (
87
+ <div className="ras-modal-row" key={row.label + i}>
88
+ <div className="ras-modal-row-head">
89
+ <span className="ras-modal-row-label">
90
+ <span className="ras-legend-dot" style={{ background: row.color }} />
91
+ {row.label}
92
+ {row.meta ? <span className="ras-modal-row-meta">{row.meta}</span> : null}
93
+ </span>
94
+ <span className="ras-modal-row-val">
95
+ <CountUp value={row.value} delay={120 + i * 55} duration={900} />
96
+ <span className="ras-modal-row-pct">{share}%</span>
97
+ </span>
98
+ </div>
99
+ <div className="ras-modal-track">
100
+ <div
101
+ className="ras-modal-fill"
102
+ style={{
103
+ width: armed ? `${pct}%` : "0%",
104
+ background: row.color,
105
+ transitionDelay: `${i * 55}ms`,
106
+ }}
107
+ />
108
+ </div>
109
+ </div>
110
+ );
111
+ })}
112
+ </div>
113
+
114
+ <div className="ras-modal-foot">
115
+ <span>
116
+ {props.rows.length} {props.rows.length === 1 ? "row" : "rows"}
117
+ </span>
118
+ <span className="ras-modal-total">
119
+ <CountUp value={props.total} duration={900} /> {props.unit ?? "total"}
120
+ </span>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
@@ -10,13 +10,16 @@ import { useThemeMode } from "../../theme";
10
10
  import { humanizeKey } from "../../components";
11
11
  import type { AdminResource } from "../adapters";
12
12
  import { adminResources } from "../adapters";
13
- import { BrandMark, IconMoon, IconSun, IconTable } from "./icons";
13
+ import { BrandMark, IconMoon, IconPanelLeft, IconSun, IconTable } from "./icons";
14
14
 
15
15
  export function Sidebar(props: {
16
16
  resources: AdminResource[];
17
17
  activeKey?: string;
18
+ collapsed?: boolean;
19
+ onToggleCollapse?: () => void;
18
20
  }): React.JSX.Element {
19
21
  const [mode, setMode] = useThemeMode();
22
+ const collapsed = props.collapsed ?? false;
20
23
 
21
24
  const byApp = useMemo(() => {
22
25
  const map = new Map<string, AdminResource[]>();
@@ -37,15 +40,29 @@ export function Sidebar(props: {
37
40
 
38
41
  return (
39
42
  <aside className="ras-sidebar">
40
- <a className="ras-brand" href="#/" aria-label="React API Stack admin">
41
- <BrandMark size={32} />
42
- <span>
43
- <span className="ras-brand-name" style={{ display: "block" }}>
44
- React API Stack
43
+ <div className="ras-brand-row">
44
+ <a className="ras-brand" href="#/" aria-label="React API Stack admin">
45
+ <BrandMark size={32} />
46
+ <span className="ras-brand-text">
47
+ <span className="ras-brand-name" style={{ display: "block" }}>
48
+ React API Stack
49
+ </span>
50
+ <span className="ras-brand-sub">Admin</span>
45
51
  </span>
46
- <span className="ras-brand-sub">Admin</span>
47
- </span>
48
- </a>
52
+ </a>
53
+ {props.onToggleCollapse ? (
54
+ <button
55
+ type="button"
56
+ className="ras-icon-btn ras-collapse-btn"
57
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
58
+ aria-pressed={collapsed}
59
+ title={collapsed ? "Expand" : "Collapse"}
60
+ onClick={props.onToggleCollapse}
61
+ >
62
+ <IconPanelLeft size={16} />
63
+ </button>
64
+ ) : null}
65
+ </div>
49
66
 
50
67
  <nav className="ras-nav">
51
68
  {byApp.map(([app, models]) => (
@@ -59,9 +76,10 @@ export function Sidebar(props: {
59
76
  href={`#/${r.app}/${r.model}/`}
60
77
  className={"ras-nav-link" + (active ? " is-active" : "")}
61
78
  aria-current={active ? "page" : undefined}
79
+ title={collapsed ? r.label : undefined}
62
80
  >
63
81
  <IconTable size={16} className="ras-ico" />
64
- {r.label}
82
+ <span className="ras-nav-text">{r.label}</span>
65
83
  </a>
66
84
  );
67
85
  })}
@@ -125,6 +125,47 @@ export const IconInbox = (p: SvgProps) => (
125
125
  </Svg>
126
126
  );
127
127
 
128
+ export const IconDatabase = (p: SvgProps) => (
129
+ <Svg {...p}>
130
+ <ellipse cx="12" cy="5" rx="8" ry="3" />
131
+ <path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
132
+ <path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
133
+ </Svg>
134
+ );
135
+
136
+ export const IconLayers = (p: SvgProps) => (
137
+ <Svg {...p}>
138
+ <path d="m12 3 9 5-9 5-9-5 9-5Z" />
139
+ <path d="m3 13 9 5 9-5" />
140
+ </Svg>
141
+ );
142
+
143
+ export const IconActivity = (p: SvgProps) => (
144
+ <Svg {...p}>
145
+ <path d="M3 12h4l2.5-7 5 14 2.5-7H21" />
146
+ </Svg>
147
+ );
148
+
149
+ export const IconArrowUpRight = (p: SvgProps) => (
150
+ <Svg {...p}>
151
+ <path d="M7 17 17 7M8 7h9v9" />
152
+ </Svg>
153
+ );
154
+
155
+ export const IconPanelLeft = (p: SvgProps) => (
156
+ <Svg {...p}>
157
+ <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
158
+ <path d="M9.5 4.5v15" />
159
+ </Svg>
160
+ );
161
+
162
+ export const IconBell = (p: SvgProps) => (
163
+ <Svg {...p}>
164
+ <path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6Z" />
165
+ <path d="M10 20a2 2 0 0 0 4 0" />
166
+ </Svg>
167
+ );
168
+
128
169
  /** The brand mark: an isometric "stack" tile in the accent colour. */
129
170
  export function BrandMark({ size = 30 }: { size?: number }): React.JSX.Element {
130
171
  return (