@manta-eu/horns-kit 0.1.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/dist/catalog/manta-react.json +914 -0
  3. package/dist/catalog.d.ts +21 -0
  4. package/dist/catalog.js +7 -0
  5. package/dist/embed/client.d.ts +15 -0
  6. package/dist/embed/client.js +45 -0
  7. package/dist/embed/horn-snapshot-view.d.ts +64 -0
  8. package/dist/embed/horn-snapshot-view.js +142 -0
  9. package/dist/embed/plan-strip.d.ts +49 -0
  10. package/dist/embed/plan-strip.js +141 -0
  11. package/dist/embed/server.d.ts +10 -0
  12. package/dist/embed/server.js +17 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.js +5 -0
  15. package/dist/look/chrome.d.ts +11 -0
  16. package/dist/look/chrome.js +7 -0
  17. package/dist/look/format.d.ts +15 -0
  18. package/dist/look/format.js +44 -0
  19. package/dist/look/index.d.ts +4 -0
  20. package/dist/look/index.js +5 -0
  21. package/dist/look/palette.d.ts +21 -0
  22. package/dist/look/palette.js +46 -0
  23. package/dist/look/types.d.ts +7 -0
  24. package/dist/look/types.js +1 -0
  25. package/dist/shape-views/attio.json +21 -0
  26. package/dist/shape-views/coresignal.json +28 -0
  27. package/dist/shape-views/generated.json +3535 -0
  28. package/dist/shape-views/google.json +22 -0
  29. package/dist/shape-views/index.d.ts +2 -0
  30. package/dist/shape-views/index.js +13 -0
  31. package/dist/shape-views/linkedin.json +23 -0
  32. package/dist/shape-views/meta.json +19 -0
  33. package/dist/shape-views/slack.json +12 -0
  34. package/dist/written/index.d.ts +2 -0
  35. package/dist/written/index.js +4 -0
  36. package/dist/written/runtime.d.ts +43 -0
  37. package/dist/written/runtime.js +83 -0
  38. package/dist/written/written-view.d.ts +23 -0
  39. package/dist/written/written-view.js +47 -0
  40. package/package.json +72 -0
