@manta-eu/horns-client 0.1.0 → 0.1.1

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 (71) hide show
  1. package/component-api.d.ts +7 -0
  2. package/dist/app/coverage-run.js +4 -3
  3. package/dist/app/home-page.js +6 -4
  4. package/dist/app/horn-page.js +7 -10
  5. package/dist/app/horn-studio.js +63 -10
  6. package/dist/app/horn-view-page.js +13 -9
  7. package/dist/app/horns-page.js +6 -9
  8. package/dist/app/new-horn-page.js +52 -10
  9. package/dist/app/paths.d.ts +15 -10
  10. package/dist/app/paths.js +18 -29
  11. package/dist/app/plans-page.d.ts +1 -0
  12. package/dist/app/plans-page.js +59 -0
  13. package/dist/app/reference-page.d.ts +1 -0
  14. package/dist/app/reference-page.js +55 -0
  15. package/dist/app/routes.js +7 -5
  16. package/dist/app/rule-page.js +18 -11
  17. package/dist/app/rules-page.js +12 -12
  18. package/dist/app/shape-entity-page.js +7 -9
  19. package/dist/app/shape-zoo-page.js +9 -10
  20. package/dist/app/shapes-page.js +13 -15
  21. package/dist/app/showcase-page.js +8 -11
  22. package/dist/components/app-shell.js +8 -7
  23. package/dist/components/catalog-card-view.js +4 -5
  24. package/dist/components/explain-dialog.d.ts +5 -0
  25. package/dist/components/explain-dialog.js +26 -0
  26. package/dist/components/plan-runs.js +2 -2
  27. package/dist/components/read-the-code.d.ts +19 -0
  28. package/dist/components/read-the-code.js +17 -3
  29. package/dist/index.d.ts +2 -3
  30. package/dist/index.js +2 -3
  31. package/dist/lib/horn-review.d.ts +7 -0
  32. package/dist/lib/horn-review.js +15 -0
  33. package/dist/lib/horn-variables.d.ts +8 -0
  34. package/dist/lib/horn-variables.js +39 -0
  35. package/dist/lib/manta/sign-in.d.ts +2 -0
  36. package/dist/lib/manta/sign-in.js +9 -0
  37. package/dist/lib/manta/sparql-select.d.ts +6 -2
  38. package/dist/lib/manta/sparql-select.js +17 -14
  39. package/dist/lib/reference/fake-sparql.d.ts +4 -0
  40. package/dist/lib/reference/fake-sparql.js +216 -0
  41. package/dist/lib/shapes/ldkit-source.d.ts +1 -1
  42. package/dist/lib/shapes/ldkit-source.js +1 -1
  43. package/dist/lib/studio-progress.js +3 -1
  44. package/dist/lib/surface/declarations.d.ts +5 -0
  45. package/dist/lib/surface/declarations.js +77 -0
  46. package/dist/lib/written-surface.js +13 -2
  47. package/dist/standalone/app.d.ts +2 -0
  48. package/dist/standalone/app.js +57 -0
  49. package/dist/standalone/client.d.ts +15 -0
  50. package/dist/{client.js → standalone/client.js} +12 -8
  51. package/dist/standalone/gates.d.ts +10 -0
  52. package/dist/standalone/gates.js +15 -0
  53. package/dist/styles.css +1 -1
  54. package/dist/vite/component-api.d.ts +8 -0
  55. package/dist/vite/component-api.js +24 -0
  56. package/dist/vite/index.d.ts +3 -0
  57. package/dist/vite/index.js +3 -0
  58. package/dist/vite/shape-catalog.d.ts +10 -0
  59. package/dist/vite/shape-catalog.js +35 -0
  60. package/dist/vite/typecheck-fs.d.ts +6 -0
  61. package/dist/vite/typecheck-fs.js +68 -0
  62. package/package.json +36 -12
  63. package/shape-catalog.d.ts +9 -0
  64. package/typecheck-fs.d.ts +6 -0
  65. package/dist/app/session-gate.d.ts +0 -6
  66. package/dist/app/session-gate.js +0 -19
  67. package/dist/client.d.ts +0 -10
  68. package/dist/lib/manta/providers.d.ts +0 -10
  69. package/dist/lib/manta/providers.js +0 -48
  70. package/dist/lib/manta/session.d.ts +0 -47
  71. package/dist/lib/manta/session.js +0 -204
@@ -0,0 +1,7 @@
1
+ // The module `hornsComponentApi` serves. A host picks up these types with
2
+ // `/// <reference types="@manta-eu/horns-client/component-api" />`, because a virtual module has no file for
3
+ // TypeScript to find on its own.
4
+ declare module "virtual:horns/component-api" {
5
+ /** One declaration per surfaced export, keyed by name, as the build read it out of the SDK. */
6
+ export const api: Readonly<Record<string, string>>;
7
+ }
@@ -14,7 +14,7 @@ import { shapeZooPath } from "./paths";
14
14
  import { isRecord } from "@manta-eu/horns";
15
15
  import { readNdjson } from "@manta-eu/horns-server/shared";
16
16
  import { problemFrom, problemOf } from "../lib/problem";
17
- import { browserSparqlSelect } from "../lib/manta/sparql-select";
17
+ import { useSparqlSelect } from "../lib/manta/sparql-select";
18
18
  import { shapesRealm } from "../lib/shapes/catalog";
19
19
  // The Studio designing a view, watched while it does it. A shape takes minutes and most of that is spent before
20
20
  // the model says anything, so the run is shown as the four stages it actually goes through, each round the zoo
@@ -121,10 +121,11 @@ function Rounds({ rounds }) {
121
121
  }
122
122
  /** The card the Studio just designed, drawn on real instances — the thing the run is for. */
