@1agh/maude 0.45.2 → 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 +43 -1
- 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/_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/ChatPanel.jsx +45 -1
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/dist/client.bundle.js +1398 -1398
- 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/http.ts +213 -7
- 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-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -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/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 +28 -0
- package/cli/commands/design.mjs +9 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// feature-2-print-artboards T1/T5 — pure crop/registration-mark geometry.
|
|
2
|
+
//
|
|
3
|
+
// Consumed by the pdf-lib post-pass (T5) to draw vector marks, and reusable
|
|
4
|
+
// by a future on-canvas marks preview (T3 left that optional — see the plan).
|
|
5
|
+
// Coordinates are PDF POINTS in the BLEED-BOX-origin space: x ∈ [0, pageWidthPt],
|
|
6
|
+
// y ∈ [0, pageHeightPt] is the rendered page itself (Chromium's page.pdf()
|
|
7
|
+
// output, unchanged); marks live OUTSIDE that box, at negative coordinates or
|
|
8
|
+
// beyond pageWidthPt/pageHeightPt. The pdf-lib post-pass enlarges MediaBox
|
|
9
|
+
// with a negative origin to make room for them without moving page content.
|
|
10
|
+
|
|
11
|
+
import { MARK_LENGTH_MM, MARK_STROKE_MM, mmToPt } from './units.ts';
|
|
12
|
+
|
|
13
|
+
export interface LineSegment {
|
|
14
|
+
x1: number;
|
|
15
|
+
y1: number;
|
|
16
|
+
x2: number;
|
|
17
|
+
y2: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Circle {
|
|
21
|
+
cx: number;
|
|
22
|
+
cy: number;
|
|
23
|
+
r: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RegistrationMark {
|
|
27
|
+
circle: Circle;
|
|
28
|
+
crosshair: LineSegment[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MarksGeometry {
|
|
32
|
+
cropMarks: LineSegment[];
|
|
33
|
+
registrationMarks: RegistrationMark[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const REG_RADIUS_MM = 1.5;
|
|
37
|
+
const REG_GAP_MM = 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The extra margin MediaBox must extend beyond the bleed box on every side to
|
|
41
|
+
* fit the requested marks without clipping. Callers ask for this BEFORE
|
|
42
|
+
* calling computeMarksGeometry, to size the enlarged MediaBox first.
|
|
43
|
+
*/
|
|
44
|
+
export function requiredSlugPt(args: { crop?: boolean; registration?: boolean }): number {
|
|
45
|
+
const markLen = mmToPt(MARK_LENGTH_MM);
|
|
46
|
+
let slug = 0;
|
|
47
|
+
if (args.crop) slug = Math.max(slug, markLen);
|
|
48
|
+
if (args.registration) {
|
|
49
|
+
slug = Math.max(slug, markLen + mmToPt(REG_GAP_MM) + 2 * mmToPt(REG_RADIUS_MM));
|
|
50
|
+
}
|
|
51
|
+
return slug;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute crop-mark (8 segments, 2 per corner) and registration-mark (4,
|
|
56
|
+
* side-midpoints) geometry for a page of the given size + bleed. Crop marks
|
|
57
|
+
* are aligned to the TRIM line but drawn starting exactly at the bleed-box
|
|
58
|
+
* edge (T1's "offset = bleed"), extending outward by `MARK_LENGTH_MM`.
|
|
59
|
+
* Registration marks sit just beyond the crop marks' reach.
|
|
60
|
+
*/
|
|
61
|
+
export function computeMarksGeometry(args: {
|
|
62
|
+
pageWidthPt: number;
|
|
63
|
+
pageHeightPt: number;
|
|
64
|
+
bleedPt: number;
|
|
65
|
+
crop?: boolean;
|
|
66
|
+
registration?: boolean;
|
|
67
|
+
}): MarksGeometry {
|
|
68
|
+
const { pageWidthPt, pageHeightPt, bleedPt } = args;
|
|
69
|
+
const markLen = mmToPt(MARK_LENGTH_MM);
|
|
70
|
+
const trimX0 = bleedPt;
|
|
71
|
+
const trimY0 = bleedPt;
|
|
72
|
+
const trimX1 = pageWidthPt - bleedPt;
|
|
73
|
+
const trimY1 = pageHeightPt - bleedPt;
|
|
74
|
+
|
|
75
|
+
const cropMarks: LineSegment[] = [];
|
|
76
|
+
if (args.crop) {
|
|
77
|
+
// Bottom-left
|
|
78
|
+
cropMarks.push({ x1: trimX0, y1: 0, x2: trimX0, y2: -markLen });
|
|
79
|
+
cropMarks.push({ x1: 0, y1: trimY0, x2: -markLen, y2: trimY0 });
|
|
80
|
+
// Bottom-right
|
|
81
|
+
cropMarks.push({ x1: trimX1, y1: 0, x2: trimX1, y2: -markLen });
|
|
82
|
+
cropMarks.push({ x1: pageWidthPt, y1: trimY0, x2: pageWidthPt + markLen, y2: trimY0 });
|
|
83
|
+
// Top-left
|
|
84
|
+
cropMarks.push({ x1: trimX0, y1: pageHeightPt, x2: trimX0, y2: pageHeightPt + markLen });
|
|
85
|
+
cropMarks.push({ x1: 0, y1: trimY1, x2: -markLen, y2: trimY1 });
|
|
86
|
+
// Top-right
|
|
87
|
+
cropMarks.push({ x1: trimX1, y1: pageHeightPt, x2: trimX1, y2: pageHeightPt + markLen });
|
|
88
|
+
cropMarks.push({ x1: pageWidthPt, y1: trimY1, x2: pageWidthPt + markLen, y2: trimY1 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const registrationMarks: RegistrationMark[] = [];
|
|
92
|
+
if (args.registration) {
|
|
93
|
+
const regR = mmToPt(REG_RADIUS_MM);
|
|
94
|
+
const offset = markLen + mmToPt(REG_GAP_MM) + regR;
|
|
95
|
+
const mk = (cx: number, cy: number): RegistrationMark => ({
|
|
96
|
+
circle: { cx, cy, r: regR },
|
|
97
|
+
crosshair: [
|
|
98
|
+
{ x1: cx - regR, y1: cy, x2: cx + regR, y2: cy },
|
|
99
|
+
{ x1: cx, y1: cy - regR, x2: cx, y2: cy + regR },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
registrationMarks.push(
|
|
103
|
+
mk(pageWidthPt / 2, pageHeightPt + offset), // top-center
|
|
104
|
+
mk(pageWidthPt / 2, -offset), // bottom-center
|
|
105
|
+
mk(-offset, pageHeightPt / 2), // left-center
|
|
106
|
+
mk(pageWidthPt + offset, pageHeightPt / 2) // right-center
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { cropMarks, registrationMarks };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const MARK_STROKE_PT = mmToPt(MARK_STROKE_MM);
|
|
@@ -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);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// DDR-184 #3 — the predicate behind ChatPanel's loud "you can't edit" ModeBanner.
|
|
2
|
+
// Pure unit test (the banner itself is a thin render of this). Keyed off the mode
|
|
3
|
+
// ID, never a label, so it stays truthful as the adapter's roster evolves.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { modeBlocksEdits, NO_EDIT_MODE_IDS } from '../client/panels/acp-capabilities.js';
|
|
8
|
+
|
|
9
|
+
const rosterWith = (currentModeId: string) => ({
|
|
10
|
+
currentModeId,
|
|
11
|
+
availableModes: [
|
|
12
|
+
{ id: 'default', name: 'Manual' },
|
|
13
|
+
{ id: 'acceptEdits', name: 'Accept Edits' },
|
|
14
|
+
{ id: 'plan', name: 'Plan Mode' },
|
|
15
|
+
{ id: 'dontAsk', name: "Don't Ask" },
|
|
16
|
+
{ id: 'bypassPermissions', name: 'Bypass Permissions' },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('modeBlocksEdits', () => {
|
|
21
|
+
test('blocks in Plan Mode (no tool execution)', () => {
|
|
22
|
+
expect(modeBlocksEdits(rosterWith('plan'))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("blocks in Don't-Ask (denies anything without a standing approval)", () => {
|
|
26
|
+
expect(modeBlocksEdits(rosterWith('dontAsk'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('does NOT block in the editing modes (Manual / Accept-Edits / Bypass)', () => {
|
|
30
|
+
expect(modeBlocksEdits(rosterWith('default'))).toBe(false);
|
|
31
|
+
expect(modeBlocksEdits(rosterWith('acceptEdits'))).toBe(false);
|
|
32
|
+
expect(modeBlocksEdits(rosterWith('bypassPermissions'))).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('false when the roster is empty / not yet loaded (no premature alarm)', () => {
|
|
36
|
+
expect(modeBlocksEdits(null)).toBe(false);
|
|
37
|
+
expect(modeBlocksEdits(undefined)).toBe(false);
|
|
38
|
+
expect(modeBlocksEdits({ availableModes: [] })).toBe(false);
|
|
39
|
+
expect(modeBlocksEdits({ currentModeId: '' })).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('NO_EDIT_MODE_IDS is exactly {plan, dontAsk}', () => {
|
|
43
|
+
expect([...NO_EDIT_MODE_IDS].sort()).toEqual(['dontAsk', 'plan']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// DDR-184 — the curated tool allow-list the bridge auto-approves so the design
|
|
2
|
+
// workflow never stalls on a per-edit / per-`maude` permission prompt (the
|
|
3
|
+
// "Manual mode blocks every edit" complaint). Two things under test:
|
|
4
|
+
//
|
|
5
|
+
// (1) WIRE contract — the list lands on `_meta.claudeCode.options.allowedTools`
|
|
6
|
+
// (SDK `allowedTools`, sdk.d.ts:1331), coexisting with the DDR-144
|
|
7
|
+
// `settingSources` narrowing and the DDR-143 `plugins` carrier. Same
|
|
8
|
+
// adapter/SDK-INTERNAL `_meta` path as acp-session-plugins.test.ts — a
|
|
9
|
+
// bump that stops forwarding it must fail HERE, not silently re-prompt.
|
|
10
|
+
// (2) SOURCE-OF-TRUTH guard — the single `Bash(maude:*)` rule only covers the
|
|
11
|
+
// whole design-helper surface because DDR-062 routes every helper through
|
|
12
|
+
// `maude design <verb>`. If that stops being true, or someone widens the
|
|
13
|
+
// Bash scope, this test fails loudly.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { MAUDE_DEFAULT_ALLOWED_TOOLS, newSessionParams } from '../acp/bridge.ts';
|
|
20
|
+
import type { SdkPluginConfig } from '../acp/plugin-bootstrap.ts';
|
|
21
|
+
|
|
22
|
+
type Meta = {
|
|
23
|
+
systemPrompt?: { append?: string };
|
|
24
|
+
claudeCode?: {
|
|
25
|
+
options?: {
|
|
26
|
+
allowedTools?: string[];
|
|
27
|
+
plugins?: SdkPluginConfig[];
|
|
28
|
+
settingSources?: string[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PLUGINS: SdkPluginConfig[] = [
|
|
34
|
+
{ type: 'local', path: '/bundle/plugins/design', skipMcpDiscovery: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('newSessionParams — allowedTools carrier shape (DDR-184)', () => {
|
|
38
|
+
test('the allow-list rides _meta.claudeCode.options.allowedTools', () => {
|
|
39
|
+
const opts = (newSessionParams('/repo', undefined)._meta as Meta).claudeCode?.options;
|
|
40
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('allowedTools is a COPY, not the shared module constant (no accidental mutation)', () => {
|
|
44
|
+
const opts = (newSessionParams('/repo')._meta as Meta).claudeCode?.options;
|
|
45
|
+
expect(opts?.allowedTools).not.toBe(MAUDE_DEFAULT_ALLOWED_TOOLS);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('coexists with settingSources (DDR-144) and plugins (DDR-143) under one options', () => {
|
|
49
|
+
const opts = (newSessionParams('/repo', 'BRIEF', PLUGINS)._meta as Meta).claudeCode?.options;
|
|
50
|
+
expect(opts?.settingSources).toEqual(['user']);
|
|
51
|
+
expect(opts?.plugins).toEqual(PLUGINS);
|
|
52
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('present even with no plugins (npm / web / power-user path)', () => {
|
|
56
|
+
const opts = (newSessionParams('/repo', 'BRIEF', [])._meta as Meta).claudeCode?.options;
|
|
57
|
+
expect(opts?.plugins).toBeUndefined();
|
|
58
|
+
expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-062)', () => {
|
|
63
|
+
const bashRules = MAUDE_DEFAULT_ALLOWED_TOOLS.filter((t) => t.startsWith('Bash'));
|
|
64
|
+
|
|
65
|
+
test('the canvas-editing file tools are all present', () => {
|
|
66
|
+
for (const t of ['Read', 'Edit', 'Write', 'Glob', 'Grep', 'NotebookEdit']) {
|
|
67
|
+
expect(MAUDE_DEFAULT_ALLOWED_TOOLS).toContain(t);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the ONLY Bash rule is prefix-scoped to `maude` — never a bare/un-scoped Bash', () => {
|
|
72
|
+
// A bare `Bash` (or `Bash(*)`) would auto-approve arbitrary command execution
|
|
73
|
+
// — exactly the blanket surface DDR-179 kept behind the prompt. Guard against
|
|
74
|
+
// anyone widening it: the list may auto-approve `maude` and nothing else.
|
|
75
|
+
expect(bashRules).toEqual(['Bash(maude:*)']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the single Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
|
|
79
|
+
// Resolve cli/commands/design.mjs relative to this test file's real path
|
|
80
|
+
// (mirrors acp-session-plugins.test.ts's realpath dance for compiled roots).
|
|
81
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
82
|
+
const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
|
|
83
|
+
expect(existsSync(designCli)).toBe(true);
|
|
84
|
+
const src = readFileSync(designCli, 'utf8');
|
|
85
|
+
// The dispatch table the `Bash(maude:*)` scope rests on — helpers reached via
|
|
86
|
+
// `maude design <verb>`, not raw `bash <path>.sh` (DDR-062). If this marker
|
|
87
|
+
// disappears, the one-rule assumption needs re-checking.
|
|
88
|
+
expect(src).toContain('BIN_VERBS');
|
|
89
|
+
expect(src).toMatch(/maude design <verb>/);
|
|
90
|
+
});
|
|
91
|
+
});
|