antisketch 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/README.md +103 -2
- package/app/distribution/brand.html +1 -0
- package/app/mcp.example.json +10 -0
- package/app/public/app.js +86 -0
- package/app/public/brand/favicon.svg +1 -0
- package/app/public/brand/identity.css +11 -0
- package/app/public/brand/identity.json +50 -0
- package/app/public/brand/mark.svg +1 -0
- package/app/public/fonts/Inter-LICENSE.txt +92 -0
- package/app/public/fonts/InterVariable.woff2 +0 -0
- package/app/public/fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/app/public/fonts/PlayfairDisplay-LICENSE.txt +93 -0
- package/app/public/fonts/PlayfairDisplay.ttf +0 -0
- package/app/public/fonts/SpaceGrotesk-LICENSE.txt +93 -0
- package/app/public/fonts/SpaceGrotesk.ttf +0 -0
- package/app/public/index.html +15 -0
- package/app/public/setup.js +53 -0
- package/app/public/style.css +318 -0
- package/app/scripts/backup.js +8 -0
- package/app/scripts/restore.js +7 -0
- package/app/server/app.js +55 -0
- package/app/server/artwork.js +25 -0
- package/app/server/capture.js +56 -0
- package/app/server/codex-connection.js +89 -0
- package/app/server/compat.js +12 -0
- package/app/server/config.js +13 -0
- package/app/server/demo-fonts.js +14 -0
- package/app/server/files.js +9 -0
- package/app/server/generate.js +53 -0
- package/app/server/index.js +21 -0
- package/app/server/install.js +69 -0
- package/app/server/interaction-contract.js +2 -0
- package/app/server/interpret.js +108 -0
- package/app/server/library.js +44 -0
- package/app/server/mcp.js +15 -0
- package/app/server/network.js +23 -0
- package/app/server/package.js +33 -0
- package/app/server/releases.js +8 -0
- package/app/server/setup.js +6 -0
- package/app/server/store.js +25 -0
- package/app/server/validate.js +48 -0
- package/app/server/worker.js +26 -0
- package/bin/antisketch +25 -0
- package/bin/design-vault +3 -0
- package/package.json +80 -4
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {prepareLibrary} from './library.js';
|
|
2
|
+
import {ROOT,DATA as libraryData,APPROVED as libraryApproved} from './config.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import {z} from 'zod';
|
|
8
|
+
import {APPROVED} from './config.js';
|
|
9
|
+
import {listReleaseFiles,verifyRelease} from './releases.js';
|
|
10
|
+
await prepareLibrary({root:ROOT,data:libraryData,approved:libraryApproved});
|
|
11
|
+
const server=new McpServer({name:'antisketch',version:'1.0.0'});
|
|
12
|
+
const output=data=>({content:[{type:'text',text:typeof data==='string'?data:JSON.stringify(data,null,2)}]});
|
|
13
|
+
server.registerTool('list_approved_designs',{description:'List personal design skills explicitly approved by the owner. Does not expose drafts.',inputSchema:{},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}},async()=>output(listReleaseFiles(APPROVED).map(({folder,...release})=>release)));
|
|
14
|
+
server.registerTool('read_design_skill',{description:'Read an approved design skill, its specification, or measured tokens. No modification or approval is possible through this tool.',inputSchema:{name:z.string().max(64),version:z.number().int().positive().optional(),part:z.enum(['skill','specification','tokens','evidence']).default('skill')},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}},async({name,version,part})=>{try{const releases=listReleaseFiles(APPROVED).filter(r=>r.name===name&&(!version||r.version===version)).sort((a,b)=>b.version-a.version);if(!releases.length)return {...output('Approved skill not found'),isError:true};const release=releases[0];verifyRelease(release.folder);const file={skill:'SKILL.md',specification:'references/specification.md',tokens:'references/tokens.json',evidence:'references/evidence.json'}[part];return output(fs.readFileSync(path.join(release.folder,file),'utf8'));}catch(e){return {...output(e.message),isError:true};}});
|
|
15
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import dns from 'node:dns/promises';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import ipaddr from 'ipaddr.js';
|
|
5
|
+
export function publicAddress(address){try{let ip=ipaddr.parse(address);if(ip.kind()==='ipv6'&&ip.isIPv4MappedAddress())ip=ip.toIPv4Address();return ip.range()==='unicast';}catch{return false;}}
|
|
6
|
+
export function normalizeURL(input){let u;try{u=new URL(input.includes('://')?input:'https://'+input);}catch{throw new Error('Enter a valid website address');}if(!['http:','https:'].includes(u.protocol)||u.username||u.password)throw new Error('Use a public HTTP or HTTPS website without embedded credentials');if(u.port&&!['80','443'].includes(u.port))throw new Error('Only standard web ports (80 and 443) are supported');u.hash='';return u.href;}
|
|
7
|
+
export async function resolvePublic(host,allowLoopback=false){host=host.replace(/^\[|\]$/g,'');const addresses=net.isIP(host)?[{address:host,family:net.isIP(host)}]:await dns.lookup(host,{all:true});if(!addresses.length||addresses.some(a=>!publicAddress(a.address)&&!(allowLoopback&&a.address==='127.0.0.1')))throw new Error('Private, local, and reserved network addresses cannot be captured');return addresses.find(a=>a.family===4)||addresses[0];}
|
|
8
|
+
export async function validateURL(input){const url=normalizeURL(input);await resolvePublic(new URL(url).hostname);return url;}
|
|
9
|
+
// Resolve once, validate every answer, then connect to that exact IP. This also
|
|
10
|
+
// protects redirects and subresources against DNS rebinding and private networks.
|
|
11
|
+
export async function startCaptureProxy({allowLoopback=false}={}){
|
|
12
|
+
const sockets=new Set();
|
|
13
|
+
const track=s=>{sockets.add(s);s.on('close',()=>sockets.delete(s));s.on('error',()=>{});s.setTimeout(45000,()=>s.destroy());return s;};
|
|
14
|
+
const server=http.createServer(async(req,res)=>{try{
|
|
15
|
+
const u=new URL(req.url);if(u.protocol!=='http:'||(!allowLoopback&&u.port&&u.port!=='80')||!['GET','HEAD'].includes(req.method))throw new Error('Request not allowed');
|
|
16
|
+
const ip=await resolvePublic(u.hostname,allowLoopback);
|
|
17
|
+
const upstream=http.request({host:ip.address,family:ip.family,port:u.port||80,path:u.pathname+u.search,method:req.method,headers:{...req.headers,host:u.host,connection:'close'}},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res);});upstream.on('socket',track);upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();});req.pipe(upstream);
|
|
18
|
+
}catch{res.writeHead(403);res.end('Capture network policy blocked this address');}});
|
|
19
|
+
server.on('connection',track);
|
|
20
|
+
server.on('connect',async(req,client,head)=>{try{const u=new URL('https://'+req.url);if((u.port||'443')!=='443')throw new Error('Port blocked');const ip=await resolvePublic(u.hostname,allowLoopback);const upstream=track(net.connect({host:ip.address,port:443,family:ip.family},()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client);}));upstream.on('error',()=>client.destroy());client.on('close',()=>upstream.destroy());}catch{client.end('HTTP/1.1 403 Forbidden\r\n\r\n');}});
|
|
21
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
|
|
22
|
+
return {url:`http://127.0.0.1:${server.address().port}`,close:async()=>{for(const s of sockets)s.destroy();await new Promise(r=>server.close(r));}};
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {embedFonts} from './demo-fonts.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {atomic,inventory,readJSON,slug} from './files.js';
|
|
5
|
+
export const DEMO_CSP="sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'; object-src 'none'";
|
|
6
|
+
export function secureDemo(html){return '<!doctype html><meta http-equiv="Content-Security-Policy" content="'+DEMO_CSP.replace('sandbox allow-scripts; ','')+'">\n'+html;}
|
|
7
|
+
function dtcg(t){
|
|
8
|
+
if(t.category==='color'){const m=t.value.match(/^rgba?\(([\d.]+)[, ]+([\d.]+)[, ]+([\d.]+)(?:[, /]+([\d.]+))?\)$/);if(m)return {$type:'color',$value:{colorSpace:'srgb',components:m.slice(1,4).map(v=>Number(v)/255),alpha:m[4]===undefined?1:Number(m[4])}};if(/^#[a-f0-9]{6}$/i.test(t.value))return {$type:'color',$value:{colorSpace:'srgb',components:[1,3,5].map(i=>parseInt(t.value.slice(i,i+2),16)/255),alpha:1}};}
|
|
9
|
+
if(t.category==='fontFamily')return {$type:'fontFamily',$value:t.value.split(',').map(v=>v.trim().replace(/^['"]|['"]$/g,''))};
|
|
10
|
+
if(['dimension','spacing','radius'].includes(t.category)&&/^-?[\d.]+px$/.test(t.value))return {$type:'dimension',$value:{value:parseFloat(t.value),unit:'px'}};
|
|
11
|
+
if(t.category==='motion'&&/^[\d.]+m?s$/.test(t.value))return {$type:'duration',$value:{value:parseFloat(t.value),unit:t.value.endsWith('ms')?'ms':'s'}};
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
export function materialize(capture,result,dir){
|
|
15
|
+
const tokens=result.tokenReferences.map(ref=>({...capture.candidates.find(t=>t.id===ref.id),name:ref.name,role:ref.role,classification:'observed',scope:'Captured elements and viewports; resolved CSS values'}));
|
|
16
|
+
const typed={};for(const t of tokens){const value=dtcg(t);if(value)typed[t.name]={...value,$description:t.role,$extensions:{'antisketch':{evidence:t.evidence,scope:t.scope}}};}
|
|
17
|
+
atomic(path.join(dir,'tokens.json'),{version:1,tokens});atomic(path.join(dir,'tokens.dtcg.json'),typed);atomic(path.join(dir,'specification.md'),result.specification);
|
|
18
|
+
result.demos.forEach((d,i)=>atomic(path.join(dir,`demo-${i+1}.html`),secureDemo(embedFonts(d.artwork?d.html.replace('<!--ANTISKETCH_ART-->',d.artwork.svg):d.html))));
|
|
19
|
+
const summary={visualReview:result.visualReview,concepts:result.concepts,name:result.name,summary:result.summary,principles:result.principles,limitations:[...capture.warnings,...capture.coverage.limits,...result.limitations],demos:result.demos.map((d,i)=>({id:i+1,title:d.title,brief:d.brief,file:`demo-${i+1}.html`}))};atomic(path.join(dir,'summary.json'),summary);return summary;
|
|
20
|
+
}
|
|
21
|
+
export function buildRelease({design,revision,version,selectedDemo,note,checks,sourceDir,releaseDir}){
|
|
22
|
+
if(fs.existsSync(releaseDir))throw new Error('This release already exists');
|
|
23
|
+
const summary=readJSON(path.join(sourceDir,'summary.json'));const name=slug(summary.name)+'-'+design.id.slice(0,8);const demo=summary.demos.find(d=>d.id===selectedDemo);if(!demo)throw new Error('Choose a final demo');
|
|
24
|
+
const stage=releaseDir+'.pending';fs.rmSync(stage,{recursive:true,force:true});fs.mkdirSync(path.join(stage,'references'),{recursive:true});fs.mkdirSync(path.join(stage,'assets'),{recursive:true});
|
|
25
|
+
try{
|
|
26
|
+
for(const file of ['tokens.json','tokens.dtcg.json','specification.md'])fs.copyFileSync(path.join(sourceDir,file),path.join(stage,'references',file));
|
|
27
|
+
fs.copyFileSync(path.join(sourceDir,demo.file),path.join(stage,'assets','demo.html'));
|
|
28
|
+
atomic(path.join(stage,'references','evidence.json'),{source:design.url,capturedAt:readJSON(path.join(sourceDir,'capture.json')).capturedAt,principles:summary.principles,measurements:readJSON(path.join(sourceDir,'capture.json')).pages.map(p=>({id:p.id,url:p.url,viewport:p.viewport,elements:p.elements,variables:p.variables,measurements:p.measurements})),limitations:summary.limitations});
|
|
29
|
+
atomic(path.join(stage,'SKILL.md'),`---\nname: ${name}\ndescription: ${JSON.stringify(`Apply the ${summary.name} design language to interfaces when this aesthetic is requested. Use scoped tokens and adapt composition to the target task.`)}\n---\n\n# ${summary.name}\n\n${summary.summary}\n\nRead [the design specification](references/specification.md) before designing. Use [scoped tokens and their evidence](references/tokens.json); [typed tokens](references/tokens.dtcg.json) contain the subset representable in DTCG format.\n\n${summary.principles.map(p=>`- **${p.title}:** ${p.description} (${p.classification})`).join('\n')}\n\nOpen [the approved example](assets/demo.html) to understand the visual relationships. It demonstrates ${demo.title}: ${demo.brief}\n\nPreserve the aesthetic relationships while choosing a composition and interaction model suited to the user's task. The example's layout and subject matter are not mandatory. Source defects and untested behaviors are not reusable design rules. Read [evidence and limits](references/evidence.json) when precision matters. The reference website's images, logos, and fonts are not licensed for reuse by this skill.\n\nThis release was approved as version ${version}; later drafts do not change it.\n`);
|
|
30
|
+
atomic(path.join(stage,'approval.json'),{version,designId:design.id,revision,selectedDemo,approvedAt:new Date().toISOString(),approvedBy:'local-owner',note,checks,sourceURL:design.url});
|
|
31
|
+
atomic(path.join(stage,'manifest.json'),{formatVersion:1,name,version,files:inventory(stage)});fs.mkdirSync(path.dirname(releaseDir),{recursive:true});fs.renameSync(stage,releaseDir);return name;
|
|
32
|
+
}catch(e){fs.rmSync(stage,{recursive:true,force:true});throw e;}
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {readJSON,contained,hash} from './files.js';
|
|
4
|
+
export function listReleaseFiles(root){if(!fs.existsSync(root))return [];const releases=[];for(const design of fs.readdirSync(root,{withFileTypes:true})){if(!design.isDirectory()||!/^[-a-z0-9]+$/.test(design.name))continue;for(const version of fs.readdirSync(path.join(root,design.name),{withFileTypes:true})){if(!version.isDirectory()||!/^v\d+$/.test(version.name))continue;const folder=path.join(root,design.name,version.name);try{const manifest=readJSON(path.join(folder,'manifest.json')),approval=readJSON(path.join(folder,'approval.json'));releases.push({name:manifest.name,version:manifest.version,designId:approval.designId,revision:approval.revision,approvedAt:approval.approvedAt,folder});}catch{}}}return releases;}
|
|
5
|
+
export function verifyRelease(folder){const manifest=readJSON(path.join(folder,'manifest.json'));if(!Array.isArray(manifest.files)||!manifest.files.length)throw new Error('Release manifest is missing');for(const f of manifest.files)if(hash(contained(folder,f.path))!==f.sha256)throw new Error('Release integrity check failed');return manifest;}
|
|
6
|
+
// Finish an approval whose files were atomically committed before a process crash.
|
|
7
|
+
// Only packages containing a valid explicit approval record are reconciled.
|
|
8
|
+
export function recoverReleases(store,root){for(const release of listReleaseFiles(root)){if(store.db.prepare('SELECT id FROM releases WHERE design_id=? AND revision=?').get(release.designId,release.revision))continue;const d=store.design(release.designId),r=store.revision(release.designId,release.revision);if(!d||!r)continue;try{verifyRelease(release.folder);const approval=readJSON(path.join(release.folder,'approval.json'));if(!approval.checks?.reference||!approval.checks?.transfer||!approval.checks?.limits||approval.approvedBy!=='local-owner')continue;store.db.transaction(()=>{store.db.prepare('INSERT INTO releases VALUES(?,?,?,?,?,?)').run(`${release.designId}-recovered-${release.version}`,release.designId,release.revision,release.version,path.relative(root,release.folder),release.approvedAt);store.stage(release.designId,release.revision,'approved',`Recovered approved skill version ${release.version}`);})();}catch{ /* Keep an inconsistent package untouched for manual recovery. */ }}}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {launchBrowser} from './capture.js';
|
|
2
|
+
let cached=null;
|
|
3
|
+
export async function browserStatus({fresh=false}={}){
|
|
4
|
+
if(!fresh&&cached&&Date.now()-cached.at<60000)return cached.promise;
|
|
5
|
+
const promise=(async()=>{let browser;try{browser=await launchBrowser({timeout:10000});return {ready:true,message:'Browser ready for captures.'};}catch{return {ready:false,message:'Install Google Chrome, then check again. Antisketch needs a local browser to capture references.',downloadURL:'https://www.google.com/chrome/'};}finally{await browser?.close();}})();cached={at:Date.now(),promise};return promise;
|
|
6
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import {databasePath} from './compat.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import {DATA,ACTIVE,now} from './config.js';
|
|
7
|
+
export function openStore(dir=DATA){
|
|
8
|
+
fs.mkdirSync(dir,{recursive:true});const db=new Database(databasePath(dir));
|
|
9
|
+
db.pragma('journal_mode = WAL');db.pragma('foreign_keys = ON');db.pragma('busy_timeout = 5000');
|
|
10
|
+
db.exec(`CREATE TABLE IF NOT EXISTS designs(id TEXT PRIMARY KEY,url TEXT NOT NULL,name TEXT NOT NULL,notes TEXT NOT NULL DEFAULT '',current_revision INTEGER NOT NULL DEFAULT 1,archived INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS revisions(design_id TEXT NOT NULL REFERENCES designs(id),number INTEGER NOT NULL,status TEXT NOT NULL,stage TEXT NOT NULL DEFAULT '',feedback TEXT NOT NULL DEFAULT '',error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(design_id,number));
|
|
12
|
+
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,design_id TEXT NOT NULL,revision INTEGER NOT NULL,kind TEXT NOT NULL,message TEXT NOT NULL,created_at TEXT NOT NULL);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS releases(id TEXT PRIMARY KEY,design_id TEXT NOT NULL REFERENCES designs(id),revision INTEGER NOT NULL,version INTEGER NOT NULL,folder TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(design_id,revision),UNIQUE(design_id,version));
|
|
14
|
+
PRAGMA user_version = 1;`);
|
|
15
|
+
const design=id=>db.prepare('SELECT * FROM designs WHERE id=?').get(id);
|
|
16
|
+
const revision=(id,n)=>db.prepare('SELECT * FROM revisions WHERE design_id=? AND number=?').get(id,n);
|
|
17
|
+
const event=(id,n,kind,message)=>db.prepare('INSERT INTO events(design_id,revision,kind,message,created_at) VALUES(?,?,?,?,?)').run(id,n,kind,message,now());
|
|
18
|
+
function stage(id,n,status,message,error=null){db.transaction(()=>{db.prepare('UPDATE revisions SET status=?,stage=?,error=?,updated_at=? WHERE design_id=? AND number=?').run(status,message,error,now(),id,n);db.prepare('UPDATE designs SET updated_at=? WHERE id=?').run(now(),id);event(id,n,status,message);})();}
|
|
19
|
+
function create(url,notes=''){const id=crypto.randomUUID(),date=now();db.transaction(()=>{db.prepare('INSERT INTO designs(id,url,name,notes,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(id,url,new URL(url).hostname,notes,date,date);db.prepare('INSERT INTO revisions(design_id,number,status,stage,created_at,updated_at) VALUES(?,1,?,?,?,?)').run(id,'queued','Waiting to capture',date,date);event(id,1,'queued','Website added to your inbox');})();return design(id);}
|
|
20
|
+
function revise(id,feedback){return db.transaction(()=>{const d=design(id);if(!d)throw new Error('Design not found');const r=revision(id,d.current_revision);if(ACTIVE.includes(r.status))throw new Error('This design is still being prepared');const n=d.current_revision+1,date=now();db.prepare('UPDATE designs SET current_revision=?,updated_at=? WHERE id=?').run(n,date,id);db.prepare('INSERT INTO revisions(design_id,number,status,stage,feedback,created_at,updated_at) VALUES(?,?,?,?,?,?,?)').run(id,n,'queued','Waiting to prepare revision',feedback,date,date);event(id,n,'queued',feedback||'Capture and extraction requested again');return n;})();}
|
|
21
|
+
function list(){return db.prepare(`SELECT d.*,r.status,r.stage,r.error,(SELECT COUNT(*) FROM releases a WHERE a.design_id=d.id) AS release_count FROM designs d JOIN revisions r ON r.design_id=d.id AND r.number=d.current_revision ORDER BY d.updated_at DESC`).all();}
|
|
22
|
+
function detail(id){const d=design(id);if(!d)return null;return {...d,revisions:db.prepare('SELECT * FROM revisions WHERE design_id=? ORDER BY number DESC').all(id),events:db.prepare('SELECT * FROM events WHERE design_id=? ORDER BY id DESC LIMIT 100').all(id),releases:db.prepare('SELECT * FROM releases WHERE design_id=? ORDER BY version DESC').all(id)};}
|
|
23
|
+
function recover(){for(const r of db.prepare("SELECT * FROM revisions WHERE status IN ('capturing','interpreting','validating')").all())stage(r.design_id,r.number,'failed','Preparation was interrupted. Retry to continue.','The app stopped before this revision was complete. Previously approved versions are unchanged.');}
|
|
24
|
+
return {db,design,revision,event,stage,create,revise,list,detail,recover,close:()=>db.close()};
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {interactionSchema} from './interaction-contract.js';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {launchBrowser} from './capture.js';
|
|
6
|
+
import {DEMO_CSP} from './package.js';
|
|
7
|
+
import {atomic,readJSON} from './files.js';
|
|
8
|
+
export async function validateDemos(dir,{signal}={}){
|
|
9
|
+
const contractsFile=path.join(dir,'interaction-tests.json');
|
|
10
|
+
const contracts=fs.existsSync(contractsFile)?readJSON(contractsFile).demos:[];
|
|
11
|
+
const server=http.createServer((req,res)=>{const m=req.url.match(/^\/demo-([12])\.html$/);if(!m){res.writeHead(404).end();return;}res.writeHead(200,{'Content-Type':'text/html','Content-Security-Policy':DEMO_CSP});res.end(fs.readFileSync(path.join(dir,`demo-${m[1]}.html`)));});
|
|
12
|
+
await new Promise(r=>server.listen(0,'127.0.0.1',r));let browser;const checks=[];
|
|
13
|
+
try{browser=await launchBrowser();const abort=()=>browser.close().catch(()=>{});signal?.addEventListener('abort',abort,{once:true});try{
|
|
14
|
+
for(const demo of [1,2])for(const width of [1440,390]){
|
|
15
|
+
signal?.throwIfAborted();const page=await browser.newPage({viewport:{width,height:900},hasTouch:width===390,isMobile:width===390});const errors=[];page.on('pageerror',e=>errors.push(e.message.slice(0,300)));page.on('dialog',d=>d.dismiss().catch(()=>{}));await page.route('**/*',r=>r.request().url().startsWith(`http://127.0.0.1:${server.address().port}/demo-`)?r.continue():r.abort());
|
|
16
|
+
await page.goto(`http://127.0.0.1:${server.address().port}/demo-${demo}.html`,{waitUntil:'load',timeout:15000});await page.evaluate(()=>document.fonts.ready);await page.waitForTimeout(200);
|
|
17
|
+
const info=await page.evaluate(()=>({overflow:document.documentElement.scrollWidth>innerWidth+2,textLength:document.body.innerText.length,controls:document.querySelectorAll('button,input,select,textarea').length,brokenImages:[...document.images].filter(image=>!image.complete||image.naturalWidth===0).length}));
|
|
18
|
+
const unnamed=await page.locator('button').evaluateAll(es=>es.filter(e=>!e.textContent.trim()&&!e.getAttribute('aria-label')&&!e.getAttribute('title')).length);
|
|
19
|
+
const issues=[...errors,...(info.brokenImages?['An image could not load in the offline sandbox']:[]),...(info.overflow?['Horizontal page overflow']:[]),...(info.textLength<80?['Demo has too little visible content']:[]),...(info.controls===0?['No interactive controls found']:[]),...(unnamed?['A button has no accessible name']:[])];
|
|
20
|
+
const screenshot=`demo-${demo}-${width}.png`;await page.screenshot({path:path.join(dir,screenshot),fullPage:true,timeout:15000});const interactions=[];
|
|
21
|
+
for(const raw of (contracts.find(c=>c.demo===demo)?.tests||[]).slice(0,4)){
|
|
22
|
+
const test=interactionSchema.parse(raw);
|
|
23
|
+
try{
|
|
24
|
+
// A fresh document for each contract; only a small declarative action vocabulary is allowed.
|
|
25
|
+
await page.reload({waitUntil:'load',timeout:15000});page.setDefaultTimeout(2500);
|
|
26
|
+
const target=page.locator(test.assertion.selector).first();
|
|
27
|
+
const before=test.assertion.kind==='textChanged'?await target.innerText():null;
|
|
28
|
+
for(const step of test.steps){signal?.throwIfAborted();const el=page.locator(step.selector).first();
|
|
29
|
+
if(step.action==='click')await el.click();else if(step.action==='fill')await el.fill(step.value);else if(step.action==='select')await el.selectOption(step.value);else await el.press(step.value);
|
|
30
|
+
}
|
|
31
|
+
const deadline=Date.now()+2500;let passed=false;
|
|
32
|
+
do{
|
|
33
|
+
const a=test.assertion;
|
|
34
|
+
passed=a.kind==='visible'?await target.isVisible():a.kind==='attributeEquals'?(await target.getAttribute(a.attribute))===a.value:a.kind==='textIncludes'?(await target.innerText()).includes(a.value):(await target.innerText())!==before;
|
|
35
|
+
if(!passed)await page.waitForTimeout(100);
|
|
36
|
+
}while(!passed&&Date.now()<deadline);
|
|
37
|
+
if(!passed)throw new Error('Expected visible result was not observed');
|
|
38
|
+
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+2);if(overflow)throw new Error('Horizontal overflow after interaction');
|
|
39
|
+
interactions.push({name:test.name,passed:true});
|
|
40
|
+
}catch(e){if(signal?.aborted)throw e;const issue=`${test.name}: ${e.message.slice(0,240)}`;issues.push(issue);interactions.push({name:test.name,passed:false,issue});}
|
|
41
|
+
}
|
|
42
|
+
for(const error of errors)if(!issues.includes(error))issues.push(error);
|
|
43
|
+
checks.push({demo,width,passed:issues.length===0,issues,screenshot,controls:info.controls,interactions});await page.close();
|
|
44
|
+
}
|
|
45
|
+
}finally{signal?.removeEventListener('abort',abort);}
|
|
46
|
+
}finally{await browser?.close().catch(()=>{});await new Promise(r=>server.close(r));}
|
|
47
|
+
const result={passed:checks.every(c=>c.passed),checkedAt:new Date().toISOString(),checks,scope:'Chromium desktop/mobile render checks, overflow, runtime errors, button names, and declared interaction workflows where supplied. Visual critique is advisory; completeness and aesthetic quality still need your review.'};atomic(path.join(dir,'validation.json'),result);return result;
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {DESIGNS,ACTIVE} from './config.js';
|
|
4
|
+
import {capture} from './capture.js';
|
|
5
|
+
import {interpret} from './interpret.js';
|
|
6
|
+
import {materialize} from './package.js';
|
|
7
|
+
import {validateDemos} from './validate.js';
|
|
8
|
+
import {readJSON} from './files.js';
|
|
9
|
+
export const revisionDir=(id,n,root=DESIGNS)=>path.join(root,id,'revisions',String(n));
|
|
10
|
+
export function createWorker(store,{designsDir=DESIGNS,captureFn=capture,interpretFn=interpret,validateFn=validateDemos}={}){
|
|
11
|
+
let active=null,closed=false;const dir=(id,n)=>revisionDir(id,n,designsDir);
|
|
12
|
+
async function tick(){if(active||closed)return;const job=store.db.prepare("SELECT r.* FROM revisions r JOIN designs d ON d.id=r.design_id WHERE r.status='queued' AND d.archived=0 ORDER BY r.created_at LIMIT 1").get();if(!job)return;
|
|
13
|
+
const controller=new AbortController();active={id:job.design_id,n:job.number,controller};const timeout=setTimeout(()=>controller.abort(new Error('Preparation exceeded the 45-minute time limit')),45*60*1000);const target=dir(job.design_id,job.number);
|
|
14
|
+
const update=(status,message)=>{controller.signal.throwIfAborted();store.stage(job.design_id,job.number,status,message);};
|
|
15
|
+
try{const design=store.design(job.design_id);fs.mkdirSync(target,{recursive:true});let evidence,previous=null,direction=null;const last=dir(job.design_id,job.number-1);
|
|
16
|
+
if(job.feedback){const prior=['interpretation.json','direction.json'].find(file=>fs.existsSync(path.join(last,file)));if(prior)previous=readJSON(path.join(last,prior));}
|
|
17
|
+
if(job.feedback&&fs.existsSync(path.join(last,'capture.json'))&&readJSON(path.join(last,'capture.json')).version>=2){update('capturing','Reusing the previous capture for your requested changes');fs.cpSync(path.join(last,'evidence'),path.join(target,'evidence'),{recursive:true});fs.copyFileSync(path.join(last,'capture.json'),path.join(target,'capture.json'));evidence=readJSON(path.join(target,'capture.json'));const prior=store.revision(job.design_id,job.number-1);if(['failed','cancelled'].includes(prior?.status)&&prior.feedback===job.feedback&&fs.existsSync(path.join(last,'direction.json'))){direction=readJSON(path.join(last,'direction.json'));for(const name of ['artwork-experiment-1.json','artwork-experiment-2.json'])if(fs.existsSync(path.join(last,name)))fs.copyFileSync(path.join(last,name),path.join(target,name));}}
|
|
18
|
+
else{update('capturing','Opening the reference in a clean browser');evidence=await captureFn(design.url,target,{signal:controller.signal,onProgress:m=>update('capturing',m)});}
|
|
19
|
+
update('interpreting','Finding the design language and building examples');const result=await interpretFn(evidence,target,{notes:design.notes,feedback:job.feedback,previous,direction,signal:controller.signal,onProgress:m=>update('interpreting',m)});controller.signal.throwIfAborted();materialize(evidence,result,target);
|
|
20
|
+
update('validating','Checking desktop and mobile demos');const validation=await validateFn(target,{signal:controller.signal});controller.signal.throwIfAborted();store.db.prepare('UPDATE designs SET name=? WHERE id=?').run(result.name,design.id);update('review',validation.passed?'Ready for your eye':'Ready to inspect · demo issues need revision before approval');
|
|
21
|
+
}catch(e){const cancelled=store.revision(job.design_id,job.number)?.status==='cancelled';if(!cancelled)store.stage(job.design_id,job.number,'failed','This revision needs attention',controller.signal.aborted?controller.signal.reason?.message||'Preparation interrupted':e.message.slice(0,1500));}
|
|
22
|
+
finally{clearTimeout(timeout);active=null;if(!closed)setImmediate(tick);}
|
|
23
|
+
}
|
|
24
|
+
const interval=setInterval(tick,1500);interval.unref();
|
|
25
|
+
return {wake:()=>setImmediate(tick),cancel(id,n){const r=store.revision(id,n);if(!r||!ACTIVE.includes(r.status))throw new Error('Only an unfinished revision can be cancelled');store.stage(id,n,'cancelled','Preparation cancelled');if(active?.id===id&&active.n===n)active.controller.abort();},async close(){closed=true;clearInterval(interval);active?.controller.abort();while(active)await new Promise(r=>setTimeout(r,50));}};
|
|
26
|
+
}
|
package/bin/antisketch
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
6
|
+
const args=process.argv.slice(2),command=args[0]&&!args[0].startsWith('-')?args.shift():'start';
|
|
7
|
+
if(args.includes('--help')||args.includes('-h')||command==='help'){
|
|
8
|
+
console.log('Usage: antisketch [start] [--no-open]\n antisketch mcp\n antisketch backup [destination]\n antisketch restore <backup>\n\nOpen your local design workshop. Connect Codex in Studio settings.\nYour library lives outside the application and survives updates.\nKeep this Terminal open; Ctrl-C stops the server.');process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
if(['mcp','backup','restore'].includes(command)){
|
|
11
|
+
if(command==='mcp'&&args.length)throw new Error('Usage: antisketch mcp');
|
|
12
|
+
process.argv=[process.execPath,command,...args];
|
|
13
|
+
await import(new URL(command==='mcp'?'../app/server/mcp.js':`../app/scripts/${command}.js`,import.meta.url));
|
|
14
|
+
}else if(command==='start'){
|
|
15
|
+
if(args.some(arg=>arg!=='--no-open'))throw new Error('Unknown option. Run antisketch --help.');
|
|
16
|
+
const port=Number(process.env.PORT||4310);if(!Number.isInteger(port)||port<1||port>65535)throw new Error('PORT must be between 1 and 65535.');
|
|
17
|
+
const url=`http://127.0.0.1:${port}`,open=()=>{if(!args.includes('--no-open')){const child=spawn('open',[url],{stdio:'ignore'});child.on('error',()=>console.log('Open '+url+' in your browser.'));child.unref();}};
|
|
18
|
+
async function healthy(){try{const response=await fetch(url+'/api/health',{signal:AbortSignal.timeout(1200)});const result=await response.json();return ['antisketch','design-vault'].includes(result.app);}catch{return false;}}
|
|
19
|
+
if(await healthy()){console.log('Antisketch is already running at '+url);open();}
|
|
20
|
+
else{
|
|
21
|
+
process.chdir(root);
|
|
22
|
+
const timer=setInterval(async()=>{if(await healthy()){clearInterval(timer);open();}},400);timer.unref();
|
|
23
|
+
await import('../app/server/index.js');
|
|
24
|
+
}
|
|
25
|
+
}else{console.error('Unknown command. Run antisketch --help.');process.exitCode=1;}
|
package/bin/design-vault
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "antisketch",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=20.17"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node app/server/index.js",
|
|
10
|
+
"dev": "node --watch app/server/index.js",
|
|
11
|
+
"test": "node --test --test-concurrency=1 app/tests/*.test.js",
|
|
12
|
+
"test:browser": "node app/tests/browser.js",
|
|
13
|
+
"check": "node app/scripts/check.js",
|
|
14
|
+
"backup": "node app/scripts/backup.js",
|
|
15
|
+
"mcp": "node app/server/mcp.js",
|
|
16
|
+
"restore": "node app/scripts/restore.js",
|
|
17
|
+
"pack:preview": "node app/scripts/pack-preview.js",
|
|
18
|
+
"test:onboarding": "node app/tests/onboarding.js",
|
|
19
|
+
"test:distribution": "node app/tests/distribution.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
23
|
+
"@openai/codex": "0.162.1",
|
|
24
|
+
"better-sqlite3": "12.11.1",
|
|
25
|
+
"express": "5.3.0",
|
|
26
|
+
"ipaddr.js": "2.5.0",
|
|
27
|
+
"playwright": "1.64.1",
|
|
28
|
+
"yazl": "3.3.1",
|
|
29
|
+
"zod": "4.6.7"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"antisketch": "bin/antisketch",
|
|
33
|
+
"design-vault": "bin/design-vault"
|
|
34
|
+
},
|
|
35
|
+
"description": "A local workshop for turning design references into reusable agent skills.",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "Harsh Joshi <harsh.joshi.pth@gmail.com>",
|
|
38
|
+
"homepage": "https://github.com/josharsh/antisketch#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/josharsh/antisketch/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"design",
|
|
44
|
+
"design-system",
|
|
45
|
+
"design-tokens",
|
|
46
|
+
"agent-skills",
|
|
47
|
+
"codex",
|
|
48
|
+
"claude-code",
|
|
49
|
+
"playwright",
|
|
50
|
+
"mcp"
|
|
51
|
+
],
|
|
52
|
+
"os": [
|
|
53
|
+
"darwin"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/josharsh/antisketch.git"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"bin/antisketch",
|
|
61
|
+
"bin/design-vault",
|
|
62
|
+
"app/server/",
|
|
63
|
+
"app/scripts/backup.js",
|
|
64
|
+
"app/scripts/restore.js",
|
|
65
|
+
"app/mcp.example.json",
|
|
66
|
+
"app/distribution/",
|
|
67
|
+
"app/public/index.html",
|
|
68
|
+
"app/public/app.js",
|
|
69
|
+
"app/public/setup.js",
|
|
70
|
+
"app/public/style.css",
|
|
71
|
+
"app/public/fonts/",
|
|
72
|
+
"app/public/brand/mark.svg",
|
|
73
|
+
"app/public/brand/favicon.svg",
|
|
74
|
+
"app/public/brand/identity.json",
|
|
75
|
+
"app/public/brand/identity.css",
|
|
76
|
+
"LICENSE"
|
|
77
|
+
],
|
|
78
|
+
"publishConfig": {
|
|
79
|
+
"access": "public",
|
|
80
|
+
"registry": "https://registry.npmjs.org/"
|
|
81
|
+
}
|
|
82
|
+
}
|