@compilr-dev/sdk 0.18.10 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/canvas/assets.d.ts +99 -0
  2. package/dist/canvas/assets.js +111 -0
  3. package/dist/canvas/color.d.ts +85 -0
  4. package/dist/canvas/color.js +216 -0
  5. package/dist/canvas/index.d.ts +8 -0
  6. package/dist/canvas/index.js +16 -0
  7. package/dist/canvas/quality-checks.d.ts +75 -0
  8. package/dist/canvas/quality-checks.js +217 -0
  9. package/dist/canvas/ramp.d.ts +45 -0
  10. package/dist/canvas/ramp.js +98 -0
  11. package/dist/capabilities/packs.js +16 -2
  12. package/dist/platform/context.d.ts +16 -2
  13. package/dist/platform/context.js +13 -1
  14. package/dist/platform/repositories.d.ts +4 -0
  15. package/dist/platform/sqlite/canvas-repository.d.ts +9 -0
  16. package/dist/platform/sqlite/canvas-repository.js +52 -0
  17. package/dist/platform/sqlite/db.js +29 -0
  18. package/dist/platform/sqlite/schema.d.ts +1 -1
  19. package/dist/platform/sqlite/schema.js +1 -1
  20. package/dist/platform/tools/canvas-tools.d.ts +12 -2
  21. package/dist/platform/tools/canvas-tools.js +316 -6
  22. package/dist/platform/tools/image-tools.d.ts +9 -2
  23. package/dist/platform/tools/image-tools.js +4 -2
  24. package/dist/platform/tools/index.js +10 -2
  25. package/dist/platform/tools/project-tools.js +3 -2
  26. package/dist/skills/canvas-exemplars.d.ts +8 -0
  27. package/dist/skills/canvas-exemplars.js +36 -0
  28. package/dist/skills/canvas-icons.d.ts +6 -0
  29. package/dist/skills/canvas-icons.js +84 -0
  30. package/dist/skills/canvas-skills.js +207 -20
  31. package/dist/skills/platform-skills.d.ts +2 -0
  32. package/dist/skills/platform-skills.js +8 -0
  33. package/dist/team/tool-config.js +4 -0
  34. package/package.json +1 -1