@@ -0,0 +1,21 @@
1
+ export declare const mantaReactCatalog: {
2
+ catalogVersion: 1;
3
+ source: {
4
+ package: string;
5
+ repository: string;
6
+ commit: string;
7
+ };
8
+ components: {
9
+ id: string;
10
+ export: string;
11
+ module: string;
12
+ role: "entity-detail" | "entity-plural" | "entity-card" | "action-card" | "generic-card";
13
+ input: "iri" | "entity" | "iris";
14
+ appliesTo: {
15
+ classes: string[];
16
+ actionShapes: string[];
17
+ };
18
+ stories: string[];
19
+ sandbox: "unverified" | "compatible" | "incompatible";
20
+ }[];
21
+ };
@@ -0,0 +1,7 @@
1
+ import { componentCatalogSchema } from "@manta-eu/horns";
2
+ import mantaReact from "./catalog/manta-react.json" with { type: "json" };
3
+ // Built-in components the Studio can pin before writing anything new. Regenerate with `catalog:snapshot`.
4
+ //
5
+ // It lives in a module of its own, apart from the package index, because it is data: a node script can
6
+ // import this without loading the kit's `.tsx`, which node's type-stripping cannot read.
7
+ export const mantaReactCatalog = componentCatalogSchema.parse(mantaReact);
@@ -0,0 +1,15 @@
1
+ import { type Root } from "react-dom/client";
2
+ import { type HornSnapshotViewProps } from "./horn-snapshot-view.tsx";
3
+ export declare const hornHydrationAttribute = "data-horn-hydration";
4
+ export type HydrateHornEmbedsOptions = {
5
+ root?: ParentNode;
6
+ onRecoverableError?: (error: unknown, embedId: string) => void;
7
+ /**
8
+ * Draws a written view once the page is live. The server sent a placeholder for it, so this is the one
9
+ * thing hydration adds rather than adopts — a host that passes nothing keeps the placeholder.
10
+ */
11
+ writtenView?: HornSnapshotViewProps["writtenView"];
12
+ /** Draws a catalog card once the page is live, for the same reason as `writtenView`. */
13
+ catalogCard?: HornSnapshotViewProps["catalogCard"];
14
+ };
15
+ export declare function hydrateHornEmbeds(options?: HydrateHornEmbedsOptions): Root[];
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { StrictMode, useEffect } from "react";
3
+ import { hydrateRoot } from "react-dom/client";
4
+ import { hornEmbedAttribute, hornReadyAttribute, hornSnapshotAttribute, parseHornSnapshot, } from "@manta-eu/horns/render";
5
+ import { HornSnapshotView } from "./horn-snapshot-view.js";
6
+ // Set when React had to discard server markup: a determinism bug in a view, worth surfacing loudly.
7
+ export const hornHydrationAttribute = "data-horn-hydration";
8
+ const hydrated = new WeakSet();
9
+ function MarkReady({ element, children }) {
10
+ useEffect(() => {
11
+ element.setAttribute(hornReadyAttribute, "true");
12
+ }, [element]);
13
+ return children;
14
+ }
15
+ function modeOf(value) {
16
+ return value === "embed" || value === "capture" ? value : "page";
17
+ }
18
+ function themeOf(value) {
19
+ return value === "dark" ? "dark" : "light";
20
+ }
21
+ // Hydrates every server-rendered embed under `root` from its own snapshot script, with no fetch: the data is
22
+ // already on the page. Safe to call again after a host inserts more embeds.
23
+ export function hydrateHornEmbeds(options = {}) {
24
+ const scope = options.root ?? document;
25
+ const scripts = [...scope.querySelectorAll(`script[${hornSnapshotAttribute}]`)];
26
+ return [...scope.querySelectorAll(`[${hornEmbedAttribute}]`)].flatMap((element) => {
27
+ if (hydrated.has(element))
28
+ return [];
29
+ const embedId = element.getAttribute(hornEmbedAttribute) ?? "";
30
+ const text = scripts.find((script) => script.getAttribute(hornSnapshotAttribute) === embedId)?.textContent;
31
+ if (!text)
32
+ return [];
33
+ hydrated.add(element);
34
+ const snapshot = parseHornSnapshot(text);
35
+ const view = (_jsx(HornSnapshotView, { snapshot: snapshot, mode: modeOf(element.getAttribute("data-horn-mode")), theme: themeOf(element.getAttribute("data-horn-theme")), ...(options.writtenView ? { writtenView: options.writtenView } : {}), ...(options.catalogCard ? { catalogCard: options.catalogCard } : {}) }));
36
+ return [
37
+ hydrateRoot(element, _jsx(StrictMode, { children: _jsx(MarkReady, { element: element, children: view }) }), {
38
+ onRecoverableError: (error) => {
39
+ element.setAttribute(hornHydrationAttribute, "recovered");
40
+ options.onRecoverableError?.(error, embedId);
41
+ },
42
+ }),
43
+ ];
44
+ });
45
+ }
@@ -0,0 +1,64 @@
1
+ import { type ReactNode } from "react";
2
+ import { type HornSnapshot, type SnapshotView } from "@manta-eu/horns/render";
3
+ import { type ChartTheme, type EntityLink } from "../look/index.ts";
4
+ import { type PlanActions } from "./plan-strip.tsx";
5
+ export type HornViewMode = "page" | "embed" | "capture";
6
+ /**
7
+ * What a written view gets beyond its own rows: the snapshot's names and where each IRI opens. A view is
8
+ * handed these rather than composing an href, because the path belongs to the host, not to the source.
9
+ */
10
+ export type WrittenViewContext = {
11
+ labels: Readonly<Record<string, string>>;
12
+ links: Readonly<Record<string, EntityLink>>;
13
+ /**
14
+ * The datastore the Horn belongs to.
15
+ *
16
+ * A catalog card fetches its own entity, so it needs somewhere to fetch from — and a server-rendered Horn
17
+ * document mounts no providers of its own. Without this the card spins for ever on a shared link.
18
+ */
19
+ datastoreId: string;
20
+ };
21
+ export type HornSnapshotViewProps = {
22
+ snapshot: HornSnapshot;
23
+ mode?: HornViewMode;
24
+ theme?: ChartTheme;
25
+ /** What the viewer may do with the action plans behind a view. Without it plans are shown, never run. */
26
+ planActions?: PlanActions;
27
+ /**
28
+ * Controls a host hangs on each view's own card — renaming it, taking it out. The kit only makes room: the
29
+ * markup is the host's, so a capture, which passes none, draws exactly what it drew before.
30
+ */
31
+ viewControls?: (view: SnapshotView) => ReactNode;
32
+ /**
33
+ * Draws a written view. The kit knows neither `@manta-eu/react` nor how to run model-written source, so a
34
+ * host that wants written views supplies this — `WrittenView` from `@manta-eu/horns-kit` does the work,
35
+ * given the host's own surface. Without it a written view stays a placeholder, which is also what a server
36
+ * render and a JavaScript-off capture always see.
37
+ */
38
+ writtenView?: (content: Extract<SnapshotView["content"], {
39
+ lane: "sandbox";
40
+ }>, view: SnapshotView, context: WrittenViewContext) => ReactNode;
41
+ /**
42
+ * Draws a catalog card. Same bargain as `writtenView`: the kit knows nothing of `@manta-eu/react`, so a
43
+ * host that wants cards supplies this and the card fetches its own entity in a browser. Without it a card
44
+ * stays a placeholder, which is what a server render and a capture always see.
45
+ */
46
+ catalogCard?: (content: Extract<SnapshotView["content"], {
47
+ lane: "component";
48
+ }>, view: SnapshotView, context: WrittenViewContext) => ReactNode;
49
+ };
50
+ /**
51
+ * Where each of a snapshot's IRIs opens, ready to hand a view.
52
+ *
53
+ * The snapshot stores what the link says; the path is composed here, because it belongs to the host rather
54
+ * than to anything a model wrote. Exported for hosts that draw one view on their own — a zoo, an entity page.
55
+ */
56
+ export declare function entityLinks(snapshot: HornSnapshot): Record<string, EntityLink>;
57
+ /** The widest a page is allowed to get. */
58
+ export declare const maxPageWidth = 1600;
59
+ /**
60
+ * The theme a Horn draws in: its own, and the host's only when it declares none. Exported because a host that
61
+ * wraps the markup has to name the same theme it rendered with, or hydration draws a different Horn.
62
+ */
63
+ export declare function hornSurface(snapshot: HornSnapshot, theme: ChartTheme): ChartTheme;
64
+ export declare function HornSnapshotView(props: HornSnapshotViewProps): import("react").JSX.Element;
@@ -0,0 +1,142 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { gridColumns } from "@manta-eu/horns";
4
+ import { captureWidth, entityLinkPath } from "@manta-eu/horns/render";
5
+ import { chartPalette, hornChrome } from "../look/index.js";
6
+ import { PlanStrip } from "./plan-strip.js";
7
+ /**
8
+ * Where each of a snapshot's IRIs opens, ready to hand a view.
9
+ *
10
+ * The snapshot stores what the link says; the path is composed here, because it belongs to the host rather
11
+ * than to anything a model wrote. Exported for hosts that draw one view on their own — a zoo, an entity page.
12
+ */
13
+ export function entityLinks(snapshot) {
14
+ return Object.fromEntries(Object.entries(snapshot.links).map(([iri, label]) => [
15
+ iri,
16
+ { href: entityLinkPath(snapshot.horn.datastoreId, iri), label },
17
+ ]));
18
+ }
19
+ const font = "Inter, system-ui, sans-serif";
20
+ /** The widest a page is allowed to get. */
21
+ export const maxPageWidth = 1600;
22
+ /** One row of the layout: what a view with `h: 1` is at least as tall as. */
23
+ const rowHeight = 72;
24
+ const pagePadding = 24;
25
+ /** Narrower than this a card of any span is not worth drawing; it flows onto its own row instead. */
26
+ const minPageWidth = 720;
27
+ const minCard = 220;
28
+ const surfaces = {
29
+ light: { ink: "#111827", muted: "#6b7280", card: "#ffffff", canvas: "#f4f5f7", border: "#e5e7eb", error: "#b91c1c" },
30
+ dark: { ink: "#f3f4f6", muted: "#9ca3af", card: "#111827", canvas: "#030712", border: "#1f2937", error: "#fca5a5" },
31
+ };
32
+ // Rendered twice from one snapshot — to a string on a server, then hydrated in a browser — so nothing here
33
+ // may read the clock, the locale or the window.
34
+ function asOf(iso) {
35
+ return `Data as of ${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
36
+ }
37
+ function filtersOf(snapshot) {
38
+ const set = snapshot.variables.filter((variable) => variable.value !== null);
39
+ if (set.length === 0)
40
+ return undefined;
41
+ return set.map((variable) => `${variable.label}: ${JSON.stringify(variable.value)}`).join(" · ");
42
+ }
43
+ /** What share of a row `span` columns is, as a percentage with a third's trailing noise left off. */
44
+ function share(span) {
45
+ return Math.round(((span / gridColumns) * 100 + Number.EPSILON) * 1e4) / 1e4;
46
+ }
47
+ /**
48
+ * Holds a written view's place through hydration.
49
+ *
50
+ * The server sent the placeholder, so the first client render has to be the placeholder too or React
51
+ * discards the whole tree and re-renders it — which is a real bug, not a cosmetic one, because every other
52
+ * view on the page gets thrown away with it. Drawing waits for the pass after mount, which is also the first
53
+ * pass where there is a browser to transpile in.
54
+ */
55
+ function WrittenSlot({ placeholder, draw }) {
56
+ const [live, setLive] = useState(false);
57
+ useEffect(() => setLive(true), []);
58
+ return live ? draw() : placeholder;
59
+ }
60
+ function ViewCard(props) {
61
+ const { view, theme, chrome, hornStyle, labels, links, plans, planActions, controls, writtenView } = props;
62
+ const { catalogCard, datastoreId, style } = props;
63
+ const context = { labels, links, datastoreId };
64
+ const colors = surfaces[theme];
65
+ const { content } = view;
66
+ return (_jsxs("section", { "data-horn-view": view.id, style: { background: colors.card, padding: chrome.padding, ...style }, children: [_jsxs("div", { style: {
67
+ display: "flex",
68
+ alignItems: "center",
69
+ justifyContent: "space-between",
70
+ gap: 8,
71
+ margin: `0 0 ${chrome.padding - 4}px`,
72
+ }, children: [_jsx("h2", { style: { margin: 0, fontSize: 15, lineHeight: "20px", color: colors.ink }, children: view.title }), controls === undefined ? null : _jsx("div", { "data-view-controls": true, children: controls })] }), content.lane === "component" ? (_jsx(WrittenSlot, { placeholder: _jsx("p", { style: { margin: 0, color: colors.muted, fontSize: 14 }, children: "This view is a catalog card, which draws in the browser rather than here." }), draw: () => catalogCard?.(content, view, context) })) : content.lane === "sandbox" ? (_jsx(WrittenSlot, { placeholder: _jsx("p", { style: { margin: 0, color: colors.muted, fontSize: 14 }, children: "This view is written code, which draws in the browser rather than here." }), draw: () => writtenView?.(content, view, context) })) : (_jsx("p", { role: "alert", style: { margin: 0, color: colors.error, fontSize: 14 }, children: content.message })), content.lane === "sandbox"
73
+ ? (content.plans ?? []).map((behind) => {
74
+ const plan = plans.find((one) => one.iri === behind.plan);
75
+ return plan === undefined ? null : (_jsx(PlanStrip, { plan: plan, onView: behind.entities, labels: labels, links: links, palette: chartPalette(theme, hornStyle), actions: planActions }, plan.iri));
76
+ })
77
+ : null] }));
78
+ }
79
+ /**
80
+ * The theme a Horn draws in: its own, and the host's only when it declares none. Exported because a host that
81
+ * wraps the markup has to name the same theme it rendered with, or hydration draws a different Horn.
82
+ */
83
+ export function hornSurface(snapshot, theme) {
84
+ return snapshot.style.theme ?? theme;
85
+ }
86
+ export function HornSnapshotView(props) {
87
+ const { snapshot, mode = "page", theme = "light", planActions, viewControls, writtenView, catalogCard, } = props;
88
+ const style = snapshot.style;
89
+ const surface = hornSurface(snapshot, theme);
90
+ const colors = surfaces[surface];
91
+ const chrome = hornChrome(style);
92
+ const links = entityLinks(snapshot);
93
+ if (mode === "capture") {
94
+ return (_jsx("main", { style: { width: captureWidth, background: colors.card, fontFamily: font }, children: snapshot.views.map((view) => (_jsx(ViewCard, { view: view, theme: surface, chrome: chrome, hornStyle: style, labels: snapshot.labels, links: links, datastoreId: snapshot.horn.datastoreId, plans: snapshot.plans,
95
+ // A capture on a server has no browser to draw written source in and passes neither of these, so
96
+ // it still gets the placeholder. A capture taken *by* a browser passes them and gets the view.
97
+ writtenView: writtenView, catalogCard: catalogCard }, view.id))) }));
98
+ }
99
+ if (mode === "embed") {
100
+ return (_jsx("div", { style: { display: "grid", gap: chrome.gap, fontFamily: font, color: colors.ink }, children: snapshot.views.map((view) => (_jsx(ViewCard, { view: view, theme: surface, chrome: chrome, hornStyle: style, labels: snapshot.labels, links: links, datastoreId: snapshot.horn.datastoreId, plans: snapshot.plans, planActions: planActions, writtenView: writtenView, catalogCard: catalogCard, style: { borderRadius: chrome.radius, border: `1px solid ${colors.border}` } }, view.id))) }));
101
+ }
102
+ const byId = new Map(snapshot.views.map((view) => [view.id, view]));
103
+ const order = new Map(snapshot.views.map((view, index) => [view.id, index]));
104
+ const filters = filtersOf(snapshot);
105
+ // The layout is read as rows: down the page by `y`, across it by `x`. A card takes the share of the row its
106
+ // `w` asks for and the row holds as many cards as fit, so a narrow screen drops to fewer per row rather than
107
+ // squeezing every card into a sliver — and a chart drawn for the box it lands in never has to be scaled.
108
+ const placed = [...snapshot.layout.items]
109
+ .filter((item) => byId.has(item.viewId))
110
+ .sort((a, b) => a.y - b.y || a.x - b.x || (order.get(a.viewId) ?? 0) - (order.get(b.viewId) ?? 0));
111
+ const laidOut = new Set(placed.map((item) => item.viewId));
112
+ const cards = [
113
+ ...placed.flatMap((item) => {
114
+ const view = byId.get(item.viewId);
115
+ return view ? [{ view, span: item.w, rows: item.h }] : [];
116
+ }),
117
+ // A view the layout does not place still gets drawn: the document is what is shown, not the placement.
118
+ ...snapshot.views.filter((view) => !laidOut.has(view.id)).map((view) => ({ view, span: gridColumns, rows: 4 })),
119
+ ];
120
+ return (_jsx("main", { style: { minHeight: "100vh", background: colors.canvas, color: colors.ink, fontFamily: font }, children: _jsxs("div", { style: {
121
+ maxWidth: maxPageWidth,
122
+ margin: "0 auto",
123
+ padding: pagePadding,
124
+ boxSizing: "border-box",
125
+ display: "flex",
126
+ flexDirection: "column",
127
+ gap: 16,
128
+ }, children: [_jsxs("header", { children: [_jsx("h1", { style: { margin: 0, fontSize: 24, lineHeight: "32px" }, children: snapshot.horn.title }), _jsx("p", { style: { margin: "4px 0 0", fontSize: 13, color: colors.muted }, children: `${asOf(snapshot.generatedAt)}${filters ? ` · ${filters}` : ""}` })] }), _jsx("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "stretch", gap: chrome.gap }, children: cards.map(({ view, span, rows }) => (_jsx("div", { style: {
129
+ display: "flex",
130
+ flexGrow: 1,
131
+ flexShrink: 1,
132
+ flexBasis: `calc(${share(span)}% - ${chrome.gap}px)`,
133
+ minWidth: `min(100%, ${Math.max(minCard, (span / gridColumns) * minPageWidth)}px)`,
134
+ minHeight: rows * rowHeight + (rows - 1) * chrome.gap,
135
+ boxSizing: "border-box",
136
+ }, children: _jsx(ViewCard, { view: view, theme: surface, chrome: chrome, hornStyle: style, labels: snapshot.labels, links: links, datastoreId: snapshot.horn.datastoreId, plans: snapshot.plans, planActions: planActions, writtenView: writtenView, catalogCard: catalogCard, ...(viewControls ? { controls: viewControls(view) } : {}), style: {
137
+ flex: "1 1 auto",
138
+ minWidth: 0,
139
+ borderRadius: chrome.radius,
140
+ boxShadow: "0 1px 2px rgba(17, 24, 39, 0.06)",
141
+ } }) }, view.id))) })] }) }));
142
+ }
@@ -0,0 +1,49 @@
1
+ import type { PlanChange, PlanStatus, SnapshotPlan } from "@manta-eu/horns/render";
2
+ import type { ChartPalette } from "../look/palette.ts";
3
+ import type { EntityLink } from "../look/types.ts";
4
+ export declare const maxShownChanges = 4;
5
+ /**
6
+ * What a host lets a viewer do with a plan. The kit only asks: the host confirms, calls agents as the viewer and
7
+ * reports back through `status`, so a Horn rendered without actions — a capture, an embed that cannot run
8
+ * anything — shows the same plan with no buttons.
9
+ */
10
+ export type PlanActions = {
11
+ onPreview: (plan: SnapshotPlan) => void;
12
+ onRun: (plan: SnapshotPlan) => void;
13
+ /** Newer than the snapshot, by plan: how a run the host started is going. */
14
+ status?: Readonly<Record<string, PlanStatus>>;
15
+ /** Plans the host is sending a request for right now. */
16
+ pending?: ReadonlySet<string>;
17
+ };
18
+ /**
19
+ * What the plan is called: its own name when the graph gives one, else what it would change — "Change status",
20
+ * "Change budget amount and daily budget" — so two plans behind one view read as two different things.
21
+ */
22
+ export declare function planTitle(plan: SnapshotPlan, labels: Readonly<Record<string, string>>): string;
23
+ /** "Changes 1 campaign and 3 campaign budgets." */
24
+ export declare function planSummary(plan: SnapshotPlan, labels: Readonly<Record<string, string>>): string;
25
+ export declare function planValue(value: PlanChange["from"], change: PlanChange, labels: Readonly<Record<string, string>>): string;
26
+ /**
27
+ * How the plan's last run went, in words. A run from before this page was opened says when it was, because a
28
+ * preview from last week says little about the plan as it stands now.
29
+ */
30
+ export declare function planStateText(status: PlanStatus, earlier?: boolean): {
31
+ text: string;
32
+ tone: "quiet" | "busy" | "good" | "bad";
33
+ };
34
+ /**
35
+ * A real run is offered only once a preview has finished cleanly while the viewer watched: one from before the
36
+ * page was opened checked the plan as it was then.
37
+ */
38
+ export declare function runnable(live: PlanStatus | undefined): boolean;
39
+ type PlanStripProps = {
40
+ plan: SnapshotPlan;
41
+ /** The things the plan changes that this view draws, shown first. */
42
+ onView: readonly string[];
43
+ labels: Readonly<Record<string, string>>;
44
+ links: Readonly<Record<string, EntityLink>>;
45
+ palette: ChartPalette;
46
+ actions?: PlanActions | undefined;
47
+ };
48
+ export declare function PlanStrip(props: PlanStripProps): import("react").JSX.Element;
49
+ export {};
@@ -0,0 +1,141 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { match } from "ts-pattern";
3
+ import { formatMeasure, missingValue } from "../look/format.js";
4
+ export const maxShownChanges = 4;
5
+ // "Campaign Budget" → "campaign budgets". English only, like every other word the kit prints.
6
+ function plural(noun, count) {
7
+ const word = noun.toLowerCase();
8
+ if (count === 1)
9
+ return word;
10
+ if (/[^aeiou]y$/.test(word))
11
+ return `${word.slice(0, -1)}ies`;
12
+ if (/(s|x|ch|sh)$/.test(word))
13
+ return `${word}es`;
14
+ return `${word}s`;
15
+ }
16
+ function listed(parts) {
17
+ if (parts.length <= 1)
18
+ return parts.join("");
19
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1] ?? ""}`;
20
+ }
21
+ /**
22
+ * What the plan is called: its own name when the graph gives one, else what it would change — "Change status",
23
+ * "Change budget amount and daily budget" — so two plans behind one view read as two different things.
24
+ */
25
+ export function planTitle(plan, labels) {
26
+ if (plan.name !== undefined)
27
+ return plan.name;
28
+ const settings = [
29
+ ...new Set(plan.changes.flatMap((change) => {
30
+ const label = labels[change.property];
31
+ return label === undefined ? [] : [label.toLowerCase()];
32
+ })),
33
+ ];
34
+ return settings.length === 0 ? "Suggested changes" : `Change ${listed(settings)}`;
35
+ }
36
+ /** "Changes 1 campaign and 3 campaign budgets." */
37
+ export function planSummary(plan, labels) {
38
+ const typed = plan.kinds.filter((kind) => kind.type !== undefined && labels[kind.type] !== undefined);
39
+ if (typed.length === 0) {
40
+ const count = Math.max(0, ...plan.kinds.map((kind) => kind.count));
41
+ return count === 0 ? "Changes nothing yet." : `Changes ${count} ${count === 1 ? "thing" : "things"}.`;
42
+ }
43
+ return `Changes ${listed(typed.map((kind) => `${kind.count} ${plural(labels[kind.type ?? ""] ?? "", kind.count)}`))}.`;
44
+ }
45
+ // Platforms name states in capitals: "PAUSED" reads "Paused".
46
+ function spoken(text) {
47
+ if (!/^[A-Z][A-Z0-9_]*$/.test(text))
48
+ return text;
49
+ const words = text.replace(/_/g, " ").toLowerCase();
50
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
51
+ }
52
+ export function planValue(value, change, labels) {
53
+ if (value === null)
54
+ return "not set";
55
+ if (typeof value === "boolean")
56
+ return value ? "Yes" : "No";
57
+ if (typeof value === "number")
58
+ return formatMeasure(value, { unit: change.unit, currency: change.currency });
59
+ return labels[value] ?? spoken(value);
60
+ }
61
+ /**
62
+ * How the plan's last run went, in words. A run from before this page was opened says when it was, because a
63
+ * preview from last week says little about the plan as it stands now.
64
+ */
65
+ export function planStateText(status, earlier = false) {
66
+ const said = planStateWords(status);
67
+ const day = status.lastRun?.startedAt.slice(0, 10);
68
+ return earlier && status.state !== "draft" && day !== undefined && day !== ""
69
+ ? { ...said, text: `${said.text} (${day})` }
70
+ : said;
71
+ }
72
+ function planStateWords(status) {
73
+ const preview = status.lastRun?.preview === true;
74
+ return match(status.state)
75
+ .with("draft", () => ({ text: "Not run yet", tone: "quiet" }))
76
+ .with("running", () => ({ text: preview ? "Previewing…" : "Making the changes…", tone: "busy" }))
77
+ .with("completed", () => preview
78
+ ? { text: "Preview finished — nothing was changed", tone: "good" }
79
+ : { text: "Changes made", tone: "good" })
80
+ .with("failed", () => ({
81
+ text: preview ? "The preview found a problem" : "The changes did not go through",
82
+ tone: "bad",
83
+ }))
84
+ .with("timed-out", () => ({ text: "It took too long and stopped", tone: "bad" }))
85
+ .exhaustive();
86
+ }
87
+ /**
88
+ * A real run is offered only once a preview has finished cleanly while the viewer watched: one from before the
89
+ * page was opened checked the plan as it was then.
90
+ */
91
+ export function runnable(live) {
92
+ return live?.state === "completed" && live.lastRun?.preview === true && live.lastRun.error === undefined;
93
+ }
94
+ export function PlanStrip(props) {
95
+ const { plan, onView, labels, links, palette, actions } = props;
96
+ const live = actions?.status?.[plan.iri];
97
+ const status = live ?? plan;
98
+ const state = planStateText(status, live === undefined);
99
+ const title = planTitle(plan, labels);
100
+ const pending = actions?.pending?.has(plan.iri) === true;
101
+ const near = new Set(onView);
102
+ const changes = [...plan.changes].sort((a, b) => Number(near.has(b.entity)) - Number(near.has(a.entity)));
103
+ const shown = changes.slice(0, maxShownChanges);
104
+ const hidden = plan.changes.length - shown.length;
105
+ const toneColor = {
106
+ quiet: palette.muted,
107
+ busy: palette.secondary,
108
+ good: palette.series[2] ?? palette.ink,
109
+ bad: palette.alert,
110
+ }[state.tone];
111
+ const nameOf = (entity) => labels[entity] ?? missingValue;
112
+ const reasonsFor = (entity) => plan.reasons.filter((reason) => reason.entity === entity);
113
+ const busy = pending || status.state === "running";
114
+ const button = (label, onClick, enabled, primary, hint) => (_jsx("button", { type: "button", onClick: onClick, disabled: !enabled, title: hint, style: {
115
+ font: "inherit",
116
+ fontSize: 13,
117
+ fontWeight: 600,
118
+ padding: "6px 12px",
119
+ borderRadius: 6,
120
+ cursor: enabled ? "pointer" : "not-allowed",
121
+ opacity: enabled ? 1 : 0.5,
122
+ border: `1px solid ${primary ? palette.ink : palette.baseline}`,
123
+ background: primary ? palette.ink : "transparent",
124
+ color: primary ? palette.surface : palette.ink,
125
+ }, children: label }));
126
+ return (_jsxs("section", { "data-horn-plan": plan.iri, "data-plan-state": status.state, "aria-label": title, style: {
127
+ marginTop: 12,
128
+ padding: 12,
129
+ borderRadius: 8,
130
+ border: `1px solid ${palette.grid}`,
131
+ background: palette.surface,
132
+ color: palette.ink,
133
+ fontSize: 13,
134
+ lineHeight: "18px",
135
+ }, children: [_jsxs("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "baseline", justifyContent: "space-between", gap: 8 }, children: [_jsx("strong", { style: { fontSize: 14 }, children: title }), _jsx("span", { "data-plan-status": true, style: { color: toneColor }, children: pending ? "Sending…" : state.text })] }), _jsx("p", { style: { margin: "2px 0 8px", color: palette.secondary }, children: planSummary(plan, labels) }), status.lastRun?.error !== undefined && (status.state === "failed" || status.state === "timed-out") ? (_jsx("p", { role: "alert", style: { margin: "0 0 8px", color: palette.alert }, children: status.lastRun.error })) : null, shown.length > 0 ? (_jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6 }, children: shown.map((change, index) => {
136
+ const link = links[change.entity];
137
+ const name = nameOf(change.entity);
138
+ const why = reasonsFor(change.entity);
139
+ return (_jsxs("li", { "data-plan-change": true, children: [link === undefined ? (_jsx("span", { style: { fontWeight: 600 }, children: name })) : (_jsx("a", { href: link.href, target: "_blank", rel: "noopener noreferrer", title: link.label, style: { fontWeight: 600, color: "inherit", textUnderlineOffset: 3 }, children: name })), _jsxs("span", { style: { color: palette.secondary }, children: [` — ${labels[change.property] ?? "A setting"}: `, _jsx("span", { style: { textDecoration: "line-through", textDecorationColor: palette.muted }, children: planValue(change.from, change, labels) }), " → "] }), _jsx("span", { style: { fontWeight: 600 }, children: planValue(change.to, change, labels) }), why.length > 0 ? (_jsx("div", { style: { color: palette.muted }, children: `Why: ${why.map((reason) => reason.text).join("; ")}` })) : null] }, index));
140
+ }) })) : (_jsx("p", { style: { margin: 0, color: palette.muted }, children: "Nothing here differs from how things are now." })), hidden > 0 ? (_jsx("p", { style: { margin: "6px 0 0", color: palette.muted }, children: `And ${hidden} more ${hidden === 1 ? "change" : "changes"}.` })) : null, actions === undefined ? null : (_jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }, children: [button("Preview", () => actions.onPreview(plan), !busy, false, "Check the changes without making them"), button("Make these changes", () => actions.onRun(plan), !busy && runnable(live), true, runnable(live) ? "Changes them in the platforms they came from" : "Preview it first")] }))] }));
141
+ }
@@ -0,0 +1,10 @@
1
+ import { type HornSnapshot } from "@manta-eu/horns/render";
2
+ import type { ChartTheme } from "../look/index.ts";
3
+ import { type HornViewMode } from "./horn-snapshot-view.tsx";
4
+ export type RenderHornEmbedOptions = {
5
+ snapshot: HornSnapshot;
6
+ mode?: HornViewMode;
7
+ theme?: ChartTheme;
8
+ embedId?: string;
9
+ };
10
+ export declare function renderHornEmbed(options: RenderHornEmbedOptions): string;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { renderToString } from "react-dom/server";
3
+ import { hornEmbedAttribute, hornSnapshotAttribute, serializeHornSnapshot, } from "@manta-eu/horns/render";
4
+ import { hornSurface, HornSnapshotView } from "./horn-snapshot-view.js";
5
+ const embedIdPattern = /^[a-z0-9-]{1,64}$/;
6
+ // For hosts that assemble HTML as a string. React hosts render <HornSnapshotView> in their own tree; either
7
+ // way `hydrateHornEmbeds` (`@manta-eu/horns-kit/client`) picks the markup up from the snapshot beside it.
8
+ export function renderHornEmbed(options) {
9
+ const { snapshot, mode = "page", theme = "light", embedId = "horn" } = options;
10
+ if (!embedIdPattern.test(embedId))
11
+ throw new Error(`"${embedId}" is not a valid embed id.`);
12
+ const markup = renderToString(_jsx(HornSnapshotView, { snapshot: snapshot, mode: mode, theme: theme }));
13
+ // The theme the markup was drawn in, which is the Horn's own when it declared one.
14
+ const surface = hornSurface(snapshot, theme);
15
+ return (`<div ${hornEmbedAttribute}="${embedId}" data-horn-mode="${mode}" data-horn-theme="${surface}">${markup}</div>` +
16
+ `<script type="application/json" ${hornSnapshotAttribute}="${embedId}">${serializeHornSnapshot(snapshot)}</script>`);
17
+ }
@@ -0,0 +1,6 @@
1
+ export { mantaReactCatalog } from "./catalog.ts";
2
+ export * from "./look/index.ts";
3
+ export { shapeViewSpecs } from "./shape-views/index.ts";
4
+ export { entityLinks, HornSnapshotView, type HornSnapshotViewProps, type HornViewMode, type WrittenViewContext, } from "./embed/horn-snapshot-view.tsx";
5
+ export { planStateText, planSummary, planTitle, runnable, type PlanActions } from "./embed/plan-strip.tsx";
6
+ export type { WrittenSurface } from "./written/runtime.ts";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { mantaReactCatalog } from "./catalog.js";
2
+ export * from "./look/index.js";
3
+ export { shapeViewSpecs } from "./shape-views/index.js";
4
+ export { entityLinks, HornSnapshotView, } from "./embed/horn-snapshot-view.js";
5
+ export { planStateText, planSummary, planTitle, runnable } from "./embed/plan-strip.js";
@@ -0,0 +1,11 @@
1
+ import type { HornStyle } from "@manta-eu/horns";
2
+ export type HornChrome = {
3
+ /** Inside a card. */
4
+ padding: number;
5
+ /** Between cards. */
6
+ gap: number;
7
+ /** Card and chart-frame corners. */
8
+ radius: number;
9
+ };
10
+ /** How much room a Horn takes, as numbers rather than classes: the capture browser has no stylesheet. */
11
+ export declare function hornChrome(style?: HornStyle): HornChrome;
@@ -0,0 +1,7 @@
1
+ const radii = { sharp: 0, soft: 8, round: 16 };
2
+ const densities = { compact: 12, comfortable: 16 };
3
+ /** How much room a Horn takes, as numbers rather than classes: the capture browser has no stylesheet. */
4
+ export function hornChrome(style) {
5
+ const padding = densities[style?.density ?? "comfortable"];
6
+ return { padding, gap: padding, radius: radii[style?.radius ?? "soft"] };
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { cardValueFormats } from "@manta-eu/horns";
2
+ import type { ValueUnit } from "@manta-eu/horns/render";
3
+ export type ValueFormat = (typeof cardValueFormats)[number];
4
+ export declare const missingValue = "\u2014";
5
+ /**
6
+ * How to print one number: what the ontology says it measures, and the currency the view's amounts are in.
7
+ * The amounts are already scaled; this only decides how they read.
8
+ */
9
+ export type Measure = {
10
+ format?: ValueFormat | undefined;
11
+ unit?: ValueUnit | undefined;
12
+ currency?: string | undefined;
13
+ };
14
+ export declare function currencyPrefix(code: string): string;
15
+ export declare function formatMeasure(value: number | null | undefined, measure?: Measure): string;
@@ -0,0 +1,44 @@
1
+ // Fixed locales, never the runtime's, so the server and the hydrating browser print identical digits.
2
+ // Compact uses en-US for the conventional capital "K"/"M" (en-GB prints "12.9k").
3
+ const formatters = {
4
+ number: new Intl.NumberFormat("en-GB", { maximumFractionDigits: 2 }),
5
+ integer: new Intl.NumberFormat("en-GB", { maximumFractionDigits: 0 }),
6
+ percent: new Intl.NumberFormat("en-GB", { style: "percent", maximumFractionDigits: 1 }),
7
+ compact: new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }),
8
+ };
9
+ export const missingValue = "—";
10
+ // A symbol where one is unambiguous to an English reader, the code everywhere else. A table rather than
11
+ // `Intl`'s currency style: runtimes disagree on symbols ("$" or "US$"), and the server and the hydrating
12
+ // browser must print the same text.
13
+ const currencySymbols = {
14
+ USD: "$",
15
+ EUR: "€",
16
+ GBP: "£",
17
+ JPY: "¥",
18
+ AUD: "A$",
19
+ CAD: "CA$",
20
+ NZD: "NZ$",
21
+ INR: "₹",
22
+ };
23
+ export function currencyPrefix(code) {
24
+ return currencySymbols[code] ?? `${code} `;
25
+ }
26
+ export function formatMeasure(value, measure = {}) {
27
+ if (value === null || value === undefined || !Number.isFinite(value))
28
+ return missingValue;
29
+ const { unit, currency } = measure;
30
+ // The ontology's PERCENT is already out of a hundred, so it is printed with its sign and never multiplied.
31
+ // RATIO is not a share of one: it is ROAS (3.2), a click-through fraction or a score alike, so it stays the
32
+ // plain number it is.
33
+ if (unit === "percent") {
34
+ const format = measure.format === undefined || measure.format === "percent" ? "number" : measure.format;
35
+ return `${formatters[format].format(value)}%`;
36
+ }
37
+ const format = measure.format ?? "number";
38
+ const digits = formatters[format].format(value === 0 ? 0 : Math.abs(value));
39
+ const sign = value < 0 ? "-" : "";
40
+ if (unit === "currency" && currency !== undefined && format !== "percent") {
41
+ return `${sign}${currencyPrefix(currency)}${digits}`;
42
+ }
43
+ return `${sign}${digits}`;
44
+ }
@@ -0,0 +1,4 @@
1
+ export { hornChrome, type HornChrome } from "./chrome.ts";
2
+ export { accentPalette, chartPalette, chartPalettes, type ChartPalette } from "./palette.ts";
3
+ export { formatMeasure, missingValue } from "./format.ts";
4
+ export type { ChartTheme, EntityLink } from "./types.ts";
@@ -0,0 +1,5 @@
1
+ // What the kit still draws itself: the frame around a view, the plan strip under it, and the numbers in
2
+ // both. Everything inside a view is written source composing `@manta-eu/react`.
3
+ export { hornChrome } from "./chrome.js";
4
+ export { accentPalette, chartPalette, chartPalettes } from "./palette.js";
5
+ export { formatMeasure, missingValue } from "./format.js";