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
16 changes: 9 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ jobs:
body: |
## Install

**macOS** — download the `.dmg` for your chip (`arm64` for Apple
silicon, `x64` for Intel). These builds are **unsigned**, so the
first launch needs: right-click the app → **Open** → **Open**.

**Windows** — run the `Setup` installer, or use the portable
`.exe`. SmartScreen will show "Windows protected your PC" →
**More info** → **Run anyway**.
**macOS** (Apple silicon) — open the `.dmg` and drag PatternFront
to Applications, then open it *from there*. These builds are
**unsigned**, so the first launch needs: right-click → **Open** →
**Open**. Opening it from the mounted image instead runs it
translocated from a random read-only path, and the copy left in
Downloads is what Finder later numbers "PatternFront 2".

**Windows** — run the `Setup` installer. SmartScreen will show
"Windows protected your PC" → **More info** → **Run anyway**.

Signing certificates cost money this project does not spend yet.
If you would rather not trust an unsigned build, `npm run dist`
Expand Down
89 changes: 72 additions & 17 deletions app/patternfront.html
Original file line number Diff line number Diff line change
Expand Up @@ -1723,20 +1723,43 @@
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. */
/* Which parameters exist, and what a value means once it arrives as text. All
three tables are derived from the DSL's own, so a primitive or parameter added
to DSL_SHAPES/DSL_RANGES is offered to the model automatically. */
const AI_ENUMS={axis:['h','v'],orientation:['up','down'],
shape:['circle','square','diamond'],font:['5x7','3x5']};
const AI_BOOLS=['blue'];
const AI_NUMS=()=>[...Object.keys(DSL_RANGES),'seed','angle'];
const AI_PARAMS=()=>[...AI_NUMS(),...AI_BOOLS,...Object.keys(AI_ENUMS),'glyphs'];
/* One shape parameter, as text, turned back into what the renderer expects.
The value arrives as a string because the schema below carries no per-parameter
types; this is where that is paid back, with a message a user can act on rather
than a NaN that clamps to a silent zero. */
function aiCoerce(name,value){
if(AI_BOOLS.includes(name)) return value===true||value==='true';
if(AI_ENUMS[name]){
if(!AI_ENUMS[name].includes(value))
throw new Error(name+' must be one of '+AI_ENUMS[name].join(', ')+
', not '+JSON.stringify(value));
return value;}
if(AI_NUMS().includes(name)){
const n=Number(value);
if(!Number.isFinite(n))
throw new Error(name+' is not a number: '+JSON.stringify(value));
return n;}
return String(value);
}
/* Structured outputs compile this schema into a grammar, and the service caps a
schema at 24 OPTIONAL parameters. Hanging all 29 DSL_RANGES keys plus the nine
others off one shape object came to 38 properties with only `type` required —
37 optional — and every request came back 400 with a message about grammar
compilation. So a shape carries its parameters as a list of name/value pairs
instead: nothing here is optional, the count is zero however many primitives
the DSL grows, and the name enum still refuses a parameter the DSL lacks.
Types and ranges are not in the grammar, which costs nothing — every value
went through aiCoerce and dslValidateShape's clamping regardless. */
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;
const int={type:'integer'},bool={type:'boolean'},str={type:'string'};
return {type:'object',additionalProperties:false,
required:['canvas','layers','post'],
properties:{
Expand All @@ -1746,12 +1769,30 @@
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}}}},
shape:{type:'object',additionalProperties:false,
required:['type','params'],
properties:{
type:{type:'string',enum:Object.keys(DSL_SHAPES)},
params:{type:'array',items:{type:'object',additionalProperties:false,
required:['name','value'],
properties:{name:{type:'string',enum:AI_PARAMS()},value:str}}}}}}}},
post:{type:'object',additionalProperties:false,
required:['mirrorX','mirrorY','rotate90','invert'],
properties:{mirrorX:bool,mirrorY:bool,rotate90:int,invert:bool}}}};
}
/* The reply's pair lists collapsed back into the flat shapes the rest of the
editor speaks, so what lands in the Program box is an ordinary program the
user can read and edit — the wire format never escapes this function. */
function aiToProgram(raw){
if(!raw||typeof raw!=='object') throw new Error('program must be an object');
return {canvas:{...(raw.canvas||{})},post:{...(raw.post||{})},
layers:(raw.layers||[]).map(l=>{
const src=(l&&l.shape)||{},shape={type:src.type};
for(const p of src.params||[]){
if(!p||typeof p.name!=='string') continue;
shape[p.name]=aiCoerce(p.name,p.value);}
return {op:l&&l.op,shape};})};
}
/* Built from the same tables, for the same reason. */
function aiSystem(){
const ranges=Object.entries(DSL_RANGES)
Expand All @@ -1766,6 +1807,19 @@
'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.',
'A program has at most '+DSL_MAX_LAYERS+' layers. That is a hard limit and a',
'program with more is thrown away whole, so compose the idea within it —',
'a subject that seems to want more layers wants a simpler reading of itself,',
'not another layer.',
'',
'A shape carries its parameters as a list of name/value pairs, and every',
'value is written as a string — {"type":"diagonal","params":[',
'{"name":"dx","value":"1"},{"name":"period","value":"8"}]}. Omit the pair',
'entirely rather than inventing a value for a parameter the primitive',
'does not take.',
'Values that are not numbers: '+
Object.entries(AI_ENUMS).map(([k,v])=>k+' '+v.join('/')).join(', ')+
', blue true/false, glyphs up to 8 letters or digits.',
'',
'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',
Expand Down Expand Up @@ -1822,9 +1876,10 @@
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); }
let raw;
try{ raw=JSON.parse(text); }
catch{ throw new Error('The model did not return a program'); }
const prog=aiToProgram(raw); // pair lists back into flat shapes
dslValidate(prog); // throws with a readable reason
return prog;
}
Expand Down
16 changes: 8 additions & 8 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ fileAssociations:
mac:
category: public.app-category.graphics-design
icon: build/icon.png
# One artifact, deliberately. Four macOS files (two DMGs, two zips) meant a
# person downloading twice ended up with PatternFront, PatternFront 2 and
# PatternFront 3 sitting in Finder, with no way to tell which was which.
# Apple silicon only: an Intel build is a build nobody here can test.
target:
- target: dmg
arch: [arm64, x64]
- target: zip
arch: [arm64, x64]
arch: [arm64]
# Ad-hoc signed. Not a substitute for a Developer ID — Gatekeeper still
# refuses the app on first launch — but it is the difference between a refusal
# the user can clear and one they cannot.
Expand Down Expand Up @@ -80,10 +82,11 @@ dmg:

