@1agh/maude 0.45.1 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// feature-2-print-artboards T1 — the single source for print unit conversion
|
|
2
|
+
// + paper presets.
|
|
3
|
+
//
|
|
4
|
+
// Authoring stays at CSS px @96dpi (DDR-027 — mm never enters JSX geometry).
|
|
5
|
+
// This module is the ONLY place mm/in ↔ px conversion happens: the on-canvas
|
|
6
|
+
// overlay (T3), the Inspector print knobs (T2), and both exporters (T4/T5)
|
|
7
|
+
// all import from here. Two mm→px sites would let the on-canvas bleed
|
|
8
|
+
// silently desync from the exported PDF — see the plan's T1 gotcha.
|
|
9
|
+
|
|
10
|
+
export const CSS_DPI = 96;
|
|
11
|
+
export const MM_PER_INCH = 25.4;
|
|
12
|
+
export const PT_PER_INCH = 72;
|
|
13
|
+
|
|
14
|
+
export function mmToPx(mm: number, dpi: number = CSS_DPI): number {
|
|
15
|
+
return (mm * dpi) / MM_PER_INCH;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inToPx(inches: number, dpi: number = CSS_DPI): number {
|
|
19
|
+
return inches * dpi;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function pxToMm(px: number, dpi: number = CSS_DPI): number {
|
|
23
|
+
return (px * MM_PER_INCH) / dpi;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pxToIn(px: number, dpi: number = CSS_DPI): number {
|
|
27
|
+
return px / dpi;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** px → pt (1pt = 1/72in) — the unit pdf-lib's PDFPage boxes expect. */
|
|
31
|
+
export function pxToPt(px: number, dpi: number = CSS_DPI): number {
|
|
32
|
+
return (px / dpi) * PT_PER_INCH;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mmToPt(mm: number): number {
|
|
36
|
+
return (mm / MM_PER_INCH) * PT_PER_INCH;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PaperUnit = 'mm' | 'in';
|
|
40
|
+
export type PaperCategory = 'iso' | 'us' | 'envelope' | 'card' | 'poster';
|
|
41
|
+
|
|
42
|
+
export interface PaperPreset {
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
category: PaperCategory;
|
|
46
|
+
unit: PaperUnit;
|
|
47
|
+
/** Portrait width × height, in `unit`. */
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const PAPER_PRESETS: readonly PaperPreset[] = [
|
|
53
|
+
// ISO A series (mm)
|
|
54
|
+
{ id: 'a6', label: 'A6', category: 'iso', unit: 'mm', width: 105, height: 148 },
|
|
55
|
+
{ id: 'a5', label: 'A5', category: 'iso', unit: 'mm', width: 148, height: 210 },
|
|
56
|
+
{ id: 'a4', label: 'A4', category: 'iso', unit: 'mm', width: 210, height: 297 },
|
|
57
|
+
{ id: 'a3', label: 'A3', category: 'iso', unit: 'mm', width: 297, height: 420 },
|
|
58
|
+
{ id: 'a2', label: 'A2', category: 'iso', unit: 'mm', width: 420, height: 594 },
|
|
59
|
+
{ id: 'a1', label: 'A1', category: 'iso', unit: 'mm', width: 594, height: 841 },
|
|
60
|
+
{ id: 'a0', label: 'A0', category: 'iso', unit: 'mm', width: 841, height: 1189 },
|
|
61
|
+
// US (in)
|
|
62
|
+
{ id: 'letter', label: 'Letter', category: 'us', unit: 'in', width: 8.5, height: 11 },
|
|
63
|
+
{ id: 'legal', label: 'Legal', category: 'us', unit: 'in', width: 8.5, height: 14 },
|
|
64
|
+
{ id: 'tabloid', label: 'Tabloid', category: 'us', unit: 'in', width: 11, height: 17 },
|
|
65
|
+
// Envelopes
|
|
66
|
+
{ id: 'dl', label: 'DL envelope', category: 'envelope', unit: 'mm', width: 110, height: 220 },
|
|
67
|
+
{ id: 'c5', label: 'C5 envelope', category: 'envelope', unit: 'mm', width: 162, height: 229 },
|
|
68
|
+
// Business cards
|
|
69
|
+
{
|
|
70
|
+
id: 'card-eu',
|
|
71
|
+
label: 'Business card (EU)',
|
|
72
|
+
category: 'card',
|
|
73
|
+
unit: 'mm',
|
|
74
|
+
width: 85,
|
|
75
|
+
height: 55,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'card-us',
|
|
79
|
+
label: 'Business card (US)',
|
|
80
|
+
category: 'card',
|
|
81
|
+
unit: 'in',
|
|
82
|
+
width: 3.5,
|
|
83
|
+
height: 2,
|
|
84
|
+
},
|
|
85
|
+
// Posters
|
|
86
|
+
{
|
|
87
|
+
id: 'poster-18x24',
|
|
88
|
+
label: 'Poster 18×24"',
|
|
89
|
+
category: 'poster',
|
|
90
|
+
unit: 'in',
|
|
91
|
+
width: 18,
|
|
92
|
+
height: 24,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'poster-24x36',
|
|
96
|
+
label: 'Poster 24×36"',
|
|
97
|
+
category: 'poster',
|
|
98
|
+
unit: 'in',
|
|
99
|
+
width: 24,
|
|
100
|
+
height: 36,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export function getPaperPreset(id: string): PaperPreset | undefined {
|
|
105
|
+
return PAPER_PRESETS.find((p) => p.id === id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type Orientation = 'portrait' | 'landscape';
|
|
109
|
+
|
|
110
|
+
/** Trim size in mm, orientation-applied (swap width/height for landscape). */
|
|
111
|
+
export function trimSizeMm(
|
|
112
|
+
preset: PaperPreset,
|
|
113
|
+
orientation: Orientation
|
|
114
|
+
): { widthMm: number; heightMm: number } {
|
|
115
|
+
const wMm = preset.unit === 'mm' ? preset.width : preset.width * MM_PER_INCH;
|
|
116
|
+
const hMm = preset.unit === 'mm' ? preset.height : preset.height * MM_PER_INCH;
|
|
117
|
+
return orientation === 'landscape'
|
|
118
|
+
? { widthMm: hMm, heightMm: wMm }
|
|
119
|
+
: { widthMm: wMm, heightMm: hMm };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Default bleed (mm) by preset's unit family — 3mm EU (mm-unit presets), 0.125in (in-unit presets). */
|
|
123
|
+
export function defaultBleedMm(preset: PaperPreset): number {
|
|
124
|
+
return preset.unit === 'mm' ? 3 : 0.125 * MM_PER_INCH;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const DEFAULT_SAFE_MARGIN_MM = 5;
|
|
128
|
+
|
|
129
|
+
/** Generous but finite bound on bleed/margin mm values — guards a
|
|
130
|
+
* malicious/broken value (a hand-edited or hub-synced `print` prop is
|
|
131
|
+
* DDR-054 untrusted canvas content) from producing a negative trim box
|
|
132
|
+
* (rect.w - 2×bleedPx) or a degenerate PDF geometry. Enforced at WRITE
|
|
133
|
+
* time by `setArtboardPrintOp` (api.ts) AND at READ time by
|
|
134
|
+
* `resolveBleedMm`/`resolveMarginsMm` below — a `print` prop can also
|
|
135
|
+
* reach the exporter via disk/hub-sync without ever passing through the
|
|
136
|
+
* write-time validator, so the read side needs its own clamp. */
|
|
137
|
+
export const MAX_PRINT_MM = 200;
|
|
138
|
+
|
|
139
|
+
function clampMm(v: number): number {
|
|
140
|
+
if (!Number.isFinite(v)) return 0;
|
|
141
|
+
return Math.min(Math.max(v, 0), MAX_PRINT_MM);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Crop/registration mark geometry — length ~3.5mm, stroke offset from trim = bleed. */
|
|
145
|
+
export const MARK_LENGTH_MM = 3.5;
|
|
146
|
+
export const MARK_STROKE_MM = 0.25;
|
|
147
|
+
|
|
148
|
+
export interface PrintMargins {
|
|
149
|
+
top: number;
|
|
150
|
+
right: number;
|
|
151
|
+
bottom: number;
|
|
152
|
+
left: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `print` JSX prop on `DCArtboard` (T2) — authoring-time intent, kept
|
|
157
|
+
* alongside the resolved px `width`/`height` DDR-027 already requires (this
|
|
158
|
+
* prop does NOT re-derive geometry at render time; the paper picker writes
|
|
159
|
+
* both this prop and the resolved px size together, see resolvePrintArtboard).
|
|
160
|
+
* Consumed by the Inspector's print picker (T2), the on-canvas overlay (T3),
|
|
161
|
+
* and both exporters (T4/T5) — all via THIS module, never a local copy.
|
|
162
|
+
*/
|
|
163
|
+
export interface ArtboardPrintProp {
|
|
164
|
+
/** Paper preset id — one of PAPER_PRESETS. */
|
|
165
|
+
paper: string;
|
|
166
|
+
orientation?: Orientation;
|
|
167
|
+
/** Bleed extension, mm. Omitted ⇒ defaultBleedMm(preset). */
|
|
168
|
+
bleedMm?: number;
|
|
169
|
+
marginsMm?: Partial<PrintMargins>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ResolvedPrintArtboard {
|
|
173
|
+
paper: string;
|
|
174
|
+
orientation: Orientation;
|
|
175
|
+
/** Trim (final cut) size, in mm. */
|
|
176
|
+
trimWidthMm: number;
|
|
177
|
+
trimHeightMm: number;
|
|
178
|
+
/** Bleed extension, in mm (uniform on all 4 sides). */
|
|
179
|
+
bleedMm: number;
|
|
180
|
+
/** Artboard px size = trim + 2×bleed — the artboard IS the bleed box (Design Decision 1). */
|
|
181
|
+
widthPx: number;
|
|
182
|
+
heightPx: number;
|
|
183
|
+
/** Trim box, in px, inset from the artboard edges by bleedPx on every side. */
|
|
184
|
+
trimWidthPx: number;
|
|
185
|
+
trimHeightPx: number;
|
|
186
|
+
bleedPx: number;
|
|
187
|
+
marginsMm: PrintMargins;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve a possibly-partial `marginsMm` override against the 5mm default —
|
|
192
|
+
* the SAME resolution `resolvePrintArtboard` applies, factored out so the
|
|
193
|
+
* on-canvas overlay (T3) and the PDF post-pass (T5) apply identical defaults
|
|
194
|
+
* without re-deriving them from a full `resolvePrintArtboard` call (which
|
|
195
|
+
* needs a paper size neither of those call sites actually has to hand — both
|
|
196
|
+
* work from the artboard's own already-resolved px rect instead, per
|
|
197
|
+
* DDR-027).
|
|
198
|
+
*/
|
|
199
|
+
export function resolveMarginsMm(marginsMm?: Partial<PrintMargins>): PrintMargins {
|
|
200
|
+
const m = marginsMm ?? {};
|
|
201
|
+
return {
|
|
202
|
+
top: typeof m.top === 'number' ? clampMm(m.top) : DEFAULT_SAFE_MARGIN_MM,
|
|
203
|
+
right: typeof m.right === 'number' ? clampMm(m.right) : DEFAULT_SAFE_MARGIN_MM,
|
|
204
|
+
bottom: typeof m.bottom === 'number' ? clampMm(m.bottom) : DEFAULT_SAFE_MARGIN_MM,
|
|
205
|
+
left: typeof m.left === 'number' ? clampMm(m.left) : DEFAULT_SAFE_MARGIN_MM,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve an `ArtboardPrintProp`'s bleed to a concrete mm value — explicit
|
|
211
|
+
* `bleedMm` wins, else the paper preset's unit-family default (3mm EU /
|
|
212
|
+
* 0.125in US). Unknown paper id with no explicit bleedMm falls back to 3mm
|
|
213
|
+
* rather than throwing — the overlay/exporter must never hard-fail a render
|
|
214
|
+
* over a bad paper id (resolvePrintArtboard is the strict, throwing entry
|
|
215
|
+
* point used at WRITE time; this one is the lenient, read-time counterpart).
|
|
216
|
+
*/
|
|
217
|
+
export function resolveBleedMm(print: { paper: string; bleedMm?: number }): number {
|
|
218
|
+
if (typeof print.bleedMm === 'number') return clampMm(print.bleedMm);
|
|
219
|
+
const preset = getPaperPreset(print.paper);
|
|
220
|
+
return preset ? defaultBleedMm(preset) : 3;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** mm → whole px, rounded — the ONE rounding rule bleed/trim/margin geometry
|
|
224
|
+
* uses everywhere (overlay, exporter, resolvePrintArtboard). */
|
|
225
|
+
export function resolveMmPx(mm: number, dpi: number = CSS_DPI): number {
|
|
226
|
+
return Math.round(mmToPx(mm, dpi));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve a `print` prop's paper/orientation/bleed into concrete px geometry.
|
|
231
|
+
* The artboard IS the bleed box: bleed lives INSIDE the artboard bounds so
|
|
232
|
+
* background content can reach the cut edge under today's clip-at-artboard
|
|
233
|
+
* -bounds rendering model (the alternative — artboard=trim, bleed outside —
|
|
234
|
+
* can't render bleeding content at all).
|
|
235
|
+
*
|
|
236
|
+
* px values are rounded to the nearest integer (CSS `width`/`height` must be
|
|
237
|
+
* whole px); overlay + exporter both re-derive px from the same mm fields
|
|
238
|
+
* on this result rather than re-rounding independently, so trim/bleed/margin
|
|
239
|
+
* geometry stays pixel-identical between what's drawn and what's exported.
|
|
240
|
+
*/
|
|
241
|
+
export function resolvePrintArtboard(args: {
|
|
242
|
+
paper: string;
|
|
243
|
+
orientation?: Orientation;
|
|
244
|
+
bleedMm?: number;
|
|
245
|
+
marginsMm?: Partial<PrintMargins>;
|
|
246
|
+
}): ResolvedPrintArtboard {
|
|
247
|
+
const preset = getPaperPreset(args.paper);
|
|
248
|
+
if (!preset) throw new Error(`unknown paper preset: ${args.paper}`);
|
|
249
|
+
const orientation = args.orientation ?? 'portrait';
|
|
250
|
+
const { widthMm: trimWidthMm, heightMm: trimHeightMm } = trimSizeMm(preset, orientation);
|
|
251
|
+
const bleedMm = args.bleedMm ?? defaultBleedMm(preset);
|
|
252
|
+
const marginsMm = resolveMarginsMm(args.marginsMm);
|
|
253
|
+
const bleedPx = resolveMmPx(bleedMm);
|
|
254
|
+
const trimWidthPx = resolveMmPx(trimWidthMm);
|
|
255
|
+
const trimHeightPx = resolveMmPx(trimHeightMm);
|
|
256
|
+
return {
|
|
257
|
+
paper: preset.id,
|
|
258
|
+
orientation,
|
|
259
|
+
trimWidthMm,
|
|
260
|
+
trimHeightMm,
|
|
261
|
+
bleedMm,
|
|
262
|
+
widthPx: trimWidthPx + 2 * bleedPx,
|
|
263
|
+
heightPx: trimHeightPx + 2 * bleedPx,
|
|
264
|
+
trimWidthPx,
|
|
265
|
+
trimHeightPx,
|
|
266
|
+
bleedPx,
|
|
267
|
+
marginsMm,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file print-overlay-content.tsx — feature-2-print-artboards T3.
|
|
3
|
+
* @scope apps/studio/print-overlay-content.tsx
|
|
4
|
+
* @purpose Registers the 'print' kind's on-canvas guide chrome into the
|
|
5
|
+
* foundation's kindOverlayRegistry (artboard-guides-overlay.tsx):
|
|
6
|
+
* bleed zone (red tint), trim line (solid), safe-margin line
|
|
7
|
+
* (magenta dashed) — the Adobe-convention colors Design
|
|
8
|
+
* Decision 3 specifies. Decorative — aria-hidden, pointer-events
|
|
9
|
+
* none, flat divs/borders only (no filters/blends — WKWebView
|
|
10
|
+
* doesn't GPU-accelerate those; same constraint the foundation
|
|
11
|
+
* overlay itself documents).
|
|
12
|
+
*
|
|
13
|
+
* Geometry reads print/units.ts ONLY (never a local mm→px copy —
|
|
14
|
+
* the T1 single-source invariant: bleedPx here must match the
|
|
15
|
+
* PDF post-pass's bleedPx (T5) exactly, or the on-canvas guide
|
|
16
|
+
* and the exported TrimBox visibly disagree).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type KindOverlayRenderFn, registerKindOverlay } from './artboard-guides-overlay.tsx';
|
|
20
|
+
import { resolveBleedMm, resolveMarginsMm, resolveMmPx } from './print/units.ts';
|
|
21
|
+
|
|
22
|
+
// Adobe print-tool convention (Design Decision 3): bleed = red, trim = solid
|
|
23
|
+
// dark line, margins/safe-area = magenta dashed. Chrome-engine constants, not
|
|
24
|
+
// DS tokens — guides are never part of exported artboard content, same
|
|
25
|
+
// reasoning as the foundation's own COLUMN_FILL/GRID_LINE constants.
|
|
26
|
+
const BLEED_TINT = 'color-mix(in oklab, oklch(55% 0.22 25) 14%, transparent)';
|
|
27
|
+
const TRIM_LINE = 'oklch(25% 0 0)';
|
|
28
|
+
const MARGIN_LINE = 'oklch(58% 0.24 340)';
|
|
29
|
+
|
|
30
|
+
const PrintOverlayContent: KindOverlayRenderFn = ({ rect, print, visibility }) => {
|
|
31
|
+
if (visibility.print !== true || !print) return null;
|
|
32
|
+
|
|
33
|
+
const bleedMm = resolveBleedMm(print);
|
|
34
|
+
const bleedPx = resolveMmPx(bleedMm);
|
|
35
|
+
const margins = resolveMarginsMm(print.marginsMm);
|
|
36
|
+
const marginPx = {
|
|
37
|
+
top: resolveMmPx(margins.top),
|
|
38
|
+
right: resolveMmPx(margins.right),
|
|
39
|
+
bottom: resolveMmPx(margins.bottom),
|
|
40
|
+
left: resolveMmPx(margins.left),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Trim box — inset from the artboard (= bleed box, Design Decision 1) by
|
|
44
|
+
// bleedPx on every side.
|
|
45
|
+
const trimW = Math.max(0, rect.w - 2 * bleedPx);
|
|
46
|
+
const trimH = Math.max(0, rect.h - 2 * bleedPx);
|
|
47
|
+
// Safe/margin box — inset further from the trim box by marginPx per side.
|
|
48
|
+
const marginW = Math.max(0, trimW - marginPx.left - marginPx.right);
|
|
49
|
+
const marginH = Math.max(0, trimH - marginPx.top - marginPx.bottom);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{bleedPx > 0 ? (
|
|
54
|
+
<>
|
|
55
|
+
{/* Bleed zone — the annulus between the artboard edge and the trim
|
|
56
|
+
line, drawn as 4 tinted strips (never a single translucent box
|
|
57
|
+
over the whole artboard, which would also tint the live content
|
|
58
|
+
inside the trim box). */}
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
position: 'absolute',
|
|
62
|
+
left: 0,
|
|
63
|
+
top: 0,
|
|
64
|
+
width: rect.w,
|
|
65
|
+
height: bleedPx,
|
|
66
|
+
background: BLEED_TINT,
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
<div
|
|
70
|
+
style={{
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
left: 0,
|
|
73
|
+
bottom: 0,
|
|
74
|
+
width: rect.w,
|
|
75
|
+
height: bleedPx,
|
|
76
|
+
background: BLEED_TINT,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
<div
|
|
80
|
+
style={{
|
|
81
|
+
position: 'absolute',
|
|
82
|
+
left: 0,
|
|
83
|
+
top: bleedPx,
|
|
84
|
+
width: bleedPx,
|
|
85
|
+
height: trimH,
|
|
86
|
+
background: BLEED_TINT,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
<div
|
|
90
|
+
style={{
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
right: 0,
|
|
93
|
+
top: bleedPx,
|
|
94
|
+
width: bleedPx,
|
|
95
|
+
height: trimH,
|
|
96
|
+
background: BLEED_TINT,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</>
|
|
100
|
+
) : null}
|
|
101
|
+
{/* Trim line — the final cut edge, solid. */}
|
|
102
|
+
<div
|
|
103
|
+
style={{
|
|
104
|
+
position: 'absolute',
|
|
105
|
+
left: bleedPx,
|
|
106
|
+
top: bleedPx,
|
|
107
|
+
width: trimW,
|
|
108
|
+
height: trimH,
|
|
109
|
+
border: `1px solid ${TRIM_LINE}`,
|
|
110
|
+
boxSizing: 'border-box',
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
{/* Safe-area / margin line — inset from trim, dashed. */}
|
|
114
|
+
{marginW > 0 && marginH > 0 ? (
|
|
115
|
+
<div
|
|
116
|
+
style={{
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
left: bleedPx + marginPx.left,
|
|
119
|
+
top: bleedPx + marginPx.top,
|
|
120
|
+
width: marginW,
|
|
121
|
+
height: marginH,
|
|
122
|
+
border: `1px dashed ${MARGIN_LINE}`,
|
|
123
|
+
boxSizing: 'border-box',
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
) : null}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
PrintOverlayContent.displayName = 'PrintOverlayContent';
|
|
131
|
+
|
|
132
|
+
registerKindOverlay('print', PrintOverlayContent);
|
|
@@ -52,7 +52,7 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
|
52
52
|
}
|
|
53
53
|
}, 15000);
|
|
54
54
|
|
|
55
|
-
test('
|
|
55
|
+
test('model/effort are no longer env-at-spawn — a config change never respawns the running adapter', async () => {
|
|
56
56
|
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
57
57
|
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
58
58
|
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
@@ -60,19 +60,24 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
|
60
60
|
const updates: unknown[] = [];
|
|
61
61
|
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
62
62
|
try {
|
|
63
|
+
// A persisted pick this mock doesn't advertise (it has no configOptions at
|
|
64
|
+
// all) is skipped best-effort, never forwarded to ANTHROPIC_MODEL/env.
|
|
63
65
|
bridge.setConfig('opus', 'thorough');
|
|
64
66
|
await bridge.prompt('hi', 'c1');
|
|
67
|
+
const sid = bridge.sessionId;
|
|
65
68
|
const first = JSON.stringify(updates);
|
|
66
|
-
expect(first).toContain('model
|
|
67
|
-
expect(first).toContain('thinking
|
|
69
|
+
expect(first).toContain('model=<unset>');
|
|
70
|
+
expect(first).toContain('thinking=<unset>');
|
|
68
71
|
|
|
69
|
-
// Changing the config
|
|
72
|
+
// Changing the config again must NOT tear down / respawn the live process
|
|
73
|
+
// (Task A3 — the old configChanged()-triggered stop() is gone).
|
|
70
74
|
updates.length = 0;
|
|
71
75
|
bridge.setConfig(null, 'fast');
|
|
72
76
|
await bridge.prompt('again', 'c1');
|
|
77
|
+
expect(bridge.sessionId).toBe(sid); // same session — no respawn happened
|
|
73
78
|
const second = JSON.stringify(updates);
|
|
74
|
-
expect(second).toContain('model=<unset>');
|
|
75
|
-
expect(second).toContain('thinking
|
|
79
|
+
expect(second).toContain('model=<unset>');
|
|
80
|
+
expect(second).toContain('thinking=<unset>');
|
|
76
81
|
} finally {
|
|
77
82
|
await bridge.stop();
|
|
78
83
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/acp-capabilities.js — the parsing +
|
|
2
|
+
// persisted-pick-resolution helpers the dynamic CapabilityBar renders from.
|
|
3
|
+
// No hardcoded model/effort/mode list is under test here BY DESIGN: these
|
|
4
|
+
// functions must work for whatever the session actually advertises.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
flattenSelectOptions,
|
|
10
|
+
parseConfigOptions,
|
|
11
|
+
parseModes,
|
|
12
|
+
resolvePersistedPick,
|
|
13
|
+
} from '../client/panels/acp-capabilities.js';
|
|
14
|
+
|
|
15
|
+
describe('parseConfigOptions', () => {
|
|
16
|
+
test('identifies model/effort/fast by id, excludes the mode-mirror entry, and buckets everything else into others', () => {
|
|
17
|
+
const configOptions = [
|
|
18
|
+
{ id: 'model', category: 'model', currentValue: 'sonnet', options: [] },
|
|
19
|
+
{ id: 'effort', category: 'thought_level', currentValue: 'default', options: [] },
|
|
20
|
+
{ id: 'mode', category: 'mode', currentValue: 'default', options: [] },
|
|
21
|
+
{ id: 'fast', category: 'model_config', currentValue: 'off', options: [] },
|
|
22
|
+
{ id: 'agent', currentValue: 'default', options: [] }, // no category — the documented gap
|
|
23
|
+
{ id: 'some-future-option', category: '_custom', currentValue: 'x', options: [] },
|
|
24
|
+
];
|
|
25
|
+
const { model, effort, fast, others } = parseConfigOptions(configOptions);
|
|
26
|
+
expect(model?.id).toBe('model');
|
|
27
|
+
expect(effort?.id).toBe('effort');
|
|
28
|
+
expect(fast?.id).toBe('fast');
|
|
29
|
+
expect(others.map((o) => o.id)).toEqual(['agent', 'some-future-option']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('tolerates missing/empty/malformed input', () => {
|
|
33
|
+
expect(parseConfigOptions(undefined)).toEqual({
|
|
34
|
+
model: null,
|
|
35
|
+
effort: null,
|
|
36
|
+
fast: null,
|
|
37
|
+
others: [],
|
|
38
|
+
});
|
|
39
|
+
expect(parseConfigOptions(null)).toEqual({ model: null, effort: null, fast: null, others: [] });
|
|
40
|
+
expect(parseConfigOptions([])).toEqual({ model: null, effort: null, fast: null, others: [] });
|
|
41
|
+
// A non-object entry must not throw.
|
|
42
|
+
expect(parseConfigOptions([null, 'x', 42]).others).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a session with only a subset of options omits the rest as null, not a stale default', () => {
|
|
46
|
+
const { model, effort, fast } = parseConfigOptions([{ id: 'model', options: [] }]);
|
|
47
|
+
expect(model).not.toBeNull();
|
|
48
|
+
expect(effort).toBeNull();
|
|
49
|
+
expect(fast).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('parseModes', () => {
|
|
54
|
+
test('returns current + available from a real SessionModeState', () => {
|
|
55
|
+
const modes = {
|
|
56
|
+
currentModeId: 'plan',
|
|
57
|
+
availableModes: [
|
|
58
|
+
{ id: 'default', name: 'Manual' },
|
|
59
|
+
{ id: 'plan', name: 'Plan Mode' },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
expect(parseModes(modes)).toEqual({
|
|
63
|
+
current: 'plan',
|
|
64
|
+
available: modes.availableModes,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an agent that never advertises modes yields an empty, non-throwing shape', () => {
|
|
69
|
+
expect(parseModes(null)).toEqual({ current: null, available: [] });
|
|
70
|
+
expect(parseModes(undefined)).toEqual({ current: null, available: [] });
|
|
71
|
+
expect(parseModes({})).toEqual({ current: null, available: [] });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('flattenSelectOptions', () => {
|
|
76
|
+
test('passes through a flat option list unchanged', () => {
|
|
77
|
+
const options = [
|
|
78
|
+
{ value: 'a', name: 'A' },
|
|
79
|
+
{ value: 'b', name: 'B' },
|
|
80
|
+
];
|
|
81
|
+
expect(flattenSelectOptions(options)).toEqual(options);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('flattens grouped SessionConfigSelectGroup[] into one leaf list', () => {
|
|
85
|
+
const options = [
|
|
86
|
+
{ group: 'g1', name: 'Group 1', options: [{ value: 'a', name: 'A' }] },
|
|
87
|
+
{
|
|
88
|
+
group: 'g2',
|
|
89
|
+
name: 'Group 2',
|
|
90
|
+
options: [
|
|
91
|
+
{ value: 'b', name: 'B' },
|
|
92
|
+
{ value: 'c', name: 'C' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
expect(flattenSelectOptions(options).map((o) => o.value)).toEqual(['a', 'b', 'c']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('tolerates missing input', () => {
|
|
100
|
+
expect(flattenSelectOptions(undefined)).toEqual([]);
|
|
101
|
+
expect(flattenSelectOptions(null)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('resolvePersistedPick', () => {
|
|
106
|
+
test('prefers the saved pick when the session still offers it', () => {
|
|
107
|
+
expect(resolvePersistedPick(['opus', 'sonnet'], 'opus', 'sonnet')).toBe('opus');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('falls back to the session default when the saved pick is no longer offered', () => {
|
|
111
|
+
expect(resolvePersistedPick(['sonnet', 'haiku'], 'opus', 'sonnet')).toBe('sonnet');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('falls back to null when there is neither a valid saved pick nor a default', () => {
|
|
115
|
+
expect(resolvePersistedPick([], 'opus', undefined)).toBeNull();
|
|
116
|
+
expect(resolvePersistedPick([], null, null)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a null/undefined saved value always defers to the default', () => {
|
|
120
|
+
expect(resolvePersistedPick(['a', 'b'], null, 'b')).toBe('b');
|
|
121
|
+
expect(resolvePersistedPick(['a', 'b'], undefined, 'a')).toBe('a');
|
|
122
|
+
});
|
|
123
|
+
});
|