@@ -0,0 +1,217 @@
1
+ import { parseColor, contrastRatio, isNeutral, hueAngle, AA_TEXT } from './color.js';
2
+ /** Every `style="…"` attribute body and every `{…}` CSS rule body, as separate scopes. */
3
+ function declarationScopes(html) {
4
+ const scopes = [];
5
+ for (const m of html.matchAll(/style\s*=\s*"([^"]*)"/gi))
6
+ scopes.push(m[1]);
7
+ for (const m of html.matchAll(/style\s*=\s*'([^']*)'/gi))
8
+ scopes.push(m[1]);
9
+ for (const m of html.matchAll(/\{([^{}]*)\}/g))
10
+ scopes.push(m[1]);
11
+ return scopes;
12
+ }
13
+ const declaration = (scope, property) => {
14
+ const m = property.exec(scope);
15
+ return m ? m[1].trim() : null;
16
+ };
17
+ const TEXT_COLOR = /(?:^|[;{\s])color\s*:\s*([^;}]+)/i;
18
+ const BACKGROUND = /(?:^|[;{\s])background(?:-color)?\s*:\s*([^;}]+)/i;
19
+ /**
20
+ * The custom properties a value actually USES — primaries only, never fallbacks.
21
+ *
22
+ * ⚠️ THIS EXISTS BECAUSE A SUBSTRING TEST GETS IT BACKWARDS. The handoff tells authors to
23
+ * write `var(--canvas-surface-1, var(--canvas-card))` so a canvas degrades on a host
24
+ * without the ramp, and to KEEP that fallback afterwards. A plain
25
+ * `/var\(--canvas-card/` test matches the fallback and fires the surface error on the
26
+ * exact corrected form the error message asks for — a validator that punishes compliance
27
+ * teaches an agent to ignore it. Caught by the negative test, not by reading the regex.
28
+ *
29
+ * So: walk to each `var(`, take the name up to the first comma, then skip the whole
30
+ * fallback by tracking paren depth.
31
+ */
32
+ export function primaryVarNames(value) {
33
+ const names = [];
34
+ for (let i = value.indexOf('var('); i !== -1; i = value.indexOf('var(', i)) {
35
+ let j = i + 4;
36
+ while (j < value.length && /\s/.test(value[j]))
37
+ j++;
38
+ let name = '';
39
+ while (j < value.length && /[-\w]/.test(value[j]))
40
+ name += value[j++];
41
+ if (name.startsWith('--'))
42
+ names.push(name);
43
+ // Skip to the matching close paren — the fallback is deliberately not scanned.
44
+ let depth = 1;
45
+ while (j < value.length && depth > 0) {
46
+ if (value[j] === '(')
47
+ depth++;
48
+ else if (value[j] === ')')
49
+ depth--;
50
+ j++;
51
+ }
52
+ i = j;
53
+ }
54
+ return names;
55
+ }
56
+ const usesVar = (value, name) => primaryVarNames(value).some((n) => n.toLowerCase() === name);
57
+ /**
58
+ * A card that shares its ground's value is a rectangle of text.
59
+ *
60
+ * ⚠️ This flags the TOKEN PAIRING, not a measured delta, and that is the whole point:
61
+ * whether `--canvas-card` is 0.0% or 2.1% from `--canvas-bg` depends on the user's
62
+ * theme, and it is under 4% on every palette anyone measured. The fix is the same in
63
+ * all of them — use the ramp — so the check needs no palette to be right.
64
+ */
65
+ export function checkSurfaceTokens(html) {
66
+ const usesCardAsSurface = declarationScopes(html).some((scope) => {
67
+ const bg = declaration(scope, BACKGROUND);
68
+ return bg !== null && usesVar(bg, '--canvas-card');
69
+ });
70
+ if (!usesCardAsSurface)
71
+ return [];
72
+ return [
73
+ {
74
+ level: 'error',
75
+ message: 'A card is filled with var(--canvas-card) over a var(--canvas-bg) ground. On a ' +
76
+ 'theme-derived palette those two sit within a few percent of each other, so the ' +
77
+ 'card disappears and only its border holds the layout together. Use the elevation ' +
78
+ 'ramp instead: background: var(--canvas-surface-1, var(--canvas-card)) for a raised ' +
79
+ 'card, var(--canvas-surface-2, var(--canvas-card)) for an inset area (tables, code), ' +
80
+ 'and border-color: var(--canvas-hairline, var(--canvas-border)). Keep the fallbacks ' +
81
+ 'so the canvas still renders on a host that has not shipped the ramp.',
82
+ },
83
+ ];
84
+ }
85
+ /** Near-white, for the "white on the accent" trap. */
86
+ function isNearWhite(value) {
87
+ if (/^\s*white\s*$/i.test(value))
88
+ return true;
89
+ const c = parseColor(value);
90
+ return c !== null && c.r > 0.85 && c.g > 0.85 && c.b > 0.85;
91
+ }
92
+ /**
93
+ * Text that cannot be read on the surface it actually sits on.
94
+ *
95
+ * Two halves, both palette-free:
96
+ * - a literal colour on a literal background, measured exactly;
97
+ * - white on `var(--canvas-accent)`, which is a pairing rather than a measurement —
98
+ * the accent is a mid-tone in every theme we ship (#FF5722 gives 3.2:1), so the pair
99
+ * is wrong whatever it resolves to.
100
+ */
101
+ export function checkTextContrast(html) {
102
+ const issues = [];
103
+ let whiteOnAccent = false;
104
+ const failures = [];
105
+ for (const scope of declarationScopes(html)) {
106
+ const fg = declaration(scope, TEXT_COLOR);
107
+ const bg = declaration(scope, BACKGROUND);
108
+ if (fg === null || bg === null)
109
+ continue;
110
+ if (usesVar(bg, '--canvas-accent') && isNearWhite(fg)) {
111
+ whiteOnAccent = true;
112
+ continue;
113
+ }
114
+ const fgc = parseColor(fg);
115
+ const bgc = parseColor(bg);
116
+ // ⚠️ A null here means a token or a keyword, NOT a failure — skip rather than guess.
117
+ if (!fgc || !bgc)
118
+ continue;
119
+ const ratio = contrastRatio(fgc, bgc);
120
+ if (ratio < AA_TEXT) {
121
+ failures.push(`${fg.trim()} on ${bg.trim()} — ${ratio.toFixed(1)}:1`);
122
+ }
123
+ }
124
+ if (whiteOnAccent) {
125
+ issues.push({
126
+ level: 'error',
127
+ message: 'White text on var(--canvas-accent). The accent is a mid-tone in every theme — ' +
128
+ 'on the default it is 3.2:1, below the 4.5:1 floor. Either put a dark ink label on ' +
129
+ 'the accent fill, or drop the fill and use the accent as the text colour on the ' +
130
+ 'page ground.',
131
+ });
132
+ }
133
+ if (failures.length) {
134
+ issues.push({
135
+ level: 'error',
136
+ message: `Text below the 4.5:1 floor against the surface it sits on: ${failures.slice(0, 4).join('; ')}` +
137
+ (failures.length > 4 ? ` (+${String(failures.length - 4)} more)` : '') +
138
+ '. Check a muted grey against the INSET surface, not the page — one that passes on ' +
139
+ 'white will fail on surface-2.',
140
+ });
141
+ }
142
+ return issues;
143
+ }
144
+ /** Hues within this many degrees are the same hue wearing two tints. */
145
+ const HUE_BUCKET = 30;
146
+ /**
147
+ * More hues than reasons.
148
+ *
149
+ * ⚠️ Greys never count. They are the ground a composition sits on, and a palette of six
150
+ * greys is a perfectly good canvas — what makes one look machine-made is four competing
151
+ * *hues* with nothing to distinguish. Warn, never error: multiple hues are legitimate
152
+ * when they encode categories a reader must tell apart.
153
+ */
154
+ export function checkHueCount(html) {
155
+ const buckets = new Set();
156
+ for (const m of html.matchAll(/#[0-9a-f]{3,8}\b|\b(?:rgb|hsl)a?\s*\([^)]*\)/gi)) {
157
+ const c = parseColor(m[0]);
158
+ if (!c || isNeutral(c))
159
+ continue;
160
+ buckets.add(Math.round(hueAngle(c) / HUE_BUCKET));
161
+ }
162
+ if (buckets.size <= 2)
163
+ return [];
164
+ return [
165
+ {
166
+ level: 'warn',
167
+ message: `${String(buckets.size)} distinct hues are hardcoded in this canvas. Count the hues, then ` +
168
+ 'count the reasons — if there are more hues than things a reader must tell apart, ' +
169
+ 'colour is decoration. One accent marking the finding, and greys for everything else.',
170
+ },
171
+ ];
172
+ }
173
+ /**
174
+ * A Tweak the user can drag that changes nothing.
175
+ *
176
+ * ⚠️ SKIPPED ENTIRELY WHEN THE CANVAS DEFINES `applyParams`. That is the host bridge's
177
+ * fourth binding path — a canvas can take the whole params object in JS and do what it
178
+ * likes with it, in which case no static reference to the param name need exist. Without
179
+ * this guard the check would report confident errors about a legitimate pattern, which is
180
+ * worse than not checking at all.
181
+ */
182
+ export function checkDeadTweaks(html, manifest) {
183
+ const controls = manifest?.controls ?? [];
184
+ if (controls.length === 0)
185
+ return [];
186
+ if (/\bapplyParams\s*[=:(]/.test(html))
187
+ return [];
188
+ const dead = controls
189
+ .map((c) => c.param)
190
+ .filter((param) => {
191
+ if (!param)
192
+ return false;
193
+ const safe = param.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
+ const bound = new RegExp(`var\\(\\s*--${safe}\\b|data-bind\\s*=\\s*["']${safe}["']|data-show\\s*=\\s*["']${safe}["']`, 'i');
195
+ return !bound.test(html);
196
+ });
197
+ if (dead.length === 0)
198
+ return [];
199
+ return [
200
+ {
201
+ level: 'error',
202
+ message: `Tweak${dead.length > 1 ? 's' : ''} declared but bound to nothing: ${dead.join(', ')}. ` +
203
+ 'A control the user can drag that changes nothing reads as a broken canvas. Either ' +
204
+ 'reference it — var(--param) in CSS, data-bind="param" for text, data-show="param" ' +
205
+ 'to toggle — or remove it from the manifest.',
206
+ },
207
+ ];
208
+ }
209
+ /** Every quality check, in the order their messages should be read. */
210
+ export function runQualityChecks(html, manifest) {
211
+ return [
212
+ ...checkSurfaceTokens(html),
213
+ ...checkTextContrast(html),
214
+ ...checkHueCount(html),
215
+ ...checkDeadTweaks(html, manifest),
216
+ ];
217
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The surface ramp the host injects.
3
+ *
4
+ * ⚠️ WHY THE HOST AND NOT CSS. A single `color-mix` cannot serve both polarities: a light
5
+ * theme's ground has no headroom above it (Catppuccin Latte sits at ~95% lightness, so a
6
+ * card 6% "above" it would be past white), while a dark theme's has plenty. CSS cannot
7
+ * branch on a palette's polarity. Whatever computes this has to know the luminance, so it
8
+ * lives here — pure, testable, and shared with the validator so the two cannot disagree
9
+ * about what "5–7% apart" means.
10
+ *
11
+ * ⚠️ THE GROUND MOVES ONLY WHEN IT MUST. The handoff's worked examples darken the ground
12
+ * in every case; this does it only when there is no room for the raised surface, so a
13
+ * dark theme's canvas keeps exactly the background its theme specifies and nothing about
14
+ * an existing canvas shifts. Latte has no room, so it darkens — there is no alternative
15
+ * that also keeps the card raised, which is the point of the ramp.
16
+ */
17
+ import { type Rgb } from './color.js';
18
+ export interface SurfaceRamp {
19
+ /** The page ground. Equal to the input unless there was no room for the ramp. */
20
+ bg: string;
21
+ /** Raised — cards. */
22
+ surface1: string;
23
+ /** Inset — tables, code, an app screen's work area. */
24
+ surface2: string;
25
+ /** 1px borders. */
26
+ hairline: string;
27
+ }
28
+ /**
29
+ * The same colour at a different perceived lightness.
30
+ *
31
+ * ⚠️ Binary search over a mix toward white or black rather than an OKLab inverse. It
32
+ * keeps the hue of the ground (a tinted terminal ground stays tinted, which is most of
33
+ * why these palettes look like themselves) and cannot produce an out-of-gamut value,
34
+ * which an inverse transform can.
35
+ */
36
+ export declare function withLightness(color: Rgb, targetL: number): Rgb;
37
+ /**
38
+ * Derive the three ramp tokens from a theme's ground and foreground.
39
+ *
40
+ * Raised is always lighter and inset always darker, in both polarities — a card that
41
+ * recedes from its page does not read as a card. When the ground cannot support both
42
+ * steps, the ground itself is moved just far enough to make room, and the returned `bg`
43
+ * says so.
44
+ */
45
+ export declare function deriveSurfaceRamp(bgInput: string, fgInput: string): SurfaceRamp | null;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The surface ramp the host injects.
3
+ *
4
+ * ⚠️ WHY THE HOST AND NOT CSS. A single `color-mix` cannot serve both polarities: a light
5
+ * theme's ground has no headroom above it (Catppuccin Latte sits at ~95% lightness, so a
6
+ * card 6% "above" it would be past white), while a dark theme's has plenty. CSS cannot
7
+ * branch on a palette's polarity. Whatever computes this has to know the luminance, so it
8
+ * lives here — pure, testable, and shared with the validator so the two cannot disagree
9
+ * about what "5–7% apart" means.
10
+ *
11
+ * ⚠️ THE GROUND MOVES ONLY WHEN IT MUST. The handoff's worked examples darken the ground
12
+ * in every case; this does it only when there is no room for the raised surface, so a
13
+ * dark theme's canvas keeps exactly the background its theme specifies and nothing about
14
+ * an existing canvas shifts. Latte has no room, so it darkens — there is no alternative
15
+ * that also keeps the card raised, which is the point of the ramp.
16
+ */
17
+ import { parseColor, oklabLightness, SURFACE_DELTA_TARGET, HAIRLINE_MIN_PCT, HAIRLINE_MAX_PCT, } from './color.js';
18
+ const hex = (n) => Math.round(Math.min(1, Math.max(0, n)) * 255)
19
+ .toString(16)
20
+ .padStart(2, '0');
21
+ const toHex = ({ r, g, b }) => `#${hex(r)}${hex(g)}${hex(b)}`;
22
+ const mix = (a, b, t) => ({
23
+ r: a.r + (b.r - a.r) * t,
24
+ g: a.g + (b.g - a.g) * t,
25
+ b: a.b + (b.b - a.b) * t,
26
+ });
27
+ const WHITE = { r: 1, g: 1, b: 1 };
28
+ const BLACK = { r: 0, g: 0, b: 0 };
29
+ /**
30
+ * The same colour at a different perceived lightness.
31
+ *
32
+ * ⚠️ Binary search over a mix toward white or black rather than an OKLab inverse. It
33
+ * keeps the hue of the ground (a tinted terminal ground stays tinted, which is most of
34
+ * why these palettes look like themselves) and cannot produce an out-of-gamut value,
35
+ * which an inverse transform can.
36
+ */
37
+ export function withLightness(color, targetL) {
38
+ const current = oklabLightness(color);
39
+ if (Math.abs(current - targetL) < 0.001)
40
+ return color;
41
+ const toward = targetL > current ? WHITE : BLACK;
42
+ let lo = 0;
43
+ let hi = 1;
44
+ let out = color;
45
+ for (let i = 0; i < 24; i++) {
46
+ const mid = (lo + hi) / 2;
47
+ out = mix(color, toward, mid);
48
+ const l = oklabLightness(out);
49
+ if (Math.abs(l - targetL) < 0.0005)
50
+ break;
51
+ if (targetL > current ? l < targetL : l > targetL)
52
+ lo = mid;
53
+ else
54
+ hi = mid;
55
+ }
56
+ return out;
57
+ }
58
+ /**
59
+ * Derive the three ramp tokens from a theme's ground and foreground.
60
+ *
61
+ * Raised is always lighter and inset always darker, in both polarities — a card that
62
+ * recedes from its page does not read as a card. When the ground cannot support both
63
+ * steps, the ground itself is moved just far enough to make room, and the returned `bg`
64
+ * says so.
65
+ */
66
+ export function deriveSurfaceRamp(bgInput, fgInput) {
67
+ const bg = parseColor(bgInput);
68
+ const fg = parseColor(fgInput);
69
+ if (!bg || !fg)
70
+ return null;
71
+ const step = SURFACE_DELTA_TARGET / 100;
72
+ let ground = oklabLightness(bg);
73
+ /*
74
+ Keep both steps inside the gamut by sliding the ground, never by shrinking the step —
75
+ a 3% ramp is the defect this exists to fix, so the separation is the invariant and the
76
+ ground's exact value is not.
77
+ */
78
+ const headroom = 1 - (ground + step);
79
+ const footroom = ground - step;
80
+ if (headroom < 0)
81
+ ground += headroom;
82
+ else if (footroom < 0)
83
+ ground -= footroom;
84
+ const groundRgb = withLightness(bg, ground);
85
+ /*
86
+ The hairline sits a fixed fraction of the way toward the foreground. Terminal palettes
87
+ derive their border from a comment or selection colour, which lands 35–100% of the way
88
+ there — that is the "hard borders" complaint, and it is why this ignores
89
+ --canvas-border entirely rather than trying to soften it.
90
+ */
91
+ const hairlineMix = (HAIRLINE_MIN_PCT + HAIRLINE_MAX_PCT) / 2 / 100;
92
+ return {
93
+ bg: toHex(groundRgb),
94
+ surface1: toHex(withLightness(groundRgb, ground + step)),
95
+ surface2: toHex(withLightness(groundRgb, ground - step)),
96
+ hairline: toHex(mix(groundRgb, fg, hairlineMix)),
97
+ };
98
+ }
@@ -244,11 +244,25 @@ export const CAPABILITY_PACKS = {
244
244
  'canvas_delete',
245
245
  'canvas_validate',
246
246
  'canvas_screenshot',
247
+ 'canvas_guide',
248
+ 'canvas_asset_add',
249
+ 'canvas_asset_list',
250
+ 'canvas_asset_delete',
247
251
  ],
248
252
  readOnly: false,
249
253
  promptModules: ['platform-tool-hints'],
250
- promptSnippet: 'Visual canvases (infographic/carousel/board) rendered from sandboxed HTML/SVG. Create/replace with canvas_write (raw HTML only — host injects the CSP; inline <style>/<script> only, no network/remote assets; optional Tweaks controls manifest). EDIT via canvas_get (outline=true or startLine/maxLines) then canvas_edit (str_replace/append/prepend) — never re-send the whole document. After authoring, canvas_validate (static) then canvas_screenshot to SEE the render (vision models) — fix overflow/readability with canvas_edit.',
251
- estimatedPromptTokens: 150,
254
+ promptSnippet: 'Visual canvases (infographic/carousel/board) rendered from sandboxed HTML/SVG. ' +
255
+ // ⚠️ FIRST, and stated as a requirement rather than a suggestion. The craft guides
256
+ // used to be reachable only when a user typed /canvas, so most canvases were
257
+ // authored with no design guidance at all — see canvas_guide.
258
+ 'START by calling canvas_guide("canvas") — the design rules live there, not here, and a ' +
259
+ 'canvas authored without them renders correctly and still reads as generic. Then ' +
260
+ 'canvas_guide on the type you pick. ' +
261
+ // ⚠️ Real images go in as ASSETS, never as base64 in the markup.
262
+ 'When the user has supplied real images, put them in with canvas_asset_add and reference ' +
263
+ 'them as <img src="asset:REF"> — never paste base64, never draw a grey box instead. ' +
264
+ 'Create/replace with canvas_write (raw HTML only — host injects the CSP; inline <style>/<script> only, no network/remote assets; optional Tweaks controls manifest). EDIT via canvas_get (outline=true or startLine/maxLines) then canvas_edit (str_replace/append/prepend) — never re-send the whole document. After authoring, canvas_validate (static) then canvas_screenshot to SEE the render (vision models) — fix overflow/readability with canvas_edit.',
265
+ estimatedPromptTokens: 190,
252
266
  estimatedToolTokens: 1500,
253
267
  },
