openvibers/openvibe.design Commits
Add AI typefaces and AI vectors (#195)
Changes
nnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++-- The typefaces a person makes with the model (plan R1 item 8, Seven Domains §11).++-- A new table; an older release ignores it.++--++-- A typeface is the person's: the glyph outlines the model drew, the metrics they were compiled++-- with, and the model that drew them. The binaries are not stored here: they are compiled from the++-- outlines on request, so a redrawn glyph replaces the file rather than adding to it.++CREATE TABLE IF NOT EXISTS ai_typefaces (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ prompt text NOT NULL,++ base_family text COLLATE "C",++ style_notes text,++ glyphs jsonb NOT NULL DEFAULT '[]'::jsonb,++ metrics jsonb NOT NULL DEFAULT '{}'::jsonb,++ provider text COLLATE "C",++ model text COLLATE "C",++ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',++ state text COLLATE "C" NOT NULL DEFAULT 'drawing',++ note text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT ai_typefaces_glyphs_array CHECK (jsonb_typeof(glyphs) = 'array'),++ CONSTRAINT ai_typefaces_state CHECK (state IN ('drawing', 'ready', 'refused'))++);++CREATE INDEX ai_typefaces_owner_created ON ai_typefaces (owner, created_at DESC);nnew file mode 100644@@@ -0,0 +1,35 @@++-- phase: expand++-- The vectors a person makes with the model (plan R1 item 8, Seven Domains §11).++-- New tables; an older release ignores all of it.++--++-- A vector is one drawing: the SVG the model drew, cleaned before it was stored, and the note the++-- person gave when they asked for it.++CREATE TABLE IF NOT EXISTS ai_vectors (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ prompt text NOT NULL,++ kind text COLLATE "C" NOT NULL DEFAULT 'icon',++ svg text NOT NULL,++ provider text COLLATE "C",++ model text COLLATE "C",++ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',++ state text COLLATE "C" NOT NULL DEFAULT 'drawn',++ note text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT ai_vectors_kind CHECK (kind IN ('icon', 'illustration', 'pattern')),++ CONSTRAINT ai_vectors_state CHECK (state IN ('drawn', 'refused'))++);++CREATE INDEX ai_vectors_owner_created ON ai_vectors (owner, created_at DESC);++++-- How many model runs a person may make a day, and how many they have made. The cap is in the++-- config; this is the count it is measured against.++CREATE TABLE IF NOT EXISTS ai_make_runs (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ day date NOT NULL DEFAULT current_date,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT ai_make_runs_kind CHECK (kind IN ('typeface', 'vector'))++);++CREATE INDEX ai_make_runs_owner_day ON ai_make_runs (owner, day);@@@ -15,12 +15,14 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "opentype.js": "^2.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0","pg": "^8.23.1",-- "subset-font": "^2.9.0"++ "subset-font": "^2.9.0",++ "wawoff2": "^2.0.1"},"devDependencies": {"@electric-sql/pglite": "^0.5.13"@@@ -1321,6 +1323,15 @@"url": "https://opencollective.com/express"}},++ "node_modules/opentype.js": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",++ "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",++ "license": "MIT",++ "bin": {++ "ot": "bin/ot"++ }++ },"node_modules/openvibe-contracts": {"version": "0.338.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",@@@ -21,12 +21,14 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "opentype.js": "^2.0.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0","pg": "^8.23.1",-- "subset-font": "^2.9.0"++ "subset-font": "^2.9.0",++ "wawoff2": "^2.0.1"},"engines": {"node": ">=22.0.0"nnew file mode 100644@@@ -0,0 +1,61 @@++/* The typefaces and vectors a person makes with the model: plain maker pages, ready for the final Design art direction. */++.tf{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}++.tf-hero{max-width:820px;margin:0 0 32px}.tf-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}++.tf-hero h1{margin:0;font-size:clamp(32px,5vw,60px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.tf-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,19px);line-height:1.5}++++.tf-form{display:grid;gap:10px;max-width:720px;margin:0 0 40px;padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}++.tf-form label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}++.tf-form label small{font-weight:500;opacity:.8}++.tf-form textarea,.tf-form input,.tf-form select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}++.tf-form textarea{min-height:110px;resize:vertical;line-height:1.5}++.tf-form button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}++.tf-form button:hover{opacity:.92}++.tf-cap{margin:2px 0 0;color:var(--text-secondary);font-size:13px}++.tf-signin{padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);color:var(--text-secondary)}++.tf-signin a{color:var(--accent);font-weight:700}++++.tf-owned{margin-top:8px}.tf-owned h2{font-size:20px}.tf-owned ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}++.tf-owned li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;padding:12px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary)}++.tf-owned a{font-weight:700;color:inherit;text-decoration:none;overflow-wrap:anywhere}.tf-owned a:hover{text-decoration:underline}++.tf-owned span{color:var(--text-secondary);font-size:13px}++.tf-n{margin-left:6px;font-size:.65em;font-weight:600;color:var(--text-secondary)}++.tf-owned .tf-shelf,.tf-owned .tf-gallery{grid-template-columns:repeat(auto-fill,minmax(min(160px,100%),1fr));gap:10px}++.tf-shelf li,.tf-gallery li{display:block;padding:0}++.tf-shelf a,.tf-gallery a{display:grid;gap:4px;height:100%;padding:14px;border-radius:12px;box-sizing:border-box}++.tf-shelf a:hover,.tf-gallery a:hover{text-decoration:none;background:color-mix(in srgb,var(--accent) 8%,transparent)}++.tf-sample{display:block;height:64px;width:auto;max-width:100%;margin-bottom:6px;color:var(--text-primary)}++.tf-sample.is-empty{display:flex;align-items:center;height:64px;font-size:14px;color:var(--text-secondary)}++.tf-shelf b{font-size:15px;overflow-wrap:anywhere}++.tf-gallery img{width:100%;height:auto;aspect-ratio:1;object-fit:contain;padding:10px;box-sizing:border-box;border-radius:10px;background:var(--bg-primary)}++.tf-gallery span{font-size:13.5px;color:var(--text-primary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.tf-gallery small{font-size:12px;color:var(--text-secondary);text-transform:capitalize}++++.tf-back{display:inline-block;margin-bottom:24px;color:var(--accent)}++.tf-name{font-size:clamp(34px,6vw,76px)!important}++.tf-sample{margin:0 0 14px;font-size:clamp(28px,5vw,64px);line-height:1.2;overflow-wrap:anywhere}++.tf-words{font-size:clamp(18px,2.6vw,30px);color:var(--text-secondary)}++.tf-letters{display:flex;flex-wrap:wrap;gap:6px;margin:26px 0 8px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}++.tf-letter{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:52px;padding:4px;border:1px solid var(--border);border-radius:8px;font-family:"OpenVibe Made",sans-serif;font-size:26px;line-height:1}++.tf-missing{opacity:.28;font-family:inherit}++.tf-specimen section{margin-top:40px}.tf-specimen h2{font-size:clamp(20px,3vw,28px)}++.tf-download p{color:var(--text-secondary);line-height:1.6}.tf-download a{color:var(--accent);font-weight:700}++.tf-note{max-width:70ch}++.tf-redraw form{display:grid;gap:10px;max-width:640px;margin-top:16px}++.tf-redraw label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}++.tf-redraw input,.tf-redraw select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}++.tf-redraw button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}++.tf-lastnote{margin-top:12px;color:var(--text-secondary);font-size:13px}++++.tf-figure{margin:0 0 8px;padding:28px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);display:grid;place-items:center}++.tf-figure svg{display:block;width:min(100%,420px);height:auto;color:var(--text-primary);fill:currentColor}++.tf-vector .tf-hero h1{font-size:clamp(24px,3.6vw,40px)}++++@media(max-width:600px){++ .tf-form{padding:14px}++ .tf-letters{gap:4px;padding:12px}++ .tf-letter{min-width:38px;min-height:44px;font-size:22px}++ .tf-figure{padding:16px}++}++/* The selects keep the browser's own look (data-ov-native): the house's drawn select paints an empty one++ with the accent, and a menu of open fonts should read as a field, not as a button. */++.tf .tf-form select,.tf .tf-redraw select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}@@@ -101,12 +101,16 @@ async function createApp(opts = {}) {const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });await fonts.seedMissing();++ // The typefaces and vectors a person makes with the model (plan R1 item 8): the store, the drawing++ // service and the routes that serve them.++ const aiStore = opts.aiStore || require('./typefaces/store').createStore({ db: s.db, log, now: () => new Date(now()) });++ const maker = opts.maker || require('./typefaces/service').createService({ db: s.db, ai, store: aiStore, config, log, s, sharing });const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker,random: opts.random || Math.random,};@@@ -159,7 +163,6 @@ async function createApp(opts = {}) {referrerPolicy: { policy: 'strict-origin-when-cross-origin' },}));app.use(cookieParser());-- app.use(createMcpRoutes(ctx));const agentLayer = express();const agentDescriptor = {id: 'design', name: 'OpenVibe.Design', purpose: 'Public theme tokens and component specifications.',@@@ -185,6 +188,9 @@ async function createApp(opts = {}) {// ── Who is signed in, then sign-in itself ───────────────app.use(sso.middleware());app.use('/auth', sso.routes());++ // The MCP endpoint, after the session middleware: the tools that make a typeface or a vector are charged to++ // the person who calls them, so they need to know who that is. The read-only tools answer as before.++ app.use(createMcpRoutes(ctx));// ── Account export/deletion from OpenVibe.Events (loopback only) ──// Raw body first: the SDK verifies the delivery signature over the exact bytes.@@@ -279,6 +285,14 @@ async function createApp(opts = {}) {app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });app.use(createIconRoutes(ctx));++ // The typefaces and vectors a person makes with the model (plan R1 item 8): the makers, the++ // specimens and the API for agents. These come before the font library's /fonts/:family, so++ // /fonts/make is the maker and not a family called "make".++ {++ const { createTypefaceRoutes, createVectorRoutes } = require('./http/typefaces');++ app.use(createTypefaceRoutes(ctx));++ app.use(createVectorRoutes(ctx));++ }app.use(createFontRoutes(ctx));app.use('/api', createApi(ctx));// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from@@@ -99,6 +99,13 @@ function load(env = process.env) {chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),},++ // The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs++ // one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),++ // so the cap counts runs, not pages.++ aiMake: {++ perDay: Math.max(0, int(env.DESIGN_AI_MAKE_PER_DAY, 20)),++ },++// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.arena: {perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),@@@ -44,6 +44,10 @@ function publicPages(themes = []) {{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },++ // The makers (plan R1 item 8): the pages an agent may drive. The drawings and the typefaces they++ // produce are the person's and stay out of the crawl.++ { path: '/fonts/make', changefreq: 'monthly', priority: 0.7, title: 'Make a typeface', text: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.' },++ { path: '/vectors/make', changefreq: 'monthly', priority: 0.7, title: 'Make a vector', text: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.' },];const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({path: `/t/${encodeURIComponent(String(t.slug))}`,@@@ -99,6 +103,12 @@ function createDiscoveryRoutes(ctx) {{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },] },++ // The makers: an agent can drive these two through the API and the MCP tools. Each run is++ // charged to the signed-in person who asks for it, and their drawings stay theirs.++ { title: 'Make things', links: [++ { title: 'Make a typeface', url: abs('/fonts/make'), note: 'describe a typeface and the model draws the letters; POST /api/v1/typefaces {prompt, baseFamily?, name?} returns the outlines and the font files (/api/v1/typefaces/<id>/font.woff2 and .otf); redraw one letter with POST /api/v1/typefaces/<id>/glyph {char, note}; MCP tool openvibe_design_make_typeface' },++ { title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },++ ] },{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },// The data an agent came for is a main section, not something to skip.{ title: 'Machine-readable', links: [@@@ -60,7 +60,19 @@ const TOOLS = [{ name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {family: { type: 'string', minLength: 1, maxLength: 100 },}) },--].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));++ // The typefaces and vectors a person makes with the model (plan R1 item 8). These two write, so they are++ // not marked read only: they need a signed-in person, and the run is charged to that person's allowance.++ { name: 'openvibe_design_make_typeface', description: 'Draw a typeface from a description and return its glyph outlines, metrics and download links. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {++ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'The typeface you want, in words ("rounded geometric sans for a kids app").' },++ baseFamily: { type: 'string', maxLength: 80, description: 'An open font family to start from, as in the font library.' },++ name: { type: 'string', maxLength: 60, description: 'The name of the face.' },++ }) },++ { name: 'openvibe_design_make_vector', description: 'Draw one clean SVG from a prompt and return it, sanitised and ready to use. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {++ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'What to draw ("a paper plane folded from a single sheet").' },++ kind: { type: 'string', enum: ['icon', 'illustration', 'pattern'], default: 'icon' },++ note: { type: 'string', maxLength: 400, description: 'A note on the drawing, when you are changing one.' },++ }) },++].map(tool => ({ ...tool, annotations: { readOnlyHint: tool.name.indexOf('_make_') < 0, openWorldHint: false, destructiveHint: false } }));const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });@@@ -73,8 +85,11 @@ function toolResult(value) {};}--async function callTool(name, args, { catalog, fonts }) {++async function callTool(name, args, ctx, req) {if (!object(args)) return toolError('Arguments must be an object.');++ const { catalog, fonts } = ctx;++ // The tools that make something are charged to the person who calls them, so they need a signed-in caller.++ const person = req && req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;switch (name) {case 'openvibe_design_list_themes': {if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) return toolError('query must be a string of at most 200 characters.');@@@ -148,6 +163,49 @@ async function callTool(name, args, { catalog, fonts }) {const font = await fonts.get(args.family);return font ? toolResult(font) : toolError('No font family by that slug.');}++ case 'openvibe_design_make_typeface': {++ if (!person) return toolError('Sign in on openvibe.design to make a typeface. The run is charged to your own allowance.');++ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');++ if (args.baseFamily !== undefined && (typeof args.baseFamily !== 'string' || args.baseFamily.length > 80)) return toolError('baseFamily is a string of at most 80 characters.');++ if (args.name !== undefined && (typeof args.name !== 'string' || args.name.length > 60)) return toolError('name is a string of at most 60 characters.');++ try {++ const drawn = await ctx.maker.drawTypeface({ owner: person, prompt: args.prompt.trim(),++ baseFamily: args.baseFamily || null, name: args.name || null });++ const id = ctx.s.newId('typ');++ const saved = await ctx.aiStore.saveTypeface({++ id, owner: person, name: drawn.name, prompt: args.prompt.trim(),++ baseFamily: args.baseFamily || null, styleNotes: args.prompt.trim(),++ glyphs: drawn.glyphs, metrics: drawn.metrics, provider: drawn.provider, model: drawn.model,++ license: drawn.license, state: 'ready',++ });++ return toolResult({ id: saved.id, name: saved.name, glyphs: saved.glyphs.length, metrics: saved.metrics,++ model: saved.model, provider: saved.provider, license: saved.license,++ page: `${BASE}/fonts/made/${encodeURIComponent(saved.id)}`,++ download: { woff2: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.woff2`, otf: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.otf` } });++ } catch (err) {++ return toolError((err && err.detail) || 'The typeface could not be drawn.');++ }++ }++ case 'openvibe_design_make_vector': {++ if (!person) return toolError('Sign in on openvibe.design to make a vector. The run is charged to your own allowance.');++ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');++ if (args.kind !== undefined && !['icon', 'illustration', 'pattern'].includes(args.kind)) return toolError('kind is icon, illustration or pattern.');++ if (args.note !== undefined && (typeof args.note !== 'string' || args.note.length > 400)) return toolError('note is a string of at most 400 characters.');++ try {++ const drawn = await ctx.maker.drawVector({ owner: person, prompt: args.prompt.trim(),++ kind: args.kind || 'icon', note: args.note || null });++ const id = ctx.s.newId('vec');++ const saved = await ctx.aiStore.saveVector({ id, owner: person, prompt: args.prompt.trim(),++ kind: drawn.kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model,++ license: drawn.license, state: 'drawn' });++ return toolResult({ id: saved.id, prompt: saved.prompt, kind: saved.kind, svg: saved.svg,++ model: saved.model, provider: saved.provider, license: saved.license,++ page: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}`,++ download: { svg: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.svg`, png: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.png` } });++ } catch (err) {++ return toolError((err && err.detail) || 'The drawing could not be made.');++ }++ }default:return null;}@@@ -181,7 +239,7 @@ function createMcpRoutes(ctx) {else if (msg.method === 'ping') replies.push(rpc(id, {}));else if (msg.method === 'tools/list') replies.push(rpc(id, { tools: TOOLS }));else if (msg.method === 'tools/call') {-- const result = await callTool(params.name, params.arguments || {}, ctx);++ const result = await callTool(params.name, params.arguments || {}, ctx, req);replies.push(result ? rpc(id, result) : error(id, -32602, 'Unknown tool'));} else replies.push(error(id, -32601, 'Method not found'));}nnew file mode 100644@@@ -0,0 +1,338 @@++'use strict';++/**++ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).++ *++ * GET /fonts/make the typeface maker: describe a face, start from an open one++ * POST /fonts/make draw it++ * GET /fonts/made/:id the specimen, the download and the redraw++ * POST /fonts/made/:id/glyph redraw one glyph with a note++ *++ * GET /vectors/make the vector maker: one drawing from a prompt++ * POST /vectors/make draw it++ * GET /vectors/made/:id the drawing, the downloads and the notes++ * POST /vectors/made/:id/edit regenerate it, or edit it with a note++ *++ * The API, for agents and for the pages themselves:++ * POST /api/v1/typefaces draw a typeface++ * GET /api/v1/typefaces/:id read it++ * POST /api/v1/typefaces/:id/glyph redraw one glyph++ * POST /api/v1/vectors draw a vector++ * GET /api/v1/vectors/:id read it++ * POST /api/v1/vectors/:id/edit regenerate it, or edit it with a note++ *++ * Signed in only: a run is charged to the person's own allowance, so there is always a person behind it.++ * The outputs are the person's: nobody else's, and the site does not list them.++ */++const express = require('express');++const { asyncRouter } = require('./router');++const views = require('../views/typefaces');++const { send } = require('../render/layout');++const { writeAllowed } = require('./same-origin');++++/** The plain words a person gets when the model is not there or says no. */++function plainError(err) {++ if (!err) return 'Something went wrong.';++ const status = Number(err.status) || 500;++ const detail = String(err.detail || err.message || '').trim();++ if (status === 503) return 'OpenVibe.AI is not configured for OpenVibe.Design right now. Try again later.';++ if (status === 429) return detail || 'You have made all the runs you can today. More tomorrow.';++ if (status === 401) return 'Sign in to make this on openvibe.design.';++ if (status === 403) return detail || 'This belongs to somebody else.';++ if (status === 404) return 'Not found.';++ return detail && status < 500 ? detail : 'Something went wrong.';++}++++function createTypefaceRoutes(ctx) {++ const { config, ai, aiStore, maker, fonts, s } = ctx;++ const store = aiStore;++ const r = asyncRouter();++ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);++ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });++++ /** A face of this person's, or a refusal in words. */++ async function own(req, res, id) {++ const owner = subjectOf(req);++ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }++ const row = await store.typeface(id, owner);++ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }++ return row;++ }++++ // ── The maker ──++ r.get('/fonts/make', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const owner = subjectOf(req);++ const bases = await fonts.list().catch(() => []);++ const mine = owner ? await store.typefacesOf(owner).catch(() => []) : [];++ return page(res, 200, views.make({ bases, mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,++ { cacheControl: 'private, no-store' });++ });++++ r.post('/fonts/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');++ const owner = subjectOf(req);++ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a typeface on openvibe.design.');++ try {++ const prompt = String(req.body.prompt || '').trim();++ const baseId = String(req.body.base || '').trim();++ const name = String(req.body.name || '').trim().slice(0, 60);++ if (!prompt) return res.status(422).type('text/plain').send('Describe the typeface you want.');++ const base = baseId ? await fonts.get(baseId).catch(() => null) : null;++ const id = s.newId('typ');++ const drawn = await maker.drawTypeface({++ owner, prompt,++ baseFamily: base ? base.family : null,++ baseNote: base ? 'Keep the letterforms of ' + base.family + ' (' + base.category + '), and change the style to the description above.' : null,++ name: name || null,++ });++ await store.saveTypeface({++ id, owner, name: drawn.name, prompt,++ baseFamily: base ? base.family : null,++ styleNotes: prompt,++ glyphs: drawn.glyphs,++ metrics: drawn.metrics,++ provider: drawn.provider, model: drawn.model,++ license: drawn.license, state: 'ready',++ });++ return res.redirect(303, '/fonts/made/' + encodeURIComponent(id));++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(plainError(err));++ }++ });++++ // ── The specimen ──++ r.get('/fonts/made/:id', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const row = await own(req, res, req.params.id);++ if (!row) return undefined;++ const compiled = await require('../typefaces/compile').compile({++ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',++ }).catch(() => null);++ return page(res, 200, views.specimen({ face: row, compiled, chars: maker.CORE }), req.viewer,++ { cacheControl: 'private, no-store' });++ });++++ r.post('/fonts/made/:id/glyph', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');++ const owner = subjectOf(req);++ if (!owner) return res.status(401).type('text/plain').send('Sign in to redraw this on openvibe.design.');++ const row = await store.typeface(req.params.id, owner);++ if (!row) return res.status(404).type('text/plain').send('Not found.');++ try {++ const char = String(req.body.char || '').slice(0, 4);++ const note = String(req.body.note || '').trim().slice(0, 400);++ if (!char || !note) return res.status(422).type('text/plain').send('Say which letter and what to change.');++ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });++ if (!redrawn) return res.status(502).type('text/plain').send('The model did not draw that letter. Try again.');++ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);++ await store.saveTypeface({++ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,++ baseFamily: row.baseFamily, styleNotes: row.styleNotes,++ glyphs, metrics: { ...row.metrics, glyphs: glyphs.length },++ provider: row.provider, model: redrawn.model || row.model,++ license: row.license, state: 'ready', note: note,++ });++ return res.redirect(303, '/fonts/made/' + encodeURIComponent(row.id));++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(plainError(err));++ }++ });++++ // ── The API ──++ r.post('/api/v1/typefaces', express.json({ limit: '32kb' }), async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to make a typeface on openvibe.design.' });++ try {++ const prompt = String((req.body && req.body.prompt) || '').trim();++ if (!prompt) return res.status(422).json({ error: 'Describe the typeface you want.' });++ const drawn = await maker.drawTypeface({ owner, prompt, baseFamily: (req.body && req.body.baseFamily) || null, name: (req.body && req.body.name) || null });++ const id = s.newId('typ');++ const saved = await store.saveTypeface({++ id, owner, name: drawn.name, prompt, baseFamily: (req.body && req.body.baseFamily) || null,++ styleNotes: prompt, glyphs: drawn.glyphs, metrics: drawn.metrics,++ provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'ready',++ });++ return res.status(201).json(saved);++ } catch (err) {++ return res.status(err.status || 500).json({ error: plainError(err) });++ }++ });++++ r.get('/api/v1/typefaces/:id', async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });++ const row = await store.typeface(req.params.id, owner);++ if (!row) return res.status(404).json({ error: 'Not found.' });++ return res.json(row);++ });++++ // The font files. They are compiled from the stored outlines on the way out, so a redrawn letter++ // is in the next download without anything being rebuilt by hand.++ for (const [ext, type] of [['woff2', 'font/woff2'], ['otf', 'font/otf']]) {++ r.get('/api/v1/typefaces/:id/font.' + ext, async (req, res) => {++ const row = await own(req, res, req.params.id);++ if (!row) return undefined;++ const compiled = await require('../typefaces/compile').compile({++ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',++ }).catch(() => null);++ if (!compiled || !compiled.ok) return res.status(502).type('text/plain').send('The font could not be compiled.');++ const file = ext === 'woff2' ? compiled.woff2 : compiled.otf;++ return res.set('Cache-Control', 'private, no-store')++ .set('Content-Disposition', 'attachment; filename="' + row.name.replace(/[^A-Za-z0-9 .'-]/g, '') + '.' + ext + '"')++ .type(type).send(file);++ });++ }++++ r.post('/api/v1/typefaces/:id/glyph', express.json({ limit: '8kb' }), async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to redraw this on openvibe.design.' });++ const row = await store.typeface(req.params.id, owner);++ if (!row) return res.status(404).json({ error: 'Not found.' });++ try {++ const char = String((req.body && req.body.char) || '').slice(0, 4);++ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);++ if (!char || !note) return res.status(422).json({ error: 'Say which letter and what to change.' });++ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });++ if (!redrawn) return res.status(502).json({ error: 'The model did not draw that letter. Try again.' });++ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);++ const saved = await store.saveTypeface({++ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,++ baseFamily: row.baseFamily, styleNotes: row.styleNotes, glyphs,++ metrics: { ...row.metrics, glyphs: glyphs.length },++ provider: row.provider, model: redrawn.model || row.model, license: row.license, state: 'ready', note,++ });++ return res.json(saved);++ } catch (err) {++ return res.status(err.status || 500).json({ error: plainError(err) });++ }++ });++++ return r;++}++++function createVectorRoutes(ctx) {++ const { config, ai, aiStore, maker, s, sharing } = ctx;++ const store = aiStore;++ const r = asyncRouter();++ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);++ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });++++ async function own(req, res, id) {++ const owner = subjectOf(req);++ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }++ const row = await store.vector(id, owner);++ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }++ return row;++ }++++ r.get('/vectors/make', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const owner = subjectOf(req);++ const mine = owner ? await store.vectorsOf(owner).catch(() => []) : [];++ return page(res, 200, views.vectorMake({ mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,++ { cacheControl: 'private, no-store' });++ });++++ r.post('/vectors/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');++ const owner = subjectOf(req);++ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a vector on openvibe.design.');++ try {++ const prompt = String(req.body.prompt || '').trim();++ const kind = ['icon', 'illustration', 'pattern'].includes(req.body.kind) ? req.body.kind : 'icon';++ if (!prompt) return res.status(422).type('text/plain').send('Describe the drawing you want.');++ const drawn = await maker.drawVector({ owner, prompt, kind });++ const id = s.newId('vec');++ await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });++ return res.redirect(303, '/vectors/made/' + encodeURIComponent(id));++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(plainError(err));++ }++ });++++ // The drawing itself, as a file. These come before the page, so /vectors/made/<id>.svg is the file++ // and not a drawing called "<id>.svg".++ r.get('/vectors/made/:id.svg', async (req, res) => {++ const row = await own(req, res, req.params.id);++ if (!row) return undefined;++ // A saved drawing opened on its own is a document: no script, no fetches, whatever the sanitiser missed.++ return res.set('Cache-Control', 'private, no-store').set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")++ .set('X-Content-Type-Options', 'nosniff').type('image/svg+xml').send(row.svg);++ });++ r.get('/vectors/made/:id.png', async (req, res) => {++ const row = await own(req, res, req.params.id);++ if (!row) return undefined;++ const png = await maker.png(row.svg, Number(req.query.size) || 512).catch(() => null);++ if (!png) return res.status(502).type('text/plain').send('The drawing could not be rendered as a PNG.');++ return res.set('Cache-Control', 'private, no-store').type('image/png').send(png);++ });++++ r.get('/vectors/made/:id', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const row = await own(req, res, req.params.id);++ if (!row) return undefined;++ return page(res, 200, views.vector({ vector: row }), req.viewer, { cacheControl: 'private, no-store' });++ });++++ r.post('/vectors/made/:id/edit', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');++ const owner = subjectOf(req);++ if (!owner) return res.status(401).type('text/plain').send('Sign in to change this on openvibe.design.');++ const row = await store.vector(req.params.id, owner);++ if (!row) return res.status(404).type('text/plain').send('Not found.');++ try {++ const note = String(req.body.note || '').trim().slice(0, 400);++ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });++ await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,++ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });++ return res.redirect(303, '/vectors/made/' + encodeURIComponent(row.id));++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(plainError(err));++ }++ });++++ // ── The API ──++ r.post('/api/v1/vectors', express.json({ limit: '32kb' }), async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to make a vector on openvibe.design.' });++ try {++ const prompt = String((req.body && req.body.prompt) || '').trim();++ const kind = ['icon', 'illustration', 'pattern'].includes(req.body && req.body.kind) ? req.body.kind : 'icon';++ if (!prompt) return res.status(422).json({ error: 'Describe the drawing you want.' });++ const drawn = await maker.drawVector({ owner, prompt, kind });++ const id = s.newId('vec');++ const saved = await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });++ return res.status(201).json(saved);++ } catch (err) {++ return res.status(err.status || 500).json({ error: plainError(err) });++ }++ });++++ r.get('/api/v1/vectors/:id', async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });++ const row = await store.vector(req.params.id, owner);++ if (!row) return res.status(404).json({ error: 'Not found.' });++ return res.json(row);++ });++++ r.post('/api/v1/vectors/:id/edit', express.json({ limit: '8kb' }), async (req, res) => {++ const owner = subjectOf(req);++ if (!owner) return res.status(401).json({ error: 'Sign in to change this on openvibe.design.' });++ const row = await store.vector(req.params.id, owner);++ if (!row) return res.status(404).json({ error: 'Not found.' });++ try {++ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);++ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });++ const saved = await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,++ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });++ return res.json(saved);++ } catch (err) {++ return res.status(err.status || 500).json({ error: plainError(err) });++ }++ });++++ return r;++}++++module.exports = { createTypefaceRoutes, createVectorRoutes, plainError };@@@ -20,6 +20,13 @@ const TABLES = [// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).{ table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },++ // The typefaces a person made with the model (/fonts/make): the outlines the model drew and the notes they++ // gave. The font files are compiled from the outlines on request, so deleting the rows deletes the fonts.++ { table: 'ai_typefaces', subject: 'owner', file: 'ai_typefaces.json', order: 'created_at', columns: ['id', 'name', 'prompt', 'base_family', 'style_notes', 'glyphs', 'metrics', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },++ // The drawings a person made with the model (/vectors/make). A drawing is theirs; nobody else's is exported.++ { table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },++ // How many model runs a person made and when. It is their own usage; it goes with the account.++ { table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },];async function extraExport(db, subject) {nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * A typeface, compiled from the glyphs the model drew (plan R1 item 8: AI typefaces).++ *++ * The glyphs arrive as outlines in a 0..1000 em box with the baseline at y = 0 and y growing up: font++ * coordinates already. The compiler normalises the metrics (one ascender, one descender, one set of side++ * bearings, one advance width per glyph), builds the font with opentype.js and writes it out.++ *++ * compile({ familyName, styleName, glyphs, license }) -> { ok: true, font, woff2, otf } | { ok: false, why }++ *++ * The glyph list is a list of { char, d, advance }. The .notdef glyph is always there, so a font that is++ * missing a letter shows a box rather than nothing.++ *++ * The two files are the same font: the WOFF2 for the web (compressed with Google's woff2 encoder, the same one++ * browsers use) and the OTF for anything that wants the outlines. The licence the person chose goes into the++ * font's name table, and the model that drew it goes in too, so a font says where it came from.++ */++const opentype = require('opentype.js');++const wawoff2 = require('wawoff2');++const glyph = require('./glyph');++const path = require('./path');++++/** The characters a face carries: Latin letters, digits and the punctuation a text needs. */++const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?'\"()-/&@#%+=*";++++/** A name a font file may carry: no path separators, nothing a filesystem would mind. */++const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 .'-]{0,60}$/;++++function fail(why) { return { ok: false, why }; }++++/**++ * Normalise the metrics of a set of glyphs. One ascender and one descender for the whole face; the advance width++ * of each glyph is its ink plus the same side bearing on each side, so the letters sit on one rhythm.++ */++function metrics(glyphs) {++ const side = 60; // the side bearing, in font units++ let top = 0, bottom = 0;++ for (const g of glyphs) {++ top = Math.max(top, g.bounds.y2);++ bottom = Math.min(bottom, g.bounds.y1);++ }++ // The ascender and the descender are the face's own, rounded to a tidy step.++ const ascender = Math.min(glyph.ASCENDER, Math.max(Math.round(top / 50) * 50, 400));++ const descender = Math.max(glyph.DESCENDER, Math.min(Math.round(bottom / 50) * 50, -100));++ const out = [];++ for (const g of glyphs) {++ const ink = g.bounds.x2 - g.bounds.x1;++ const advance = Math.max(Math.round(ink) + side * 2, 120);++ out.push({++ char: g.char,++ d: g.d,++ advance,++ // The ink is centred in the advance width, so every letter has the same breathing room.++ left: Math.round((advance - ink) / 2) - g.bounds.x1,++ });++ }++ return { ascender, descender, glyphs: out };++}++++/**++ * Compile the face. The outlines are checked once more here (the server never trusts a stored glyph), then the++ * font is built and written as a WOFF2 and an OTF.++ */++async function compile({ familyName, styleName = 'Regular', glyphs, license = 'OFL-1.1', model = null, designer = null } = {}) {++ const name = String(familyName || '').trim();++ if (!FAMILY.test(name)) return fail('a font needs a name of letters, digits, spaces or dashes');++ if (!Array.isArray(glyphs) || !glyphs.length) return fail('the face has no glyphs to compile');++++ // Every glyph is read again: a stored outline is data, and data is read before it is used.++ const checked = [];++ const seen = new Set();++ for (const g of glyphs) {++ const char = String(g.char == null ? '' : g.char);++ if (!char || seen.has(char)) continue;++ const r = glyph.clean(g.d);++ if (!r.ok) continue;++ seen.add(char);++ checked.push({ char, ...r.glyph });++ }++ if (!checked.length) return fail('none of the glyphs could be read');++++ const m = metrics(checked);++ const opentypeGlyphs = [];++ // .notdef: a box, so a missing letter shows an empty one.++ const box = new opentype.Path();++ box.moveTo(80, 0); box.lineTo(420, 0); box.lineTo(420, 700); box.lineTo(80, 700); box.close();++ opentypeGlyphs.push(new opentype.Glyph({ name: '.notdef', unicode: 0, advanceWidth: 500, path: box }));++ // The space, always.++ opentypeGlyphs.push(new opentype.Glyph({ name: 'space', unicode: 32, advanceWidth: 260, path: new opentype.Path() }));++++ for (const g of m.glyphs) {++ const segments = path.parse(g.d);++ if (!segments.ok) continue;++ const p = path.pathOf(segments.segments);++ // The outline is moved so the ink sits where the advance width says it does.++ const moved = new opentype.Path();++ for (const c of p.commands) {++ const cmd = { ...c };++ if (cmd.x !== undefined) cmd.x += g.left;++ if (cmd.x1 !== undefined) cmd.x1 += g.left;++ if (cmd.x2 !== undefined) cmd.x2 += g.left;++ moved.commands.push(cmd);++ }++ const cp = g.char.codePointAt(0);++ opentypeGlyphs.push(new opentype.Glyph({++ name: 'uni' + cp.toString(16).toUpperCase().padStart(4, '0'),++ unicode: cp,++ advanceWidth: g.advance,++ path: moved,++ }));++ }++++ // The name table says who made it and under what licence: a font that travels says where it came from.++ // opentype.js writes one table per platform, so these go in as options rather than as edits afterwards.++ const maker = String(designer || 'Made with OpenVibe.Design').slice(0, 60);++ const font = new opentype.Font({++ familyName: name,++ styleName,++ unitsPerEm: glyph.UPEM,++ ascender: m.ascender,++ descender: m.descender,++ glyphs: opentypeGlyphs,++ designer: maker,++ license: String(license || 'OFL-1.1').slice(0, 60),++ copyright: maker,++ description: model ? 'Drawn by ' + String(model).slice(0, 60) + ' on OpenVibe.Design' : 'Made on OpenVibe.Design',++ });++ font.outlinesFormat = 'cff';++++ const otf = Buffer.from(font.toArrayBuffer());++ const woff2 = Buffer.from(await wawoff2.compress(otf));++ return {++ ok: true,++ font,++ otf,++ woff2,++ metrics: { ascender: m.ascender, descender: m.descender, unitsPerEm: glyph.UPEM, glyphs: checked.length },++ glyphs: m.glyphs,++ };++}++++module.exports = { compile, metrics, CHARSET, FAMILY };nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * A glyph outline, checked before it may become part of a font (plan R1 item 8: AI typefaces).++ *++ * The model writes outlines as SVG path data. Whatever comes back is hostile input until it has been read: the++ * checks here are the ones a font needs, and nothing else is allowed through.++ *++ * clean(data) → { ok: true, glyph } | { ok: false, why }++ *++ * A glyph must be:++ * - ONE path. A letter may have several contours (the hole in an "o"), but they all live in one path element.++ * - CLOSED. Every contour ends with Z, so the outline has no open end to leak fill or show a seam.++ * - INSIDE the em box. The outline may not leave the 0..upem box the font works in, with a small margin for++ * the overshoot a curve needs.++ * - PLAIN. Numbers and path commands only: no script, no style, no url(), no external reference of any kind.++ *++ * The result is the same outline, normalised: absolute commands, two decimals, and the metrics a font needs++ * (the advance width and the bounds).++ */++const path = require('./path');++++/** The em box every outline lives in. The font's own units per em. */++const UPEM = 1000;++/** The baseline sits at 0; the ascenders reach 800 and the descenders fall to -200. */++const ASCENDER = 800;++const DESCENDER = -200;++/** A curve may overshoot the em box by this much, or it would look cut off. */++const OVERSHOOT = 20;++++/** What a model may send for one glyph: path data, or a whole <path> element. */++const MAX_BYTES = 8000;++++/** Path data is commands and numbers only. Nothing else may appear in it. */++const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s+\-eE]*$/;++++/**++ * The markup a model may wrap the path in, and what may be inside it. A <path> element with a d attribute, and++ * nothing else: no script, no style, no image, no use, no event handler, no external reference.++ */++const ELEMENT = /<\s*([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)>/;++const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;++const DANGEROUS = /^(?:on|xmlns:xlink|xlink|href|src|formaction|style|srcset)$/i;++++/** Read one path element out of the model's answer. */++function extract(data) {++ const raw = String(data == null ? '' : data).trim();++ if (!raw) return { ok: false, why: 'the glyph is empty' };++ if (raw.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };++ // A code fence or a sentence around the path is stripped, so an answer that says "here it is" still works.++ const fenced = /\x60\x60\x60[a-z]*\s*([\s\S]*?)\x60\x60\x60/i.exec(raw);++ const body = (fenced ? fenced[1] : raw).trim();++ if (!body) return { ok: false, why: 'the glyph is empty' };++++ // Refuse anything that is not a path element before looking for one: a script or a style is never acceptable.++ if (/<\s*(script|style|foreignObject|iframe|object|embed|image|use|a)\b/i.test(body))++ return { ok: false, why: 'a glyph is a path, not a script or a reference' };++ if (/javascript:|data:text\/html|<!\[CDATA\[|&#[x]?[0-9a-f]/i.test(body))++ return { ok: false, why: 'the glyph carries something a font must not carry' };++++ // Bare path data (no element) is fine: that is what most models answer with.++ if (!body.includes('<')) return { ok: true, d: body };++++ const match = ELEMENT.exec(body);++ if (!match) return { ok: false, why: 'the glyph is not a path element' };++ const name = match[1].toLowerCase();++ if (name !== 'path') return { ok: false, why: 'a glyph is one <path> element, not a <' + name + '>' };++ // Nothing may follow the path element.++ const rest = body.slice(match.index + match[0].length).replace(/<\s*\/\s*path\s*>/gi, '').trim();++ if (rest) return { ok: false, why: 'a glyph is one path and nothing else' };++++ let d = null;++ for (const a of match[2].matchAll(ATTR)) {++ const key = a[1].toLowerCase();++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);++ if (DANGEROUS.test(key)) return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };++ if (key === 'd') { d = value; continue; }++ // fill, fill-rule and transform are the only harmless extras; a font sets its own colour anyway.++ if (key === 'fill' || key === 'fill-rule' || key === 'transform') continue;++ return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };++ }++ if (!d) return { ok: false, why: 'the path has no d attribute' };++ return { ok: true, d };++}++++/**++ * Check one glyph. Returns the normalised outline and the metrics a font needs, or a plain reason in words.++ */++function clean(data) {++ const found = extract(data);++ if (!found.ok) return found;++ const d = String(found.d || '').trim();++ if (!d) return { ok: false, why: 'the path has no d attribute' };++ if (d.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };++ if (!PATH_DATA.test(d)) return { ok: false, why: 'the path data has something a path cannot hold' };++++ const parsed = path.parse(d);++ if (!parsed.ok) return { ok: false, why: parsed.why };++ const segments = parsed.segments;++++ // One path, several contours allowed (the hole in an "o"), but every contour closed.++ const contours = path.subpaths(segments);++ if (!contours.length) return { ok: false, why: 'the path draws nothing' };++ if (contours.length > 4) return { ok: false, why: 'a letter is not that many separate shapes' };++ if (!path.closed(segments)) return { ok: false, why: 'the outline is not closed' };++++ const box = path.bbox(segments);++ if (!box) return { ok: false, why: 'the path draws nothing' };++ if (box.x1 < -OVERSHOOT || box.y1 < DESCENDER - OVERSHOOT || box.x2 > UPEM + OVERSHOOT || box.y2 > ASCENDER + OVERSHOOT)++ return { ok: false, why: 'the outline leaves the em box' };++ // A letter has some size: a mark smaller than a fiftieth of the em reads as a speck, not a letter.++ if (box.x2 - box.x1 < 20 || box.y2 - box.y1 < 20) return { ok: false, why: 'the outline is too small to read' };++++ // The outline is placed as the model drew it: the baseline is y = 0, y grows up.++ return {++ ok: true,++ glyph: {++ d: path.toData(segments),++ advance: Math.round(box.x2 - box.x1) + 120, // the ink plus a side bearing on each side++ bounds: { x1: Math.round(box.x1), y1: Math.round(box.y1), x2: Math.round(box.x2), y2: Math.round(box.y2) },++ },++ };++}++++module.exports = { clean, extract, UPEM, ASCENDER, DESCENDER, OVERSHOOT, MAX_BYTES };nnew file mode 100644@@@ -0,0 +1,338 @@++'use strict';++/**++ * SVG path data, read for a typeface (plan R1 item 8: AI typefaces).++ *++ * One parser for the whole service: the model writes outlines as path data, and the server has to know three things++ * about each one before it may become a glyph — is it a single closed contour inside the em box, how big is it, and++ * what commands does it use. This module answers all three and hands back a clean, absolute command list.++ *++ * Absolute commands only (M L C Q Z), because a font's outlines are absolute: the model may write relative commands++ * or arcs, and both are converted here. Arcs become cubic curves (the same curve, four segments at most), because a++ * font's outlines are cubics and the compiler does not read arcs.++ *++ * parse(d) → { ok: true, segments, bbox, subpaths } | { ok: false, why }++ * bbox(segments) → { x1, y1, x2, y2 } (the true bounds, curves included)++ * closed(segments)→ true when every contour ends with Z++ * pathOf(segments)→ an opentype.js Path++ * toData(segments)→ normalised path data (absolute, two decimals)++ */++++const NUM = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/;++const COMMANDS = 'MmLlHhVvCcSsQqTtAaZz';++const EPS = 1e-6;++++/** Round to two decimals and drop a trailing zero: the same outline, written once. */++function round(n) {++ const v = Math.round(Number(n) * 100) / 100;++ return Object.is(v, -0) ? 0 : v;++}++++function numText(n) {++ return String(round(n));++}++++/** The scanner: numbers and command letters, with the separators between them. */++function scan(d) {++ const src = String(d || '');++ const out = [];++ let i = 0;++ while (i < src.length) {++ const c = src[i];++ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === ',') { i++; continue; }++ if (COMMANDS.includes(c)) { out.push({ cmd: c }); i++; continue; }++ const m = NUM.exec(src.slice(i));++ if (!m || !m[0]) return { ok: false, why: 'unexpected character "' + c + '" in the path data' };++ out.push({ num: Number(m[0]) });++ i += m[0].length;++ }++ return { ok: true, tokens: out };++}++++/** The points of one cubic, and where it actually turns (the curve's true bounds). */++function cubicExtrema(p0, p1, p2, p3) {++ const ts = [0, 1];++ // B'(t) = 3(1-t)^2 (p1-p0) + 6(1-t)t (p2-p1) + 3t^2 (p3-p2) → a quadratic in t.++ const a = -p0 + 3 * p1 - 3 * p2 + p3;++ const b = 2 * (p0 - 2 * p1 + p2);++ const c = p1 - p0;++ const push = (t) => { if (t > EPS && t < 1 - EPS) ts.push(t); };++ if (Math.abs(a) < EPS) {++ if (Math.abs(b) > EPS) push(-c / b);++ } else {++ const disc = b * b - 4 * a * c;++ if (disc >= 0) {++ const sq = Math.sqrt(disc);++ push((-b + sq) / (2 * a));++ push((-b - sq) / (2 * a));++ }++ }++ const at = (t) => {++ const u = 1 - t;++ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;++ };++ return ts.map(at);++}++++function quadExtrema(p0, p1, p2) {++ const ts = [0, 1];++ const den = p0 - 2 * p1 + p2;++ if (Math.abs(den) > EPS) {++ const t = (p0 - p1) / den;++ if (t > EPS && t < 1 - EPS) ts.push(t);++ }++ const at = (t) => (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2;++ return ts.map(at);++}++++/** An elliptical arc as cubic segments (the endpoint-to-centre form, split into quarter-turns). */++function arcToCubics(x1, y1, rx, ry, phiDeg, largeArc, sweep, x2, y2) {++ if (rx === 0 || ry === 0) return [[x1, y1, x2, y2, x2, y2]];++ const phi = (phiDeg % 360) * Math.PI / 180;++ const cosP = Math.cos(phi), sinP = Math.sin(phi);++ const dx2 = (x1 - x2) / 2, dy2 = (y1 - y2) / 2;++ const x1p = cosP * dx2 + sinP * dy2;++ const y1p = -sinP * dx2 + cosP * dy2;++ let rxa = Math.abs(rx), rya = Math.abs(ry);++ const lambda = (x1p * x1p) / (rxa * rxa) + (y1p * y1p) / (rya * rya);++ if (lambda > 1) { const s = Math.sqrt(lambda); rxa *= s; rya *= s; }++ const sign = largeArc === sweep ? -1 : 1;++ const num = rxa * rxa * rya * rya - rxa * rxa * y1p * y1p - rya * rya * x1p * x1p;++ const den = rxa * rxa * y1p * y1p + rya * rya * x1p * x1p;++ const coef = sign * Math.sqrt(Math.max(0, num / (den || 1)));++ const cxp = coef * (rxa * y1p / rya);++ const cyp = coef * -(rya * x1p / rxa);++ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;++ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;++ const angle = (ux, uy, vx, vy) => {++ const dot = ux * vx + uy * vy;++ const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) || 1;++ let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));++ if (ux * vy - uy * vx < 0) a = -a;++ return a;++ };++ const ux = (x1p - cxp) / rxa, uy = (y1p - cyp) / rya;++ const vx = (-x1p - cxp) / rxa, vy = (-y1p - cyp) / rya;++ const theta1 = angle(1, 0, ux, uy);++ let dTheta = angle(ux, uy, vx, vy);++ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;++ else if (sweep && dTheta < 0) dTheta += 2 * Math.PI;++ const parts = Math.ceil(Math.abs(dTheta) / (Math.PI / 2)) || 1;++ const delta = dTheta / parts;++ const k = (4 / 3) * Math.tan(delta / 4);++ const out = [];++ let th = theta1;++ let px = x1, py = y1;++ for (let i = 0; i < parts; i++) {++ const th2 = th + delta;++ const cos1 = Math.cos(th), sin1 = Math.sin(th), cos2 = Math.cos(th2), sin2 = Math.sin(th2);++ const e1x = cx + rxa * cosP * cos1 - rya * sinP * sin1;++ const e1y = cy + rxa * sinP * cos1 + rya * cosP * sin1;++ const e2x = cx + rxa * cosP * cos2 - rya * sinP * sin2;++ const e2y = cy + rxa * sinP * cos2 + rya * cosP * sin2;++ const d1x = -rxa * cosP * sin1 - rya * sinP * cos1;++ const d1y = -rxa * sinP * sin1 + rya * cosP * cos1;++ const d2x = -rxa * cosP * sin2 - rya * sinP * cos2;++ const d2y = -rxa * sinP * sin2 + rya * cosP * cos2;++ out.push([px + k * d1x, py + k * d1y, e2x - k * d2x, e2y - k * d2y, e2x, e2y]);++ px = e2x; py = e2y; th = th2;++ }++ return out;++}++++/**++ * Read the path data. Every command is made absolute; S and T take the previous control point into account; an arc++ * becomes cubics. The result is a list of { type, args } with types M, L, C, Q and Z.++ */++function parse(d) {++ const scanned = scan(d);++ if (!scanned.ok) return scanned;++ const tokens = scanned.tokens;++ const segments = [];++ let x = 0, y = 0, startX = 0, startY = 0;++ let lastCtrl = null, lastQuad = null, cmd = null;++ let i = 0;++ const need = (n) => {++ const vals = [];++ for (let k = 0; k < n; k++) {++ const t = tokens[i++];++ if (!t || t.num === undefined) return null;++ vals.push(t.num);++ }++ return vals;++ };++ while (i < tokens.length) {++ const t = tokens[i];++ if (t.cmd) { cmd = t.cmd; i++; }++ else if (!cmd) return { ok: false, why: 'the path data starts with a number, not a command' };++ else if (cmd === 'M') cmd = 'L'; // extra pairs after a move are lines++ else if (cmd === 'm') cmd = 'l';++ const rel = cmd === cmd.toLowerCase();++ const abs = cmd.toUpperCase();++ if (abs === 'Z') {++ // The command letter was already consumed above; Z never repeats, so nothing is read here.++ segments.push({ type: 'Z', args: [] });++ x = startX; y = startY; lastCtrl = null; lastQuad = null;++ continue;++ }++ if (abs === 'M') {++ const v = need(2); if (!v) return { ok: false, why: 'a move needs two numbers' };++ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];++ startX = x; startY = y;++ segments.push({ type: 'M', args: [x, y] });++ lastCtrl = null; lastQuad = null;++ continue;++ }++ if (abs === 'L') {++ const v = need(2); if (!v) return { ok: false, why: 'a line needs two numbers' };++ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];++ segments.push({ type: 'L', args: [x, y] });++ lastCtrl = null; lastQuad = null;++ continue;++ }++ if (abs === 'H') {++ const v = need(1); if (!v) return { ok: false, why: 'a horizontal line needs one number' };++ x = rel ? x + v[0] : v[0];++ segments.push({ type: 'L', args: [x, y] });++ lastCtrl = null; lastQuad = null;++ continue;++ }++ if (abs === 'V') {++ const v = need(1); if (!v) return { ok: false, why: 'a vertical line needs one number' };++ y = rel ? y + v[0] : v[0];++ segments.push({ type: 'L', args: [x, y] });++ lastCtrl = null; lastQuad = null;++ continue;++ }++ if (abs === 'C') {++ const v = need(6); if (!v) return { ok: false, why: 'a curve needs six numbers' };++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3], x + v[4], y + v[5]] : v;++ segments.push({ type: 'C', args: p });++ lastCtrl = [p[2], p[3]];++ x = p[4]; y = p[5];++ lastQuad = null;++ continue;++ }++ if (abs === 'S') {++ const v = need(4); if (!v) return { ok: false, why: 'a smooth curve needs four numbers' };++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;++ const c1 = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y];++ segments.push({ type: 'C', args: [c1[0], c1[1], p[0], p[1], p[2], p[3]] });++ lastCtrl = [p[0], p[1]];++ x = p[2]; y = p[3];++ lastQuad = null;++ continue;++ }++ if (abs === 'Q') {++ const v = need(4); if (!v) return { ok: false, why: 'a quadratic curve needs four numbers' };++ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;++ segments.push({ type: 'Q', args: p });++ lastQuad = [p[0], p[1]];++ x = p[2]; y = p[3];++ lastCtrl = null;++ continue;++ }++ if (abs === 'T') {++ const v = need(2); if (!v) return { ok: false, why: 'a smooth quadratic needs two numbers' };++ const p = rel ? [x + v[0], y + v[1]] : v;++ const c1 = lastQuad ? [2 * x - lastQuad[0], 2 * y - lastQuad[1]] : [x, y];++ segments.push({ type: 'Q', args: [c1[0], c1[1], p[0], p[1]] });++ lastQuad = c1;++ x = p[0]; y = p[1];++ lastCtrl = null;++ continue;++ }++ if (abs === 'A') {++ const v = need(7); if (!v) return { ok: false, why: 'an arc needs seven numbers' };++ const [rx, ry, phi, laf, sf, ex, ey] = v;++ const tx = rel ? x + ex : ex, ty = rel ? y + ey : ey;++ const parts = arcToCubics(x, y, rx, ry, phi, laf !== 0, sf !== 0, tx, ty);++ for (const p of parts) segments.push({ type: 'C', args: p });++ x = tx; y = ty;++ lastCtrl = null; lastQuad = null;++ continue;++ }++ return { ok: false, why: 'the command "' + cmd + '" is not a path command' };++ }++ if (!segments.length) return { ok: false, why: 'the path data is empty' };++ return { ok: true, segments };++}++++/** The true bounds of the outline, curves included. */++function bbox(segments) {++ let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;++ const add = (x, y) => { if (x < x1) x1 = x; if (y < y1) y1 = y; if (x > x2) x2 = x; if (y > y2) y2 = y; };++ let cx = 0, cy = 0, sx = 0, sy = 0;++ for (const s of segments) {++ if (s.type === 'M') { cx = sx = s.args[0]; cy = sy = s.args[1]; add(cx, cy); continue; }++ if (s.type === 'L') { cx = s.args[0]; cy = s.args[1]; add(cx, cy); continue; }++ if (s.type === 'C') {++ const [a, b, c, d, e, f] = s.args;++ const xs = cubicExtrema(cx, a, c, e);++ const ys = cubicExtrema(cy, b, d, f);++ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);++ cx = e; cy = f;++ continue;++ }++ if (s.type === 'Q') {++ const [a, b, c, d] = s.args;++ const xs = quadExtrema(cx, a, c), ys = quadExtrema(cy, b, d);++ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);++ cx = c; cy = d;++ continue;++ }++ if (s.type === 'Z') { cx = sx; cy = sy; }++ }++ if (!Number.isFinite(x1)) return null;++ return { x1, y1, x2, y2 };++}++++/** The contours of the outline: each with its start and whether it was closed. */++function subpaths(segments) {++ const out = [];++ let cur = null;++ for (const s of segments) {++ if (s.type === 'M') { cur = { start: [s.args[0], s.args[1]], closed: false, points: [[s.args[0], s.args[1]]] }; out.push(cur); continue; }++ if (!cur) continue;++ if (s.type === 'L') cur.points.push([s.args[0], s.args[1]]);++ else if (s.type === 'C') cur.points.push([s.args[4], s.args[5]]);++ else if (s.type === 'Q') cur.points.push([s.args[2], s.args[3]]);++ else if (s.type === 'Z') cur.closed = true;++ }++ return out;++}++++/** True when every contour ends with a close: a glyph's outline must not run off the edge. */++function closed(segments) {++ const subs = subpaths(segments);++ if (!subs.length) return false;++ return subs.every((s) => s.closed);++}++++/** The same outline as path data: absolute, two decimals, arcs already cubics. */++function toData(segments) {++ const parts = [];++ for (const s of segments) {++ if (s.type === 'M') parts.push('M' + numText(s.args[0]) + ' ' + numText(s.args[1]));++ else if (s.type === 'L') parts.push('L' + numText(s.args[0]) + ' ' + numText(s.args[1]));++ else if (s.type === 'C') parts.push('C' + s.args.map(numText).join(' '));++ else if (s.type === 'Q') parts.push('Q' + s.args.map(numText).join(' '));++ else parts.push('Z');++ }++ return parts.join(' ');++}++++/** The outline as an opentype.js Path (font coordinates: y up, the baseline at 0). */++function pathOf(segments) {++ const opentype = require('opentype.js');++ const path = new opentype.Path();++ for (const s of segments) {++ if (s.type === 'M') path.moveTo(s.args[0], s.args[1]);++ else if (s.type === 'L') path.lineTo(s.args[0], s.args[1]);++ else if (s.type === 'C') path.bezierCurveTo(s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]);++ else if (s.type === 'Q') path.quadraticCurveTo(s.args[0], s.args[1], s.args[2], s.args[3]);++ else path.close();++ }++ return path;++}++++module.exports = { parse, bbox, subpaths, closed, toData, pathOf, round, numText, scan };nnew file mode 100644@@@ -0,0 +1,274 @@++'use strict';++/**++ * The model, drawing (plan R1 item 8, Seven Domains §11).++ *++ * Two things are made here: a typeface and a vector. Both are the person's own work, both are attributed++ * to them (on_behalf_of, so their own allowance pays for it), and both are capped per person per day.++ *++ * A typeface is drawn in batches. A face carries about ninety characters, and asking a model for ninety++ * outlines in one answer does not work: it runs long, it repeats itself and it gives up on the tail. So the++ * letters are asked for a batch at a time, with the same style notes carried through every batch, and each++ * outline is checked before it is kept.++ *++ * A vector is drawn in one answer, cleaned, and saved.++ */++const glyph = require('./glyph');++const { compile, CHARSET, FAMILY } = require('./compile');++const { sanitize } = require('../vectors/svg');++++/** How many letters are asked for at a time. */++const BATCH = 26;++++/** How many model runs a person may make a day, when the config does not say. */++const DEFAULT_PER_DAY = 20;++++/** The style notes, carried through every batch so the letters look like one family. */++const STYLE = [++ 'You are a type designer drawing the outlines of a typeface.',++ 'Answer with JSON only: {"glyphs": {"A": "<path d=\"...\"/>", "B": "<path d=\"...\"/>", ...}}.',++ 'Each glyph is ONE <path> element with only a d attribute. Nothing else: no group, no fill, no style, no script, no explanation.',++ 'The coordinates are font coordinates: x grows to the right, y grows UP, the baseline is y = 0.',++ 'The em box is 1000 units wide: x from 0 to 1000, y from -200 (descenders) to 800 (cap height and ascenders).',++ 'Cap height is 700, so an uppercase letter runs from y = 0 to y = 700. Lowercase x-height is 500.',++ 'Letters sit on the baseline and start at x = 60, with about 60 units of space on each side.',++ 'Every contour must be closed: the last command of each contour is Z.',++ 'Curves are C or Q commands. Never use arcs (A). Never use relative commands.',++ 'Letters with a hole (o, e, a, b, d, g, p, q, 0, 4, 6, 8, 9) have the hole as a second closed contour inside the first.',++].join('\n');++++/** What a vector answer looks like. */++const VECTOR_STYLE = [++ 'You are an illustrator drawing one clean SVG.',++ 'Answer with the SVG only: <svg viewBox="0 0 512 512"> ... </svg>.',++ 'The drawing is made of path, circle, ellipse, line, polyline, polygon and rect elements, and nothing else.',++ 'No script, no style element, no image, no use, no foreignObject, no external reference of any kind.',++ 'Use fill and stroke for colour; keep the drawing flat and legible at small sizes.',++].join('\n');++++function fail(status, code, detail) {++ return Object.assign(new Error(detail), { status, code, detail });++}++++/** The letters of the charset, in the batches they are drawn in. */++function batches(charset = CHARSET) {++ const out = [];++ for (let i = 0; i < charset.length; i += BATCH) out.push(charset.slice(i, i + BATCH));++ return out;++}++++/** The letters a typeface must have to be a typeface at all. */++const CORE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';++++function createService({ db, ai, store, config = {}, log = console, s, sharing = null } = {}) {++ const perDay = Math.max(0, Number((config.aiMake && config.aiMake.perDay) || DEFAULT_PER_DAY) || 0);++++ /** The cap: how many runs this person has left today. */++ async function left(owner) {++ if (!owner) return 0;++ const used = await store.runsToday(owner);++ return Math.max(0, perDay - used);++ }++++ /** A name for the face: what the person wrote, made into a font name. */++ function nameFrom(prompt, base) {++ const words = String(prompt || '').trim().split(/\s+/).slice(0, 4).join(' ');++ const candidate = (words || base || 'New Face').replace(/[^A-Za-z0-9 .'-]/g, ' ').trim().slice(0, 60);++ return candidate && FAMILY.test(candidate) ? candidate : (base ? String(base).slice(0, 60) : 'New Face');++ }++++ /** One model answer, read as JSON. */++ function readJson(text) {++ const body = String(text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');++ const start = body.indexOf('{');++ const end = body.lastIndexOf('}');++ if (start < 0 || end <= start) return null;++ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }++ }++++ /** The style notes the model is given, with the person's own words and the base they chose. */++ function notesFor({ prompt, baseFamily, baseNote }) {++ return [++ STYLE,++ '',++ 'The typeface: ' + String(prompt || 'a plain, legible sans').slice(0, 600),++ baseFamily ? 'It is based on ' + String(baseFamily).slice(0, 80) + ', and keeps that letterform.' : '',++ baseNote ? String(baseNote).slice(0, 400) : '',++ 'Draw these characters, and only these: ' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?' + '"' + '()-/&@#%+=*',++ ].filter(Boolean).join('\n');++ }++++ /**++ * Draw a typeface. The letters are asked for a batch at a time; each outline is checked as it arrives,++ * and a batch that comes back unusable is asked for once more, saying what was wrong with it.++ */++ async function drawTypeface({ owner, prompt, baseFamily = null, baseNote = null, name = null, license = 'OFL-1.1', onProgress = null } = {}) {++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');++ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a typeface on openvibe.design.');++ const remaining = await left(owner);++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');++++ const groups = batches();++ const drawn = new Map();++ let provider = null;++ let model = null;++ let runs = 0;++++ for (const group of groups) {++ const ask = async (extra = '') => {++ await store.countRun(owner, 'typeface');++ runs++;++ const answer = await ai.text({++ system: STYLE,++ prompt: notesFor({ prompt, baseFamily, baseNote }) + (extra ? '\n\n' + extra : ''),++ maxTokens: 8000,++ target: { service: 'design', type: 'typeface', id: String(baseFamily || 'new') },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ person: String(owner),++ });++ provider = provider || (answer.provider || null);++ model = model || (answer.model || null);++ return answer;++ };++ const keep = (answer) => {++ const json = readJson(answer.text);++ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};++ const kept = [];++ const whys = [];++ for (const char of group) {++ if (drawn.has(char)) continue;++ const raw = got[char] !== undefined ? got[char] : (got[char.toUpperCase()] !== undefined ? got[char.toUpperCase()] : null);++ if (raw === null || raw === undefined) { whys.push(char + ' was missing'); continue; }++ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);++ if (!clean.ok) { whys.push(char + ': ' + clean.why); continue; }++ drawn.set(char, clean.glyph);++ kept.push(char);++ }++ return { kept, whys };++ };++++ let answer = await ask();++ let result = keep(answer);++ // A batch that came back unusable is asked for once more, saying what was wrong with it.++ if (result.kept.length < group.length * 0.6) {++ const why = result.whys.slice(0, 8).join('; ');++ const again = await ask('The last answer had problems: ' + (why || 'too few usable letters') + '. Draw them again, carefully.');++ const second = keep(again);++ if (second.kept.length > result.kept.length) result = second;++ }++ if (onProgress) onProgress({ drawn: drawn.size, total: CORE.length });++ }++++ const glyphs = [...drawn.entries()].map(([char, g]) => ({ char, d: g.d, advance: g.advance, bounds: g.bounds }));++ const have = new Set(glyphs.map((g) => g.char));++ const missing = CORE.split('').filter((c) => !have.has(c));++ if (!glyphs.length) throw fail(502, 'ai.refused', 'The model did not draw any letters. Try again with a clearer description.');++++ const face = await compile({++ familyName: name || nameFrom(prompt, baseFamily),++ glyphs,++ license,++ model,++ designer: 'Made on OpenVibe.Design',++ });++ if (!face.ok) throw fail(502, 'ai.refused', face.why);++++ return {++ glyphs,++ metrics: { ...face.metrics, missing },++ provider,++ model,++ license,++ runs,++ name: name || nameFrom(prompt, baseFamily),++ };++ }++++ /**++ * Draw one vector. The answer is cleaned before it is kept: an SVG is read out of the allowlist and++ * written again from scratch, so nothing the model wrote survives unless it was allowed.++ */++ async function drawVector({ owner, prompt, kind = 'icon', note = null, onBehalf = true } = {}) {++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');++ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a vector on openvibe.design.');++ const remaining = await left(owner);++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');++++ await store.countRun(owner, 'vector');++ const answer = await ai.text({++ system: VECTOR_STYLE,++ prompt: 'Draw: ' + String(prompt || '').slice(0, 800) + (note ? '\n\nThe note on the last drawing: ' + String(note).slice(0, 400) : ''),++ maxTokens: 3000,++ target: { service: 'design', type: 'vector', id: String(kind || 'icon') },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ ...(onBehalf ? { person: String(owner) } : {}),++ });++++ // The SVG is taken out of the answer, then cleaned.++ const raw = String(answer.text || '');++ const start = raw.indexOf('<svg');++ const end = raw.lastIndexOf('</svg>');++ const markup = start >= 0 && end > start ? raw.slice(start, end + 6) : raw;++ const clean = sanitize(markup);++ if (!clean.ok) throw fail(502, 'ai.refused', 'The model drew something that is not a drawing: ' + clean.why);++++ return {++ svg: clean.svg,++ provider: answer.provider || null,++ model: answer.model || null,++ license: 'OFL-1.1',++ kind: String(kind || 'icon'),++ };++ }++++ /**++ * Redraw one glyph with a note: "make the a rounder", "the g needs a tail".++ * The face's own style notes go with it, so the letter comes back like the rest of them.++ */++ async function redrawGlyph({ owner, face, char, note } = {}) {++ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');++ if (!owner) throw fail(401, 'auth.required', 'Sign in to redraw this on openvibe.design.');++ const remaining = await left(owner);++ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');++ const wanted = String(char || '').slice(0, 4);++ if (!wanted) throw fail(422, 'ai.invalid_input', 'Say which letter to redraw.');++ const what = String(note || '').trim().slice(0, 400);++ if (!what) throw fail(422, 'ai.invalid_input', 'Say what to change about the letter.');++++ await store.countRun(owner, 'typeface');++ const answer = await ai.text({++ system: STYLE,++ prompt: [++ notesFor({ prompt: face.prompt, baseFamily: face.baseFamily, baseNote: face.styleNotes && face.styleNotes !== face.prompt ? face.styleNotes : null }),++ '',++ 'Now draw ONE letter: "' + wanted + '".',++ 'What to change: ' + what,++ 'Answer with JSON only: {"glyphs": {"' + wanted + '": "<path d=\"...\"/>"}}.',++ ].join('\n'),++ maxTokens: 1200,++ target: { service: 'design', type: 'typeface_glyph', id: String(face.id || 'new') },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ person: String(owner),++ });++ const json = readJson(answer.text);++ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};++ const raw = got[wanted] !== undefined ? got[wanted] : (got[wanted.toUpperCase()] !== undefined ? got[wanted.toUpperCase()] : null);++ if (raw === null || raw === undefined) return null;++ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);++ if (!clean.ok) return null;++ return { char: wanted, d: clean.glyph.d, advance: clean.glyph.advance, bounds: clean.glyph.bounds, model: answer.model || null };++ }++++ /**++ * A drawing as a PNG, for the download. resvg draws the SVG; it is already clean, so it is drawn as it is.++ */++ async function png(svg, size = 512) {++ const pixels = Math.max(16, Math.min(2048, Number(size) || 512));++ const { Resvg } = require('@resvg/resvg-js');++ const rendered = new Resvg(String(svg || ''), { fitTo: { mode: 'width', value: pixels }, background: 'rgba(0,0,0,0)' });++ return rendered.render().asPng();++ }++++ return { drawTypeface, drawVector, redrawGlyph, png, left, batches, notesFor, perDay, CORE };++}++++module.exports = { createService, batches, fail, DEFAULT_PER_DAY, STYLE, VECTOR_STYLE, CORE, BATCH };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Where the typefaces and vectors a person made live (plan R1 item 8, Seven Domains §11).++ *++ * Two tables, one rule: a row belongs to the person who made it and to nobody else. Nothing here is++ * published, shared or listed by the site; the only way out is the person's own page.++ *++ * The daily cap is measured here too: every model run is counted, and the count is what the cap in the++ * config is compared against.++ */++++/** A typeface row, as the pages and the API see it. */++function typefaceRow(row) {++ if (!row) return null;++ return {++ id: String(row.id),++ name: String(row.name),++ prompt: String(row.prompt || ''),++ baseFamily: row.base_family ? String(row.base_family) : null,++ styleNotes: row.style_notes ? String(row.style_notes) : null,++ glyphs: Array.isArray(row.glyphs) ? row.glyphs : [],++ metrics: row.metrics && typeof row.metrics === 'object' ? row.metrics : {},++ provider: row.provider ? String(row.provider) : null,++ model: row.model ? String(row.model) : null,++ license: String(row.license || 'OFL-1.1'),++ state: String(row.state || 'drawing'),++ note: row.note ? String(row.note) : null,++ createdAt: row.created_at,++ };++}++++/** A vector row, as the pages and the API see it. */++function vectorRow(row) {++ if (!row) return null;++ return {++ id: String(row.id),++ prompt: String(row.prompt || ''),++ kind: String(row.kind || 'icon'),++ svg: String(row.svg || ''),++ provider: row.provider ? String(row.provider) : null,++ model: row.model ? String(row.model) : null,++ license: String(row.license || 'OFL-1.1'),++ state: String(row.state || 'drawn'),++ note: row.note ? String(row.note) : null,++ createdAt: row.created_at,++ };++}++++function createStore({ db, log = console, now = () => new Date() } = {}) {++ /** A typeface of this person's, or null. Another person's typeface is not found, not refused. */++ async function typeface(id, owner) {++ const row = await db.maybe('SELECT * FROM ai_typefaces WHERE id = $1 AND owner = $2', [String(id), String(owner)]);++ return typefaceRow(row);++ }++++ /** A vector of this person's, or null. */++ async function vector(id, owner) {++ const row = await db.maybe('SELECT * FROM ai_vectors WHERE id = $1 AND owner = $2', [String(id), String(owner)]);++ return vectorRow(row);++ }++++ /** The person's typefaces, newest first. */++ async function typefacesOf(owner, limit = 24) {++ const rows = await db.many('SELECT * FROM ai_typefaces WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);++ return rows.map(typefaceRow);++ }++++ /** The person's vectors, newest first. */++ async function vectorsOf(owner, limit = 24) {++ const rows = await db.many('SELECT * FROM ai_vectors WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);++ return rows.map(vectorRow);++ }++++ async function saveTypeface({ id, owner, name, prompt, baseFamily = null, styleNotes = null, glyphs = [], metrics = {}, provider = null, model = null, license = 'OFL-1.1', state = 'drawing', note = null }) {++ await db.exec(++ 'INSERT INTO ai_typefaces (id, owner, name, prompt, base_family, style_notes, glyphs, metrics, provider, model, license, state, note) ' +++ 'VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13) ' +++ 'ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, style_notes = EXCLUDED.style_notes, glyphs = EXCLUDED.glyphs, ' +++ 'metrics = EXCLUDED.metrics, provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, ' +++ 'state = EXCLUDED.state, note = EXCLUDED.note, updated_at = now()',++ [String(id), String(owner), String(name), String(prompt || ''), baseFamily, styleNotes, JSON.stringify(glyphs), JSON.stringify(metrics), provider, model, String(license || 'OFL-1.1'), String(state || 'drawing'), note],++ );++ return await typeface(id, owner);++ }++++ async function saveVector({ id, owner, prompt, kind = 'icon', svg, provider = null, model = null, license = 'OFL-1.1', state = 'drawn', note = null }) {++ await db.exec(++ 'INSERT INTO ai_vectors (id, owner, prompt, kind, svg, provider, model, license, state, note) ' +++ 'VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ' +++ 'ON CONFLICT (id) DO UPDATE SET prompt = EXCLUDED.prompt, kind = EXCLUDED.kind, svg = EXCLUDED.svg, ' +++ 'provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, state = EXCLUDED.state, ' +++ 'note = EXCLUDED.note, updated_at = now()',++ [String(id), String(owner), String(prompt || ''), String(kind || 'icon'), String(svg || ''), provider, model, String(license || 'OFL-1.1'), String(state || 'drawn'), note],++ );++ return await vector(id, owner);++ }++++ /**++ * How many model runs this person has made today. The cap in the config is compared against this.++ * The count is of runs, not of pages: one typeface is several runs, because the letters are drawn++ * a batch at a time.++ */++ async function runsToday(owner, kind = null) {++ const row = kind++ ? await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND kind = $2 AND day = current_date', [String(owner), String(kind)])++ : await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND day = current_date', [String(owner)]);++ return (row && Number(row.n)) || 0;++ }++++ /** One model run, counted. */++ async function countRun(owner, kind) {++ await db.exec('INSERT INTO ai_make_runs (owner, kind) VALUES ($1, $2)', [String(owner), String(kind)]);++ }++++ return { typeface, vector, typefacesOf, vectorsOf, saveTypeface, saveVector, runsToday, countRun };++}++++module.exports = { createStore, typefaceRow, vectorRow };nnew file mode 100644@@@ -0,0 +1,273 @@++'use strict';++/**++ * An SVG, cleaned before it is shown or saved (plan R1 item 8: AI vectors).++ *++ * The model writes an SVG. A browser will run anything in an SVG it is given, so the answer is read here first and++ * rebuilt from scratch out of what a drawing may contain. Nothing the model wrote is trusted: the output is the++ * allowlist below and nothing else.++ *++ * sanitize(markup) -> { ok: true, svg } | { ok: false, why }++ *++ * What may stay:++ * - the elements below: the shapes a drawing is made of++ * - the attributes below: how a shape looks++ * - one viewBox, so the drawing scales++ *++ * What may not:++ * - script, style, foreignObject, animate, set, use, image, a, and every other element++ * - any event handler (onclick), any href, any external reference, any url() that is not a local #id++ * - entity declarations, CDATA, doctypes, processing instructions++ * - anything that is not well-formed markup++ */++const path = require('../typefaces/path');++++/** The elements a drawing may be made of, with the spelling SVG gives them. */++const ELEMENTS = new Map([++ ['svg', 'svg'],++ ['g', 'g'],++ ['path', 'path'],++ ['circle', 'circle'],++ ['ellipse', 'ellipse'],++ ['line', 'line'],++ ['polyline', 'polyline'],++ ['polygon', 'polygon'],++ ['rect', 'rect'],++ ['defs', 'defs'],++ ['clippath', 'clipPath'],++ ['mask', 'mask'],++ ['lineargradient', 'linearGradient'],++ ['radialgradient', 'radialGradient'],++ ['stop', 'stop'],++ ['title', 'title'],++ ['desc', 'desc'],++]);++++/** The attributes each element may carry. */++const ATTRIBUTES = {++ svg: new Set(['viewBox', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform', 'xmlns', 'version', 'preserveAspectRatio']),++ g: new Set(['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),++ path: new Set(['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),++ circle: new Set(['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),++ ellipse: new Set(['cx', 'cy', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),++ line: new Set(['x1', 'y1', 'x2', 'y2', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'opacity', 'color', 'transform']),++ polyline: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),++ polygon: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),++ rect: new Set(['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),++ defs: new Set([]),++ clipPath: new Set(['id', 'clipPathUnits', 'transform']),++ mask: new Set(['id', 'maskUnits', 'maskContentUnits', 'x', 'y', 'width', 'height']),++ linearGradient: new Set(['id', 'x1', 'y1', 'x2', 'y2', 'gradientUnits', 'gradientTransform', 'spreadMethod']),++ radialGradient: new Set(['id', 'cx', 'cy', 'r', 'fx', 'fy', 'gradientUnits', 'gradientTransform', 'spreadMethod']),++ stop: new Set(['offset', 'stop-color', 'stop-opacity']),++ title: new Set([]),++ desc: new Set([]),++};++++/** The attributes that are never allowed, whatever the element. */++const NEVER = /^(?:on[a-z]+|href|xlink:href|src|srcset|formaction|action|xmlns:xlink|xml:base|base|content|ping|data|codebase|manifest|profile|usemap|class|style)$/i;++++/** The style properties a style attribute may hold. */++const STYLE_PROPERTIES = new Set(['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', 'color', 'stop-color', 'stop-opacity', 'clip-rule', 'paint-order']);++++/** How large an SVG may be. */++const MAX_BYTES = 200000;++++const TAG = /<\s*(\/\s*)?([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;++const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;++++function esc(s) {++ return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++}++++function fail(why) { return { ok: false, why }; }++++/**++ * A colour, a number, a length: the values a shape may be drawn with.++ *++ * A value that is merely empty or odd is dropped (the drawing goes on without it). A value that reaches++ * outside the drawing, or that would run something, refuses the drawing: quietly drawing something else++ * than the model meant is worse than saying no.++ */++function safeValue(value) {++ const v = String(value).trim();++ if (!v) return { ok: false, drop: true };++ if (v.length > 300) return { ok: false, drop: true };++ // No url() except a local reference, no javascript:, no expression.++ if (/url\s*\(/i.test(v)) {++ const only = /^url\(\s*['"]?#[A-Za-z][\w:.-]*['"]?\s*\)$/i.test(v);++ if (!only) return { ok: false, why: 'the drawing reaches outside itself' };++ }++ if (/javascript:|expression\s*\(|@import|behavior\s*:|binding\s*:/i.test(v)) return { ok: false, why: 'the drawing carries something a drawing must not carry' };++ if (/[<>&]/.test(v)) return { ok: false, drop: true };++ return { ok: true, value: v };++}++++/** A style attribute: only the properties above, and only safe values. */++function safeStyle(value) {++ const parts = String(value).split(';');++ const kept = [];++ for (const part of parts) {++ const idx = part.indexOf(':');++ if (idx < 0) continue;++ const prop = part.slice(0, idx).trim().toLowerCase();++ if (!STYLE_PROPERTIES.has(prop)) continue;++ const val = safeValue(part.slice(idx + 1));++ if (!val.ok) {++ if (val.why) return { ok: false, why: val.why };++ continue;++ }++ kept.push(prop + ':' + val.value);++ }++ return { ok: true, value: kept.join(';') };++}++++/** The numbers of a points list: an even run of numbers, nothing else. */++function safePoints(value) {++ const raw = String(value).trim();++ if (!raw || raw.length > 4000) return null;++ const nums = raw.split(/[\s,]+/).filter(Boolean);++ if (nums.length < 4 || nums.length % 2 !== 0) return null;++ for (const n of nums) if (!/^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?$/.test(n)) return null;++ return nums.join(' ');++}++++/** The path data of a path: read with the same rules the font uses, so a drawing cannot smuggle anything. */++function safePathData(value) {++ const raw = String(value).trim();++ if (!raw || raw.length > 20000) return null;++ const parsed = path.parse(raw);++ if (!parsed.ok) return null;++ return path.toData(parsed.segments);++}++++/** The attributes of one element, read into a plain object, with the ones that are not allowed refused. */++function readAttrs(name, restRaw) {++ const attrs = {};++ for (const a of restRaw.matchAll(ATTR)) {++ const key = a[1].toLowerCase();++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);++ if (key === 'style') {++ const style = safeStyle(value);++ if (!style.ok) return style;++ if (style.value) attrs.style = style.value;++ continue;++ }++ if (NEVER.test(key)) return { ok: false, why: 'the drawing carries a ' + key + ' attribute' };++ if (!ATTRIBUTES[name].has(key)) continue; // an attribute nobody needs is simply dropped++ if (key === 'd') {++ const d = safePathData(value);++ if (d === null) return { ok: false, why: 'the drawing has a path it cannot read' };++ attrs.d = d;++ continue;++ }++ if (key === 'points') {++ const pts = safePoints(value);++ if (pts === null) return { ok: false, why: 'the drawing has points it cannot read' };++ attrs.points = pts;++ continue;++ }++ const val = safeValue(value);++ if (!val.ok) {++ if (val.why) return { ok: false, why: val.why };++ continue;++ }++ attrs[key] = val.value;++ }++ return { ok: true, attrs };++}++++/**++ * Read one SVG. The markup is walked tag by tag; only the elements and attributes on the list are kept, and++ * everything is written back out from scratch, so nothing the model wrote survives unless it was allowed.++ */++function sanitize(markup) {++ const raw = String(markup == null ? '' : markup).trim();++ if (!raw) return fail('the drawing is empty');++ if (raw.length > MAX_BYTES) return fail('the drawing is too large');++++ // The things that are never in a drawing, refused before anything is read.++ if (/<!\[CDATA\[|<!ENTITY|<!DOCTYPE|<\?/i.test(raw)) return fail('the drawing carries markup a drawing must not carry');++ if (/javascript:|vbscript:|data:text\/html|<script|<foreignObject|<iframe|<embed|<object|<use\b|<image\b|<animate|<set\b|<audio|<video|<handler/i.test(raw))++ return fail('the drawing carries something a drawing must not carry');++++ const out = [];++ const stack = [];++ let rootAttrs = null;++ let viewBox = null;++++ for (const m of raw.matchAll(TAG)) {++ const [full, closing, nameRaw, restRaw] = m;++ const lower = nameRaw.toLowerCase();++ const name = ELEMENTS.get(lower);++ if (!name) return fail('a drawing is made of shapes, and this is not one');++++ if (closing) {++ // The root's own close is not written out: the root tag is written once, at the end.++ if (name === 'svg' && !stack.length) {++ if (!rootAttrs) return fail('there is no drawing here');++ continue;++ }++ if (!stack.length || stack[stack.length - 1] !== name) return fail('the drawing is not drawn properly');++ stack.pop();++ out.push('</' + name + '>');++ continue;++ }++++ const read = readAttrs(name, restRaw);++ if (!read.ok) return read;++ const selfClosing = /\/\s*$/.test(restRaw);++ const parts = Object.keys(read.attrs).map((k) => ' ' + k + '="' + esc(read.attrs[k]) + '"');++++ if (name === 'svg') {++ // The root is the one drawing; a second one inside it is not allowed.++ if (rootAttrs) return fail('there is more than one drawing here');++ rootAttrs = parts;++ for (const a of restRaw.matchAll(ATTR)) {++ if (a[1].toLowerCase() !== 'viewbox') continue;++ const box = safeValue(a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]));++ if (!box.ok) return fail(box.why || 'the drawing has no size');++ viewBox = box.value;++ }++ // The root's own tag is written once, at the end, with its viewBox.++ continue;++ }++++ if (selfClosing) out.push('<' + name + parts.join('') + '/>');++ else {++ out.push('<' + name + parts.join('') + '>');++ stack.push(name);++ }++ }++++ if (!rootAttrs) return fail('there is no drawing here');++ if (stack.length) return fail('the drawing is not closed');++ if (!viewBox) return fail('the drawing has no size');++++ // The root's attributes are read again, on their own, so the viewBox can be required.++ const root = /<\s*svg\b([^>]*)>/i.exec(raw);++ const keep = [];++ for (const a of root[1].matchAll(ATTR)) {++ const key = a[1].toLowerCase();++ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);++ if (key === 'viewbox') continue;++ if (key === 'style') {++ const style = safeStyle(value);++ if (!style.ok) return fail(style.why);++ if (style.value) keep.push('style="' + esc(style.value) + '"');++ continue;++ }++ if (NEVER.test(key)) return fail('the drawing carries a ' + key + ' attribute');++ if (!ATTRIBUTES.svg.has(key)) continue;++ const val = safeValue(value);++ if (!val.ok) {++ if (val.why) return fail(val.why);++ continue;++ }++ keep.push(key + '="' + esc(val.value) + '"');++ }++++ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + esc(viewBox) + '"' + (keep.length ? ' ' + keep.join(' ') : '') + '>' + out.join('') + '</svg>';++ return { ok: true, svg };++}++++module.exports = { sanitize, ELEMENTS, ATTRIBUTES, NEVER, MAX_BYTES, safeValue, safeStyle, safePoints, safePathData };nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).++ *++ * Two makers and two pages. Every page is server-rendered and shows its content: the form, the drawings,++ * the specimen and the downloads are all in the HTML, and nothing is behind a tab.++ *++ * The specimen shows the face in its own voice: the font is written into the page as a data URL, so the++ * letters on screen are the letters the person just made.++ */++const { layout, esc } = require('../render/layout');++++/** The stylesheet both pages share. */++const STYLES = ['css/typefaces.css'];++++/** The font as a data URL the page can use: the specimen is drawn in the face it just made. */++function fontFace(woff2) {++ if (!woff2) return '';++ return '@font-face{font-family:"OpenVibe Made";src:url(data:font/woff2;base64,' + woff2.toString('base64') + ') format("woff2");font-weight:400;font-style:normal;font-display:swap}';++}++++/** "Aa" (or the first two letters it has) drawn from a typeface's own outlines, as one inline SVG. */++function sample(glyphs) {++ const list = Array.isArray(glyphs) ? glyphs.filter((g) => g && typeof g.d === 'string' && /^[MmLlHhVvCcSsQqTtZz0-9.,\s-]+$/.test(g.d)) : [];++ const by = new Map(list.map((g) => [g.char, g]));++ const picked = ['A', 'a'].every((c) => by.has(c)) ? [by.get('A'), by.get('a')] : list.slice(0, 2);++ if (!picked.length) return '<span class="tf-sample is-empty">Not drawn yet</span>';++ let x = 0;++ const paths = picked.map((g) => { const p = '<path transform="translate(' + x + ' 0)" d="' + esc(g.d) + '"/>'; x += Number(g.advance) || 600; return p; }).join('');++ return '<svg class="tf-sample" viewBox="0 -800 ' + x + ' 1000" aria-hidden="true"><g transform="scale(1 -1)" fill="currentColor">' + paths + '</g></svg>';++}++++/** The maker: describe a face, start from an open one. */++function make({ bases = [], mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {++ const options = bases.map((b) => '<option value="' + esc(b.id) + '">' + esc(b.family) + ' — ' + esc(b.category) + '</option>').join('');++ // Each typeface you made is shown in itself: "Aa" drawn from its own stored outlines (validated paths, font units,++ // baseline at 0), so the list reads as a shelf of faces, not a list of names (Opus's design pass, 11-08).++ const owned = mine.length++ ? '<section class="tf-owned"><h2>Your typefaces <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-shelf">' + mine.map((f) => '<li><a href="/fonts/made/' + esc(f.id) + '">'++ + sample(f.glyphs) + '<b>' + esc(f.name) + '</b><span>' + esc(f.glyphs.length) + ' letters</span></a></li>').join('') + '</ul></section>'++ : '';++ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI typefaces</p><h1>Describe a typeface.</h1>'++ + '<p>The model draws the letters, one batch at a time. You get the outlines, the font files and the licence.</p></header>'++ + (signedIn++ ? '<form class="tf-form" method="post" action="/fonts/make"><label for="tf-prompt">What kind of typeface?</label>'++ + '<textarea id="tf-prompt" name="prompt" required maxlength="600" placeholder="rounded geometric sans for a kids\' app, high x-height, open apertures"></textarea>'++ + '<label for="tf-name">Name it <small>optional</small></label><input id="tf-name" name="name" maxlength="60" placeholder="Bubble Sans">'++ + '<label for="tf-base">Start from an open font <small>optional</small></label><select id="tf-base" name="base" data-ov-native><option value="">No base, just the description</option>' + options + '</select>'++ + '<button type="submit">Draw the typeface</button>'++ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'++ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a typeface. The runs are charged to your own allowance.</p>')++ + owned++ + '</div>';++ return layout({ title: 'Make a typeface', description: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.',++ path: '/fonts/make', active: '/fonts', styles: STYLES, body });++}++++/** The specimen: the face in its own voice, the download, and the redraw. */++function specimen({ face, compiled, chars = '' } = {}) {++ const glyphs = Array.isArray(face.glyphs) ? face.glyphs : [];++ const css = compiled && compiled.ok ? fontFace(compiled.woff2) : '';++ const sample = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';++ const rows = chars.split('').map((c) => {++ const g = glyphs.find((x) => x.char === c);++ return '<option value="' + esc(c) + '"' + (g ? '' : ' disabled') + '>' + esc(c) + '</option>';++ }).join('');++ const letters = sample.split('').map((c) => {++ const g = glyphs.find((x) => x.char === c);++ return '<span class="tf-letter' + (g ? '' : ' tf-missing') + '">' + esc(c) + '</span>';++ }).join('');++ const body = '<div class="tf tf-specimen"><a class="tf-back" href="/fonts/make">← Make another</a>'++ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(face.license) + (face.model ? ' · drawn by ' + esc(face.model) : '') + '</p>'++ + '<h1 class="tf-name" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(face.name) + '</h1>'++ + '<p>' + esc(face.prompt) + '</p></header>'++ + '<p class="tf-sample" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(sample) + '</p>'++ + '<p class="tf-sample tf-words" style="font-family:\'OpenVibe Made\',sans-serif">The quick brown fox jumps over the lazy dog.</p>'++ + '<div class="tf-letters">' + letters + '</div>'++ + '<section class="tf-download"><h2>Take it with you</h2><p>'++ + (compiled && compiled.ok++ ? '<a href="/api/v1/typefaces/' + esc(face.id) + '/font.woff2" download>Download the WOFF2</a> · <a href="/api/v1/typefaces/' + esc(face.id) + '/font.otf" download>Download the OTF</a>'++ : 'The font files are being compiled. Try again in a moment.')++ + '</p><p class="tf-note">The font is yours, offered under the ' + esc(face.license) + '. The name table says which model drew it.</p></section>'++ + '<section class="tf-redraw"><h2>Redraw one letter</h2><p>Say what to change and the model draws that letter again, in the same style.</p>'++ + '<form method="post" action="/fonts/made/' + esc(face.id) + '/glyph"><label for="tf-char">Letter</label>'++ + '<select id="tf-char" name="char" data-ov-native>' + rows + '</select>'++ + '<label for="tf-note">What to change</label><input id="tf-note" name="note" required maxlength="400" placeholder="make the tail longer and curl it up">'++ + '<button type="submit">Redraw the letter</button></form>'++ + (face.note ? '<p class="tf-lastnote">Last note: ' + esc(face.note) + '</p>' : '')++ + '</section></div>';++ return layout({ title: face.name + ' — a typeface you made', description: 'A typeface you described, drawn by the model, with the outlines and the font files.',++ path: '/fonts/made/' + face.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow',++ inlineStyles: css ? [css] : [], body });++}++++/** The vector maker: one drawing from a prompt. */++function vectorMake({ mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {++ // Your drawings as drawings: each one's own SVG as a picture (an <img> runs nothing), with what it was asked to be.++ const owned = mine.length++ ? '<section class="tf-owned"><h2>Your drawings <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-gallery">' + mine.map((v) => '<li><a href="/vectors/made/' + esc(v.id) + '">'++ + '<img src="/vectors/made/' + esc(v.id) + '.svg" alt="" loading="lazy" width="160" height="160"><span>' + esc(v.prompt.slice(0, 80)) + '</span><small>' + esc(v.kind) + '</small></a></li>').join('') + '</ul></section>'++ : '';++ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI vectors</p><h1>Describe a drawing.</h1>'++ + '<p>One clean SVG: an icon, an illustration or a pattern. You can draw it again, change it with a note, and take it away.</p></header>'++ + (signedIn++ ? '<form class="tf-form" method="post" action="/vectors/make"><label for="tv-prompt">What should it be?</label>'++ + '<textarea id="tv-prompt" name="prompt" required maxlength="600" placeholder="a paper plane folded from a single sheet, mid-flight, flat two-colour"></textarea>'++ + '<label for="tv-kind">What kind</label><select id="tv-kind" name="kind" data-ov-native><option value="icon">Icon</option><option value="illustration">Illustration</option><option value="pattern">Pattern</option></select>'++ + '<button type="submit">Draw it</button>'++ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'++ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a vector. The runs are charged to your own allowance.</p>')++ + owned + '</div>';++ return layout({ title: 'Make a vector', description: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.',++ path: '/vectors/make', active: '/fonts', styles: STYLES, body });++}++++/** The drawing: the SVG, the downloads and the notes. */++function vector({ vector: v } = {}) {++ const body = '<div class="tf tf-vector"><a class="tf-back" href="/vectors/make">← Make another</a>'++ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(v.kind) + (v.model ? ' · drawn by ' + esc(v.model) : '') + '</p>'++ + '<h1>' + esc(v.prompt) + '</h1></header>'++ + '<figure class="tf-figure">' + v.svg + '</figure>'++ + '<section class="tf-download"><h2>Take it with you</h2><p>'++ + '<a href="/vectors/made/' + esc(v.id) + '.svg" download>Download the SVG</a> · <a href="/vectors/made/' + esc(v.id) + '.png" download>Download the PNG</a>'++ + '</p><p class="tf-note">The drawing is yours, offered under the ' + esc(v.license) + '.</p></section>'++ + '<section class="tf-redraw"><h2>Change it</h2><p>Say what to change, or leave the note empty to draw it again.</p>'++ + '<form method="post" action="/vectors/made/' + esc(v.id) + '/edit"><label for="tv-note">What to change</label>'++ + '<input id="tv-note" name="note" maxlength="400" placeholder="make the wings longer and add a shadow">'++ + '<button type="submit" name="regenerate" value="1">Draw it again</button></form>'++ + (v.note ? '<p class="tf-lastnote">Last note: ' + esc(v.note) + '</p>' : '')++ + '</section></div>';++ return layout({ title: 'A drawing you made', description: 'An SVG the model drew for you, clean and ready to use.',++ path: '/vectors/made/' + v.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow', body });++}++++module.exports = { make, specimen, vectorMake, vector };@@@ -24,8 +24,12 @@ const components = require('openvibe-shared/components');'openvibe_design_validate_kit_document','openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate','openvibe_design_list_fonts', 'openvibe_design_get_font',++ 'openvibe_design_make_typeface', 'openvibe_design_make_vector',]);-- assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));++ assert.ok(listed.every(x => x.inputSchema && x.annotations));++ // Every tool but the two that make something only reads.++ assert.ok(listed.filter(x => x.annotations.readOnlyHint).length === listed.length - 2);++ assert.ok(listed.filter(x => !x.annotations.readOnlyHint).every(x => x.name.startsWith('openvibe_design_make_')));assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);assert.strictEqual((await t.get('/mcp')).status, 405);assert.strictEqual((await t.get('/mcp', { body: '{', headers: { 'content-type': 'application/json' } })).json().error.code, -32700);nnew file mode 100644@@@ -0,0 +1,388 @@++'use strict';++/**++ * The typefaces and vectors a person makes with the model (plan R1 item 8).++ *++ * Four things are proved here:++ * - the sanitisers refuse hostile input: a script, an event handler, an external reference, a path that++ * is not closed, an outline that leaves the em box++ * - the font compiles: the same glyph fixtures always give the same font, with the metrics the compiler++ * promises and the model named in the font's name table++ * - the cap holds: a person runs out of runs for the day and is told so in plain words++ * - the outputs stay private: another person cannot read them, and they are not in the sitemap++ */++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { clean } = require('../server/typefaces/glyph');++const { compile } = require('../server/typefaces/compile');++const { sanitize } = require('../server/vectors/svg');++++/** The letters the fake model draws. Every one of them is a closed path inside the em box. */++const LETTERS = {++ A: 'M100 0 L300 700 L500 0 L420 0 L340 180 L160 180 L80 0 Z',++ B: 'M100 0 L100 700 L350 700 L500 540 L500 390 L330 370 L500 0 Z M180 80 L180 620 L330 620 L420 540 L420 460 L330 460 L180 460 Z',++ C: 'M450 0 L380 70 L340 30 L300 0 L200 0 L100 150 L100 550 L200 700 L300 700 L340 670 L380 630 L450 700 L330 800 L220 800 L100 720 L0 550 L0 150 L100 -20 L220 -100 L330 -100 Z',++ D: 'M100 0 L100 700 L300 700 L450 570 L450 130 L300 0 Z M190 90 L190 610 L290 610 L360 540 L360 160 L290 90 Z',++ E: 'M100 0 L100 700 L450 700 L450 610 L200 610 L200 400 L400 400 L400 310 L200 310 L200 90 L450 90 L450 0 Z',++};++/** The letters the fake model draws for everything else: a plain block, so every character has an outline. */++const block = (x) => 'M100 0 L100 700 L400 700 L400 0 Z';++++const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';++++/** The fake model: it draws letters when it is asked for a typeface, and a drawing when it is asked for a vector. */++function fakeAi(overrides = {}) {++ return {++ enabled: true,++ text: async ({ prompt }) => {++ if (String(prompt).includes('Draw these characters')) {++ const wanted = /Draw these characters, and only these: (.+)/.exec(String(prompt));++ const chars = wanted ? wanted[1].trim() : CHARSET;++ const glyphs = {};++ for (const c of chars) glyphs[c] = LETTERS[c] || block(c);++ return { text: JSON.stringify({ glyphs }), provider: 'anthropic', model: 'claude-opus-5-5' };++ }++ if (String(prompt).includes('Draw:') || String(prompt).includes('Now draw ONE letter')) {++ const svg = '<svg viewBox="0 0 24 24"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></svg>';++ return { text: svg, provider: 'anthropic', model: 'claude-opus-5-5' };++ }++ return { text: '{}', provider: 'anthropic', model: 'claude-opus-5-5' };++ },++ ...overrides,++ };++}++++(async () => {++ // ── The sanitisers, on their own ──++ await check('a glyph is one closed path inside the em box', () => {++ const good = clean('M100 0 L400 0 L400 700 L100 700 Z');++ assert.ok(good.ok);++ assert.equal(good.glyph.d, 'M100 0 L400 0 L400 700 L100 700 Z');++ assert.equal(good.glyph.advance, 420);++ // An open contour is not an outline.++ assert.equal(clean('M100 0 L400 0 L400 700').ok, false);++ // An outline that leaves the em box is refused.++ assert.equal(clean('M100 0 L4000 0 L4000 700 L100 700 Z').ok, false);++ // A path with nothing in it is refused.++ assert.equal(clean('M100 100 L101 100 L101 101 Z').ok, false);++ });++++ await check('a glyph carries no script, handler or reference', () => {++ for (const hostile of [++ '<script>alert(1)</script>',++ '<path onload="alert(1)" d="M0 0 L100 100 Z"/>',++ '<path href="http://example.com/x" d="M0 0 L100 100 Z"/>',++ '<path style="x" d="M0 0 L100 100 Z"/>',++ '<foreignObject><b>x</b></foreignObject>',++ '<image href="http://example.com/x.png"/>',++ 'javascript:alert(1)',++ 'M0 0 L10 10 url(http://example.com)',++ '<path d="M0 0 L100 100 Z"/><path d="M0 0 L100 100 Z"/>',++ ]) {++ const r = clean(hostile);++ assert.equal(r.ok, false, hostile);++ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);++ }++ });++++ await check('a drawing is made of shapes and nothing else', () => {++ const good = sanitize('<svg viewBox="0 0 24 24"><g fill="#333"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></g></svg>');++ assert.ok(good.ok);++ assert.ok(good.svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'));++ assert.ok(good.svg.includes('<path d="M4 4 L20 4 L20 20 L4 20 Z"/>'));++ assert.ok(!good.svg.includes('http://example.com'));++ for (const hostile of [++ '<svg viewBox="0 0 24 24"><script>alert(1)</script></svg>',++ '<svg viewBox="0 0 24 24" onload="alert(1)"><path d="M0 0 L10 10 Z"/></svg>',++ '<svg viewBox="0 0 24 24"><path onclick="x()" d="M0 0 L10 10 Z"/></svg>',++ '<svg viewBox="0 0 24 24"><foreignObject><b>x</b></foreignObject></svg>',++ '<svg viewBox="0 0 24 24"><image href="http://example.com/x.png"/></svg>',++ '<svg viewBox="0 0 24 24"><use href="#x"/></svg>',++ '<svg viewBox="0 0 24 24"><path href="http://example.com" d="M0 0 L10 10 Z"/></svg>',++ '<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg viewBox="0 0 24 24"><path d="M0 0 L10 10 Z"/></svg>',++ '<svg viewBox="0 0 24 24"><![CDATA[x]]></svg>',++ '<svg viewBox="0 0 24 24"><path fill="url(http://example.com/x)" d="M0 0 L10 10 Z"/></svg>',++ '<svg viewBox="0 0 24 24"><path d="M0 0 L10 10 javascript"/></svg>',++ ]) {++ const r = sanitize(hostile);++ assert.equal(r.ok, false, hostile);++ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);++ }++ // A drawing without a size cannot be shown.++ assert.equal(sanitize('<svg><path d="M0 0 L10 10 Z"/></svg>').ok, false);++ });++++ // ── The compiler, on fixed fixtures ──++ await check('the same glyphs always compile to the same font', async () => {++ const glyphs = Object.entries(LETTERS).map(([char, d]) => ({ char, d }));++ const first = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });++ const second = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });++ assert.ok(first.ok, first.why);++ assert.ok(second.ok);++ assert.deepEqual(first.otf, second.otf);++ assert.deepEqual(first.woff2, second.woff2);++ // The files are what they say they are.++ assert.equal(first.woff2.slice(0, 4).toString('latin1'), 'wOF2');++ assert.equal(first.otf.slice(0, 4).toString('latin1'), 'OTTO');++ // The metrics are the face's own.++ assert.equal(first.metrics.unitsPerEm, 1000);++ assert.equal(first.metrics.glyphs, 5);++ assert.ok(first.metrics.ascender >= 400 && first.metrics.ascender <= 800);++ assert.ok(first.metrics.descender >= -200 && first.metrics.descender <= -100);++ });++++ await check('a font says which model drew it', async () => {++ const out = await compile({ familyName: 'Named Face', glyphs: [{ char: 'A', d: LETTERS.A }], model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design', license: 'OFL-1.1' });++ assert.ok(out.ok);++ const names = out.font.names.unicode;++ assert.equal(names.fontFamily.en, 'Named Face');++ assert.equal(names.license.en, 'OFL-1.1');++ assert.match(names.description.en, /claude-opus-5-5/);++ });++++ await check('a font without a name or without glyphs is refused', async () => {++ assert.equal((await compile({ familyName: '', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);++ assert.equal((await compile({ familyName: '../../etc/passwd', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);++ assert.equal((await compile({ familyName: 'Fine', glyphs: [] })).ok, false);++ // A glyph that is not an outline does not become one on the way in.++ assert.equal((await compile({ familyName: 'Fine', glyphs: [{ char: 'A', d: '<script>alert(1)</script>' }] })).ok, false);++ });++++ // ── The service, over HTTP ──++ const t = await boot({ ai: fakeAi() });++ try {++ const user = t.network.addUser('ada');++ const other = t.network.addUser('bob');++++ await check('the maker page is public and the work is not', async () => {++ const page = await t.get('/fonts/make');++ assert.equal(page.status, 200);++ assert.match(page.text, /Describe a typeface/);++ assert.match(page.text, /Sign in/);++ const vectorPage = await t.get('/vectors/make');++ assert.equal(vectorPage.status, 200);++ assert.match(vectorPage.text, /Describe a drawing/);++ // The tools are pages a crawler may read; the drawings are not.++ const sitemap = (await t.get('/sitemap.xml')).text;++ assert.ok(sitemap.includes('/fonts/make'));++ assert.ok(sitemap.includes('/vectors/make'));++ assert.ok(!sitemap.includes('/fonts/made/'));++ assert.ok(!sitemap.includes('/vectors/made/'));++ const llms = (await t.get('/llms.txt')).text;++ assert.ok(llms.includes('/fonts/make'));++ assert.ok(llms.includes('/vectors/make'));++ });++++ await check('a typeface is drawn, compiled and downloadable', async () => {++ const made = await t.get('/fonts/make', { as: user, method: 'POST',++ body: new URLSearchParams({ prompt: 'rounded geometric sans for a kids app', name: 'Bubble Sans' }).toString(),++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });++ assert.equal(made.status, 303, made.text);++ const id = made.headers.get('location').split('/').pop();++ const row = (await t.store.db.many('SELECT * FROM ai_typefaces WHERE id=$1', [id]))[0];++ assert.ok(row);++ assert.equal(row.owner, user.subject);++ assert.equal(row.name, 'Bubble Sans');++ assert.equal(row.license, 'OFL-1.1');++ assert.equal(row.model, 'claude-opus-5-5');++ assert.ok(row.glyphs.length > 50, 'every letter is drawn');++ assert.ok(row.glyphs.every(g => g.d && g.char && g.advance));++++ const specimen = await t.get('/fonts/made/' + id, { as: user });++ assert.equal(specimen.status, 200);++ assert.match(specimen.text, /Bubble Sans/);++ assert.match(specimen.text, /font\/woff2;base64/);++ assert.match(specimen.text, /Redraw one letter/);++++ const woff2 = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });++ assert.equal(woff2.status, 200);++ assert.equal(woff2.buffer.slice(0, 4).toString('latin1'), 'wOF2');++ assert.match(woff2.headers.get('content-disposition') || '', /attachment/);++ const otf = await t.get('/api/v1/typefaces/' + id + '/font.otf', { as: user });++ assert.equal(otf.status, 200);++ assert.equal(otf.buffer.slice(0, 4).toString('latin1'), 'OTTO');++ });++++ await check('another person cannot read a typeface or its files', async () => {++ const rows = await t.store.db.many('SELECT id FROM ai_typefaces LIMIT 1');++ const id = rows[0].id;++ assert.equal((await t.get('/api/v1/typefaces/' + id)).status, 401);++ assert.equal((await t.get('/api/v1/typefaces/' + id, { as: other })).status, 404);++ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: other })).status, 404);++ assert.equal((await t.get('/fonts/made/' + id, { as: other })).status, 404);++ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2')).status, 401);++ });++++ await check('one letter is redrawn with a note', async () => {++ const rows = await t.store.db.many('SELECT id, glyphs FROM ai_typefaces LIMIT 1');++ const id = rows[0].id;++ const before = rows[0].glyphs.length;++ const redrawn = await t.get('/fonts/made/' + id + '/glyph', { as: user, method: 'POST',++ body: new URLSearchParams({ char: 'A', note: 'make the apex rounder' }).toString(),++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });++ assert.equal(redrawn.status, 303, redrawn.text);++ const after = await t.store.db.many('SELECT glyphs, note FROM ai_typefaces WHERE id=$1', [id]);++ assert.equal(after[0].glyphs.length, before);++ assert.ok(after[0].glyphs.some(g => g.char === 'A'));++ assert.equal(after[0].note, 'make the apex rounder');++ // The font still compiles after the redraw.++ const file = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });++ assert.equal(file.status, 200);++ });++++ await check('a vector is drawn, saved and downloadable as SVG and PNG', async () => {++ const made = await t.get('/vectors/make', { as: user, method: 'POST',++ body: new URLSearchParams({ prompt: 'a paper plane folded from a single sheet', kind: 'icon' }).toString(),++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });++ assert.equal(made.status, 303, made.text);++ const id = made.headers.get('location').split('/').pop();++ const row = (await t.store.db.many('SELECT * FROM ai_vectors WHERE id=$1', [id]))[0];++ assert.ok(row);++ assert.equal(row.owner, user.subject);++ assert.ok(row.svg.startsWith('<svg'));++ assert.ok(!row.svg.includes('<script'));++++ const page = await t.get('/vectors/made/' + id, { as: user });++ assert.equal(page.status, 200);++ assert.match(page.text, /Download the SVG/);++ const svg = await t.get('/vectors/made/' + id + '.svg', { as: user });++ assert.equal(svg.status, 200);++ assert.match(svg.headers.get('content-type') || '', /image\/svg\+xml/);++ assert.match(svg.headers.get('content-security-policy') || '', /default-src 'none'.*sandbox/, 'a saved drawing runs nothing when opened on its own');++ const png = await t.get('/vectors/made/' + id + '.png', { as: user });++ assert.equal(png.status, 200);++ assert.equal(png.buffer.slice(1, 4).toString('latin1'), 'PNG');++ });++++ await check('another person cannot read a vector', async () => {++ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');++ const id = rows[0].id;++ assert.equal((await t.get('/api/v1/vectors/' + id)).status, 401);++ assert.equal((await t.get('/api/v1/vectors/' + id, { as: other })).status, 404);++ assert.equal((await t.get('/vectors/made/' + id + '.svg', { as: other })).status, 404);++ });++++ await check('the drawing is redrawn with a note', async () => {++ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');++ const id = rows[0].id;++ const edited = await t.get('/vectors/made/' + id + '/edit', { as: user, method: 'POST',++ body: new URLSearchParams({ note: 'make the wings longer' }).toString(),++ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });++ assert.equal(edited.status, 303, edited.text);++ const after = await t.store.db.many('SELECT note FROM ai_vectors WHERE id=$1', [id]);++ assert.equal(after[0].note, 'make the wings longer');++ });++++ await check('MCP can make a typeface and a vector, and needs a signed-in person', async () => {++ const anonymous = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 1, method: 'tools/call',++ params: { name: 'openvibe_design_make_vector', arguments: { prompt: 'a small bird' } } } });++ assert.equal(anonymous.json().result.isError, true);++ assert.match(anonymous.json().result.content[0].text, /Sign in/);++++ const made = await t.get('/mcp', { as: user, method: 'POST', json: { jsonrpc: '2.0', id: 2, method: 'tools/call',++ params: { name: 'openvibe_design_make_typeface', arguments: { prompt: 'a plain geometric sans', name: 'Grid Sans' } } } });++ assert.equal(made.json().result.isError, false, made.text);++ const out = made.json().result.structuredContent;++ assert.equal(out.name, 'Grid Sans');++ assert.ok(out.glyphs > 50);++ assert.equal(out.model, 'claude-opus-5-5');++ assert.ok(out.download.woff2.includes('/font.woff2'));++++ const list = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 3, method: 'tools/list' } });++ const names = list.json().result.tools.map(x => x.name);++ assert.ok(names.includes('openvibe_design_make_typeface'));++ assert.ok(names.includes('openvibe_design_make_vector'));++ // The tools that write are not marked read only.++ const vector = list.json().result.tools.find(x => x.name === 'openvibe_design_make_vector');++ assert.equal(vector.annotations.readOnlyHint, false);++ });++++ await check('the API makes a typeface for a signed-in person', async () => {++ const made = await t.get('/api/v1/typefaces', { as: user, method: 'POST', json: { prompt: 'a condensed sans' } });++ assert.equal(made.status, 201, made.text);++ assert.ok(made.json().glyphs.length > 50);++ assert.equal(made.json().license, 'OFL-1.1');++ const anon = await t.get('/api/v1/typefaces', { method: 'POST', json: { prompt: 'a condensed sans' } });++ assert.equal(anon.status, 401);++ });++ } finally { await t.close(); }++++ // ── The cap ──++ await check('a person runs out of runs for the day and is told in plain words', async () => {++ const capped = await boot({ ai: fakeAi(), env: { DESIGN_AI_MAKE_PER_DAY: '1' } });++ try {++ const user = capped.network.addUser('cleo');++ const first = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });++ assert.equal(first.status, 201, first.text);++ const second = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'another bird' } });++ assert.equal(second.status, 429);++ assert.match(second.json().error, /today|tomorrow/i);++ // The cap is per person: somebody else may still make one.++ const other = capped.network.addUser('dara');++ const theirs = await capped.get('/api/v1/vectors', { as: other, method: 'POST', json: { prompt: 'a small bird' } });++ assert.equal(theirs.status, 201, theirs.text);++ // The runs are counted, so the cap is measured against something.++ const runs = await capped.store.db.many('SELECT owner, kind FROM ai_make_runs');++ assert.equal(runs.length, 2);++ assert.deepEqual(new Set(runs.map(r => r.kind)), new Set(['vector']));++ } finally { await capped.close(); }++ });++++ // ── The account the work belongs to (ADR-033) ──++ await check('an export carries the person\'s own typefaces and vectors; deleting the account takes them away', async () => {++ const calls = [];++ const secret = `fixture_${'design'.repeat(8)}`;++ const t2 = await boot({ ai: fakeAi(), env: { DESIGN_EVENTS_SECRET: secret },++ accountSend: async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201, json: async () => ({}) }; } });++ try {++ const owner = t2.network.addUser('ida');++ const stranger = t2.network.addUser('eve');++ await t2.get('/api/v1/typefaces', { as: owner, method: 'POST', json: { prompt: 'a plain geometric sans', name: 'Kept Sans' } });++ await t2.get('/api/v1/vectors', { as: owner, method: 'POST', json: { prompt: 'a small bird' } });++ await t2.get('/api/v1/vectors', { as: stranger, method: 'POST', json: { prompt: 'another bird' } });++++ const exportEvent = { event_id: 'evt_01JZ0000000000000000000E11', event_type: 'network.account.export_requested', source: 'network',++ payload: { export_id: 'exp_01JZ0000000000000000000EX2', subject: owner.subject } };++ const raw = JSON.stringify({ event: exportEvent, seq: 1 });++ const res = await t2.get('/internal/events', { method: 'POST', body: raw,++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, secret) } });++ assert.equal(res.status, 200, res.text);++ const part = calls.find((c) => c.path.startsWith('/internal/account-exports/'));++ assert.ok(part, 'the part was pushed to Network');++ const names = part.body.files.map((f) => f.name).sort();++ assert.ok(names.includes('ai_typefaces.json'), names.join(', '));++ assert.ok(names.includes('ai_vectors.json'), names.join(', '));++ assert.ok(names.includes('ai_make_runs.json'), names.join(', '));++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('Kept Sans'), 'the typeface travels with the person');++ assert.ok(!all.includes(stranger.subject), 'another account is never named');++++ const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D11', event_type: 'network.account.deleted', source: 'network',++ payload: { deletion_id: 'del_01JZ0000000000000000000DE2', subject: owner.subject, aliases: [] } };++ const rawDelete = JSON.stringify({ event: deleteEvent, seq: 1 });++ const gone = await t2.get('/internal/events', { method: 'POST', body: rawDelete,++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(rawDelete, secret) } });++ assert.equal(gone.status, 200, gone.text);++ assert.equal(gone.json().outcome, 'erased');++ const db = t2.ctx.s.db;++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_typefaces WHERE owner = $1', [owner.subject])), 0);++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [owner.subject])), 0);++ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_make_runs WHERE owner = $1', [owner.subject])), 0);++ assert.ok(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [stranger.subject])) > 0, 'another person\'s drawing stays');++ } finally { await t2.close(); }++ });++++ // ── When the model is not there ──++ await check('a plain word when the model is not configured', async () => {++ const off = await boot({ ai: { enabled: false, text: async () => { throw new Error('no'); } } });++ try {++ const user = off.network.addUser('fay');++ const made = await off.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });++ assert.equal(made.status, 503);++ assert.match(made.json().error, /not configured/i);++ assert.ok(!/Error:|at Object/.test(made.json().error));++ } finally { await off.close(); }++ });++++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+-- The typefaces a person makes with the model (plan R1 item 8, Seven Domains §11).
+-- A new table; an older release ignores it.
+--
+-- A typeface is the person's: the glyph outlines the model drew, the metrics they were compiled
+-- with, and the model that drew them. The binaries are not stored here: they are compiled from the
+-- outlines on request, so a redrawn glyph replaces the file rather than adding to it.
+CREATE TABLE IF NOT EXISTS ai_typefaces (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ prompt text NOT NULL,
+ base_family text COLLATE "C",
+ style_notes text,
+ glyphs jsonb NOT NULL DEFAULT '[]'::jsonb,
+ metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
+ provider text COLLATE "C",
+ model text COLLATE "C",
+ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',
+ state text COLLATE "C" NOT NULL DEFAULT 'drawing',
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT ai_typefaces_glyphs_array CHECK (jsonb_typeof(glyphs) = 'array'),
+ CONSTRAINT ai_typefaces_state CHECK (state IN ('drawing', 'ready', 'refused'))
+);
+CREATE INDEX ai_typefaces_owner_created ON ai_typefaces (owner, created_at DESC);
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+-- phase: expand
+-- The vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
+-- New tables; an older release ignores all of it.
+--
+-- A vector is one drawing: the SVG the model drew, cleaned before it was stored, and the note the
+-- person gave when they asked for it.
+CREATE TABLE IF NOT EXISTS ai_vectors (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ prompt text NOT NULL,
+ kind text COLLATE "C" NOT NULL DEFAULT 'icon',
+ svg text NOT NULL,
+ provider text COLLATE "C",
+ model text COLLATE "C",
+ license text COLLATE "C" NOT NULL DEFAULT 'OFL-1.1',
+ state text COLLATE "C" NOT NULL DEFAULT 'drawn',
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT ai_vectors_kind CHECK (kind IN ('icon', 'illustration', 'pattern')),
+ CONSTRAINT ai_vectors_state CHECK (state IN ('drawn', 'refused'))
+);
+CREATE INDEX ai_vectors_owner_created ON ai_vectors (owner, created_at DESC);
+
+-- How many model runs a person may make a day, and how many they have made. The cap is in the
+-- config; this is the count it is measured against.
+CREATE TABLE IF NOT EXISTS ai_make_runs (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ day date NOT NULL DEFAULT current_date,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT ai_make_runs_kind CHECK (kind IN ('typeface', 'vector'))
+);
+CREATE INDEX ai_make_runs_owner_day ON ai_make_runs (owner, day);
@@ -15,12 +15,14 @@
@@ -15,12 +15,14 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "opentype.js": "^2.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
- "subset-font": "^2.9.0"
+ "subset-font": "^2.9.0",
+ "wawoff2": "^2.0.1"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.13"
"@electric-sql/pglite": "^0.5.13"
@@ -1321,6 +1323,15 @@
@@ -1321,6 +1323,15 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/opentype.js": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
+ "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
+ "license": "MIT",
+ "bin": {
+ "ot": "bin/ot"
+ }
+ },
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
"version": "0.338.0",
"version": "0.338.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
@@ -21,12 +21,14 @@
@@ -21,12 +21,14 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "opentype.js": "^2.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.154.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
- "subset-font": "^2.9.0"
+ "subset-font": "^2.9.0",
+ "wawoff2": "^2.0.1"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+/* The typefaces and vectors a person makes with the model: plain maker pages, ready for the final Design art direction. */
+.tf{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
+.tf-hero{max-width:820px;margin:0 0 32px}.tf-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
+.tf-hero h1{margin:0;font-size:clamp(32px,5vw,60px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.tf-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,19px);line-height:1.5}
+
+.tf-form{display:grid;gap:10px;max-width:720px;margin:0 0 40px;padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
+.tf-form label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}
+.tf-form label small{font-weight:500;opacity:.8}
+.tf-form textarea,.tf-form input,.tf-form select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}
+.tf-form textarea{min-height:110px;resize:vertical;line-height:1.5}
+.tf-form button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}
+.tf-form button:hover{opacity:.92}
+.tf-cap{margin:2px 0 0;color:var(--text-secondary);font-size:13px}
+.tf-signin{padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);color:var(--text-secondary)}
+.tf-signin a{color:var(--accent);font-weight:700}
+
+.tf-owned{margin-top:8px}.tf-owned h2{font-size:20px}.tf-owned ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}
+.tf-owned li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;padding:12px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary)}
+.tf-owned a{font-weight:700;color:inherit;text-decoration:none;overflow-wrap:anywhere}.tf-owned a:hover{text-decoration:underline}
+.tf-owned span{color:var(--text-secondary);font-size:13px}
+.tf-n{margin-left:6px;font-size:.65em;font-weight:600;color:var(--text-secondary)}
+.tf-owned .tf-shelf,.tf-owned .tf-gallery{grid-template-columns:repeat(auto-fill,minmax(min(160px,100%),1fr));gap:10px}
+.tf-shelf li,.tf-gallery li{display:block;padding:0}
+.tf-shelf a,.tf-gallery a{display:grid;gap:4px;height:100%;padding:14px;border-radius:12px;box-sizing:border-box}
+.tf-shelf a:hover,.tf-gallery a:hover{text-decoration:none;background:color-mix(in srgb,var(--accent) 8%,transparent)}
+.tf-sample{display:block;height:64px;width:auto;max-width:100%;margin-bottom:6px;color:var(--text-primary)}
+.tf-sample.is-empty{display:flex;align-items:center;height:64px;font-size:14px;color:var(--text-secondary)}
+.tf-shelf b{font-size:15px;overflow-wrap:anywhere}
+.tf-gallery img{width:100%;height:auto;aspect-ratio:1;object-fit:contain;padding:10px;box-sizing:border-box;border-radius:10px;background:var(--bg-primary)}
+.tf-gallery span{font-size:13.5px;color:var(--text-primary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.tf-gallery small{font-size:12px;color:var(--text-secondary);text-transform:capitalize}
+
+.tf-back{display:inline-block;margin-bottom:24px;color:var(--accent)}
+.tf-name{font-size:clamp(34px,6vw,76px)!important}
+.tf-sample{margin:0 0 14px;font-size:clamp(28px,5vw,64px);line-height:1.2;overflow-wrap:anywhere}
+.tf-words{font-size:clamp(18px,2.6vw,30px);color:var(--text-secondary)}
+.tf-letters{display:flex;flex-wrap:wrap;gap:6px;margin:26px 0 8px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}
+.tf-letter{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:52px;padding:4px;border:1px solid var(--border);border-radius:8px;font-family:"OpenVibe Made",sans-serif;font-size:26px;line-height:1}
+.tf-missing{opacity:.28;font-family:inherit}
+.tf-specimen section{margin-top:40px}.tf-specimen h2{font-size:clamp(20px,3vw,28px)}
+.tf-download p{color:var(--text-secondary);line-height:1.6}.tf-download a{color:var(--accent);font-weight:700}
+.tf-note{max-width:70ch}
+.tf-redraw form{display:grid;gap:10px;max-width:640px;margin-top:16px}
+.tf-redraw label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}
+.tf-redraw input,.tf-redraw select{width:100%;min-width:0;min-height:46px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:16px}
+.tf-redraw button{justify-self:start;min-height:46px;padding:10px 22px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;font-size:15px;cursor:pointer}
+.tf-lastnote{margin-top:12px;color:var(--text-secondary);font-size:13px}
+
+.tf-figure{margin:0 0 8px;padding:28px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);display:grid;place-items:center}
+.tf-figure svg{display:block;width:min(100%,420px);height:auto;color:var(--text-primary);fill:currentColor}
+.tf-vector .tf-hero h1{font-size:clamp(24px,3.6vw,40px)}
+
+@media(max-width:600px){
+ .tf-form{padding:14px}
+ .tf-letters{gap:4px;padding:12px}
+ .tf-letter{min-width:38px;min-height:44px;font-size:22px}
+ .tf-figure{padding:16px}
+}
+/* The selects keep the browser's own look (data-ov-native): the house's drawn select paints an empty one
+ with the accent, and a menu of open fonts should read as a field, not as a button. */
+.tf .tf-form select,.tf .tf-redraw select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}
@@ -101,12 +101,16 @@ async function createApp(opts = {}) {
@@ -101,12 +101,16 @@ async function createApp(opts = {}) {
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
await fonts.seedMissing();
await fonts.seedMissing();
+ // The typefaces and vectors a person makes with the model (plan R1 item 8): the store, the drawing
+ // service and the routes that serve them.
+ const aiStore = opts.aiStore || require('./typefaces/store').createStore({ db: s.db, log, now: () => new Date(now()) });
+ const maker = opts.maker || require('./typefaces/service').createService({ db: s.db, ai, store: aiStore, config, log, s, sharing });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,
+ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -159,7 +163,6 @@ async function createApp(opts = {}) {
@@ -159,7 +163,6 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
app.use(cookieParser());
app.use(cookieParser());
- app.use(createMcpRoutes(ctx));
const agentLayer = express();
const agentLayer = express();
const agentDescriptor = {
const agentDescriptor = {
id: 'design', name: 'OpenVibe.Design', purpose: 'Public theme tokens and component specifications.',
id: 'design', name: 'OpenVibe.Design', purpose: 'Public theme tokens and component specifications.',
@@ -185,6 +188,9 @@ async function createApp(opts = {}) {
@@ -185,6 +188,9 @@ async function createApp(opts = {}) {
// ── Who is signed in, then sign-in itself ───────────────
// ── Who is signed in, then sign-in itself ───────────────
app.use(sso.middleware());
app.use(sso.middleware());
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
+ // The MCP endpoint, after the session middleware: the tools that make a typeface or a vector are charged to
+ // the person who calls them, so they need to know who that is. The read-only tools answer as before.
+ app.use(createMcpRoutes(ctx));
// ── Account export/deletion from OpenVibe.Events (loopback only) ──
// ── Account export/deletion from OpenVibe.Events (loopback only) ──
// Raw body first: the SDK verifies the delivery signature over the exact bytes.
// Raw body first: the SDK verifies the delivery signature over the exact bytes.
@@ -279,6 +285,14 @@ async function createApp(opts = {}) {
@@ -279,6 +285,14 @@ async function createApp(opts = {}) {
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.use(createIconRoutes(ctx));
app.use(createIconRoutes(ctx));
+ // The typefaces and vectors a person makes with the model (plan R1 item 8): the makers, the
+ // specimens and the API for agents. These come before the font library's /fonts/:family, so
+ // /fonts/make is the maker and not a family called "make".
+ {
+ const { createTypefaceRoutes, createVectorRoutes } = require('./http/typefaces');
+ app.use(createTypefaceRoutes(ctx));
+ app.use(createVectorRoutes(ctx));
+ }
app.use(createFontRoutes(ctx));
app.use(createFontRoutes(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
@@ -99,6 +99,13 @@ function load(env = process.env) {
@@ -99,6 +99,13 @@ function load(env = process.env) {
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
},
},
+ // The typefaces and vectors a person makes with the model (plan R1 item 8): how many model runs
+ // one person may make a day. A typeface is several runs (the letters are drawn a batch at a time),
+ // so the cap counts runs, not pages.
+ aiMake: {
+ perDay: Math.max(0, int(env.DESIGN_AI_MAKE_PER_DAY, 20)),
+ },
+
// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
arena: {
arena: {
perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),
perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),
@@ -44,6 +44,10 @@ function publicPages(themes = []) {
@@ -44,6 +44,10 @@ function publicPages(themes = []) {
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
+ // The makers (plan R1 item 8): the pages an agent may drive. The drawings and the typefaces they
+ // produce are the person's and stay out of the crawl.
+ { path: '/fonts/make', changefreq: 'monthly', priority: 0.7, title: 'Make a typeface', text: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.' },
+ { path: '/vectors/make', changefreq: 'monthly', priority: 0.7, title: 'Make a vector', text: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.' },
];
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
path: `/t/${encodeURIComponent(String(t.slug))}`,
path: `/t/${encodeURIComponent(String(t.slug))}`,
@@ -99,6 +103,12 @@ function createDiscoveryRoutes(ctx) {
@@ -99,6 +103,12 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
] },
+ // The makers: an agent can drive these two through the API and the MCP tools. Each run is
+ // charged to the signed-in person who asks for it, and their drawings stay theirs.
+ { title: 'Make things', links: [
+ { title: 'Make a typeface', url: abs('/fonts/make'), note: 'describe a typeface and the model draws the letters; POST /api/v1/typefaces {prompt, baseFamily?, name?} returns the outlines and the font files (/api/v1/typefaces/<id>/font.woff2 and .otf); redraw one letter with POST /api/v1/typefaces/<id>/glyph {char, note}; MCP tool openvibe_design_make_typeface' },
+ { title: 'Make a vector', url: abs('/vectors/make'), note: 'describe an icon, an illustration or a pattern and the model draws one clean SVG; POST /api/v1/vectors {prompt, kind} returns it; change it with POST /api/v1/vectors/<id>/edit {note}; the drawing as SVG or PNG at /vectors/made/<id>.svg and .png; MCP tool openvibe_design_make_vector' },
+ ] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
// The data an agent came for is a main section, not something to skip.
// The data an agent came for is a main section, not something to skip.
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -60,7 +60,19 @@ const TOOLS = [
@@ -60,7 +60,19 @@ const TOOLS = [
{ name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {
{ name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {
family: { type: 'string', minLength: 1, maxLength: 100 },
family: { type: 'string', minLength: 1, maxLength: 100 },
}) },
}) },
-].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
+ // The typefaces and vectors a person makes with the model (plan R1 item 8). These two write, so they are
+ // not marked read only: they need a signed-in person, and the run is charged to that person's allowance.
+ { name: 'openvibe_design_make_typeface', description: 'Draw a typeface from a description and return its glyph outlines, metrics and download links. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {
+ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'The typeface you want, in words ("rounded geometric sans for a kids app").' },
+ baseFamily: { type: 'string', maxLength: 80, description: 'An open font family to start from, as in the font library.' },
+ name: { type: 'string', maxLength: 60, description: 'The name of the face.' },
+ }) },
+ { name: 'openvibe_design_make_vector', description: 'Draw one clean SVG from a prompt and return it, sanitised and ready to use. Needs a signed-in person: the run is charged to their allowance.', inputSchema: schema(['prompt'], {
+ prompt: { type: 'string', minLength: 3, maxLength: 600, description: 'What to draw ("a paper plane folded from a single sheet").' },
+ kind: { type: 'string', enum: ['icon', 'illustration', 'pattern'], default: 'icon' },
+ note: { type: 'string', maxLength: 400, description: 'A note on the drawing, when you are changing one.' },
+ }) },
+].map(tool => ({ ...tool, annotations: { readOnlyHint: tool.name.indexOf('_make_') < 0, openWorldHint: false, destructiveHint: false } }));
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
@@ -73,8 +85,11 @@ function toolResult(value) {
@@ -73,8 +85,11 @@ function toolResult(value) {
};
};
}
}
-async function callTool(name, args, { catalog, fonts }) {
+async function callTool(name, args, ctx, req) {
if (!object(args)) return toolError('Arguments must be an object.');
if (!object(args)) return toolError('Arguments must be an object.');
+ const { catalog, fonts } = ctx;
+ // The tools that make something are charged to the person who calls them, so they need a signed-in caller.
+ const person = req && req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
switch (name) {
switch (name) {
case 'openvibe_design_list_themes': {
case 'openvibe_design_list_themes': {
if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) return toolError('query must be a string of at most 200 characters.');
if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) return toolError('query must be a string of at most 200 characters.');
@@ -148,6 +163,49 @@ async function callTool(name, args, { catalog, fonts }) {
@@ -148,6 +163,49 @@ async function callTool(name, args, { catalog, fonts }) {
const font = await fonts.get(args.family);
const font = await fonts.get(args.family);
return font ? toolResult(font) : toolError('No font family by that slug.');
return font ? toolResult(font) : toolError('No font family by that slug.');
}
}
+ case 'openvibe_design_make_typeface': {
+ if (!person) return toolError('Sign in on openvibe.design to make a typeface. The run is charged to your own allowance.');
+ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');
+ if (args.baseFamily !== undefined && (typeof args.baseFamily !== 'string' || args.baseFamily.length > 80)) return toolError('baseFamily is a string of at most 80 characters.');
+ if (args.name !== undefined && (typeof args.name !== 'string' || args.name.length > 60)) return toolError('name is a string of at most 60 characters.');
+ try {
+ const drawn = await ctx.maker.drawTypeface({ owner: person, prompt: args.prompt.trim(),
+ baseFamily: args.baseFamily || null, name: args.name || null });
+ const id = ctx.s.newId('typ');
+ const saved = await ctx.aiStore.saveTypeface({
+ id, owner: person, name: drawn.name, prompt: args.prompt.trim(),
+ baseFamily: args.baseFamily || null, styleNotes: args.prompt.trim(),
+ glyphs: drawn.glyphs, metrics: drawn.metrics, provider: drawn.provider, model: drawn.model,
+ license: drawn.license, state: 'ready',
+ });
+ return toolResult({ id: saved.id, name: saved.name, glyphs: saved.glyphs.length, metrics: saved.metrics,
+ model: saved.model, provider: saved.provider, license: saved.license,
+ page: `${BASE}/fonts/made/${encodeURIComponent(saved.id)}`,
+ download: { woff2: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.woff2`, otf: `${BASE}/api/v1/typefaces/${encodeURIComponent(saved.id)}/font.otf` } });
+ } catch (err) {
+ return toolError((err && err.detail) || 'The typeface could not be drawn.');
+ }
+ }
+ case 'openvibe_design_make_vector': {
+ if (!person) return toolError('Sign in on openvibe.design to make a vector. The run is charged to your own allowance.');
+ if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 600) return toolError('prompt is a nonempty string of at most 600 characters.');
+ if (args.kind !== undefined && !['icon', 'illustration', 'pattern'].includes(args.kind)) return toolError('kind is icon, illustration or pattern.');
+ if (args.note !== undefined && (typeof args.note !== 'string' || args.note.length > 400)) return toolError('note is a string of at most 400 characters.');
+ try {
+ const drawn = await ctx.maker.drawVector({ owner: person, prompt: args.prompt.trim(),
+ kind: args.kind || 'icon', note: args.note || null });
+ const id = ctx.s.newId('vec');
+ const saved = await ctx.aiStore.saveVector({ id, owner: person, prompt: args.prompt.trim(),
+ kind: drawn.kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model,
+ license: drawn.license, state: 'drawn' });
+ return toolResult({ id: saved.id, prompt: saved.prompt, kind: saved.kind, svg: saved.svg,
+ model: saved.model, provider: saved.provider, license: saved.license,
+ page: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}`,
+ download: { svg: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.svg`, png: `${BASE}/vectors/made/${encodeURIComponent(saved.id)}.png` } });
+ } catch (err) {
+ return toolError((err && err.detail) || 'The drawing could not be made.');
+ }
+ }
default:
default:
return null;
return null;
}
}
@@ -181,7 +239,7 @@ function createMcpRoutes(ctx) {
@@ -181,7 +239,7 @@ function createMcpRoutes(ctx) {
else if (msg.method === 'ping') replies.push(rpc(id, {}));
else if (msg.method === 'ping') replies.push(rpc(id, {}));
else if (msg.method === 'tools/list') replies.push(rpc(id, { tools: TOOLS }));
else if (msg.method === 'tools/list') replies.push(rpc(id, { tools: TOOLS }));
else if (msg.method === 'tools/call') {
else if (msg.method === 'tools/call') {
- const result = await callTool(params.name, params.arguments || {}, ctx);
+ const result = await callTool(params.name, params.arguments || {}, ctx, req);
replies.push(result ? rpc(id, result) : error(id, -32602, 'Unknown tool'));
replies.push(result ? rpc(id, result) : error(id, -32602, 'Unknown tool'));
} else replies.push(error(id, -32601, 'Method not found'));
} else replies.push(error(id, -32601, 'Method not found'));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,338 @@
@@ -0,0 +1,338 @@
+'use strict';
+/**
+ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
+ *
+ * GET /fonts/make the typeface maker: describe a face, start from an open one
+ * POST /fonts/make draw it
+ * GET /fonts/made/:id the specimen, the download and the redraw
+ * POST /fonts/made/:id/glyph redraw one glyph with a note
+ *
+ * GET /vectors/make the vector maker: one drawing from a prompt
+ * POST /vectors/make draw it
+ * GET /vectors/made/:id the drawing, the downloads and the notes
+ * POST /vectors/made/:id/edit regenerate it, or edit it with a note
+ *
+ * The API, for agents and for the pages themselves:
+ * POST /api/v1/typefaces draw a typeface
+ * GET /api/v1/typefaces/:id read it
+ * POST /api/v1/typefaces/:id/glyph redraw one glyph
+ * POST /api/v1/vectors draw a vector
+ * GET /api/v1/vectors/:id read it
+ * POST /api/v1/vectors/:id/edit regenerate it, or edit it with a note
+ *
+ * Signed in only: a run is charged to the person's own allowance, so there is always a person behind it.
+ * The outputs are the person's: nobody else's, and the site does not list them.
+ */
+const express = require('express');
+const { asyncRouter } = require('./router');
+const views = require('../views/typefaces');
+const { send } = require('../render/layout');
+const { writeAllowed } = require('./same-origin');
+
+/** The plain words a person gets when the model is not there or says no. */
+function plainError(err) {
+ if (!err) return 'Something went wrong.';
+ const status = Number(err.status) || 500;
+ const detail = String(err.detail || err.message || '').trim();
+ if (status === 503) return 'OpenVibe.AI is not configured for OpenVibe.Design right now. Try again later.';
+ if (status === 429) return detail || 'You have made all the runs you can today. More tomorrow.';
+ if (status === 401) return 'Sign in to make this on openvibe.design.';
+ if (status === 403) return detail || 'This belongs to somebody else.';
+ if (status === 404) return 'Not found.';
+ return detail && status < 500 ? detail : 'Something went wrong.';
+}
+
+function createTypefaceRoutes(ctx) {
+ const { config, ai, aiStore, maker, fonts, s } = ctx;
+ const store = aiStore;
+ const r = asyncRouter();
+ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
+ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });
+
+ /** A face of this person's, or a refusal in words. */
+ async function own(req, res, id) {
+ const owner = subjectOf(req);
+ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }
+ const row = await store.typeface(id, owner);
+ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }
+ return row;
+ }
+
+ // ── The maker ──
+ r.get('/fonts/make', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const owner = subjectOf(req);
+ const bases = await fonts.list().catch(() => []);
+ const mine = owner ? await store.typefacesOf(owner).catch(() => []) : [];
+ return page(res, 200, views.make({ bases, mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,
+ { cacheControl: 'private, no-store' });
+ });
+
+ r.post('/fonts/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a typeface on openvibe.design.');
+ try {
+ const prompt = String(req.body.prompt || '').trim();
+ const baseId = String(req.body.base || '').trim();
+ const name = String(req.body.name || '').trim().slice(0, 60);
+ if (!prompt) return res.status(422).type('text/plain').send('Describe the typeface you want.');
+ const base = baseId ? await fonts.get(baseId).catch(() => null) : null;
+ const id = s.newId('typ');
+ const drawn = await maker.drawTypeface({
+ owner, prompt,
+ baseFamily: base ? base.family : null,
+ baseNote: base ? 'Keep the letterforms of ' + base.family + ' (' + base.category + '), and change the style to the description above.' : null,
+ name: name || null,
+ });
+ await store.saveTypeface({
+ id, owner, name: drawn.name, prompt,
+ baseFamily: base ? base.family : null,
+ styleNotes: prompt,
+ glyphs: drawn.glyphs,
+ metrics: drawn.metrics,
+ provider: drawn.provider, model: drawn.model,
+ license: drawn.license, state: 'ready',
+ });
+ return res.redirect(303, '/fonts/made/' + encodeURIComponent(id));
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(plainError(err));
+ }
+ });
+
+ // ── The specimen ──
+ r.get('/fonts/made/:id', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const row = await own(req, res, req.params.id);
+ if (!row) return undefined;
+ const compiled = await require('../typefaces/compile').compile({
+ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',
+ }).catch(() => null);
+ return page(res, 200, views.specimen({ face: row, compiled, chars: maker.CORE }), req.viewer,
+ { cacheControl: 'private, no-store' });
+ });
+
+ r.post('/fonts/made/:id/glyph', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).type('text/plain').send('Sign in to redraw this on openvibe.design.');
+ const row = await store.typeface(req.params.id, owner);
+ if (!row) return res.status(404).type('text/plain').send('Not found.');
+ try {
+ const char = String(req.body.char || '').slice(0, 4);
+ const note = String(req.body.note || '').trim().slice(0, 400);
+ if (!char || !note) return res.status(422).type('text/plain').send('Say which letter and what to change.');
+ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });
+ if (!redrawn) return res.status(502).type('text/plain').send('The model did not draw that letter. Try again.');
+ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);
+ await store.saveTypeface({
+ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,
+ baseFamily: row.baseFamily, styleNotes: row.styleNotes,
+ glyphs, metrics: { ...row.metrics, glyphs: glyphs.length },
+ provider: row.provider, model: redrawn.model || row.model,
+ license: row.license, state: 'ready', note: note,
+ });
+ return res.redirect(303, '/fonts/made/' + encodeURIComponent(row.id));
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(plainError(err));
+ }
+ });
+
+ // ── The API ──
+ r.post('/api/v1/typefaces', express.json({ limit: '32kb' }), async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to make a typeface on openvibe.design.' });
+ try {
+ const prompt = String((req.body && req.body.prompt) || '').trim();
+ if (!prompt) return res.status(422).json({ error: 'Describe the typeface you want.' });
+ const drawn = await maker.drawTypeface({ owner, prompt, baseFamily: (req.body && req.body.baseFamily) || null, name: (req.body && req.body.name) || null });
+ const id = s.newId('typ');
+ const saved = await store.saveTypeface({
+ id, owner, name: drawn.name, prompt, baseFamily: (req.body && req.body.baseFamily) || null,
+ styleNotes: prompt, glyphs: drawn.glyphs, metrics: drawn.metrics,
+ provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'ready',
+ });
+ return res.status(201).json(saved);
+ } catch (err) {
+ return res.status(err.status || 500).json({ error: plainError(err) });
+ }
+ });
+
+ r.get('/api/v1/typefaces/:id', async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });
+ const row = await store.typeface(req.params.id, owner);
+ if (!row) return res.status(404).json({ error: 'Not found.' });
+ return res.json(row);
+ });
+
+ // The font files. They are compiled from the stored outlines on the way out, so a redrawn letter
+ // is in the next download without anything being rebuilt by hand.
+ for (const [ext, type] of [['woff2', 'font/woff2'], ['otf', 'font/otf']]) {
+ r.get('/api/v1/typefaces/:id/font.' + ext, async (req, res) => {
+ const row = await own(req, res, req.params.id);
+ if (!row) return undefined;
+ const compiled = await require('../typefaces/compile').compile({
+ familyName: row.name, glyphs: row.glyphs, license: row.license, model: row.model, designer: 'Made on OpenVibe.Design',
+ }).catch(() => null);
+ if (!compiled || !compiled.ok) return res.status(502).type('text/plain').send('The font could not be compiled.');
+ const file = ext === 'woff2' ? compiled.woff2 : compiled.otf;
+ return res.set('Cache-Control', 'private, no-store')
+ .set('Content-Disposition', 'attachment; filename="' + row.name.replace(/[^A-Za-z0-9 .'-]/g, '') + '.' + ext + '"')
+ .type(type).send(file);
+ });
+ }
+
+ r.post('/api/v1/typefaces/:id/glyph', express.json({ limit: '8kb' }), async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to redraw this on openvibe.design.' });
+ const row = await store.typeface(req.params.id, owner);
+ if (!row) return res.status(404).json({ error: 'Not found.' });
+ try {
+ const char = String((req.body && req.body.char) || '').slice(0, 4);
+ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);
+ if (!char || !note) return res.status(422).json({ error: 'Say which letter and what to change.' });
+ const redrawn = await maker.redrawGlyph({ owner, face: row, char, note });
+ if (!redrawn) return res.status(502).json({ error: 'The model did not draw that letter. Try again.' });
+ const glyphs = row.glyphs.filter((g) => g.char !== char).concat([redrawn]);
+ const saved = await store.saveTypeface({
+ id: row.id, owner: row.owner, name: row.name, prompt: row.prompt,
+ baseFamily: row.baseFamily, styleNotes: row.styleNotes, glyphs,
+ metrics: { ...row.metrics, glyphs: glyphs.length },
+ provider: row.provider, model: redrawn.model || row.model, license: row.license, state: 'ready', note,
+ });
+ return res.json(saved);
+ } catch (err) {
+ return res.status(err.status || 500).json({ error: plainError(err) });
+ }
+ });
+
+ return r;
+}
+
+function createVectorRoutes(ctx) {
+ const { config, ai, aiStore, maker, s, sharing } = ctx;
+ const store = aiStore;
+ const r = asyncRouter();
+ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
+ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });
+
+ async function own(req, res, id) {
+ const owner = subjectOf(req);
+ if (!owner) { res.status(401).type('text/plain').send('Sign in to open this on openvibe.design.'); return null; }
+ const row = await store.vector(id, owner);
+ if (!row) { res.status(404).type('text/plain').send('Not found.'); return null; }
+ return row;
+ }
+
+ r.get('/vectors/make', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const owner = subjectOf(req);
+ const mine = owner ? await store.vectorsOf(owner).catch(() => []) : [];
+ return page(res, 200, views.vectorMake({ mine, signedIn: Boolean(owner), left: owner ? await maker.left(owner) : 0, perDay: maker.perDay }), req.viewer,
+ { cacheControl: 'private, no-store' });
+ });
+
+ r.post('/vectors/make', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).type('text/plain').send('Sign in to make a vector on openvibe.design.');
+ try {
+ const prompt = String(req.body.prompt || '').trim();
+ const kind = ['icon', 'illustration', 'pattern'].includes(req.body.kind) ? req.body.kind : 'icon';
+ if (!prompt) return res.status(422).type('text/plain').send('Describe the drawing you want.');
+ const drawn = await maker.drawVector({ owner, prompt, kind });
+ const id = s.newId('vec');
+ await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });
+ return res.redirect(303, '/vectors/made/' + encodeURIComponent(id));
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(plainError(err));
+ }
+ });
+
+ // The drawing itself, as a file. These come before the page, so /vectors/made/<id>.svg is the file
+ // and not a drawing called "<id>.svg".
+ r.get('/vectors/made/:id.svg', async (req, res) => {
+ const row = await own(req, res, req.params.id);
+ if (!row) return undefined;
+ // A saved drawing opened on its own is a document: no script, no fetches, whatever the sanitiser missed.
+ return res.set('Cache-Control', 'private, no-store').set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
+ .set('X-Content-Type-Options', 'nosniff').type('image/svg+xml').send(row.svg);
+ });
+ r.get('/vectors/made/:id.png', async (req, res) => {
+ const row = await own(req, res, req.params.id);
+ if (!row) return undefined;
+ const png = await maker.png(row.svg, Number(req.query.size) || 512).catch(() => null);
+ if (!png) return res.status(502).type('text/plain').send('The drawing could not be rendered as a PNG.');
+ return res.set('Cache-Control', 'private, no-store').type('image/png').send(png);
+ });
+
+ r.get('/vectors/made/:id', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const row = await own(req, res, req.params.id);
+ if (!row) return undefined;
+ return page(res, 200, views.vector({ vector: row }), req.viewer, { cacheControl: 'private, no-store' });
+ });
+
+ r.post('/vectors/made/:id/edit', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('Open this form on openvibe.design.');
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).type('text/plain').send('Sign in to change this on openvibe.design.');
+ const row = await store.vector(req.params.id, owner);
+ if (!row) return res.status(404).type('text/plain').send('Not found.');
+ try {
+ const note = String(req.body.note || '').trim().slice(0, 400);
+ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });
+ await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,
+ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });
+ return res.redirect(303, '/vectors/made/' + encodeURIComponent(row.id));
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(plainError(err));
+ }
+ });
+
+ // ── The API ──
+ r.post('/api/v1/vectors', express.json({ limit: '32kb' }), async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to make a vector on openvibe.design.' });
+ try {
+ const prompt = String((req.body && req.body.prompt) || '').trim();
+ const kind = ['icon', 'illustration', 'pattern'].includes(req.body && req.body.kind) ? req.body.kind : 'icon';
+ if (!prompt) return res.status(422).json({ error: 'Describe the drawing you want.' });
+ const drawn = await maker.drawVector({ owner, prompt, kind });
+ const id = s.newId('vec');
+ const saved = await store.saveVector({ id, owner, prompt, kind, svg: drawn.svg, provider: drawn.provider, model: drawn.model, license: drawn.license, state: 'drawn' });
+ return res.status(201).json(saved);
+ } catch (err) {
+ return res.status(err.status || 500).json({ error: plainError(err) });
+ }
+ });
+
+ r.get('/api/v1/vectors/:id', async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to open this on openvibe.design.' });
+ const row = await store.vector(req.params.id, owner);
+ if (!row) return res.status(404).json({ error: 'Not found.' });
+ return res.json(row);
+ });
+
+ r.post('/api/v1/vectors/:id/edit', express.json({ limit: '8kb' }), async (req, res) => {
+ const owner = subjectOf(req);
+ if (!owner) return res.status(401).json({ error: 'Sign in to change this on openvibe.design.' });
+ const row = await store.vector(req.params.id, owner);
+ if (!row) return res.status(404).json({ error: 'Not found.' });
+ try {
+ const note = String((req.body && req.body.note) || '').trim().slice(0, 400);
+ const drawn = await maker.drawVector({ owner, prompt: row.prompt, kind: row.kind, note: note || null });
+ const saved = await store.saveVector({ id: row.id, owner: row.owner, prompt: row.prompt, kind: row.kind, svg: drawn.svg,
+ provider: drawn.provider, model: drawn.model, license: row.license, state: 'drawn', note: note || null });
+ return res.json(saved);
+ } catch (err) {
+ return res.status(err.status || 500).json({ error: plainError(err) });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createTypefaceRoutes, createVectorRoutes, plainError };
@@ -20,6 +20,13 @@ const TABLES = [
@@ -20,6 +20,13 @@ const TABLES = [
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
{ table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },
{ table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },
{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
+ // The typefaces a person made with the model (/fonts/make): the outlines the model drew and the notes they
+ // gave. The font files are compiled from the outlines on request, so deleting the rows deletes the fonts.
+ { table: 'ai_typefaces', subject: 'owner', file: 'ai_typefaces.json', order: 'created_at', columns: ['id', 'name', 'prompt', 'base_family', 'style_notes', 'glyphs', 'metrics', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
+ // The drawings a person made with the model (/vectors/make). A drawing is theirs; nobody else's is exported.
+ { table: 'ai_vectors', subject: 'owner', file: 'ai_vectors.json', order: 'created_at', columns: ['id', 'prompt', 'kind', 'svg', 'provider', 'model', 'license', 'state', 'note', 'created_at', 'updated_at'], erase: 'delete' },
+ // How many model runs a person made and when. It is their own usage; it goes with the account.
+ { table: 'ai_make_runs', subject: 'owner', file: 'ai_make_runs.json', order: 'created_at', columns: ['id', 'kind', 'day', 'created_at'], erase: 'delete' },
];
];
async function extraExport(db, subject) {
async function extraExport(db, subject) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * A typeface, compiled from the glyphs the model drew (plan R1 item 8: AI typefaces).
+ *
+ * The glyphs arrive as outlines in a 0..1000 em box with the baseline at y = 0 and y growing up: font
+ * coordinates already. The compiler normalises the metrics (one ascender, one descender, one set of side
+ * bearings, one advance width per glyph), builds the font with opentype.js and writes it out.
+ *
+ * compile({ familyName, styleName, glyphs, license }) -> { ok: true, font, woff2, otf } | { ok: false, why }
+ *
+ * The glyph list is a list of { char, d, advance }. The .notdef glyph is always there, so a font that is
+ * missing a letter shows a box rather than nothing.
+ *
+ * The two files are the same font: the WOFF2 for the web (compressed with Google's woff2 encoder, the same one
+ * browsers use) and the OTF for anything that wants the outlines. The licence the person chose goes into the
+ * font's name table, and the model that drew it goes in too, so a font says where it came from.
+ */
+const opentype = require('opentype.js');
+const wawoff2 = require('wawoff2');
+const glyph = require('./glyph');
+const path = require('./path');
+
+/** The characters a face carries: Latin letters, digits and the punctuation a text needs. */
+const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?'\"()-/&@#%+=*";
+
+/** A name a font file may carry: no path separators, nothing a filesystem would mind. */
+const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 .'-]{0,60}$/;
+
+function fail(why) { return { ok: false, why }; }
+
+/**
+ * Normalise the metrics of a set of glyphs. One ascender and one descender for the whole face; the advance width
+ * of each glyph is its ink plus the same side bearing on each side, so the letters sit on one rhythm.
+ */
+function metrics(glyphs) {
+ const side = 60; // the side bearing, in font units
+ let top = 0, bottom = 0;
+ for (const g of glyphs) {
+ top = Math.max(top, g.bounds.y2);
+ bottom = Math.min(bottom, g.bounds.y1);
+ }
+ // The ascender and the descender are the face's own, rounded to a tidy step.
+ const ascender = Math.min(glyph.ASCENDER, Math.max(Math.round(top / 50) * 50, 400));
+ const descender = Math.max(glyph.DESCENDER, Math.min(Math.round(bottom / 50) * 50, -100));
+ const out = [];
+ for (const g of glyphs) {
+ const ink = g.bounds.x2 - g.bounds.x1;
+ const advance = Math.max(Math.round(ink) + side * 2, 120);
+ out.push({
+ char: g.char,
+ d: g.d,
+ advance,
+ // The ink is centred in the advance width, so every letter has the same breathing room.
+ left: Math.round((advance - ink) / 2) - g.bounds.x1,
+ });
+ }
+ return { ascender, descender, glyphs: out };
+}
+
+/**
+ * Compile the face. The outlines are checked once more here (the server never trusts a stored glyph), then the
+ * font is built and written as a WOFF2 and an OTF.
+ */
+async function compile({ familyName, styleName = 'Regular', glyphs, license = 'OFL-1.1', model = null, designer = null } = {}) {
+ const name = String(familyName || '').trim();
+ if (!FAMILY.test(name)) return fail('a font needs a name of letters, digits, spaces or dashes');
+ if (!Array.isArray(glyphs) || !glyphs.length) return fail('the face has no glyphs to compile');
+
+ // Every glyph is read again: a stored outline is data, and data is read before it is used.
+ const checked = [];
+ const seen = new Set();
+ for (const g of glyphs) {
+ const char = String(g.char == null ? '' : g.char);
+ if (!char || seen.has(char)) continue;
+ const r = glyph.clean(g.d);
+ if (!r.ok) continue;
+ seen.add(char);
+ checked.push({ char, ...r.glyph });
+ }
+ if (!checked.length) return fail('none of the glyphs could be read');
+
+ const m = metrics(checked);
+ const opentypeGlyphs = [];
+ // .notdef: a box, so a missing letter shows an empty one.
+ const box = new opentype.Path();
+ box.moveTo(80, 0); box.lineTo(420, 0); box.lineTo(420, 700); box.lineTo(80, 700); box.close();
+ opentypeGlyphs.push(new opentype.Glyph({ name: '.notdef', unicode: 0, advanceWidth: 500, path: box }));
+ // The space, always.
+ opentypeGlyphs.push(new opentype.Glyph({ name: 'space', unicode: 32, advanceWidth: 260, path: new opentype.Path() }));
+
+ for (const g of m.glyphs) {
+ const segments = path.parse(g.d);
+ if (!segments.ok) continue;
+ const p = path.pathOf(segments.segments);
+ // The outline is moved so the ink sits where the advance width says it does.
+ const moved = new opentype.Path();
+ for (const c of p.commands) {
+ const cmd = { ...c };
+ if (cmd.x !== undefined) cmd.x += g.left;
+ if (cmd.x1 !== undefined) cmd.x1 += g.left;
+ if (cmd.x2 !== undefined) cmd.x2 += g.left;
+ moved.commands.push(cmd);
+ }
+ const cp = g.char.codePointAt(0);
+ opentypeGlyphs.push(new opentype.Glyph({
+ name: 'uni' + cp.toString(16).toUpperCase().padStart(4, '0'),
+ unicode: cp,
+ advanceWidth: g.advance,
+ path: moved,
+ }));
+ }
+
+ // The name table says who made it and under what licence: a font that travels says where it came from.
+ // opentype.js writes one table per platform, so these go in as options rather than as edits afterwards.
+ const maker = String(designer || 'Made with OpenVibe.Design').slice(0, 60);
+ const font = new opentype.Font({
+ familyName: name,
+ styleName,
+ unitsPerEm: glyph.UPEM,
+ ascender: m.ascender,
+ descender: m.descender,
+ glyphs: opentypeGlyphs,
+ designer: maker,
+ license: String(license || 'OFL-1.1').slice(0, 60),
+ copyright: maker,
+ description: model ? 'Drawn by ' + String(model).slice(0, 60) + ' on OpenVibe.Design' : 'Made on OpenVibe.Design',
+ });
+ font.outlinesFormat = 'cff';
+
+ const otf = Buffer.from(font.toArrayBuffer());
+ const woff2 = Buffer.from(await wawoff2.compress(otf));
+ return {
+ ok: true,
+ font,
+ otf,
+ woff2,
+ metrics: { ascender: m.ascender, descender: m.descender, unitsPerEm: glyph.UPEM, glyphs: checked.length },
+ glyphs: m.glyphs,
+ };
+}
+
+module.exports = { compile, metrics, CHARSET, FAMILY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * A glyph outline, checked before it may become part of a font (plan R1 item 8: AI typefaces).
+ *
+ * The model writes outlines as SVG path data. Whatever comes back is hostile input until it has been read: the
+ * checks here are the ones a font needs, and nothing else is allowed through.
+ *
+ * clean(data) → { ok: true, glyph } | { ok: false, why }
+ *
+ * A glyph must be:
+ * - ONE path. A letter may have several contours (the hole in an "o"), but they all live in one path element.
+ * - CLOSED. Every contour ends with Z, so the outline has no open end to leak fill or show a seam.
+ * - INSIDE the em box. The outline may not leave the 0..upem box the font works in, with a small margin for
+ * the overshoot a curve needs.
+ * - PLAIN. Numbers and path commands only: no script, no style, no url(), no external reference of any kind.
+ *
+ * The result is the same outline, normalised: absolute commands, two decimals, and the metrics a font needs
+ * (the advance width and the bounds).
+ */
+const path = require('./path');
+
+/** The em box every outline lives in. The font's own units per em. */
+const UPEM = 1000;
+/** The baseline sits at 0; the ascenders reach 800 and the descenders fall to -200. */
+const ASCENDER = 800;
+const DESCENDER = -200;
+/** A curve may overshoot the em box by this much, or it would look cut off. */
+const OVERSHOOT = 20;
+
+/** What a model may send for one glyph: path data, or a whole <path> element. */
+const MAX_BYTES = 8000;
+
+/** Path data is commands and numbers only. Nothing else may appear in it. */
+const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s+\-eE]*$/;
+
+/**
+ * The markup a model may wrap the path in, and what may be inside it. A <path> element with a d attribute, and
+ * nothing else: no script, no style, no image, no use, no event handler, no external reference.
+ */
+const ELEMENT = /<\s*([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)>/;
+const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
+const DANGEROUS = /^(?:on|xmlns:xlink|xlink|href|src|formaction|style|srcset)$/i;
+
+/** Read one path element out of the model's answer. */
+function extract(data) {
+ const raw = String(data == null ? '' : data).trim();
+ if (!raw) return { ok: false, why: 'the glyph is empty' };
+ if (raw.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };
+ // A code fence or a sentence around the path is stripped, so an answer that says "here it is" still works.
+ const fenced = /\x60\x60\x60[a-z]*\s*([\s\S]*?)\x60\x60\x60/i.exec(raw);
+ const body = (fenced ? fenced[1] : raw).trim();
+ if (!body) return { ok: false, why: 'the glyph is empty' };
+
+ // Refuse anything that is not a path element before looking for one: a script or a style is never acceptable.
+ if (/<\s*(script|style|foreignObject|iframe|object|embed|image|use|a)\b/i.test(body))
+ return { ok: false, why: 'a glyph is a path, not a script or a reference' };
+ if (/javascript:|data:text\/html|<!\[CDATA\[|&#[x]?[0-9a-f]/i.test(body))
+ return { ok: false, why: 'the glyph carries something a font must not carry' };
+
+ // Bare path data (no element) is fine: that is what most models answer with.
+ if (!body.includes('<')) return { ok: true, d: body };
+
+ const match = ELEMENT.exec(body);
+ if (!match) return { ok: false, why: 'the glyph is not a path element' };
+ const name = match[1].toLowerCase();
+ if (name !== 'path') return { ok: false, why: 'a glyph is one <path> element, not a <' + name + '>' };
+ // Nothing may follow the path element.
+ const rest = body.slice(match.index + match[0].length).replace(/<\s*\/\s*path\s*>/gi, '').trim();
+ if (rest) return { ok: false, why: 'a glyph is one path and nothing else' };
+
+ let d = null;
+ for (const a of match[2].matchAll(ATTR)) {
+ const key = a[1].toLowerCase();
+ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
+ if (DANGEROUS.test(key)) return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };
+ if (key === 'd') { d = value; continue; }
+ // fill, fill-rule and transform are the only harmless extras; a font sets its own colour anyway.
+ if (key === 'fill' || key === 'fill-rule' || key === 'transform') continue;
+ return { ok: false, why: 'a glyph path carries no ' + key + ' attribute' };
+ }
+ if (!d) return { ok: false, why: 'the path has no d attribute' };
+ return { ok: true, d };
+}
+
+/**
+ * Check one glyph. Returns the normalised outline and the metrics a font needs, or a plain reason in words.
+ */
+function clean(data) {
+ const found = extract(data);
+ if (!found.ok) return found;
+ const d = String(found.d || '').trim();
+ if (!d) return { ok: false, why: 'the path has no d attribute' };
+ if (d.length > MAX_BYTES) return { ok: false, why: 'the glyph is too long' };
+ if (!PATH_DATA.test(d)) return { ok: false, why: 'the path data has something a path cannot hold' };
+
+ const parsed = path.parse(d);
+ if (!parsed.ok) return { ok: false, why: parsed.why };
+ const segments = parsed.segments;
+
+ // One path, several contours allowed (the hole in an "o"), but every contour closed.
+ const contours = path.subpaths(segments);
+ if (!contours.length) return { ok: false, why: 'the path draws nothing' };
+ if (contours.length > 4) return { ok: false, why: 'a letter is not that many separate shapes' };
+ if (!path.closed(segments)) return { ok: false, why: 'the outline is not closed' };
+
+ const box = path.bbox(segments);
+ if (!box) return { ok: false, why: 'the path draws nothing' };
+ if (box.x1 < -OVERSHOOT || box.y1 < DESCENDER - OVERSHOOT || box.x2 > UPEM + OVERSHOOT || box.y2 > ASCENDER + OVERSHOOT)
+ return { ok: false, why: 'the outline leaves the em box' };
+ // A letter has some size: a mark smaller than a fiftieth of the em reads as a speck, not a letter.
+ if (box.x2 - box.x1 < 20 || box.y2 - box.y1 < 20) return { ok: false, why: 'the outline is too small to read' };
+
+ // The outline is placed as the model drew it: the baseline is y = 0, y grows up.
+ return {
+ ok: true,
+ glyph: {
+ d: path.toData(segments),
+ advance: Math.round(box.x2 - box.x1) + 120, // the ink plus a side bearing on each side
+ bounds: { x1: Math.round(box.x1), y1: Math.round(box.y1), x2: Math.round(box.x2), y2: Math.round(box.y2) },
+ },
+ };
+}
+
+module.exports = { clean, extract, UPEM, ASCENDER, DESCENDER, OVERSHOOT, MAX_BYTES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,338 @@
@@ -0,0 +1,338 @@
+'use strict';
+/**
+ * SVG path data, read for a typeface (plan R1 item 8: AI typefaces).
+ *
+ * One parser for the whole service: the model writes outlines as path data, and the server has to know three things
+ * about each one before it may become a glyph — is it a single closed contour inside the em box, how big is it, and
+ * what commands does it use. This module answers all three and hands back a clean, absolute command list.
+ *
+ * Absolute commands only (M L C Q Z), because a font's outlines are absolute: the model may write relative commands
+ * or arcs, and both are converted here. Arcs become cubic curves (the same curve, four segments at most), because a
+ * font's outlines are cubics and the compiler does not read arcs.
+ *
+ * parse(d) → { ok: true, segments, bbox, subpaths } | { ok: false, why }
+ * bbox(segments) → { x1, y1, x2, y2 } (the true bounds, curves included)
+ * closed(segments)→ true when every contour ends with Z
+ * pathOf(segments)→ an opentype.js Path
+ * toData(segments)→ normalised path data (absolute, two decimals)
+ */
+
+const NUM = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/;
+const COMMANDS = 'MmLlHhVvCcSsQqTtAaZz';
+const EPS = 1e-6;
+
+/** Round to two decimals and drop a trailing zero: the same outline, written once. */
+function round(n) {
+ const v = Math.round(Number(n) * 100) / 100;
+ return Object.is(v, -0) ? 0 : v;
+}
+
+function numText(n) {
+ return String(round(n));
+}
+
+/** The scanner: numbers and command letters, with the separators between them. */
+function scan(d) {
+ const src = String(d || '');
+ const out = [];
+ let i = 0;
+ while (i < src.length) {
+ const c = src[i];
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === ',') { i++; continue; }
+ if (COMMANDS.includes(c)) { out.push({ cmd: c }); i++; continue; }
+ const m = NUM.exec(src.slice(i));
+ if (!m || !m[0]) return { ok: false, why: 'unexpected character "' + c + '" in the path data' };
+ out.push({ num: Number(m[0]) });
+ i += m[0].length;
+ }
+ return { ok: true, tokens: out };
+}
+
+/** The points of one cubic, and where it actually turns (the curve's true bounds). */
+function cubicExtrema(p0, p1, p2, p3) {
+ const ts = [0, 1];
+ // B'(t) = 3(1-t)^2 (p1-p0) + 6(1-t)t (p2-p1) + 3t^2 (p3-p2) → a quadratic in t.
+ const a = -p0 + 3 * p1 - 3 * p2 + p3;
+ const b = 2 * (p0 - 2 * p1 + p2);
+ const c = p1 - p0;
+ const push = (t) => { if (t > EPS && t < 1 - EPS) ts.push(t); };
+ if (Math.abs(a) < EPS) {
+ if (Math.abs(b) > EPS) push(-c / b);
+ } else {
+ const disc = b * b - 4 * a * c;
+ if (disc >= 0) {
+ const sq = Math.sqrt(disc);
+ push((-b + sq) / (2 * a));
+ push((-b - sq) / (2 * a));
+ }
+ }
+ const at = (t) => {
+ const u = 1 - t;
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
+ };
+ return ts.map(at);
+}
+
+function quadExtrema(p0, p1, p2) {
+ const ts = [0, 1];
+ const den = p0 - 2 * p1 + p2;
+ if (Math.abs(den) > EPS) {
+ const t = (p0 - p1) / den;
+ if (t > EPS && t < 1 - EPS) ts.push(t);
+ }
+ const at = (t) => (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2;
+ return ts.map(at);
+}
+
+/** An elliptical arc as cubic segments (the endpoint-to-centre form, split into quarter-turns). */
+function arcToCubics(x1, y1, rx, ry, phiDeg, largeArc, sweep, x2, y2) {
+ if (rx === 0 || ry === 0) return [[x1, y1, x2, y2, x2, y2]];
+ const phi = (phiDeg % 360) * Math.PI / 180;
+ const cosP = Math.cos(phi), sinP = Math.sin(phi);
+ const dx2 = (x1 - x2) / 2, dy2 = (y1 - y2) / 2;
+ const x1p = cosP * dx2 + sinP * dy2;
+ const y1p = -sinP * dx2 + cosP * dy2;
+ let rxa = Math.abs(rx), rya = Math.abs(ry);
+ const lambda = (x1p * x1p) / (rxa * rxa) + (y1p * y1p) / (rya * rya);
+ if (lambda > 1) { const s = Math.sqrt(lambda); rxa *= s; rya *= s; }
+ const sign = largeArc === sweep ? -1 : 1;
+ const num = rxa * rxa * rya * rya - rxa * rxa * y1p * y1p - rya * rya * x1p * x1p;
+ const den = rxa * rxa * y1p * y1p + rya * rya * x1p * x1p;
+ const coef = sign * Math.sqrt(Math.max(0, num / (den || 1)));
+ const cxp = coef * (rxa * y1p / rya);
+ const cyp = coef * -(rya * x1p / rxa);
+ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
+ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
+ const angle = (ux, uy, vx, vy) => {
+ const dot = ux * vx + uy * vy;
+ const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) || 1;
+ let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
+ if (ux * vy - uy * vx < 0) a = -a;
+ return a;
+ };
+ const ux = (x1p - cxp) / rxa, uy = (y1p - cyp) / rya;
+ const vx = (-x1p - cxp) / rxa, vy = (-y1p - cyp) / rya;
+ const theta1 = angle(1, 0, ux, uy);
+ let dTheta = angle(ux, uy, vx, vy);
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
+ else if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
+ const parts = Math.ceil(Math.abs(dTheta) / (Math.PI / 2)) || 1;
+ const delta = dTheta / parts;
+ const k = (4 / 3) * Math.tan(delta / 4);
+ const out = [];
+ let th = theta1;
+ let px = x1, py = y1;
+ for (let i = 0; i < parts; i++) {
+ const th2 = th + delta;
+ const cos1 = Math.cos(th), sin1 = Math.sin(th), cos2 = Math.cos(th2), sin2 = Math.sin(th2);
+ const e1x = cx + rxa * cosP * cos1 - rya * sinP * sin1;
+ const e1y = cy + rxa * sinP * cos1 + rya * cosP * sin1;
+ const e2x = cx + rxa * cosP * cos2 - rya * sinP * sin2;
+ const e2y = cy + rxa * sinP * cos2 + rya * cosP * sin2;
+ const d1x = -rxa * cosP * sin1 - rya * sinP * cos1;
+ const d1y = -rxa * sinP * sin1 + rya * cosP * cos1;
+ const d2x = -rxa * cosP * sin2 - rya * sinP * cos2;
+ const d2y = -rxa * sinP * sin2 + rya * cosP * cos2;
+ out.push([px + k * d1x, py + k * d1y, e2x - k * d2x, e2y - k * d2y, e2x, e2y]);
+ px = e2x; py = e2y; th = th2;
+ }
+ return out;
+}
+
+/**
+ * Read the path data. Every command is made absolute; S and T take the previous control point into account; an arc
+ * becomes cubics. The result is a list of { type, args } with types M, L, C, Q and Z.
+ */
+function parse(d) {
+ const scanned = scan(d);
+ if (!scanned.ok) return scanned;
+ const tokens = scanned.tokens;
+ const segments = [];
+ let x = 0, y = 0, startX = 0, startY = 0;
+ let lastCtrl = null, lastQuad = null, cmd = null;
+ let i = 0;
+ const need = (n) => {
+ const vals = [];
+ for (let k = 0; k < n; k++) {
+ const t = tokens[i++];
+ if (!t || t.num === undefined) return null;
+ vals.push(t.num);
+ }
+ return vals;
+ };
+ while (i < tokens.length) {
+ const t = tokens[i];
+ if (t.cmd) { cmd = t.cmd; i++; }
+ else if (!cmd) return { ok: false, why: 'the path data starts with a number, not a command' };
+ else if (cmd === 'M') cmd = 'L'; // extra pairs after a move are lines
+ else if (cmd === 'm') cmd = 'l';
+ const rel = cmd === cmd.toLowerCase();
+ const abs = cmd.toUpperCase();
+ if (abs === 'Z') {
+ // The command letter was already consumed above; Z never repeats, so nothing is read here.
+ segments.push({ type: 'Z', args: [] });
+ x = startX; y = startY; lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ if (abs === 'M') {
+ const v = need(2); if (!v) return { ok: false, why: 'a move needs two numbers' };
+ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];
+ startX = x; startY = y;
+ segments.push({ type: 'M', args: [x, y] });
+ lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ if (abs === 'L') {
+ const v = need(2); if (!v) return { ok: false, why: 'a line needs two numbers' };
+ x = rel ? x + v[0] : v[0]; y = rel ? y + v[1] : v[1];
+ segments.push({ type: 'L', args: [x, y] });
+ lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ if (abs === 'H') {
+ const v = need(1); if (!v) return { ok: false, why: 'a horizontal line needs one number' };
+ x = rel ? x + v[0] : v[0];
+ segments.push({ type: 'L', args: [x, y] });
+ lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ if (abs === 'V') {
+ const v = need(1); if (!v) return { ok: false, why: 'a vertical line needs one number' };
+ y = rel ? y + v[0] : v[0];
+ segments.push({ type: 'L', args: [x, y] });
+ lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ if (abs === 'C') {
+ const v = need(6); if (!v) return { ok: false, why: 'a curve needs six numbers' };
+ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3], x + v[4], y + v[5]] : v;
+ segments.push({ type: 'C', args: p });
+ lastCtrl = [p[2], p[3]];
+ x = p[4]; y = p[5];
+ lastQuad = null;
+ continue;
+ }
+ if (abs === 'S') {
+ const v = need(4); if (!v) return { ok: false, why: 'a smooth curve needs four numbers' };
+ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;
+ const c1 = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y];
+ segments.push({ type: 'C', args: [c1[0], c1[1], p[0], p[1], p[2], p[3]] });
+ lastCtrl = [p[0], p[1]];
+ x = p[2]; y = p[3];
+ lastQuad = null;
+ continue;
+ }
+ if (abs === 'Q') {
+ const v = need(4); if (!v) return { ok: false, why: 'a quadratic curve needs four numbers' };
+ const p = rel ? [x + v[0], y + v[1], x + v[2], y + v[3]] : v;
+ segments.push({ type: 'Q', args: p });
+ lastQuad = [p[0], p[1]];
+ x = p[2]; y = p[3];
+ lastCtrl = null;
+ continue;
+ }
+ if (abs === 'T') {
+ const v = need(2); if (!v) return { ok: false, why: 'a smooth quadratic needs two numbers' };
+ const p = rel ? [x + v[0], y + v[1]] : v;
+ const c1 = lastQuad ? [2 * x - lastQuad[0], 2 * y - lastQuad[1]] : [x, y];
+ segments.push({ type: 'Q', args: [c1[0], c1[1], p[0], p[1]] });
+ lastQuad = c1;
+ x = p[0]; y = p[1];
+ lastCtrl = null;
+ continue;
+ }
+ if (abs === 'A') {
+ const v = need(7); if (!v) return { ok: false, why: 'an arc needs seven numbers' };
+ const [rx, ry, phi, laf, sf, ex, ey] = v;
+ const tx = rel ? x + ex : ex, ty = rel ? y + ey : ey;
+ const parts = arcToCubics(x, y, rx, ry, phi, laf !== 0, sf !== 0, tx, ty);
+ for (const p of parts) segments.push({ type: 'C', args: p });
+ x = tx; y = ty;
+ lastCtrl = null; lastQuad = null;
+ continue;
+ }
+ return { ok: false, why: 'the command "' + cmd + '" is not a path command' };
+ }
+ if (!segments.length) return { ok: false, why: 'the path data is empty' };
+ return { ok: true, segments };
+}
+
+/** The true bounds of the outline, curves included. */
+function bbox(segments) {
+ let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
+ const add = (x, y) => { if (x < x1) x1 = x; if (y < y1) y1 = y; if (x > x2) x2 = x; if (y > y2) y2 = y; };
+ let cx = 0, cy = 0, sx = 0, sy = 0;
+ for (const s of segments) {
+ if (s.type === 'M') { cx = sx = s.args[0]; cy = sy = s.args[1]; add(cx, cy); continue; }
+ if (s.type === 'L') { cx = s.args[0]; cy = s.args[1]; add(cx, cy); continue; }
+ if (s.type === 'C') {
+ const [a, b, c, d, e, f] = s.args;
+ const xs = cubicExtrema(cx, a, c, e);
+ const ys = cubicExtrema(cy, b, d, f);
+ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);
+ cx = e; cy = f;
+ continue;
+ }
+ if (s.type === 'Q') {
+ const [a, b, c, d] = s.args;
+ const xs = quadExtrema(cx, a, c), ys = quadExtrema(cy, b, d);
+ for (let k = 0; k < xs.length; k++) add(xs[k], ys[k]);
+ cx = c; cy = d;
+ continue;
+ }
+ if (s.type === 'Z') { cx = sx; cy = sy; }
+ }
+ if (!Number.isFinite(x1)) return null;
+ return { x1, y1, x2, y2 };
+}
+
+/** The contours of the outline: each with its start and whether it was closed. */
+function subpaths(segments) {
+ const out = [];
+ let cur = null;
+ for (const s of segments) {
+ if (s.type === 'M') { cur = { start: [s.args[0], s.args[1]], closed: false, points: [[s.args[0], s.args[1]]] }; out.push(cur); continue; }
+ if (!cur) continue;
+ if (s.type === 'L') cur.points.push([s.args[0], s.args[1]]);
+ else if (s.type === 'C') cur.points.push([s.args[4], s.args[5]]);
+ else if (s.type === 'Q') cur.points.push([s.args[2], s.args[3]]);
+ else if (s.type === 'Z') cur.closed = true;
+ }
+ return out;
+}
+
+/** True when every contour ends with a close: a glyph's outline must not run off the edge. */
+function closed(segments) {
+ const subs = subpaths(segments);
+ if (!subs.length) return false;
+ return subs.every((s) => s.closed);
+}
+
+/** The same outline as path data: absolute, two decimals, arcs already cubics. */
+function toData(segments) {
+ const parts = [];
+ for (const s of segments) {
+ if (s.type === 'M') parts.push('M' + numText(s.args[0]) + ' ' + numText(s.args[1]));
+ else if (s.type === 'L') parts.push('L' + numText(s.args[0]) + ' ' + numText(s.args[1]));
+ else if (s.type === 'C') parts.push('C' + s.args.map(numText).join(' '));
+ else if (s.type === 'Q') parts.push('Q' + s.args.map(numText).join(' '));
+ else parts.push('Z');
+ }
+ return parts.join(' ');
+}
+
+/** The outline as an opentype.js Path (font coordinates: y up, the baseline at 0). */
+function pathOf(segments) {
+ const opentype = require('opentype.js');
+ const path = new opentype.Path();
+ for (const s of segments) {
+ if (s.type === 'M') path.moveTo(s.args[0], s.args[1]);
+ else if (s.type === 'L') path.lineTo(s.args[0], s.args[1]);
+ else if (s.type === 'C') path.bezierCurveTo(s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]);
+ else if (s.type === 'Q') path.quadraticCurveTo(s.args[0], s.args[1], s.args[2], s.args[3]);
+ else path.close();
+ }
+ return path;
+}
+
+module.exports = { parse, bbox, subpaths, closed, toData, pathOf, round, numText, scan };
new file mode 100644
new file mode 100644
@@ -0,0 +1,274 @@
@@ -0,0 +1,274 @@
+'use strict';
+/**
+ * The model, drawing (plan R1 item 8, Seven Domains §11).
+ *
+ * Two things are made here: a typeface and a vector. Both are the person's own work, both are attributed
+ * to them (on_behalf_of, so their own allowance pays for it), and both are capped per person per day.
+ *
+ * A typeface is drawn in batches. A face carries about ninety characters, and asking a model for ninety
+ * outlines in one answer does not work: it runs long, it repeats itself and it gives up on the tail. So the
+ * letters are asked for a batch at a time, with the same style notes carried through every batch, and each
+ * outline is checked before it is kept.
+ *
+ * A vector is drawn in one answer, cleaned, and saved.
+ */
+const glyph = require('./glyph');
+const { compile, CHARSET, FAMILY } = require('./compile');
+const { sanitize } = require('../vectors/svg');
+
+/** How many letters are asked for at a time. */
+const BATCH = 26;
+
+/** How many model runs a person may make a day, when the config does not say. */
+const DEFAULT_PER_DAY = 20;
+
+/** The style notes, carried through every batch so the letters look like one family. */
+const STYLE = [
+ 'You are a type designer drawing the outlines of a typeface.',
+ 'Answer with JSON only: {"glyphs": {"A": "<path d=\"...\"/>", "B": "<path d=\"...\"/>", ...}}.',
+ 'Each glyph is ONE <path> element with only a d attribute. Nothing else: no group, no fill, no style, no script, no explanation.',
+ 'The coordinates are font coordinates: x grows to the right, y grows UP, the baseline is y = 0.',
+ 'The em box is 1000 units wide: x from 0 to 1000, y from -200 (descenders) to 800 (cap height and ascenders).',
+ 'Cap height is 700, so an uppercase letter runs from y = 0 to y = 700. Lowercase x-height is 500.',
+ 'Letters sit on the baseline and start at x = 60, with about 60 units of space on each side.',
+ 'Every contour must be closed: the last command of each contour is Z.',
+ 'Curves are C or Q commands. Never use arcs (A). Never use relative commands.',
+ 'Letters with a hole (o, e, a, b, d, g, p, q, 0, 4, 6, 8, 9) have the hole as a second closed contour inside the first.',
+].join('\n');
+
+/** What a vector answer looks like. */
+const VECTOR_STYLE = [
+ 'You are an illustrator drawing one clean SVG.',
+ 'Answer with the SVG only: <svg viewBox="0 0 512 512"> ... </svg>.',
+ 'The drawing is made of path, circle, ellipse, line, polyline, polygon and rect elements, and nothing else.',
+ 'No script, no style element, no image, no use, no foreignObject, no external reference of any kind.',
+ 'Use fill and stroke for colour; keep the drawing flat and legible at small sizes.',
+].join('\n');
+
+function fail(status, code, detail) {
+ return Object.assign(new Error(detail), { status, code, detail });
+}
+
+/** The letters of the charset, in the batches they are drawn in. */
+function batches(charset = CHARSET) {
+ const out = [];
+ for (let i = 0; i < charset.length; i += BATCH) out.push(charset.slice(i, i + BATCH));
+ return out;
+}
+
+/** The letters a typeface must have to be a typeface at all. */
+const CORE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+function createService({ db, ai, store, config = {}, log = console, s, sharing = null } = {}) {
+ const perDay = Math.max(0, Number((config.aiMake && config.aiMake.perDay) || DEFAULT_PER_DAY) || 0);
+
+ /** The cap: how many runs this person has left today. */
+ async function left(owner) {
+ if (!owner) return 0;
+ const used = await store.runsToday(owner);
+ return Math.max(0, perDay - used);
+ }
+
+ /** A name for the face: what the person wrote, made into a font name. */
+ function nameFrom(prompt, base) {
+ const words = String(prompt || '').trim().split(/\s+/).slice(0, 4).join(' ');
+ const candidate = (words || base || 'New Face').replace(/[^A-Za-z0-9 .'-]/g, ' ').trim().slice(0, 60);
+ return candidate && FAMILY.test(candidate) ? candidate : (base ? String(base).slice(0, 60) : 'New Face');
+ }
+
+ /** One model answer, read as JSON. */
+ function readJson(text) {
+ const body = String(text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
+ const start = body.indexOf('{');
+ const end = body.lastIndexOf('}');
+ if (start < 0 || end <= start) return null;
+ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }
+ }
+
+ /** The style notes the model is given, with the person's own words and the base they chose. */
+ function notesFor({ prompt, baseFamily, baseNote }) {
+ return [
+ STYLE,
+ '',
+ 'The typeface: ' + String(prompt || 'a plain, legible sans').slice(0, 600),
+ baseFamily ? 'It is based on ' + String(baseFamily).slice(0, 80) + ', and keeps that letterform.' : '',
+ baseNote ? String(baseNote).slice(0, 400) : '',
+ 'Draw these characters, and only these: ' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,:;!?' + '"' + '()-/&@#%+=*',
+ ].filter(Boolean).join('\n');
+ }
+
+ /**
+ * Draw a typeface. The letters are asked for a batch at a time; each outline is checked as it arrives,
+ * and a batch that comes back unusable is asked for once more, saying what was wrong with it.
+ */
+ async function drawTypeface({ owner, prompt, baseFamily = null, baseNote = null, name = null, license = 'OFL-1.1', onProgress = null } = {}) {
+ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
+ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a typeface on openvibe.design.');
+ const remaining = await left(owner);
+ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
+
+ const groups = batches();
+ const drawn = new Map();
+ let provider = null;
+ let model = null;
+ let runs = 0;
+
+ for (const group of groups) {
+ const ask = async (extra = '') => {
+ await store.countRun(owner, 'typeface');
+ runs++;
+ const answer = await ai.text({
+ system: STYLE,
+ prompt: notesFor({ prompt, baseFamily, baseNote }) + (extra ? '\n\n' + extra : ''),
+ maxTokens: 8000,
+ target: { service: 'design', type: 'typeface', id: String(baseFamily || 'new') },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ person: String(owner),
+ });
+ provider = provider || (answer.provider || null);
+ model = model || (answer.model || null);
+ return answer;
+ };
+ const keep = (answer) => {
+ const json = readJson(answer.text);
+ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};
+ const kept = [];
+ const whys = [];
+ for (const char of group) {
+ if (drawn.has(char)) continue;
+ const raw = got[char] !== undefined ? got[char] : (got[char.toUpperCase()] !== undefined ? got[char.toUpperCase()] : null);
+ if (raw === null || raw === undefined) { whys.push(char + ' was missing'); continue; }
+ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);
+ if (!clean.ok) { whys.push(char + ': ' + clean.why); continue; }
+ drawn.set(char, clean.glyph);
+ kept.push(char);
+ }
+ return { kept, whys };
+ };
+
+ let answer = await ask();
+ let result = keep(answer);
+ // A batch that came back unusable is asked for once more, saying what was wrong with it.
+ if (result.kept.length < group.length * 0.6) {
+ const why = result.whys.slice(0, 8).join('; ');
+ const again = await ask('The last answer had problems: ' + (why || 'too few usable letters') + '. Draw them again, carefully.');
+ const second = keep(again);
+ if (second.kept.length > result.kept.length) result = second;
+ }
+ if (onProgress) onProgress({ drawn: drawn.size, total: CORE.length });
+ }
+
+ const glyphs = [...drawn.entries()].map(([char, g]) => ({ char, d: g.d, advance: g.advance, bounds: g.bounds }));
+ const have = new Set(glyphs.map((g) => g.char));
+ const missing = CORE.split('').filter((c) => !have.has(c));
+ if (!glyphs.length) throw fail(502, 'ai.refused', 'The model did not draw any letters. Try again with a clearer description.');
+
+ const face = await compile({
+ familyName: name || nameFrom(prompt, baseFamily),
+ glyphs,
+ license,
+ model,
+ designer: 'Made on OpenVibe.Design',
+ });
+ if (!face.ok) throw fail(502, 'ai.refused', face.why);
+
+ return {
+ glyphs,
+ metrics: { ...face.metrics, missing },
+ provider,
+ model,
+ license,
+ runs,
+ name: name || nameFrom(prompt, baseFamily),
+ };
+ }
+
+ /**
+ * Draw one vector. The answer is cleaned before it is kept: an SVG is read out of the allowlist and
+ * written again from scratch, so nothing the model wrote survives unless it was allowed.
+ */
+ async function drawVector({ owner, prompt, kind = 'icon', note = null, onBehalf = true } = {}) {
+ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
+ if (!owner) throw fail(401, 'auth.required', 'Sign in to make a vector on openvibe.design.');
+ const remaining = await left(owner);
+ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
+
+ await store.countRun(owner, 'vector');
+ const answer = await ai.text({
+ system: VECTOR_STYLE,
+ prompt: 'Draw: ' + String(prompt || '').slice(0, 800) + (note ? '\n\nThe note on the last drawing: ' + String(note).slice(0, 400) : ''),
+ maxTokens: 3000,
+ target: { service: 'design', type: 'vector', id: String(kind || 'icon') },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ ...(onBehalf ? { person: String(owner) } : {}),
+ });
+
+ // The SVG is taken out of the answer, then cleaned.
+ const raw = String(answer.text || '');
+ const start = raw.indexOf('<svg');
+ const end = raw.lastIndexOf('</svg>');
+ const markup = start >= 0 && end > start ? raw.slice(start, end + 6) : raw;
+ const clean = sanitize(markup);
+ if (!clean.ok) throw fail(502, 'ai.refused', 'The model drew something that is not a drawing: ' + clean.why);
+
+ return {
+ svg: clean.svg,
+ provider: answer.provider || null,
+ model: answer.model || null,
+ license: 'OFL-1.1',
+ kind: String(kind || 'icon'),
+ };
+ }
+
+ /**
+ * Redraw one glyph with a note: "make the a rounder", "the g needs a tail".
+ * The face's own style notes go with it, so the letter comes back like the rest of them.
+ */
+ async function redrawGlyph({ owner, face, char, note } = {}) {
+ if (!ai || !ai.enabled) throw fail(503, 'ai.not_configured', 'OpenVibe.AI is not configured for OpenVibe.Design.');
+ if (!owner) throw fail(401, 'auth.required', 'Sign in to redraw this on openvibe.design.');
+ const remaining = await left(owner);
+ if (remaining < 1) throw fail(429, 'ai.capped', 'You have made ' + perDay + ' runs today. More tomorrow.');
+ const wanted = String(char || '').slice(0, 4);
+ if (!wanted) throw fail(422, 'ai.invalid_input', 'Say which letter to redraw.');
+ const what = String(note || '').trim().slice(0, 400);
+ if (!what) throw fail(422, 'ai.invalid_input', 'Say what to change about the letter.');
+
+ await store.countRun(owner, 'typeface');
+ const answer = await ai.text({
+ system: STYLE,
+ prompt: [
+ notesFor({ prompt: face.prompt, baseFamily: face.baseFamily, baseNote: face.styleNotes && face.styleNotes !== face.prompt ? face.styleNotes : null }),
+ '',
+ 'Now draw ONE letter: "' + wanted + '".',
+ 'What to change: ' + what,
+ 'Answer with JSON only: {"glyphs": {"' + wanted + '": "<path d=\"...\"/>"}}.',
+ ].join('\n'),
+ maxTokens: 1200,
+ target: { service: 'design', type: 'typeface_glyph', id: String(face.id || 'new') },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ person: String(owner),
+ });
+ const json = readJson(answer.text);
+ const got = json && json.glyphs && typeof json.glyphs === 'object' ? json.glyphs : {};
+ const raw = got[wanted] !== undefined ? got[wanted] : (got[wanted.toUpperCase()] !== undefined ? got[wanted.toUpperCase()] : null);
+ if (raw === null || raw === undefined) return null;
+ const clean = glyph.clean(typeof raw === 'object' ? (raw.d || raw.path || '') : raw);
+ if (!clean.ok) return null;
+ return { char: wanted, d: clean.glyph.d, advance: clean.glyph.advance, bounds: clean.glyph.bounds, model: answer.model || null };
+ }
+
+ /**
+ * A drawing as a PNG, for the download. resvg draws the SVG; it is already clean, so it is drawn as it is.
+ */
+ async function png(svg, size = 512) {
+ const pixels = Math.max(16, Math.min(2048, Number(size) || 512));
+ const { Resvg } = require('@resvg/resvg-js');
+ const rendered = new Resvg(String(svg || ''), { fitTo: { mode: 'width', value: pixels }, background: 'rgba(0,0,0,0)' });
+ return rendered.render().asPng();
+ }
+
+ return { drawTypeface, drawVector, redrawGlyph, png, left, batches, notesFor, perDay, CORE };
+}
+
+module.exports = { createService, batches, fail, DEFAULT_PER_DAY, STYLE, VECTOR_STYLE, CORE, BATCH };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Where the typefaces and vectors a person made live (plan R1 item 8, Seven Domains §11).
+ *
+ * Two tables, one rule: a row belongs to the person who made it and to nobody else. Nothing here is
+ * published, shared or listed by the site; the only way out is the person's own page.
+ *
+ * The daily cap is measured here too: every model run is counted, and the count is what the cap in the
+ * config is compared against.
+ */
+
+/** A typeface row, as the pages and the API see it. */
+function typefaceRow(row) {
+ if (!row) return null;
+ return {
+ id: String(row.id),
+ name: String(row.name),
+ prompt: String(row.prompt || ''),
+ baseFamily: row.base_family ? String(row.base_family) : null,
+ styleNotes: row.style_notes ? String(row.style_notes) : null,
+ glyphs: Array.isArray(row.glyphs) ? row.glyphs : [],
+ metrics: row.metrics && typeof row.metrics === 'object' ? row.metrics : {},
+ provider: row.provider ? String(row.provider) : null,
+ model: row.model ? String(row.model) : null,
+ license: String(row.license || 'OFL-1.1'),
+ state: String(row.state || 'drawing'),
+ note: row.note ? String(row.note) : null,
+ createdAt: row.created_at,
+ };
+}
+
+/** A vector row, as the pages and the API see it. */
+function vectorRow(row) {
+ if (!row) return null;
+ return {
+ id: String(row.id),
+ prompt: String(row.prompt || ''),
+ kind: String(row.kind || 'icon'),
+ svg: String(row.svg || ''),
+ provider: row.provider ? String(row.provider) : null,
+ model: row.model ? String(row.model) : null,
+ license: String(row.license || 'OFL-1.1'),
+ state: String(row.state || 'drawn'),
+ note: row.note ? String(row.note) : null,
+ createdAt: row.created_at,
+ };
+}
+
+function createStore({ db, log = console, now = () => new Date() } = {}) {
+ /** A typeface of this person's, or null. Another person's typeface is not found, not refused. */
+ async function typeface(id, owner) {
+ const row = await db.maybe('SELECT * FROM ai_typefaces WHERE id = $1 AND owner = $2', [String(id), String(owner)]);
+ return typefaceRow(row);
+ }
+
+ /** A vector of this person's, or null. */
+ async function vector(id, owner) {
+ const row = await db.maybe('SELECT * FROM ai_vectors WHERE id = $1 AND owner = $2', [String(id), String(owner)]);
+ return vectorRow(row);
+ }
+
+ /** The person's typefaces, newest first. */
+ async function typefacesOf(owner, limit = 24) {
+ const rows = await db.many('SELECT * FROM ai_typefaces WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);
+ return rows.map(typefaceRow);
+ }
+
+ /** The person's vectors, newest first. */
+ async function vectorsOf(owner, limit = 24) {
+ const rows = await db.many('SELECT * FROM ai_vectors WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [String(owner), Math.max(1, Math.min(100, Number(limit) || 24))]);
+ return rows.map(vectorRow);
+ }
+
+ async function saveTypeface({ id, owner, name, prompt, baseFamily = null, styleNotes = null, glyphs = [], metrics = {}, provider = null, model = null, license = 'OFL-1.1', state = 'drawing', note = null }) {
+ await db.exec(
+ 'INSERT INTO ai_typefaces (id, owner, name, prompt, base_family, style_notes, glyphs, metrics, provider, model, license, state, note) ' +
+ 'VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13) ' +
+ 'ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, style_notes = EXCLUDED.style_notes, glyphs = EXCLUDED.glyphs, ' +
+ 'metrics = EXCLUDED.metrics, provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, ' +
+ 'state = EXCLUDED.state, note = EXCLUDED.note, updated_at = now()',
+ [String(id), String(owner), String(name), String(prompt || ''), baseFamily, styleNotes, JSON.stringify(glyphs), JSON.stringify(metrics), provider, model, String(license || 'OFL-1.1'), String(state || 'drawing'), note],
+ );
+ return await typeface(id, owner);
+ }
+
+ async function saveVector({ id, owner, prompt, kind = 'icon', svg, provider = null, model = null, license = 'OFL-1.1', state = 'drawn', note = null }) {
+ await db.exec(
+ 'INSERT INTO ai_vectors (id, owner, prompt, kind, svg, provider, model, license, state, note) ' +
+ 'VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ' +
+ 'ON CONFLICT (id) DO UPDATE SET prompt = EXCLUDED.prompt, kind = EXCLUDED.kind, svg = EXCLUDED.svg, ' +
+ 'provider = EXCLUDED.provider, model = EXCLUDED.model, license = EXCLUDED.license, state = EXCLUDED.state, ' +
+ 'note = EXCLUDED.note, updated_at = now()',
+ [String(id), String(owner), String(prompt || ''), String(kind || 'icon'), String(svg || ''), provider, model, String(license || 'OFL-1.1'), String(state || 'drawn'), note],
+ );
+ return await vector(id, owner);
+ }
+
+ /**
+ * How many model runs this person has made today. The cap in the config is compared against this.
+ * The count is of runs, not of pages: one typeface is several runs, because the letters are drawn
+ * a batch at a time.
+ */
+ async function runsToday(owner, kind = null) {
+ const row = kind
+ ? await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND kind = $2 AND day = current_date', [String(owner), String(kind)])
+ : await db.maybe('SELECT count(*)::int AS n FROM ai_make_runs WHERE owner = $1 AND day = current_date', [String(owner)]);
+ return (row && Number(row.n)) || 0;
+ }
+
+ /** One model run, counted. */
+ async function countRun(owner, kind) {
+ await db.exec('INSERT INTO ai_make_runs (owner, kind) VALUES ($1, $2)', [String(owner), String(kind)]);
+ }
+
+ return { typeface, vector, typefacesOf, vectorsOf, saveTypeface, saveVector, runsToday, countRun };
+}
+
+module.exports = { createStore, typefaceRow, vectorRow };
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
+'use strict';
+/**
+ * An SVG, cleaned before it is shown or saved (plan R1 item 8: AI vectors).
+ *
+ * The model writes an SVG. A browser will run anything in an SVG it is given, so the answer is read here first and
+ * rebuilt from scratch out of what a drawing may contain. Nothing the model wrote is trusted: the output is the
+ * allowlist below and nothing else.
+ *
+ * sanitize(markup) -> { ok: true, svg } | { ok: false, why }
+ *
+ * What may stay:
+ * - the elements below: the shapes a drawing is made of
+ * - the attributes below: how a shape looks
+ * - one viewBox, so the drawing scales
+ *
+ * What may not:
+ * - script, style, foreignObject, animate, set, use, image, a, and every other element
+ * - any event handler (onclick), any href, any external reference, any url() that is not a local #id
+ * - entity declarations, CDATA, doctypes, processing instructions
+ * - anything that is not well-formed markup
+ */
+const path = require('../typefaces/path');
+
+/** The elements a drawing may be made of, with the spelling SVG gives them. */
+const ELEMENTS = new Map([
+ ['svg', 'svg'],
+ ['g', 'g'],
+ ['path', 'path'],
+ ['circle', 'circle'],
+ ['ellipse', 'ellipse'],
+ ['line', 'line'],
+ ['polyline', 'polyline'],
+ ['polygon', 'polygon'],
+ ['rect', 'rect'],
+ ['defs', 'defs'],
+ ['clippath', 'clipPath'],
+ ['mask', 'mask'],
+ ['lineargradient', 'linearGradient'],
+ ['radialgradient', 'radialGradient'],
+ ['stop', 'stop'],
+ ['title', 'title'],
+ ['desc', 'desc'],
+]);
+
+/** The attributes each element may carry. */
+const ATTRIBUTES = {
+ svg: new Set(['viewBox', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform', 'xmlns', 'version', 'preserveAspectRatio']),
+ g: new Set(['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),
+ path: new Set(['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'opacity', 'color', 'transform']),
+ circle: new Set(['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
+ ellipse: new Set(['cx', 'cy', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
+ line: new Set(['x1', 'y1', 'x2', 'y2', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'opacity', 'color', 'transform']),
+ polyline: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
+ polygon: new Set(['points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
+ rect: new Set(['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'fill-opacity', 'fill-rule', 'opacity', 'color', 'transform']),
+ defs: new Set([]),
+ clipPath: new Set(['id', 'clipPathUnits', 'transform']),
+ mask: new Set(['id', 'maskUnits', 'maskContentUnits', 'x', 'y', 'width', 'height']),
+ linearGradient: new Set(['id', 'x1', 'y1', 'x2', 'y2', 'gradientUnits', 'gradientTransform', 'spreadMethod']),
+ radialGradient: new Set(['id', 'cx', 'cy', 'r', 'fx', 'fy', 'gradientUnits', 'gradientTransform', 'spreadMethod']),
+ stop: new Set(['offset', 'stop-color', 'stop-opacity']),
+ title: new Set([]),
+ desc: new Set([]),
+};
+
+/** The attributes that are never allowed, whatever the element. */
+const NEVER = /^(?:on[a-z]+|href|xlink:href|src|srcset|formaction|action|xmlns:xlink|xml:base|base|content|ping|data|codebase|manifest|profile|usemap|class|style)$/i;
+
+/** The style properties a style attribute may hold. */
+const STYLE_PROPERTIES = new Set(['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', 'color', 'stop-color', 'stop-opacity', 'clip-rule', 'paint-order']);
+
+/** How large an SVG may be. */
+const MAX_BYTES = 200000;
+
+const TAG = /<\s*(\/\s*)?([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
+const ATTR = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
+
+function esc(s) {
+ return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+}
+
+function fail(why) { return { ok: false, why }; }
+
+/**
+ * A colour, a number, a length: the values a shape may be drawn with.
+ *
+ * A value that is merely empty or odd is dropped (the drawing goes on without it). A value that reaches
+ * outside the drawing, or that would run something, refuses the drawing: quietly drawing something else
+ * than the model meant is worse than saying no.
+ */
+function safeValue(value) {
+ const v = String(value).trim();
+ if (!v) return { ok: false, drop: true };
+ if (v.length > 300) return { ok: false, drop: true };
+ // No url() except a local reference, no javascript:, no expression.
+ if (/url\s*\(/i.test(v)) {
+ const only = /^url\(\s*['"]?#[A-Za-z][\w:.-]*['"]?\s*\)$/i.test(v);
+ if (!only) return { ok: false, why: 'the drawing reaches outside itself' };
+ }
+ if (/javascript:|expression\s*\(|@import|behavior\s*:|binding\s*:/i.test(v)) return { ok: false, why: 'the drawing carries something a drawing must not carry' };
+ if (/[<>&]/.test(v)) return { ok: false, drop: true };
+ return { ok: true, value: v };
+}
+
+/** A style attribute: only the properties above, and only safe values. */
+function safeStyle(value) {
+ const parts = String(value).split(';');
+ const kept = [];
+ for (const part of parts) {
+ const idx = part.indexOf(':');
+ if (idx < 0) continue;
+ const prop = part.slice(0, idx).trim().toLowerCase();
+ if (!STYLE_PROPERTIES.has(prop)) continue;
+ const val = safeValue(part.slice(idx + 1));
+ if (!val.ok) {
+ if (val.why) return { ok: false, why: val.why };
+ continue;
+ }
+ kept.push(prop + ':' + val.value);
+ }
+ return { ok: true, value: kept.join(';') };
+}
+
+/** The numbers of a points list: an even run of numbers, nothing else. */
+function safePoints(value) {
+ const raw = String(value).trim();
+ if (!raw || raw.length > 4000) return null;
+ const nums = raw.split(/[\s,]+/).filter(Boolean);
+ if (nums.length < 4 || nums.length % 2 !== 0) return null;
+ for (const n of nums) if (!/^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?$/.test(n)) return null;
+ return nums.join(' ');
+}
+
+/** The path data of a path: read with the same rules the font uses, so a drawing cannot smuggle anything. */
+function safePathData(value) {
+ const raw = String(value).trim();
+ if (!raw || raw.length > 20000) return null;
+ const parsed = path.parse(raw);
+ if (!parsed.ok) return null;
+ return path.toData(parsed.segments);
+}
+
+/** The attributes of one element, read into a plain object, with the ones that are not allowed refused. */
+function readAttrs(name, restRaw) {
+ const attrs = {};
+ for (const a of restRaw.matchAll(ATTR)) {
+ const key = a[1].toLowerCase();
+ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
+ if (key === 'style') {
+ const style = safeStyle(value);
+ if (!style.ok) return style;
+ if (style.value) attrs.style = style.value;
+ continue;
+ }
+ if (NEVER.test(key)) return { ok: false, why: 'the drawing carries a ' + key + ' attribute' };
+ if (!ATTRIBUTES[name].has(key)) continue; // an attribute nobody needs is simply dropped
+ if (key === 'd') {
+ const d = safePathData(value);
+ if (d === null) return { ok: false, why: 'the drawing has a path it cannot read' };
+ attrs.d = d;
+ continue;
+ }
+ if (key === 'points') {
+ const pts = safePoints(value);
+ if (pts === null) return { ok: false, why: 'the drawing has points it cannot read' };
+ attrs.points = pts;
+ continue;
+ }
+ const val = safeValue(value);
+ if (!val.ok) {
+ if (val.why) return { ok: false, why: val.why };
+ continue;
+ }
+ attrs[key] = val.value;
+ }
+ return { ok: true, attrs };
+}
+
+/**
+ * Read one SVG. The markup is walked tag by tag; only the elements and attributes on the list are kept, and
+ * everything is written back out from scratch, so nothing the model wrote survives unless it was allowed.
+ */
+function sanitize(markup) {
+ const raw = String(markup == null ? '' : markup).trim();
+ if (!raw) return fail('the drawing is empty');
+ if (raw.length > MAX_BYTES) return fail('the drawing is too large');
+
+ // The things that are never in a drawing, refused before anything is read.
+ if (/<!\[CDATA\[|<!ENTITY|<!DOCTYPE|<\?/i.test(raw)) return fail('the drawing carries markup a drawing must not carry');
+ if (/javascript:|vbscript:|data:text\/html|<script|<foreignObject|<iframe|<embed|<object|<use\b|<image\b|<animate|<set\b|<audio|<video|<handler/i.test(raw))
+ return fail('the drawing carries something a drawing must not carry');
+
+ const out = [];
+ const stack = [];
+ let rootAttrs = null;
+ let viewBox = null;
+
+ for (const m of raw.matchAll(TAG)) {
+ const [full, closing, nameRaw, restRaw] = m;
+ const lower = nameRaw.toLowerCase();
+ const name = ELEMENTS.get(lower);
+ if (!name) return fail('a drawing is made of shapes, and this is not one');
+
+ if (closing) {
+ // The root's own close is not written out: the root tag is written once, at the end.
+ if (name === 'svg' && !stack.length) {
+ if (!rootAttrs) return fail('there is no drawing here');
+ continue;
+ }
+ if (!stack.length || stack[stack.length - 1] !== name) return fail('the drawing is not drawn properly');
+ stack.pop();
+ out.push('</' + name + '>');
+ continue;
+ }
+
+ const read = readAttrs(name, restRaw);
+ if (!read.ok) return read;
+ const selfClosing = /\/\s*$/.test(restRaw);
+ const parts = Object.keys(read.attrs).map((k) => ' ' + k + '="' + esc(read.attrs[k]) + '"');
+
+ if (name === 'svg') {
+ // The root is the one drawing; a second one inside it is not allowed.
+ if (rootAttrs) return fail('there is more than one drawing here');
+ rootAttrs = parts;
+ for (const a of restRaw.matchAll(ATTR)) {
+ if (a[1].toLowerCase() !== 'viewbox') continue;
+ const box = safeValue(a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]));
+ if (!box.ok) return fail(box.why || 'the drawing has no size');
+ viewBox = box.value;
+ }
+ // The root's own tag is written once, at the end, with its viewBox.
+ continue;
+ }
+
+ if (selfClosing) out.push('<' + name + parts.join('') + '/>');
+ else {
+ out.push('<' + name + parts.join('') + '>');
+ stack.push(name);
+ }
+ }
+
+ if (!rootAttrs) return fail('there is no drawing here');
+ if (stack.length) return fail('the drawing is not closed');
+ if (!viewBox) return fail('the drawing has no size');
+
+ // The root's attributes are read again, on their own, so the viewBox can be required.
+ const root = /<\s*svg\b([^>]*)>/i.exec(raw);
+ const keep = [];
+ for (const a of root[1].matchAll(ATTR)) {
+ const key = a[1].toLowerCase();
+ const value = a[3] !== undefined ? a[3] : (a[4] !== undefined ? a[4] : a[5]);
+ if (key === 'viewbox') continue;
+ if (key === 'style') {
+ const style = safeStyle(value);
+ if (!style.ok) return fail(style.why);
+ if (style.value) keep.push('style="' + esc(style.value) + '"');
+ continue;
+ }
+ if (NEVER.test(key)) return fail('the drawing carries a ' + key + ' attribute');
+ if (!ATTRIBUTES.svg.has(key)) continue;
+ const val = safeValue(value);
+ if (!val.ok) {
+ if (val.why) return fail(val.why);
+ continue;
+ }
+ keep.push(key + '="' + esc(val.value) + '"');
+ }
+
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + esc(viewBox) + '"' + (keep.length ? ' ' + keep.join(' ') : '') + '>' + out.join('') + '</svg>';
+ return { ok: true, svg };
+}
+
+module.exports = { sanitize, ELEMENTS, ATTRIBUTES, NEVER, MAX_BYTES, safeValue, safeStyle, safePoints, safePathData };
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * The typefaces and vectors a person makes with the model (plan R1 item 8, Seven Domains §11).
+ *
+ * Two makers and two pages. Every page is server-rendered and shows its content: the form, the drawings,
+ * the specimen and the downloads are all in the HTML, and nothing is behind a tab.
+ *
+ * The specimen shows the face in its own voice: the font is written into the page as a data URL, so the
+ * letters on screen are the letters the person just made.
+ */
+const { layout, esc } = require('../render/layout');
+
+/** The stylesheet both pages share. */
+const STYLES = ['css/typefaces.css'];
+
+/** The font as a data URL the page can use: the specimen is drawn in the face it just made. */
+function fontFace(woff2) {
+ if (!woff2) return '';
+ return '@font-face{font-family:"OpenVibe Made";src:url(data:font/woff2;base64,' + woff2.toString('base64') + ') format("woff2");font-weight:400;font-style:normal;font-display:swap}';
+}
+
+/** "Aa" (or the first two letters it has) drawn from a typeface's own outlines, as one inline SVG. */
+function sample(glyphs) {
+ const list = Array.isArray(glyphs) ? glyphs.filter((g) => g && typeof g.d === 'string' && /^[MmLlHhVvCcSsQqTtZz0-9.,\s-]+$/.test(g.d)) : [];
+ const by = new Map(list.map((g) => [g.char, g]));
+ const picked = ['A', 'a'].every((c) => by.has(c)) ? [by.get('A'), by.get('a')] : list.slice(0, 2);
+ if (!picked.length) return '<span class="tf-sample is-empty">Not drawn yet</span>';
+ let x = 0;
+ const paths = picked.map((g) => { const p = '<path transform="translate(' + x + ' 0)" d="' + esc(g.d) + '"/>'; x += Number(g.advance) || 600; return p; }).join('');
+ return '<svg class="tf-sample" viewBox="0 -800 ' + x + ' 1000" aria-hidden="true"><g transform="scale(1 -1)" fill="currentColor">' + paths + '</g></svg>';
+}
+
+/** The maker: describe a face, start from an open one. */
+function make({ bases = [], mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {
+ const options = bases.map((b) => '<option value="' + esc(b.id) + '">' + esc(b.family) + ' — ' + esc(b.category) + '</option>').join('');
+ // Each typeface you made is shown in itself: "Aa" drawn from its own stored outlines (validated paths, font units,
+ // baseline at 0), so the list reads as a shelf of faces, not a list of names (Opus's design pass, 11-08).
+ const owned = mine.length
+ ? '<section class="tf-owned"><h2>Your typefaces <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-shelf">' + mine.map((f) => '<li><a href="/fonts/made/' + esc(f.id) + '">'
+ + sample(f.glyphs) + '<b>' + esc(f.name) + '</b><span>' + esc(f.glyphs.length) + ' letters</span></a></li>').join('') + '</ul></section>'
+ : '';
+ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI typefaces</p><h1>Describe a typeface.</h1>'
+ + '<p>The model draws the letters, one batch at a time. You get the outlines, the font files and the licence.</p></header>'
+ + (signedIn
+ ? '<form class="tf-form" method="post" action="/fonts/make"><label for="tf-prompt">What kind of typeface?</label>'
+ + '<textarea id="tf-prompt" name="prompt" required maxlength="600" placeholder="rounded geometric sans for a kids\' app, high x-height, open apertures"></textarea>'
+ + '<label for="tf-name">Name it <small>optional</small></label><input id="tf-name" name="name" maxlength="60" placeholder="Bubble Sans">'
+ + '<label for="tf-base">Start from an open font <small>optional</small></label><select id="tf-base" name="base" data-ov-native><option value="">No base, just the description</option>' + options + '</select>'
+ + '<button type="submit">Draw the typeface</button>'
+ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'
+ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a typeface. The runs are charged to your own allowance.</p>')
+ + owned
+ + '</div>';
+ return layout({ title: 'Make a typeface', description: 'Describe a typeface and the model draws it: the outlines, a WOFF2 and an OTF, under the SIL Open Font License.',
+ path: '/fonts/make', active: '/fonts', styles: STYLES, body });
+}
+
+/** The specimen: the face in its own voice, the download, and the redraw. */
+function specimen({ face, compiled, chars = '' } = {}) {
+ const glyphs = Array.isArray(face.glyphs) ? face.glyphs : [];
+ const css = compiled && compiled.ok ? fontFace(compiled.woff2) : '';
+ const sample = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ const rows = chars.split('').map((c) => {
+ const g = glyphs.find((x) => x.char === c);
+ return '<option value="' + esc(c) + '"' + (g ? '' : ' disabled') + '>' + esc(c) + '</option>';
+ }).join('');
+ const letters = sample.split('').map((c) => {
+ const g = glyphs.find((x) => x.char === c);
+ return '<span class="tf-letter' + (g ? '' : ' tf-missing') + '">' + esc(c) + '</span>';
+ }).join('');
+ const body = '<div class="tf tf-specimen"><a class="tf-back" href="/fonts/make">← Make another</a>'
+ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(face.license) + (face.model ? ' · drawn by ' + esc(face.model) : '') + '</p>'
+ + '<h1 class="tf-name" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(face.name) + '</h1>'
+ + '<p>' + esc(face.prompt) + '</p></header>'
+ + '<p class="tf-sample" style="font-family:\'OpenVibe Made\',sans-serif">' + esc(sample) + '</p>'
+ + '<p class="tf-sample tf-words" style="font-family:\'OpenVibe Made\',sans-serif">The quick brown fox jumps over the lazy dog.</p>'
+ + '<div class="tf-letters">' + letters + '</div>'
+ + '<section class="tf-download"><h2>Take it with you</h2><p>'
+ + (compiled && compiled.ok
+ ? '<a href="/api/v1/typefaces/' + esc(face.id) + '/font.woff2" download>Download the WOFF2</a> · <a href="/api/v1/typefaces/' + esc(face.id) + '/font.otf" download>Download the OTF</a>'
+ : 'The font files are being compiled. Try again in a moment.')
+ + '</p><p class="tf-note">The font is yours, offered under the ' + esc(face.license) + '. The name table says which model drew it.</p></section>'
+ + '<section class="tf-redraw"><h2>Redraw one letter</h2><p>Say what to change and the model draws that letter again, in the same style.</p>'
+ + '<form method="post" action="/fonts/made/' + esc(face.id) + '/glyph"><label for="tf-char">Letter</label>'
+ + '<select id="tf-char" name="char" data-ov-native>' + rows + '</select>'
+ + '<label for="tf-note">What to change</label><input id="tf-note" name="note" required maxlength="400" placeholder="make the tail longer and curl it up">'
+ + '<button type="submit">Redraw the letter</button></form>'
+ + (face.note ? '<p class="tf-lastnote">Last note: ' + esc(face.note) + '</p>' : '')
+ + '</section></div>';
+ return layout({ title: face.name + ' — a typeface you made', description: 'A typeface you described, drawn by the model, with the outlines and the font files.',
+ path: '/fonts/made/' + face.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow',
+ inlineStyles: css ? [css] : [], body });
+}
+
+/** The vector maker: one drawing from a prompt. */
+function vectorMake({ mine = [], signedIn = false, left = 0, perDay = 0 } = {}) {
+ // Your drawings as drawings: each one's own SVG as a picture (an <img> runs nothing), with what it was asked to be.
+ const owned = mine.length
+ ? '<section class="tf-owned"><h2>Your drawings <span class="tf-n">' + mine.length + '</span></h2><ul class="tf-gallery">' + mine.map((v) => '<li><a href="/vectors/made/' + esc(v.id) + '">'
+ + '<img src="/vectors/made/' + esc(v.id) + '.svg" alt="" loading="lazy" width="160" height="160"><span>' + esc(v.prompt.slice(0, 80)) + '</span><small>' + esc(v.kind) + '</small></a></li>').join('') + '</ul></section>'
+ : '';
+ const body = '<div class="tf"><header class="tf-hero"><p class="tf-eyebrow">AI vectors</p><h1>Describe a drawing.</h1>'
+ + '<p>One clean SVG: an icon, an illustration or a pattern. You can draw it again, change it with a note, and take it away.</p></header>'
+ + (signedIn
+ ? '<form class="tf-form" method="post" action="/vectors/make"><label for="tv-prompt">What should it be?</label>'
+ + '<textarea id="tv-prompt" name="prompt" required maxlength="600" placeholder="a paper plane folded from a single sheet, mid-flight, flat two-colour"></textarea>'
+ + '<label for="tv-kind">What kind</label><select id="tv-kind" name="kind" data-ov-native><option value="icon">Icon</option><option value="illustration">Illustration</option><option value="pattern">Pattern</option></select>'
+ + '<button type="submit">Draw it</button>'
+ + '<p class="tf-cap">' + esc(perDay) + ' model runs a day. You have ' + esc(left) + ' left today.</p></form>'
+ : '<p class="tf-signin"><a href="/auth/login">Sign in</a> to draw a vector. The runs are charged to your own allowance.</p>')
+ + owned + '</div>';
+ return layout({ title: 'Make a vector', description: 'Describe an icon, an illustration or a pattern and the model draws it as one clean SVG you can download.',
+ path: '/vectors/make', active: '/fonts', styles: STYLES, body });
+}
+
+/** The drawing: the SVG, the downloads and the notes. */
+function vector({ vector: v } = {}) {
+ const body = '<div class="tf tf-vector"><a class="tf-back" href="/vectors/make">← Make another</a>'
+ + '<header class="tf-hero"><p class="tf-eyebrow">' + esc(v.kind) + (v.model ? ' · drawn by ' + esc(v.model) : '') + '</p>'
+ + '<h1>' + esc(v.prompt) + '</h1></header>'
+ + '<figure class="tf-figure">' + v.svg + '</figure>'
+ + '<section class="tf-download"><h2>Take it with you</h2><p>'
+ + '<a href="/vectors/made/' + esc(v.id) + '.svg" download>Download the SVG</a> · <a href="/vectors/made/' + esc(v.id) + '.png" download>Download the PNG</a>'
+ + '</p><p class="tf-note">The drawing is yours, offered under the ' + esc(v.license) + '.</p></section>'
+ + '<section class="tf-redraw"><h2>Change it</h2><p>Say what to change, or leave the note empty to draw it again.</p>'
+ + '<form method="post" action="/vectors/made/' + esc(v.id) + '/edit"><label for="tv-note">What to change</label>'
+ + '<input id="tv-note" name="note" maxlength="400" placeholder="make the wings longer and add a shadow">'
+ + '<button type="submit" name="regenerate" value="1">Draw it again</button></form>'
+ + (v.note ? '<p class="tf-lastnote">Last note: ' + esc(v.note) + '</p>' : '')
+ + '</section></div>';
+ return layout({ title: 'A drawing you made', description: 'An SVG the model drew for you, clean and ready to use.',
+ path: '/vectors/made/' + v.id, active: '/fonts', styles: STYLES, robots: 'noindex, nofollow', body });
+}
+
+module.exports = { make, specimen, vectorMake, vector };
@@ -24,8 +24,12 @@ const components = require('openvibe-shared/components');
@@ -24,8 +24,12 @@ const components = require('openvibe-shared/components');
'openvibe_design_validate_kit_document',
'openvibe_design_validate_kit_document',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
'openvibe_design_list_fonts', 'openvibe_design_get_font',
'openvibe_design_list_fonts', 'openvibe_design_get_font',
+ 'openvibe_design_make_typeface', 'openvibe_design_make_vector',
]);
]);
- assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
+ assert.ok(listed.every(x => x.inputSchema && x.annotations));
+ // Every tool but the two that make something only reads.
+ assert.ok(listed.filter(x => x.annotations.readOnlyHint).length === listed.length - 2);
+ assert.ok(listed.filter(x => !x.annotations.readOnlyHint).every(x => x.name.startsWith('openvibe_design_make_')));
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
assert.strictEqual((await t.get('/mcp')).status, 405);
assert.strictEqual((await t.get('/mcp')).status, 405);
assert.strictEqual((await t.get('/mcp', { body: '{', headers: { 'content-type': 'application/json' } })).json().error.code, -32700);
assert.strictEqual((await t.get('/mcp', { body: '{', headers: { 'content-type': 'application/json' } })).json().error.code, -32700);
new file mode 100644
new file mode 100644
@@ -0,0 +1,388 @@
@@ -0,0 +1,388 @@
+'use strict';
+/**
+ * The typefaces and vectors a person makes with the model (plan R1 item 8).
+ *
+ * Four things are proved here:
+ * - the sanitisers refuse hostile input: a script, an event handler, an external reference, a path that
+ * is not closed, an outline that leaves the em box
+ * - the font compiles: the same glyph fixtures always give the same font, with the metrics the compiler
+ * promises and the model named in the font's name table
+ * - the cap holds: a person runs out of runs for the day and is told so in plain words
+ * - the outputs stay private: another person cannot read them, and they are not in the sitemap
+ */
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { clean } = require('../server/typefaces/glyph');
+const { compile } = require('../server/typefaces/compile');
+const { sanitize } = require('../server/vectors/svg');
+
+/** The letters the fake model draws. Every one of them is a closed path inside the em box. */
+const LETTERS = {
+ A: 'M100 0 L300 700 L500 0 L420 0 L340 180 L160 180 L80 0 Z',
+ B: 'M100 0 L100 700 L350 700 L500 540 L500 390 L330 370 L500 0 Z M180 80 L180 620 L330 620 L420 540 L420 460 L330 460 L180 460 Z',
+ C: 'M450 0 L380 70 L340 30 L300 0 L200 0 L100 150 L100 550 L200 700 L300 700 L340 670 L380 630 L450 700 L330 800 L220 800 L100 720 L0 550 L0 150 L100 -20 L220 -100 L330 -100 Z',
+ D: 'M100 0 L100 700 L300 700 L450 570 L450 130 L300 0 Z M190 90 L190 610 L290 610 L360 540 L360 160 L290 90 Z',
+ E: 'M100 0 L100 700 L450 700 L450 610 L200 610 L200 400 L400 400 L400 310 L200 310 L200 90 L450 90 L450 0 Z',
+};
+/** The letters the fake model draws for everything else: a plain block, so every character has an outline. */
+const block = (x) => 'M100 0 L100 700 L400 700 L400 0 Z';
+
+const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+/** The fake model: it draws letters when it is asked for a typeface, and a drawing when it is asked for a vector. */
+function fakeAi(overrides = {}) {
+ return {
+ enabled: true,
+ text: async ({ prompt }) => {
+ if (String(prompt).includes('Draw these characters')) {
+ const wanted = /Draw these characters, and only these: (.+)/.exec(String(prompt));
+ const chars = wanted ? wanted[1].trim() : CHARSET;
+ const glyphs = {};
+ for (const c of chars) glyphs[c] = LETTERS[c] || block(c);
+ return { text: JSON.stringify({ glyphs }), provider: 'anthropic', model: 'claude-opus-5-5' };
+ }
+ if (String(prompt).includes('Draw:') || String(prompt).includes('Now draw ONE letter')) {
+ const svg = '<svg viewBox="0 0 24 24"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></svg>';
+ return { text: svg, provider: 'anthropic', model: 'claude-opus-5-5' };
+ }
+ return { text: '{}', provider: 'anthropic', model: 'claude-opus-5-5' };
+ },
+ ...overrides,
+ };
+}
+
+(async () => {
+ // ── The sanitisers, on their own ──
+ await check('a glyph is one closed path inside the em box', () => {
+ const good = clean('M100 0 L400 0 L400 700 L100 700 Z');
+ assert.ok(good.ok);
+ assert.equal(good.glyph.d, 'M100 0 L400 0 L400 700 L100 700 Z');
+ assert.equal(good.glyph.advance, 420);
+ // An open contour is not an outline.
+ assert.equal(clean('M100 0 L400 0 L400 700').ok, false);
+ // An outline that leaves the em box is refused.
+ assert.equal(clean('M100 0 L4000 0 L4000 700 L100 700 Z').ok, false);
+ // A path with nothing in it is refused.
+ assert.equal(clean('M100 100 L101 100 L101 101 Z').ok, false);
+ });
+
+ await check('a glyph carries no script, handler or reference', () => {
+ for (const hostile of [
+ '<script>alert(1)</script>',
+ '<path onload="alert(1)" d="M0 0 L100 100 Z"/>',
+ '<path href="http://example.com/x" d="M0 0 L100 100 Z"/>',
+ '<path style="x" d="M0 0 L100 100 Z"/>',
+ '<foreignObject><b>x</b></foreignObject>',
+ '<image href="http://example.com/x.png"/>',
+ 'javascript:alert(1)',
+ 'M0 0 L10 10 url(http://example.com)',
+ '<path d="M0 0 L100 100 Z"/><path d="M0 0 L100 100 Z"/>',
+ ]) {
+ const r = clean(hostile);
+ assert.equal(r.ok, false, hostile);
+ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);
+ }
+ });
+
+ await check('a drawing is made of shapes and nothing else', () => {
+ const good = sanitize('<svg viewBox="0 0 24 24"><g fill="#333"><path d="M4 4 L20 4 L20 20 L4 20 Z"/></g></svg>');
+ assert.ok(good.ok);
+ assert.ok(good.svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'));
+ assert.ok(good.svg.includes('<path d="M4 4 L20 4 L20 20 L4 20 Z"/>'));
+ assert.ok(!good.svg.includes('http://example.com'));
+ for (const hostile of [
+ '<svg viewBox="0 0 24 24"><script>alert(1)</script></svg>',
+ '<svg viewBox="0 0 24 24" onload="alert(1)"><path d="M0 0 L10 10 Z"/></svg>',
+ '<svg viewBox="0 0 24 24"><path onclick="x()" d="M0 0 L10 10 Z"/></svg>',
+ '<svg viewBox="0 0 24 24"><foreignObject><b>x</b></foreignObject></svg>',
+ '<svg viewBox="0 0 24 24"><image href="http://example.com/x.png"/></svg>',
+ '<svg viewBox="0 0 24 24"><use href="#x"/></svg>',
+ '<svg viewBox="0 0 24 24"><path href="http://example.com" d="M0 0 L10 10 Z"/></svg>',
+ '<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg viewBox="0 0 24 24"><path d="M0 0 L10 10 Z"/></svg>',
+ '<svg viewBox="0 0 24 24"><![CDATA[x]]></svg>',
+ '<svg viewBox="0 0 24 24"><path fill="url(http://example.com/x)" d="M0 0 L10 10 Z"/></svg>',
+ '<svg viewBox="0 0 24 24"><path d="M0 0 L10 10 javascript"/></svg>',
+ ]) {
+ const r = sanitize(hostile);
+ assert.equal(r.ok, false, hostile);
+ assert.ok(typeof r.why === 'string' && r.why.length > 0, hostile);
+ }
+ // A drawing without a size cannot be shown.
+ assert.equal(sanitize('<svg><path d="M0 0 L10 10 Z"/></svg>').ok, false);
+ });
+
+ // ── The compiler, on fixed fixtures ──
+ await check('the same glyphs always compile to the same font', async () => {
+ const glyphs = Object.entries(LETTERS).map(([char, d]) => ({ char, d }));
+ const first = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });
+ const second = await compile({ familyName: 'Fixture Sans', glyphs, model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design' });
+ assert.ok(first.ok, first.why);
+ assert.ok(second.ok);
+ assert.deepEqual(first.otf, second.otf);
+ assert.deepEqual(first.woff2, second.woff2);
+ // The files are what they say they are.
+ assert.equal(first.woff2.slice(0, 4).toString('latin1'), 'wOF2');
+ assert.equal(first.otf.slice(0, 4).toString('latin1'), 'OTTO');
+ // The metrics are the face's own.
+ assert.equal(first.metrics.unitsPerEm, 1000);
+ assert.equal(first.metrics.glyphs, 5);
+ assert.ok(first.metrics.ascender >= 400 && first.metrics.ascender <= 800);
+ assert.ok(first.metrics.descender >= -200 && first.metrics.descender <= -100);
+ });
+
+ await check('a font says which model drew it', async () => {
+ const out = await compile({ familyName: 'Named Face', glyphs: [{ char: 'A', d: LETTERS.A }], model: 'claude-opus-5-5', designer: 'Made on OpenVibe.Design', license: 'OFL-1.1' });
+ assert.ok(out.ok);
+ const names = out.font.names.unicode;
+ assert.equal(names.fontFamily.en, 'Named Face');
+ assert.equal(names.license.en, 'OFL-1.1');
+ assert.match(names.description.en, /claude-opus-5-5/);
+ });
+
+ await check('a font without a name or without glyphs is refused', async () => {
+ assert.equal((await compile({ familyName: '', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);
+ assert.equal((await compile({ familyName: '../../etc/passwd', glyphs: [{ char: 'A', d: LETTERS.A }] })).ok, false);
+ assert.equal((await compile({ familyName: 'Fine', glyphs: [] })).ok, false);
+ // A glyph that is not an outline does not become one on the way in.
+ assert.equal((await compile({ familyName: 'Fine', glyphs: [{ char: 'A', d: '<script>alert(1)</script>' }] })).ok, false);
+ });
+
+ // ── The service, over HTTP ──
+ const t = await boot({ ai: fakeAi() });
+ try {
+ const user = t.network.addUser('ada');
+ const other = t.network.addUser('bob');
+
+ await check('the maker page is public and the work is not', async () => {
+ const page = await t.get('/fonts/make');
+ assert.equal(page.status, 200);
+ assert.match(page.text, /Describe a typeface/);
+ assert.match(page.text, /Sign in/);
+ const vectorPage = await t.get('/vectors/make');
+ assert.equal(vectorPage.status, 200);
+ assert.match(vectorPage.text, /Describe a drawing/);
+ // The tools are pages a crawler may read; the drawings are not.
+ const sitemap = (await t.get('/sitemap.xml')).text;
+ assert.ok(sitemap.includes('/fonts/make'));
+ assert.ok(sitemap.includes('/vectors/make'));
+ assert.ok(!sitemap.includes('/fonts/made/'));
+ assert.ok(!sitemap.includes('/vectors/made/'));
+ const llms = (await t.get('/llms.txt')).text;
+ assert.ok(llms.includes('/fonts/make'));
+ assert.ok(llms.includes('/vectors/make'));
+ });
+
+ await check('a typeface is drawn, compiled and downloadable', async () => {
+ const made = await t.get('/fonts/make', { as: user, method: 'POST',
+ body: new URLSearchParams({ prompt: 'rounded geometric sans for a kids app', name: 'Bubble Sans' }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
+ assert.equal(made.status, 303, made.text);
+ const id = made.headers.get('location').split('/').pop();
+ const row = (await t.store.db.many('SELECT * FROM ai_typefaces WHERE id=$1', [id]))[0];
+ assert.ok(row);
+ assert.equal(row.owner, user.subject);
+ assert.equal(row.name, 'Bubble Sans');
+ assert.equal(row.license, 'OFL-1.1');
+ assert.equal(row.model, 'claude-opus-5-5');
+ assert.ok(row.glyphs.length > 50, 'every letter is drawn');
+ assert.ok(row.glyphs.every(g => g.d && g.char && g.advance));
+
+ const specimen = await t.get('/fonts/made/' + id, { as: user });
+ assert.equal(specimen.status, 200);
+ assert.match(specimen.text, /Bubble Sans/);
+ assert.match(specimen.text, /font\/woff2;base64/);
+ assert.match(specimen.text, /Redraw one letter/);
+
+ const woff2 = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });
+ assert.equal(woff2.status, 200);
+ assert.equal(woff2.buffer.slice(0, 4).toString('latin1'), 'wOF2');
+ assert.match(woff2.headers.get('content-disposition') || '', /attachment/);
+ const otf = await t.get('/api/v1/typefaces/' + id + '/font.otf', { as: user });
+ assert.equal(otf.status, 200);
+ assert.equal(otf.buffer.slice(0, 4).toString('latin1'), 'OTTO');
+ });
+
+ await check('another person cannot read a typeface or its files', async () => {
+ const rows = await t.store.db.many('SELECT id FROM ai_typefaces LIMIT 1');
+ const id = rows[0].id;
+ assert.equal((await t.get('/api/v1/typefaces/' + id)).status, 401);
+ assert.equal((await t.get('/api/v1/typefaces/' + id, { as: other })).status, 404);
+ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: other })).status, 404);
+ assert.equal((await t.get('/fonts/made/' + id, { as: other })).status, 404);
+ assert.equal((await t.get('/api/v1/typefaces/' + id + '/font.woff2')).status, 401);
+ });
+
+ await check('one letter is redrawn with a note', async () => {
+ const rows = await t.store.db.many('SELECT id, glyphs FROM ai_typefaces LIMIT 1');
+ const id = rows[0].id;
+ const before = rows[0].glyphs.length;
+ const redrawn = await t.get('/fonts/made/' + id + '/glyph', { as: user, method: 'POST',
+ body: new URLSearchParams({ char: 'A', note: 'make the apex rounder' }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
+ assert.equal(redrawn.status, 303, redrawn.text);
+ const after = await t.store.db.many('SELECT glyphs, note FROM ai_typefaces WHERE id=$1', [id]);
+ assert.equal(after[0].glyphs.length, before);
+ assert.ok(after[0].glyphs.some(g => g.char === 'A'));
+ assert.equal(after[0].note, 'make the apex rounder');
+ // The font still compiles after the redraw.
+ const file = await t.get('/api/v1/typefaces/' + id + '/font.woff2', { as: user });
+ assert.equal(file.status, 200);
+ });
+
+ await check('a vector is drawn, saved and downloadable as SVG and PNG', async () => {
+ const made = await t.get('/vectors/make', { as: user, method: 'POST',
+ body: new URLSearchParams({ prompt: 'a paper plane folded from a single sheet', kind: 'icon' }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
+ assert.equal(made.status, 303, made.text);
+ const id = made.headers.get('location').split('/').pop();
+ const row = (await t.store.db.many('SELECT * FROM ai_vectors WHERE id=$1', [id]))[0];
+ assert.ok(row);
+ assert.equal(row.owner, user.subject);
+ assert.ok(row.svg.startsWith('<svg'));
+ assert.ok(!row.svg.includes('<script'));
+
+ const page = await t.get('/vectors/made/' + id, { as: user });
+ assert.equal(page.status, 200);
+ assert.match(page.text, /Download the SVG/);
+ const svg = await t.get('/vectors/made/' + id + '.svg', { as: user });
+ assert.equal(svg.status, 200);
+ assert.match(svg.headers.get('content-type') || '', /image\/svg\+xml/);
+ assert.match(svg.headers.get('content-security-policy') || '', /default-src 'none'.*sandbox/, 'a saved drawing runs nothing when opened on its own');
+ const png = await t.get('/vectors/made/' + id + '.png', { as: user });
+ assert.equal(png.status, 200);
+ assert.equal(png.buffer.slice(1, 4).toString('latin1'), 'PNG');
+ });
+
+ await check('another person cannot read a vector', async () => {
+ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');
+ const id = rows[0].id;
+ assert.equal((await t.get('/api/v1/vectors/' + id)).status, 401);
+ assert.equal((await t.get('/api/v1/vectors/' + id, { as: other })).status, 404);
+ assert.equal((await t.get('/vectors/made/' + id + '.svg', { as: other })).status, 404);
+ });
+
+ await check('the drawing is redrawn with a note', async () => {
+ const rows = await t.store.db.many('SELECT id FROM ai_vectors LIMIT 1');
+ const id = rows[0].id;
+ const edited = await t.get('/vectors/made/' + id + '/edit', { as: user, method: 'POST',
+ body: new URLSearchParams({ note: 'make the wings longer' }).toString(),
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://openvibe.design' } });
+ assert.equal(edited.status, 303, edited.text);
+ const after = await t.store.db.many('SELECT note FROM ai_vectors WHERE id=$1', [id]);
+ assert.equal(after[0].note, 'make the wings longer');
+ });
+
+ await check('MCP can make a typeface and a vector, and needs a signed-in person', async () => {
+ const anonymous = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 1, method: 'tools/call',
+ params: { name: 'openvibe_design_make_vector', arguments: { prompt: 'a small bird' } } } });
+ assert.equal(anonymous.json().result.isError, true);
+ assert.match(anonymous.json().result.content[0].text, /Sign in/);
+
+ const made = await t.get('/mcp', { as: user, method: 'POST', json: { jsonrpc: '2.0', id: 2, method: 'tools/call',
+ params: { name: 'openvibe_design_make_typeface', arguments: { prompt: 'a plain geometric sans', name: 'Grid Sans' } } } });
+ assert.equal(made.json().result.isError, false, made.text);
+ const out = made.json().result.structuredContent;
+ assert.equal(out.name, 'Grid Sans');
+ assert.ok(out.glyphs > 50);
+ assert.equal(out.model, 'claude-opus-5-5');
+ assert.ok(out.download.woff2.includes('/font.woff2'));
+
+ const list = await t.get('/mcp', { method: 'POST', json: { jsonrpc: '2.0', id: 3, method: 'tools/list' } });
+ const names = list.json().result.tools.map(x => x.name);
+ assert.ok(names.includes('openvibe_design_make_typeface'));
+ assert.ok(names.includes('openvibe_design_make_vector'));
+ // The tools that write are not marked read only.
+ const vector = list.json().result.tools.find(x => x.name === 'openvibe_design_make_vector');
+ assert.equal(vector.annotations.readOnlyHint, false);
+ });
+
+ await check('the API makes a typeface for a signed-in person', async () => {
+ const made = await t.get('/api/v1/typefaces', { as: user, method: 'POST', json: { prompt: 'a condensed sans' } });
+ assert.equal(made.status, 201, made.text);
+ assert.ok(made.json().glyphs.length > 50);
+ assert.equal(made.json().license, 'OFL-1.1');
+ const anon = await t.get('/api/v1/typefaces', { method: 'POST', json: { prompt: 'a condensed sans' } });
+ assert.equal(anon.status, 401);
+ });
+ } finally { await t.close(); }
+
+ // ── The cap ──
+ await check('a person runs out of runs for the day and is told in plain words', async () => {
+ const capped = await boot({ ai: fakeAi(), env: { DESIGN_AI_MAKE_PER_DAY: '1' } });
+ try {
+ const user = capped.network.addUser('cleo');
+ const first = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });
+ assert.equal(first.status, 201, first.text);
+ const second = await capped.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'another bird' } });
+ assert.equal(second.status, 429);
+ assert.match(second.json().error, /today|tomorrow/i);
+ // The cap is per person: somebody else may still make one.
+ const other = capped.network.addUser('dara');
+ const theirs = await capped.get('/api/v1/vectors', { as: other, method: 'POST', json: { prompt: 'a small bird' } });
+ assert.equal(theirs.status, 201, theirs.text);
+ // The runs are counted, so the cap is measured against something.
+ const runs = await capped.store.db.many('SELECT owner, kind FROM ai_make_runs');
+ assert.equal(runs.length, 2);
+ assert.deepEqual(new Set(runs.map(r => r.kind)), new Set(['vector']));
+ } finally { await capped.close(); }
+ });
+
+ // ── The account the work belongs to (ADR-033) ──
+ await check('an export carries the person\'s own typefaces and vectors; deleting the account takes them away', async () => {
+ const calls = [];
+ const secret = `fixture_${'design'.repeat(8)}`;
+ const t2 = await boot({ ai: fakeAi(), env: { DESIGN_EVENTS_SECRET: secret },
+ accountSend: async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201, json: async () => ({}) }; } });
+ try {
+ const owner = t2.network.addUser('ida');
+ const stranger = t2.network.addUser('eve');
+ await t2.get('/api/v1/typefaces', { as: owner, method: 'POST', json: { prompt: 'a plain geometric sans', name: 'Kept Sans' } });
+ await t2.get('/api/v1/vectors', { as: owner, method: 'POST', json: { prompt: 'a small bird' } });
+ await t2.get('/api/v1/vectors', { as: stranger, method: 'POST', json: { prompt: 'another bird' } });
+
+ const exportEvent = { event_id: 'evt_01JZ0000000000000000000E11', event_type: 'network.account.export_requested', source: 'network',
+ payload: { export_id: 'exp_01JZ0000000000000000000EX2', subject: owner.subject } };
+ const raw = JSON.stringify({ event: exportEvent, seq: 1 });
+ const res = await t2.get('/internal/events', { method: 'POST', body: raw,
+ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, secret) } });
+ assert.equal(res.status, 200, res.text);
+ const part = calls.find((c) => c.path.startsWith('/internal/account-exports/'));
+ assert.ok(part, 'the part was pushed to Network');
+ const names = part.body.files.map((f) => f.name).sort();
+ assert.ok(names.includes('ai_typefaces.json'), names.join(', '));
+ assert.ok(names.includes('ai_vectors.json'), names.join(', '));
+ assert.ok(names.includes('ai_make_runs.json'), names.join(', '));
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('Kept Sans'), 'the typeface travels with the person');
+ assert.ok(!all.includes(stranger.subject), 'another account is never named');
+
+ const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D11', event_type: 'network.account.deleted', source: 'network',
+ payload: { deletion_id: 'del_01JZ0000000000000000000DE2', subject: owner.subject, aliases: [] } };
+ const rawDelete = JSON.stringify({ event: deleteEvent, seq: 1 });
+ const gone = await t2.get('/internal/events', { method: 'POST', body: rawDelete,
+ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(rawDelete, secret) } });
+ assert.equal(gone.status, 200, gone.text);
+ assert.equal(gone.json().outcome, 'erased');
+ const db = t2.ctx.s.db;
+ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_typefaces WHERE owner = $1', [owner.subject])), 0);
+ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [owner.subject])), 0);
+ assert.equal(Number(await db.value('SELECT COUNT(*) FROM ai_make_runs WHERE owner = $1', [owner.subject])), 0);
+ assert.ok(Number(await db.value('SELECT COUNT(*) FROM ai_vectors WHERE owner = $1', [stranger.subject])) > 0, 'another person\'s drawing stays');
+ } finally { await t2.close(); }
+ });
+
+ // ── When the model is not there ──
+ await check('a plain word when the model is not configured', async () => {
+ const off = await boot({ ai: { enabled: false, text: async () => { throw new Error('no'); } } });
+ try {
+ const user = off.network.addUser('fay');
+ const made = await off.get('/api/v1/vectors', { as: user, method: 'POST', json: { prompt: 'a small bird' } });
+ assert.equal(made.status, 503);
+ assert.match(made.json().error, /not configured/i);
+ assert.ok(!/Error:|at Object/.test(made.json().error));
+ } finally { await off.close(); }
+ });
+
+ done();
+})();