+ Optional. Your key is stored in this browser and is sent only to
+ Anthropic, never to this project. The program it writes lands in the box above,
+ where you can read and edit it before rendering.
+
+
+
+
+
+
@@ -1682,6 +1699,139 @@
$('dslX').onclick=()=>closeOv('ovDsl');
$('ovDsl').addEventListener('pointerdown',e=>{if(e.target===$('ovDsl'))closeOv('ovDsl');});
$('dslSrc').oninput=dslPreviewStat;
+/* ═══════ writing a program with a model ═══════
+ The DSL is the contract. A model writes a program; the app validates and renders
+ it exactly as if it had been typed by hand, so nothing reaches the canvas that
+ the panel could not have produced on its own — and the user can read the program
+ before rendering it.
+
+ Structured outputs do the heavy lifting: output_config.format constrains the
+ reply to the schema below, so there is no prose to parse out and a malformed
+ program is a validation error rather than a parse failure. Note what the schema
+ CANNOT say — numeric bounds, string lengths and recursion are not supported —
+ so every range still goes through dslValidateShape's clamping, which is where
+ it belonged anyway.
+
+ Raw fetch rather than the Anthropic SDK: the renderer is one HTML file with no
+ bundler and no node_modules, which is the documented raw-HTTP case. */
+const AI_URL='https://api.anthropic.com/v1/messages';
+const AI_MODEL='claude-opus-5';
+const AI_KEY='pf.aikey';
+/* The schema is built from the DSL's own tables, so it cannot drift from the
+ renderer: a primitive or parameter added to DSL_SHAPES/DSL_RANGES appears here
+ automatically. Every parameter is optional — the model supplies what a shape
+ needs — but `type` is required, since without it there is no shape at all. */
+function aiSchema(){
+ const num={type:'number'},int={type:'integer'},bool={type:'boolean'},str={type:'string'};
+ const shapeProps={type:{type:'string',enum:Object.keys(DSL_SHAPES)},
+ axis:{type:'string',enum:['h','v']},
+ orientation:{type:'string',enum:['up','down']},
+ shape:{type:'string',enum:['circle','square','diamond']},
+ font:{type:'string',enum:['5x7','3x5']},
+ glyphs:str,seed:int,blue:bool,angle:int};
+ for(const k of Object.keys(DSL_RANGES))
+ shapeProps[k]=(k==='level'||k==='density')?num:int;
+ return {type:'object',additionalProperties:false,
+ required:['canvas','layers','post'],
+ properties:{
+ canvas:{type:'object',additionalProperties:false,
+ required:['width','height','scale','autoSize'],
+ properties:{width:int,height:int,scale:int,autoSize:bool}},
+ layers:{type:'array',items:{type:'object',additionalProperties:false,
+ required:['op','shape'],
+ properties:{op:{type:'string',enum:['set','union','intersect','xor','subtract']},
+ shape:{type:'object',additionalProperties:false,required:['type'],
+ properties:shapeProps}}}},
+ post:{type:'object',additionalProperties:false,
+ required:['mirrorX','mirrorY','rotate90','invert'],
+ properties:{mirrorX:bool,mirrorY:bool,rotate90:int,invert:bool}}}};
+}
+/* Built from the same tables, for the same reason. */
+function aiSystem(){
+ const ranges=Object.entries(DSL_RANGES)
+ .map(([k,[lo,hi]])=>k+' '+lo+'-'+hi).join(', ');
+ return [
+ 'You write programs for a 1-bit tiling pattern renderer used to make OpenFront',
+ 'territory patterns. Answer only with a program matching the schema.',
+ '',
+ 'Primitives: '+Object.keys(DSL_SHAPES).join(', ')+'.',
+ 'Parameter ranges: '+ranges+'.',
+ '',
+ 'Layers compose in order. The first layer is always "set"; later layers use',
+ 'union, intersect, xor or subtract. Give each shape only the parameters that',
+ 'primitive takes.',
+ '',
+ 'Leave canvas.autoSize true unless the user asks for an exact size: the canvas',
+ 'is then sized to a common multiple of every layer\'s period, which is what',
+ 'makes the result tile seamlessly. Canvas limits are '+DSL_MIN_W+'-'+DSL_MAX_W+
+ ' wide and '+DSL_MIN_H+'-'+DSL_MAX_H+' tall; scale 0-7 magnifies by 2^scale.',
+ 'A good default canvas is 32x32 at scale 1.',
+ ].join('\n');
+}
+const aiKey=()=>{try{return store.get(AI_KEY)||'';}catch{return '';}};
+function aiSync(){
+ const has=!!aiKey();
+ $('aiAsk').hidden=!has;
+ $('aiForget').disabled=!has;
+}
+function aiSay(msg,ok){
+ $('aiStat').innerHTML=''+msg+'';
+}
+$('aiSave').onclick=()=>{
+ const v=$('aiKey').value.trim();
+ if(!v){aiSay('Paste a key first',false);return;}
+ store.set(AI_KEY,v);$('aiKey').value='';aiSync();
+ aiSay('Key saved in this browser',true);};
+$('aiForget').onclick=()=>{
+ store.set(AI_KEY,'');$('aiKey').value='';aiSync();aiSay('Key forgotten',true);};
+/* Separated from the button so it can be tested against a stubbed fetch — no test
+ in this repo ever needs a key or a network. */
+async function aiWrite(desc,fetchImpl){
+ const key=aiKey();
+ if(!key) throw new Error('No API key set');
+ if(!desc.trim()) throw new Error('Describe the pattern first');
+ const res=await (fetchImpl||fetch)(AI_URL,{
+ method:'POST',
+ headers:{'content-type':'application/json','x-api-key':key,
+ 'anthropic-version':'2023-06-01',
+ // Required for a browser-origin request; the renderer is one.
+ 'anthropic-dangerous-direct-browser-access':'true'},
+ body:JSON.stringify({
+ model:AI_MODEL,
+ // Thinking is on by default on this model and max_tokens caps thinking plus
+ // output together, so this is sized for both. Low effort: writing a small
+ // program is not the kind of work deeper reasoning improves.
+ max_tokens:4000,
+ output_config:{effort:'low',format:{type:'json_schema',schema:aiSchema()}},
+ system:aiSystem(),
+ messages:[{role:'user',content:desc}]}),
+ });
+ if(!res.ok){
+ let detail='';
+ try{const e=await res.json();detail=e&&e.error&&e.error.message||'';}catch{}
+ throw new Error('The model service said '+res.status+(detail?': '+detail:''));}
+ const data=await res.json();
+ // Safety classifiers can decline; that is a 200 with no usable content, so it
+ // has to be checked before reading the reply.
+ if(data.stop_reason==='refusal') throw new Error('The model declined this request');
+ const text=(data.content||[]).filter(b=>b.type==='text').map(b=>b.text).join('');
+ if(!text) throw new Error('The model returned nothing to render');
+ let prog;
+ try{ prog=JSON.parse(text); }
+ catch{ throw new Error('The model did not return a program'); }
+ dslValidate(prog); // throws with a readable reason
+ return prog;
+}
+$('aiGo').onclick=async()=>{
+ const btn=$('aiGo');btn.disabled=true;aiSay('Writing…',true);
+ try{
+ const prog=await aiWrite($('aiWhat').value);
+ dslShow(prog);
+ aiSay('Written. Read it above, then Replace canvas.',true);
+ }catch(e){ aiSay(e.message,false); }
+ finally{ btn.disabled=false; }};
+aiSync();
+
$('dslGo').onclick=()=>{
let r;
try{ r=dslRender(JSON.parse($('dslSrc').value)); }
diff --git a/electron/main.js b/electron/main.js
index 7a75806..3117f18 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -76,7 +76,12 @@ async function serve(request) {
'content-security-policy':
"default-src 'none'; img-src 'self' data: blob:; " +
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " +
- "font-src 'self'; connect-src 'self' blob: data:; form-action 'none'; " +
+ // api.anthropic.com is the one outbound origin the renderer may reach, and
+ // only when the user has set their own key. Widened to that host rather
+ // than to https:, so a compromised renderer still cannot phone anywhere
+ // else. Everything the app does without a key stays entirely local.
+ "font-src 'self'; connect-src 'self' blob: data: https://api.anthropic.com; " +
+ "form-action 'none'; " +
"base-uri 'none'; frame-ancestors 'none'",
},
});
diff --git a/tools/lib/extract.js b/tools/lib/extract.js
index 825380e..c17045e 100644
--- a/tools/lib/extract.js
+++ b/tools/lib/extract.js
@@ -54,8 +54,11 @@ function matchBrace(text, from) {
/** Source of `function (...) { ... }`, braces balanced. */
function grabFunction(js, name) {
- const at = js.indexOf(`function ${name}(`);
+ let at = js.indexOf(`function ${name}(`);
if (at < 0) throw new Error(`function ${name} not found in the editor`);
+ // Include a preceding `async`; without it the extracted copy throws
+ // "await is only valid in async functions" and reads as an app bug.
+ if (js.slice(Math.max(0, at - 6), at) === 'async ') at -= 6;
return js.slice(at, matchBrace(js, js.indexOf('{', js.indexOf(')', at))) + 1);
}
diff --git a/tools/verify-behaviour.js b/tools/verify-behaviour.js
index ca8edd8..7b6884c 100755
--- a/tools/verify-behaviour.js
+++ b/tools/verify-behaviour.js
@@ -60,8 +60,12 @@ function matchBrace(text, from, open = '{', close = '}') {
}
function grabFunction(name) {
- const at = js.indexOf(`function ${name}(`);
+ let at = js.indexOf(`function ${name}(`);
if (at < 0) throw new Error(`function ${name} not found`);
+ // Take the `async` with it. Slicing from `function` silently drops the keyword
+ // and the extracted copy then throws "await is only valid in async functions"
+ // — an error about the test harness wearing the costume of an app bug.
+ if (js.slice(Math.max(0, at - 6), at) === 'async ') at -= 6;
const brace = js.indexOf('{', js.indexOf(')', at));
return js.slice(at, matchBrace(js, brace) + 1);
}
@@ -540,6 +544,126 @@ function duotoneTests() {
JSON.stringify(richCanvas));
}
+/* ── 6c. the model writes a program, and every way that can go wrong ───── */
+//
+// The AI path is the only part of the app that touches the network, so it is the
+// only part that can fail in ways the user did not cause. None of these tests
+// needs a key or a network: aiWrite() takes the fetch implementation as an
+// argument precisely so the model can be stubbed.
+//
+// What matters is not that the happy path works — it is that every failure lands
+// as a message rather than as a broken editor, and that a program from a model
+// goes through exactly the same validation as one typed by hand.
+
+async function aiTests() {
+ console.log('\n=== writing a program with a model ===');
+
+ const GOOD = {
+ canvas: { width: 32, height: 32, scale: 1, autoSize: true },
+ layers: [{ op: 'set', shape: { type: 'diagonal', dx: 1, dy: 1, period: 8, thickness: 3 } }],
+ post: { mirrorX: false, mirrorY: false, rotate90: 0, invert: false },
+ };
+ const reply = (body, ok = true, status = 200) => async () => ({
+ ok, status,
+ json: async () => body,
+ });
+ const asText = (obj) => ({ stop_reason: 'end_turn', content: [{ type: 'text', text: JSON.stringify(obj) }] });
+
+ const ctx = sandbox({
+ store: { get: (k) => (k === 'pf.aikey' ? 'sk-test' : ''), set() {} },
+ $: () => ({ hidden: false, disabled: false, value: '', innerHTML: '' }),
+ });
+ run(ctx, [
+ grabConst('DSL_MIN_W'), grabConst('DSL_SHAPES'), grabConst('DSL_CANVAS_LOCKED'),
+ grabConst('DSL_RANGES'), grabConst('floorMod'), grabFunction('pyRound'),
+ grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'),
+ grabConst('F57'), grabConst('F35'),
+ grabFunction('dslValidateShape'), grabFunction('dslValidate'),
+ grabConst('AI_URL'), grabConst('AI_MODEL'), grabConst('AI_KEY'),
+ grabFunction('aiSchema'), grabFunction('aiSystem'),
+ grabConst('aiKey'), grabFunction('aiWrite'),
+ ].join('\n'));
+ const aiWrite = ctx.aiWrite;
+ const failed = async (fetchImpl, desc = 'stripes') => {
+ try { await aiWrite(desc, fetchImpl); return null; }
+ catch (e) { return e.message; }
+ };
+
+ // The request itself: a wrong model or a rejected parameter is a 400 nobody
+ // would guess from the UI, so the shape is pinned here rather than discovered.
+ let sent = null;
+ await aiWrite('diagonal stripes', async (url, init) => {
+ sent = { url, body: JSON.parse(init.body), headers: init.headers };
+ return { ok: true, status: 200, json: async () => asText(GOOD) };
+ });
+ chk('it posts to the messages endpoint', sent.url === 'https://api.anthropic.com/v1/messages', sent.url);
+ chk('it asks for the current model', sent.body.model === 'claude-opus-5', sent.body.model);
+ chk('it sends no rejected sampling parameters',
+ !('temperature' in sent.body) && !('top_p' in sent.body) && !('top_k' in sent.body));
+ chk('it sends no removed thinking budget',
+ !sent.body.thinking || !('budget_tokens' in sent.body.thinking));
+ chk('it leaves room for thinking as well as output', sent.body.max_tokens >= 2000,
+ String(sent.body.max_tokens));
+ chk('it constrains the reply to the schema',
+ sent.body.output_config.format.type === 'json_schema');
+ chk('the browser-origin header is set',
+ sent.headers['anthropic-dangerous-direct-browser-access'] === 'true');
+ chk('the key travels in the header, never the body',
+ sent.headers['x-api-key'] === 'sk-test' && !JSON.stringify(sent.body).includes('sk-test'));
+
+ // The schema is generated from the DSL's own tables, so it cannot drift.
+ const schema = ctx.aiSchema();
+ const shapeEnum = schema.properties.layers.items.properties.shape.properties.type.enum;
+ // `const` bindings are not properties of the vm context, so ask for the value.
+ const primitiveCount = vm.runInContext('Object.keys(DSL_SHAPES).length', ctx);
+ chk('the schema offers every primitive the renderer has',
+ shapeEnum.length === primitiveCount, `${shapeEnum.length} primitives`);
+ chk('the prompt names the primitives too',
+ ctx.aiSystem().includes('diagonal') && ctx.aiSystem().includes('halftone'));
+
+ // A well-formed reply is validated, not trusted.
+ const prog = await aiWrite('diagonal stripes', reply(asText(GOOD)));
+ chk('a well-formed program comes back validated',
+ prog.layers[0].shape.type === 'diagonal' && prog.layers[0].op === 'set');
+
+ // Every failure mode, in the order a user would meet them.
+ chk('an unknown shape is rejected, not rendered',
+ (await failed(reply(asText({ layers: [{ op: 'set', shape: { type: 'nope' } }] })))) ===
+ 'unknown shape type "nope"');
+ chk('a reply that is not a program says so',
+ (await failed(reply({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'sorry!' }] }))) ===
+ 'The model did not return a program');
+ chk('an empty reply says so',
+ (await failed(reply({ stop_reason: 'end_turn', content: [] }))) ===
+ 'The model returned nothing to render');
+ chk('a refusal is reported, not parsed',
+ (await failed(reply({ stop_reason: 'refusal', content: [] }))) ===
+ 'The model declined this request');
+ const http = await failed(reply({ error: { message: 'invalid x-api-key' } }, false, 401));
+ chk('an HTTP error carries the status and the reason',
+ http.includes('401') && http.includes('invalid x-api-key'), http);
+ chk('an empty description is refused before any request',
+ (await failed(() => { throw new Error('should not have been called'); }, ' ')) ===
+ 'Describe the pattern first');
+
+ // With no key the path refuses cleanly rather than sending an unauthenticated
+ // request — the panel hides the control, and this is the belt to that braces.
+ const noKey = sandbox({ store: { get: () => '', set() {} },
+ $: () => ({ hidden: false, disabled: false, value: '', innerHTML: '' }) });
+ run(noKey, [grabConst('AI_KEY'), grabConst('aiKey'), grabConst('AI_URL'), grabConst('AI_MODEL'),
+ grabConst('DSL_MIN_W'), grabConst('DSL_SHAPES'), grabConst('DSL_RANGES'),
+ grabFunction('aiSchema'), grabFunction('aiSystem'),
+ grabConst('DSL_CANVAS_LOCKED'), grabConst('floorMod'), grabFunction('pyRound'),
+ grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'),
+ grabConst('F57'), grabConst('F35'),
+ grabFunction('dslValidateShape'), grabFunction('dslValidate'),
+ grabFunction('aiWrite')].join('\n'));
+ let noKeyMsg = null;
+ try { await noKey.aiWrite('stripes', () => { throw new Error('should not have been called'); }); }
+ catch (e) { noKeyMsg = e.message; }
+ chk('with no key it never reaches the network', noKeyMsg === 'No API key set', String(noKeyMsg));
+}
+
/* ── 7. grab: 8-connected flood fill over an ASCII scene ───────────────── */
function grabTests() {
@@ -760,6 +884,7 @@ oversize.then(async () => {
loadTests();
previewTests();
duotoneTests();
+ await aiTests();
fitTests();
grabTests();
clearTests();
diff --git a/tools/verify-electron.py b/tools/verify-electron.py
index 97a5b33..98be760 100755
--- a/tools/verify-electron.py
+++ b/tools/verify-electron.py
@@ -129,6 +129,21 @@ def main() -> int:
# so nothing else in this suite would notice.
chk('the mac bundle is ad-hoc signed at minimum', 'identity: "-"' in yml)
+ # The renderer may reach exactly one outbound origin, and only when the user
+ # has set their own key. `https:` or `*` here would let a compromised renderer
+ # phone anywhere; this check is what keeps the widening honest.
+ print("\n=== outbound network ===")
+ main = read("main.js")
+ connect = re.search(r"connect-src ([^\"]+)", main)
+ # The directive ends at the ';' that separates it from the next one.
+ hosts = connect.group(1).split(";")[0].split() if connect else []
+ chk("connect-src names the AI provider and nothing wider",
+ "https://api.anthropic.com" in hosts
+ and not any(h in ("https:", "*", "http:") for h in hosts),
+ " ".join(hosts))
+ chk("the key is never written into the packaged app",
+ "sk-ant" not in read_root("app/patternfront.html"))
+
print()
if fails:
print(f"*** {len(fails)} FAILURE(S) ***")
diff --git a/tools/verify-stamps.py b/tools/verify-stamps.py
index c869071..afd523c 100755
--- a/tools/verify-stamps.py
+++ b/tools/verify-stamps.py
@@ -60,7 +60,15 @@ def encode(w: int, h: int, scale: int, bits) -> str:
def main() -> int:
src = open(APP, encoding="utf-8").read()
js = "\n".join(re.findall(r"", src, re.S))
- stamps = re.findall(r"\['([a-z]+)','([a-z0-9_-]+)','([A-Za-z0-9_-]+)'\]", js)
+ # Only the STAMPS table. Scanning the whole script for three-string arrays
+ # matched anything shaped like one — an enum of ['circle','square','diamond']
+ # was read as a stamp and failed to decode as pattern data.
+ block = re.search(r"const STAMPS=\[\n(.*?)\n\];", js, re.S)
+ if not block:
+ print("no STAMPS table found")
+ return 1
+ stamps = re.findall(r"\['([a-z]+)','([a-z0-9_-]+)','([A-Za-z0-9_-]+)'\]",
+ block.group(1))
if not stamps:
print("no STAMPS table found")
return 2