123
123
  function DesignedCard({ datastoreId, spec }) {
124
+ const select = useSparqlSelect();
124
125
  const realm = shapesRealm();
125
126
  const zoo = useQuery({
126
127
  queryKey: ["coverage-preview", datastoreId, spec.schema],
127
- queryFn: ({ signal }) => buildShapeZoo({ spec, realm, datastoreId, select: browserSparqlSelect(datastoreId), signal }),
128
+ queryFn: ({ signal }) => buildShapeZoo({ spec, realm, datastoreId, select: select, signal }),
128
129
  });
129
130
  if (zoo.isPending)
130
131
  return _jsx(Loading, { label: "Drawing it on real instances\u2026", rows: 3 });
@@ -146,7 +147,7 @@ function VerdictPanel({ datastoreId, run }) {
146
147
  if (verdict === undefined)
147
148
  return null;
148
149
  return match(verdict.status)
149
- .with("accepted", () => (_jsxs("div", { "data-verdict": "accepted", className: "flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-default-foreground", children: `The zoo accepted it in ${verdict.rounds ?? 1} round${verdict.rounds === 1 ? "" : "s"}.` }), isPresent(verdict.spec) ? (_jsxs(_Fragment, { children: [_jsx("ul", { className: "flex flex-wrap gap-1.5", children: verdict.spec.fields.map((field) => (_jsx("li", { "data-field": field.name, className: "rounded-md border border-default-border px-2 py-0.5 font-mono text-xs text-muted-foreground", children: `${field.name} · ${field.type}` }, field.name))) }), _jsx(DesignedCard, { datastoreId: datastoreId, spec: verdict.spec })] })) : null, isPresent(verdict.advice) ? (_jsxs("div", { "data-advice": true, className: "flex flex-col gap-2 rounded-lg border border-warning px-4 py-3", children: [_jsx("p", { className: "text-sm font-medium text-default-foreground", children: "What a stranger would not understand" }), _jsx("ul", { className: "flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground", children: verdict.advice.map((one) => (_jsx("li", { children: one }, one))) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "The zoo still accepted this view. This is advice for whoever reviews it, not a reason it failed." })] })) : null, _jsx(Link, { className: "text-sm text-muted-foreground underline hover:text-default-foreground", to: shapeZooPath(datastoreId, run.schema), children: "Open its zoo to keep it" })] })))
150
+ .with("accepted", () => (_jsxs("div", { "data-verdict": "accepted", className: "flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-default-foreground", children: `The zoo accepted it in ${verdict.rounds ?? 1} round${verdict.rounds === 1 ? "" : "s"}.` }), isPresent(verdict.spec) ? (_jsxs(_Fragment, { children: [_jsx("ul", { className: "flex flex-wrap gap-1.5", children: verdict.spec.fields.map((field) => (_jsx("li", { "data-field": field.name, className: "rounded-md border border-default-border px-2 py-0.5 font-mono text-xs text-muted-foreground", children: `${field.name} · ${field.type}` }, field.name))) }), _jsx(DesignedCard, { datastoreId: datastoreId, spec: verdict.spec })] })) : null, isPresent(verdict.advice) ? (_jsxs("div", { "data-advice": true, className: "flex flex-col gap-2 rounded-lg border border-warning px-4 py-3", children: [_jsx("p", { className: "text-sm font-medium text-default-foreground", children: "What a stranger would not understand" }), _jsx("ul", { className: "flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground", children: verdict.advice.map((one) => (_jsx("li", { children: one }, one))) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "The zoo still accepted this view. This is advice for whoever reviews it, not a reason it failed." })] })) : null, _jsx(Link, { className: "text-sm text-muted-foreground underline hover:text-default-foreground", to: shapeZooPath(run.schema), children: "Open its zoo to keep it" })] })))
150
151
  .with("gave-up", () => (_jsxs("div", { "data-verdict": "gave-up", className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm text-default-foreground", children: `It gave up after ${verdict.rounds ?? stages.length} round${verdict.rounds === 1 ? "" : "s"}. What the zoo kept saying:` }), _jsx("ul", { className: "flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground", children: (verdict.problems ?? []).map((problem) => (_jsx("li", { children: problem }, problem))) })] })))
151
152
  .with("no-data", () => (_jsx("p", { "data-verdict": "no-data", className: "text-sm text-muted-foreground", children: "Nothing to design from \u2014 this datastore holds no instances of it." })))
152
153
  .otherwise(() => (_jsxs("p", { "data-verdict": "failed", className: "flex items-start gap-2 text-sm text-error-foreground", children: [_jsx(TriangleAlert, { size: 16, "aria-hidden": "true", className: "mt-0.5 shrink-0" }), verdict.error ?? "It broke, with no reason given."] })));
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isPresent } from "@manta-eu/lib/present";
3
- import { Link } from "react-router";
4
3
  import { ChevronRight, Database } from "lucide-react";
5
4
  import { EmptyState } from "../components/states";
6
5
  import { hornsPath } from "./paths";
7
- import { useMantaSession } from "../lib/manta/session";
6
+ import { useAuthContext } from "@manta-eu/react/providers/AuthContext";
7
+ import { useActiveDatastore } from "@manta-eu/react/providers";
8
8
  export function HomePage() {
9
- const { datastores } = useMantaSession();
10
- return (_jsxs("div", { className: "flex w-full flex-col gap-6", children: [_jsxs("header", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "text-2xl font-semibold text-default-foreground", children: "Manta Horns" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Choose a datastore to see the Horns made from its data, or to make a new one." })] }), !isPresent(datastores) ? (_jsx(EmptyState, { title: "Your account has no datastores yet", hint: "A datastore is where your connected data lives. Ask the person who manages Manta at your company to give you access to one.", icon: _jsx(Database, { size: 20 }) })) : (_jsx("ul", { "data-datastores": true, className: "grid gap-3 sm:grid-cols-2 md:grid-cols-3", children: datastores.map((datastore) => (_jsx("li", { children: _jsxs(Link, { to: hornsPath(datastore.id), className: "flex items-center gap-3 rounded-lg border border-default-border bg-card px-4 py-3 transition-colors hover:bg-card/70", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Database, { size: 16, "aria-hidden": "true" }) }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium text-default-foreground", children: datastore.name }), _jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })] }) }, datastore.id))) }))] }));
9
+ const { datastores } = useAuthContext();
10
+ // Crossing between datastores is the router's move: the basename changes, which a <Link> cannot do.
11
+ const { enterDatastore } = useActiveDatastore();
12
+ return (_jsxs("div", { className: "flex w-full flex-col gap-6", children: [_jsxs("header", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "text-2xl font-semibold text-default-foreground", children: "Manta Horns" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Choose a datastore to see the Horns made from its data, or to make a new one." })] }), !isPresent(datastores) ? (_jsx(EmptyState, { title: "Your account has no datastores yet", hint: "A datastore is where your connected data lives. Ask the person who manages Manta at your company to give you access to one.", icon: _jsx(Database, { size: 20 }) })) : (_jsx("ul", { "data-datastores": true, className: "grid gap-3 sm:grid-cols-2 md:grid-cols-3", children: datastores.map((datastore) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => enterDatastore(datastore.id, { path: hornsPath() }), className: "flex items-center gap-3 rounded-lg border border-default-border bg-card px-4 py-3 transition-colors hover:bg-card/70", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Database, { size: 16, "aria-hidden": "true" }) }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium text-default-foreground", children: datastore.name }), _jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })] }) }, datastore.id))) }))] }));
11
13
  }
