Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
+110
@@ -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
|
||||
}
|
||||
+148
@@ -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])),
|
||||
[[]],
|
||||
)
|
||||
}
|
||||
+139
@@ -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 }
|
||||
}
|
||||
+108
@@ -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 }
|
||||
}
|
||||
+49
@@ -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 }
|
||||
}
|
||||
+121
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user