@plannotator/pi-extension 0.28.50 → 0.28.51

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.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * A shape is one thing being worked out with one agent session. Each time the
6
6
  * agent draws it, the store keeps a full snapshot: a version. A version holds
7
- * four views (Picture, How it works, Requirements, Code), the numbered
7
+ * four views (Picture, How it works, What to know, Code), the numbered
8
8
  * references the views cite, and the parts the person can point at.
9
9
  *
10
10
  * Browser-safe and dependency-free: the store (packages/shared/shape), the
@@ -13,7 +13,7 @@
13
13
  * - `checkShapeBoard` validates what an agent writes (`plannotator shape
14
14
  * update --file`) and names the exact field that is wrong.
15
15
  * - `shapeParts` lists the parts of a board: every `data-mark` of a drawn
16
- * view, every requirement, every Code step, the title and the claim. Ids
16
+ * view, every paragraph of What to know, every Code step, the title and the claim. Ids
17
17
  * stay the same across versions, which is what lets the board redraw only
18
18
  * what changed.
19
19
  * - `diffShapeVersions` compares two snapshots by those ids ("what changed
@@ -27,13 +27,13 @@ import { ulid } from '../ulid.ts';
27
27
 
28
28
  export const SHAPE_FORMAT_VERSION = 1;
29
29
 
30
- export type ShapeViewId = 'picture' | 'how' | 'reqs' | 'code';
30
+ export type ShapeViewId = 'picture' | 'how' | 'know' | 'code';
31
31
 
32
32
  /** The four views, in the dock's order. Their labels are the product's words. */
33
33
  export const SHAPE_VIEWS: readonly { id: ShapeViewId; label: string }[] = [
34
34
  { id: 'picture', label: 'Picture' },
35
35
  { id: 'how', label: 'How it works' },
36
- { id: 'reqs', label: 'Requirements' },
36
+ { id: 'know', label: 'What to know' },
37
37
  { id: 'code', label: 'Code' },
38
38
  ];
39
39
 
@@ -67,8 +67,14 @@ export interface ShapeDrawnView {
67
67
  height?: number;
68
68
  /** A stacked drawing for a narrow board (a phone), shown below 700 px. */
69
69
  tall?: { html: string; css?: string };
70
+ /**
71
+ * The Picture only: a fuller drawing behind the board's "Show more", when there is more
72
+ * that must be drawn. It flows and scrolls like a page. Most boards have none.
73
+ */
74
+ more?: { html: string; css?: string };
70
75
  }
71
76
 
77
+ // An old board's Requirements. Nothing new writes them (`checkShapeBoard` refuses them); a saved version keeps them and What to know shows them.
72
78
  export interface ShapeRequirement {
73
79
  /** Stable across versions. */
74
80
  id: string;
@@ -137,7 +143,7 @@ export interface ShapeRef {
137
143
  sub?: string;
138
144
  target: string;
139
145
  line?: number;
140
- images?: { src: string; alt: string }[];
146
+ images?: { src: string; alt: string; crop?: [number, number, number, number] }[];
141
147
  }
142
148
 
143
149
  /** What an agent writes: one whole board. */
@@ -152,6 +158,9 @@ export interface ShapeBoard {
152
158
  views: {
153
159
  picture?: ShapeDrawnView;
154
160
  how?: ShapeDrawnView;
161
+ /** What the person needs to know: at most two short paragraphs of prose. Inline Markdown. */
162
+ know?: string;
163
+ /** Only on a version saved before What to know. */
155
164
  reqs?: ShapeRequirements;
156
165
  code?: ShapeCodeView;
157
166
  };
@@ -189,12 +198,19 @@ export interface ShapeVersion extends ShapeBoard {
189
198
 
190
199
  export type ShapeState = 'active' | 'finished';
191
200
 
201
+ export interface ShapeProject {
202
+ name: string;
203
+ root: string;
204
+ /** The linked worktree the board was written from, when that folds into the project at `root`. */
205
+ worktree?: string;
206
+ }
207
+
192
208
  /** `shape.json`: the shape itself. */
193
209
  export interface ShapeRecord {
194
210
  v: typeof SHAPE_FORMAT_VERSION;
195
211
  id: string;
196
212
  title: string;
197
- project: { name: string; root: string };
213
+ project: ShapeProject;
198
214
  session: ShapeSession | null;
199
215
  /** The Inbox thread it hangs off (`msg_…`), or null for a standalone shape. */
200
216
  thread: string | null;
@@ -230,6 +246,22 @@ export interface ShapeNote {
230
246
  re: string | null;
231
247
  /** When an agent picked it up (`shape read`); null while it waits. */
232
248
  delivered: string | null;
249
+ /** A box dragged over the board: where it sits. `part` then names every part inside it, comma-separated. */
250
+ box?: ShapeNoteBox;
251
+ /** The `data-code` places inside the box ("src/a.ts#L42"). */
252
+ code?: string[];
253
+ /** A picture of the box, grown to the whole of the parts it picked, the box drawn on it: `files/…` in the store, its full path in `shape read --notes`. */
254
+ picture?: string;
255
+ }
256
+
257
+ /** Where a box sits, as fractions of its drawing (or of What to know). `tall` and `more`: on that layout of the view. */
258
+ export interface ShapeNoteBox {
259
+ x: number;
260
+ y: number;
261
+ w: number;
262
+ h: number;
263
+ tall?: true;
264
+ more?: true;
233
265
  }
234
266
 
235
267
  /** A Change it that no agent has picked up: "1 change waits for the agent". */
@@ -268,8 +300,8 @@ export const SHAPE_LIMITS = {
268
300
  html: 300_000,
269
301
  css: 60_000,
270
302
  canvas: { min: 200, max: 4000 },
271
- groups: 12,
272
- items: 40,
303
+ know: 1200,
304
+ knowParagraphs: 2,
273
305
  line: 400,
274
306
  sections: 20,
275
307
  patch: 3_000_000,
@@ -281,6 +313,8 @@ export const SHAPE_LIMITS = {
281
313
  images: 12,
282
314
  noteText: 4000,
283
315
  quote: 600,
316
+ /** A box's picture, as a data: URL. */
317
+ notePicture: 6_000_000,
284
318
  } as const;
285
319
 
286
320
  const PART_ID_RE = /^[A-Za-z][\w.-]{0,63}$/;
@@ -548,7 +582,7 @@ export function shapeHtmlProblems(html: string): string[] {
548
582
  else if (SHAPE_URL_ATTRS.includes(name)) {
549
583
  const link = tag === 'a' && (name === 'href' || name === 'xlink:href');
550
584
  if (!shapeUrlAllowed(value, link)) {
551
- add(`<${tag} ${name}="${shown}">: nothing may load from outside the board; use files/<name>, #id or a data:image${link ? ' (a link may be https://)' : ''}`);
585
+ add(`<${tag} ${name}="${shown}">: nothing may load from outside the board; use a file on this computer, files/<name>, #id or a data:image${link ? ' (a link may be https://)' : ''}`);
552
586
  }
553
587
  } else if (name === 'style') {
554
588
  for (const p of shapeCssProblems(value)) add(`<${tag} style>: ${p}`);
@@ -744,69 +778,48 @@ function checkDrawn(c: Checker, value: unknown, field: string): ShapeDrawnView |
744
778
  if (th !== undefined) tall = { html: th, ...(tc ? { css: tc } : {}) };
745
779
  }
746
780
  }
781
+ let more: ShapeDrawnView['more'];
782
+ if (value.more !== undefined && value.more !== null) {
783
+ if (!isRecord(value.more)) c.fail(`${field}.more`, 'must be an object with html');
784
+ else {
785
+ const mh = c.text(value.more.html, `${field}.more.html`, SHAPE_LIMITS.html);
786
+ if (mh !== undefined) for (const p of shapeHtmlProblems(mh)) c.fail(`${field}.more.html`, p);
787
+ const mc = c.text(value.more.css, `${field}.more.css`, SHAPE_LIMITS.css, false);
788
+ if (mc !== undefined) for (const p of shapeCssProblems(mc)) c.fail(`${field}.more.css`, p);
789
+ if (mh !== undefined) more = { html: mh, ...(mc ? { css: mc } : {}) };
790
+ }
791
+ }
747
792
  if (html === undefined) return undefined;
748
793
  return {
749
794
  html,
750
795
  ...(css ? { css } : {}),
751
796
  ...(width !== undefined && height !== undefined ? { width, height } : {}),
752
797
  ...(tall ? { tall } : {}),
798
+ ...(more ? { more } : {}),
753
799
  };
754
800
  }
755
801
 
756
- function checkReqs(c: Checker, value: unknown, field: string): ShapeRequirements | undefined {
802
+ /** What to know's paragraphs: blank lines part them. */
803
+ export function shapeKnowParagraphs(text: string): string[] {
804
+ return text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
805
+ }
806
+
807
+ function checkKnow(c: Checker, value: unknown, field: string): string | undefined {
757
808
  if (value === undefined || value === null) return undefined;
758
- if (!isRecord(value)) {
759
- c.fail(field, 'must be an object with groups');
809
+ if (typeof value !== 'string') {
810
+ c.fail(field, 'must be a string of Markdown');
760
811
  return undefined;
761
812
  }
762
- const heading = c.text(value.heading, `${field}.heading`, SHAPE_LIMITS.line, false);
763
- const groups = c.list(value.groups, `${field}.groups`, SHAPE_LIMITS.groups);
764
- if (!groups) {
765
- if (value.groups === undefined) c.fail(`${field}.groups`, 'is required');
813
+ const text = value.trim();
814
+ if (!text) {
815
+ c.fail(field, 'is empty');
766
816
  return undefined;
767
817
  }
768
- const ids = new Set<string>();
769
- const out: ShapeRequirementGroup[] = [];
770
- groups.forEach((g, gi) => {
771
- const gf = `${field}.groups[${gi}]`;
772
- if (!isRecord(g)) return c.fail(gf, 'must be an object');
773
- const id = c.id(g.id, `${gf}.id`);
774
- const label = c.text(g.label, `${gf}.label`, 80);
775
- const items = c.list(g.items, `${gf}.items`, SHAPE_LIMITS.items) ?? [];
776
- if (g.items === undefined) c.fail(`${gf}.items`, 'is required');
777
- const rows: ShapeRequirement[] = [];
778
- items.forEach((it, ii) => {
779
- const f = `${gf}.items[${ii}]`;
780
- if (!isRecord(it)) return c.fail(f, 'must be an object');
781
- const iid = c.id(it.id, `${f}.id`);
782
- if (iid && ids.has(iid)) c.fail(`${f}.id`, `"${iid}" is used twice`);
783
- if (iid) ids.add(iid);
784
- const text = c.text(it.text, `${f}.text`, SHAPE_LIMITS.line);
785
- let code: ShapeRequirement['code'];
786
- if (it.code !== undefined && it.code !== null) {
787
- if (!isRecord(it.code)) c.fail(`${f}.code`, 'must be { path, line }');
788
- else {
789
- const path = c.path(it.code.path, `${f}.code.path`);
790
- const line = c.int(it.code.line, `${f}.code.line`, 1, 10_000_000);
791
- if (path && line) code = { path, line };
792
- }
793
- }
794
- if (iid && text) rows.push({ id: iid, text, ...(code ? { code } : {}) });
795
- });
796
- if (id && label) out.push({ id, label, items: rows });
797
- });
798
- let foot: ShapeRequirements['foot'];
799
- const feet = c.list(value.foot, `${field}.foot`, 6);
800
- if (feet) {
801
- foot = [];
802
- feet.forEach((f, i) => {
803
- if (!isRecord(f)) return c.fail(`${field}.foot[${i}]`, 'must be { label, text }');
804
- const label = c.text(f.label, `${field}.foot[${i}].label`, 24);
805
- const text = c.text(f.text, `${field}.foot[${i}].text`, SHAPE_LIMITS.line);
806
- if (label && text) foot!.push({ label, text });
807
- });
808
- }
809
- return { ...(heading ? { heading } : {}), groups: out, ...(foot?.length ? { foot } : {}) };
818
+ const cut = 'Cut it to what the person needs to know.';
819
+ if (text.length > SHAPE_LIMITS.know) c.fail(field, `is ${text.length} characters; at most ${SHAPE_LIMITS.know}. ${cut}`);
820
+ const paragraphs = shapeKnowParagraphs(text).length;
821
+ if (paragraphs > SHAPE_LIMITS.knowParagraphs) c.fail(field, `is ${paragraphs} paragraphs; at most ${SHAPE_LIMITS.knowParagraphs}. ${cut}`);
822
+ return text;
810
823
  }
811
824
 
812
825
  function checkCode(c: Checker, value: unknown, field: string): ShapeCodeView | undefined {
@@ -918,7 +931,10 @@ function checkRefs(c: Checker, value: unknown): ShapeRef[] | undefined {
918
931
  }
919
932
  const src = c.text(im.src, `${f}.images[${j}].src`, SHAPE_LIMITS.target);
920
933
  const alt = c.text(im.alt, `${f}.images[${j}].alt`, SHAPE_LIMITS.refText, false) ?? '';
921
- return src ? [{ src, alt }] : [];
934
+ const crop = im.crop;
935
+ const goodCrop = Array.isArray(crop) && crop.length === 4 && crop.every((v) => typeof v === 'number' && v >= 0 && v <= 1) && crop[2] > 0 && crop[3] > 0 && crop[0] + crop[2] <= 1 && crop[1] + crop[3] <= 1;
936
+ if (crop !== undefined && !goodCrop) c.fail(`${f}.images[${j}].crop`, 'must be [x, y, width, height], each from 0 to 1');
937
+ return src ? [{ src, alt, ...(goodCrop ? { crop: crop as [number, number, number, number] } : {}) }] : [];
922
938
  });
923
939
  }
924
940
  if (n !== undefined && kind && title && target) {
@@ -954,20 +970,21 @@ export function checkShapeBoard(input: unknown): ShapeCheck<ShapeBoard> {
954
970
  else asOf = new Date(input.asOf).toISOString();
955
971
  }
956
972
  const views: ShapeBoard['views'] = {};
957
- if (!isRecord(input.views)) c.fail('views', 'is required: { picture, how, reqs, code }, at least one');
973
+ if (!isRecord(input.views)) c.fail('views', 'is required: { picture, how, know, code }, at least one');
958
974
  else {
959
975
  for (const key of Object.keys(input.views)) {
960
- if (!SHAPE_VIEWS.some((v) => v.id === key)) c.fail(`views.${key}`, 'is not a view (picture, how, reqs, code)');
976
+ if (key === 'reqs') c.fail('views.reqs', 'Requirements are gone. Put what the person needs to know in views.know, as at most two short paragraphs');
977
+ else if (!SHAPE_VIEWS.some((v) => v.id === key)) c.fail(`views.${key}`, 'is not a view (picture, how, know, code)');
961
978
  }
962
979
  const picture = checkDrawn(c, input.views.picture, 'views.picture');
963
980
  const how = checkDrawn(c, input.views.how, 'views.how');
964
- const reqs = checkReqs(c, input.views.reqs, 'views.reqs');
981
+ const know = checkKnow(c, input.views.know, 'views.know');
965
982
  const code = checkCode(c, input.views.code, 'views.code');
966
983
  if (picture) views.picture = picture;
967
984
  if (how) views.how = how;
968
- if (reqs) views.reqs = reqs;
985
+ if (know) views.know = know;
969
986
  if (code) views.code = code;
970
- if (!input.views.picture && !input.views.how && !input.views.reqs && !input.views.code) c.fail('views', 'needs at least one view');
987
+ if (!input.views.picture && !input.views.how && !input.views.know && !input.views.reqs && !input.views.code) c.fail('views', 'needs at least one view');
971
988
  }
972
989
  const refs = checkRefs(c, input.refs);
973
990
  if (c.errors.length || !title || !claim) return { ok: false, errors: c.errors.length ? c.errors : ['title and claim are required'] };
@@ -1063,7 +1080,7 @@ export function shapeParts(board: ShapeBoard): ShapePart[] {
1063
1080
  const drawn = board.views[view];
1064
1081
  if (!drawn) continue;
1065
1082
  const seen = new Set<string>();
1066
- for (const html of [drawn.html, drawn.tall?.html ?? '']) {
1083
+ for (const html of [drawn.html, drawn.tall?.html ?? '', drawn.more?.html ?? '']) {
1067
1084
  for (const mark of shapeHtmlMarks(html)) {
1068
1085
  if (seen.has(mark.id)) continue;
1069
1086
  seen.add(mark.id);
@@ -1071,8 +1088,9 @@ export function shapeParts(board: ShapeBoard): ShapePart[] {
1071
1088
  }
1072
1089
  }
1073
1090
  }
1091
+ shapeKnowParagraphs(board.views.know ?? '').forEach((text, i) => parts.push({ view: 'know', id: `know-${i + 1}`, text: fold(text) }));
1074
1092
  for (const group of board.views.reqs?.groups ?? []) {
1075
- for (const item of group.items) parts.push({ view: 'reqs', id: item.id, text: fold(`${group.label}: ${item.text}`) });
1093
+ for (const item of group.items) parts.push({ view: 'know', id: item.id, text: fold(`${group.label}: ${item.text}`) });
1076
1094
  }
1077
1095
  board.views.code?.guide.sections.forEach((section, i) => {
1078
1096
  parts.push({ view: 'code', id: `step-${i + 1}`, text: fold(`${section.title}. ${section.overview} ${section.diffs.map((d) => d.file).join(' ')}`) });
@@ -1117,7 +1135,9 @@ export function shapeCitedRefs(board: ShapeBoard): Set<number> {
1117
1135
  for (const view of ['picture', 'how'] as const) {
1118
1136
  fromHtml(board.views[view]?.html);
1119
1137
  fromHtml(board.views[view]?.tall?.html);
1138
+ fromHtml(board.views[view]?.more?.html);
1120
1139
  }
1140
+ fromText(board.views.know);
1121
1141
  for (const group of board.views.reqs?.groups ?? []) for (const item of group.items) fromText(item.text);
1122
1142
  for (const foot of board.views.reqs?.foot ?? []) fromText(foot.text);
1123
1143
  fromText(board.views.code?.source);
@@ -1143,24 +1163,37 @@ export interface ShapeDiff {
1143
1163
  refs: { added: number[]; removed: number[]; changed: number[] };
1144
1164
  }
1145
1165
 
1166
+ /** What a view holds on this version. What to know is the words, or an old board's Requirements. */
1167
+ /** A view, part or note saved when What to know was Requirements says "reqs": it is `know` now. */
1168
+ export function shapeViewNow<T>(view: T): T | 'know' {
1169
+ return (view as unknown) === 'reqs' ? 'know' : view;
1170
+ }
1171
+
1172
+ export function shapeViewContent(views: ShapeBoard['views'], view: ShapeViewId): unknown {
1173
+ return view === 'know' ? views.know ?? views.reqs : views[view];
1174
+ }
1175
+
1146
1176
  type Diffable = Pick<ShapeVersion, 'n' | 'views' | 'parts' | 'refs'> & Partial<Pick<ShapeVersion, 'title' | 'claim'>>;
1147
1177
 
1148
1178
  /** What changed from `a` to `b`, by part ids. Snapshots, so any two versions compare. */
1149
1179
  export function diffShapeVersions(a: Diffable, b: Diffable): ShapeDiff {
1150
1180
  const key = (p: ShapePart) => `${p.view}\u0000${p.id}`;
1151
- const before = new Map(a.parts.map((p) => [key(p), p]));
1152
- const after = new Map(b.parts.map((p) => [key(p), p]));
1181
+ const now = (p: ShapePart): ShapePart => ({ ...p, view: shapeViewNow(p.view) });
1182
+ const partsA = a.parts.map(now);
1183
+ const partsB = b.parts.map(now);
1184
+ const before = new Map(partsA.map((p) => [key(p), p]));
1185
+ const after = new Map(partsB.map((p) => [key(p), p]));
1153
1186
  const changes: ShapeChange[] = [];
1154
- for (const p of b.parts) {
1187
+ for (const p of partsB) {
1155
1188
  const old = before.get(key(p));
1156
1189
  if (!old) changes.push({ view: p.view, id: p.id, kind: 'added', after: p.text });
1157
1190
  else if (old.text !== p.text) changes.push({ view: p.view, id: p.id, kind: 'changed', before: old.text, after: p.text });
1158
1191
  }
1159
- for (const p of a.parts) if (!after.has(key(p))) changes.push({ view: p.view, id: p.id, kind: 'removed', before: p.text });
1192
+ for (const p of partsA) if (!after.has(key(p))) changes.push({ view: p.view, id: p.id, kind: 'removed', before: p.text });
1160
1193
 
1161
1194
  const views = SHAPE_VIEWS.map((v) => v.id).filter((view) => {
1162
1195
  if (changes.some((c) => c.view === view)) return true;
1163
- return JSON.stringify(a.views[view] ?? null) !== JSON.stringify(b.views[view] ?? null);
1196
+ return JSON.stringify(shapeViewContent(a.views, view) ?? null) !== JSON.stringify(shapeViewContent(b.views, view) ?? null);
1164
1197
  });
1165
1198
 
1166
1199
  const refsA = new Map((a.refs ?? []).map((r) => [r.n, JSON.stringify(r)]));
@@ -1204,6 +1237,24 @@ export interface ShapeNoteInput {
1204
1237
  part?: string | null;
1205
1238
  quote?: string | null;
1206
1239
  text: string;
1240
+ box?: ShapeNoteBox;
1241
+ code?: string[];
1242
+ /** A PNG as a data: URL; the store keeps it as a file. */
1243
+ picture?: string;
1244
+ }
1245
+
1246
+ const NOTE_PICTURE_RE = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/;
1247
+
1248
+ function checkNoteBox(c: Checker, input: unknown): ShapeNoteBox | undefined {
1249
+ if (input === undefined) return undefined;
1250
+ const b = isRecord(input) ? input : {};
1251
+ const at = [b.x, b.y, b.w, b.h];
1252
+ if (!at.every((n) => typeof n === 'number' && n >= 0 && n <= 1)) {
1253
+ c.fail('box', 'must be x, y, w and h, each from 0 to 1');
1254
+ return undefined;
1255
+ }
1256
+ const [x, y, w, h] = at as number[];
1257
+ return { x: x!, y: y!, w: w!, h: h!, ...(b.tall === true ? { tall: true as const } : {}), ...(b.more === true ? { more: true as const } : {}) };
1207
1258
  }
1208
1259
 
1209
1260
  /** Validate a person's Change it or Ask. No part: a general comment (on a view, or the whole board). */
@@ -1215,8 +1266,9 @@ export function checkShapeNoteInput(input: unknown): ShapeCheck<ShapeNoteInput>
1215
1266
  const v = c.int(input.v, 'v', 1, 1_000_000);
1216
1267
  let view: ShapeViewId | null = null;
1217
1268
  if (input.view !== undefined && input.view !== null) {
1218
- if (!SHAPE_VIEWS.some((x) => x.id === input.view)) c.fail('view', 'must be picture, how, reqs or code');
1219
- else view = input.view as ShapeViewId;
1269
+ const now = shapeViewNow(input.view);
1270
+ if (!SHAPE_VIEWS.some((x) => x.id === now)) c.fail('view', 'must be picture, how, know or code');
1271
+ else view = now as ShapeViewId;
1220
1272
  }
1221
1273
  let part: string | null = null;
1222
1274
  if (input.part !== undefined && input.part !== null) {
@@ -1226,6 +1278,70 @@ export function checkShapeNoteInput(input: unknown): ShapeCheck<ShapeNoteInput>
1226
1278
  if (part && !view) c.fail('view', 'is required with a part');
1227
1279
  const quote = c.text(input.quote, 'quote', SHAPE_LIMITS.quote, false) ?? null;
1228
1280
  const text = c.text(input.text, 'text', SHAPE_LIMITS.noteText);
1281
+ const box = checkNoteBox(c, input.box);
1282
+ if (box && !view) c.fail('view', 'is required with a box');
1283
+ const code = input.code === undefined ? undefined : Array.isArray(input.code) && input.code.length <= 40 && input.code.every((t) => typeof t === 'string' && parseShapeCodeTarget(t)) ? (input.code as string[]) : undefined;
1284
+ if (input.code !== undefined && !code) c.fail('code', 'must be up to 40 data-code places');
1285
+ const picture = input.picture === undefined ? undefined : typeof input.picture === 'string' && input.picture.length <= SHAPE_LIMITS.notePicture && NOTE_PICTURE_RE.test(input.picture) ? input.picture : undefined;
1286
+ if (input.picture !== undefined && !picture) c.fail('picture', 'must be a PNG data: URL within the limit');
1229
1287
  if (c.errors.length || !kind || !v || !text) return { ok: false, errors: c.errors };
1230
- return { ok: true, value: { kind, v, view, part, quote, text } };
1288
+ return { ok: true, value: { kind, v, view, part, quote, text, ...(box ? { box } : {}), ...(code ? { code } : {}), ...(picture ? { picture } : {}) } };
1289
+ }
1290
+
1291
+ /** The words a person reads on a drawing. */
1292
+ export function shapeDrawnWords(html: string): number {
1293
+ html = html.replace(/<title\b[^>]*>.*?<\/title>/gis, ' ');
1294
+ const text = html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ');
1295
+ return text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
1296
+ }
1297
+
1298
+ /** How much of an SVG's viewBox its boxes and words span. Null when it cannot tell (no viewBox, transforms, HTML). */
1299
+ function shapeDrawingSpan(html: string): { w: number; h: number } | null {
1300
+ const box = html.match(/<svg[^>]*viewBox="([-\d.]+)[ ,]+([-\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)"/);
1301
+ if (!box || !Number(box[3]) || !Number(box[4])) return null;
1302
+ const xs: number[] = [];
1303
+ const ys: number[] = [];
1304
+ for (const m of html.matchAll(/<(rect|text)\b([^>]*)>/g)) {
1305
+ const a = (k: string) => Number(m[2]?.match(new RegExp(`\\s${k}="([-\\d.]+)"`))?.[1] ?? NaN);
1306
+ if (![a('x'), a('y')].every(Number.isFinite)) continue;
1307
+ xs.push(a('x'), a('x') + (a('width') || 0));
1308
+ ys.push(a('y'), a('y') + (a('height') || 0));
1309
+ }
1310
+ if (xs.length < 4) return null;
1311
+ return { w: (Math.max(...xs) - Math.min(...xs)) / Number(box[3]), h: (Math.max(...ys) - Math.min(...ys)) / Number(box[4]) };
1312
+ }
1313
+
1314
+ /**
1315
+ * What `shape update` tells the agent about a stored board: how it will behave, not a verdict.
1316
+ * Each note says what to change. It never refuses: the board is stored either way.
1317
+ */
1318
+ export function shapeBoardNotes(board: Pick<ShapeBoard, 'views' | 'refs'>): string[] {
1319
+ const notes: string[] = [];
1320
+ const picture = board.views.picture;
1321
+ if (!picture) return notes;
1322
+ const words = shapeDrawnWords(picture.html);
1323
+ if (words > 240) notes.push(`The Picture has ${words} words. Read at a glance, about 200 is the most. Move the rest behind numbers (a ref's sub and images) or to views.know.`);
1324
+ if (picture.width === undefined) notes.push('The Picture has no width and height, so it scrolls like a page. Give it a fixed canvas: it then always fits one screen.');
1325
+ else if (!picture.tall) notes.push('The Picture has no tall drawing, so a phone pans it sideways. That works. For a board you expect on a phone, add views.picture.tall.');
1326
+ const cited = [...picture.html.matchAll(/\sdata-ref\s*=\s*["']?(\d{1,3})/g)].map((m) => Number(m[1]));
1327
+ const onPicture = (board.refs ?? []).filter((r) => cited.includes(r.n));
1328
+ if (!onPicture.length) notes.push('The Picture has no numbers. Put data-ref="n" on the things that have more behind them: the person hovers a number to see its picture and line.');
1329
+ const bare = onPicture.filter((r) => !r.sub && !r.images?.length && r.kind !== 'image').map((r) => r.n);
1330
+ if (bare.length === 1) notes.push(`Bubble ${bare[0]} shows only a title. Give that ref a sub (one line) or images (a small real picture).`);
1331
+ else if (bare.length) notes.push(`Bubbles ${bare.join(', ')} show only a title. Give those refs a sub (one line) or images (a small real picture).`);
1332
+ if (!/\sdata-mark\s*=/.test(picture.html)) notes.push('The Picture has no data-mark, so the person cannot point at a part. Put data-mark="id" on each part.');
1333
+ const seen = shapeDrawingSpan(picture.html);
1334
+ if (seen && (seen.w < 0.6 || seen.h < 0.6)) notes.push(`The drawing fills ${Math.round(seen.w * 100)}% of the canvas's width and ${Math.round(seen.h * 100)}% of its height. Spread it out, or make the viewBox fit it.`);
1335
+ // A name `shape update` has no icon, logo or drawn thing for (shared/shape/stamps.ts leaves it empty with data-missing),
1336
+ // in any drawing, the phone's and the more's too; each once.
1337
+ const how = board.views.how;
1338
+ for (const view of [picture, picture.tall, picture.more, how, how?.tall, how?.more])
1339
+ for (const m of (view?.html ?? '').matchAll(/\sdata-missing="([^"]*)"/g)) {
1340
+ const note = m[1]!.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
1341
+ if (!notes.includes(note)) notes.push(note);
1342
+ }
1343
+ // A real logo keeps its own colours: the kit's marks say so with data-brand.
1344
+ const colours = [picture.html.replace(/<svg data-brand\b.*?<\/svg>/gs, ''), picture.css ?? ''].join(' ').match(/#[0-9a-f]{3,8}\b(?=[^<>]*[;"'}])|\brgba?\(/gi);
1345
+ if (colours?.length) notes.push(`The Picture has ${colours.length} fixed colours (such as ${colours[0]}), which break in light or dark. Use var(--ink), var(--grey), var(--mark) and the rest.`);
1346
+ return notes;
1231
1347
  }
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { extname } from "node:path";
26
26
  import { isShapeId, type ShapeNote, type ShapeRecord, type ShapeSession, type ShapeVersion } from "./format.ts";
27
- import type { ShapeStore, ShapeSummary } from "./store.ts";
27
+ import { shapeCodeRoot, type ShapeStore, type ShapeSummary } from "./store.ts";
28
28
 
29
29
  export type ShapeRouteResult =
30
30
  | { status: number; body: unknown }
@@ -156,7 +156,7 @@ export async function handleShapeRoute(req: ShapeRouteRequest, deps: ShapeRouteD
156
156
  }
157
157
 
158
158
  if (sub === "code" && parts.length === 3 && deps.code) {
159
- const root = shape.project.root;
159
+ const root = shapeCodeRoot(shape.project);
160
160
  if (parts[2] === "file" && method === "GET") return deps.code.file(root, req.params.get("path"));
161
161
  if (parts[2] === "hover" && method === "POST") {
162
162
  let body: unknown;