win:
icon: build/icon.png
# Likewise one: the installer, x64. The arm64 and universal installers and the
# portable build were three more things to choose between and none of them
# answered a question the plain installer does not.
target:
- target: nsis
arch: [x64, arm64]
- target: portable
arch: [x64]

nsis:
Expand All @@ -94,8 +97,5 @@ nsis:
createStartMenuShortcut: true
artifactName: ${productName}-Setup-${version}-${arch}.${ext}

portable:
artifactName: ${productName}-${version}-portable.${ext}

# electron-builder would otherwise try to reach out during CI runs.
publish: null
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "patternfront",
"productName": "PatternFront",
"version": "0.2.1",
"version": "0.2.2",
"description": "A 1-bit pattern editor for OpenFront territory patterns \u2014 desktop app for macOS and Windows.",
"keywords": [
"pixel-art",
Expand Down
58 changes: 57 additions & 1 deletion tools/verify-behaviour.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,9 +558,12 @@ function duotoneTests() {
async function aiTests() {
console.log('\n=== writing a program with a model ===');

// The wire format: parameters as name/value pairs, every value a string.
const pairs = (o) => Object.entries(o).map(([name, value]) => ({ name, value: String(value) }));
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 } }],
layers: [{ op: 'set', shape: { type: 'diagonal',
params: pairs({ 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 () => ({
Expand All @@ -580,6 +583,8 @@ async function aiTests() {
grabConst('F57'), grabConst('F35'),
grabFunction('dslValidateShape'), grabFunction('dslValidate'),
grabConst('AI_URL'), grabConst('AI_MODEL'), grabConst('AI_KEY'),
grabConst('AI_ENUMS'), grabConst('AI_BOOLS'), grabConst('AI_NUMS'), grabConst('AI_PARAMS'),
grabFunction('aiCoerce'), grabFunction('aiToProgram'),
grabFunction('aiSchema'), grabFunction('aiSystem'),
grabConst('aiKey'), grabFunction('aiWrite'),
].join('\n'));
Expand Down Expand Up @@ -620,11 +625,60 @@ async function aiTests() {
shapeEnum.length === primitiveCount, `${shapeEnum.length} primitives`);
chk('the prompt names the primitives too',
ctx.aiSystem().includes('diagonal') && ctx.aiSystem().includes('halftone'));
// Every limit dslValidate can throw on has to be in the prompt, or the model
// writes something reasonable and the whole reply is binned. "city skyline"
// came back with more than DSL_MAX_LAYERS layers and the user got "too many
// layers" — the cap was the one bound the prompt never mentioned.
const maxLayers = vm.runInContext('DSL_MAX_LAYERS', ctx);
chk('the prompt states the layer cap the validator enforces',
ctx.aiSystem().includes(`at most ${maxLayers} layers`),
`cap is ${maxLayers}`);

// Structured outputs compile the schema into a grammar, and the service caps
// a schema at 24 OPTIONAL parameters. The first version of this schema hung
// every DSL parameter off one shape object — 38 properties with only `type`
// required — so all 37 optional ones came back as a 400 that no user could
// act on and the whole feature was dead. Counting them here is what stops a
// new primitive or parameter from quietly doing it again.
const optionals = (node) => {
if (!node || typeof node !== 'object') return 0;
let n = 0;
if (node.properties) {
const req = new Set(node.required || []);
n += Object.keys(node.properties).filter(k => !req.has(k)).length;
for (const v of Object.values(node.properties)) n += optionals(v);
}
if (node.items) n += optionals(node.items);
return n;
};
const optCount = optionals(schema);
chk('the schema stays under the service\'s optional-parameter limit',
optCount <= 24, `${optCount} optional, limit 24`);

// 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');
// The pair list is a wire format, not something the renderer or the Program
// box ever sees: what comes back must be an ordinary flat shape, with numbers
// that are numbers. A "8" reaching dslRender would clamp and tile wrong.
const sh = prog.layers[0].shape;
chk('pairs collapse into a flat shape the renderer speaks',
sh.dx === 1 && sh.period === 8 && sh.thickness === 3 && !('params' in sh),
JSON.stringify(sh));
chk('numeric parameters arrive as numbers, not strings',
typeof sh.period === 'number' && typeof sh.dx === 'number');
const bad = (name, value) => failed(reply(asText({
canvas: GOOD.canvas, post: GOOD.post,
layers: [{ op: 'set', shape: { type: 'diagonal', params: [{ name, value }] } }] })));
chk('a parameter that is not a number is named in the error',
(await bad('period', 'eight')) === 'period is not a number: "eight"',
String(await bad('period', 'eight')));
chk('a value outside an enumerated set is refused',
(await bad('axis', 'sideways')) === 'axis must be one of h, v, not "sideways"',
String(await bad('axis', 'sideways')));
chk('booleans survive the crossing',
ctx.aiCoerce('blue', 'true') === true && ctx.aiCoerce('blue', 'false') === false);

// Every failure mode, in the order a user would meet them.
chk('an unknown shape is rejected, not rendered',
Expand Down Expand Up @@ -652,6 +706,8 @@ async function aiTests() {
$: () => ({ 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'),
grabConst('AI_ENUMS'), grabConst('AI_BOOLS'), grabConst('AI_NUMS'),
grabConst('AI_PARAMS'), grabFunction('aiCoerce'), grabFunction('aiToProgram'),
grabFunction('aiSchema'), grabFunction('aiSystem'),
grabConst('DSL_CANVAS_LOCKED'), grabConst('floorMod'), grabFunction('pyRound'),
grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'),
Expand Down
12 changes: 9 additions & 3 deletions tools/verify-electron.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,15 @@ def main() -> int:

print("\n=== packaging ===")
yml = read_root("electron-builder.yml")
chk("mac builds both architectures", "arch: [arm64, x64]" in yml)
chk("windows gets an installer and a portable build",
"target: nsis" in yml and "target: portable" in yml)
# One file per platform, on purpose. Shipping two DMGs and two zips left
# people with "PatternFront 2" and "PatternFront 3" in Finder — macOS
# numbering duplicate copies — with nothing to say which was which.
chk("macOS ships one artifact: the Apple silicon dmg",
"- target: dmg" in yml and "arch: [arm64]" in yml
and "target: zip" not in yml and "arch: [arm64, x64]" not in yml)
chk("windows ships one artifact: the x64 installer",
"target: nsis" in yml and "arch: [x64]" in yml
and "target: portable" not in yml)
chk("only the app ships, not the repo",
"- electron/**/*" in yml and "- app/**/*" in yml and "tools/" not in yml)
chk("signing is wired but inert", "hardenedRuntime: false" in yml
Expand Down
Loading