Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions app/patternfront.html
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,23 @@
font:inherit;padding:0.4rem"></textarea>
<button class="b go" id="dslGo">Replace canvas</button>
<span class="note" id="dslStat"></span>
<hr style="border:0;border-top:1px solid var(--void);margin:1px 0">
<div class="h" style="margin:0">Describe it instead</div>
<span class="note">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.</span>
<div class="r"><input id="aiKey" type="password" placeholder="Anthropic API key" class="wide"
autocomplete="off" spellcheck="false"
style="background:var(--well);color:var(--text);border:0.1rem solid var(--edge-dim);
font:inherit;padding:0 0.4rem">
<button class="b" id="aiSave" style="flex:0 0 auto">Save</button>
<button class="b" id="aiForget" style="flex:0 0 auto">Forget</button></div>
<div class="r" id="aiAsk" hidden><input id="aiWhat" class="wide"
placeholder="diagonal stripes with a thin gap"
style="background:var(--well);color:var(--text);border:0.1rem solid var(--edge-dim);
font:inherit;padding:0 0.4rem">
<button class="b go" id="aiGo" style="flex:0 0 auto">Write it</button></div>
<span class="note" id="aiStat"></span>
</div>
</div></div>

Expand Down Expand Up @@ -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='<span style="color:var('+(ok?'--ok':'--danger')+')">'+msg+'</span>';
}
$('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)); }
Expand Down
7 changes: 6 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
},
});
Expand Down
5 changes: 4 additions & 1 deletion tools/lib/extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ function matchBrace(text, from) {

/** Source of `function <name>(...) { ... }`, 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);
}

Expand Down
127 changes: 126 additions & 1 deletion tools/verify-behaviour.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -760,6 +884,7 @@ oversize.then(async () => {
loadTests();
previewTests();
duotoneTests();
await aiTests();
fitTests();
grabTests();
clearTests();
Expand Down
15 changes: 15 additions & 0 deletions tools/verify-electron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ***")
Expand Down
10 changes: 9 additions & 1 deletion tools/verify-stamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<script>(.*?)</script>", 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
Expand Down
Loading