@@ -1,25 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isPresent } from "@manta-eu/lib/present";
3
3
  import { useParams, useSearchParams } from "react-router";
4
+ import { useActiveDatastore } from "@manta-eu/react/providers";
4
5
  import { variablesFromSearch } from "@manta-eu/horns/render";
5
6
  import { BackLink, DatastoreMissing, ErrorState, Loading } from "../components/states";
6
7
  import { hornsPath } from "./paths";
7
8
  import { HornStudio } from "./horn-studio";
8
9
  import { useSavedHorn } from "./saved-horn";
9
10
  import { VariableBar } from "./variable-bar";
10
- import { MantaSparqlProvider } from "../lib/manta/providers";
11
- import { useMantaSession } from "../lib/manta/session";
12
11
  // A saved Horn, as the Studio's whole screen: the document comes back from the datastore and the snapshot is
13
12
  // built from it, so what is on screen is what is stored rather than what a client happened to be holding.
14
13
  export function HornPage() {
15
- const { datastoreId, slug } = useParams();
16
- const { datastores } = useMantaSession();
17
- const datastore = datastores.find((candidate) => candidate.id === datastoreId);
18
- if (!isPresent(datastore)) {
14
+ const { datastoreId } = useActiveDatastore();
15
+ const { slug } = useParams();
16
+ if (!isPresent(datastoreId)) {
19
17
  return (_jsx(Centered, { children: _jsx(DatastoreMissing, {}) }));
20
18
  }
21
- // The provider supplies the query client, so the reader has to sit inside it.
22
- return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(SavedHornPage, { slug: slug ?? "", datastoreId: datastore.id }) }));
19
+ return _jsx(SavedHornPage, { slug: slug ?? "", datastoreId: datastoreId });
23
20
  }
24
21
  /** Until there is a Horn to put in the Studio, the screen is just this. */
