diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b634f9b..9f8b569 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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` diff --git a/app/patternfront.html b/app/patternfront.html index ab962aa..e7aad97 100644 --- a/app/patternfront.html +++ b/app/patternfront.html @@ -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:{ @@ -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) @@ -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', @@ -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; } diff --git a/electron-builder.yml b/electron-builder.yml index b24b6e9..444dc20 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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. @@ -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: @@ -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 diff --git a/package-lock.json b/package-lock.json index ef9c15f..bd2adf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patternfront", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patternfront", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "electron": "^43.4.0", diff --git a/package.json b/package.json index 613701c..f690dd8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/verify-behaviour.js b/tools/verify-behaviour.js index 7b6884c..7581957 100755 --- a/tools/verify-behaviour.js +++ b/tools/verify-behaviour.js @@ -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 () => ({ @@ -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')); @@ -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', @@ -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'), diff --git a/tools/verify-electron.py b/tools/verify-electron.py index a411099..3e95374 100755 --- a/tools/verify-electron.py +++ b/tools/verify-electron.py @@ -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