canon-atlas 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,10 @@ To define your own, put a `canon-atlas.json` at the root:
60
60
 
61
61
  References that resolve nowhere are not errors: they render as dashed, unwritten nodes, because a name the corpus reaches for but has not written yet is a fact worth seeing. In live mode an unwritten node offers to be written.
62
62
 
63
+ ### The article schema
64
+
65
+ A store may carry a `schema.json` beside its collections ([pi-canon](https://github.com/shaneconner/pi-canon) writes one when it creates a store), declaring rules for an article's `capsule`, `title`, and `body`: `required`, `min_chars`, `max_chars`, and a `hint`. The atlas enforces the same contract at every one of its write doors. Enforcement is asymmetric on purpose: a `required` rule rejects a save that touches the field (and everything on create), with nothing written and the editor keeping your text while it tells you what to correct; every other rule warns, and a document already in violation shows heal notes in the reader instead of errors, so a body edit is never held hostage to a legacy omission. A malformed `schema.json` fails open and loud: nothing is enforced, and the map panel and every save say so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
66
+
63
67
  ## Color
64
68
 
65
69
  Color is the cluster a document belongs to, sampled from one perceptual ramp (Batlow), and the force layout pulls each cluster into its own region, so a band of the palette and a region of the map mean the same thing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canon-atlas",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "An atlas over a catalog of markdown documents that reference each other: a constellation graph, search, a reader, and local editing. Agnostic to what wrote the catalog.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -22,15 +22,31 @@ function isDir(p) {
22
22
  }
23
23
 
24
24
  /* Resolution order: an explicit canon-atlas.json at the root wins, then preset
25
- detection (a store with both articles/ and journal/ is pi-canon), then the
26
- bare default of one mutable collection over every markdown file. A config
27
- that names a title but no collections keeps the detected preset. */
25
+ detection (a store with both articles/ and journal/, at the root or nested
26
+ under .canon/, is pi-canon), then the bare default of one mutable collection
27
+ over every markdown file. A config that names a title but no collections
28
+ keeps the detected preset. The prefix handling lives in the shared
29
+ detectPreset so Node and the browser resolve the same store the same way. */
28
30
  export function loadConfig(root) {
29
31
  const file = join(root, CONFIG_NAME);
30
32
  const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
31
- const detect = () =>
32
- isDir(join(root, "articles")) && isDir(join(root, "journal")) ? P.PI_CANON_PRESET : P.DEFAULT_CONFIG;
33
- return P.buildConfig(P.composeConfig(raw, detect));
33
+ const detect = () => {
34
+ for (const prefix of ["", ".canon/"]) {
35
+ if (isDir(join(root, prefix, "articles")) && isDir(join(root, prefix, "journal"))) {
36
+ return P.detectPreset([prefix + "articles/x.md", prefix + "journal/x.md"]);
37
+ }
38
+ }
39
+ return P.DEFAULT_CONFIG;
40
+ };
41
+ const config = P.buildConfig(P.composeConfig(raw, detect));
42
+ /* The store's article contract, when it carries one: schema.json beside the
43
+ collections (pi-canon writes it at store creation). Parsed by the shared
44
+ pipeline so the browser and this process enforce identical rules. */
45
+ const sf = join(root, P.schemaPrefix(config), P.SCHEMA_FILE);
46
+ const s = P.parseSchema(existsSync(sf) ? readFileSync(sf, "utf8") : null);
47
+ config.schema = s.schema;
48
+ config.schemaProblems = s.problems;
49
+ return config;
34
50
  }
35
51
 
36
52
  /* Optional embedding vectors at the path config.embeddings names. Absent is
package/src/serve.mjs CHANGED
@@ -26,11 +26,14 @@ import {
26
26
  } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
29
+ import { createRequire } from "node:module";
29
30
  import { loadConfig, loadVectors, collectionFor } from "./config.mjs";
30
31
  import { scanCorpus } from "./scan.mjs";
31
32
  import { buildGraph } from "./graph.mjs";
32
33
  import { buildData, renderPage, renderAppPage } from "./page.mjs";
33
34
 
35
+ const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
36
+
34
37
  const CACHE_TTL_MS = 3000;
35
38
  const BODY_LIMIT = 4_000_000;
36
39
 
@@ -149,6 +152,22 @@ export function corpusApi(rootArg) {
149
152
  return { full, rel: norm, col };
150
153
  }
151
154
 
155
+ /* The store's schema, enforced at this door the way pi-canon enforces it at
156
+ its own: a required violation the save touched (or a create) rejects with
157
+ nothing written, so the client keeps the text and says what to correct;
158
+ everything else comes back as warnings beside the success. */
159
+ function schemaGate(content, prevRaw, col) {
160
+ const cfg = config();
161
+ if (col.immutable) return [];
162
+ /* A broken declaration enforces nothing, but every write says so. */
163
+ if (!cfg.schema) return cfg.schemaProblems || [];
164
+ const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields.summary);
165
+ if (r.rejects.length) {
166
+ throw httpError(422, "Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
167
+ }
168
+ return r.warns.concat(cfg.schemaProblems || []);
169
+ }
170
+
152
171
  /* Serve one API request whose path inside the API is sub ("/graph",
153
172
  "/doc"). Returns true when the route was taken; errors throw httpError
154
173
  for the caller's catch, so both hosts report them the same way. */
@@ -169,12 +188,13 @@ export function corpusApi(rootArg) {
169
188
  if (req.method === "POST") {
170
189
  guardMutation(req);
171
190
  const body = await readBody(req);
172
- const { full, rel } = confine(body.path);
191
+ const { full, rel, col } = confine(body.path);
173
192
  if (existsSync(full)) throw httpError(409, "document already exists");
193
+ const warnings = schemaGate(body.content, null, col);
174
194
  mkdirSync(dirname(full), { recursive: true });
175
195
  writeFileSync(full, String(body.content ?? ""), { flag: "wx" });
176
196
  bust();
177
- send(201, { path: rel });
197
+ send(201, warnings.length ? { path: rel, warnings } : { path: rel });
178
198
  return true;
179
199
  }
180
200
  if (req.method === "PUT") {
@@ -183,9 +203,10 @@ export function corpusApi(rootArg) {
183
203
  const { full, rel, col } = confine(body.path);
184
204
  if (col.immutable) throw httpError(403, `${col.name} is immutable`);
185
205
  if (!existsSync(full)) throw httpError(404, "no such document");
206
+ const warnings = schemaGate(body.content, readFileSync(full, "utf8"), col);
186
207
  atomicWrite(full, String(body.content ?? ""));
187
208
  bust();
188
- send(200, { path: rel });
209
+ send(200, warnings.length ? { path: rel, warnings } : { path: rel });
189
210
  return true;
190
211
  }
191
212
  if (req.method === "DELETE") {
package/src/ui/app.js CHANGED
@@ -554,6 +554,11 @@
554
554
  none: "No clusters yet: add links between documents, or provide embeddings, and regions will form.",
555
555
  }[data.basis];
556
556
  ov.appendChild(el("p", "side-meta", basisLine + " Select a cluster to isolate it, or any node to read it."));
557
+ /* A schema the owner believes is enforced while a typo disabled it is
558
+ the worst state, so declaration problems surface here, loud. */
559
+ if (data.schemaProblems && data.schemaProblems.length) {
560
+ ov.appendChild(el("p", "err", data.schemaProblems.join("\n")));
561
+ }
557
562
  data.collections.forEach(function (c) {
558
563
  if (state.mode[c.name] === "off" && c.count) {
559
564
  ov.appendChild(el("p", "side-meta",
@@ -779,6 +784,11 @@
779
784
  }
780
785
  var kind = n.exists ? n.collection + (n.immutable ? ", immutable" : "") : "unwritten reference";
781
786
  side.appendChild(el("p", "side-kind", kind));
787
+ /* A document in violation of the store's schema reads fine; the notes
788
+ say what an edit would heal. */
789
+ if (n.schemaNotes && n.schemaNotes.length) {
790
+ side.appendChild(el("p", "schema-note", n.schemaNotes.join("\n")));
791
+ }
782
792
  var t = el("p", "side-title", n.title);
783
793
  if (n.exists) t.style.color = d3.hcl(d3.color(n.color)).brighter(1.1) + "";
784
794
  side.appendChild(t);
@@ -862,6 +872,9 @@
862
872
  }
863
873
 
864
874
  function showErr(err) {
875
+ /* Replace any standing error: a corrected retry must not stack lines. */
876
+ var old = side.querySelector(".err");
877
+ if (old) old.parentNode.removeChild(old);
865
878
  var p = el("p", "err", String((err && err.message) || err));
866
879
  side.insertBefore(p, side.firstChild);
867
880
  }
@@ -96,13 +96,22 @@ var AtlasPipeline = (function () {
96
96
  }
97
97
 
98
98
  function detectPreset(relPaths) {
99
- let hasArticles = false;
100
- let hasJournal = false;
101
- for (const p of relPaths) {
102
- if (p.startsWith("articles/")) hasArticles = true;
103
- if (p.startsWith("journal/")) hasJournal = true;
99
+ for (const prefix of ["", ".canon/"]) {
100
+ let hasArticles = false;
101
+ let hasJournal = false;
102
+ for (const p of relPaths) {
103
+ if (p.startsWith(prefix + "articles/")) hasArticles = true;
104
+ if (p.startsWith(prefix + "journal/")) hasJournal = true;
105
+ }
106
+ if (hasArticles && hasJournal) {
107
+ if (!prefix) return PI_CANON_PRESET;
108
+ return Object.assign({}, PI_CANON_PRESET, {
109
+ collections: PI_CANON_PRESET.collections.map((c) =>
110
+ Object.assign({}, c, { match: prefix + c.match })),
111
+ });
112
+ }
104
113
  }
105
- return hasArticles && hasJournal ? PI_CANON_PRESET : DEFAULT_CONFIG;
114
+ return DEFAULT_CONFIG;
106
115
  }
107
116
 
108
117
  /* Optional embedding vectors: a parsed JSON object of root-relative markdown
@@ -224,6 +233,124 @@ var AtlasPipeline = (function () {
224
233
  return m ? m[1].trim() : "";
225
234
  }
226
235
 
236
+ /* ── article schema ────────────────────────────────────────────────────────
237
+ A store may carry a schema.json beside its collections: pi-canon writes one
238
+ when it creates a store, and this file is the shared contract, so the atlas
239
+ enforces the same rules at its own write doors. The shape mirrors pi-canon
240
+ exactly: an "article" object with capsule / title / body rules, each rule
241
+ taking required, min_chars, max_chars, hint. Enforcement is asymmetric on
242
+ purpose: required rejects a save that touches the field (and everything on
243
+ create), everything else warns, and a document read in violation carries
244
+ heal notes instead of errors. A malformed file fails open and loud: nothing
245
+ is enforced and the problems say so, because a contract the owner believes
246
+ is enforced while a typo disabled it is the worst state. */
247
+
248
+ const SCHEMA_FILE = "schema.json";
249
+ const SCHEMA_FIELDS = ["capsule", "title", "body"];
250
+ const SCHEMA_RULE_KEYS = ["required", "min_chars", "max_chars", "hint"];
251
+
252
+ /* The store lives either at the corpus root or nested under .canon/; the
253
+ schema file sits beside the collections, wherever they are. */
254
+ function schemaPrefix(config) {
255
+ return config.collections.some((c) => (c.match || "").startsWith(".canon/")) ? ".canon/" : "";
256
+ }
257
+
258
+ /* Raw file text (or null when absent) into { schema, problems }. */
259
+ function parseSchema(text) {
260
+ if (text == null) return { schema: undefined, problems: [] };
261
+ let raw;
262
+ try {
263
+ raw = JSON.parse(text);
264
+ } catch (e) {
265
+ return {
266
+ schema: undefined,
267
+ problems: [SCHEMA_FILE + " is not valid JSON (" + e.message + "); its rules are not being enforced."],
268
+ };
269
+ }
270
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
271
+ return { schema: undefined, problems: [SCHEMA_FILE + " must hold a JSON object; its rules are not being enforced."] };
272
+ }
273
+ const problems = [];
274
+ const declared = raw.article;
275
+ if (declared === undefined) return { schema: {}, problems };
276
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
277
+ return { schema: undefined, problems: [SCHEMA_FILE + ': "article" must be an object; its rules are not being enforced.'] };
278
+ }
279
+ const schema = {};
280
+ for (const [name, value] of Object.entries(declared)) {
281
+ if (!SCHEMA_FIELDS.includes(name)) {
282
+ problems.push(SCHEMA_FILE + ': unknown article field "' + name + '" is ignored (fields: ' + SCHEMA_FIELDS.join(", ") + ").");
283
+ continue;
284
+ }
285
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
286
+ problems.push(SCHEMA_FILE + ': rule for "' + name + '" must be an object and is ignored.');
287
+ continue;
288
+ }
289
+ const rule = {};
290
+ for (const [key, val] of Object.entries(value)) {
291
+ if (!SCHEMA_RULE_KEYS.includes(key)) {
292
+ problems.push(SCHEMA_FILE + ': unknown rule key "' + name + "." + key + '" is ignored (keys: ' + SCHEMA_RULE_KEYS.join(", ") + ").");
293
+ } else if (key === "required" && typeof val === "boolean") rule.required = val;
294
+ else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
295
+ else if (key === "hint" && typeof val === "string") rule.hint = val;
296
+ else problems.push(SCHEMA_FILE + ': "' + name + "." + key + '" has the wrong type and is ignored.');
297
+ }
298
+ schema[name] = rule;
299
+ }
300
+ return { schema, problems };
301
+ }
302
+
303
+ /* The title the schema means: the body's leading # heading, nothing later. */
304
+ function titleOf(body) {
305
+ for (const line of body.split("\n")) {
306
+ if (!line.trim()) continue;
307
+ const m = /^#\s+(.+)$/.exec(line);
308
+ return m ? m[1].trim() : "";
309
+ }
310
+ return "";
311
+ }
312
+
313
+ /* One save (or one read, when prevRaw === raw so nothing counts as touched)
314
+ against the schema. summaryKey is the frontmatter key this collection
315
+ carries its capsule under. A required violation rejects only when the save
316
+ touched that field or created the document; a legacy violation the save
317
+ left alone warns instead, so a body edit is never held hostage to an old
318
+ missing capsule. */
319
+ function checkDoc(raw, prevRaw, schema, summaryKey) {
320
+ const rejects = [];
321
+ const warns = [];
322
+ if (!schema) return { rejects, warns };
323
+ const created = prevRaw == null;
324
+ const extract = (text) => {
325
+ const { meta, body } = parseFrontmatter(text);
326
+ return {
327
+ capsule: String(meta[summaryKey] || "").trim(),
328
+ title: titleOf(body),
329
+ body: body.trim(),
330
+ };
331
+ };
332
+ const now = extract(raw);
333
+ const before = created ? null : extract(prevRaw);
334
+ for (const name of SCHEMA_FIELDS) {
335
+ const rule = schema[name];
336
+ if (!rule) continue;
337
+ const value = now[name];
338
+ const touched = created || value !== before[name];
339
+ const hint = rule.hint ? " (" + rule.hint + ")" : "";
340
+ if (rule.required && !value) {
341
+ if (touched) rejects.push(name + " is required" + hint);
342
+ else warns.push("schema: " + name + " is required and missing; this document can be healed by editing" + hint);
343
+ }
344
+ if (value && rule.min_chars != null && value.length < rule.min_chars) {
345
+ warns.push("schema: " + name + " is under " + rule.min_chars + " characters" + hint);
346
+ }
347
+ if (value && rule.max_chars != null && value.length > rule.max_chars) {
348
+ warns.push("schema: " + name + " is over " + rule.max_chars + " characters (" + value.length + ")" + hint);
349
+ }
350
+ }
351
+ return { rejects, warns };
352
+ }
353
+
227
354
  function extractLinks(body) {
228
355
  const out = [];
229
356
  // Fenced code carries example links that were never authored as references.
@@ -265,7 +392,14 @@ var AtlasPipeline = (function () {
265
392
  const allTags = asTags(meta[f.tags]);
266
393
  const lift = config.preset === "pi-canon";
267
394
  const fromPath = lift ? allTags.filter((t) => t.startsWith("path:")) : [];
395
+ /* A read never rejects: a document in violation carries heal notes. */
396
+ let schemaNotes;
397
+ if (config.schema && !col.immutable) {
398
+ const w = checkDoc(text, text, config.schema, f.summary).warns;
399
+ if (w.length) schemaNotes = w;
400
+ }
268
401
  return {
402
+ schemaNotes,
269
403
  path: rel,
270
404
  address,
271
405
  collection: col.name,
@@ -787,6 +921,7 @@ var AtlasPipeline = (function () {
787
921
  preset: config.preset,
788
922
  mode,
789
923
  basis,
924
+ schemaProblems: config.schemaProblems && config.schemaProblems.length ? config.schemaProblems : undefined,
790
925
  collections: config.collections.map((c) => ({
791
926
  name: c.name,
792
927
  match: c.match,
@@ -811,6 +946,7 @@ var AtlasPipeline = (function () {
811
946
  exists: n.exists,
812
947
  degree: n.degree,
813
948
  rank: n.rank,
949
+ schemaNotes: n.schemaNotes,
814
950
  html: n.body ? renderMarkdown(n.body) : "",
815
951
  })),
816
952
  edges,
@@ -832,6 +968,13 @@ var AtlasPipeline = (function () {
832
968
  const raw = composeConfig(opts.configJson, () => detectPreset(md.map((f) => f.path)));
833
969
  const config = buildConfig(raw);
834
970
  config.vectors = parseVectors(opts.vectorsJson);
971
+ /* schemaTexts carries both candidate files ({ root, nested }); which one is
972
+ the store's contract depends on where the collections landed, so the
973
+ choice waits for the config. */
974
+ const texts = opts.schemaTexts || {};
975
+ const s = parseSchema((schemaPrefix(config) ? texts.nested : texts.root) ?? null);
976
+ config.schema = s.schema;
977
+ config.schemaProblems = s.problems;
835
978
  const docs = scanFiles(md, config);
836
979
  const graph = buildGraph(docs);
837
980
  return buildData(config, graph, opts.mode || "fs");
@@ -839,12 +982,16 @@ var AtlasPipeline = (function () {
839
982
 
840
983
  return {
841
984
  CONFIG_NAME,
985
+ SCHEMA_FILE,
842
986
  PI_CANON_PRESET,
843
987
  DEFAULT_CONFIG,
844
988
  buildConfig,
845
989
  composeConfig,
846
990
  detectPreset,
847
991
  parseVectors,
992
+ parseSchema,
993
+ schemaPrefix,
994
+ checkDoc,
848
995
  collectionFor,
849
996
  parseFrontmatter,
850
997
  extractLinks,
package/src/ui/shell.js CHANGED
@@ -193,10 +193,29 @@
193
193
  .catch(function () { return null; });
194
194
  }
195
195
 
196
+ /* Raw text at a nested path under the handle, or null when absent. */
197
+ function readTextAt(parts) {
198
+ var dir = Promise.resolve(handle);
199
+ for (var i = 0; i < parts.length - 1; i++) {
200
+ (function (name) {
201
+ dir = dir.then(function (d) { return d.getDirectoryHandle(name); });
202
+ })(parts[i]);
203
+ }
204
+ return dir
205
+ .then(function (d) { return d.getFileHandle(parts[parts.length - 1]); })
206
+ .then(function (fh) { return fh.getFile(); })
207
+ .then(function (f) { return f.text(); })
208
+ .catch(function () { return null; });
209
+ }
210
+
196
211
  /* "fs" when the handle came with write permission, "browse" when it is
197
212
  read-only (a dropped folder whose write grant was declined still reads). */
198
213
  var handleMode = "fs";
199
214
 
215
+ /* The store's article contract, parsed after each mount so the write path
216
+ enforces the same rules the wire data's heal notes report. */
217
+ var storeSchema = { schema: undefined, problems: [] };
218
+
200
219
  function buildFromFolder(progress) {
201
220
  return listDir(handle, "", [])
202
221
  .then(function (list) {
@@ -206,12 +225,20 @@
206
225
  .then(function (files) {
207
226
  return readJson(CONFIG).then(function (configJson) {
208
227
  var vecName = (configJson && configJson.embeddings) || "embeddings.json";
209
- return readJson(vecName).then(function (vectorsJson) {
228
+ return Promise.all([
229
+ readJson(vecName),
230
+ readTextAt(["schema.json"]),
231
+ readTextAt([".canon", "schema.json"]),
232
+ ]).then(function (loaded) {
233
+ var schemaTexts = { root: loaded[1], nested: loaded[2] };
210
234
  currentData = AtlasPipeline.buildWireData(files, {
211
235
  configJson: configJson,
212
- vectorsJson: vectorsJson,
236
+ vectorsJson: loaded[0],
237
+ schemaTexts: schemaTexts,
213
238
  mode: handleMode,
214
239
  });
240
+ var nested = currentData.collections.some(function (c) { return (c.match || "").indexOf(".canon/") === 0; });
241
+ storeSchema = AtlasPipeline.parseSchema(nested ? schemaTexts.nested : schemaTexts.root);
215
242
  return currentData;
216
243
  });
217
244
  });
@@ -252,6 +279,21 @@
252
279
  });
253
280
  }
254
281
 
282
+ /* The same asymmetric enforcement the server applies: a required violation
283
+ the save touched (or a create) rejects with nothing written, so the editor
284
+ keeps the text and says what to correct; everything else is a warning. */
285
+ function schemaGateFs(content, prevRaw, col) {
286
+ if (col.immutable) return [];
287
+ /* A broken declaration enforces nothing, but every write says so. */
288
+ if (!storeSchema.schema) return storeSchema.problems || [];
289
+ var summaryKey = (col.fields && col.fields.summary) || "summary";
290
+ var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, summaryKey);
291
+ if (r.rejects.length) {
292
+ throw new Error("Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
293
+ }
294
+ return r.warns.concat(storeSchema.problems);
295
+ }
296
+
255
297
  var fsBackend = {
256
298
  graph: function () { return buildFromFolder(); },
257
299
  readDoc: function (path) {
@@ -264,18 +306,25 @@
264
306
  writeDoc: function (path, content) {
265
307
  var col = confine(path);
266
308
  if (col.immutable) return Promise.reject(new Error(col.name + " is immutable"));
267
- return fileHandleFor(path, false).then(function (fh) { return writeThrough(fh, content); }).then(function () {
268
- return { path: path };
269
- });
309
+ return fileHandleFor(path, false)
310
+ .then(function (fh) {
311
+ return fh.getFile().then(function (f) { return f.text(); }).then(function (prev) {
312
+ var warnings = schemaGateFs(content, prev, col);
313
+ return writeThrough(fh, content).then(function () {
314
+ return warnings.length ? { path: path, warnings: warnings } : { path: path };
315
+ });
316
+ });
317
+ });
270
318
  },
271
319
  createDoc: function (path, content) {
272
- confine(path);
320
+ var col = confine(path);
273
321
  // Refuse to overwrite: succeed only if the file does not already exist.
274
322
  return fileHandleFor(path, false).then(
275
323
  function () { throw new Error("document already exists"); },
276
324
  function () {
325
+ var warnings = schemaGateFs(content, null, col);
277
326
  return fileHandleFor(path, true).then(function (fh) { return writeThrough(fh, content); }).then(function () {
278
- return { path: path };
327
+ return warnings.length ? { path: path, warnings: warnings } : { path: path };
279
328
  });
280
329
  }
281
330
  );
@@ -361,9 +410,15 @@
361
410
  ).then(function (docs) {
362
411
  var mem = {};
363
412
  docs.forEach(function (f) { mem[f.path] = f.text; });
364
- var data = AtlasPipeline.buildWireData(docs, {
413
+ return Promise.all([textOf("schema.json"), textOf(".canon/schema.json")]).then(function (st) {
414
+ return { docs: docs, mem: mem, schemaTexts: { root: st[0], nested: st[1] } };
415
+ });
416
+ }).then(function (got) {
417
+ var mem = got.mem;
418
+ var data = AtlasPipeline.buildWireData(got.docs, {
365
419
  configJson: configJson,
366
420
  vectorsJson: vectorsJson,
421
+ schemaTexts: got.schemaTexts,
367
422
  mode: "browse",
368
423
  });
369
424
  currentData = data;
package/src/ui/style.css CHANGED
@@ -555,4 +555,7 @@ select.abtn option { background: var(--bg-elevated); color: var(--text); }
555
555
 
556
556
  .abtn.danger:hover { border-color: #8b2942; color: #e08a9c; }
557
557
 
558
- .err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; }
558
+ .err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; white-space: pre-line; }
559
+
560
+ /* Schema heal notes: information beside a document, never a wall. */
561
+ .schema-note { margin: 0.35rem 0 0.6rem; font-size: 0.72rem; color: #a89a5f; white-space: pre-line; }