figura-cli 0.23.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/skills/fig/SKILL.md +61 -18
- package/skills/skills.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -68,4 +68,4 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
68
68
|
`}class rX{constructor(x=YN.stdin,Q=YN.stdout){this._stdin=x,this._stdout=Q,this._readBuffer=new dX,this._started=!1,this._ondata=(W)=>{this._readBuffer.append(W),this.processReadBuffer()},this._onerror=(W)=>{this.onerror?.(W)}}async start(){if(this._started)throw Error("StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.");this._started=!0,this._stdin.on("data",this._ondata),this._stdin.on("error",this._onerror)}processReadBuffer(){while(!0)try{let x=this._readBuffer.readMessage();if(x===null)break;this.onmessage?.(x)}catch(x){this.onerror?.(x)}}async close(){if(this._stdin.off("data",this._ondata),this._stdin.off("error",this._onerror),this._stdin.listenerCount("data")===0)this._stdin.pause();this._readBuffer.clear(),this.onclose?.()}send(x){return new Promise((Q)=>{let W=WN(x);if(this._stdout.write(W))Q();else this._stdout.once("drain",Q)})}}function XN(x,Q,W){return x.toUpperCase()!=="GET"&&Q==="global"&&!W}function JN(x){let Q={name:x.name};if(x.appDescription!==void 0)Q.appDescription=x.appDescription;if(x.tokensBlock!==void 0)Q.tokensBlock=x.tokensBlock;if(x.surfaces!==void 0)Q.surfaces=x.surfaces;if(x.identity!==void 0)Q.identity=x.identity;if(x.platforms!==void 0)Q.platforms=x.platforms;if(x.proseSource!==void 0)Q.proseSource=x.proseSource;return Q}import{createHash as bk}from"node:crypto";import{existsSync as zk,readdirSync as sX,readFileSync as tX}from"node:fs";import{homedir as vk}from"node:os";import{fileURLToPath as GN}from"node:url";import{dirname as Bk,join as Ox,relative as wk,sep as Kk}from"node:path";var Hk=".figura-version";var bN="fig";function zN(x){let Q=[],W=(X)=>{for(let J of sX(X,{withFileTypes:!0})){let G=Ox(X,J.name);if(J.isDirectory())W(G);else if(J.isFile())Q.push(G)}};try{for(let X of sX(x,{withFileTypes:!0}))if(X.isDirectory()&&X.name===bN)W(Ox(x,X.name))}catch{return null}if(Q.length===0)return null;let Y=bk("sha256");for(let X of Q.sort()){let J=wk(x,X).split(Kk).join("/");Y.update(J),Y.update("\x00"),Y.update(tX(X)),Y.update("\x00")}return Y.digest("hex").slice(0,16)}function $N(x){try{return sX(x,{withFileTypes:!0}).some((Q)=>Q.isDirectory()&&zk(Ox(x,Q.name,"SKILL.md")))}catch{return!1}}function vN(){let x=[new URL("../skills/",import.meta.url),new URL("../../skills/",import.meta.url)].map((W)=>GN(W));for(let W of x)if($N(W))return W;let Q=GN(new URL("./",import.meta.url));for(let W=0;W<8;W++){let Y=Ox(Q,".claude","skills");if($N(Y))return Y;let X=Bk(Q);if(X===Q)break;Q=X}return null}function Vk(){let x=vN();return x?zN(x):null}function qk(){let x=vN();if(!x)return null;try{return JSON.parse(tX(Ox(x,"skills.json"),"utf8")).skills?.[bN]?.version??null}catch{return null}}function Nk(x){try{return tX(Ox(x,Hk),"utf8").trim()||null}catch{return null}}function Lk(x,Q){let W=(J)=>{let G=/^\D*(\d+)\.(\d+)\.(\d+)/.exec(J.trim());return G?[Number(G[1]),Number(G[2]),Number(G[3])]:null},Y=W(x),X=W(Q);if(!Y||!X)return!1;for(let J=0;J<3;J++){if(Y[J]>X[J])return!0;if(Y[J]<X[J])return!1}return!1}function Uk(x){let{stamp:Q,installedHash:W,bundledHash:Y,referenceVersion:X}=x;if(W==null)return"absent";if(Y!=null&&W===Y&&Q===X)return"pristine";if(Q!=null&&X!=null&&Lk(X,Q))return"stale";return"modified"}function Fk(x,Q=Ox(vk(),".claude","skills")){try{let W=Nk(Q),Y=zN(Q),X=Vk(),J=Uk({stamp:W,installedHash:Y,bundledHash:X,referenceVersion:qk()});return{version:W,hash:Y,status:J}}catch{return{version:null,hash:null,status:"absent"}}}function I3(x){return{channel:"mcp",mcpVersion:x,skill:Fk(x)}}function BN(x){if(x==="modified")return"⚠ your fig skill is MODIFIED (differs from figura-cli's shipped version) — run `figura install` to restore it";if(x==="stale")return"⚠ your fig skill is outdated (behind figura-cli) — run `figura install` to reinstall it";return null}import{promises as YJ}from"node:fs";import{homedir as XJ}from"node:os";import{join as JJ}from"node:path";var ik=604800000,Ck=21600000;function S3(x){try{let Q=new URL(x);return`${Q.protocol}//${Q.host}`.toLowerCase()}catch{return x.trim().toLowerCase().replace(/\/+$/,"")}}async function GJ(x,Q="latest",W){let Y=await Promise.resolve().then(() => (AN(),jN)),X=W!==void 0?W:Y.getUserAgent()??null;if(X){let J=Y.resolveCommand(X,"global",[`${x}@${Q}`]);if(J)return{command:`${J.command} ${J.args.join(" ")}`,generic:!1}}return{command:`npm i -g ${x}@${Q}`,generic:!0}}function $J(x){return x.generic?`Update with your package manager's global install, e.g. ${x.command}`:`Run: ${x.command}`}function IN(x){if(x==="figura-cli")return{pkg:"figura-cli",cacheFile:JJ(XJ(),".figura","update-check.json"),minHeader:"x-figura-min-cli",latestHeader:"x-figura-latest-cli",label:"figura",installPkg:"figura-cli"};return{pkg:"figura-mcp",cacheFile:JJ(XJ(),".figura","update-check-mcp.json"),minHeader:"x-figura-min-mcp",latestHeader:"x-figura-latest-mcp",label:"figura-mcp",installPkg:"figura-mcp"}}function Tk(x,Q,W){if(!Q.min&&!Q.latest)return null;let Y={...x},X=!1,J=(G)=>typeof G!=="number"||W-G>=Ck;if(Q.min&&(x.min!==Q.min||J(x.minAt)))Y.min=Q.min,Y.minAt=W,X=!0;if(Q.latest&&(x.latest!==Q.latest||J(x.latestAt)))Y.latest=Q.latest,Y.latestAt=W,X=!0;return X?Y:null}function ON(x,Q){return typeof x==="number"&&Q-x<ik}function bJ(x,Q,W){let Y=x.serverAdvice?.[Q];if(!Y)return{};return{min:ON(Y.minAt,W)?Y.min:void 0,latest:ON(Y.latestAt,W)?Y.latest:void 0}}function w9(x,Q){let W=(J)=>{let G=/^\D*(\d+)\.(\d+)\.(\d+)/.exec(J.trim());return G?[Number(G[1]),Number(G[2]),Number(G[3])]:null},Y=W(x),X=W(Q);if(!Y||!X)return!1;for(let J=0;J<3;J++){if(Y[J]>X[J])return!0;if(Y[J]<X[J])return!1}return!1}function kk(x){if(x.CI)return"ci";if(x.FIGURA_NO_UPDATE_NOTIFIER==="1"||x.NO_UPDATE_NOTIFIER==="1")return"opt-out";return null}function _k(x,Q,W,Y){return`⚠ ${x.label} ${Q} → ${W} available. ${Y}`}function uk(x,Q,W,Y){return`⚠ ${x.label} ${Q} is below the minimum supported ${W} — `+`some tools may fail. ${Y}`}function EN(x,Q,W){let Y=x.latest,X=bJ(x,Q,W).latest;if(Y&&X)return w9(X,Y)?X:Y;return Y??X}function pk(x,Q,W,Y,X=Date.now()){let J=bJ(W,Y,X).min;if(J&&w9(J,Q))return{level:"firm",target:J};let G=EN(W,Y,X);if(G&&w9(G,Q))return{level:"stale",target:G};return null}function lk(x,Q,W,Y){return Q.level==="firm"?`Your ${x.label} ${W} is below the minimum supported ${Q.target} — `+`some Figura tools may fail. Ask the user to update: ${Y}`:`${x.label} ${W} → ${Q.target} available. Ask the user to update: ${Y}`}async function SN(x){try{let Q=x.cache??await zJ(x.stream),W=pk(x.stream,x.current,Q,x.origin,x.now);if(!W)return null;let Y=$J(await GJ(x.stream.installPkg,"latest",x.agent));return lk(x.stream,W,x.current,Y)}catch{return null}}function PN(x,Q){let W=Q(x.minHeader)?.trim(),Y=Q(x.latestHeader)?.trim();return{min:W||void 0,latest:Y||void 0}}async function RN(x,Q,W,Y=Date.now()){try{if(!W.min&&!W.latest)return;let X=await zJ(x),J={...X.serverAdvice??{}},G=Tk(J[Q]??{},W,Y);if(!G)return;J[Q]=G,await nk(x,{...X,serverAdvice:J})}catch{}}async function zJ(x){try{let Q=JSON.parse(await YJ.readFile(x.cacheFile,"utf8"));return Q&&typeof Q==="object"?Q:{}}catch{return{}}}async function nk(x,Q){await YJ.mkdir(JJ(XJ(),".figura"),{recursive:!0,mode:448}),await YJ.writeFile(x.cacheFile,JSON.stringify(Q,null,2)+`
|
|
69
69
|
`,{mode:384})}async function ZN(x,Q,W,Y=process.env,X=Date.now()){try{if(kk(Y))return;let J=await zJ(Q),G=bJ(J,W,X).min;if(G&&w9(G,x)){let b=$J(await GJ(Q.installPkg));process.stderr.write(uk(Q,x,G,b)+`
|
|
70
70
|
`);return}let $=EN(J,W,X);if($&&w9($,x)){let b=$J(await GJ(Q.installPkg));process.stderr.write(_k(Q,x,$,b)+`
|
|
71
|
-
`)}}catch{}}var yk=F.object({name:F.string(),hex:F.string().describe("3/4/6/8-digit hex, e.g. #1FB6A6 or #1FB6A6FF."),role:F.string().optional(),meaning:F.string().optional()}).passthrough(),gk=F.object({id:F.string().optional(),rule:F.string()}).passthrough(),hk=F.object({colors:F.array(yk).optional(),gradient:F.string().optional(),type:F.object({families:F.record(F.string()).optional(),ramp:F.array(F.number()).optional(),rules:F.string().optional()}).passthrough().optional(),spacing:F.array(F.number()).optional(),radius:F.array(F.number()).optional(),voice:F.object({nouns:F.array(F.string()).optional(),promise:F.string().optional(),lines:F.array(F.string()).optional(),tone:F.string().optional()}).passthrough().optional(),forbidden:F.array(gk).optional()}).passthrough(),vJ=F.object({components:F.array(F.object({name:F.string(),spec:F.string()}).passthrough()).optional(),icons:F.string().optional(),layout:F.string().optional(),surfaces:F.array(F.string()).optional(),bans:F.array(F.string()).optional(),tokensBlock:F.string().optional()}).passthrough(),ok=F.object({mobile:vJ.optional(),web:vJ.optional(),desktop:vJ.optional()}).passthrough(),ak="https://api.figura.so",dk="mcp",iN=wJ(pN(),".figura","config.json"),rk=wJ(".figura","config.json");async function CN(x){try{let Q=JSON.parse(await K9.readFile(x,"utf8"));if(Q&&typeof Q==="object")return Q}catch{}return{}}async function sk(x=process.cwd()){let Q=pN(),W=x;for(;;){if(W!==Q){let X=wJ(W,rk);try{return await K9.access(X),X}catch{}}let Y=fk(W);if(Y===W)return null;W=Y}}async function Z3(){let x=await sk(),Q=x?await CN(x):{},W=await CN(iN),Y={...W,...Q},X=process.env.FIGURA_TOKEN||Y.token||void 0,J=process.env.FIGURA_TOKEN?"env":Q.token?"project":W.token?"global":"none",G=(process.env.FIGURA_API_URL||Y.apiUrl||ak).replace(/\/+$/,"");return{token:X,apiUrl:G,team:Y.team,source:J,configPath:J==="project"?x:J==="global"?iN:null}}async function U0(x,Q={}){let W=await Z3(),Y=W.token;if(!Y)throw Error("No Figura token. Run `figura link` in this repo (or `figura login`, or set FIGURA_TOKEN).");let X=(Q.method??"GET").toUpperCase();if(XN(X,W.source,process.env.FIGURA_ALLOW_GLOBAL==="1"))throw Error(`this project isn't linked to a Figura team — the global credential${W.team?` (team '${W.team}')`:""} would receive this write. Ask the user to run \`figura link\` in this repo (or set FIGURA_ALLOW_GLOBAL=1 to use the global team deliberately).`);let J=`${W.apiUrl}${x.startsWith("/")?"":"/"}${x}`,G={Accept:"application/json",Authorization:`Bearer ${Y}`,"X-Figura-Source":dk},$;if(Q.body!==void 0)G["Content-Type"]="application/json",$=JSON.stringify(Q.body);let b=await o9.request({url:J,method:Q.method??"GET",headers:G,data:$,validateStatus:()=>!0,transformRequest:[(B)=>B],transformResponse:[(B)=>B]});{let B=b.headers,K=(w)=>{let V=B?.[w];return typeof V==="string"?V:void 0};RN(R3,S3(W.apiUrl),PN(R3,K))}let z=typeof b.data==="string"?b.data:"",v=void 0;if(z)try{v=JSON.parse(z)}catch{v=z}if(b.status<200||b.status>=300){let B=v&&typeof v==="object"&&v!==null&&"error"in v?String(v.error):typeof v==="string"&&v?v:b.statusText||`HTTP ${b.status}`;throw Error(`${B} (HTTP ${b.status})`)}return v}var TN=2500,tk=600000,ek=60000,kN=(x)=>new Promise((Q)=>setTimeout(Q,x));async function x_(x){let Q=Date.now(),W=null;for(;;){let Y;try{Y=await U0(`/v1/jobs/${encodeURIComponent(x)}`),W=null}catch(X){if(W??=Date.now(),Date.now()-W>=ek){let J=X instanceof Error?X.message:String(X);throw Error(`Lost contact with the Figura API while waiting for job ${x} (${J}). `+`The job may still complete — resume by checking GET /v1/jobs/${x} instead of regenerating.`)}await kN(TN);continue}if(Y.status==="done")return Y;if(Y.status==="error")throw Error(Y.error||"The generation job failed. Please retry.");if(Date.now()-Q>tk)throw Error(`Timed out waiting for job ${x} — it may still complete; `+`check GET /v1/jobs/${x} instead of regenerating.`);await kN(TN)}}function D0(x){return{content:[{type:"text",text:JSON.stringify(x,null,2)}]}}function P0(x){return{content:[{type:"text",text:x instanceof Error?x.message:String(x)}],isError:!0}}var _N=!1;async function H9(x){try{if(_N)return x;if(!x||typeof x!=="object"||Array.isArray(x))return x;let{apiUrl:Q}=await Z3(),W=await SN({stream:R3,current:S8,origin:S3(Q)});if(!W)return x;return _N=!0,{...x,notice:W}}catch{return x}}function P3(x,Q){if(!x||typeof x!=="object"||Array.isArray(x))return x;let W=BN(Q);if(!W)return x;return{...x,skillWarning:W}}var E8=()=>F.string().optional().describe("Product area / surface the fig belongs to, e.g. 'dashboard', 'settings', 'onboarding'. Free text."),V9=["portrait","landscape","both"],i3=["single","compare","variations"],{version:S8,name:Q_}=(()=>{try{let x=JSON.parse(mk(new URL("../package.json",import.meta.url),"utf8"));return{version:typeof x.version==="string"?x.version:"0.0.0",name:typeof x.name==="string"?x.name:void 0}}catch{return{version:"0.0.0"}}})(),R3=IN(Q_),I0=new aX({name:"figura",version:S8});I0.registerTool("figura_whoami",{title:"Figura: whoami",description:"Return the identity (team, actor, scopes) for the configured Figura token, plus `binding` — which config tier supplied it (env | project | global). A `global` binding means this project is not linked to a team and writes will be refused; ask the user to run `figura link` in the repo.",inputSchema:{}},async()=>{try{let x=await U0("/v1/whoami"),{source:Q,configPath:W}=await Z3();return D0(await H9({...x,binding:{source:Q,configPath:W}}))}catch(x){return P0(x)}});I0.registerTool("figura_set_brand",{title:"Figura: set brand profile",description:'Set (upsert) the team brand profile that the fig generator renders against. Use this to "introduce a repo to Figura": scan the codebase for its design system, synthesize the brand, then push it here. `name` is required. PREFERRED — send a STRUCTURED brand: `identity` (shared colors/gradient/type/spacing/radius/voice/forbidden) + `platforms` (per-platform surfaces, keys mobile|web|desktop) — this makes the brand structured + addressable (is_structured=true). LEGACY/fallback — send prose: `tokensBlock` (freeform design-token reference) + `surfaces` (the product\'s own key areas). `appDescription` is a one-clause product summary. This is a partial merge — fields you omit keep their saved value; a structured write clears the dormant prose columns and a prose write downgrades to legacy. Editing the team brand — confirm with the user before calling.',inputSchema:{name:F.string().describe("Brand / app name (required)."),appDescription:F.string().optional().describe("One clause on what the product is + who it is for."),identity:hk.optional().describe("STRUCTURED shared identity (platform-agnostic): { colors:[{name,hex,role?,meaning?}], gradient?, type?:{families?:{slot:font},ramp?:number[],rules?}, spacing?:number[], radius?:number[], voice?:{nouns?,promise?,lines?,tone?}, forbidden?:[{id?,rule}] }. hex is 3/4/6/8 digits. Sending this makes the brand STRUCTURED (is_structured=true). Validated server-side."),platforms:ok.optional().describe("STRUCTURED per-platform surfaces — a map keyed by mobile|web|desktop; include ONLY the platforms the product targets: { <platform>: { components?:[{name,spec}], icons?, layout?, surfaces?:string[], bans?:string[], tokensBlock? } }. `tokensBlock` here is a per-platform escape hatch for guidance that does not fit a structured field."),proseSource:F.string().optional().describe(`The human-readable freeform description this structured brand was authored from — stored as the editable source shown in the web brand editor; NOT used by generation. Send it alongside a structured write so a skill/CLI/MCP-authored brand still seeds the web editor's "Author from a description" textarea (a repo scan should pass the prose it synthesized the brand from; a prose→structured upgrade should pass the original prose).`),tokensBlock:F.string().optional().describe("LEGACY prose fallback: freeform plain-text design-token reference (colors + hex + role, type scale + faces, spacing, gradient, voice/conventions). Prefer `identity`/`platforms`. Injected verbatim into the fig-generation prompt."),surfaces:F.array(F.string()).optional().describe(`LEGACY prose fallback: the product's own key areas/sections (e.g. ["Feed", "Settings", "Checkout"]) — read from the repo's real navigation/routes. For a structured brand put surfaces under each platform instead. Pass [] to clear.`)}},async(x)=>{try{return D0(await U0("/brand",{method:"PUT",body:JN(x)}))}catch(Q){return P0(Q)}});I0.registerTool("figura_brand_schema",{title:"Figura: brand schema",description:"Return the JSON Schema for the STRUCTURED brand — the ONE authority (derived server-side from the zod schema PUT /brand validates). Fetch this BEFORE synthesizing a brand for figura_set_brand's `identity`/`platforms`, and author against it so the brand validates first try (strict keys; hex 3/4/6/8 digits; platform keys mobile|web|desktop; no blank array entries). Read-only. Returns { schema }.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/brand/schema"))}catch(x){return P0(x)}});var W_={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",svg:"image/svg+xml"},BJ=5242880;function uN(x){let Q=x.split(/[?#]/)[0].split(".").pop()?.toLowerCase()??"";return W_[Q]}async function lN(x,Q){let W,Y=Q;if(/^https?:\/\//i.test(x)){let X=await o9.get(x,{responseType:"arraybuffer",maxContentLength:BJ,timeout:30000});if(W=Buffer.from(X.data).toString("base64"),!Y){let J=String(X.headers["content-type"]??"").split(";")[0].trim().toLowerCase();Y=J.startsWith("image/")?J:uN(x)}}else{let{size:X}=await K9.stat(x);if(X>BJ)throw Error(`'${x}' is ${(X/1048576).toFixed(1)}MB — over the ${BJ/1048576}MB asset limit.`);if(W=(await K9.readFile(x)).toString("base64"),!Y)Y=uN(x)}if(!Y)throw Error(`Could not determine an image content-type for '${x}' — pass contentType (e.g. image/png).`);return{data:W,contentType:Y}}async function Y_(x,Q){let{data:W,contentType:Y}=await lN(x,Q),X=await U0("/v1/assets",{method:"POST",body:{data:W,contentType:Y}});if(!X||typeof X.url!=="string")throw Error("Asset upload did not return a URL.");return X.url}var nN=()=>F.array(F.object({source:F.string().describe("The image — a local file path or an http(s) URL (e.g. a screenshot)."),match:F.string().optional().describe('Exact string in the HTML to replace with the hosted URL (defaults to `source` — i.e. write <img src="<source>"> in the HTML).')})).optional().describe("Images to attach: each is uploaded to durable figura hosting (stable non-expiring URL, allowed by the fig CSP) and substituted into the HTML in place of `match`/`source`. Use for real screenshots/photos/logos instead of base64 data-URIs (HTML size cap) or external URLs (expire/CSP-blocked).");async function mN(x,Q){if(!Q?.length)return{html:x,uploaded:[]};let W=x,Y=[];for(let X of Q){let J=X.match??X.source;if(!W.includes(J))throw Error(`images: '${J}' does not occur in the html — reference it (e.g. <img src="${J}">) so it can be swapped for the hosted URL, or set 'match'.`);let G=await Y_(X.source);W=W.split(J).join(G),Y.push({source:X.source,url:G})}return{html:W,uploaded:Y}}function fN(x,Q){if(!Q.length)return x;if(x&&typeof x==="object"&&!Array.isArray(x))return{...x,assets:Q};return{result:x,assets:Q}}I0.registerTool("figura_create_fig",{title:"Figura: create fig",description:"Create a new visualization from an HTML document — this HOSTS it (the create IS the hosting; there is no separate \"land it in Figura\" step). Every create returns `viewUrl`: ALWAYS a link you can open for approval, whether or not the fig is published — the public share url once published, else a team-scoped viewer link only signed-in teammates can open. The fig lands as a workspace DRAFT (`status: 'draft'`): hosted and viewable, visible to its author, but NOT in the team library and silent for teammates — iterate freely without claiming the team's attention, then COMMIT only the session's final recommendation (`commit: true` on that final create/revise, or figura_commit_fig). Do NOT pass publish:true just to get something viewable — omit it (default false) so the fig lands team-private, open `viewUrl` for review, and only publish when the human wants a link they can share outside the team (figura_publish_fig, once approved). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only (never blocks the create). If it comes back non-empty, read it and consider a figura_revise_fig call that binds the missing tokens before telling the user you're done. To include REAL images (screenshots/photos/logos), pass them via `images` — they are uploaded to durable figura hosting and their stable URLs substituted into the HTML. TIP — injected components: if your HTML shows phone frames (a compare/multi-screen mock), wrap each state in <div class=\"fig-device portrait|landscape\"><div class=\"fig-screen\">…</div><div class=\"fig-di\"></div><div class=\"fig-homebar\"></div></div> — Figura injects accurate iPhone frame CSS at save, BEFORE your <style>, so you normally leave those classes alone; if you do style them your rule wins by cascade (the frame still works), but to shrink frames for an overview grid wrap each frame in the injected `.fig-mini` wrapper (e.g. <div class=\"fig-mini\" style=\"--fig-scale:.34\"> … the .fig-device block … </div>) — it scales the device with transform and reserves the scaled layout box — rather than re-declaring .fig-device width/height or adding transform: scale() yourself (which fights the injected geometry), and never CSS zoom (it leaks unzoomed width into horizontal overflow). Same injection applies to an iOS status bar (<div class=\"fig-statusbar\" data-time=\"9:41\"></div>), a compare control bar (.fig-controls/.fig-seg), and the .frame-web browser window.",inputSchema:{html:F.string().describe("The full HTML document for the visualization."),images:nN(),title:F.string().optional().describe("Title (otherwise derived from the HTML)."),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional(),model:F.string().optional().describe("Model attribution."),device:F.string().optional().describe("Catalog iPhone id the fig targets (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3') — keys the injected frame/safe-area geometry."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team. Share link ONLY — does not commit the fig to the team library (that's `commit`)."),commit:F.boolean().optional().describe("Commit to the team library on create (sends status: 'published'). Default false — the fig lands as a workspace DRAFT: hosted + viewable via `viewUrl`, visible to its author, but not in the team library and silent for teammates. Set true ONLY when this write is the session's final recommendation (the fig the user asked for / signed off on); explorations and intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:F.string().optional().describe("OPTIONAL: the brief that produced this HTML, if there was one — you wrote the HTML yourself, so Figura never sees a prompt unless you pass it here. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig — lets teammates see 'why' this fig exists.")}},async(x)=>{try{let{html:Q,uploaded:W}=await mN(x.html,x.images),Y={html:Q};for(let J of["title","description","area","tags","orientation","mode","model","device","issue","publish","prompt"])if(x[J]!==void 0)Y[J]=x[J];if(x.commit)Y.status="published";let X=I3(S8);return Y.toolchain=X,D0(P3(await H9(fN(await U0("/v1/figs",{method:"POST",body:Y}),W)),X.skill.status))}catch(Q){return P0(Q)}});var X_=["single","compare","variations","overview-focus","document"];I0.registerTool("figura_compose_fig",{title:"Figura: compose fig",description:"PREFERRED way to build a device fig. You write ONLY each screen's CONTENT (the markup inside the phone) + typed params; Figura owns the STRUCTURE — the iPhone frame, the Dynamic Island/notch/home bar, the exact catalog artboard geometry, the REAL safe-area insets, and the composition (grid/diptych/overview). This means the parts that used to drift every few figs — hand-rolled artboards, guessed `--fig-safe-top:59px`, a forgotten `data-device` so no safe machinery stamped — are correct-by-construction: you never write them. Pass `layout` + an ARRAY of `screens`. For every DEVICE layout (single/compare/variations/overview-focus) each screen REQUIRES `device` (a catalog id like 'iphone-16' — fetch figura_fig_devices), `orientation` ('portrait'|'landscape'), and `safeZone` ('respect' = Figura pads your content to the device's real safe insets; 'ignore' = edge-to-edge splash/hero). Each screen's `html` is CONTENT ONLY — do NOT draw a frame, a bezel, a status bar, or safe padding; do NOT set `--fig-safe-*`; do NOT wrap in `.fig-device`. Style your content with brand tokens via `sharedCss` (ONE stylesheet all screens share) + optional per-screen `css`. Layouts: `single` (one frame), `compare` (as-is → proposed diptych — pass label per screen), `variations` (responsive grid + status chips), `overview-focus` (CSS overview⇄focus toggle for arguing a redesign), `document` (NO device framing — brand/overview figs; screens need no device/orientation/safeZone). Lands EXACTLY like figura_create_fig (workspace DRAFT, returns `viewUrl` + `brandWarnings` + `warnings`; commit the session's final pick). For a `document`/exotic hand-authored fig where you need full control of the HTML, use figura_create_fig instead.",inputSchema:{layout:F.enum(X_).describe("The composition Figura emits."),title:F.string().optional().describe("Fig title (else derived from the HTML)."),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional().describe('Fig-level orientation for the persisted row (may be "both").'),device:F.string().optional().describe("Fig-level default catalog device (per-screen `device` still keys each frame)."),sharedCss:F.string().optional().describe("ONE stylesheet ALL screens share — put brand tokens + shared classes here."),screens:F.array(F.object({html:F.string().describe("SCREEN CONTENT markup only — no frame, no bezel, no safe padding."),css:F.string().optional().describe("Optional per-screen content styles (appended unscoped)."),device:F.string().optional().describe("Catalog device id (e.g. 'iphone-16'). REQUIRED for device layouts."),orientation:F.enum(["portrait","landscape"]).optional().describe("REQUIRED for device layouts."),safeZone:F.enum(["respect","ignore"]).optional().describe("REQUIRED for device layouts. 'respect' pads to the real catalog safe insets; 'ignore' is edge-to-edge."),label:F.string().optional().describe("Chrome label (compare/variations chip, focus pill).")})).describe("The screens to compose. One for `single`; two for a `compare` diptych; N for variations/overview-focus/document."),model:F.string().optional().describe("Model attribution."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish to a shareable public URL on compose (requires a paid plan). Default false."),commit:F.boolean().optional().describe("Commit to the team library (sends status: 'published'). Default false — lands a workspace draft."),prompt:F.string().optional().describe("OPTIONAL brief that produced this fig (DB-only).")}},async(x)=>{try{let Q={layout:x.layout,screens:x.screens};for(let Y of["title","description","area","tags","orientation","device","sharedCss","model","issue","publish","prompt"])if(x[Y]!==void 0)Q[Y]=x[Y];if(x.commit)Q.status="published";let W=I3(S8);return Q.toolchain=W,D0(P3(await H9(await U0("/v1/figs/compose",{method:"POST",body:Q})),W.skill.status))}catch(Q){return P0(Q)}});I0.registerTool("figura_upload_asset",{title:"Figura: upload asset",description:"Upload a durable image asset and get back a STABLE, figura-hosted URL that never expires — use it in a fig's <img src> instead of a base64 data-URI (bloats the HTML past the publish size) or an external URL that expires (Instagram/CDN signed links). The fig CSP allows figura-hosted images. Provide `path` (a local image file), `url` (an http(s) image to fetch and re-host), or `data` (base64 bytes) + `contentType`. Accepts png/jpeg/gif/webp/avif/svg, up to 5MB. Returns `{ url }`.",inputSchema:{path:F.string().optional().describe("Local path to an image file to upload (content-type inferred from the extension)."),url:F.string().optional().describe("http(s) URL of an image to fetch and re-host (alternative to `path`) — use for expiring/signed source URLs so the fig keeps rendering after they die."),data:F.string().optional().describe("Base64-encoded image bytes (alternative to `path`/`url`). Requires `contentType`."),contentType:F.string().optional().describe("MIME type, e.g. image/png. Required with `data`; inferred from the extension/response for `path`/`url`.")}},async(x)=>{try{let Q=x.path??x.url;if(Q){let{data:W,contentType:Y}=await lN(Q,x.contentType);return D0(await U0("/v1/assets",{method:"POST",body:{data:W,contentType:Y}}))}if(!x.data)throw Error("Provide `path` (a local image file), `url` (an http(s) image), or `data` (base64 bytes).");if(!x.contentType)throw Error("`contentType` is required with `data` — e.g. image/png.");return D0(await U0("/v1/assets",{method:"POST",body:{data:x.data,contentType:x.contentType}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_generate_fig",{title:"Figura: generate fig",description:"Generate a new fig server-side from a natural-language brief, then persist it — this HOSTS it (the create IS the hosting). The engine runs Figura's on-brand generation — you do NOT write the HTML; just describe the screen. SPENDS TEAM CREDITS: only use when the user explicitly opts for a server render; otherwise default to writing the HTML yourself and landing it with figura_create_fig (free, uses the calling model). Runs as an async server job; this call polls until the fig lands (typically 30–90s). Returns the created fig (id, slug) and `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published). Like figura_create_fig, the generated fig lands as a workspace DRAFT — not in the team library, silent for teammates — commit the session's final pick with figura_commit_fig. Requires an active paid subscription.",inputSchema:{prompt:F.string().describe("A natural-language brief describing the screen/UI to generate."),area:E8(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional(),platform:F.enum(["mobile","web"]).optional().describe("Output platform: 'mobile' (iPhone frame) or 'web' (browser frame). When omitted, the engine INFERS it from the brief — a desktop/web/browser/landing-page/dashboard brief renders web, an iPhone/mobile/app-screen brief renders mobile — falling back to mobile only when neither is signalled. Pass this to force a platform regardless of the wording."),device:F.string().optional().describe("Catalog iPhone id to target (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),title:F.string().optional().describe("Title (otherwise derived from the generated HTML)."),fidelity:F.enum(["fast","high"]).optional().describe("Generation fidelity (default fast)."),publish:F.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team.")}},async(x)=>{try{let Q={prompt:x.prompt};for(let X of["area","orientation","mode","platform","device","title","publish"])if(x[X]!==void 0)Q[X]=x[X];if(x.fidelity!==void 0)Q.model=x.fidelity;let W=I3(S8);Q.toolchain=W,Q.async=!0;let Y=await U0("/v1/figs/generate",{method:"POST",body:Q});if(Y&&typeof Y==="object"&&typeof Y.jobId==="string"){let X=await x_(Y.jobId),J=X.result??{};return D0(P3(await H9(J.fig??X.result),W.skill.status))}return D0(P3(await H9(Y),W.skill.status))}catch(Q){return P0(Q)}});I0.registerTool("figura_fig_scaffold",{title:"Figura: fig scaffold",description:"Return the CANONICAL fig authoring scaffold — the exact system prompt Figura injects when it renders a fig server-side (design rules + exemplars + accent hints + your team's composed brand + the component/device kit context), composed for a target (platform/device/area/mode/orientation). USE THIS on the hand-build path (before writing a fig's HTML with figura_create_fig): author against this scaffold instead of a hand-maintained copy of the guidance, so your fig follows the SAME rules + brand the server would. Read-only — no fig is created, no credits are spent. Returns { scaffold, platform, mode, orientation, area, device, brand }.",inputSchema:{platform:F.enum(["mobile","web"]).optional().describe("Target platform: 'mobile' (iPhone frame, default) or 'web' (browser frame)."),device:F.string().optional().describe("Catalog iPhone id (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),area:E8(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional()}},async(x)=>{try{let Q=new URLSearchParams;for(let Y of["platform","device","area","orientation","mode"])if(x[Y]!==void 0)Q.set(Y,String(x[Y]));let W=Q.toString();return D0(await U0(`/v1/fig/scaffold${W?`?${W}`:""}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_fig_devices",{title:"Figura: fig devices",description:"Return the CANONICAL framed-device catalog — every valid `data-device` id plus its per-device geometry (logical dims portrait/landscape, safe-area insets, cutout island|notch|none, corner radius). USE THIS when authoring a CANVAS COMPOSITION fig that frames a specific phone in-fig (a `.fig-device` element with `data-device` set): pick a real id from here instead of guessing, since an unknown data-device silently gets NO frame. Read-only — no fig is created, no credits are spent. Returns { devices, default }.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/fig/devices"))}catch(x){return P0(x)}});I0.registerTool("figura_revise_fig",{title:"Figura: revise fig",description:"Create a new revision (version) of an existing fig from updated HTML — this HOSTS it, same as create. Returns the new fig (id, slug, version) and `viewUrl` — ALWAYS a link to open and re-review. The revision lands as a workspace DRAFT regardless of the parent's status — not in the team library, silent for teammates — so iterate freely; when a revision is the session's final agreed version, commit it (`commit: true` on that call, or figura_commit_fig). Publishing is NOT inherited from the fig being revised: the new revision defaults to unpublished (a team-scoped viewer `viewUrl`) even if the fig it revises is currently live — the existing public link keeps serving the OLD version until you pass `publish: true` on THIS call, which republishes at the SAME stable link (same publicId across the chain). The new version joins the original fig's chain — it does not replace it in place. Use list_revisions for the chain (rootFig + every version). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only. If it comes back non-empty, consider one more revision that binds the missing tokens. To include REAL images (screenshots/photos/logos), pass them via `images` — uploaded to durable figura hosting, stable URLs substituted into the HTML.",inputSchema:{id:F.string().describe("The fig to revise — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),html:F.string().describe("The full updated HTML document."),images:nN(),title:F.string().optional().describe("Title (otherwise derived from the HTML)."),description:F.string().optional(),area:F.string().optional().describe("Product area / surface; free text. Inherited from the parent if omitted."),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional().describe("Inherited from the parent if omitted."),mode:F.enum(i3).optional(),model:F.string().optional().describe("Model attribution."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish the new revision to a shareable public URL (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately. If the fig being revised is ALREADY published, pass true here too or its public link keeps serving the prior version. Share link ONLY — does not commit the revision to the team library (that's `commit`)."),commit:F.boolean().optional().describe("Commit this revision to the team library (sends status: 'published'). Default false — the revision lands as a workspace DRAFT even when the fig it revises is committed. Set true ONLY when this revision is the session's final agreed version; intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:F.string().optional().describe("OPTIONAL: the brief/ask that produced this revision, if there was one. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig.")}},async(x)=>{try{let{html:Q,uploaded:W}=await mN(x.html,x.images),Y={html:Q};for(let X of["title","description","area","tags","orientation","mode","model","issue","publish","prompt"])if(x[X]!==void 0)Y[X]=x[X];if(x.commit)Y.status="published";return D0(fN(await U0(`/v1/figs/${encodeURIComponent(x.id)}/revise`,{method:"POST",body:Y}),W))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_revisions",{title:"Figura: list revisions",description:"List the full revision chain for a fig (all versions sharing a root), oldest to newest.",inputSchema:{id:F.string().describe("Any fig in the chain — its id (`fig_…`), a UUID, share link/URL, or publicId.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/revisions`))}catch(Q){return P0(Q)}});I0.registerTool("figura_commit_fig",{title:"Figura: commit fig",description:"Commit a fig to the team library (status draft → published). Machine-created figs land as workspace DRAFTS — hosted, viewable via `viewUrl`, visible to their author, but NOT in the team library and silent for teammates. Committing is the explicit act that puts one in the team library and notifies the team (digest-batched) — so commit ONLY the session's final recommendation (the fig the user asked for / signed off on), never every exploration or intermediate iteration. If the final fig is one you're about to create/revise anyway, prefer `commit: true` on that call over a separate commit here. Orthogonal to figura_publish_fig (the public share link): a shared draft's URL resolves fine, and committing creates no link. Idempotent. Pass `uncommit: true` to reverse it (published → draft; the share link is untouched). Returns { id, status, committed, alreadyCommitted, viewUrl }.",inputSchema:{id:F.string().describe("The fig to commit — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),uncommit:F.boolean().optional().describe("Set true to uncommit: the fig leaves the team library back to an author-visible workspace draft. Defaults to false (commit).")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/${x.uncommit?"uncommit":"commit"}`,{method:"POST",body:{}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_publish_fig",{title:"Figura: publish fig",description:"Publish (or unpublish) an existing fig to its shareable public URL. The fig is ALREADY hosted and viewable team-privately without this (figura_create_fig/figura_revise_fig's `viewUrl`) — only call this once the human wants a link they can share outside the team. Share link ONLY — it does not put the fig in the team library (that's figura_commit_fig; a shared draft's URL resolves fine). Requires a paid plan. Returns { isPublic, url, publicId, viewUrl }.",inputSchema:{id:F.string().describe("The fig to publish — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),isPublic:F.boolean().optional().describe("Set false to unpublish (make private). Defaults to true (publish).")}},async(x)=>{try{let Q={};if(x.isPublic!==void 0)Q.isPublic=x.isPublic;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/publish`,{method:"POST",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_figs",{title:"Figura: list figs",description:"List visualizations for the team, optionally filtered by area or tag.",inputSchema:{area:E8(),tag:F.string().optional(),limit:F.number().optional(),cursor:F.string().optional()}},async(x)=>{try{let Q=new URLSearchParams;if(x.area)Q.set("area",x.area);if(x.tag)Q.set("tag",x.tag);if(x.limit!==void 0)Q.set("limit",String(x.limit));if(x.cursor)Q.set("cursor",x.cursor);let W=Q.toString();return D0(await U0(`/v1/figs${W?`?${W}`:""}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_get_fig",{title:"Figura: get fig",description:"Fetch a single visualization by id. The response carries `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published).",inputSchema:{id:F.string().describe("The fig — its id (`fig_…`), a UUID, a share link/URL, or its publicId.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_comment",{title:"Figura: comment",description:"Add a comment to a visualization.",inputSchema:{fig_id:F.string().describe("The fig to comment on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),body:F.string().describe("Comment text.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.fig_id)}/comments`,{method:"POST",body:{body:x.body}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_request_feedback",{title:"Figura: request feedback",description:"Ask a teammate to review a fig. Resolve `from` to a team member (by name or email); they get a notification (in-app, push, email) and the discussion happens in the fig's comment thread. Use this when the user says they want a specific person's feedback/eyes/review on a fig. Returns { ok, request: { to, status }, figUrl }.",inputSchema:{id:F.string().describe("The fig to request feedback on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),from:F.string().describe("The teammate to ask, by name or email (resolved within the team)."),note:F.string().optional().describe("Optional message for them (e.g. what to look at).")}},async(x)=>{try{let Q={from:x.from};if(x.note!==void 0)Q.note=x.note;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/request-feedback`,{method:"POST",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_sources",{title:"Figura: list sources",description:"List the company-brain source-spine connectors for the team, with event counts and last-synced times.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/sources"))}catch(x){return P0(x)}});I0.registerTool("figura_sync_source",{title:"Figura: sync source",description:"Ingest new events from a connected source into the spine. Returns { ingested, fetched, nextCursor }.",inputSchema:{id:F.string().describe("The source id to sync (from figura_list_sources).")}},async(x)=>{try{return D0(await U0(`/v1/sources/${encodeURIComponent(x.id)}/sync`,{method:"POST"}))}catch(Q){return P0(Q)}});I0.registerTool("figura_mine_screens",{title:"Figura: mine screens",description:"Build the app's screen catalog from a connected git source's CURRENT file tree (framework-aware, convention-based — no file bodies, no fig generation, no credits). Returns { screens: [{slug,name,path,framework}], framework, count, commitSha, truncated }.",inputSchema:{id:F.string().describe("The git source id to mine (from figura_list_sources).")}},async(x)=>{try{return D0(await U0(`/v1/sources/${encodeURIComponent(x.id)}/mine-screens`,{method:"POST"}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_screens",{title:"Figura: list screens",description:"Read the team's mined screen catalog — the app's current screens ({slug,name,path,framework}) plus when it was last mined. Free, read-only. Run figura_mine_screens first to populate it.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/sources/screens"))}catch(x){return P0(x)}});I0.registerTool("figura_update_fig",{title:"Figura: update fig",description:"Update a fig's METADATA only (title/description/area/tags/status). Set status='archived' to soft-delete. For library membership (draft ⇄ published) prefer figura_commit_fig — a status PATCH here is a silent metadata setter that never notifies the team. To change the fig's HTML, use figura_revise_fig — it saves a new version and never destroys the old one. update cannot touch the HTML.",inputSchema:{id:F.string().describe("The fig to update — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),title:F.string().optional(),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional().describe("Replaces the existing tags."),status:F.enum(["draft","published","archived"]).optional()}},async(x)=>{try{let Q={};if(x.title!==void 0)Q.title=x.title;if(x.description!==void 0)Q.description=x.description;if(x.area!==void 0)Q.area=x.area;if(x.tags!==void 0)Q.tags=x.tags;if(x.status!==void 0)Q.status=x.status;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}`,{method:"PATCH",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_delete_fig",{title:"Figura: delete fig",description:"Archive a fig (soft-delete; leaves the library, public link stops resolving) or permanently delete it with hard=true.",inputSchema:{id:F.string().describe("The fig to delete — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),hard:F.boolean().optional().describe("Permanently delete instead of archive.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}${x.hard?"?hard=true":""}`,{method:"DELETE"}))}catch(Q){return P0(Q)}});(async()=>{let{apiUrl:x}=await Z3();await ZN(S8,R3,S3(x))})();await I0.connect(new rX);
|
|
71
|
+
`)}}catch{}}var yk=F.object({name:F.string(),hex:F.string().describe("3/4/6/8-digit hex, e.g. #1FB6A6 or #1FB6A6FF."),role:F.string().optional(),meaning:F.string().optional()}).passthrough(),gk=F.object({id:F.string().optional(),rule:F.string()}).passthrough(),hk=F.object({colors:F.array(yk).optional(),gradient:F.string().optional(),type:F.object({families:F.record(F.string()).optional(),ramp:F.array(F.number()).optional(),rules:F.string().optional()}).passthrough().optional(),spacing:F.array(F.number()).optional(),radius:F.array(F.number()).optional(),voice:F.object({nouns:F.array(F.string()).optional(),promise:F.string().optional(),lines:F.array(F.string()).optional(),tone:F.string().optional()}).passthrough().optional(),forbidden:F.array(gk).optional()}).passthrough(),vJ=F.object({components:F.array(F.object({name:F.string(),spec:F.string()}).passthrough()).optional(),icons:F.string().optional(),layout:F.string().optional(),surfaces:F.array(F.string()).optional(),bans:F.array(F.string()).optional(),tokensBlock:F.string().optional()}).passthrough(),ok=F.object({mobile:vJ.optional(),web:vJ.optional(),desktop:vJ.optional()}).passthrough(),ak="https://api.figura.so",dk="mcp",iN=wJ(pN(),".figura","config.json"),rk=wJ(".figura","config.json");async function CN(x){try{let Q=JSON.parse(await K9.readFile(x,"utf8"));if(Q&&typeof Q==="object")return Q}catch{}return{}}async function sk(x=process.cwd()){let Q=pN(),W=x;for(;;){if(W!==Q){let X=wJ(W,rk);try{return await K9.access(X),X}catch{}}let Y=fk(W);if(Y===W)return null;W=Y}}async function Z3(){let x=await sk(),Q=x?await CN(x):{},W=await CN(iN),Y={...W,...Q},X=process.env.FIGURA_TOKEN||Y.token||void 0,J=process.env.FIGURA_TOKEN?"env":Q.token?"project":W.token?"global":"none",G=(process.env.FIGURA_API_URL||Y.apiUrl||ak).replace(/\/+$/,"");return{token:X,apiUrl:G,team:Y.team,source:J,configPath:J==="project"?x:J==="global"?iN:null}}async function U0(x,Q={}){let W=await Z3(),Y=W.token;if(!Y)throw Error("No Figura token. Run `figura link` in this repo (or `figura login`, or set FIGURA_TOKEN).");let X=(Q.method??"GET").toUpperCase();if(XN(X,W.source,process.env.FIGURA_ALLOW_GLOBAL==="1"))throw Error(`this project isn't linked to a Figura team — the global credential${W.team?` (team '${W.team}')`:""} would receive this write. Ask the user to run \`figura link\` in this repo (or set FIGURA_ALLOW_GLOBAL=1 to use the global team deliberately).`);let J=`${W.apiUrl}${x.startsWith("/")?"":"/"}${x}`,G={Accept:"application/json",Authorization:`Bearer ${Y}`,"X-Figura-Source":dk},$;if(Q.body!==void 0)G["Content-Type"]="application/json",$=JSON.stringify(Q.body);let b=await o9.request({url:J,method:Q.method??"GET",headers:G,data:$,validateStatus:()=>!0,transformRequest:[(B)=>B],transformResponse:[(B)=>B]});{let B=b.headers,K=(w)=>{let V=B?.[w];return typeof V==="string"?V:void 0};RN(R3,S3(W.apiUrl),PN(R3,K))}let z=typeof b.data==="string"?b.data:"",v=void 0;if(z)try{v=JSON.parse(z)}catch{v=z}if(b.status<200||b.status>=300){let B=v&&typeof v==="object"&&v!==null&&"error"in v?String(v.error):typeof v==="string"&&v?v:b.statusText||`HTTP ${b.status}`;throw Error(`${B} (HTTP ${b.status})`)}return v}var TN=2500,tk=600000,ek=60000,kN=(x)=>new Promise((Q)=>setTimeout(Q,x));async function x_(x){let Q=Date.now(),W=null;for(;;){let Y;try{Y=await U0(`/v1/jobs/${encodeURIComponent(x)}`),W=null}catch(X){if(W??=Date.now(),Date.now()-W>=ek){let J=X instanceof Error?X.message:String(X);throw Error(`Lost contact with the Figura API while waiting for job ${x} (${J}). `+`The job may still complete — resume by checking GET /v1/jobs/${x} instead of regenerating.`)}await kN(TN);continue}if(Y.status==="done")return Y;if(Y.status==="error")throw Error(Y.error||"The generation job failed. Please retry.");if(Date.now()-Q>tk)throw Error(`Timed out waiting for job ${x} — it may still complete; `+`check GET /v1/jobs/${x} instead of regenerating.`);await kN(TN)}}function D0(x){return{content:[{type:"text",text:JSON.stringify(x,null,2)}]}}function P0(x){return{content:[{type:"text",text:x instanceof Error?x.message:String(x)}],isError:!0}}var _N=!1;async function H9(x){try{if(_N)return x;if(!x||typeof x!=="object"||Array.isArray(x))return x;let{apiUrl:Q}=await Z3(),W=await SN({stream:R3,current:S8,origin:S3(Q)});if(!W)return x;return _N=!0,{...x,notice:W}}catch{return x}}function P3(x,Q){if(!x||typeof x!=="object"||Array.isArray(x))return x;let W=BN(Q);if(!W)return x;return{...x,skillWarning:W}}var E8=()=>F.string().optional().describe("Product area / surface the fig belongs to, e.g. 'dashboard', 'settings', 'onboarding'. Free text."),V9=["portrait","landscape","both"],i3=["single","compare","variations"],{version:S8,name:Q_}=(()=>{try{let x=JSON.parse(mk(new URL("../package.json",import.meta.url),"utf8"));return{version:typeof x.version==="string"?x.version:"0.0.0",name:typeof x.name==="string"?x.name:void 0}}catch{return{version:"0.0.0"}}})(),R3=IN(Q_),I0=new aX({name:"figura",version:S8});I0.registerTool("figura_whoami",{title:"Figura: whoami",description:"Return the identity (team, actor, scopes) for the configured Figura token, plus `binding` — which config tier supplied it (env | project | global). A `global` binding means this project is not linked to a team and writes will be refused; ask the user to run `figura link` in the repo.",inputSchema:{}},async()=>{try{let x=await U0("/v1/whoami"),{source:Q,configPath:W}=await Z3();return D0(await H9({...x,binding:{source:Q,configPath:W}}))}catch(x){return P0(x)}});I0.registerTool("figura_set_brand",{title:"Figura: set brand profile",description:'Set (upsert) the team brand profile that the fig generator renders against. Use this to "introduce a repo to Figura": scan the codebase for its design system, synthesize the brand, then push it here. `name` is required. PREFERRED — send a STRUCTURED brand: `identity` (shared colors/gradient/type/spacing/radius/voice/forbidden) + `platforms` (per-platform surfaces, keys mobile|web|desktop) — this makes the brand structured + addressable (is_structured=true). LEGACY/fallback — send prose: `tokensBlock` (freeform design-token reference) + `surfaces` (the product\'s own key areas). `appDescription` is a one-clause product summary. This is a partial merge — fields you omit keep their saved value; a structured write clears the dormant prose columns and a prose write downgrades to legacy. Editing the team brand — confirm with the user before calling.',inputSchema:{name:F.string().describe("Brand / app name (required)."),appDescription:F.string().optional().describe("One clause on what the product is + who it is for."),identity:hk.optional().describe("STRUCTURED shared identity (platform-agnostic): { colors:[{name,hex,role?,meaning?}], gradient?, type?:{families?:{slot:font},ramp?:number[],rules?}, spacing?:number[], radius?:number[], voice?:{nouns?,promise?,lines?,tone?}, forbidden?:[{id?,rule}] }. hex is 3/4/6/8 digits. Sending this makes the brand STRUCTURED (is_structured=true). Validated server-side."),platforms:ok.optional().describe("STRUCTURED per-platform surfaces — a map keyed by mobile|web|desktop; include ONLY the platforms the product targets: { <platform>: { components?:[{name,spec}], icons?, layout?, surfaces?:string[], bans?:string[], tokensBlock? } }. `tokensBlock` here is a per-platform escape hatch for guidance that does not fit a structured field."),proseSource:F.string().optional().describe(`The human-readable freeform description this structured brand was authored from — stored as the editable source shown in the web brand editor; NOT used by generation. Send it alongside a structured write so a skill/CLI/MCP-authored brand still seeds the web editor's "Author from a description" textarea (a repo scan should pass the prose it synthesized the brand from; a prose→structured upgrade should pass the original prose).`),tokensBlock:F.string().optional().describe("LEGACY prose fallback: freeform plain-text design-token reference (colors + hex + role, type scale + faces, spacing, gradient, voice/conventions). Prefer `identity`/`platforms`. Injected verbatim into the fig-generation prompt."),surfaces:F.array(F.string()).optional().describe(`LEGACY prose fallback: the product's own key areas/sections (e.g. ["Feed", "Settings", "Checkout"]) — read from the repo's real navigation/routes. For a structured brand put surfaces under each platform instead. Pass [] to clear.`)}},async(x)=>{try{return D0(await U0("/brand",{method:"PUT",body:JN(x)}))}catch(Q){return P0(Q)}});I0.registerTool("figura_brand_schema",{title:"Figura: brand schema",description:"Return the JSON Schema for the STRUCTURED brand — the ONE authority (derived server-side from the zod schema PUT /brand validates). Fetch this BEFORE synthesizing a brand for figura_set_brand's `identity`/`platforms`, and author against it so the brand validates first try (strict keys; hex 3/4/6/8 digits; platform keys mobile|web|desktop; no blank array entries). Read-only. Returns { schema }.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/brand/schema"))}catch(x){return P0(x)}});var W_={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",svg:"image/svg+xml"},BJ=5242880;function uN(x){let Q=x.split(/[?#]/)[0].split(".").pop()?.toLowerCase()??"";return W_[Q]}async function lN(x,Q){let W,Y=Q;if(/^https?:\/\//i.test(x)){let X=await o9.get(x,{responseType:"arraybuffer",maxContentLength:BJ,timeout:30000});if(W=Buffer.from(X.data).toString("base64"),!Y){let J=String(X.headers["content-type"]??"").split(";")[0].trim().toLowerCase();Y=J.startsWith("image/")?J:uN(x)}}else{let{size:X}=await K9.stat(x);if(X>BJ)throw Error(`'${x}' is ${(X/1048576).toFixed(1)}MB — over the ${BJ/1048576}MB asset limit.`);if(W=(await K9.readFile(x)).toString("base64"),!Y)Y=uN(x)}if(!Y)throw Error(`Could not determine an image content-type for '${x}' — pass contentType (e.g. image/png).`);return{data:W,contentType:Y}}async function Y_(x,Q){let{data:W,contentType:Y}=await lN(x,Q),X=await U0("/v1/assets",{method:"POST",body:{data:W,contentType:Y}});if(!X||typeof X.url!=="string")throw Error("Asset upload did not return a URL.");return X.url}var nN=()=>F.array(F.object({source:F.string().describe("The image — a local file path or an http(s) URL (e.g. a screenshot)."),match:F.string().optional().describe('Exact string in the HTML to replace with the hosted URL (defaults to `source` — i.e. write <img src="<source>"> in the HTML).')})).optional().describe("Images to attach: each is uploaded to durable figura hosting (stable non-expiring URL, allowed by the fig CSP) and substituted into the HTML in place of `match`/`source`. Use for real screenshots/photos/logos instead of base64 data-URIs (HTML size cap) or external URLs (expire/CSP-blocked).");async function mN(x,Q){if(!Q?.length)return{html:x,uploaded:[]};let W=x,Y=[];for(let X of Q){let J=X.match??X.source;if(!W.includes(J))throw Error(`images: '${J}' does not occur in the html — reference it (e.g. <img src="${J}">) so it can be swapped for the hosted URL, or set 'match'.`);let G=await Y_(X.source);W=W.split(J).join(G),Y.push({source:X.source,url:G})}return{html:W,uploaded:Y}}function fN(x,Q){if(!Q.length)return x;if(x&&typeof x==="object"&&!Array.isArray(x))return{...x,assets:Q};return{result:x,assets:Q}}I0.registerTool("figura_create_fig",{title:"Figura: create fig",description:"Create a new visualization from an HTML document — this HOSTS it (the create IS the hosting; there is no separate \"land it in Figura\" step). Every create returns `viewUrl`: ALWAYS a link you can open for approval, whether or not the fig is published — the public share url once published, else a team-scoped viewer link only signed-in teammates can open. The fig lands as a workspace DRAFT (`status: 'draft'`): hosted and viewable, visible to its author, but NOT in the team library and silent for teammates — iterate freely without claiming the team's attention, then COMMIT only the session's final recommendation (`commit: true` on that final create/revise, or figura_commit_fig). Do NOT pass publish:true just to get something viewable — omit it (default false) so the fig lands team-private, open `viewUrl` for review, and only publish when the human wants a link they can share outside the team (figura_publish_fig, once approved). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only (never blocks the create). If it comes back non-empty, read it and consider a figura_revise_fig call that binds the missing tokens before telling the user you're done. To include REAL images (screenshots/photos/logos), pass them via `images` — they are uploaded to durable figura hosting and their stable URLs substituted into the HTML. TIP — injected components: if your HTML shows phone frames (a compare/multi-screen mock), wrap each state in <div class=\"fig-device portrait|landscape\"><div class=\"fig-screen\">…</div><div class=\"fig-di\"></div><div class=\"fig-homebar\"></div></div> — Figura injects accurate iPhone frame CSS at save, BEFORE your <style>, so you normally leave those classes alone; if you do style them your rule wins by cascade (the frame still works), but to shrink frames for an overview grid wrap each frame in the injected `.fig-mini` wrapper (e.g. <div class=\"fig-mini\" style=\"--fig-scale:.34\"> … the .fig-device block … </div>) — it scales the device with transform and reserves the scaled layout box — rather than re-declaring .fig-device width/height or adding transform: scale() yourself (which fights the injected geometry), and never CSS zoom (it leaks unzoomed width into horizontal overflow). Same injection applies to an iOS status bar (<div class=\"fig-statusbar\" data-time=\"9:41\"></div>), a compare control bar (.fig-controls/.fig-seg), and the .frame-web browser window.",inputSchema:{html:F.string().describe("The full HTML document for the visualization."),images:nN(),title:F.string().optional().describe("Title (otherwise derived from the HTML)."),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional(),model:F.string().optional().describe("Model attribution."),device:F.string().optional().describe("Catalog iPhone id the fig targets (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3') — keys the injected frame/safe-area geometry."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team. Share link ONLY — does not commit the fig to the team library (that's `commit`)."),commit:F.boolean().optional().describe("Commit to the team library on create (sends status: 'published'). Default false — the fig lands as a workspace DRAFT: hosted + viewable via `viewUrl`, visible to its author, but not in the team library and silent for teammates. Set true ONLY when this write is the session's final recommendation (the fig the user asked for / signed off on); explorations and intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:F.string().optional().describe("OPTIONAL: the brief that produced this HTML, if there was one — you wrote the HTML yourself, so Figura never sees a prompt unless you pass it here. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig — lets teammates see 'why' this fig exists.")}},async(x)=>{try{let{html:Q,uploaded:W}=await mN(x.html,x.images),Y={html:Q};for(let J of["title","description","area","tags","orientation","mode","model","device","issue","publish","prompt"])if(x[J]!==void 0)Y[J]=x[J];if(x.commit)Y.status="published";let X=I3(S8);return Y.toolchain=X,D0(P3(await H9(fN(await U0("/v1/figs",{method:"POST",body:Y}),W)),X.skill.status))}catch(Q){return P0(Q)}});var X_=["single","compare","variations","overview-focus","document"];I0.registerTool("figura_compose_fig",{title:"Figura: compose fig",description:"PREFERRED way to build a device fig. You write ONLY each screen's CONTENT (the markup inside the phone) + typed params; Figura owns the STRUCTURE — the iPhone frame, the Dynamic Island/notch/home bar, the exact catalog artboard geometry, the REAL safe-area insets, and the composition (grid/diptych/overview). This means the parts that used to drift every few figs — hand-rolled artboards, guessed `--fig-safe-top:59px`, a forgotten `data-device` so no safe machinery stamped — are correct-by-construction: you never write them. Pass `layout` + an ARRAY of `screens`. For every DEVICE layout (single/compare/variations/overview-focus) each screen REQUIRES `device` (a catalog id like 'iphone-16' — fetch figura_fig_devices), `orientation` ('portrait'|'landscape'), and `safeZone` ('respect' = Figura pads your content to the device's real safe insets; 'ignore' = edge-to-edge splash/hero). Each screen's `html` is CONTENT ONLY — do NOT draw a frame, a bezel, a status bar, or safe padding; do NOT set `--fig-safe-*`; do NOT wrap in `.fig-device`. Style your content with brand tokens via `sharedCss` (ONE stylesheet all screens share) + optional per-screen `css`. Layouts: `single` (one frame), `compare` (as-is → proposed diptych — pass label per screen), `overview-focus` (**use for ANY redesign argument or set of candidate directions** — a CSS toggle that OPENS on the full-frame Focus view with state pills; the Overview grid is the alternative behind the second tab), `variations` (a scan-only grid at the kit's ~0.34 device scale with NO toggle — every frame renders ~134px wide, so body copy and annotations are unreadable; use ONLY for a pure catalogue, NEVER to argue a change), `document` (NO device framing — brand/overview figs; screens need no device/orientation/safeZone). Lands EXACTLY like figura_create_fig (workspace DRAFT, returns `viewUrl` + `brandWarnings` + `warnings`; commit the session's final pick). For a `document`/exotic hand-authored fig where you need full control of the HTML, use figura_create_fig instead.",inputSchema:{layout:F.enum(X_).describe("The composition Figura emits."),title:F.string().optional().describe("Fig title (else derived from the HTML)."),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional().describe('Fig-level orientation for the persisted row (may be "both").'),device:F.string().optional().describe("Fig-level default catalog device (per-screen `device` still keys each frame)."),sharedCss:F.string().optional().describe("ONE stylesheet ALL screens share — put brand tokens + shared classes here."),screens:F.array(F.object({html:F.string().describe("SCREEN CONTENT markup only — no frame, no bezel, no safe padding."),css:F.string().optional().describe("Optional per-screen content styles (appended unscoped)."),device:F.string().optional().describe("Catalog device id (e.g. 'iphone-16'). REQUIRED for device layouts."),orientation:F.enum(["portrait","landscape"]).optional().describe("REQUIRED for device layouts."),safeZone:F.enum(["respect","ignore"]).optional().describe("REQUIRED for device layouts. 'respect' pads to the real catalog safe insets; 'ignore' is edge-to-edge."),label:F.string().optional().describe("Chrome label (compare/variations chip, focus pill).")})).describe("The screens to compose. One for `single`; two for a `compare` diptych; N for variations/overview-focus/document."),model:F.string().optional().describe("Model attribution."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish to a shareable public URL on compose (requires a paid plan). Default false."),commit:F.boolean().optional().describe("Commit to the team library (sends status: 'published'). Default false — lands a workspace draft."),prompt:F.string().optional().describe("OPTIONAL brief that produced this fig (DB-only).")}},async(x)=>{try{let Q={layout:x.layout,screens:x.screens};for(let Y of["title","description","area","tags","orientation","device","sharedCss","model","issue","publish","prompt"])if(x[Y]!==void 0)Q[Y]=x[Y];if(x.commit)Q.status="published";let W=I3(S8);return Q.toolchain=W,D0(P3(await H9(await U0("/v1/figs/compose",{method:"POST",body:Q})),W.skill.status))}catch(Q){return P0(Q)}});I0.registerTool("figura_upload_asset",{title:"Figura: upload asset",description:"Upload a durable image asset and get back a STABLE, figura-hosted URL that never expires — use it in a fig's <img src> instead of a base64 data-URI (bloats the HTML past the publish size) or an external URL that expires (Instagram/CDN signed links). The fig CSP allows figura-hosted images. Provide `path` (a local image file), `url` (an http(s) image to fetch and re-host), or `data` (base64 bytes) + `contentType`. Accepts png/jpeg/gif/webp/avif/svg, up to 5MB. Returns `{ url }`.",inputSchema:{path:F.string().optional().describe("Local path to an image file to upload (content-type inferred from the extension)."),url:F.string().optional().describe("http(s) URL of an image to fetch and re-host (alternative to `path`) — use for expiring/signed source URLs so the fig keeps rendering after they die."),data:F.string().optional().describe("Base64-encoded image bytes (alternative to `path`/`url`). Requires `contentType`."),contentType:F.string().optional().describe("MIME type, e.g. image/png. Required with `data`; inferred from the extension/response for `path`/`url`.")}},async(x)=>{try{let Q=x.path??x.url;if(Q){let{data:W,contentType:Y}=await lN(Q,x.contentType);return D0(await U0("/v1/assets",{method:"POST",body:{data:W,contentType:Y}}))}if(!x.data)throw Error("Provide `path` (a local image file), `url` (an http(s) image), or `data` (base64 bytes).");if(!x.contentType)throw Error("`contentType` is required with `data` — e.g. image/png.");return D0(await U0("/v1/assets",{method:"POST",body:{data:x.data,contentType:x.contentType}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_generate_fig",{title:"Figura: generate fig",description:"Generate a new fig server-side from a natural-language brief, then persist it — this HOSTS it (the create IS the hosting). The engine runs Figura's on-brand generation — you do NOT write the HTML; just describe the screen. SPENDS TEAM CREDITS: only use when the user explicitly opts for a server render; otherwise default to writing the HTML yourself and landing it with figura_create_fig (free, uses the calling model). Runs as an async server job; this call polls until the fig lands (typically 30–90s). Returns the created fig (id, slug) and `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published). Like figura_create_fig, the generated fig lands as a workspace DRAFT — not in the team library, silent for teammates — commit the session's final pick with figura_commit_fig. Requires an active paid subscription.",inputSchema:{prompt:F.string().describe("A natural-language brief describing the screen/UI to generate."),area:E8(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional(),platform:F.enum(["mobile","web"]).optional().describe("Output platform: 'mobile' (iPhone frame) or 'web' (browser frame). When omitted, the engine INFERS it from the brief — a desktop/web/browser/landing-page/dashboard brief renders web, an iPhone/mobile/app-screen brief renders mobile — falling back to mobile only when neither is signalled. Pass this to force a platform regardless of the wording."),device:F.string().optional().describe("Catalog iPhone id to target (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),title:F.string().optional().describe("Title (otherwise derived from the generated HTML)."),fidelity:F.enum(["fast","high"]).optional().describe("Generation fidelity (default fast)."),publish:F.boolean().optional().describe("Publish to a shareable public URL on create (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately, so only set this when the human explicitly wants a link they can share outside the team.")}},async(x)=>{try{let Q={prompt:x.prompt};for(let X of["area","orientation","mode","platform","device","title","publish"])if(x[X]!==void 0)Q[X]=x[X];if(x.fidelity!==void 0)Q.model=x.fidelity;let W=I3(S8);Q.toolchain=W,Q.async=!0;let Y=await U0("/v1/figs/generate",{method:"POST",body:Q});if(Y&&typeof Y==="object"&&typeof Y.jobId==="string"){let X=await x_(Y.jobId),J=X.result??{};return D0(P3(await H9(J.fig??X.result),W.skill.status))}return D0(P3(await H9(Y),W.skill.status))}catch(Q){return P0(Q)}});I0.registerTool("figura_fig_scaffold",{title:"Figura: fig scaffold",description:"Return the CANONICAL fig authoring scaffold — the exact system prompt Figura injects when it renders a fig server-side (design rules + exemplars + accent hints + your team's composed brand + the component/device kit context), composed for a target (platform/device/area/mode/orientation). USE THIS on the hand-build path (before writing a fig's HTML with figura_create_fig): author against this scaffold instead of a hand-maintained copy of the guidance, so your fig follows the SAME rules + brand the server would. Read-only — no fig is created, no credits are spent. Returns { scaffold, platform, mode, orientation, area, device, brand }.",inputSchema:{platform:F.enum(["mobile","web"]).optional().describe("Target platform: 'mobile' (iPhone frame, default) or 'web' (browser frame)."),device:F.string().optional().describe("Catalog iPhone id (e.g. 'iphone-16', 'iphone-17-pro', 'iphone-se-3'). Mobile only; default iphone-16."),area:E8(),orientation:F.enum(V9).optional(),mode:F.enum(i3).optional()}},async(x)=>{try{let Q=new URLSearchParams;for(let Y of["platform","device","area","orientation","mode"])if(x[Y]!==void 0)Q.set(Y,String(x[Y]));let W=Q.toString();return D0(await U0(`/v1/fig/scaffold${W?`?${W}`:""}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_fig_devices",{title:"Figura: fig devices",description:"Return the CANONICAL framed-device catalog — every valid `data-device` id plus its per-device geometry (logical dims portrait/landscape, safe-area insets, cutout island|notch|none, corner radius). USE THIS when authoring a CANVAS COMPOSITION fig that frames a specific phone in-fig (a `.fig-device` element with `data-device` set): pick a real id from here instead of guessing, since an unknown data-device silently gets NO frame. Read-only — no fig is created, no credits are spent. Returns { devices, default }.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/fig/devices"))}catch(x){return P0(x)}});I0.registerTool("figura_revise_fig",{title:"Figura: revise fig",description:"Create a new revision (version) of an existing fig from updated HTML — this HOSTS it, same as create. Returns the new fig (id, slug, version) and `viewUrl` — ALWAYS a link to open and re-review. The revision lands as a workspace DRAFT regardless of the parent's status — not in the team library, silent for teammates — so iterate freely; when a revision is the session's final agreed version, commit it (`commit: true` on that call, or figura_commit_fig). Publishing is NOT inherited from the fig being revised: the new revision defaults to unpublished (a team-scoped viewer `viewUrl`) even if the fig it revises is currently live — the existing public link keeps serving the OLD version until you pass `publish: true` on THIS call, which republishes at the SAME stable link (same publicId across the chain). The new version joins the original fig's chain — it does not replace it in place. Use list_revisions for the chain (rootFig + every version). The response also carries `brandWarnings` — a deterministic (no-LLM) colors/fonts/radii/spacing diff against the team's brand tokens, WARN-only. If it comes back non-empty, consider one more revision that binds the missing tokens. To include REAL images (screenshots/photos/logos), pass them via `images` — uploaded to durable figura hosting, stable URLs substituted into the HTML.",inputSchema:{id:F.string().describe("The fig to revise — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),html:F.string().describe("The full updated HTML document."),images:nN(),title:F.string().optional().describe("Title (otherwise derived from the HTML)."),description:F.string().optional(),area:F.string().optional().describe("Product area / surface; free text. Inherited from the parent if omitted."),tags:F.array(F.string()).optional(),orientation:F.enum(V9).optional().describe("Inherited from the parent if omitted."),mode:F.enum(i3).optional(),model:F.string().optional().describe("Model attribution."),issue:F.number().optional().describe("Linked issue number."),publish:F.boolean().optional().describe("Publish the new revision to a shareable public URL (requires a paid plan). Default false — the response's `viewUrl` is already open-able team-privately. If the fig being revised is ALREADY published, pass true here too or its public link keeps serving the prior version. Share link ONLY — does not commit the revision to the team library (that's `commit`)."),commit:F.boolean().optional().describe("Commit this revision to the team library (sends status: 'published'). Default false — the revision lands as a workspace DRAFT even when the fig it revises is committed. Set true ONLY when this revision is the session's final agreed version; intermediate iterations stay drafts. Committing later: figura_commit_fig."),prompt:F.string().optional().describe("OPTIONAL: the brief/ask that produced this revision, if there was one. Stored on the fig (DB-only, never stamped into the HTML), and returned by figura_get_fig.")}},async(x)=>{try{let{html:Q,uploaded:W}=await mN(x.html,x.images),Y={html:Q};for(let X of["title","description","area","tags","orientation","mode","model","issue","publish","prompt"])if(x[X]!==void 0)Y[X]=x[X];if(x.commit)Y.status="published";return D0(fN(await U0(`/v1/figs/${encodeURIComponent(x.id)}/revise`,{method:"POST",body:Y}),W))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_revisions",{title:"Figura: list revisions",description:"List the full revision chain for a fig (all versions sharing a root), oldest to newest.",inputSchema:{id:F.string().describe("Any fig in the chain — its id (`fig_…`), a UUID, share link/URL, or publicId.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/revisions`))}catch(Q){return P0(Q)}});I0.registerTool("figura_commit_fig",{title:"Figura: commit fig",description:"Commit a fig to the team library (status draft → published). Machine-created figs land as workspace DRAFTS — hosted, viewable via `viewUrl`, visible to their author, but NOT in the team library and silent for teammates. Committing is the explicit act that puts one in the team library and notifies the team (digest-batched) — so commit ONLY the session's final recommendation (the fig the user asked for / signed off on), never every exploration or intermediate iteration. If the final fig is one you're about to create/revise anyway, prefer `commit: true` on that call over a separate commit here. Orthogonal to figura_publish_fig (the public share link): a shared draft's URL resolves fine, and committing creates no link. Idempotent. Pass `uncommit: true` to reverse it (published → draft; the share link is untouched). Returns { id, status, committed, alreadyCommitted, viewUrl }.",inputSchema:{id:F.string().describe("The fig to commit — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),uncommit:F.boolean().optional().describe("Set true to uncommit: the fig leaves the team library back to an author-visible workspace draft. Defaults to false (commit).")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/${x.uncommit?"uncommit":"commit"}`,{method:"POST",body:{}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_publish_fig",{title:"Figura: publish fig",description:"Publish (or unpublish) an existing fig to its shareable public URL. The fig is ALREADY hosted and viewable team-privately without this (figura_create_fig/figura_revise_fig's `viewUrl`) — only call this once the human wants a link they can share outside the team. Share link ONLY — it does not put the fig in the team library (that's figura_commit_fig; a shared draft's URL resolves fine). Requires a paid plan. Returns { isPublic, url, publicId, viewUrl }.",inputSchema:{id:F.string().describe("The fig to publish — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),isPublic:F.boolean().optional().describe("Set false to unpublish (make private). Defaults to true (publish).")}},async(x)=>{try{let Q={};if(x.isPublic!==void 0)Q.isPublic=x.isPublic;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/publish`,{method:"POST",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_figs",{title:"Figura: list figs",description:"List visualizations for the team, optionally filtered by area or tag.",inputSchema:{area:E8(),tag:F.string().optional(),limit:F.number().optional(),cursor:F.string().optional()}},async(x)=>{try{let Q=new URLSearchParams;if(x.area)Q.set("area",x.area);if(x.tag)Q.set("tag",x.tag);if(x.limit!==void 0)Q.set("limit",String(x.limit));if(x.cursor)Q.set("cursor",x.cursor);let W=Q.toString();return D0(await U0(`/v1/figs${W?`?${W}`:""}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_get_fig",{title:"Figura: get fig",description:"Fetch a single visualization by id. The response carries `viewUrl` — ALWAYS a link you can open for approval (a team-scoped viewer when unpublished, the public share url once published).",inputSchema:{id:F.string().describe("The fig — its id (`fig_…`), a UUID, a share link/URL, or its publicId.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}`))}catch(Q){return P0(Q)}});I0.registerTool("figura_comment",{title:"Figura: comment",description:"Add a comment to a visualization.",inputSchema:{fig_id:F.string().describe("The fig to comment on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),body:F.string().describe("Comment text.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.fig_id)}/comments`,{method:"POST",body:{body:x.body}}))}catch(Q){return P0(Q)}});I0.registerTool("figura_request_feedback",{title:"Figura: request feedback",description:"Ask a teammate to review a fig. Resolve `from` to a team member (by name or email); they get a notification (in-app, push, email) and the discussion happens in the fig's comment thread. Use this when the user says they want a specific person's feedback/eyes/review on a fig. Returns { ok, request: { to, status }, figUrl }.",inputSchema:{id:F.string().describe("The fig to request feedback on — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),from:F.string().describe("The teammate to ask, by name or email (resolved within the team)."),note:F.string().optional().describe("Optional message for them (e.g. what to look at).")}},async(x)=>{try{let Q={from:x.from};if(x.note!==void 0)Q.note=x.note;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}/request-feedback`,{method:"POST",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_sources",{title:"Figura: list sources",description:"List the company-brain source-spine connectors for the team, with event counts and last-synced times.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/sources"))}catch(x){return P0(x)}});I0.registerTool("figura_sync_source",{title:"Figura: sync source",description:"Ingest new events from a connected source into the spine. Returns { ingested, fetched, nextCursor }.",inputSchema:{id:F.string().describe("The source id to sync (from figura_list_sources).")}},async(x)=>{try{return D0(await U0(`/v1/sources/${encodeURIComponent(x.id)}/sync`,{method:"POST"}))}catch(Q){return P0(Q)}});I0.registerTool("figura_mine_screens",{title:"Figura: mine screens",description:"Build the app's screen catalog from a connected git source's CURRENT file tree (framework-aware, convention-based — no file bodies, no fig generation, no credits). Returns { screens: [{slug,name,path,framework}], framework, count, commitSha, truncated }.",inputSchema:{id:F.string().describe("The git source id to mine (from figura_list_sources).")}},async(x)=>{try{return D0(await U0(`/v1/sources/${encodeURIComponent(x.id)}/mine-screens`,{method:"POST"}))}catch(Q){return P0(Q)}});I0.registerTool("figura_list_screens",{title:"Figura: list screens",description:"Read the team's mined screen catalog — the app's current screens ({slug,name,path,framework}) plus when it was last mined. Free, read-only. Run figura_mine_screens first to populate it.",inputSchema:{}},async()=>{try{return D0(await U0("/v1/sources/screens"))}catch(x){return P0(x)}});I0.registerTool("figura_update_fig",{title:"Figura: update fig",description:"Update a fig's METADATA only (title/description/area/tags/status). Set status='archived' to soft-delete. For library membership (draft ⇄ published) prefer figura_commit_fig — a status PATCH here is a silent metadata setter that never notifies the team. To change the fig's HTML, use figura_revise_fig — it saves a new version and never destroys the old one. update cannot touch the HTML.",inputSchema:{id:F.string().describe("The fig to update — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),title:F.string().optional(),description:F.string().optional(),area:E8(),tags:F.array(F.string()).optional().describe("Replaces the existing tags."),status:F.enum(["draft","published","archived"]).optional()}},async(x)=>{try{let Q={};if(x.title!==void 0)Q.title=x.title;if(x.description!==void 0)Q.description=x.description;if(x.area!==void 0)Q.area=x.area;if(x.tags!==void 0)Q.tags=x.tags;if(x.status!==void 0)Q.status=x.status;return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}`,{method:"PATCH",body:Q}))}catch(Q){return P0(Q)}});I0.registerTool("figura_delete_fig",{title:"Figura: delete fig",description:"Archive a fig (soft-delete; leaves the library, public link stops resolving) or permanently delete it with hard=true.",inputSchema:{id:F.string().describe("The fig to delete — its id (`fig_…`), a UUID, a share link/URL, or its publicId."),hard:F.boolean().optional().describe("Permanently delete instead of archive.")}},async(x)=>{try{return D0(await U0(`/v1/figs/${encodeURIComponent(x.id)}${x.hard?"?hard=true":""}`,{method:"DELETE"}))}catch(Q){return P0(Q)}});(async()=>{let{apiUrl:x}=await Z3();await ZN(S8,R3,S3(x))})();await I0.connect(new rX);
|
package/package.json
CHANGED
package/skills/fig/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: fig
|
|
3
3
|
description: "Mock up a screen as an on-brand, shareable HTML fig via your Figura team — render a UI state in seconds before writing the real code. Use when asked to visualize a screen, mock a UI, or 'make a fig'. iPhone-framed by default; browser frame for web/desktop."
|
|
4
4
|
argument-hint: '<what to visualize>'
|
|
5
|
-
allowed-tools: 'mcp__figura__figura_whoami, mcp__figura__figura_fig_scaffold, mcp__figura__figura_fig_devices, mcp__figura__figura_generate_fig, mcp__figura__figura_compose_fig, mcp__figura__figura_create_fig, mcp__figura__figura_revise_fig, mcp__figura__figura_publish_fig, mcp__figura__figura_commit_fig, Bash(figura *), Bash(bunx figura-cli *)'
|
|
5
|
+
allowed-tools: 'mcp__figura__figura_whoami, mcp__figura__figura_fig_scaffold, mcp__figura__figura_fig_devices, mcp__figura__figura_list_figs, mcp__figura__figura_generate_fig, mcp__figura__figura_compose_fig, mcp__figura__figura_create_fig, mcp__figura__figura_revise_fig, mcp__figura__figura_publish_fig, mcp__figura__figura_commit_fig, mcp__figura__figura_request_feedback, Bash(figura *), Bash(bunx figura-cli *)'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# /fig — figure it out before you build it
|
|
@@ -123,9 +123,12 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
|
|
|
123
123
|
|
|
124
124
|
- **`layout`** — `single` (one frame), `compare` (as-is → proposed diptych;
|
|
125
125
|
default this when Existing-first turned up a baseline — pass a `label` per
|
|
126
|
-
screen), `
|
|
127
|
-
|
|
128
|
-
`
|
|
126
|
+
screen), `overview-focus` (**the one for arguing a redesign or several
|
|
127
|
+
candidate directions** — opens on the full-frame Focus toggle, Overview
|
|
128
|
+
grid behind the second tab), `variations` (a scan-only grid of states at
|
|
129
|
+
~0.34 scale, NO toggle — only for a pure catalogue nobody has to read
|
|
130
|
+
detail in; never for a redesign argument), or `document` (NO device
|
|
131
|
+
framing — brand/overview figs).
|
|
129
132
|
- **`screens`** — an ARRAY. Each screen's **`html` is CONTENT ONLY**: the
|
|
130
133
|
markup that goes *inside* the phone. Do **NOT** draw a frame, bezel, status
|
|
131
134
|
bar, or safe padding; do **NOT** set `--fig-safe-*`; do **NOT** wrap it in
|
|
@@ -247,33 +250,48 @@ so you hand it the studied structure in the brief (see step 3, "Server render").
|
|
|
247
250
|
`figura fig uncommit <id>`) returns it to a draft — the share link is
|
|
248
251
|
untouched either way.
|
|
249
252
|
|
|
250
|
-
|
|
253
|
+
8. **Assign a reviewer.** Committing notifies the team in a digest — that is
|
|
254
|
+
ambient, not an assignment. If the fig argues a change or wants a decision,
|
|
255
|
+
finish by putting it in front of a named person with
|
|
256
|
+
**`figura_request_feedback`** (CLI: `figura fig request-feedback <id> --from
|
|
257
|
+
<name|email> --note <text>`). See "Assign a reviewer" below — this step is
|
|
258
|
+
what stops a fig from being landed, committed, and never actually read.
|
|
259
|
+
|
|
260
|
+
## Presenting a redesign — FOCUS first, overview second
|
|
251
261
|
|
|
252
262
|
When a fig argues a change against a current state (a shipped baseline, or
|
|
253
263
|
several candidate directions), use **`figura_compose_fig` with
|
|
254
264
|
`layout: 'overview-focus'`** — Figura emits the two switchable views (the
|
|
255
|
-
CSS-only radio-tab
|
|
265
|
+
CSS-only radio-tab Focus full-frame toggle ⇄ Overview grid) for you from your
|
|
256
266
|
per-screen content. You supply each state's CONTENT + a `label`; the composition,
|
|
257
|
-
the
|
|
267
|
+
the focus pills, the scaled overview grid, and the no-horizontal-scroll guarantee
|
|
258
268
|
are server-owned. The design intent below still guides WHAT each state's content
|
|
259
269
|
says and which is recommended — you just no longer hand-write the toggle markup
|
|
260
270
|
or the frame scaling. (If you're on the `create` escape hatch instead, the same
|
|
261
271
|
pattern must be hand-authored — plain CSS-only radio tabs, binary, no library:)
|
|
262
272
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
273
|
+
**★ FOCUS IS THE DEFAULT VIEW.** The fig opens on ONE full-size frame with state
|
|
274
|
+
pills — on desktop AND mobile. Overview is the *alternative*, behind the second
|
|
275
|
+
tab. Never open a redesign fig on the scaled grid: `layout: 'variations'` (a
|
|
276
|
+
scan-only grid at the kit's `--fig-scale:0.34`) is NOT a redesign presentation —
|
|
277
|
+
using it for one shipped a 4-state fig as 134px phones with ~3px annotation
|
|
278
|
+
chips. A Focus-only fig is **complete on its own**; add Overview only when there
|
|
279
|
+
are enough states that scanning them at once genuinely helps.
|
|
280
|
+
|
|
281
|
+
- **Focus — full frame (default).** One state at a time behind state pills
|
|
282
|
+
(Today / A / B / …), each state framed identically so flipping states in place
|
|
283
|
+
reads as spot-the-difference. This is where the context lives, pinned on the
|
|
284
|
+
frame as annotation chips: what's broken today, what each variation improves,
|
|
285
|
+
what it trades away. Open on the first pill = the honest current state.
|
|
286
|
+
- **Overview — side by side (alternative).** Every state at once: the honest
|
|
287
|
+
current state first, then each variation, as scaled-down frames in a grid.
|
|
288
|
+
Under each: a status chip and ONE condensed context line (`+ / –`). This view
|
|
289
|
+
is for scanning and picking a direction, not for reading detail. The grid must
|
|
267
290
|
wrap to one column before it can overflow — **a fig never scrolls
|
|
268
291
|
horizontally**, at any viewport. To shrink the framed phones, wrap each in the
|
|
269
292
|
kit's **`.fig-mini`** wrapper (see "Scaling a framed device" below), never a
|
|
270
293
|
rule on `.fig-device` and never CSS `zoom` (zoom leaks an unzoomed
|
|
271
294
|
`scrollWidth` into horizontal overflow — the exact thing this view forbids).
|
|
272
|
-
- **Focus — full frame.** One state at a time behind state pills (Today / A /
|
|
273
|
-
B / …), each state framed identically so flipping states in place reads as
|
|
274
|
-
spot-the-difference. This is where the context lives, pinned on the frame
|
|
275
|
-
as annotation chips: what's broken today, what each variation improves,
|
|
276
|
-
what it trades away.
|
|
277
295
|
- **In both views:** render the current state honestly from the shipped
|
|
278
296
|
implementation — **reconstructed from source** per "Reconstruct the baseline
|
|
279
297
|
from SOURCE" (no screenshot ⇒ study the component file(s) and rebuild the real
|
|
@@ -310,8 +328,8 @@ pattern must be hand-authored — plain CSS-only radio tabs, binary, no library:
|
|
|
310
328
|
|
|
311
329
|
A **wipe-slider** stays the narrow tool for exactly two states of the same
|
|
312
330
|
layout with pixel-level tweaks. Never ship a squeezed side-by-side as the
|
|
313
|
-
*only* view
|
|
314
|
-
|
|
331
|
+
*only* view — a scan-only grid is never the whole argument. The reverse is
|
|
332
|
+
NOT true: a Focus-only toggle is a complete fig, however many states it has.
|
|
315
333
|
|
|
316
334
|
## JavaScript is ALLOWED — use it for high-fidelity animation
|
|
317
335
|
|
|
@@ -352,6 +370,31 @@ Example — a GSAP-driven entrance for a mobile screen, pinned + allowlisted:
|
|
|
352
370
|
</script>
|
|
353
371
|
```
|
|
354
372
|
|
|
373
|
+
## Assign a reviewer — a proposal fig isn't done until someone's on it
|
|
374
|
+
|
|
375
|
+
**A link nobody was pointed at is a fig nobody saw.** Landing, publishing, and
|
|
376
|
+
committing all produce a URL; none of them puts the fig in front of a specific
|
|
377
|
+
person. Committing notifies the team in a *digest*, which is ambient — it is not
|
|
378
|
+
an assignment, and it is not a decision request.
|
|
379
|
+
|
|
380
|
+
So after landing any fig that **argues a change or wants a decision**, send a
|
|
381
|
+
feedback request as a required final step (step 8 above):
|
|
382
|
+
|
|
383
|
+
- **MCP:** `figura_request_feedback` — the fig id, the teammate, and a one-line
|
|
384
|
+
note saying what to look at or decide.
|
|
385
|
+
- **CLI:** `figura fig request-feedback <id> --from <name|email> [--note <text>]`
|
|
386
|
+
(use this when the MCP is bound to a different team than the fig — the CLI
|
|
387
|
+
reads the repo's `.figura/config.json`, the MCP may not).
|
|
388
|
+
|
|
389
|
+
They get an in-app notification, a push, and an email, and the discussion lands
|
|
390
|
+
in the fig's comment thread. **Name what you want from them** ("does 02 close
|
|
391
|
+
#2375?") rather than "thoughts?" — a request with a question attached gets
|
|
392
|
+
answered; a bare link gets deferred.
|
|
393
|
+
|
|
394
|
+
Ask the user who should review only when it isn't obvious from context. Skip the
|
|
395
|
+
request only for throwaway/exploratory figs, or when the user explicitly wants it
|
|
396
|
+
private.
|
|
397
|
+
|
|
355
398
|
## Metadata & geometry — stamped for you
|
|
356
399
|
|
|
357
400
|
Every landed fig is **auto-stamped server-side** (on `create` and `generate`)
|
package/skills/skills.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Skill version manifest (#752) — the source of truth for each Figura skill's OWN version line, DECOUPLED from the figura-cli package version. A release bumps the version here when a skill's content changes, then regenerates the engine seed (bun packages/core/scripts/build-skill-seed.mjs) and re-seeds. Version is a date tag (YYYY.MM.DD[.n]); releasedAt is ISO-8601. The CLI/MCP stamp this version into .figura-version at install; the engine seeds the registry from it.",
|
|
3
3
|
"skills": {
|
|
4
|
-
"fig": { "version": "2026.07.
|
|
4
|
+
"fig": { "version": "2026.07.28", "releasedAt": "2026-07-28T00:00:00.000Z" },
|
|
5
5
|
"figura-brand": { "version": "2026.07.26", "releasedAt": "2026-07-26T00:00:00.000Z" }
|
|
6
6
|
}
|
|
7
7
|
}
|