pindeck 3.0.0-beta.1 → 3.0.0-beta.2
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/README.md +217 -44
- package/cli.mjs +6 -3
- package/lib/agent-process.mjs +7 -3
- package/lib/agent-tools.mjs +32 -9
- package/lib/capabilities.mjs +121 -0
- package/lib/claude.mjs +7 -3
- package/lib/codex.mjs +4 -2
- package/lib/deck-files.mjs +94 -0
- package/lib/frameworks.mjs +41 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +2 -1
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/editor.css +1 -0
- package/public/editor.js +79 -22
- package/public/index.html +2 -2
- package/server.mjs +599 -166
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* What this server can do on this machine, decided once on the backend and used everywhere: the editor
|
|
2
|
+
(status), the API (refusals before any side effect), the CLI (its error text) and the assistant's system prompt.
|
|
3
|
+
|
|
4
|
+
macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
|
|
5
|
+
pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
|
|
6
|
+
waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
|
|
7
|
+
layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
|
|
8
|
+
turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
|
|
14
|
+
export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
|
|
15
|
+
export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux (beta)" }[platform] || platform);
|
|
16
|
+
|
|
17
|
+
const CHROME_PATHS = {
|
|
18
|
+
darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
19
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
20
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
21
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
|
22
|
+
linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
|
23
|
+
"/snap/bin/chromium"],
|
|
24
|
+
};
|
|
25
|
+
const OFFICE_APPS = ["/Applications/Microsoft PowerPoint.app", "/Applications/Keynote.app"];
|
|
26
|
+
|
|
27
|
+
const isExecutable = (p) => {
|
|
28
|
+
try { return fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true); } catch { return false; }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/* An executable by path, or by name on PATH. Never runs it. Returns { path } or { path: null, reason }. */
|
|
32
|
+
export function findExecutable(name, { env = process.env } = {}) {
|
|
33
|
+
if (name.includes("/")) {
|
|
34
|
+
if (isExecutable(name)) return { path: name };
|
|
35
|
+
return { path: null, reason: fs.existsSync(name) ? `${name} is not an executable file` : `${name} does not exist` };
|
|
36
|
+
}
|
|
37
|
+
for (const dir of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
38
|
+
const p = path.join(dir, name);
|
|
39
|
+
if (isExecutable(p)) return { path: p };
|
|
40
|
+
}
|
|
41
|
+
return { path: null, reason: `no "${name}" command on the PATH` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const on = () => ({ available: true, reason: "" });
|
|
45
|
+
const off = (reason) => ({ available: false, reason });
|
|
46
|
+
|
|
47
|
+
export function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
|
|
48
|
+
const mac = platform === "darwin";
|
|
49
|
+
const caps = { platform, label: platformLabel(platform) };
|
|
50
|
+
|
|
51
|
+
const claudeBin = env.CLAUDE_BIN || "claude";
|
|
52
|
+
const claude = findExecutable(claudeBin, { env });
|
|
53
|
+
caps.claude = claude.path ? on() : off(`Claude Code is not available on the server (${claude.reason}). ` +
|
|
54
|
+
`Install and log in to the claude CLI there, or set CLAUDE_BIN. Editing by hand still works.`);
|
|
55
|
+
const codex = findExecutable(env.CODEX_BIN || "codex", { env });
|
|
56
|
+
caps.codex = codex.path ? on() : off(`OpenAI Codex is not available on the server (${codex.reason}). ` +
|
|
57
|
+
`Install and log in to the codex CLI there, or set CODEX_BIN. Editing by hand still works.`);
|
|
58
|
+
|
|
59
|
+
let chrome = null, chromeWhy = "";
|
|
60
|
+
if (env.CHROME_BIN) {
|
|
61
|
+
const c = findExecutable(env.CHROME_BIN, { env });
|
|
62
|
+
chrome = c.path;
|
|
63
|
+
chromeWhy = c.reason ? `CHROME_BIN: ${c.reason}` : "";
|
|
64
|
+
} else {
|
|
65
|
+
chrome = (CHROME_PATHS[platform] || []).find(isExecutable) || null;
|
|
66
|
+
chromeWhy = chrome ? "" : "no Chrome or Chromium found (set CHROME_BIN)";
|
|
67
|
+
}
|
|
68
|
+
if (chrome && platform === "linux" && env.PINDECK_LINUX_CHROME === "0") {
|
|
69
|
+
chrome = null;
|
|
70
|
+
chromeWhy = "Chrome features are turned off (PINDECK_LINUX_CHROME=0)";
|
|
71
|
+
}
|
|
72
|
+
const chromeCap = chrome ? on() : off(chromeWhy);
|
|
73
|
+
caps.chromePath = chrome;
|
|
74
|
+
caps.screenshot = chromeCap;
|
|
75
|
+
caps.headlessLayout = chromeCap;
|
|
76
|
+
caps.pdfExport = chromeCap;
|
|
77
|
+
|
|
78
|
+
// Linux: Poppler renders PDF pages, LibreOffice exports PowerPoint slides to PDF, ffmpeg decodes audio
|
|
79
|
+
const has = (name) => !!findExecutable(name, { env }).path;
|
|
80
|
+
const poppler = has("pdftoppm") && has("pdfinfo"), soffice = has("soffice") || has("libreoffice");
|
|
81
|
+
caps.pdfPages = mac || poppler ? on()
|
|
82
|
+
: off("Pictures of PDF pages need Poppler on the server (pdftoppm and pdfinfo; install poppler-utils). Attached PDFs can still be read.");
|
|
83
|
+
caps.pptxImport = on(); // the import itself reads the .pptx file: no other program needed
|
|
84
|
+
caps.pptxPictures = mac ? (OFFICE_APPS.some((a) => fs.existsSync(a)) ? on() : off("Pictures of PowerPoint slides need PowerPoint or Keynote."))
|
|
85
|
+
: soffice && poppler ? on()
|
|
86
|
+
: off("Pictures of PowerPoint slides need LibreOffice (soffice) and Poppler (pdftoppm, pdfinfo) on the server.");
|
|
87
|
+
caps.audioConvert = mac || has("ffmpeg") ? on() : off("Waveforms for audio other than WAV need ffmpeg on the server.");
|
|
88
|
+
return caps;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* The capabilities for the editor and the status API: no executable paths. */
|
|
92
|
+
export const publicCapabilities = ({ chromePath, ...caps }) => caps;
|
|
93
|
+
|
|
94
|
+
/* The lines of Claude's system prompt about what this server cannot do. Empty on a full macOS setup. */
|
|
95
|
+
export function capabilityPrompt(caps, cli) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
if (!caps.screenshot.available)
|
|
98
|
+
lines.push(`- \`${cli} shot\` does not work here: ${caps.screenshot.reason}. Use \`${cli} layout\` to check slides; ` +
|
|
99
|
+
`it works while the editor is open in the user's browser.`);
|
|
100
|
+
if (!caps.pdfExport.available) lines.push(`- \`${cli} pdf\` does not work here: ${caps.pdfExport.reason}.`);
|
|
101
|
+
if (!caps.pdfPages.available) lines.push(`- \`${cli} pdf-figure\` does not work here: ${caps.pdfPages.reason}`);
|
|
102
|
+
if (!lines.length) return "";
|
|
103
|
+
return `\n\n## This PinDeck server (${caps.label})\nSome commands are not available, so do not try them:\n${lines.join("\n")}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* --port / PORT: an integer 0–65535 (0: any free port, for tests). null if invalid. */
|
|
107
|
+
export function parsePort(value) {
|
|
108
|
+
const s = String(value ?? "").trim();
|
|
109
|
+
if (!/^\d+$/.test(s)) return null;
|
|
110
|
+
const n = Number(s);
|
|
111
|
+
return n <= 65535 ? n : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* The command Claude runs for the CLI, quoted for the shell. The Bash permission is this exact prefix, so
|
|
115
|
+
it must be one fixed string; double quotes as before, single quotes when the path has characters that
|
|
116
|
+
double quotes do not protect. */
|
|
117
|
+
export function cliCommand(rel) {
|
|
118
|
+
if (/^[\w./@%+=:,-]+$/.test(rel)) return `node ${rel}`;
|
|
119
|
+
if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
|
|
120
|
+
return `node '${rel.replaceAll("'", `'\\''`)}'`;
|
|
121
|
+
}
|
package/lib/claude.mjs
CHANGED
|
@@ -3,19 +3,22 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
const exec = promisify(execFile);
|
|
4
4
|
import { AgentProcess } from './agent-process.mjs';
|
|
5
5
|
import { normalizeModels } from './models.mjs';
|
|
6
|
+
/* A long-running Claude Code process in stream-json mode. One process serves the whole chat; it runs under
|
|
7
|
+
the user's normal Claude login. env may be a function: the server's address is known only once it listens. */
|
|
6
8
|
export class ClaudeSession {
|
|
7
9
|
constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; }
|
|
8
10
|
get running() { return !!this.transport && !this.transport.closed; }
|
|
9
11
|
async start(resumeId) {
|
|
10
12
|
if (this.running) return;
|
|
11
13
|
this.stopped = false;
|
|
14
|
+
const env = typeof this.env === 'function' ? this.env() : this.env;
|
|
12
15
|
try {
|
|
13
16
|
const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
|
|
14
|
-
{ env: { ...process.env, ...
|
|
17
|
+
{ env: { ...process.env, ...env }, timeout: 15000, maxBuffer: 65536 });
|
|
15
18
|
if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
|
|
16
19
|
} catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
|
|
17
20
|
if (this.stopped) throw new Error('Agent stopped.');
|
|
18
|
-
const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env
|
|
21
|
+
const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env,
|
|
19
22
|
args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
|
20
23
|
...(typeof this.args === 'function' ? this.args() : this.args || []), ...(resumeId ? ['--resume', resumeId] : [])],
|
|
21
24
|
onStderr: this.onStderr,
|
|
@@ -34,5 +37,6 @@ export class ClaudeSession {
|
|
|
34
37
|
}
|
|
35
38
|
async send(content) { this.transport.write({ type: 'user', message: { role: 'user', content } }); }
|
|
36
39
|
async interrupt() { if (this.running) await this.transport.request('interrupt', {}, { claude: true, timeout: 5000 }); }
|
|
37
|
-
|
|
40
|
+
/* Resolves once the process has exited, so shutdown can wait for it. */
|
|
41
|
+
stop() { this.stopped = true; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
|
|
38
42
|
}
|
package/lib/codex.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { AgentProcess } from './agent-process.mjs';
|
|
2
3
|
import { normalizeModels } from './models.mjs';
|
|
3
4
|
import { deckTools } from './agent-tools.mjs';
|
|
5
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
4
6
|
export const CODEX_CONFIG = {
|
|
5
7
|
'features.shell_tool': false, 'features.unified_exec': false, 'features.js_repl': false,
|
|
6
8
|
'features.apps': false, 'features.plugins': false, 'features.multi_agent': false,
|
|
@@ -23,7 +25,7 @@ export class CodexSession {
|
|
|
23
25
|
});
|
|
24
26
|
this.transport = transport;
|
|
25
27
|
try {
|
|
26
|
-
await transport.request('initialize', { clientInfo: { name: 'pindeck', version:
|
|
28
|
+
await transport.request('initialize', { clientInfo: { name: 'pindeck', version: VERSION }, capabilities: { experimentalApi: true } });
|
|
27
29
|
transport.write({ method: 'initialized', params: {} });
|
|
28
30
|
const auth = await transport.request('account/read', { refreshToken: false });
|
|
29
31
|
if (auth.requiresOpenaiAuth && !auth.account) throw new Error('Codex is not signed in. Run codex login, then refresh models.');
|
|
@@ -100,5 +102,5 @@ export class CodexSession {
|
|
|
100
102
|
this.cancelRequested = true; this.controller?.abort();
|
|
101
103
|
if (this.turnId && this.running) await this.transport.request('turn/interrupt', { threadId: this.sessionId, turnId: this.turnId });
|
|
102
104
|
}
|
|
103
|
-
stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; t
|
|
105
|
+
stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
|
|
104
106
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* Writing deck files safely, and telling whether they changed.
|
|
2
|
+
|
|
3
|
+
writeAtomic: the new content goes to a temporary file next to the target, is flushed, gets the target's
|
|
4
|
+
permissions and then replaces the target with one rename. A reader (the browser, Claude, an editor, the
|
|
5
|
+
watcher) sees the old file or the new one, never half of it. A symlinked target is written where the link
|
|
6
|
+
points, so the link stays a link. Each file is atomic on its own; a set of files is not one transaction.
|
|
7
|
+
|
|
8
|
+
revision: a hash of the deck's editable files as they are now. The editor sends the revision it shows
|
|
9
|
+
with each change; a different one means someone else changed the files in between (409, nothing written).
|
|
10
|
+
This catches stale edits from the editor. It is not a lock: a program that writes between PinDeck's check
|
|
11
|
+
and its write can still lose its change, so only one writer (PinDeck, Claude, or another tool) at a time. */
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
export function writeAtomic(file, data) {
|
|
18
|
+
let target = file, mode = null;
|
|
19
|
+
try {
|
|
20
|
+
if (fs.lstatSync(file).isSymbolicLink()) target = fs.realpathSync(file);
|
|
21
|
+
mode = fs.statSync(target).mode & 0o7777;
|
|
22
|
+
} catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
23
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.pindeck-${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
24
|
+
let fd = null;
|
|
25
|
+
try {
|
|
26
|
+
fd = fs.openSync(tmp, "wx", mode ?? 0o666); // wx: never reuse an existing file
|
|
27
|
+
fs.writeFileSync(fd, data);
|
|
28
|
+
fs.fsyncSync(fd);
|
|
29
|
+
if (mode != null) fs.fchmodSync(fd, mode); // the umask must not change the file's permissions
|
|
30
|
+
fs.closeSync(fd);
|
|
31
|
+
fd = null;
|
|
32
|
+
fs.renameSync(tmp, target);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (fd != null) try { fs.closeSync(fd); } catch {}
|
|
35
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
36
|
+
throw new Error(`Could not save ${path.basename(file)}: ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Same bytes? (a missing file is null) */
|
|
41
|
+
export function sameContent(file, data) {
|
|
42
|
+
let now;
|
|
43
|
+
try { now = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") return data == null; throw e; }
|
|
44
|
+
return data != null && now.equals(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function revision(dir, files) {
|
|
48
|
+
const h = crypto.createHash("sha256");
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
let c = null;
|
|
51
|
+
try { c = fs.readFileSync(path.join(dir, f)); } catch {}
|
|
52
|
+
h.update(f + (c == null ? "\0-" : `\0${c.length}\0`));
|
|
53
|
+
if (c) h.update(c);
|
|
54
|
+
}
|
|
55
|
+
return h.digest("hex").slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
|
|
59
|
+
time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
|
|
60
|
+
export/ folders are left out; so are symlinks, which point elsewhere. */
|
|
61
|
+
const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
|
|
62
|
+
export const KEEP_BYTES = 2 * 1024 * 1024;
|
|
63
|
+
export function scanFiles(dir, maxBytes = KEEP_BYTES) {
|
|
64
|
+
const files = new Map();
|
|
65
|
+
const walk = (rel) => {
|
|
66
|
+
let entries = [];
|
|
67
|
+
try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
70
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
71
|
+
if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
|
|
72
|
+
if (!e.isFile()) continue;
|
|
73
|
+
try {
|
|
74
|
+
const st = fs.statSync(path.join(dir, r));
|
|
75
|
+
files.set(r, { size: st.size, mtimeMs: st.mtimeMs, data: st.size <= maxBytes ? fs.readFileSync(path.join(dir, r)) : null });
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk("");
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* What changed between two scans: { rel: { before, after } } with the content on each side (null: the
|
|
84
|
+
file did not exist), and the files that changed but are too large to keep a copy of. */
|
|
85
|
+
export function scanChanges(before, after) {
|
|
86
|
+
const changed = {}, tooLarge = [];
|
|
87
|
+
for (const rel of new Set([...before.keys(), ...after.keys()])) {
|
|
88
|
+
const b = before.get(rel), a = after.get(rel);
|
|
89
|
+
if (b && a && b.size === a.size && (b.data && a.data ? b.data.equals(a.data) : b.mtimeMs === a.mtimeMs)) continue;
|
|
90
|
+
if ((b && !b.data) || (a && !a.data)) { tooLarge.push(rel); continue; }
|
|
91
|
+
changed[rel] = { before: b ? b.data : null, after: a ? a.data : null };
|
|
92
|
+
}
|
|
93
|
+
return { changed, tooLarge };
|
|
94
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
|
|
2
|
+
with class "slides" (inside <div class="reveal">). Other frameworks are recognised by their markup and
|
|
3
|
+
scripts, so the user gets a clear "not supported (yet)" before PinDeck changes anything, instead of an
|
|
4
|
+
editor without slides. Only tags and scripts are looked at, never the slides' text, so a talk that
|
|
5
|
+
mentions "impress.js" is not taken for one. */
|
|
6
|
+
|
|
7
|
+
import { parse, findSlides } from "./eids.mjs";
|
|
8
|
+
|
|
9
|
+
const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
|
|
10
|
+
["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
|
|
11
|
+
["Slidev", /slidev/i],
|
|
12
|
+
["impress.js", /id=["']impress["']|impress(\.min)?\.js/i],
|
|
13
|
+
["remark", /remark(-latest)?(\.min)?\.js|remark\.create\s*\(/i],
|
|
14
|
+
["Spectacle", /<script[^>]+src=["'][^"']*spectacle/i],
|
|
15
|
+
["Shower", /class=["'][^"']*\bshower\b|shower-core/i],
|
|
16
|
+
["deck.js", /deck-container|deck\.core(\.min)?\.js/i],
|
|
17
|
+
["bespoke.js", /bespoke(\.min)?\.js|bespoke\.from\s*\(/i],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/* Tags and script contents only: the slides' text could name any framework */
|
|
21
|
+
const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
|
|
22
|
+
|
|
23
|
+
/* { name, supported }: reveal.js decks are supported, including one without slides yet */
|
|
24
|
+
export function detectFramework(html) {
|
|
25
|
+
if (findSlides(parse(html)).length) return { name: "reveal.js", supported: true };
|
|
26
|
+
const tags = markup(html);
|
|
27
|
+
const other = OTHERS.find(([, re]) => re.test(tags));
|
|
28
|
+
if (other) return { name: other[0], supported: false };
|
|
29
|
+
if (/class=["'][^"']*\breveal\b/i.test(tags) || /Reveal\.initialize\s*\(/.test(tags)) return { name: "reveal.js", supported: true };
|
|
30
|
+
return { name: null, supported: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* The message for a deck PinDeck cannot edit, or null */
|
|
34
|
+
export function unsupportedMessage(html, file) {
|
|
35
|
+
const fw = detectFramework(html);
|
|
36
|
+
if (fw.supported) return null;
|
|
37
|
+
const what = fw.name ? `${file} is a ${fw.name} deck.` :
|
|
38
|
+
`${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
|
|
39
|
+
return `${what} PinDeck works with reveal.js decks only for now; other frameworks may follow. ` +
|
|
40
|
+
`Nothing was changed.`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* The deck's own instructions for Claude (CLAUDE.md, AGENTS.md), with @path imports expanded.
|
|
2
|
+
|
|
3
|
+
PinDeck does not load CLAUDE.md files from parent folders the way Claude Code does: those are usually
|
|
4
|
+
written for coding work PinDeck's Claude cannot do (tests, commits, package managers), they change
|
|
5
|
+
with where the deck happens to sit, and on a shared server a parent folder may be writable by others.
|
|
6
|
+
A deck pulls in shared rules explicitly instead: "Follow @../shared/STYLE.md" includes that file.
|
|
7
|
+
|
|
8
|
+
An import is "@" followed by a path (relative to the file it is in, absolute, or ~/...), at the start
|
|
9
|
+
of a line or after a space or "(". It is expanded only when that path is a text file: an e-mail address
|
|
10
|
+
or an @mention stays as it is. Imports in code spans and code blocks are left alone. Included files may
|
|
11
|
+
import others, up to 5 levels deep; each file is included once (no cycles), and all included text
|
|
12
|
+
together is limited to 200 KB. The imported text follows the file that imports it. */
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const MAX_DEPTH = 5, MAX_BYTES = 200_000;
|
|
19
|
+
|
|
20
|
+
/* Apply fn to the text outside code: fenced blocks (``` or ~~~) and `code spans` stay as they are */
|
|
21
|
+
function outsideCode(text, fn) {
|
|
22
|
+
let fence = null;
|
|
23
|
+
return text.split("\n").map((line) => {
|
|
24
|
+
const f = /^\s*(```|~~~)/.exec(line);
|
|
25
|
+
if (f) { fence = fence ? (fence === f[1] ? null : fence) : f[1]; return line; }
|
|
26
|
+
if (fence) return line;
|
|
27
|
+
return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : fn(part))).join("");
|
|
28
|
+
}).join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolveRef = (ref, dir) => (ref === "~" || ref.startsWith("~/") ? path.join(os.homedir(), ref.slice(1))
|
|
32
|
+
: path.resolve(dir, ref));
|
|
33
|
+
|
|
34
|
+
function readText(file) {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.statSync(file).isFile()) return null;
|
|
37
|
+
const buf = fs.readFileSync(file);
|
|
38
|
+
return buf.includes(0) ? null : buf.toString("utf8");
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expand(file, text, depth, seen, budget) {
|
|
43
|
+
const dir = path.dirname(file), found = [];
|
|
44
|
+
const body = outsideCode(text, (part) => part.replace(/(^|[\s(])@([^\s`()<>"']+)/g, (m, pre, raw) => {
|
|
45
|
+
const ref = raw.replace(/[.,:;!?]+$/, ""), tail = raw.slice(ref.length); // "see @STYLE.md." ends a sentence
|
|
46
|
+
const abs = resolveRef(ref, dir);
|
|
47
|
+
const content = readText(abs);
|
|
48
|
+
if (content == null) return m; // not a file: an e-mail address, an @mention, a typo
|
|
49
|
+
let real = abs;
|
|
50
|
+
try { real = fs.realpathSync(abs); } catch {}
|
|
51
|
+
const note = (why) => `${pre}${ref} (${why})${tail}`;
|
|
52
|
+
if (seen.has(real)) return note("included once, above or below");
|
|
53
|
+
if (depth >= MAX_DEPTH) return note("not included: imports nested too deep");
|
|
54
|
+
if (Buffer.byteLength(content) > budget.left) return note("not included: imported files over 200 KB in all");
|
|
55
|
+
budget.left -= Buffer.byteLength(content);
|
|
56
|
+
seen.add(real);
|
|
57
|
+
found.push({ ref, abs, content });
|
|
58
|
+
return note("included below");
|
|
59
|
+
}));
|
|
60
|
+
const parts = [body.trim()];
|
|
61
|
+
for (const imp of found) {
|
|
62
|
+
parts.push(`### ${imp.ref} (included by ${path.basename(file)})\n` + expand(imp.abs, imp.content, depth + 1, seen, budget));
|
|
63
|
+
}
|
|
64
|
+
return parts.filter(Boolean).join("\n\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* A deck instruction file with its imports, or null when it is missing or empty */
|
|
68
|
+
export function readInstructions(file) {
|
|
69
|
+
const text = readText(file);
|
|
70
|
+
if (text == null || !text.trim()) return null;
|
|
71
|
+
let real = file;
|
|
72
|
+
try { real = fs.realpathSync(file); } catch {}
|
|
73
|
+
return expand(file, text, 0, new Set([real]), { left: MAX_BYTES });
|
|
74
|
+
}
|
package/lib/models.mjs
CHANGED
|
@@ -22,7 +22,8 @@ export function selectModel(catalog, current, requested, preferences = {}) {
|
|
|
22
22
|
const provider = requested.provider ?? current.provider;
|
|
23
23
|
if (!PROVIDERS.includes(provider)) throw new Error('Unknown provider.');
|
|
24
24
|
const models = catalog[provider]?.models || [];
|
|
25
|
-
|
|
25
|
+
// no model chosen yet (a provider picked on the command line, before discovery): the backend's default
|
|
26
|
+
const id = requested.model ?? ((provider === current.provider && current.model) || models.find(m => m.isDefault)?.id || models[0]?.id);
|
|
26
27
|
const model = models.find(m => m.id === id);
|
|
27
28
|
if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
|
|
28
29
|
const sameModel = provider === current.provider && id === current.model;
|
package/lib/pdf-render.jxa.js
CHANGED
|
@@ -39,10 +39,12 @@ function run(argv) {
|
|
|
39
39
|
null, w, h, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0);
|
|
40
40
|
const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
|
|
41
41
|
$.NSGraphicsContext.saveGraphicsState;
|
|
42
|
-
$.NSGraphicsContext.
|
|
43
|
-
$.NSColor.whiteColor.setFill;
|
|
44
|
-
$.NSRectFill($.NSMakeRect(0, 0, w, h));
|
|
42
|
+
$.NSGraphicsContext.setCurrentContext(ctx);
|
|
45
43
|
const cg = ctx.CGContext;
|
|
44
|
+
// a white page, as in a PDF viewer (and as Poppler renders on Linux): most PDFs paint no background,
|
|
45
|
+
// and a transparent picture would show dark text on a dark slide
|
|
46
|
+
$.CGContextSetRGBFillColor(cg, 1, 1, 1, 1);
|
|
47
|
+
$.CGContextFillRect(cg, $.CGRectMake(0, 0, w, h));
|
|
46
48
|
$.CGContextScaleCTM(cg, scale, scale);
|
|
47
49
|
$.CGContextTranslateCTM(cg, tx, ty);
|
|
48
50
|
page.drawWithBoxToContext($.kPDFDisplayBoxMediaBox, cg);
|
package/lib/pdf.mjs
CHANGED
|
@@ -46,7 +46,8 @@ function chromeRun(chrome, args, profile, done, timeout) {
|
|
|
46
46
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
47
47
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
48
48
|
proc.on("error", (e) => finish(e));
|
|
49
|
-
|
|
49
|
+
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
50
|
+
proc.on("close", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -61,7 +62,8 @@ export async function printLayout(chrome, url, profile) {
|
|
|
61
62
|
/* Step 2: print a (static) page to a PDF file. */
|
|
62
63
|
export async function printPdf(chrome, url, out, profile) {
|
|
63
64
|
try { fs.rmSync(out, { force: true }); } catch {}
|
|
64
|
-
|
|
65
|
+
// the virtual-time budget lets Chrome follow PinDeck's login start page to the print page before printing
|
|
66
|
+
await chromeRun(chrome, ["--virtual-time-budget=5000", "--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
|
|
65
67
|
(_o, e) => /bytes written to file/.test(e), 90000);
|
|
66
68
|
if (!fs.existsSync(out)) throw new Error("Chrome did not write the PDF.");
|
|
67
69
|
return out;
|
package/lib/platform-notice.mjs
CHANGED
|
@@ -2,13 +2,13 @@ import http from 'node:http';
|
|
|
2
2
|
|
|
3
3
|
export function createPlatformNoticeServer(platform) {
|
|
4
4
|
const windows = platform === 'win32';
|
|
5
|
-
const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck
|
|
5
|
+
const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck runs on macOS and Linux';
|
|
6
6
|
const explanation = windows
|
|
7
|
-
? 'PinDeck
|
|
8
|
-
: 'This version of PinDeck
|
|
9
|
-
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck ·
|
|
7
|
+
? 'PinDeck runs on macOS and, as a beta, on Linux. Windows support is planned for a future version; there is no release date yet.'
|
|
8
|
+
: 'This version of PinDeck runs on macOS and, as a beta, on Linux. This operating system is not supported.';
|
|
9
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · system not supported</title><style>
|
|
10
10
|
:root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
|
|
11
|
-
</style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can
|
|
11
|
+
</style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can run PinDeck on a Mac or a Linux machine (and use it from this browser through an SSH tunnel), or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
|
|
12
12
|
return http.createServer((req, res) => {
|
|
13
13
|
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
|
|
14
14
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
@@ -28,7 +28,7 @@ export async function showPlatformNotice(port, platform = process.platform) {
|
|
|
28
28
|
});
|
|
29
29
|
server.listen(port, '127.0.0.1', () => {
|
|
30
30
|
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
31
|
-
console.log(`PinDeck
|
|
31
|
+
console.log(`PinDeck runs on macOS and Linux only. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
}
|
package/lib/pptx-render.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/* Pictures of PowerPoint slides
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Pictures of PowerPoint slides, and of PDF pages.
|
|
2
|
+
|
|
3
|
+
macOS: PowerPoint, or else Keynote, exports the deck as a PDF with one page per slide, hidden slides
|
|
4
|
+
included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages (thumbnails, references for Claude)
|
|
5
|
+
and regions (diagrams, PDF figures) to PNG.
|
|
6
|
+
Linux: LibreOffice (soffice --headless) exports the PDF, and Poppler (pdfinfo, pdftoppm) renders it.
|
|
7
|
+
Both are optional; lib/capabilities.mjs turns the features on when they are found.
|
|
4
8
|
|
|
5
9
|
Both apps are sandboxed, so the files go through a folder inside the app's own container, which
|
|
6
10
|
needs no permission dialog. macOS asks once whether PinDeck may control the app. An app that was
|
|
@@ -11,7 +15,8 @@ import crypto from "node:crypto";
|
|
|
11
15
|
import fs from "node:fs";
|
|
12
16
|
import os from "node:os";
|
|
13
17
|
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
import { findExecutable } from "./capabilities.mjs";
|
|
15
20
|
|
|
16
21
|
const JXA = path.join(path.dirname(fileURLToPath(import.meta.url)), "pdf-render.jxa.js");
|
|
17
22
|
const APPS = [
|
|
@@ -24,8 +29,16 @@ const osa = (args, timeout = 240000) => new Promise((resolve, reject) =>
|
|
|
24
29
|
err ? reject(new Error((errOut || err.message).trim().split("\n").pop())) : resolve(out.trim())));
|
|
25
30
|
const q = (s) => JSON.stringify(s); // AppleScript string literal (same quoting rules for paths)
|
|
26
31
|
|
|
32
|
+
const mac = () => process.platform === "darwin";
|
|
33
|
+
const tool = (...names) => names.map((n) => findExecutable(n).path).find(Boolean) || null;
|
|
34
|
+
const run = (cmd, args, timeout = 120000) => new Promise((resolve, reject) =>
|
|
35
|
+
execFile(cmd, args, { timeout, maxBuffer: 1e7 }, (err, out, errOut) =>
|
|
36
|
+
err ? reject(new Error(String(errOut || err.message).trim().split("\n").pop())) : resolve(String(out))));
|
|
37
|
+
export const popplerAvailable = () => !!(tool("pdftoppm") && tool("pdfinfo"));
|
|
38
|
+
export const sofficePath = () => tool("soffice", "libreoffice");
|
|
39
|
+
|
|
27
40
|
export function renderAvailable() {
|
|
28
|
-
return
|
|
41
|
+
return mac() ? APPS.some((a) => fs.existsSync(a.bundle)) : !!(sofficePath() && popplerAvailable());
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
function exportScript(app, pptx, pdf) {
|
|
@@ -58,7 +71,7 @@ return "ok"`;
|
|
|
58
71
|
|
|
59
72
|
/* Exports the deck to <workDir>/deck.pdf. Returns { pdf, app }. */
|
|
60
73
|
export async function exportPdf(pptxPath, workDir) {
|
|
61
|
-
if (
|
|
74
|
+
if (!mac()) return exportPdfLibreOffice(pptxPath, workDir);
|
|
62
75
|
const errors = [];
|
|
63
76
|
for (const app of APPS) {
|
|
64
77
|
if (!fs.existsSync(app.bundle)) continue;
|
|
@@ -81,17 +94,73 @@ export async function exportPdf(pptxPath, workDir) {
|
|
|
81
94
|
throw new Error(errors.length ? errors.join("; ") : "Neither PowerPoint nor Keynote is installed.");
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
/*
|
|
85
|
-
|
|
97
|
+
/* Linux: LibreOffice exports the deck, hidden slides included (so page n is slide n). Its own profile in
|
|
98
|
+
the work folder, so a LibreOffice the user has open, or a stale lock, does not get in the way. */
|
|
99
|
+
async function exportPdfLibreOffice(pptxPath, workDir) {
|
|
100
|
+
const soffice = sofficePath();
|
|
101
|
+
if (!soffice) throw new Error("LibreOffice (soffice) is not installed.");
|
|
102
|
+
const dir = fs.mkdtempSync(path.join(workDir, "libreoffice-"));
|
|
103
|
+
try {
|
|
104
|
+
const inFile = path.join(dir, "deck.pptx");
|
|
105
|
+
fs.copyFileSync(pptxPath, inFile);
|
|
106
|
+
await run(soffice, ["--headless", "--norestore", `-env:UserInstallation=${pathToFileURL(path.join(dir, "profile")).href}`,
|
|
107
|
+
"--convert-to", 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
|
|
108
|
+
"--outdir", dir, inFile], 240000);
|
|
109
|
+
const out = path.join(dir, "deck.pdf");
|
|
110
|
+
if (!fs.existsSync(out)) throw new Error("LibreOffice wrote no PDF.");
|
|
111
|
+
const pdf = path.join(workDir, "deck.pdf");
|
|
112
|
+
fs.copyFileSync(out, pdf);
|
|
113
|
+
return { pdf, app: "LibreOffice" };
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rm(dir, { recursive: true, force: true }, () => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Renders pages or regions (see pdf-render.jxa.js for the jobs). Returns the page count. Boxes are in
|
|
120
|
+
slide px of a page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
|
|
86
121
|
export async function renderJobs(pdf, jobs, workDir, { slideWidth = 1280 } = {}) {
|
|
122
|
+
if (!mac()) return renderPoppler(pdf, jobs, slideWidth);
|
|
87
123
|
return +(await runJxa({ pdf, slideWidth, jobs }, workDir));
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (
|
|
126
|
+
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (PDFKit or Poppler, no other app) */
|
|
91
127
|
export async function pdfInfo(pdf, workDir) {
|
|
128
|
+
if (!mac()) return popplerInfo(pdf);
|
|
92
129
|
return JSON.parse(await runJxa({ pdf, info: true }, workDir));
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
async function popplerInfo(pdf, exact = false) {
|
|
133
|
+
const pages = +(/^Pages:\s+(\d+)/m.exec(await run(tool("pdfinfo"), [pdf])) || [])[1];
|
|
134
|
+
if (!pages) throw new Error("pdfinfo could not read the PDF.");
|
|
135
|
+
const out = await run(tool("pdfinfo"), ["-f", "1", "-l", String(pages), pdf]);
|
|
136
|
+
const sizes = [...out.matchAll(/^Page\s+(\d+)\s+size:\s+([\d.]+) x ([\d.]+)/gm)]
|
|
137
|
+
.sort((a, b) => a[1] - b[1]).map((m) => (exact ? [+m[2], +m[3]] : [Math.round(+m[2]), Math.round(+m[3])]));
|
|
138
|
+
return { pages, sizes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The same jobs as pdf-render.jxa.js, with pdftoppm: whole pages scaled to a width, or a box of the page
|
|
142
|
+
(in slide px) at job.scale output px per slide px. White background, as PDFKit's. */
|
|
143
|
+
async function renderPoppler(pdf, jobs, slideWidth) {
|
|
144
|
+
const info = await popplerInfo(pdf, true);
|
|
145
|
+
for (const job of jobs) {
|
|
146
|
+
const size = info.sizes[job.page - 1];
|
|
147
|
+
if (!size) continue;
|
|
148
|
+
const [pw, ph] = size;
|
|
149
|
+
const args = ["-f", String(job.page), "-l", String(job.page), "-png", "-singlefile"];
|
|
150
|
+
if (job.box) {
|
|
151
|
+
const [x, y, bw, bh] = job.box, s = job.scale || 2, f = pw / (slideWidth || 1280); // PDF points per slide px
|
|
152
|
+
args.push("-r", String((s / f) * 72), "-x", String(Math.round(x * s)), "-y", String(Math.round(y * s)),
|
|
153
|
+
"-W", String(Math.max(1, Math.round(bw * s))), "-H", String(Math.max(1, Math.round(bh * s))));
|
|
154
|
+
} else {
|
|
155
|
+
const scale = (job.width || 1280) / pw;
|
|
156
|
+
args.push("-scale-to-x", String(Math.round(pw * scale)), "-scale-to-y", String(Math.round(ph * scale)));
|
|
157
|
+
}
|
|
158
|
+
const prefix = job.out.replace(/\.png$/i, ""); // pdftoppm adds .png
|
|
159
|
+
await run(tool("pdftoppm"), [...args, pdf, prefix]);
|
|
160
|
+
}
|
|
161
|
+
return info.pages;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
async function runJxa(spec, workDir) {
|
|
96
165
|
const file = path.join(workDir, `jobs-${crypto.randomBytes(4).toString("hex")}.json`);
|
|
97
166
|
fs.writeFileSync(file, JSON.stringify(spec));
|
package/lib/system-prompt.md
CHANGED
|
@@ -12,7 +12,9 @@ Each user message may start with an <editor-context> block:
|
|
|
12
12
|
- sometimes the path of a screenshot of the slide with the pin markers drawn on it.
|
|
13
13
|
|
|
14
14
|
## Files
|
|
15
|
-
-
|
|
15
|
+
- {{DECK}}: the slides. theme.css: the styles. Edit these. You may also edit or create other files in the
|
|
16
|
+
deck folder when a request needs it (a script such as deck.js, a style sheet, the deck's notes), but not
|
|
17
|
+
vendor/ (third-party libraries). The user can undo everything a turn changed in one step.
|
|
16
18
|
- brand.css is LOCKED (logos, footer, brand colours). Never edit it. If a request needs a brand
|
|
17
19
|
change, say so instead. Elements marked brand-locked are positioned by brand.css.
|
|
18
20
|
- Follow the deck's CLAUDE.md (at the end of these instructions) and README.md for the template's
|
|
@@ -37,7 +39,7 @@ in the editor. Do not delete hidden slides, and do not unhide them unless asked.
|
|
|
37
39
|
|
|
38
40
|
## Attached files
|
|
39
41
|
The user can attach figures and audio to a message. PinDeck has already saved them in the deck's
|
|
40
|
-
`figures/` folder; <editor-context> lists their paths (relative to
|
|
42
|
+
`figures/` folder; <editor-context> lists their paths (relative to {{DECK}}), size and, for audio,
|
|
41
43
|
the duration and a generated waveform SVG. If a file was dropped onto a slide, the drop position is
|
|
42
44
|
given too. Put them on slides with the deck's existing patterns: a figure like the other `figure.fig`
|
|
43
45
|
blocks, audio like the deck's audio demo (audio element, play button, waveform). Read an image
|
|
@@ -84,7 +86,7 @@ If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build
|
|
|
84
86
|
|
|
85
87
|
## Hand edits
|
|
86
88
|
The user can also select, move and resize elements in the editor. Those changes are written as
|
|
87
|
-
inline styles on the element in
|
|
89
|
+
inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width`/`height` (and
|
|
88
90
|
`max-width`/`max-height: none`) for sizes. You get a note about each one. Treat them as intended;
|
|
89
91
|
keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
|
|
90
92
|
The user can also edit text in place (double-click); you get a note with the new text. Keep their
|