25
22
  function Centered({ children }) {
@@ -29,10 +26,10 @@ function SavedHornPage({ datastoreId, slug }) {
29
26
  const [search] = useSearchParams();
30
27
  const horn = useSavedHorn(datastoreId, slug);
31
28
  if (horn.isError) {
32
- return (_jsxs(Centered, { children: [_jsx(BackLink, { to: hornsPath(datastoreId), label: "All Horns" }), _jsx(ErrorState, { title: "That Horn could not be opened", hint: horn.error instanceof Error ? horn.error.message : "It may have been removed." })] }));
29
+ return (_jsxs(Centered, { children: [_jsx(BackLink, { to: hornsPath(), label: "All Horns" }), _jsx(ErrorState, { title: "That Horn could not be opened", hint: horn.error instanceof Error ? horn.error.message : "It may have been removed." })] }));
33
30
  }
34
31
  if (!isPresent(horn.data)) {
35
32
  return (_jsx(Centered, { children: _jsx(Loading, { label: "Opening the Horn\u2026" }) }));
36
33
  }
37
- return (_jsx(HornStudio, { datastoreId: datastoreId, slug: slug, stored: horn.data.horn, revision: horn.data.revision, backTo: hornsPath(datastoreId), variables: variablesFromSearch(horn.data.horn, search.toString()), toolbar: _jsx(VariableBar, { horn: horn.data.horn }) }));
34
+ return (_jsx(HornStudio, { datastoreId: datastoreId, slug: slug, stored: horn.data.horn, revision: horn.data.revision, backTo: hornsPath(), variables: variablesFromSearch(horn.data.horn, search.toString()), toolbar: _jsx(VariableBar, { horn: horn.data.horn }) }));
38
35
  }
@@ -11,10 +11,10 @@ import { describeHornChange, minViewWidth, visualEditOps } from "@manta-eu/horns
11
11
  import { HornSnapshotView } from "@manta-eu/horns-kit";
12
12
  import { writtenHornView } from "../components/written-horn-view";
13
13
  import { catalogCardView } from "../components/catalog-card-view";
14
- import { ReadTheCode, writtenReviewStates } from "../components/read-the-code";
14
+ import { ReadTheCode, ReadTheCodeDialog, writtenReviewStates } from "../components/read-the-code";
15
15
  import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
16
16
  import { sendHornScreenshot } from "../components/rasterize";
17
- import { reviewHorn } from "../lib/horn-review";
17
+ import { nameOfView, reviewHorn } from "../lib/horn-review";
18
18
  import { StudioChat } from "../components/studio-chat";
19
19
  import { ErrorState, Loading } from "../components/states";
20
20
  import { isRecord } from "@manta-eu/horns";
@@ -42,12 +42,13 @@ function failureOf(body, status) {
42
42
  }
43
43
  /** How much a card grows or shrinks by: a third of the twelve columns, so the page keeps its rhythm. */
44
44
  const widthStep = 4;
45
+ // Trimmed after the cut, not before it: a slug ending in a dash is not one the schema accepts.
45
46
  const kebab = (name) => name
46
47
  .trim()
47
48
  .toLowerCase()
48
49
  .replace(/[^a-z0-9]+/g, "-")
49
- .replace(/^-+|-+$/g, "")
50
- .slice(0, 60);
50
+ .slice(0, 60)
51
+ .replace(/^-+|-+$/g, "");
51
52
  /**
52
53
  * The Studio as a whole screen, the way Lovable lays out a project: the conversation down the left, the Horn in
53
54
  * a frame on the right, and saving in the bar across the top.
@@ -87,6 +88,10 @@ export function HornStudio(props) {
87
88
  // Hashing is async, so the states are held rather than derived: a card shows what was last worked out for
88
89
  // the draft it is drawing.
89
90
  const [reviewStates, setReviewStates] = useState({});
91
+ // Save does not refuse a Horn whose code nobody read; it offers the reading, then goes on saving.
92
+ const [toRead, setToRead] = useState([]);
93
+ const [savingAfterReading, setSavingAfterReading] = useState(false);
94
+ const [readingTotal, setReadingTotal] = useState(0);
90
95
  useEffect(() => {
91
96
  let live = true;
92
97
  void writtenReviewStates(shown.horn).then((states) => {
@@ -182,7 +187,8 @@ export function HornStudio(props) {
182
187
  setProgress([...thinking]);
183
188
  review = await reviewHorn({ datastoreId, horn: reply.horn, asked, variables });
184
189
  for (const problem of review.problems) {
185
- thinking.push({ title: `“${problem.viewId}” would not draw`, detail: problem.message });
190
+ const named = nameOfView(reply.horn, problem.viewId);
191
+ thinking.push({ title: `“${named}” would not draw`, detail: problem.message });
186
192
  }
187
193
  if (isPresent(review.advice)) {
188
194
  const count = review.advice.length;
@@ -198,7 +204,7 @@ export function HornStudio(props) {
198
204
  }
199
205
  const said = [
200
206
  // A view that would not draw is not an opinion; it goes first, and it is a fact the Studio can act on.
201
- ...review.problems.map((problem) => `“${problem.viewId}” does not draw: ${problem.message}`),
207
+ ...review.problems.map((problem) => `“${nameOfView(reply.horn, problem.viewId)}” does not draw: ${problem.message}`),
202
208
  ...review.advice,
203
209
  ];
204
210
  setEntries([
@@ -350,14 +356,37 @@ export function HornStudio(props) {
350
356
  setSaveFailure("Give the Horn a title before saving it: its address is made from the title.");
351
357
  return;
352
358
  }
359
+ // Asked of the Horn being saved rather than the one on screen, which may be an earlier version. The
360
+ // save route refuses unread code, so meeting it here turns a dead end into the reading it asks for.
361
+ const states = await writtenReviewStates(saving.horn);
362
+ const unread = saving.horn.views
363
+ .map((view) => view.id)
364
+ .filter((id) => states[id] === "unread" || states[id] === "stale");
365
+ if (unread.length > 0) {
366
+ setSaveFailure(undefined);
367
+ setToRead(unread);
368
+ setReadingTotal(unread.length);
369
+ setSavingAfterReading(true);
370
+ return;
371
+ }
353
372
  setSaveState("saving");
354
373
  setSaveFailure(undefined);
355
374
  try {
356
- const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/${encodeURIComponent(name)}`, {
375
+ const put = (at) => fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/${encodeURIComponent(at)}`, {
357
376
  method: "PUT",
358
377
  headers: { "content-type": "application/json" },
359
378
  body: JSON.stringify({ horn: saving.horn, ...(isPresent(baseRevision) ? { baseRevision } : {}) }),
360
379
  });
380
+ // A Horn that has never been saved takes its address from its title, and two Horns can be given the
381
+ // same title — every Horn built for a plan nobody named is "Suggested changes". The name is taken, not
382
+ // the Horn: saving under the next one free is what a person means, and a 409 here can mean nothing
383
+ // else, because revising a Horn is what carries a `baseRevision`.
384
+ let at = name;
385
+ let response = await put(at);
386
+ for (let next = 2; response.status === 409 && !isPresent(baseRevision) && next <= 20; next += 1) {
387
+ at = `${name}-${next}`;
388
+ response = await put(at);
389
+ }
361
390
  const body = await response.json().catch(() => undefined);
362
391
  if (!response.ok) {
363
392
  setSaveState("failed");
@@ -365,13 +394,13 @@ export function HornStudio(props) {
365
394
  return;
366
395
  }
367
396
  setBaseRevision(isRecord(body) && typeof body["revision"] === "string" ? body["revision"] : baseRevision);
368
- setSlug(name);
397
+ setSlug(at);
369
398
  setSavedId(saving.id);
370
399
  setSaveState("idle");
371
400
  // The showcase leads with a picture of the Horn, and this browser has it drawn already. A saved Horn is
372
401
  // saved whether or not the picture was taken, so this cannot fail the save and is never waited on.
373
402
  if (isPresent(snapshot.data)) {
374
- void sendHornScreenshot({ datastoreId, slug: name, snapshot: snapshot.data });
403
+ void sendHornScreenshot({ datastoreId, slug: at, snapshot: snapshot.data });
375
404
  }
376
405
  }
377
406
  catch (caught) {
@@ -379,6 +408,13 @@ export function HornStudio(props) {
379
408
  setSaveFailure(caught instanceof Error ? caught.message : String(caught));
380
409
  }
381
410
  }
411
+ useEffect(() => {
412
+ if (!savingAfterReading || toRead.length > 0)
413
+ return;
414
+ setSavingAfterReading(false);
415
+ // The queue emptying is the event; `save` is redefined every render and is not one.
416
+ void save();
417
+ }, [savingAfterReading, toRead]);
382
418
  // Where things stand, as a sentence: a badge says less, and an address like "/spend-watch" says nothing.
383
419
  const status = saveState === "saving"
384
420
  ? "Saving…"
@@ -391,7 +427,24 @@ export function HornStudio(props) {
391
427
  : "Not saved yet";
392
428
  const showChat = !mobile || pane === "chat";
393
429
  const showPreview = !mobile || pane === "preview";
394
- return (_jsxs("div", { "data-horn-studio": true, className: "flex h-svh w-full flex-col bg-sidebar", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-3 px-3", children: [mobile ? (_jsx(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: ArrowLeft, "aria-label": "All Horns", onClick: () => navigate(backTo) })) : (_jsx(Button, { variant: "ghost", size: "sm", icon: ArrowLeft, showLeftIcon: true, onClick: () => navigate(backTo), children: "All Horns" })), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: latest.horn.title }), _jsx("span", { "data-save-status": true, className: "truncate text-xs text-muted-foreground", children: status })] }), mobile ? (_jsx("div", { className: "flex items-center gap-1 rounded-full bg-muted p-1", role: "group", "aria-label": "Show", children: ["chat", "preview"].map((each) => (_jsx(Button, { variant: pane === each ? "secondary" : "ghost", size: "xs", "aria-pressed": pane === each, onClick: () => setPane(each), children: each === "chat" ? "Chat" : "Preview" }, each))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [unsaved ? (_jsx(Button, { variant: "secondary", size: "sm", disabled: saveState === "saving" || pending, onClick: discard, children: mobile ? "Discard" : "Discard changes" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !unsaved || saveState === "saving" || pending, onClick: () => void save(), children: saveState === "saving" ? "Saving…" : mobile ? "Save" : "Save changes" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [showChat ? (_jsxs("aside", { ref: panelRef, "aria-label": "Studio chat", className: "flex min-h-0 shrink-0 flex-col", style: mobile ? { width: "100%" } : { width: panelWidth }, children: [isPresent(saveFailure) ? (_jsx("div", { className: "px-4 pt-2", children: _jsx(ErrorState, { title: "That save did not go through", hint: saveFailure }) })) : null, _jsx(StudioChat, { entries: entries, pending: pending, progress: progress, previewing: shown.id, welcome: welcome ?? "What would you like to change?", reassurance: reassurance ?? "Say it in your own words. Nothing is saved until you choose Save changes.", starters: starters ?? restyleStarters, placeholder: placeholder ?? "Describe a change, in your own words…", onSend: (text) => void send(text), onPreview: (id) => {
430
+ const reading = (() => {
431
+ const [first] = toRead;
432
+ if (first === undefined)
433
+ return undefined;
434
+ const view = latest.horn.views.find((candidate) => candidate.id === first);
435
+ if (view === undefined || "ref" in view || view.render.kind !== "tsx")
436
+ return undefined;
437
+ return { id: first, title: view.title, source: view.render.source, state: reviewStates[first] ?? "unread" };
438
+ })();
439
+ return (_jsxs("div", { "data-horn-studio": true, className: "flex h-svh w-full flex-col bg-sidebar", children: [reading === undefined ? null : (_jsx(ReadTheCodeDialog, { viewId: reading.id, title: reading.title, source: reading.source, state: reading.state, open: true, standing: { at: readingTotal - toRead.length + 1, of: readingTotal }, onClose: () => {
440
+ // "Not yet" stops the whole save, rather than moving on to the next one unread.
441
+ setToRead([]);
442
+ setSavingAfterReading(false);
443
+ }, onKeep: (change) => {
444
+ // Only once the keep is in the draft: the save that follows the last one reads the draft, and
445
+ // a draft still missing it looks exactly like code nobody read.
446
+ void edit(change, `Read and kept the code for "${reading.title}".`).then(() => setToRead((was) => was.slice(1)));
447
+ } })), _jsxs("header", { className: "flex h-14 shrink-0 items-center gap-3 px-3", children: [mobile ? (_jsx(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: ArrowLeft, "aria-label": "All Horns", onClick: () => navigate(backTo) })) : (_jsx(Button, { variant: "ghost", size: "sm", icon: ArrowLeft, showLeftIcon: true, onClick: () => navigate(backTo), children: "All Horns" })), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: latest.horn.title }), _jsx("span", { "data-save-status": true, className: "truncate text-xs text-muted-foreground", children: status })] }), mobile ? (_jsx("div", { className: "flex items-center gap-1 rounded-full bg-muted p-1", role: "group", "aria-label": "Show", children: ["chat", "preview"].map((each) => (_jsx(Button, { variant: pane === each ? "secondary" : "ghost", size: "xs", "aria-pressed": pane === each, onClick: () => setPane(each), children: each === "chat" ? "Chat" : "Preview" }, each))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [unsaved ? (_jsx(Button, { variant: "secondary", size: "sm", disabled: saveState === "saving" || pending, onClick: discard, children: mobile ? "Discard" : "Discard changes" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !unsaved || saveState === "saving" || pending, onClick: () => void save(), children: saveState === "saving" ? "Saving…" : mobile ? "Save" : "Save changes" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [showChat ? (_jsxs("aside", { ref: panelRef, "aria-label": "Studio chat", className: "flex min-h-0 shrink-0 flex-col", style: mobile ? { width: "100%" } : { width: panelWidth }, children: [isPresent(saveFailure) ? (_jsx("div", { className: "px-4 pt-2", children: _jsx(ErrorState, { title: "That save did not go through", hint: saveFailure }) })) : null, _jsx(StudioChat, { entries: entries, pending: pending, progress: progress, previewing: shown.id, welcome: welcome ?? "What would you like to change?", reassurance: reassurance ?? "Say it in your own words. Nothing is saved until you choose Save changes.", starters: starters ?? restyleStarters, placeholder: placeholder ?? "Describe a change, in your own words…", onSend: (text) => void send(text), onPreview: (id) => {
395
448
  setViewing(id === latest.id ? undefined : id);
396
449
  if (mobile)
397
450
  setPane("preview");
@@ -3,6 +3,7 @@ import { useState } from "react";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { isPresent } from "@manta-eu/lib/present";
5
5
  import { Link, useParams, useSearchParams } from "react-router";
6
+ import { useActiveDatastore } from "@manta-eu/react/providers";
6
7
  import { Camera, Link2, Pencil } from "lucide-react";
7
8
  import { Button, Input } from "@manta-eu/react";
8
9
  import { hornSnapshotSchema, variablesFromSearch } from "@manta-eu/horns/render";
@@ -11,24 +12,23 @@ import { writtenHornView } from "../components/written-horn-view";
11
12
  import { catalogCardView } from "../components/catalog-card-view";
12
13
  import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
13
14
  import { sendHornScreenshot } from "../components/rasterize";
15
+ import { useExplainDialog } from "../components/explain-dialog";
16
+ import { useHornVariables } from "../lib/horn-variables";
14
17
  import { DatastoreMissing, ErrorState, Loading } from "../components/states";
15
18
  import { hornPath, hornsPath } from "./paths";
16
19
  import { useSavedHorn } from "./saved-horn";
17
20
  import { VariableBar } from "./variable-bar";
18
21
  import { isRecord } from "@manta-eu/horns";
19
- import { MantaSparqlProvider } from "../lib/manta/providers";
20
- import { useMantaSession } from "../lib/manta/session";
21
22
  import { appTheme } from "../lib/theme";
22
23
  // A saved Horn as the thing itself: what a shared link opens. No chat, nothing to save — the URL is the state,
23
24
  // so the link somebody is sent draws exactly the Horn they were looking at, filters and all. The plans behind a
24
25
  // view are still offered here, because a person who can open the Horn is a person who can act on it.
25
26
  export function HornViewPage() {
26
- const { datastoreId, slug } = useParams();
27
- const { datastores } = useMantaSession();
28
- const datastore = datastores.find((candidate) => candidate.id === datastoreId);
29
- if (!isPresent(datastore))
27
+ const { datastoreId } = useActiveDatastore();
28
+ const { slug } = useParams();
29
+ if (!isPresent(datastoreId))
30
30
  return _jsx(DatastoreMissing, {});
31
- return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(Viewer, { datastoreId: datastore.id, slug: slug ?? "" }) }));
31
+ return _jsx(Viewer, { datastoreId: datastoreId, slug: slug ?? "" });
32
32
  }
33
33
  function ShareLink() {
34
34
  const [shown, setShown] = useState();
@@ -60,10 +60,14 @@ function UpdatePicture({ datastoreId, slug, snapshot }) {
60
60
  return (_jsxs(_Fragment, { children: [problem === undefined ? null : (_jsx("span", { role: "alert", className: "text-xs text-muted-foreground", children: problem })), _jsxs(Button, { variant: "secondary", size: "sm", disabled: state === "working", onClick: () => void take(), children: [_jsx(Camera, { size: 14, "aria-hidden": "true" }), state === "working" ? "Taking…" : state === "kept" ? "Picture updated" : "Update picture"] })] }));
61
61
  }
62
62
  function Viewer({ datastoreId, slug }) {
63
+ const explain = useExplainDialog();
63
64
  const [search] = useSearchParams();
64
65
  const saved = useSavedHorn(datastoreId, slug);
65
66
  const horn = saved.data?.horn;
66
67
  const variables = horn ? variablesFromSearch(horn, search.toString()) : {};
68
+ // Writing goes through nuqs, which owns the URL; reading stays with `variablesFromSearch`, which is what
69
+ // types a value against the variable that declared it.
70
+ const urlVariables = useHornVariables(horn);
67
71
  const snapshot = useQuery({
68
72
  enabled: isPresent(horn),
69
73
  queryKey: ["horn-view", datastoreId, slug, saved.data?.revision ?? "", variables],
@@ -90,7 +94,7 @@ function Viewer({ datastoreId, slug }) {
90
94
  }
91
95
  if (!isPresent(horn))
92
96
  return _jsx(Loading, { label: "Opening the Horn\u2026", rows: 3 });
93
- return (_jsxs("div", { "data-horn-viewer": true, className: "flex w-full flex-col gap-4", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx(Link, { to: hornsPath(datastoreId), className: "text-sm text-muted-foreground hover:text-default-foreground", children: "All Horns" }), _jsx(VariableBar, { horn: horn })] }), _jsxs("div", { className: "flex items-center gap-2", children: [planRuns.problem === undefined ? null : (_jsx("span", { role: "alert", className: "text-xs text-muted-foreground", children: planRuns.problem })), _jsx(UpdatePicture, { datastoreId: datastoreId, slug: slug, snapshot: snapshot.data }), _jsx(ShareLink, {}), _jsx(Button, { variant: "ghost", size: "sm", asChild: true, children: _jsxs(Link, { to: hornPath(datastoreId, slug), children: [_jsx(Pencil, { size: 14, "aria-hidden": "true" }), "Change it"] }) })] })] }), snapshot.isError ? (_jsx(ErrorState, { title: "The Horn could not be drawn", hint: snapshot.error.message })) : !isPresent(snapshot.data) ? (_jsx(Loading, { label: "Drawing the Horn\u2026", rows: 4 })) : (
97
+ return (_jsxs("div", { "data-horn-viewer": true, className: "flex w-full flex-col gap-4", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx(Link, { to: hornsPath(), className: "text-sm text-muted-foreground hover:text-default-foreground", children: "All Horns" }), _jsx(VariableBar, { horn: horn })] }), _jsxs("div", { className: "flex items-center gap-2", children: [planRuns.problem === undefined ? null : (_jsx("span", { role: "alert", className: "text-xs text-muted-foreground", children: planRuns.problem })), _jsx(UpdatePicture, { datastoreId: datastoreId, slug: slug, snapshot: snapshot.data }), _jsx(ShareLink, {}), _jsx(Button, { variant: "ghost", size: "sm", asChild: true, children: _jsxs(Link, { to: hornPath(slug), children: [_jsx(Pencil, { size: 14, "aria-hidden": "true" }), "Change it"] }) })] })] }), snapshot.isError ? (_jsx(ErrorState, { title: "The Horn could not be drawn", hint: snapshot.error.message })) : !isPresent(snapshot.data) ? (_jsx(Loading, { label: "Drawing the Horn\u2026", rows: 4 })) : (
94
98
  // The Horn draws its own page, title and canvas.
95
- _jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, catalogCard: catalogCardView }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels })] }))] }));
99
+ _jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, catalogCard: catalogCardView, explain: explain.open, onVariableChange: urlVariables.set }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels }), explain.dialog] }))] }));
96
100
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isPresent } from "@manta-eu/lib/present";
3
- import { Link, useParams } from "react-router";
3
+ import { Link } from "react-router";
4
+ import { useActiveDatastore } from "@manta-eu/react/providers";
4
5
  import { ChevronRight, Columns3, Plus } from "lucide-react";
5
6
  import { Button } from "@manta-eu/react";
6
7
  import { PageHeader } from "../components/page-header";
@@ -8,15 +9,11 @@ import { DatastoreMissing, EmptyState, ErrorState, Loading } from "../components
8
9
  import { hornViewPath, newHornPath } from "./paths";
9
10
  import { useQuery } from "@tanstack/react-query";
10
11
  import { isRecord } from "@manta-eu/horns";
11
- import { MantaSparqlProvider } from "../lib/manta/providers";
12
- import { useMantaSession } from "../lib/manta/session";
13
12
  export function HornsPage() {
14
- const { datastoreId } = useParams();
15
- const { datastores } = useMantaSession();
16
- const datastore = datastores.find((candidate) => candidate.id === datastoreId);
17
- if (!isPresent(datastore))
13
+ const { datastoreId } = useActiveDatastore();
14
+ if (!isPresent(datastoreId))
18
15
  return _jsx(DatastoreMissing, {});
19
- return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(HornsIn, { datastoreId: datastore.id }) }));
16
+ return _jsx(HornsIn, { datastoreId: datastoreId });
20
17
  }
21
18
  function HornsIn({ datastoreId }) {
22
19
  const horns = useQuery({
@@ -39,5 +36,5 @@ function HornsIn({ datastoreId }) {
39
36
  : []);
40
37
  },
41
38
  });
42
- return (_jsxs("div", { className: "flex w-full flex-col gap-6", children: [_jsx(PageHeader, { title: "Horns", description: "Dashboards and apps made from your data. Open one to look at it or change it, or describe a new one.", actions: _jsx(Button, { variant: "primary", asChild: true, children: _jsxs(Link, { to: newHornPath(datastoreId), children: [_jsx(Plus, { size: 16, "aria-hidden": "true" }), "New Horn"] }) }) }), horns.isPending ? (_jsx(Loading, { label: "Reading this datastore's Horns\u2026" })) : horns.isError ? (_jsx(ErrorState, { title: "Your Horns could not be loaded", hint: "Try again in a moment. If it keeps happening, the data service may be down." })) : !isPresent(horns.data) ? (_jsx(EmptyState, { title: "No Horns yet", hint: "Describe what you want to see, in your own words, and it is built for you from this datastore's data.", icon: _jsx(Columns3, { size: 20 }), action: _jsx(Button, { variant: "primary", size: "sm", asChild: true, children: _jsxs(Link, { to: newHornPath(datastoreId), children: [_jsx(Plus, { size: 14, "aria-hidden": "true" }), "Make your first Horn"] }) }) })) : (_jsx("ul", { "data-horns": true, className: "grid gap-3 sm:grid-cols-2 md:grid-cols-3", children: horns.data.map((horn) => (_jsx("li", { children: _jsxs(Link, { to: hornViewPath(datastoreId, horn.slug), className: "flex items-center gap-3 rounded-lg border border-default-border bg-card px-4 py-3 transition-colors hover:bg-card/70", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Columns3, { size: 16 }) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: horn.title }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: horn.number === 1 ? "Saved once" : `Saved ${horn.number} times` })] }), _jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })] }) }, horn.slug))) }))] }));
39
+ return (_jsxs("div", { className: "flex w-full flex-col gap-6", children: [_jsx(PageHeader, { title: "Horns", description: "Dashboards and apps made from your data. Open one to look at it or change it, or describe a new one.", actions: _jsx(Button, { variant: "primary", asChild: true, children: _jsxs(Link, { to: newHornPath(), children: [_jsx(Plus, { size: 16, "aria-hidden": "true" }), "New Horn"] }) }) }), horns.isPending ? (_jsx(Loading, { label: "Reading this datastore's Horns\u2026" })) : horns.isError ? (_jsx(ErrorState, { title: "Your Horns could not be loaded", hint: "Try again in a moment. If it keeps happening, the data service may be down." })) : !isPresent(horns.data) ? (_jsx(EmptyState, { title: "No Horns yet", hint: "Describe what you want to see, in your own words, and it is built for you from this datastore's data.", icon: _jsx(Columns3, { size: 20 }), action: _jsx(Button, { variant: "primary", size: "sm", asChild: true, children: _jsxs(Link, { to: newHornPath(), children: [_jsx(Plus, { size: 14, "aria-hidden": "true" }), "Make your first Horn"] }) }) })) : (_jsx("ul", { "data-horns": true, className: "grid gap-3 sm:grid-cols-2 md:grid-cols-3", children: horns.data.map((horn) => (_jsx("li", { children: _jsxs(Link, { to: hornViewPath(horn.slug), className: "flex items-center gap-3 rounded-lg border border-default-border bg-card px-4 py-3 transition-colors hover:bg-card/70", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Columns3, { size: 16 }) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: horn.title }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: horn.number === 1 ? "Saved once" : `Saved ${horn.number} times` })] }), _jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })] }) }, horn.slug))) }))] }));
43
40
  }
@@ -1,13 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useSearchParams } from "react-router";
5
+ import { z } from "zod";
3
6
  import { isPresent } from "@manta-eu/lib/present";
4
- import { useParams } from "react-router";
7
+ import { useActiveDatastore } from "@manta-eu/react/providers";
8
+ import { hornSchema } from "@manta-eu/horns";
5
9
  import { standaloneHorn } from "@manta-eu/horns/shapes";
6
- import { DatastoreMissing } from "../components/states";
10
+ import { DatastoreMissing, ErrorState, Loading } from "../components/states";
7
11
  import { hornsPath } from "./paths";
8
12
  import { HornStudio } from "./horn-studio";
9
- import { MantaSparqlProvider } from "../lib/manta/providers";
10
- import { useMantaSession } from "../lib/manta/session";
13
+ import { problemFrom } from "../lib/problem";
11
14
  import { shapesRealm } from "../lib/shapes/catalog";
12
15
  // The way in: a Horn from nothing. It opens straight into the Studio with an empty document, the way a new
13
16
  // Lovable project opens on its composer: the first thing typed is what it should show, the model names it as
@@ -18,17 +21,56 @@ const starterPrompts = [
18
21
  "What happened in search last week that I should look at?",
19
22
  ];
20
23
  export function NewHornPage() {
21
- const { datastoreId } = useParams();
22
- const { datastores } = useMantaSession();
23
- const datastore = datastores.find((candidate) => candidate.id === datastoreId);
24
- if (!isPresent(datastore)) {
24
+ const { datastoreId } = useActiveDatastore();
25
+ const [search] = useSearchParams();
26
+ const about = search.get("about");
27
+ if (!isPresent(datastoreId)) {
25
28
  return (_jsx("main", { className: "flex h-svh w-full items-center justify-center bg-sidebar p-6", children: _jsx(DatastoreMissing, {}) }));
26
29
  }
27
- return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(NewHorn, { datastoreId: datastore.id }) }));
30
+ return isPresent(about) ? (_jsx(NewHornAbout, { datastoreId: datastoreId, about: about })) : (_jsx(NewHorn, { datastoreId: datastoreId }));
31
+ }
32
+ /**
33
+ * What the lookup answers with: the Horn, and whether it is one somebody kept. A kept Horn brings its slug
34
+ * and the revision it is at, which is what saving an edit of it needs — without them a save would write a
35
+ * second Horn about the same thing, or be refused for revising a Horn it never read.
36
+ */
37
+ const aboutAnswer = z.object({
38
+ horn: hornSchema,
39
+ kept: z.boolean(),
40
+ slug: z.string().min(1).optional(),
41
+ revision: z.string().min(1).optional(),
42
+ });
43
+ /**
44
+ * A Horn started from the thing it is about, rather than from nothing.
45
+ *
46
+ * The worker already builds one for a rule or a plan that nobody has kept — that is what the route answers
47
+ * with — so this opens the Studio on it. Still a draft: it becomes the Horn for that thing when somebody
48
+ * saves it, and `about` is what makes the next lookup find it. One somebody already kept opens as itself,
49
+ * so that saving revises it rather than making another.
50
+ */
51
+ function NewHornAbout({ datastoreId, about }) {
52
+ const seed = useQuery({
53
+ queryKey: ["horn-about", datastoreId, about],
54
+ queryFn: async ({ signal }) => {
55
+ const at = `/api/d/${encodeURIComponent(datastoreId)}/horns/by/about?iri=${encodeURIComponent(about)}`;
56
+ const response = await fetch(at, { signal });
57
+ if (!response.ok)
58
+ throw new Error(await problemFrom(response, `The answer was ${response.status}.`));
59
+ return aboutAnswer.parse(await response.json());
60
+ },
61
+ });
62
+ if (seed.isPending) {
63
+ return (_jsx("main", { className: "flex h-svh w-full items-center justify-center bg-sidebar p-6", children: _jsx(Loading, { label: "Building a Horn for this\u2026" }) }));
64
+ }
65
+ if (seed.isError) {
66
+ return (_jsx("main", { className: "flex h-svh w-full items-center justify-center bg-sidebar p-6", children: _jsx(ErrorState, { title: "Nothing could be built for this", hint: seed.error.message }) }));
67
+ }
68
+ const { horn, kept, slug, revision } = seed.data;
69
+ return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, ...(isPresent(slug) ? { slug } : {}), ...(isPresent(revision) ? { revision } : {}), backTo: hornsPath(), welcome: "Here is what this shows. What would you change?", reassurance: kept ? "Saving keeps your changes to this Horn." : "Nothing is saved until you save it.", placeholder: "Describe what you want to see\u2026" }));
28
70
  }
