@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the SVG generator: the prompt, reading a reply, the substance check, the transform repair and
|
|
3
|
+
* the draw-in motion. No dependencies beyond the sanitiser, so the dashboard and the host-side finish step of a
|
|
4
|
+
* Claude Code drawing run the same checks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { sanitizeSvg, removedSummary, tokens, parseTag, DRAWABLE } = require('./images-svg-sanitize');
|
|
8
|
+
|
|
9
|
+
const STYLES = { still: 'Still', draw: 'Draw the strokes', css: 'CSS keyframes', smil: 'SMIL' };
|
|
10
|
+
const SHAPES = { wide: [960, 400], landscape: [960, 640], square: [800, 800] };
|
|
11
|
+
const SHAPE_LABELS = { wide: 'Wide, 960×400', landscape: 'Landscape, 960×640', square: 'Square, 800×800' };
|
|
12
|
+
const MOTION = { animation: 'draw-rise', duration: 1100, stagger: 110, replay: true, threshold: 0.25 };
|
|
13
|
+
const MIN_DRAWABLE = 4;
|
|
14
|
+
const MIN_PARTS = 4;
|
|
15
|
+
const STROKED = new Set(['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect']);
|
|
16
|
+
|
|
17
|
+
const STYLE_RULES = {
|
|
18
|
+
still: 'A still drawing: no animation of any kind.',
|
|
19
|
+
draw: 'Stroke only: every shape has fill="none" and an explicit stroke colour and stroke-width, with round line caps. '
|
|
20
|
+
+ 'No filled shapes and no animation: the drawing is animated for you, part by part, by tracing its strokes.',
|
|
21
|
+
css: 'Animate it with ONE <style> element holding @keyframes, on classed elements, wrapped in '
|
|
22
|
+
+ '@media (prefers-reduced-motion: no-preference) so a reader who asks for less motion sees the finished drawing. '
|
|
23
|
+
+ 'Animations run once and end on the finished drawing (animation-fill-mode: both). Never put a transform attribute on '
|
|
24
|
+
+ 'an element you animate with CSS: put it on a wrapping <g> instead.',
|
|
25
|
+
smil: 'Animate it with SMIL (<animate>, <animateTransform>), begin times staggered part by part, fill="freeze", '
|
|
26
|
+
+ 'ending on the finished drawing. Never animate href, style, class or an event attribute.',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The palette as words a model can follow. */
|
|
30
|
+
function paletteLine(palette) {
|
|
31
|
+
return Object.entries(palette).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The system and user prompt. `files` asks for two files (Claude Code) instead of a JSON reply.
|
|
36
|
+
*/
|
|
37
|
+
function buildPrompt({ prompt, style, palette, brief, shape, files = null }) {
|
|
38
|
+
const [w, h] = SHAPES[shape] || SHAPES.wide;
|
|
39
|
+
const out = files
|
|
40
|
+
? `Write exactly two files in the current folder and nothing else:\n1. ${files.svg}: the SVG.\n2. ${files.words}: {"summary": "one or two plain sentences on what it shows", "alt": "alt text, at most 125 characters"}.\nDo not run any commands. When both files are written, stop.`
|
|
41
|
+
: 'Answer with JSON only: {"svg": "the whole SVG", "summary": "one or two plain sentences on what it shows", "alt": "alt text, at most 125 characters"}.';
|
|
42
|
+
const system = `You draw illustrations as SVG code for a website.
|
|
43
|
+
${brief ? `The brief: ${brief}\n` : ''}
|
|
44
|
+
Rules:
|
|
45
|
+
- One <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"> root, with no width or height attribute.
|
|
46
|
+
- 5 to 9 top-level parts, each a <g class="part"> in reading order, and every shape inside a part. At least ${MIN_DRAWABLE} drawable elements in all.
|
|
47
|
+
- Only these colours: ${paletteLine(palette)}. Tints by opacity are fine.
|
|
48
|
+
- No <script>, <foreignObject>, <image>, event handlers or external links; url() and href may point only at an id in this file.
|
|
49
|
+
- No text unless the request asks for it, and never a logo or a brand name.
|
|
50
|
+
- ${STYLE_RULES[style] || STYLE_RULES.still}
|
|
51
|
+
|
|
52
|
+
${out}`;
|
|
53
|
+
return { system, user: `Draw: ${prompt}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The SVG, summary and alt text out of a reply: JSON, JSON in a fence, or bare markup. */
|
|
57
|
+
function parseReply(text) {
|
|
58
|
+
const t = String(text || '');
|
|
59
|
+
const a = t.indexOf('{');
|
|
60
|
+
const b = t.lastIndexOf('}');
|
|
61
|
+
if (a >= 0 && b > a) {
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(t.slice(a, b + 1));
|
|
64
|
+
if (j && typeof j.svg === 'string') return { svg: j.svg, summary: String(j.summary || '').trim().slice(0, 600), alt: String(j.alt || '').trim().slice(0, 125) };
|
|
65
|
+
} catch (_) { /* not JSON, try the markup */ }
|
|
66
|
+
}
|
|
67
|
+
const m = t.match(/<svg[\s>][\s\S]*<\/svg>/i);
|
|
68
|
+
return { svg: m ? m[0] : '', summary: '', alt: '' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The root <svg> tag's attributes, as a map. */
|
|
72
|
+
function rootAttrs(svg) {
|
|
73
|
+
const m = svg.match(/<svg\b[^>]*>/i);
|
|
74
|
+
const tag = m && parseTag(m[0].replace(/\/>$/, '>'));
|
|
75
|
+
return tag ? Object.fromEntries(tag.attrs) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fix the root: a viewBox from width and height when it has none, then no width or height. */
|
|
79
|
+
function fixRoot(svg) {
|
|
80
|
+
const attrs = rootAttrs(svg);
|
|
81
|
+
if (!attrs) return svg;
|
|
82
|
+
let tag = svg.match(/<svg\b[^>]*>/i)[0];
|
|
83
|
+
const num = (v) => parseFloat(String(v || '').replace(/px$/, ''));
|
|
84
|
+
if (!attrs.viewBox && num(attrs.width) > 0 && num(attrs.height) > 0) {
|
|
85
|
+
tag = tag.replace(/<svg\b/i, `<svg viewBox="0 0 ${num(attrs.width)} ${num(attrs.height)}"`);
|
|
86
|
+
}
|
|
87
|
+
tag = tag.replace(/\s(width|height)="[^"]*"/g, '');
|
|
88
|
+
return svg.replace(/<svg\b[^>]*>/i, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a drawing has enough in it, or why not, in words a model can act on. */
|
|
92
|
+
function substance(svg) {
|
|
93
|
+
const attrs = rootAttrs(svg);
|
|
94
|
+
if (!attrs) return { ok: false, why: 'There is no <svg> root.', drawable: 0, parts: 0 };
|
|
95
|
+
const drawable = (svg.match(new RegExp(`<(${[...DRAWABLE].join('|')})\\b`, 'g')) || []).length;
|
|
96
|
+
const parts = (svg.match(/\bclass="[^"]*\bpart\b[^"]*"/g) || []).length;
|
|
97
|
+
const whys = [];
|
|
98
|
+
if (!attrs.viewBox) whys.push('the root has no viewBox');
|
|
99
|
+
if (attrs.width || attrs.height) whys.push('the root has a width or height');
|
|
100
|
+
if (drawable < MIN_DRAWABLE) whys.push(`it has ${drawable} drawable element${drawable === 1 ? '' : 's'}, and it needs at least ${MIN_DRAWABLE}`);
|
|
101
|
+
if (parts < MIN_PARTS) whys.push(`it has ${parts} <g class="part"> group${parts === 1 ? '' : 's'}, and it needs at least ${MIN_PARTS}`);
|
|
102
|
+
return { ok: !whys.length, why: whys.length ? `The drawing was not used: ${whys.join('; ')}.` : '', drawable, parts };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The classes a <style> animates. */
|
|
106
|
+
function animatedClasses(svg) {
|
|
107
|
+
const out = new Set();
|
|
108
|
+
for (const m of svg.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
109
|
+
for (const r of m[1].matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
110
|
+
if (!/animation/i.test(r[2])) continue;
|
|
111
|
+
for (const c of r[1].matchAll(/\.([A-Za-z_][\w-]*)/g)) out.add(c[1]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A CSS animation that sets transform overrides the element's own transform attribute, so a drawing that is
|
|
119
|
+
* placed with one jumps when it animates. Move such a transform onto a wrapping <g>. Returns { svg, moved }.
|
|
120
|
+
*/
|
|
121
|
+
function repairTransforms(svg, extra = []) {
|
|
122
|
+
const classes = animatedClasses(svg);
|
|
123
|
+
for (const c of extra) classes.add(c);
|
|
124
|
+
if (!classes.size) return { svg, moved: 0 };
|
|
125
|
+
const out = [];
|
|
126
|
+
const wraps = [];
|
|
127
|
+
let moved = 0;
|
|
128
|
+
for (const t of tokens(svg)) {
|
|
129
|
+
const tag = t.startsWith('<') && !t.startsWith('<!') && !t.startsWith('<?') ? parseTag(t) : null;
|
|
130
|
+
if (!tag) { out.push(t); continue; }
|
|
131
|
+
if (tag.close) {
|
|
132
|
+
out.push(t);
|
|
133
|
+
const w = wraps.pop();
|
|
134
|
+
if (w) out.push('</g>');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const cls = (tag.attrs.find(([k]) => k === 'class') || [])[1] || '';
|
|
138
|
+
const tf = tag.attrs.find(([k]) => k === 'transform');
|
|
139
|
+
const hit = tag.name !== 'svg' && tf && cls.split(/\s+/).some((c) => classes.has(c));
|
|
140
|
+
if (hit) {
|
|
141
|
+
moved += 1;
|
|
142
|
+
const rest = t.replace(/\stransform\s*=\s*("[^"]*"|'[^']*')/, '');
|
|
143
|
+
out.push(`<g transform="${tf[1].replace(/"/g, '"')}">`, rest);
|
|
144
|
+
if (tag.self) out.push('</g>'); else wraps.push(true);
|
|
145
|
+
} else {
|
|
146
|
+
out.push(t);
|
|
147
|
+
if (!tag.self) wraps.push(false);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { svg: out.join(''), moved };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The draw-in motion, written into the file so it plays anywhere: each part rises into place in turn, and its
|
|
155
|
+
* strokes are traced by length (pathLength="1", so no script has to measure them). Reduced motion gets the finished drawing.
|
|
156
|
+
*/
|
|
157
|
+
function addDrawMotion(svg, motion = MOTION) {
|
|
158
|
+
const out = [];
|
|
159
|
+
let depth = 0;
|
|
160
|
+
let part = -1;
|
|
161
|
+
let inPart = 0;
|
|
162
|
+
for (const t of tokens(svg)) {
|
|
163
|
+
const tag = t.startsWith('<') && !t.startsWith('<!') && !t.startsWith('<?') ? parseTag(t) : null;
|
|
164
|
+
if (!tag) { out.push(t); continue; }
|
|
165
|
+
if (tag.close) {
|
|
166
|
+
depth -= 1;
|
|
167
|
+
if (inPart && depth < inPart) inPart = 0;
|
|
168
|
+
out.push(t);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
let s = t;
|
|
172
|
+
const cls = (tag.attrs.find(([k]) => k === 'class') || [])[1] || '';
|
|
173
|
+
if (!inPart && /\bpart\b/.test(cls)) {
|
|
174
|
+
part += 1;
|
|
175
|
+
const style = (tag.attrs.find(([k]) => k === 'style') || [])[1];
|
|
176
|
+
s = style !== undefined ? s.replace(/\sstyle="[^"]*"/, ` style="--i:${part};${style.replace(/"/g, '"')}"`) : s.replace(/^<([\w:-]+)/, `<$1 style="--i:${part}"`);
|
|
177
|
+
if (!tag.self) inPart = depth + 1;
|
|
178
|
+
} else if (inPart && STROKED.has(tag.name) && !tag.attrs.some(([k]) => k === 'pathLength')) {
|
|
179
|
+
s = s.replace(/^<([\w:-]+)/, '<$1 pathLength="1"');
|
|
180
|
+
}
|
|
181
|
+
if (!tag.self) depth += 1;
|
|
182
|
+
out.push(s);
|
|
183
|
+
}
|
|
184
|
+
const d = Number(motion.duration) || MOTION.duration;
|
|
185
|
+
const st = Number(motion.stagger) || MOTION.stagger;
|
|
186
|
+
const css = `<style>@media (prefers-reduced-motion: no-preference){`
|
|
187
|
+
+ `.part{animation:part-rise ${d}ms cubic-bezier(.2,.7,.2,1) both;animation-delay:calc(var(--i,0)*${st}ms)}`
|
|
188
|
+
+ `.part [pathLength]{stroke-dasharray:1;stroke-dashoffset:1;animation:part-draw ${d}ms ease-out forwards;animation-delay:calc(var(--i,0)*${st}ms)}`
|
|
189
|
+
+ `@keyframes part-draw{to{stroke-dashoffset:0}}`
|
|
190
|
+
+ `@keyframes part-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}}</style>`;
|
|
191
|
+
return out.join('').replace(/(<svg\b[^>]*>)/i, `$1${css}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Everything a drawing goes through after the model: sanitise, fix the root, repair transforms, check it, and
|
|
196
|
+
* add the draw-in motion for that style. Returns { ok, svg, why, note, drawable, parts, moved }.
|
|
197
|
+
*/
|
|
198
|
+
function finish(raw, { style, motion = MOTION } = {}) {
|
|
199
|
+
const clean = sanitizeSvg(raw);
|
|
200
|
+
const note = removedSummary(clean);
|
|
201
|
+
if (!clean.svg) return { ok: false, why: 'The answer held no <svg> drawing. Answer with the whole SVG.', note };
|
|
202
|
+
if (clean.drawableLost) {
|
|
203
|
+
return { ok: false, note, why: `The drawing used markup that is not allowed (${note}), and ${clean.drawableLost} drawable element${clean.drawableLost === 1 ? ' was' : 's were'} lost with it. Draw it again with plain SVG shapes only.` };
|
|
204
|
+
}
|
|
205
|
+
const rooted = fixRoot(clean.svg);
|
|
206
|
+
const rep = repairTransforms(rooted, style === 'draw' ? ['part'] : []);
|
|
207
|
+
const chk = substance(rep.svg);
|
|
208
|
+
if (!chk.ok) return { ok: false, why: chk.why, note, drawable: chk.drawable, parts: chk.parts };
|
|
209
|
+
const svg = style === 'draw' ? addDrawMotion(rep.svg, motion) : rep.svg;
|
|
210
|
+
return { ok: true, svg, note, drawable: chk.drawable, parts: chk.parts, moved: rep.moved };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { STYLES, SHAPES, SHAPE_LABELS, MOTION, MIN_DRAWABLE, MIN_PARTS, buildPrompt, parseReply, paletteLine,
|
|
214
|
+
substance, fixRoot, repairTransforms, animatedClasses, addDrawMotion, finish };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An allow-list SVG sanitiser, for markup an AI wrote. It keeps drawing, text, gradients, styles and
|
|
3
|
+
* animation; it drops scripts, foreignObject, embedded images, event handlers and every reference that leaves
|
|
4
|
+
* the file. It says what it removed, so a caller can tell a harmless clean-up from a drawing that lost its parts.
|
|
5
|
+
* No dependencies: the host-side finish step of a Claude Code drawing runs it too.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DRAWABLE = new Set(['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'use']);
|
|
9
|
+
|
|
10
|
+
const ELEMENTS = new Set([
|
|
11
|
+
'svg', 'g', 'defs', 'symbol', 'use', 'title', 'desc', 'metadata', 'style',
|
|
12
|
+
'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'textPath',
|
|
13
|
+
'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker',
|
|
14
|
+
'filter', 'feGaussianBlur', 'feOffset', 'feBlend', 'feColorMatrix', 'feComposite', 'feFlood', 'feMerge',
|
|
15
|
+
'feMergeNode', 'feDropShadow', 'feMorphology', 'feTurbulence', 'feDisplacementMap',
|
|
16
|
+
'animate', 'animateTransform', 'animateMotion', 'set', 'mpath',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Removed with everything inside them.
|
|
20
|
+
const DROP = new Set(['script', 'foreignObject', 'image', 'iframe', 'object', 'embed', 'audio', 'video', 'canvas', 'handler', 'listener']);
|
|
21
|
+
// Removed, their children kept.
|
|
22
|
+
const UNWRAP = new Set(['a', 'switch']);
|
|
23
|
+
|
|
24
|
+
const ATTRS = new Set([
|
|
25
|
+
'id', 'class', 'style', 'viewBox', 'preserveAspectRatio', 'xmlns', 'xmlns:xlink', 'version', 'role', 'aria-label',
|
|
26
|
+
'aria-hidden', 'aria-labelledby', 'aria-describedby', 'focusable', 'lang', 'xml:space', 'href', 'xlink:href',
|
|
27
|
+
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy', 'fr', 'd', 'points', 'width', 'height',
|
|
28
|
+
'dx', 'dy', 'rotate', 'textLength', 'lengthAdjust', 'pathLength', 'startOffset', 'method', 'spacing', 'side',
|
|
29
|
+
'transform', 'transform-origin', 'opacity', 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width',
|
|
30
|
+
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity',
|
|
31
|
+
'vector-effect', 'paint-order', 'color', 'display', 'visibility', 'overflow', 'clip-path', 'clip-rule', 'mask', 'filter',
|
|
32
|
+
'marker-start', 'marker-mid', 'marker-end', 'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing',
|
|
33
|
+
'word-spacing', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'text-decoration',
|
|
34
|
+
'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'spreadMethod',
|
|
35
|
+
'patternUnits', 'patternContentUnits', 'patternTransform', 'clipPathUnits', 'maskUnits', 'maskContentUnits',
|
|
36
|
+
'markerWidth', 'markerHeight', 'markerUnits', 'refX', 'refY', 'orient',
|
|
37
|
+
'filterUnits', 'primitiveUnits', 'in', 'in2', 'result', 'stdDeviation', 'mode', 'values', 'type', 'operator',
|
|
38
|
+
'k1', 'k2', 'k3', 'k4', 'flood-color', 'flood-opacity', 'radius', 'baseFrequency', 'numOctaves', 'seed',
|
|
39
|
+
'stitchTiles', 'scale', 'xChannelSelector', 'yChannelSelector',
|
|
40
|
+
'attributeName', 'attributeType', 'from', 'to', 'by', 'begin', 'dur', 'end', 'repeatCount', 'repeatDur', 'fill',
|
|
41
|
+
'calcMode', 'keyTimes', 'keySplines', 'keyPoints', 'additive', 'accumulate', 'restart', 'min', 'max', 'path',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// An animation may not change these: a link or a handler set by SMIL is a script by another route.
|
|
45
|
+
const UNANIMATABLE = /^(href|xlink:href|on.*|style|class)$/i;
|
|
46
|
+
|
|
47
|
+
const escAttr = (v) => String(v).replace(/&(?!(#\d+|#x[0-9a-f]+|[a-z]+);)/gi, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
48
|
+
|
|
49
|
+
/** CSS with every way out of the file removed. Returns [css, changed]. */
|
|
50
|
+
function cleanCss(css) {
|
|
51
|
+
const before = css;
|
|
52
|
+
const out = css
|
|
53
|
+
.replace(/@import[^;]*;?/gi, '')
|
|
54
|
+
.replace(/url\(\s*(['"]?)(?!#)[^)]*\1\s*\)/gi, 'none')
|
|
55
|
+
.replace(/expression\s*\(/gi, '(')
|
|
56
|
+
.replace(/-moz-binding|behavior\s*:/gi, '')
|
|
57
|
+
.replace(/javascript:/gi, '');
|
|
58
|
+
return [out, out !== before];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An attribute value that refers to something: only a fragment in this file is allowed. */
|
|
62
|
+
function localRef(v) {
|
|
63
|
+
return /^\s*#[A-Za-z_][\w.:-]*\s*$/.test(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every tag and the text between them, in order. */
|
|
67
|
+
function tokens(src) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const re = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\?[\s\S]*?\?>|<\/?[A-Za-z][^>]*>|[^<]+|</g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = re.exec(src))) out.push(m[0]);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseTag(t) {
|
|
76
|
+
const m = t.match(/^<(\/?)([A-Za-z][\w:.-]*)([\s\S]*?)(\/?)>$/);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
const attrs = [];
|
|
79
|
+
const re = /([^\s=/>]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
|
|
80
|
+
let a;
|
|
81
|
+
while ((a = re.exec(m[3]))) attrs.push([a[1], a[2] === undefined ? '' : a[2].replace(/^["']|["']$/g, '')]);
|
|
82
|
+
return { close: !!m[1], name: m[2], attrs, self: !!m[4] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sanitise SVG markup. Returns { svg, removed: { elements: {name: n}, attributes: n, css: n }, drawableLost, drawable }.
|
|
87
|
+
* `svg` is '' when there is no <svg> root at all.
|
|
88
|
+
*/
|
|
89
|
+
function sanitizeSvg(input) {
|
|
90
|
+
const src = String(input || '');
|
|
91
|
+
const start = src.search(/<svg[\s>]/i);
|
|
92
|
+
const end = src.lastIndexOf('</svg>');
|
|
93
|
+
const removed = { elements: {}, attributes: 0, css: 0 };
|
|
94
|
+
if (start < 0 || end < start) return { svg: '', removed, drawableLost: 0, drawable: 0 };
|
|
95
|
+
const parts = [];
|
|
96
|
+
const stack = []; // the open elements: { name, keep }
|
|
97
|
+
let dropDepth = 0;
|
|
98
|
+
let drawable = 0;
|
|
99
|
+
let drawableLost = 0;
|
|
100
|
+
const note = (n) => { removed.elements[n] = (removed.elements[n] || 0) + 1; };
|
|
101
|
+
const inStyle = () => stack.length && stack[stack.length - 1].name === 'style' && stack[stack.length - 1].keep;
|
|
102
|
+
for (const t of tokens(src.slice(start, end + 6))) {
|
|
103
|
+
if (t.startsWith('<!--') || t.startsWith('<?') || (t.startsWith('<!') && !t.startsWith('<![CDATA['))) continue;
|
|
104
|
+
if (!t.startsWith('<') || t.startsWith('<![CDATA[') || t === '<') {
|
|
105
|
+
if (dropDepth) continue;
|
|
106
|
+
if (t === '<') { parts.push('<'); continue; }
|
|
107
|
+
if (inStyle()) {
|
|
108
|
+
const [css, changed] = cleanCss(t);
|
|
109
|
+
if (changed) removed.css += 1;
|
|
110
|
+
parts.push(css);
|
|
111
|
+
} else if (t.startsWith('<![CDATA[')) {
|
|
112
|
+
parts.push(t.slice(9, -3).replace(/&/g, '&').replace(/</g, '<'));
|
|
113
|
+
} else {
|
|
114
|
+
parts.push(t);
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const tag = parseTag(t);
|
|
119
|
+
if (!tag) continue;
|
|
120
|
+
if (tag.close) {
|
|
121
|
+
const open = stack.pop();
|
|
122
|
+
if (!open) continue;
|
|
123
|
+
if (open.drop) { dropDepth -= 1; continue; }
|
|
124
|
+
if (!dropDepth && open.keep) parts.push(`</${open.name}>`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const name = tag.name;
|
|
128
|
+
const known = ELEMENTS.has(name);
|
|
129
|
+
const drop = dropDepth > 0 || DROP.has(name) || (!known && !UNWRAP.has(name));
|
|
130
|
+
if (drop) {
|
|
131
|
+
if (DRAWABLE.has(name)) drawableLost += 1;
|
|
132
|
+
if (!dropDepth) note(name);
|
|
133
|
+
if (!tag.self) { stack.push({ name, drop: true }); dropDepth += 1; }
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (UNWRAP.has(name)) {
|
|
137
|
+
note(name);
|
|
138
|
+
if (!tag.self) stack.push({ name, keep: false });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// An animation that would set a link, a handler or a style is dropped whole.
|
|
142
|
+
const target = (tag.attrs.find(([k]) => k === 'attributeName') || [])[1];
|
|
143
|
+
if (/^(animate|set|animateTransform|animateMotion)$/.test(name) && target && UNANIMATABLE.test(target)) {
|
|
144
|
+
note(name);
|
|
145
|
+
if (!tag.self) { stack.push({ name, drop: true }); dropDepth += 1; }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const kept = [];
|
|
149
|
+
for (const [k, raw] of tag.attrs) {
|
|
150
|
+
const v = raw;
|
|
151
|
+
if (!ATTRS.has(k) && !/^data-[\w-]+$/.test(k)) { removed.attributes += 1; continue; }
|
|
152
|
+
if ((k === 'href' || k === 'xlink:href') && !localRef(v)) { removed.attributes += 1; continue; }
|
|
153
|
+
if (/javascript:|vbscript:|data:/i.test(v) && k !== 'd') { removed.attributes += 1; continue; }
|
|
154
|
+
if (/url\(/i.test(v)) {
|
|
155
|
+
const urls = v.match(/url\(\s*(['"]?)([^)'"]*)\1\s*\)/gi) || [];
|
|
156
|
+
if (urls.some((u) => !/url\(\s*['"]?#/i.test(u))) { removed.attributes += 1; continue; }
|
|
157
|
+
}
|
|
158
|
+
let value = v;
|
|
159
|
+
if (k === 'style') {
|
|
160
|
+
const [css, changed] = cleanCss(v);
|
|
161
|
+
if (changed) removed.css += 1;
|
|
162
|
+
value = css;
|
|
163
|
+
}
|
|
164
|
+
kept.push(`${k}="${escAttr(value)}"`);
|
|
165
|
+
}
|
|
166
|
+
if (name === 'svg' && !kept.some((a) => a.startsWith('xmlns='))) kept.unshift('xmlns="http://www.w3.org/2000/svg"');
|
|
167
|
+
if (DRAWABLE.has(name)) drawable += 1;
|
|
168
|
+
parts.push(`<${name}${kept.length ? ` ${kept.join(' ')}` : ''}${tag.self ? '/>' : '>'}`);
|
|
169
|
+
if (!tag.self) stack.push({ name, keep: true });
|
|
170
|
+
}
|
|
171
|
+
while (stack.length) {
|
|
172
|
+
const open = stack.pop();
|
|
173
|
+
if (open.keep) parts.push(`</${open.name}>`);
|
|
174
|
+
}
|
|
175
|
+
return { svg: parts.join(''), removed, drawableLost, drawable };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A one-line account of what was removed, or '' when nothing was. */
|
|
179
|
+
function removedSummary(r) {
|
|
180
|
+
const els = Object.entries(r.removed.elements).map(([k, n]) => `${n} ${k}`);
|
|
181
|
+
const bits = [];
|
|
182
|
+
if (els.length) bits.push(`elements: ${els.join(', ')}`);
|
|
183
|
+
if (r.removed.attributes) bits.push(`${r.removed.attributes} attribute${r.removed.attributes === 1 ? '' : 's'}`);
|
|
184
|
+
if (r.removed.css) bits.push(`${r.removed.css} unsafe style rule${r.removed.css === 1 ? '' : 's'}`);
|
|
185
|
+
return bits.join('; ');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { sanitizeSvg, removedSummary, cleanCss, tokens, parseTag, DRAWABLE, ELEMENTS };
|
package/images-svg.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate SVG: a drawing, still or animated, written by a text model as SVG code, sanitised, checked for
|
|
3
|
+
* substance and kept on brand from the `svg:` block of workspace.images.settings.yml. A thin or unsafe answer is
|
|
4
|
+
* retried with the reason, within max_attempts and the time budget. Saved into images/svg/ with its records.
|
|
5
|
+
* A paid call goes out only with confirm=yes, which only the confirm dialog's Yes sends.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const yaml = require('js-yaml');
|
|
11
|
+
|
|
12
|
+
const { esc } = require('./html');
|
|
13
|
+
const { CONFIG_DIR, workspaceDir } = require('./workspaces');
|
|
14
|
+
const images = require('./images');
|
|
15
|
+
const providers = require('./images-providers');
|
|
16
|
+
const core = require('./images-svg-core');
|
|
17
|
+
const { writeRecords } = require('./images-records');
|
|
18
|
+
|
|
19
|
+
const PALETTE_KEYS = ['primary', 'secondary', 'accent', 'dark', 'light'];
|
|
20
|
+
const HEX = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
21
|
+
const DEFAULTS = { brief: '', palette: 'brand-tokens', max_attempts: 3, request_timeout: 180, time_budget: 240, motion: core.MOTION, examples: [] };
|
|
22
|
+
const MIN_ATTEMPT_MS = 30000;
|
|
23
|
+
|
|
24
|
+
/** The brand tokens' five colours. */
|
|
25
|
+
function brandPalette() {
|
|
26
|
+
let t = {};
|
|
27
|
+
try { t = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'brand-tokens.json'), 'utf8')).tokens || {}; } catch (_) { /* fall back */ }
|
|
28
|
+
const fallback = { primary: '#0074e0', secondary: '#2b447c', accent: '#e4dcff', dark: '#000000', light: '#ebe8e5' };
|
|
29
|
+
return Object.fromEntries(PALETTE_KEYS.map((k) => [k, HEX.test(t[`brand-${k}`] || '') ? t[`brand-${k}`] : fallback[k]]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `svg:` settings, with defaults for anything missing or out of range. */
|
|
33
|
+
function config() {
|
|
34
|
+
let raw = {};
|
|
35
|
+
try { raw = (yaml.load(fs.readFileSync(path.join(CONFIG_DIR, 'workspace.images.settings.yml'), 'utf8')) || {}).svg || {}; } catch (_) { /* defaults */ }
|
|
36
|
+
const num = (v, d, lo, hi) => (Number.isFinite(Number(v)) ? Math.min(hi, Math.max(lo, Number(v))) : d);
|
|
37
|
+
const palette = brandPalette();
|
|
38
|
+
if (raw.palette && typeof raw.palette === 'object') for (const k of PALETTE_KEYS) if (HEX.test(String(raw.palette[k] || ''))) palette[k] = String(raw.palette[k]);
|
|
39
|
+
const m = raw.motion && typeof raw.motion === 'object' ? raw.motion : {};
|
|
40
|
+
return {
|
|
41
|
+
brief: String(raw.brief || DEFAULTS.brief).trim().slice(0, 1000),
|
|
42
|
+
palette,
|
|
43
|
+
max_attempts: num(raw.max_attempts, DEFAULTS.max_attempts, 1, 5),
|
|
44
|
+
request_timeout: num(raw.request_timeout, DEFAULTS.request_timeout, 30, 600),
|
|
45
|
+
time_budget: num(raw.time_budget, DEFAULTS.time_budget, 30, 1200),
|
|
46
|
+
motion: { ...core.MOTION, duration: num(m.duration, core.MOTION.duration, 200, 5000), stagger: num(m.stagger, core.MOTION.stagger, 0, 1000),
|
|
47
|
+
threshold: num(m.threshold, core.MOTION.threshold, 0, 1), replay: m.replay !== false },
|
|
48
|
+
examples: (Array.isArray(raw.examples) ? raw.examples : []).filter((e) => e && e.title && e.prompt).slice(0, 20)
|
|
49
|
+
.map((e) => ({ title: String(e.title).slice(0, 80), prompt: String(e.prompt).slice(0, 2000), style: core.STYLES[e.style] ? e.style : 'draw' })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The palette for one run: the configured one, with any colour the form changed. */
|
|
54
|
+
function paletteFor(form, cfg) {
|
|
55
|
+
const p = { ...cfg.palette };
|
|
56
|
+
for (const k of PALETTE_KEYS) if (HEX.test(String(form[`palette_${k}`] || ''))) p[k] = String(form[`palette_${k}`]).toLowerCase();
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A request, checked, or why not. */
|
|
61
|
+
function plan(form) {
|
|
62
|
+
const cfg = config();
|
|
63
|
+
const prompt = String(form.prompt || '').trim();
|
|
64
|
+
if (!prompt) return { ok: false, error: 'Say what the drawing should show.' };
|
|
65
|
+
if (prompt.length > 2000) return { ok: false, error: 'The prompt can be 2000 characters at most.' };
|
|
66
|
+
const prov = providers.pick(form.provider, 'svg');
|
|
67
|
+
const model = prov.svgModels[form.model] ? String(form.model) : prov.defaultSvgModel;
|
|
68
|
+
const style = core.STYLES[form.style] ? String(form.style) : 'draw';
|
|
69
|
+
const shape = core.SHAPES[form.shape] ? String(form.shape) : 'wide';
|
|
70
|
+
const name = images.safeName(form.name || '').replace(/\.[^.]*$/, '')
|
|
71
|
+
|| prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'drawing';
|
|
72
|
+
const per = prov.svgEstimate({ model });
|
|
73
|
+
return { ok: true, provider: prov.id, prompt, model, style, shape, name, palette: paletteFor(form, cfg), png: form.png === 'yes',
|
|
74
|
+
attempts: cfg.max_attempts, estimate: per === null ? null : per, cfg };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The folder drawings go into. */
|
|
78
|
+
const svgDir = () => path.join(workspaceDir('images'), 'svg');
|
|
79
|
+
|
|
80
|
+
/** Save a finished drawing with its records, and its PNG preview when asked. */
|
|
81
|
+
function save(dir, p, done, extra) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
const file = images.freeName(dir, p.name, 'svg');
|
|
84
|
+
const abs = path.join(dir, file);
|
|
85
|
+
fs.writeFileSync(abs, done.svg);
|
|
86
|
+
let png = '';
|
|
87
|
+
if (p.png) {
|
|
88
|
+
const pngPath = abs.replace(/\.svg$/, '.png');
|
|
89
|
+
if (require('./images-render').renderPng(abs, pngPath, core.SHAPES[p.shape])) png = path.basename(pngPath);
|
|
90
|
+
}
|
|
91
|
+
writeRecords(abs, { prompt: p.prompt, style: core.STYLES[p.style], palette: core.paletteLine(p.palette), size: core.SHAPES[p.shape].join('x'),
|
|
92
|
+
created: new Date().toISOString(), png: png || undefined, motion: p.style === 'draw' ? p.cfg.motion : undefined,
|
|
93
|
+
sanitised: done.note || undefined, ...extra });
|
|
94
|
+
images.forgetScan();
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Generate a drawing. Only with confirm=yes. Returns { ok, rel, cost, attempts } or a Claude Code session. */
|
|
99
|
+
async function generateSvg(form, opts = {}) {
|
|
100
|
+
if (form.confirm !== 'yes') return { ok: false, status: 400, error: 'Generating needs confirming first.' };
|
|
101
|
+
const p = plan(form);
|
|
102
|
+
if (!p.ok) return { ok: false, status: 400, error: p.error };
|
|
103
|
+
const prov = providers.get(p.provider);
|
|
104
|
+
const dir = opts.dir || svgDir();
|
|
105
|
+
if (prov.async) {
|
|
106
|
+
if (!prov.available().ok) return { ok: false, status: 503, error: `Claude Code is not available: ${prov.available().why}.` };
|
|
107
|
+
const name = images.freeName(dir, p.name, 'svg').replace(/\.svg$/, '');
|
|
108
|
+
const { system, user } = core.buildPrompt({ ...p, brief: p.cfg.brief, files: { svg: `${name}.svg`, words: `${name}.claude.json` } });
|
|
109
|
+
const start = opts.start || require('./jobs').hostRunnerRequest;
|
|
110
|
+
const r = await prov.startSession({ kind: 'svg', name, dir, prompt: p.prompt, fullPrompt: `${system}\n\n${user}`, style: p.style,
|
|
111
|
+
palette: core.paletteLine(p.palette), motion: p.cfg.motion, attempts: p.attempts, shape: p.shape, pixels: core.SHAPES[p.shape],
|
|
112
|
+
png: p.png, created: new Date().toISOString() }, start);
|
|
113
|
+
if (!r.ok) return { ok: false, status: 503, error: r.error };
|
|
114
|
+
return { ok: true, async: true, session: r.session };
|
|
115
|
+
}
|
|
116
|
+
const key = opts.key || prov.key();
|
|
117
|
+
if (!key) return { ok: false, status: 503, error: `Set ai_providers.${prov.keyName}.api_key in core/config/settings.yml first.` };
|
|
118
|
+
const { system, user } = core.buildPrompt({ ...p, brief: p.cfg.brief });
|
|
119
|
+
const messages = [{ role: 'user', content: user }];
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
const budget = p.cfg.time_budget * 1000;
|
|
122
|
+
let cost = 0;
|
|
123
|
+
let priced = true;
|
|
124
|
+
const tries = [];
|
|
125
|
+
for (let n = 1; n <= p.attempts; n += 1) {
|
|
126
|
+
const left = budget - (Date.now() - started);
|
|
127
|
+
if (n > 1 && left < MIN_ATTEMPT_MS) break;
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await prov.svg({ system, messages, model: p.model }, key, { timeoutMs: Math.min(p.cfg.request_timeout * 1000, Math.max(MIN_ATTEMPT_MS, left)) });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, status: 502, error: `${prov.label} refused it: ${e.message}`, attempts: tries };
|
|
133
|
+
}
|
|
134
|
+
if (res.cost === null || res.cost === undefined) priced = false; else cost += res.cost;
|
|
135
|
+
const reply = core.parseReply(res.text);
|
|
136
|
+
const done = core.finish(reply.svg, { style: p.style, motion: p.cfg.motion });
|
|
137
|
+
tries.push(done.ok ? 'ok' : done.why);
|
|
138
|
+
if (done.ok) {
|
|
139
|
+
const file = save(dir, p, done, { provider: prov.label, model: res.model, attempts: n, summary: reply.summary, alt: reply.alt,
|
|
140
|
+
described_by: reply.summary || reply.alt ? `${res.model}, in the same answer` : undefined,
|
|
141
|
+
cost: priced ? `about $${cost.toFixed(4)}` : undefined });
|
|
142
|
+
const root = fs.realpathSync(workspaceDir('images'));
|
|
143
|
+
const rel = path.relative(root, fs.realpathSync(path.join(dir, file))).split(path.sep).join('/');
|
|
144
|
+
return { ok: true, rel, file, cost: priced ? cost : null, attempts: n };
|
|
145
|
+
}
|
|
146
|
+
messages.push({ role: 'assistant', content: String(res.text || '').slice(0, 20000) }, { role: 'user', content: `${done.why} Answer again with the whole JSON.` });
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, status: 502, error: `No usable drawing after ${tries.length} attempt${tries.length === 1 ? '' : 's'}: ${tries[tries.length - 1] || 'out of time'}`,
|
|
149
|
+
cost: priced ? cost : null, attempts: tries };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ------------------------------------------------------------------- forms --
|
|
153
|
+
|
|
154
|
+
const opt = (o, chosen) => Object.entries(o).map(([v, l]) => `<option value="${esc(v)}"${v === chosen ? ' selected' : ''}>${esc(l)}</option>`).join('');
|
|
155
|
+
|
|
156
|
+
/** The provider's own fields: its models, and what it costs. */
|
|
157
|
+
function providerFieldsHtml(id) {
|
|
158
|
+
const prov = providers.pick(id, 'svg');
|
|
159
|
+
const note = prov.async
|
|
160
|
+
? 'Claude Code writes the SVG under your own Claude Code login, with no API charge. It runs as a screen session you can watch on Screens.'
|
|
161
|
+
: prov.svgEstimate({}) === null ? `${esc(prov.label)} publishes no per-drawing figure on the page this was built from; the real cost is not reported back.`
|
|
162
|
+
: `About $${prov.svgEstimate({}).toFixed(3)} an attempt; the real cost is shown after.`;
|
|
163
|
+
return `<label class="uk-form-label" for="svg-model">Model</label>
|
|
164
|
+
<select class="uk-select uk-form-small" id="svg-model" name="model">${opt(prov.svgModels, prov.defaultSvgModel)}</select>
|
|
165
|
+
<p class="uk-text-meta uk-margin-small-top">${note}</p>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formHtml() {
|
|
169
|
+
const cfg = config();
|
|
170
|
+
const list = providers.list('svg');
|
|
171
|
+
const ready = list.find((p) => p.available().ok);
|
|
172
|
+
const sel = list.map((p) => {
|
|
173
|
+
const a = p.available();
|
|
174
|
+
return `<option value="${esc(p.id)}"${ready && p.id === ready.id ? ' selected' : ''}${a.ok ? '' : ' disabled'}>${esc(p.label)}${a.ok ? '' : ` (${esc(a.why)})`}</option>`;
|
|
175
|
+
}).join('');
|
|
176
|
+
const examples = cfg.examples.map((e, i) => `<option value="${i}" data-prompt="${esc(e.prompt)}" data-style="${esc(e.style)}">${esc(e.title)}</option>`).join('');
|
|
177
|
+
const colours = PALETTE_KEYS.map((k) => `<label class="svg-swatch"><input type="color" name="palette_${k}" value="${esc(cfg.palette[k])}"> ${k}</label>`).join('');
|
|
178
|
+
return `
|
|
179
|
+
<div class="editor-card img-ai img-svg">
|
|
180
|
+
<h3 class="uk-margin-small-bottom">Generate SVG</h3>
|
|
181
|
+
${ready ? '' : '<div class="msg error">No SVG provider is set up on this machine: set an <code>ai_providers.<provider>.api_key</code> in <code>core/config/settings.yml</code>, or install the claude CLI.</div>'}
|
|
182
|
+
<form hx-post="/actions/image-svg" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
183
|
+
<input type="hidden" name="workspace" value="images">
|
|
184
|
+
<div class="uk-grid-small uk-child-width-1-2@s" uk-grid>
|
|
185
|
+
<div><label class="uk-form-label" for="svg-provider">Provider</label>
|
|
186
|
+
<select class="uk-select uk-form-small" id="svg-provider" name="provider"
|
|
187
|
+
hx-get="/fragments/images/svg-provider-fields" hx-trigger="change" hx-target="#svg-fields" hx-swap="innerHTML" hx-include="this">${sel}</select></div>
|
|
188
|
+
<div id="svg-fields">${providerFieldsHtml(ready ? ready.id : '')}</div>
|
|
189
|
+
</div>
|
|
190
|
+
${examples ? `<label class="uk-form-label uk-margin-small-top" for="svg-example">Start from an example</label>
|
|
191
|
+
<select class="uk-select uk-form-small" id="svg-example" data-svg-example><option value="">Choose an example…</option>${examples}</select>` : ''}
|
|
192
|
+
<label class="uk-form-label uk-margin-small-top" for="svg-prompt">What should it show?</label>
|
|
193
|
+
<textarea class="uk-textarea" id="svg-prompt" name="prompt" rows="4" maxlength="2000" required data-ace="markdown"></textarea>
|
|
194
|
+
<div class="uk-grid-small uk-child-width-1-3@s uk-margin-small-top" uk-grid>
|
|
195
|
+
<div><label class="uk-form-label" for="svg-style">Style</label>
|
|
196
|
+
<select class="uk-select uk-form-small" id="svg-style" name="style">${opt(core.STYLES, 'draw')}</select></div>
|
|
197
|
+
<div><label class="uk-form-label" for="svg-shape">Shape</label>
|
|
198
|
+
<select class="uk-select uk-form-small" id="svg-shape" name="shape">${opt(core.SHAPE_LABELS, 'wide')}</select></div>
|
|
199
|
+
<div><label class="uk-form-label" for="svg-name">File name</label>
|
|
200
|
+
<input class="uk-input uk-form-small" id="svg-name" name="name" maxlength="80"></div>
|
|
201
|
+
</div>
|
|
202
|
+
<fieldset class="svg-palette uk-margin-small-top"><legend class="uk-form-label">Palette, for this drawing (from the brand tokens)</legend>${colours}</fieldset>
|
|
203
|
+
<label class="uk-margin-small-top uk-display-block"><input class="uk-checkbox" type="checkbox" name="png" value="yes"> Also save a PNG preview</label>
|
|
204
|
+
<p class="uk-margin-small-top"><button class="uk-button uk-button-primary" type="submit"${ready ? '' : ' disabled'}>See the plan</button>
|
|
205
|
+
<span class="uk-text-meta">Saved into <code>images/svg/</code>. Nothing is sent until you confirm the plan.</span></p>
|
|
206
|
+
</form>
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function planHtml(p) {
|
|
211
|
+
const prov = providers.get(p.provider);
|
|
212
|
+
const vals = esc(JSON.stringify({ workspace: 'images', prompt: p.prompt, provider: p.provider, model: p.model, style: p.style, shape: p.shape,
|
|
213
|
+
name: p.name, png: p.png ? 'yes' : '', confirm: 'yes', ...Object.fromEntries(Object.entries(p.palette).map(([k, v]) => [`palette_${k}`, v])) }));
|
|
214
|
+
const cost = prov.async ? 'No API charge: it uses your own Claude Code login.'
|
|
215
|
+
: p.estimate === null ? `${esc(prov.label)} publishes no per-drawing figure on the page this was built from; check its pricing page.`
|
|
216
|
+
: `About $${p.estimate.toFixed(3)} an attempt, at most ${p.attempts} attempts (about $${(p.estimate * p.attempts).toFixed(3)}); the real cost is shown after.`;
|
|
217
|
+
const swatches = Object.entries(p.palette).map(([k, v]) => `<span class="svg-chip" style="background:${esc(v)}" title="${esc(k)} ${esc(v)}"></span>`).join('');
|
|
218
|
+
return `
|
|
219
|
+
<div class="editor-card img-ai-plan">
|
|
220
|
+
<h3 class="uk-margin-small-bottom">The plan: generate an SVG</h3>
|
|
221
|
+
<dl class="img-info">
|
|
222
|
+
<dt>Provider</dt><dd>${esc(prov.label)}, ${esc(prov.svgModels[p.model] || p.model)}</dd>
|
|
223
|
+
<dt>Style</dt><dd>${esc(core.STYLES[p.style])}${p.style === 'draw' ? `: parts rise in turn and their strokes are traced (${p.cfg.motion.duration} ms, ${p.cfg.motion.stagger} ms apart)` : ''}; reduced motion shows the finished drawing</dd>
|
|
224
|
+
<dt>Size</dt><dd>${esc(core.SHAPE_LABELS[p.shape])}</dd>
|
|
225
|
+
<dt>Palette</dt><dd>${swatches} <code>${esc(core.paletteLine(p.palette))}</code></dd>
|
|
226
|
+
<dt>Saved as</dt><dd><code>images/svg/${esc(p.name)}.svg</code>${p.png ? ' and a PNG preview' : ''}, with its prompt, summary and alt text</dd>
|
|
227
|
+
<dt>Prompt</dt><dd>${esc(p.prompt)}</dd>
|
|
228
|
+
<dt>Checks</dt><dd>Sanitised (no scripts, handlers or outside links), at least ${core.MIN_DRAWABLE} shapes in ${core.MIN_PARTS} parts; a thin or unsafe answer is retried with the reason, up to ${p.attempts} attempts${prov.async ? '' : ` within ${p.cfg.time_budget} s`}</dd>
|
|
229
|
+
<dt>Cost</dt><dd>${cost}</dd>
|
|
230
|
+
${prov.async ? '<dt>Where</dt><dd>A screen session: watch it on Screens. The drawing appears in <code>images/svg/</code> when it is done.</dd>' : ''}
|
|
231
|
+
</dl>
|
|
232
|
+
<p>
|
|
233
|
+
<button type="button" class="uk-button uk-button-primary arm-step" title="${prov.async ? 'Start a Claude Code session to draw it' : `Generate an SVG with ${esc(prov.label)} (paid)`}"
|
|
234
|
+
hx-post="/actions/image-svg" hx-trigger="confirmed-remove" hx-vals="${vals}"
|
|
235
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">${prov.async ? 'Start drawing' : 'Generate'}</button>
|
|
236
|
+
<button type="button" class="uk-button uk-button-default" hx-get="/fragments/images/svg-form" hx-target="#editor-modal-body" hx-swap="innerHTML">Change it</button>
|
|
237
|
+
</p>
|
|
238
|
+
</div>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { PALETTE_KEYS, config, brandPalette, paletteFor, plan, generateSvg, formHtml, planHtml, providerFieldsHtml, svgDir };
|