Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,110 @@
/**
* bindVariablesToComponent
*
* Binds design token variables to the visual properties of a component node.
* Supports fills, strokes, all padding directions, item spacing, and corner radius.
* Only binds properties for which a variable ID is provided in `bindings`.
*
* This function should be called on each variant individually within a component
* set, OR on the component set itself for properties shared by all variants.
*
* @param {ComponentNode | FrameNode | RectangleNode} component
* The Figma node to mutate. Usually a ComponentNode or one of its children.
* @param {{
* fills?: string,
* strokes?: string,
* paddingTop?: string,
* paddingBottom?: string,
* paddingLeft?: string,
* paddingRight?: string,
* itemSpacing?: string,
* cornerRadius?: string
* }} bindings
* Each key is a visual property name; each value is a Figma Variable ID
* (e.g. "VariableID:123:456"). Omit a key to skip binding that property.
* @returns {Promise<{ mutatedNodeIds: string[] }>}
* List of node IDs that were mutated (for audit/validation purposes).
*/
async function bindVariablesToComponent(component, bindings) {
const mutatedNodeIds = []
if (!component) {
return { mutatedNodeIds }
}
// --- Fills ---
if (bindings.fills) {
const fillVar = await figma.variables.getVariableByIdAsync(bindings.fills)
if (fillVar) {
const existingFills = component.fills
if (Array.isArray(existingFills) && existingFills.length > 0) {
// Bind the color of the first fill to the variable
const boundFill = figma.variables.setBoundVariableForPaint(
existingFills[0],
'color',
fillVar,
)
component.fills = [boundFill, ...existingFills.slice(1)]
} else {
// No existing fill — create a solid fill bound to the variable
const boundFill = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } },
'color',
fillVar,
)
component.fills = [boundFill]
}
mutatedNodeIds.push(component.id)
}
}
// --- Strokes ---
if (bindings.strokes) {
const strokeVar = await figma.variables.getVariableByIdAsync(bindings.strokes)
if (strokeVar) {
const existingStrokes = component.strokes
if (Array.isArray(existingStrokes) && existingStrokes.length > 0) {
const boundStroke = figma.variables.setBoundVariableForPaint(
existingStrokes[0],
'color',
strokeVar,
)
component.strokes = [boundStroke, ...existingStrokes.slice(1)]
} else {
const boundStroke = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } },
'color',
strokeVar,
)
component.strokes = [boundStroke]
}
if (!mutatedNodeIds.includes(component.id)) {
mutatedNodeIds.push(component.id)
}
}
}
// --- Spacing properties (FLOAT variables bound via setBoundVariable) ---
const floatBindings = [
['paddingTop', 'paddingTop'],
['paddingBottom', 'paddingBottom'],
['paddingLeft', 'paddingLeft'],
['paddingRight', 'paddingRight'],
['itemSpacing', 'itemSpacing'],
['cornerRadius', 'cornerRadius'],
]
for (const [bindingKey, figmaProp] of floatBindings) {
if (bindings[bindingKey]) {
const variable = await figma.variables.getVariableByIdAsync(bindings[bindingKey])
if (variable) {
component.setBoundVariable(figmaProp, variable)
if (!mutatedNodeIds.includes(component.id)) {
mutatedNodeIds.push(component.id)
}
}
}
}
return { mutatedNodeIds }
}
@@ -0,0 +1,127 @@
/**
* cleanupOrphans
*
* Finds and removes all Figma nodes (pages, frames, components, variables,
* and variable collections) that were tagged with the given `dsb_run_id`
* by a previous build run. This is safe cleanup: it uses plugin data tags,
* never name-prefix matching, so it cannot accidentally delete user-owned nodes.
*
* Use this when a build run fails mid-way and you need to reset to a clean
* slate before retrying. The function traverses the entire document looking
* for `dsb_run_id` plugin data matching `runId`.
*
* Variables and variable collections are handled separately (they are not
* scene nodes and cannot be discovered via node traversal).
*
* @param {string} runId - The dsb_run_id value to match (e.g. "ds-build-2024-001").
* @returns {Promise<{
* removedCount: number,
* removedIds: string[]
* }>}
*/
async function cleanupOrphans(runId) {
if (!runId) {
throw new Error('cleanupOrphans: runId is required.')
}
const removedIds = []
const originalPage = figma.currentPage
// --- Remove tagged scene nodes (pages, frames, components, etc.) ---
// Collect pages to remove (can't remove during iteration)
const pagesToRemove = []
for (const page of figma.root.children) {
if (page.getPluginData('dsb_run_id') === runId) {
pagesToRemove.push(page)
continue
}
// Traverse all nodes on this page
await figma.setCurrentPageAsync(page)
const nodesToRemove = []
page.findAll((node) => {
if (node.getPluginData('dsb_run_id') === runId) {
nodesToRemove.push(node)
return false // Don't descend — removing the parent removes its children
}
return true
})
// Remove deepest nodes first (children before parents) to avoid
// "parent no longer exists" errors
const sorted = nodesToRemove.sort((a, b) => {
// Sort by depth descending: deeper nodes first
return getDepth(b) - getDepth(a)
})
for (const node of sorted) {
if (node && node.parent) {
removedIds.push(node.id)
node.remove()
}
}
}
// Remove tagged pages last
for (const page of pagesToRemove) {
// Cannot remove the last page in the document
if (figma.root.children.length <= 1) {
break
}
removedIds.push(page.id)
page.remove()
}
// --- Remove tagged variables ---
const allVariables = await figma.variables.getLocalVariablesAsync()
for (const variable of allVariables) {
if (variable.getPluginData('dsb_run_id') === runId) {
removedIds.push(variable.id)
variable.remove()
}
}
// --- Remove tagged variable collections ---
// Must be done after variables are removed
const allCollections = await figma.variables.getLocalVariableCollectionsAsync()
for (const collection of allCollections) {
if (collection.getPluginData('dsb_run_id') === runId) {
removedIds.push(collection.id)
collection.remove()
}
}
// Restore original page (if it still exists)
try {
await figma.setCurrentPageAsync(originalPage)
} catch (_) {
// Original page was removed — switch to first available page
if (figma.root.children.length > 0) {
await figma.setCurrentPageAsync(figma.root.children[0])
}
}
return {
removedCount: removedIds.length,
removedIds,
}
}
/**
* Returns the depth of a node in the document tree.
* Root children (pages) have depth 1; their children have depth 2; etc.
*
* @param {BaseNode} node
* @returns {number}
*/
function getDepth(node) {
let depth = 0
let current = node
while (current.parent) {
depth++
current = current.parent
}
return depth
}
@@ -0,0 +1,148 @@
/**
* createComponentWithVariants
*
* Creates a component set by generating all combinations of `variantAxes`,
* building one Figma component per combination, then calling
* `figma.combineAsVariants` to produce the component set. After combining,
* the variants are repositioned into a grid so they don't all stack at (0, 0).
*
* @param {{
* name: string,
* variantAxes: Record<string, string[]>,
* baseProps: {
* width: number,
* height: number,
* fills?: Paint[],
* padding?: {top?: number, bottom?: number, left?: number, right?: number},
* radius?: number,
* layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE',
* itemSpacing?: number
* },
* page: PageNode
* }} config
* - `name`: Component set name (e.g. "Button").
* - `variantAxes`: Each key is a variant property name; each value is an array of
* allowed values. All combinations are generated (Cartesian product).
* Example: { Size: ['Small', 'Medium', 'Large'], Style: ['Primary', 'Ghost'] }
* produces 6 variants.
* - `baseProps`: Visual properties applied to every variant.
* - `page`: The PageNode to create components on (must be set as current page by caller).
* @param {string} [runId] - Optional dsb_run_id to tag every node.
* @returns {Promise<{
* componentSet: ComponentSetNode,
* variants: ComponentNode[]
* }>}
*/
async function createComponentWithVariants(config, runId) {
const { name, variantAxes, baseProps, page } = config
// Ensure we are on the correct page
await figma.setCurrentPageAsync(page)
// Compute Cartesian product of variant axes
const axisNames = Object.keys(variantAxes)
const axisValues = axisNames.map((k) => variantAxes[k])
const combinations = cartesianProduct(axisValues)
// Build one component per combination
const components = []
for (const combo of combinations) {
const comp = figma.createComponent()
// Name: "Property=Value, Property=Value, ..."
comp.name = axisNames.map((ax, i) => `${ax}=${combo[i]}`).join(', ')
// Base geometry
comp.resize(baseProps.width, baseProps.height)
// Fills
if (baseProps.fills !== undefined) {
comp.fills = baseProps.fills
} else {
comp.fills = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }]
}
// Corner radius
if (baseProps.radius !== undefined) {
comp.cornerRadius = baseProps.radius
}
// Auto-layout
if (baseProps.layoutMode && baseProps.layoutMode !== 'NONE') {
comp.layoutMode = baseProps.layoutMode
comp.primaryAxisAlignItems = 'CENTER'
comp.counterAxisAlignItems = 'CENTER'
if (baseProps.itemSpacing !== undefined) {
comp.itemSpacing = baseProps.itemSpacing
}
}
// Padding
if (baseProps.padding) {
comp.paddingTop = baseProps.padding.top ?? 0
comp.paddingBottom = baseProps.padding.bottom ?? 0
comp.paddingLeft = baseProps.padding.left ?? 0
comp.paddingRight = baseProps.padding.right ?? 0
}
// Plugin data
const variantKey = axisNames.map((ax, i) => `${ax}:${combo[i]}`).join('|')
comp.setPluginData('dsb_key', `component/${name}/${variantKey}`)
if (runId) {
comp.setPluginData('dsb_run_id', runId)
}
page.appendChild(comp)
components.push(comp)
}
// Combine into a component set
const componentSet = figma.combineAsVariants(components, page)
componentSet.name = name
componentSet.setPluginData('dsb_key', `componentSet/${name}`)
if (runId) {
componentSet.setPluginData('dsb_run_id', runId)
}
// Grid layout — variants stack at (0, 0) after combineAsVariants; reposition them.
const GRID_GAP = 16
const cols = Math.max(1, axisValues[axisValues.length - 1]?.length ?? 1)
const variantWidth = baseProps.width
const variantHeight = baseProps.height
componentSet.children.forEach((variant, idx) => {
const col = idx % cols
const row = Math.floor(idx / cols)
variant.x = col * (variantWidth + GRID_GAP)
variant.y = row * (variantHeight + GRID_GAP)
})
// Resize component set to wrap its children with padding
const totalCols = Math.min(cols, combinations.length)
const totalRows = Math.ceil(combinations.length / cols)
const PADDING = 40
componentSet.resize(
totalCols * variantWidth + (totalCols - 1) * GRID_GAP + PADDING * 2,
totalRows * variantHeight + (totalRows - 1) * GRID_GAP + PADDING * 2,
)
// Position component set at a safe canvas location
componentSet.x = 480
componentSet.y = 80
return { componentSet, variants: componentSet.children }
}
/**
* Computes the Cartesian product of multiple arrays.
* cartesianProduct([[A, B], [1, 2]]) → [[A,1], [A,2], [B,1], [B,2]]
*
* @param {Array<string[]>} arrays
* @returns {string[][]}
*/
function cartesianProduct(arrays) {
return arrays.reduce(
(acc, curr) => acc.flatMap((combo) => curr.map((val) => [...combo, val])),
[[]],
)
}
@@ -0,0 +1,139 @@
/**
* createDocumentationPage
*
* Creates a new Figma page with a standardized documentation layout: a page
* title, optional description, and an ordered list of sections each built by
* a caller-supplied `contentFn`. The content function receives the section
* frame and may append any nodes to it.
*
* This function is used for standalone documentation pages (e.g. a Foundations
* page, a Getting Started page, or a component page with documentation).
* It does not handle component sets — those live on separate pages created by
* createComponentWithVariants.
*
* @param {string} pageName - The Figma page name (e.g. "Foundations", "Getting Started").
* @param {{
* title: string,
* description?: string,
* sections: Array<{
* name: string,
* contentFn: (sectionFrame: FrameNode) => Promise<void>
* }>
* }} config
* - `title`: Large heading displayed at the top of the page.
* - `description`: Optional subtitle displayed below the heading.
* - `sections`: Ordered list of sections. Each section gets its own frame
* with a heading and is passed to `contentFn` for population.
* @param {string} [runId] - Optional dsb_run_id to tag every created node.
* @returns {Promise<{
* page: PageNode,
* titleNode: TextNode,
* frameIds: string[]
* }>}
* `frameIds` is an ordered list of IDs for the root frame and each section frame.
*/
async function createDocumentationPage(pageName, config, runId) {
await figma.loadFontAsync({ family: 'Inter', style: 'Bold' })
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' })
await figma.loadFontAsync({ family: 'Inter', style: 'Medium' })
// Create and activate the page
const page = figma.createPage()
page.name = pageName
await figma.setCurrentPageAsync(page)
if (runId) {
page.setPluginData('dsb_run_id', runId)
page.setPluginData('dsb_key', `page/${pageName}`)
}
const frameIds = []
// Root scroll container — 1440px wide, auto-height
const root = figma.createFrame()
root.name = pageName
root.layoutMode = 'VERTICAL'
root.primaryAxisAlignItems = 'MIN'
root.counterAxisAlignItems = 'MIN'
root.itemSpacing = 80
root.paddingTop = 80
root.paddingBottom = 120
root.paddingLeft = 80
root.paddingRight = 80
root.layoutSizingHorizontal = 'FIXED'
root.layoutSizingVertical = 'HUG'
root.resize(1440, 1)
root.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]
root.x = 0
root.y = 0
page.appendChild(root)
if (runId) {
root.setPluginData('dsb_run_id', runId)
root.setPluginData('dsb_key', `frame/root/${pageName}`)
}
frameIds.push(root.id)
// Page header: title + optional description
const header = figma.createFrame()
header.name = 'Header'
header.layoutMode = 'VERTICAL'
header.itemSpacing = 12
header.layoutSizingHorizontal = 'FILL'
header.layoutSizingVertical = 'HUG'
header.fills = []
root.appendChild(header)
const titleNode = figma.createText()
titleNode.fontName = { family: 'Inter', style: 'Bold' }
titleNode.characters = config.title
titleNode.fontSize = 40
titleNode.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]
titleNode.layoutSizingHorizontal = 'FILL'
header.appendChild(titleNode)
if (config.description) {
const descNode = figma.createText()
descNode.fontName = { family: 'Inter', style: 'Regular' }
descNode.characters = config.description
descNode.fontSize = 16
descNode.lineHeight = { value: 24, unit: 'PIXELS' }
descNode.fills = [{ type: 'SOLID', color: { r: 0.4, g: 0.4, b: 0.4 } }]
descNode.layoutSizingHorizontal = 'FILL'
header.appendChild(descNode)
}
// Sections
for (const section of config.sections) {
const sectionFrame = figma.createFrame()
sectionFrame.name = `Section/${section.name}`
sectionFrame.layoutMode = 'VERTICAL'
sectionFrame.itemSpacing = 20
sectionFrame.layoutSizingHorizontal = 'FILL'
sectionFrame.layoutSizingVertical = 'HUG'
sectionFrame.fills = []
root.appendChild(sectionFrame)
if (runId) {
sectionFrame.setPluginData('dsb_run_id', runId)
sectionFrame.setPluginData('dsb_key', `frame/section/${pageName}/${section.name}`)
}
// Section heading
const sectionHeading = figma.createText()
sectionHeading.fontName = { family: 'Inter', style: 'Bold' }
sectionHeading.characters = section.name
sectionHeading.fontSize = 24
sectionHeading.fills = [{ type: 'SOLID', color: { r: 0.07, g: 0.07, b: 0.07 } }]
sectionHeading.layoutSizingHorizontal = 'FILL'
sectionFrame.appendChild(sectionHeading)
// Invoke the caller's content function to populate the section
await section.contentFn(sectionFrame)
frameIds.push(sectionFrame.id)
}
return { page, titleNode, frameIds }
}
@@ -0,0 +1,108 @@
/**
* createSemanticTokens
*
* Creates a batch of Figma variables in the given collection, one per entry in
* `tokenMap`. Supports raw values, variable alias references, code syntax, and
* scopes. Returns a map of token name → Variable for use in subsequent steps.
*
* @param {VariableCollection} collection - The target variable collection.
* @param {Record<string, string>} modeIds - Map of {modeName: modeId} from createVariableCollection.
* @param {Array<{
* name: string,
* type: 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN',
* values: Record<string, string | number | boolean | {type: 'VARIABLE_ALIAS', id: string}>,
* scopes?: VariableScope[],
* codeSyntax?: {WEB?: string, ANDROID?: string, iOS?: string}
* }>} tokenMap - Ordered list of token definitions.
* - `name`: Variable name using slash hierarchy (e.g. "color/bg/primary").
* - `type`: Figma variable type.
* - `values`: Map of {modeName: value}. Values can be raw (hex string for COLOR,
* number for FLOAT) or alias objects {type: 'VARIABLE_ALIAS', id: variableId}.
* For COLOR, raw values are accepted as hex strings ("#rrggbb" or "#rrggbbaa")
* and converted to {r, g, b, a} automatically.
* - `scopes`: Array of VariableScope strings. Omit to use [] (hidden/primitive).
* - `codeSyntax`: Platform code syntax strings. Omit to skip.
* @param {string} [runId] - Optional dsb_run_id to tag every variable.
* @returns {Promise<{variables: Record<string, Variable>}>}
* `variables` maps each token name to its created Variable object.
*/
async function createSemanticTokens(collection, modeIds, tokenMap, runId) {
const variables = {}
for (const token of tokenMap) {
// Create the variable
const variable = figma.variables.createVariable(token.name, collection, token.type)
// Tag for cleanup
variable.setPluginData('dsb_key', `variable/${token.name}`)
if (runId) {
variable.setPluginData('dsb_run_id', runId)
}
// Set values for each mode
for (const [modeName, rawValue] of Object.entries(token.values)) {
const modeId = modeIds[modeName]
if (!modeId) {
throw new Error(
`createSemanticTokens: mode "${modeName}" not found in modeIds for token "${token.name}". ` +
`Available modes: ${Object.keys(modeIds).join(', ')}`,
)
}
let value = rawValue
// Convert hex strings to Figma RGBA for COLOR type
if (token.type === 'COLOR' && typeof rawValue === 'string' && rawValue.startsWith('#')) {
value = hexToFigmaColor(rawValue)
}
variable.setValueForMode(modeId, value)
}
// Set scopes (default: empty array = hidden from property pickers / primitives)
variable.scopes = token.scopes || []
// Set code syntax per platform
if (token.codeSyntax) {
if (token.codeSyntax.WEB) {
variable.setVariableCodeSyntax('WEB', token.codeSyntax.WEB)
}
if (token.codeSyntax.ANDROID) {
variable.setVariableCodeSyntax('ANDROID', token.codeSyntax.ANDROID)
}
if (token.codeSyntax.iOS) {
variable.setVariableCodeSyntax('iOS', token.codeSyntax.iOS)
}
}
variables[token.name] = variable
}
return { variables }
}
/**
* Converts a hex color string to a Figma RGBA object.
* Supports "#rgb", "#rrggbb", and "#rrggbbaa".
*
* @param {string} hex
* @returns {{ r: number, g: number, b: number, a: number }}
*/
function hexToFigmaColor(hex) {
let h = hex.replace('#', '')
// Expand shorthand #rgb → #rrggbb
if (h.length === 3) {
h = h
.split('')
.map((c) => c + c)
.join('')
}
const r = parseInt(h.substring(0, 2), 16) / 255
const g = parseInt(h.substring(2, 4), 16) / 255
const b = parseInt(h.substring(4, 6), 16) / 255
const a = h.length === 8 ? parseInt(h.substring(6, 8), 16) / 255 : 1
return { r, g, b, a }
}
@@ -0,0 +1,49 @@
/**
* createVariableCollection
*
* Creates a new Figma variable collection with the specified name and modes.
* If `modeNames` has more than one entry, the first mode is renamed from
* Figma's default "Mode 1" to the first name, and additional modes are added.
*
* Every created collection is tagged with `dsb_key` plugin data so it can be
* found and cleaned up idempotently by `cleanupOrphans`.
*
* @param {string} name - The display name of the collection (e.g. "Color", "Spacing").
* @param {string[]} modeNames - Ordered list of mode names (e.g. ["Light", "Dark"] or ["Value"]).
* @param {string} [runId] - Optional dsb_run_id to tag for cleanup.
* @returns {Promise<{
* collection: VariableCollection,
* modeIds: Record<string, string>
* }>}
* `modeIds` maps each mode name to its modeId string.
*/
async function createVariableCollection(name, modeNames, runId) {
if (!modeNames || modeNames.length === 0) {
throw new Error('createVariableCollection: modeNames must have at least one entry.')
}
// Create the collection — Figma always creates it with one mode named "Mode 1".
const collection = figma.variables.createVariableCollection(name)
// Tag for idempotent cleanup
collection.setPluginData('dsb_key', `collection/${name}`)
if (runId) {
collection.setPluginData('dsb_run_id', runId)
}
// modeIds accumulator
const modeIds = {}
// Rename the default first mode
const defaultMode = collection.modes[0]
collection.renameMode(defaultMode.modeId, modeNames[0])
modeIds[modeNames[0]] = defaultMode.modeId
// Add additional modes
for (let i = 1; i < modeNames.length; i++) {
const newModeId = collection.addMode(modeNames[i])
modeIds[modeNames[i]] = newModeId
}
return { collection, modeIds }
}
@@ -0,0 +1,121 @@
/**
* inspectFileStructure
*
* Reads the current Figma file and returns a structural inventory:
* all pages (with child counts), all local variable collections (with mode
* names and variable counts), all component sets, all local text styles,
* and all local effect styles.
*
* This is a read-only discovery function — it never creates or mutates nodes.
* Run it at the start of Phase 0 to understand what already exists before
* planning any creation work.
*
* @returns {Promise<{
* pages: Array<{id: string, name: string, childCount: number}>,
* variableCollections: Array<{
* id: string,
* name: string,
* modes: Array<{modeId: string, name: string}>,
* variableCount: number,
* variableNames: string[]
* }>,
* componentSets: Array<{id: string, name: string, variantCount: number, pageId: string, pageName: string}>,
* textStyles: Array<{id: string, name: string, fontFamily: string, fontStyle: string, fontSize: number}>,
* effectStyles: Array<{id: string, name: string, effectCount: number}>
* }>}
*/
async function inspectFileStructure() {
const result = {
pages: [],
variableCollections: [],
componentSets: [],
textStyles: [],
effectStyles: [],
}
// --- Pages ---
for (const page of figma.root.children) {
result.pages.push({
id: page.id,
name: page.name,
childCount: page.children.length,
})
}
// --- Variable collections ---
const collections = await figma.variables.getLocalVariableCollectionsAsync()
for (const coll of collections) {
const variables = await Promise.all(
coll.variableIds.map((id) => figma.variables.getVariableByIdAsync(id)),
)
const variableNames = variables.filter(Boolean).map((v) => v.name)
result.variableCollections.push({
id: coll.id,
name: coll.name,
modes: coll.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
variableCount: coll.variableIds.length,
variableNames,
})
}
// --- Component sets (and standalone components) ---
// We need to load all pages to inspect components across the whole file.
const originalPage = figma.currentPage
for (const page of figma.root.children) {
await figma.setCurrentPageAsync(page)
const componentSetsOnPage = page.findAllWithCriteria({ types: ['COMPONENT_SET'] })
for (const cs of componentSetsOnPage) {
result.componentSets.push({
id: cs.id,
name: cs.name,
variantCount: cs.children.length,
pageId: page.id,
pageName: page.name,
})
}
// Also capture standalone components (not inside a component set)
const standaloneComponents = page
.findAllWithCriteria({ types: ['COMPONENT'] })
.filter((c) => c.parent && c.parent.type !== 'COMPONENT_SET')
for (const comp of standaloneComponents) {
result.componentSets.push({
id: comp.id,
name: comp.name,
variantCount: 1,
pageId: page.id,
pageName: page.name,
})
}
}
// Restore original page
await figma.setCurrentPageAsync(originalPage)
// --- Text styles ---
const textStyles = figma.getLocalTextStyles()
for (const ts of textStyles) {
result.textStyles.push({
id: ts.id,
name: ts.name,
fontFamily: ts.fontName.family,
fontStyle: ts.fontName.style,
fontSize: ts.fontSize,
})
}
// --- Effect styles ---
const effectStyles = figma.getLocalEffectStyles()
for (const es of effectStyles) {
result.effectStyles.push({
id: es.id,
name: es.name,
effectCount: es.effects.length,
})
}
return result
}
@@ -0,0 +1,92 @@
/**
* Scans the entire Figma file for nodes tagged with dsb_* pluginData
* and returns a complete state map for session recovery.
*
* Use this at the start of every new session, after context truncation,
* or when resuming an interrupted build.
*
* @param {string} runId - The run ID to filter by (optional — if omitted, returns ALL dsb-tagged nodes)
* @returns {{ runId: string, taggedNodes: Object<string, {nodeId: string, type: string, name: string, phase: string}>, variableCollections: Array, variables: Array, styles: Array }}
*/
async function rehydrateState(runId) {
const taggedNodes = {}
const variableCollections = []
const variables = []
const styles = []
// Scan all pages for dsb-tagged scene nodes
for (const page of figma.root.children) {
await figma.setCurrentPageAsync(page)
// Check the page itself
const pageRunId = page.getPluginData('dsb_run_id')
const pageKey = page.getPluginData('dsb_key')
if (pageKey && (!runId || pageRunId === runId)) {
taggedNodes[pageKey] = {
nodeId: page.id,
type: page.type,
name: page.name,
phase: page.getPluginData('dsb_phase') || 'unknown',
}
}
// Scan all descendants
page.findAll((node) => {
const nodeRunId = node.getPluginData('dsb_run_id')
const nodeKey = node.getPluginData('dsb_key')
if (nodeKey && (!runId || nodeRunId === runId)) {
taggedNodes[nodeKey] = {
nodeId: node.id,
type: node.type,
name: node.name,
phase: node.getPluginData('dsb_phase') || 'unknown',
}
}
return false // don't collect, just scan
})
}
// Inventory variable collections (variables don't support pluginData — use name-based lookup)
const collections = await figma.variables.getLocalVariableCollectionsAsync()
for (const coll of collections) {
variableCollections.push({
id: coll.id,
name: coll.name,
modes: coll.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
variableCount: coll.variableIds.length,
})
}
// Inventory variables (name + collection for idempotency key)
const allVars = await figma.variables.getLocalVariablesAsync()
for (const v of allVars) {
variables.push({
id: v.id,
name: v.name,
collectionId: v.variableCollectionId,
resolvedType: v.resolvedType,
})
}
// Inventory styles
for (const s of figma.getLocalTextStyles()) {
styles.push({ id: s.id, name: s.name, type: 'TEXT' })
}
for (const s of figma.getLocalEffectStyles()) {
styles.push({ id: s.id, name: s.name, type: 'EFFECT' })
}
for (const s of figma.getLocalPaintStyles()) {
styles.push({ id: s.id, name: s.name, type: 'PAINT' })
}
return {
runId: runId || 'all',
taggedNodes,
taggedNodeCount: Object.keys(taggedNodes).length,
variableCollections,
variableCount: variables.length,
variables,
styleCount: styles.length,
styles,
}
}
@@ -0,0 +1,83 @@
/**
* validateCreation
*
* Verifies that a set of nodes exist and match expected structural properties.
* Designed to run immediately after a creation script to catch partial failures
* before proceeding to the next build phase.
*
* Each check specifies a node ID and any combination of expected properties.
* A check passes when all specified expectations are met; it fails (with a
* reason string) as soon as any expectation is violated.
*
* @param {Array<{
* nodeId: string,
* expectedChildCount?: number,
* expectedName?: string,
* expectedType?: NodeType
* }>} checks
* - `nodeId`: The Figma node ID to look up via figma.getNodeById.
* - `expectedChildCount`: If set, the node must have exactly this many direct children.
* Applies to any node with a `children` property (frames, component sets, etc.).
* - `expectedName`: If set, the node's `.name` must exactly match this string.
* - `expectedType`: If set, the node's `.type` must exactly match this string.
* @returns {{
* passed: string[],
* failed: Array<{nodeId: string, reason: string}>
* }}
* `passed`: Array of nodeIds that passed all checks.
* `failed`: Array of objects with the nodeId and a human-readable reason string.
*/
function validateCreation(checks) {
const passed = []
const failed = []
for (const check of checks) {
const node = figma.getNodeById(check.nodeId)
// Node must exist
if (!node) {
failed.push({
nodeId: check.nodeId,
reason: `Node not found. It may not have been created, or was deleted.`,
})
continue
}
const reasons = []
// Type check
if (check.expectedType !== undefined && node.type !== check.expectedType) {
reasons.push(`type is "${node.type}", expected "${check.expectedType}"`)
}
// Name check
if (check.expectedName !== undefined && node.name !== check.expectedName) {
reasons.push(`name is "${node.name}", expected "${check.expectedName}"`)
}
// Child count check
if (check.expectedChildCount !== undefined) {
if (!('children' in node)) {
reasons.push(
`node type "${node.type}" does not have children, but expectedChildCount=${check.expectedChildCount} was specified`,
)
} else {
const actualCount = node.children.length
if (actualCount !== check.expectedChildCount) {
reasons.push(`has ${actualCount} children, expected ${check.expectedChildCount}`)
}
}
}
if (reasons.length > 0) {
failed.push({
nodeId: check.nodeId,
reason: reasons.join('; '),
})
} else {
passed.push(check.nodeId)
}
}
return { passed, failed }
}