29
71
  function NewHorn({ datastoreId }) {
30
72
  // Made once: the route owns the IRI and the revision on save, and this is a document that has never been
31
73
  // stored. A new render must not make a new one underneath the draft.
32
74
  const [horn] = useState(() => standaloneHorn({ id: "untitled", title: "Untitled Horn", views: [], realm: shapesRealm(), datastoreId }));
33
- return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, backTo: hornsPath(datastoreId), welcome: "What should this Horn show?", reassurance: "Say it in your own words, and I will build it from this datastore's data.", placeholder: "Describe what you want to see\u2026", starters: starterPrompts }));
75
+ return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, backTo: hornsPath(), welcome: "What should this Horn show?", reassurance: "Say it in your own words, and I will build it from this datastore's data.", placeholder: "Describe what you want to see\u2026", starters: starterPrompts }));
34
76
  }
@@ -1,13 +1,18 @@
1
- export declare function hornsPath(datastoreId: string): string;
2
- export declare function newHornPath(datastoreId: string): string;
3
- export declare function hornPath(datastoreId: string, slug: string): string;
1
+ export declare const hornsPath: () => string;
2
+ export declare const newHornPath: () => string;
3
+ /** A new Horn started from the thing it is about: a rule, an action plan. The IRI rides as a parameter. */
4
+ export declare const newHornAboutPath: (iri: string) => string;
5
+ export declare const hornPath: (slug: string) => string;
4
6
  /** A saved Horn as the thing itself: what a shared link opens, with the variables it carries. */
