@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.
- package/generated/config.ts +33 -1
- package/generated/diff-files.ts +13 -0
- package/generated/guide-viewer-manifest.ts +4 -4
- package/generated/inbox/agent-link.ts +31 -16
- package/generated/inbox/connection.ts +26 -0
- package/generated/open-in-apps.ts +10 -0
- package/generated/question-block.ts +11 -2
- package/generated/review-core.ts +32 -19
- package/generated/review-skill-loader.ts +23 -2
- package/generated/shape/core.ts +190 -74
- package/generated/shape/routes.ts +2 -2
- package/generated/shape/store.ts +147 -48
- package/package.json +1 -1
- package/plannotator.html +335 -335
- package/release.json +1 -1
- package/review-editor.html +451 -441
package/generated/shape/core.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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' | '
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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 (
|
|
759
|
-
c.fail(field, 'must be
|
|
809
|
+
if (typeof value !== 'string') {
|
|
810
|
+
c.fail(field, 'must be a string of Markdown');
|
|
760
811
|
return undefined;
|
|
761
812
|
}
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
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 (
|
|
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: '
|
|
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
|
|
1152
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1219
|
-
|
|
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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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
|
|
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
|
|
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;
|