254
268
  plans: {
@@ -45,8 +45,22 @@ export interface PlatformHooks {
45
45
  export interface PlatformToolsConfig {
46
46
  /** Data access layer */
47
47
  context: PlatformContext;
48
- /** Working directory override (defaults to process.cwd()) */
49
- cwd?: string;
48
+ /**
49
+ * Working directory override (defaults to process.cwd()).
50
+ * Accepts a getter, because platform tools are memoised and a host's active
51
+ * project can change after they are built.
52
+ */
53
+ cwd?: string | (() => string | undefined | null);
50
54
  /** Optional hooks for CLI-specific side effects */
51
55
  hooks?: PlatformHooks;
52
56
  }
57
+ /**
58
+ * Resolve a `cwd` that may be a value or a getter.
59
+ *
60
+ * ⚠️ Platform tools are built once and memoised by every host, so a plain string is
61
+ * captured at construction. In Desktop that meant relative paths resolved against
62
+ * whichever project was open when the tools were first requested — for the rest of the
63
+ * session, across every project switch. Hosts whose working directory moves pass a
64
+ * getter; this is the one place that unwraps it.
65
+ */
66
+ export declare function resolveCwd(cwd?: string | (() => string | undefined | null)): string;
@@ -4,4 +4,16 @@
4
4
  * Provides a single entry point for data access, abstracting over the
5
5
  * underlying storage backend (SQLite in CLI, PostgreSQL in web/API).
6
6
  */
7
- export {};
7
+ /**
8
+ * Resolve a `cwd` that may be a value or a getter.
9
+ *
10
+ * ⚠️ Platform tools are built once and memoised by every host, so a plain string is
11
+ * captured at construction. In Desktop that meant relative paths resolved against
12
+ * whichever project was open when the tools were first requested — for the rest of the
13
+ * session, across every project switch. Hosts whose working directory moves pass a
14
+ * getter; this is the one place that unwraps it.
15
+ */
16
+ export function resolveCwd(cwd) {
17
+ const value = typeof cwd === 'function' ? cwd() : cwd;
18
+ return value ?? process.cwd();
19
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { Project, WorkItem, WorkItemComment, ProjectDocument, Plan, PlanSummary, PlanWithWorkItem, HistoryEntry, CreateProjectInput, UpdateProjectInput, ProjectListOptions, CreateWorkItemInput, UpdateWorkItemInput, QueryWorkItemsInput, CreateCommentInput, UpdateCommentInput, CreateDocumentInput, UpdateDocumentInput, CreatePlanInput, UpdatePlanInput, ListPlansOptions, WorkItemQueryResult, ProjectListResult, BulkCreateItem, ProjectStatus, WorkItemType, WorkItemStatus, DocumentType, PlanStatus } from './types.js';
9
9
  import type { CanvasRecord, CanvasSummary, CreateCanvasInput, UpdateCanvasInput } from '../canvas/types.js';
10
+ import type { CanvasAsset, CreateCanvasAssetInput } from '../canvas/assets.js';
10
11
  export interface IProjectRepository {
11
12
  create(input: CreateProjectInput): Promise<Project>;
12
13
  getById(id: number): Promise<Project | null>;
@@ -53,6 +54,9 @@ export interface ICanvasRepository {
53
54
  listByProject(projectId: number): Promise<CanvasSummary[]>;
54
55
  update(id: number, input: UpdateCanvasInput): Promise<CanvasRecord | null>;
55
56
  delete(id: number): Promise<boolean>;
57
+ addAsset?(input: CreateCanvasAssetInput): Promise<CanvasAsset>;
58
+ listAssets?(canvasId: number): Promise<CanvasAsset[]>;
59
+ deleteAsset?(canvasId: number, ref: string): Promise<boolean>;
56
60
  }
57
61
  export interface IPlanRepository {
58
62
  create(input: CreatePlanInput): Promise<Plan>;
@@ -5,6 +5,7 @@
5
5
  * SQL reserved word, so it is always quoted as "values" in statements.
6
6
  */
7
7
  import type Database from 'better-sqlite3';
8
+ import type { CanvasAsset, CreateCanvasAssetInput } from '../../canvas/assets.js';
8
9
  import type { ICanvasRepository } from '../repositories.js';
9
10
  import type { CanvasRecord, CanvasSummary, CreateCanvasInput, UpdateCanvasInput } from '../../canvas/types.js';
10
11
  export declare class SQLiteCanvasRepository implements ICanvasRepository {
@@ -15,4 +16,12 @@ export declare class SQLiteCanvasRepository implements ICanvasRepository {
15
16
  listByProject(projectId: number): Promise<CanvasSummary[]>;
16
17
  update(id: number, input: UpdateCanvasInput): Promise<CanvasRecord | null>;
17
18
  delete(id: number): Promise<boolean>;
19
+ /**
20
+ * ⚠️ UPSERT on (canvas_id, ref). Re-adding the same handle REPLACES the image rather
21
+ * than failing on the unique index — an agent correcting a photo should not have to
22
+ * delete first, and a half-applied fix is worse than an overwrite.
23
+ */
24
+ addAsset(input: CreateCanvasAssetInput): Promise<CanvasAsset>;
25
+ listAssets(canvasId: number): Promise<CanvasAsset[]>;
26
+ deleteAsset(canvasId: number, ref: string): Promise<boolean>;
18
27
  }
@@ -92,4 +92,56 @@ export class SQLiteCanvasRepository {
92
92
  const result = this.db.prepare('DELETE FROM canvases WHERE id = ?').run(id);
93
93
  return Promise.resolve(result.changes > 0);
94
94
  }
95
+ // ---------------------------------------------------------------------------
96
+ // Assets — images stored beside the canvas, referenced from its markup as
97
+ // `asset:<ref>` and substituted by the host at render and export.
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * ⚠️ UPSERT on (canvas_id, ref). Re-adding the same handle REPLACES the image rather
101
+ * than failing on the unique index — an agent correcting a photo should not have to
102
+ * delete first, and a half-applied fix is worse than an overwrite.
103
+ */
104
+ addAsset(input) {
105
+ this.db
106
+ .prepare(`INSERT INTO canvas_assets (canvas_id, ref, media_type, data, width, height, bytes, source_path)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
108
+ ON CONFLICT(canvas_id, ref) DO UPDATE SET
109
+ media_type = excluded.media_type,
110
+ data = excluded.data,
111
+ width = excluded.width,
112
+ height = excluded.height,
113
+ bytes = excluded.bytes,
114
+ source_path = excluded.source_path`)
115
+ .run(input.canvasId, input.ref, input.mediaType, input.data, input.width ?? null, input.height ?? null, input.bytes, input.sourcePath ?? null);
116
+ const row = this.db
117
+ .prepare('SELECT * FROM canvas_assets WHERE canvas_id = ? AND ref = ?')
118
+ .get(input.canvasId, input.ref);
119
+ return Promise.resolve(toAsset(row));
120
+ }
121
+ listAssets(canvasId) {
122
+ const rows = this.db
123
+ .prepare('SELECT * FROM canvas_assets WHERE canvas_id = ? ORDER BY id')
124
+ .all(canvasId);
125
+ return Promise.resolve(rows.map(toAsset));
126
+ }
127
+ deleteAsset(canvasId, ref) {
128
+ const result = this.db
129
+ .prepare('DELETE FROM canvas_assets WHERE canvas_id = ? AND ref = ?')
130
+ .run(canvasId, ref);
131
+ return Promise.resolve(result.changes > 0);
132
+ }
133
+ }
134
+ function toAsset(row) {
135
+ return {
136
+ id: row.id,
137
+ canvasId: row.canvas_id,
138
+ ref: row.ref,
139
+ mediaType: row.media_type,
140
+ data: row.data,
141
+ width: row.width ?? undefined,
142
+ height: row.height ?? undefined,
143
+ bytes: row.bytes,
144
+ sourcePath: row.source_path ?? undefined,
145
+ createdAt: new Date(row.created_at),
146
+ };
95
147
  }
@@ -180,4 +180,33 @@ function runMigrations(db, fromVersion, toVersion) {
180
180
  db.exec(`ALTER TABLE canvases ADD COLUMN page_size TEXT;`);
181
181
  db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(9);
182
182
  }
183
+ if (fromVersion < 10 && toVersion >= 10) {
184
+ /*
185
+ Canvas assets — images stored BESIDE a canvas rather than inside it.
186
+
187
+ ⚠️ The markup references `asset:<ref>` and the host substitutes a data URI at render
188
+ and at export. Inlining base64 into `content` would also render (the CSP allows
189
+ data:) but would put hundreds of KB in the middle of a document agents read as an
190
+ outline and patch with str_replace — the editing discipline that keeps canvas
191
+ authoring reliable stops working at that size.
192
+ */
193
+ db.exec(`
194
+ CREATE TABLE IF NOT EXISTS canvas_assets (
195
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
196
+ canvas_id INTEGER NOT NULL,
197
+ ref TEXT NOT NULL,
198
+ media_type TEXT NOT NULL,
199
+ data TEXT NOT NULL,
200
+ width INTEGER,
201
+ height INTEGER,
202
+ bytes INTEGER NOT NULL,
203
+ source_path TEXT,
204
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
205
+ FOREIGN KEY (canvas_id) REFERENCES canvases(id) ON DELETE CASCADE
206
+ );
207
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_canvas_assets_ref
208
+ ON canvas_assets(canvas_id, ref);
209
+ `);
210
+ db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(10);
211
+ }
183
212
  }
@@ -4,7 +4,7 @@
4
4
  * Shared between CLI and Desktop — both access ~/.compilr-dev/projects.db
5
5
  * Schema version must be kept in sync across all consumers.
6
6
  */
7
- export declare const SCHEMA_VERSION = 9;
7
+ export declare const SCHEMA_VERSION = 10;
8
8
  export declare const SCHEMA_SQL = "\n-- Schema version tracking\nCREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Projects table\nCREATE TABLE IF NOT EXISTS projects (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT UNIQUE NOT NULL,\n display_name TEXT NOT NULL,\n description TEXT,\n type TEXT DEFAULT 'general',\n status TEXT DEFAULT 'active',\n path TEXT NOT NULL,\n docs_path TEXT,\n repo_pattern TEXT DEFAULT 'single',\n language TEXT,\n framework TEXT,\n package_manager TEXT,\n runtime_version TEXT,\n commands TEXT,\n git_remote TEXT,\n git_branch TEXT DEFAULT 'main',\n workflow_mode TEXT DEFAULT 'flexible',\n lifecycle_state TEXT DEFAULT 'setup',\n current_item_id TEXT,\n last_context TEXT,\n metadata TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_activity_at DATETIME\n);\n\n-- Work items (backlog items, tasks, bugs)\nCREATE TABLE IF NOT EXISTS work_items (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n item_number INTEGER NOT NULL,\n item_id TEXT NOT NULL,\n type TEXT NOT NULL,\n status TEXT DEFAULT 'backlog',\n priority TEXT DEFAULT 'medium',\n guided_step TEXT,\n owner TEXT,\n title TEXT NOT NULL,\n description TEXT,\n estimated_effort TEXT,\n actual_minutes INTEGER,\n completed_at DATETIME,\n completed_by TEXT,\n commit_hash TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,\n UNIQUE (project_id, item_id)\n);\n\n-- Project documents (PRD, architecture, plans, etc.)\nCREATE TABLE IF NOT EXISTS project_documents (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n doc_type TEXT NOT NULL,\n title TEXT NOT NULL,\n content TEXT NOT NULL,\n status TEXT,\n work_item_id INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,\n FOREIGN KEY (work_item_id) REFERENCES work_items(id) ON DELETE SET NULL\n);\n\n-- Work item history (audit trail)\nCREATE TABLE IF NOT EXISTS work_item_history (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n work_item_id INTEGER NOT NULL,\n project_id INTEGER NOT NULL,\n action TEXT NOT NULL,\n old_value TEXT,\n new_value TEXT,\n notes TEXT,\n changed_by TEXT,\n changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (work_item_id) REFERENCES work_items(id) ON DELETE CASCADE,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\n\n-- Indexes\nCREATE INDEX IF NOT EXISTS idx_projects_path ON projects(path);\nCREATE INDEX IF NOT EXISTS idx_projects_docs_path ON projects(docs_path);\nCREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);\nCREATE INDEX IF NOT EXISTS idx_work_items_project ON work_items(project_id);\nCREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status);\nCREATE INDEX IF NOT EXISTS idx_work_items_priority ON work_items(priority);\nCREATE INDEX IF NOT EXISTS idx_work_items_owner ON work_items(owner);\nCREATE INDEX IF NOT EXISTS idx_project_documents_project ON project_documents(project_id);\nCREATE INDEX IF NOT EXISTS idx_project_documents_type ON project_documents(doc_type);\nCREATE INDEX IF NOT EXISTS idx_project_documents_status ON project_documents(status);\nCREATE INDEX IF NOT EXISTS idx_project_documents_work_item ON project_documents(work_item_id);\nCREATE INDEX IF NOT EXISTS idx_work_item_history_item ON work_item_history(work_item_id);\n\n-- Terminal sessions (multi-terminal awareness)\nCREATE TABLE IF NOT EXISTS terminal_sessions (\n id TEXT PRIMARY KEY,\n project_id INTEGER,\n pid INTEGER NOT NULL,\n tty_path TEXT,\n label TEXT,\n started_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_heartbeat DATETIME DEFAULT CURRENT_TIMESTAMP,\n active_agent TEXT DEFAULT 'default',\n agents_json TEXT DEFAULT '[]',\n status TEXT DEFAULT 'active',\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL\n);\nCREATE INDEX IF NOT EXISTS idx_terminal_sessions_project ON terminal_sessions(project_id);\nCREATE INDEX IF NOT EXISTS idx_terminal_sessions_status ON terminal_sessions(status);\n\n-- File locks (multi-terminal file lock awareness)\nCREATE TABLE IF NOT EXISTS file_locks (\n path TEXT NOT NULL,\n project_id INTEGER NOT NULL,\n session_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n locked_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (path, project_id),\n FOREIGN KEY (session_id) REFERENCES terminal_sessions(id) ON DELETE CASCADE,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS idx_file_locks_session ON file_locks(session_id);\n\n-- Session notifications (cross-session notifications)\nCREATE TABLE IF NOT EXISTS session_notifications (\n id TEXT PRIMARY KEY,\n project_id INTEGER NOT NULL,\n from_session_id TEXT NOT NULL,\n to_session_id TEXT,\n type TEXT NOT NULL,\n title TEXT NOT NULL,\n message TEXT,\n payload_json TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n read_at DATETIME,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,\n FOREIGN KEY (from_session_id) REFERENCES terminal_sessions(id) ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS idx_session_notifications_project ON session_notifications(project_id);\nCREATE INDEX IF NOT EXISTS idx_session_notifications_to_session ON session_notifications(to_session_id);\nCREATE INDEX IF NOT EXISTS idx_session_notifications_unread ON session_notifications(read_at);\n\n-- Work item comments\nCREATE TABLE IF NOT EXISTS work_item_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n work_item_id INTEGER NOT NULL,\n project_id INTEGER NOT NULL,\n author TEXT NOT NULL,\n content TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (work_item_id) REFERENCES work_items(id) ON DELETE CASCADE,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS idx_work_item_comments_work_item ON work_item_comments(work_item_id);\nCREATE INDEX IF NOT EXISTS idx_work_item_comments_project ON work_item_comments(project_id);\n\n-- Canvases (visual reasoning surfaces: infographic / carousel / board)\nCREATE TABLE IF NOT EXISTS canvases (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id INTEGER NOT NULL,\n type TEXT NOT NULL,\n title TEXT NOT NULL,\n content TEXT NOT NULL,\n controls TEXT NOT NULL DEFAULT '{\"controls\":[]}',\n \"values\" TEXT NOT NULL DEFAULT '{}',\n page_size TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS idx_canvases_project ON canvases(project_id);\n";
9
9
  export interface ProjectRecord {
10
10
  id: number;
@@ -4,7 +4,7 @@
4
4
  * Shared between CLI and Desktop — both access ~/.compilr-dev/projects.db
5
5
  * Schema version must be kept in sync across all consumers.
6
6
  */
7
- export const SCHEMA_VERSION = 9;
7
+ export const SCHEMA_VERSION = 10;
8
8
  export const SCHEMA_SQL = `
9
9
  -- Schema version tracking
10
10
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -10,6 +10,7 @@
10
10
  * manifest (validated here). Persistence is delegated to ctx.canvases
11
11
  * (ICanvasRepository); the current project is resolved like the document tools.
12
12
  */
13
+ import type { ImageResizer } from './image-tools.js';
13
14
  import type { PlatformToolsConfig } from '../context.js';
14
15
  import type { CanvasType, ControlManifest } from '../../canvas/types.js';
15
16
  /**
@@ -29,8 +30,10 @@ export interface CanvasIssue {
29
30
  message: string;
30
31
  }
31
32
  /** Run deterministic static checks on canvas content. No rendering. */
32
- export declare function runCanvasChecks(html: string, type: CanvasType): CanvasIssue[];
33
- export declare function createCanvasTools(config: PlatformToolsConfig): (import("@compilr-dev/agents").Tool<{
33
+ export declare function runCanvasChecks(html: string, type: CanvasType, manifest?: ControlManifest): CanvasIssue[];
34
+ export declare function createCanvasTools(config: PlatformToolsConfig, imageConfig?: {
35
+ resizer?: ImageResizer;
36
+ }): (import("@compilr-dev/agents").Tool<{
34
37
  type: string;
35
38
  title: string;
36
39
  html: string;
@@ -50,4 +53,11 @@ export declare function createCanvasTools(config: PlatformToolsConfig): (import(
50
53
  new_str?: string;
51
54
  content?: string;
52
55
  replace_all?: boolean;
56
+ }> | import("@compilr-dev/agents").Tool<{
57
+ topic: string;
58
+ }> | import("@compilr-dev/agents").Tool<{
59
+ canvas_id: number;
60
+ path: string;
61
+ ref: string;
62
+ max_width?: number;
53
63
  }>)[];