5
- export declare function hornViewPath(datastoreId: string, slug: string): string;
6
- export declare function shapesPath(datastoreId: string): string;
7
- export declare function shapeZooPath(datastoreId: string, schema: string): string;
8
- export declare function shapeEntityPath(datastoreId: string, schema: string, entity: string): string;
7
+ export declare const hornViewPath: (slug: string) => string;
9
8
  /** A datastore's Horns as cards: what each one answers, who it is for, what it reads. */
10
- export declare const showcasePath: (datastoreId: string) => string;
9
+ export declare const showcasePath: () => string;
11
10
  /** What Manta works out about a datastore: every rule, and whether it can be looked at. */
12
- export declare function rulesPath(datastoreId: string): string;
13
- export declare function rulePath(datastoreId: string, ruleId: string): string;
11
+ export declare const rulesPath: () => string;
12
+ export declare const plansPath: () => string;
13
+ export declare const rulePath: (ruleId: string) => string;
14
+ /** The reference Horn drawn against a fixture. Dev only; see docs/standard.md. */
15
+ export declare const referencePath: () => string;
16
+ export declare const shapesPath: () => string;
17
+ export declare const shapeZooPath: (schema: string) => string;
18
+ export declare const shapeEntityPath: (schema: string, entity: string) => string;
package/dist/app/paths.js CHANGED
@@ -1,32 +1,21 @@
1
- export function hornsPath(datastoreId) {
2
- return `/d/${encodeURIComponent(datastoreId)}/horns`;
3
- }
4
- export function newHornPath(datastoreId) {
5
- return `${hornsPath(datastoreId)}/new`;
6
- }
7
- export function hornPath(datastoreId, slug) {
8
- return `${hornsPath(datastoreId)}/${encodeURIComponent(slug)}`;
9
- }
1
+ // Relative to the datastore: `ActiveDatastoreProvider` scopes the router's basename to `/d/<datastoreId>`,
2
+ // so a helper that wrote the prefix itself would double it.
3
+ export const hornsPath = () => "/horns";
4
+ export const newHornPath = () => "/horns/new";
5
+ /** A new Horn started from the thing it is about: a rule, an action plan. The IRI rides as a parameter. */
6
+ export const newHornAboutPath = (iri) => `/horns/new?about=${encodeURIComponent(iri)}`;
7
+ export const hornPath = (slug) => `/horns/${encodeURIComponent(slug)}`;
10
8
  /** A saved Horn as the thing itself: what a shared link opens, with the variables it carries. */
11
- export function hornViewPath(datastoreId, slug) {
12
- return `${hornPath(datastoreId, slug)}/view`;
13
- }
14
- export function shapesPath(datastoreId) {
15
- return `/d/${encodeURIComponent(datastoreId)}/shapes`;
16
- }
17
- export function shapeZooPath(datastoreId, schema) {
18
- return `${shapesPath(datastoreId)}/${encodeURIComponent(schema)}`;
19
- }
20
- // An entity IRI carries slashes and colons, so the entity travels as a query parameter rather than a segment.
21
- export function shapeEntityPath(datastoreId, schema, entity) {
22
- return `${shapeZooPath(datastoreId, schema)}/entity?iri=${encodeURIComponent(entity)}`;
23
- }
9
+ export const hornViewPath = (slug) => `${hornPath(slug)}/view`;
24
10
  /** A datastore's Horns as cards: what each one answers, who it is for, what it reads. */
25
- export const showcasePath = (datastoreId) => `/d/${encodeURIComponent(datastoreId)}/showcase`;
11
+ export const showcasePath = () => "/horns/showcase";
26
12
  /** What Manta works out about a datastore: every rule, and whether it can be looked at. */
27
- export function rulesPath(datastoreId) {
28
- return `/d/${encodeURIComponent(datastoreId)}/rules`;
29
- }
30
- export function rulePath(datastoreId, ruleId) {
31
- return `${rulesPath(datastoreId)}/${encodeURIComponent(ruleId)}`;
32
- }
13
+ export const rulesPath = () => "/horns/rules";
14
+ export const plansPath = () => "/horns/plans";
15
+ export const rulePath = (ruleId) => `${rulesPath()}/${encodeURIComponent(ruleId)}`;
16
+ /** The reference Horn drawn against a fixture. Dev only; see docs/standard.md. */
17
+ export const referencePath = () => "/horns/reference";
18
+ export const shapesPath = () => "/horns/shapes";
19
+ export const shapeZooPath = (schema) => `${shapesPath()}/${encodeURIComponent(schema)}`;
20
+ // An entity IRI carries slashes and colons, so the entity travels as a query parameter rather than a segment.
21
+ export const shapeEntityPath = (schema, entity) => `${shapeZooPath(schema)}/entity?iri=${encodeURIComponent(entity)}`;
@@ -0,0 +1 @@
1
+ export declare function PlansPage(): import("react").JSX.Element;