Transform the src/utils directory into a standalone kito library structured for npm distribution. The library provides:
- Explicit context separation:
kito/editorvskito/uiimports - Design tool abstraction: Unified API for Figma and Penpot
- Validated storage: Type-safe storage operations with key management
- Zero configuration: Auto-detects design tool context
Explicit Context Imports:
// Editor context (runs in plugin sandbox with figma/penpot globals)
import Kito from 'kito/editor'
// UI context (runs in iframe with window object)
import Kito from 'kito/ui'No "Core" or "Plugin" terminology:
- Use "Editor" for the design tool sandbox context (replaces "Core")
- Use "UI" for the iframe/window context
- Methods explicitly named:
sendToUI(),sendToEditor()
Library Structure:
kito/
├── package.json
├── README.md
├── src/
│ ├── index.js # Main export (throws error, must use /editor or /ui)
│ ├── editor.js # Editor context - exports { Actions, Storage, Messages, Canvas, History, Environment, Nodes }
│ ├── ui.js # UI context - exports { Messages, Actions, Environment }
│ ├── editor/
│ │ ├── Actions.js # UI lifecycle (showUI, closePlugin)
│ │ ├── Storage.js # Validated storage operations
│ │ ├── Messages.js # Editor→UI messaging
│ │ ├── Canvas.js # Selection and node operations
│ │ ├── History.js # Undo/redo operations
│ │ ├── Environment.js # Context information
│ │ └── Nodes.js # Node mapper utilities (wrap/unwrap)
│ ├── ui/
│ │ ├── Messages.js # UI→Editor messaging
│ │ ├── Actions.js # UI operations (ready, resize)
│ │ └── Environment.js # Context information
│ ├── nodes/
│ │ ├── OpenNode.js # Unified node structure/wrapper
│ │ ├── FigmaMapper.js # Figma node → OpenNode mapper
│ │ ├── PenpotMapper.js # Penpot node → OpenNode mapper
│ │ └── FramerMapper.js # Framer node → OpenNode mapper (future)
│ └── shared/
│ ├── constants.js # Design tool constants (FIGMA, PENPOT, FRAMER)
│ └── config.js # Auto-detect and load CONFIG
└── tests/ # Unit tests
import { Actions, Storage, Messages, Canvas, History, Environment } from 'kito/editor'
// Actions - UI lifecycle operations
Actions.showUI(options) // Show plugin UI
Actions.closePlugin() // Close plugin
// Storage - Validated storage operations (Editor-only)
Storage.setKeys(KEYS) // Optional: Set valid keys for integrity checks
await Storage.get(key) // Get value
await Storage.set(key, value) // Set value
await Storage.getMultiple(keys) // Get multiple values
await Storage.setMultiple(pairs) // Set multiple values
await Storage.remove(key) // Remove value
Storage.getKey(keyName) // Get key by name (requires setKeys to be called first)
// Messages - Communication with UI
// Note: .on() subscribes to UI messages (no need to specify direction)
Messages.on('message-name', (payload) => {}) // Listen to UI
Messages.send('message-name', payload) // Send to UI
// Canvas - Selection and node operations
Canvas.getCurrentSelection() // Returns OpenNode[] (wrapped)
Canvas.setSelection(nodes) // Accepts OpenNode[] or native nodes
Canvas.getCurrentPage()
Canvas.onSelectionChange(callback)
Canvas.createFrame() // Returns OpenNode (wrapped)
Canvas.notify(text) // Show notification toast in canvas
// Nodes - Node mapper utilities
const openNode = Nodes.wrap(figmaNode) // Wrap native node → OpenNode
const nativeNode = Nodes.unwrap(openNode) // Unwrap OpenNode → native node
const openNodes = Nodes.wrapMany(nodes) // Wrap array of nodes
const nativeNodes = Nodes.unwrapMany(openNodes) // Unwrap array of nodes
// History - Undo/redo operations
History.startUndoBlock()
History.finishUndoBlock(id)
// Environment - Context information
Environment.getCurrent() // Returns 'figma' or 'penpot'
Environment.getCurrentCommand() // Get menu command that triggered pluginimport { Messages, Actions, Environment } from 'kito/ui'
// Messages - Communication with Editor
// Note: .on() subscribes to Editor messages (explicit by import context)
Messages.on('message-name', (payload) => {}) // Listen to Editor
Messages.send('message-name', payload) // Send to Editor
// Actions - UI-specific operations
Actions.ready() // Signal UI is ready
Actions.resizeUI(width, height) // Resize UI window
// Environment - Context information
Environment.getCurrent() // Returns 'figma' or 'penpot'Create new directory: kito/ (alongside src/, not inside it)
Files to create:
kito/package.json- npm package configkito/README.md- Library documentationkito/src/index.js- Main entry (error message)kito/src/editor.js- Editor context singletonkito/src/ui.js- UI context singletonkito/src/shared/constants.js- Design tool constantskito/src/storage/Storage.js- Storage utility
package.json exports:
{
"name": "kito",
"version": "1.0.0",
"exports": {
".": "./src/index.js",
"./editor": "./src/editor.js",
"./ui": "./src/ui.js"
}
}Merge from:
- FigPen design tool methods (Figma/Penpot abstraction)
- Storage functionality (validated storage operations)
Key features:
- Singleton pattern with auto-initialization
- Auto-detect
figmavspenpotglobal - Load CONFIG internally from environment/globals
- All canvas/storage operations
- Editor → UI messaging
Renamed methods:
notifyUI()→sendToUI()onUIMessage()→onUIMessage()(keep)currentSelection()→getCurrentSelection()currentPage()→getCurrentPage()currentCommand()→getCurrentCommand()
Features:
- Singleton pattern with auto-initialization
- Auto-detect
figmavspenpotfrom parent window messages - UI → Editor messaging only
- No canvas/storage access
Renamed methods:
notifyEditor()→sendToEditor()onEditorMessage()→onEditorMessage()(keep)initializeUI()→ready()
Merge from existing Storage.js:
- Validated keys system with
init(keys) - All get/set operations with error tracking
- Batch operations (getMultiple, setMultiple)
- Error emission for tracking
Integration:
- Accessed via
kito.storage.*in editor context - NOT available in UI context (storage is editor-only)
Purpose: Abstract differences between Figma, Penpot, and Framer node APIs into a unified OpenNode structure. Mappers are internal - developers work with OpenNodes transparently.
Files:
kito/src/nodes/OpenNodeMapper.js- Base mapper classkito/src/nodes/FigmaMapper.js- Figma → OpenNode mapperkito/src/nodes/PenpotMapper.js- Penpot → OpenNode mapperkito/src/nodes/FramerMapper.js- Framer → OpenNode mapper (future)
OpenNode Structure (Basic v1):
class OpenNode {
constructor(nativeNode, designTool) {
this._native = nativeNode // Original node reference
this._tool = designTool // 'figma', 'penpot', 'framer'
}
// Read-only properties
get id() { ... }
get name() { ... }
get type() { ... } // 'FRAME', 'TEXT', 'GROUP', etc.
get x() { ... }
get y() { ... }
get width() { ... }
get height() { ... }
get visible() { ... }
get locked() { ... }
// Hierarchy (read-only)
get parent() { ... } // Returns OpenNode or null
get children() { ... } // Returns OpenNode[]
// Text properties (read-only, TEXT nodes only)
get characters() { ... }
// Mutation methods
setName(name) { ... }
setPosition(x, y) { ... }
setX(x) { ... }
setY(y) { ... }
resize(width, height) { ... }
setWidth(width) { ... }
setHeight(height) { ... }
setVisible(visible) { ... }
setLocked(locked) { ... }
async setCharacters(text) { ... } // Handles font loading internally
// Hierarchy mutations
addChild(node) { ... }
removeChild(node) { ... }
// Other methods
clone() { ... }
remove() { ... }
getBounds() { ... }
// Access native node if needed (escape hatch)
getNative() { return this._native }
}Mapper Implementation: Object-based Pattern (Recommended)
Use abstract base mapper with platform-specific implementations that return plain OpenNode objects:
Base Mapper (Abstract Pattern):
// kito/src/nodes/OpenNodeMapper.js
/**
* NodeType enum for normalized node types
*/
export const NodeType = {
FRAME: 'FRAME',
GROUP: 'GROUP',
RECTANGLE: 'RECTANGLE',
TEXT: 'TEXT',
IMAGE: 'IMAGE',
INSTANCE: 'INSTANCE',
COMPONENT: 'COMPONENT',
ELLIPSE: 'ELLIPSE',
LINE: 'LINE',
VECTOR: 'VECTOR'
}
/**
* OpenNode mapper class - defines the contract for platform mappers
*/
export class OpenNodeMapper {
/**
* Map platform node to OpenNode
* @param {any} platformNode - Native platform node
* @returns {OpenNode} Normalized OpenNode object
*/
mapToOpenNode(platformNode) {
throw new Error('mapToOpenNode must be implemented by subclass')
}
/**
* Extract native platform node from OpenNode
* @param {OpenNode} openNode - OpenNode wrapper
* @returns {any} Native platform node
*/
mapFromOpenNode(openNode) {
return openNode._native
}
/**
* Get current selection as OpenNodes
* @returns {OpenNode[]} Array of selected OpenNodes
*/
getSelection() {
throw new Error('getSelection must be implemented by subclass')
}
/**
* Get current page as OpenNode
* @returns {OpenNode} Current page as OpenNode
*/
getCurrentPage() {
throw new Error('getCurrentPage must be implemented by subclass')
}
/**
* Create a new platform node
* @param {NodeType} type - Type of node to create
* @param {Object} options - Node creation options
* @returns {any} Native platform node
*/
createPlatformNode(type, options = {}) {
throw new Error('createPlatformNode must be implemented by subclass')
}
}Figma Mapper Implementation:
// kito/src/nodes/FigmaMapper.js
import { OpenNodeMapper, NodeType } from './OpenNodeMapper.js'
export class FigmaNodeMapper extends OpenNodeMapper {
mapToOpenNode(figmaNode) {
if (!figmaNode) return null
// Return OpenNode object with properties + methods
return {
// Store reference to native node
_native: figmaNode,
_platform: 'figma',
// Properties (direct mapping)
id: figmaNode.id,
name: figmaNode.name,
type: this.mapNodeType(figmaNode.type),
x: figmaNode.x,
y: figmaNode.y,
width: figmaNode.width,
height: figmaNode.height,
visible: figmaNode.visible,
locked: figmaNode.locked,
// Hierarchy (lazy map children/parent)
get parent() {
return figmaNode.parent ? this.mapToOpenNode(figmaNode.parent) : null
},
get children() {
return figmaNode.children?.map(child => this.mapToOpenNode(child)) || []
},
// Text-specific properties
get characters() {
return figmaNode.type === 'TEXT' ? figmaNode.characters : undefined
},
// Methods - Mutation operations
setPosition: (x, y) => {
figmaNode.x = x
figmaNode.y = y
},
setSize: (width, height) => {
figmaNode.resize(width, height)
},
setName: (name) => {
figmaNode.name = name
},
async setCharacters(text) {
if (figmaNode.type === 'TEXT') {
await figma.loadFontAsync(figmaNode.fontName)
figmaNode.characters = text
}
},
addChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
figmaNode.appendChild(nativeChild)
},
removeChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
nativeChild.remove()
},
clone: () => {
const cloned = figmaNode.clone()
return this.mapToOpenNode(cloned)
},
remove: () => {
figmaNode.remove()
},
getBounds: () => ({
x: figmaNode.x,
y: figmaNode.y,
width: figmaNode.width,
height: figmaNode.height
}),
// Escape hatch - get native node
getNative: () => figmaNode
}
}
mapNodeType(figmaType) {
const typeMap = {
'FRAME': NodeType.FRAME,
'GROUP': NodeType.GROUP,
'RECTANGLE': NodeType.RECTANGLE,
'TEXT': NodeType.TEXT,
'ELLIPSE': NodeType.ELLIPSE,
'LINE': NodeType.LINE,
'VECTOR': NodeType.VECTOR,
'INSTANCE': NodeType.INSTANCE,
'COMPONENT': NodeType.COMPONENT
}
return typeMap[figmaType] || NodeType.RECTANGLE
}
getSelection() {
return figma.currentPage.selection.map(node => this.mapToOpenNode(node))
}
getCurrentPage() {
return this.mapToOpenNode(figma.currentPage)
}
createPlatformNode(type, options = {}) {
switch (type) {
case NodeType.FRAME:
return figma.createFrame()
case NodeType.RECTANGLE:
return figma.createRectangle()
case NodeType.TEXT:
return figma.createText()
case NodeType.ELLIPSE:
return figma.createEllipse()
default:
return figma.createRectangle()
}
}
}Penpot Mapper Implementation:
// kito/src/nodes/PenpotMapper.js
import { OpenNodeMapper, NodeType } from './OpenNodeMapper.js'
export class PenpotNodeMapper extends OpenNodeMapper {
mapToOpenNode(penpotNode) {
if (!penpotNode) return null
return {
_native: penpotNode,
_platform: 'penpot',
// Properties (with normalization)
id: penpotNode.id,
name: penpotNode.name,
type: this.mapNodeType(penpotNode.type), // Normalize to uppercase
x: penpotNode.x,
y: penpotNode.y,
width: penpotNode.width,
height: penpotNode.height,
visible: penpotNode.visible,
locked: penpotNode.blocked, // Penpot uses 'blocked' instead of 'locked'
// Hierarchy
get parent() {
return penpotNode.parent ? this.mapToOpenNode(penpotNode.parent) : null
},
get children() {
return penpotNode.children?.map(child => this.mapToOpenNode(child)) || []
},
// Text properties
get characters() {
return penpotNode.type === 'text' ? penpotNode.characters : undefined
},
// Methods - Penpot-specific implementations
setPosition: (x, y) => {
penpotNode.x = x
penpotNode.y = y
},
setSize: (width, height) => {
penpotNode.resize(width, height)
},
setName: (name) => {
penpotNode.name = name
},
async setCharacters(text) {
if (penpotNode.type === 'text') {
penpotNode.characters = text // No font loading in Penpot
}
},
addChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
penpotNode.appendChild(nativeChild)
},
removeChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
nativeChild.remove()
},
clone: () => {
const cloned = penpotNode.clone()
return this.mapToOpenNode(cloned)
},
remove: () => {
penpotNode.remove()
},
getBounds: () => ({
x: penpotNode.x,
y: penpotNode.y,
width: penpotNode.width,
height: penpotNode.height
}),
getNative: () => penpotNode
}
}
mapNodeType(penpotType) {
// Penpot uses lowercase, normalize to uppercase
const typeMap = {
'frame': NodeType.FRAME,
'group': NodeType.GROUP,
'rect': NodeType.RECTANGLE,
'text': NodeType.TEXT,
'circle': NodeType.ELLIPSE,
'path': NodeType.VECTOR
}
return typeMap[penpotType] || NodeType.RECTANGLE
}
getSelection() {
return penpot.selection.map(node => this.mapToOpenNode(node))
}
getCurrentPage() {
return this.mapToOpenNode(penpot.currentPage)
}
createPlatformNode(type, options = {}) {
switch (type) {
case NodeType.FRAME:
return penpot.createBoard() // Penpot calls frames "boards"
case NodeType.RECTANGLE:
return penpot.createRectangle()
case NodeType.TEXT:
return penpot.createText()
case NodeType.ELLIPSE:
return penpot.createEllipse()
default:
return penpot.createRectangle()
}
}
}Benefits of Object-based Mapper:
- ✅ Explicit and clear: Each property and method is clearly defined
- ✅ Type-safe ready: Easy to add TypeScript types
- ✅ Better IDE support: Autocomplete works perfectly
- ✅ Testable: Easy to mock and test individual mappers
- ✅ Maintainable: Clear separation of platform-specific logic
- ✅ Extensible: Easy to add new platforms by extending OpenNodeMapper
- ✅ No magic: No Proxy tricks, just plain objects with methods
Internal Integration (Transparent to Developers):
Canvas methods automatically wrap/unwrap:
// kito/src/editor/Canvas.js
import { wrap, unwrap, wrapMany, unwrapMany } from '../nodes/mappers'
export default {
getCurrentSelection() {
const nativeNodes = getCurrentSelectionNative()
return wrapMany(nativeNodes) // Returns OpenNode[]
},
setSelection(nodes) {
// Accept OpenNode[] or native nodes
const nativeNodes = Array.isArray(nodes)
? nodes.map(n => n.getNative ? n.getNative() : n)
: nodes
setSelectionNative(nativeNodes)
},
createFrame() {
const nativeFrame = createFrameNative()
return wrap(nativeFrame) // Returns OpenNode
}
}Developer Experience (No Mapper Exposure):
// Developer code - no knowledge of mappers needed!
import { Canvas } from 'kito/editor'
const selection = Canvas.getCurrentSelection() // OpenNode[]
selection.forEach(node => {
// Read properties (read-only)
console.log(node.name) // Works for Figma & Penpot
console.log(node.x, node.y) // Works for both
console.log(node.width, node.height)
// Mutate via methods (required)
node.setPosition(node.x + 100, node.y) // Move node
node.setName('New Name')
if (node.type === 'TEXT') {
await node.setCharacters('Hello') // Font loading handled internally
}
})
const frame = Canvas.createFrame() // OpenNode
frame.setName('My Frame')
frame.resize(200, 100)
// OR
frame.setWidth(200)
frame.setHeight(100)Phase 1 Properties (Minimum Viable):
Basic properties to implement in first version:
id,name,typex,y,width,heightparent,childrencharacters,setCharacters()(for TEXT nodes)remove()
Phase 2+ Properties (Future):
visible,locked,opacityfills,strokes,effectsrotation,constraintscomponentProperties- Layout properties (auto-layout, padding, etc.)
// Throw helpful error if imported without /editor or /ui
throw new Error(
'kito must be imported with explicit context:\n' +
' Editor: import Kito from "kito/editor"\n' +
' UI: import Kito from "kito/ui"'
)The library uses standard exports, so no special alias configuration is needed. Import directly using relative path during development:
// Development (local library)
import { Actions, Canvas } from '../kito/editor'
// Production (from npm)
import { Actions, Canvas } from 'kito/editor'Bundler Configuration Examples:
Webpack (webpack.config.js):
module.exports = {
// No special configuration needed
// Webpack handles package exports natively
resolve: {
extensions: ['.js', '.json']
}
}Vite (vite.config.js):
import { defineConfig } from 'vite'
export default defineConfig({
// No special configuration needed
// Vite handles package exports natively
resolve: {
extensions: ['.js', '.json']
}
})Rollup (rollup.config.js):
import resolve from '@rollup/plugin-node-resolve'
export default {
plugins: [
resolve({
// Handles package exports from package.json
exportConditions: ['import', 'module', 'default']
})
]
}Bun (bunfig.toml):
# Bun handles package exports natively, no config needed
# Just use standard importsParcel:
// No configuration needed
// Parcel handles package exports automatically
// Just add to package.json dependenciesFor Development (Local Library):
Add to your project's package.json during development:
{
"dependencies": {
"kito": "file:./kito"
}
}Then run npm install or bun install to link the local package.
Purpose: Abstract differences between Figma, Penpot, and Framer node APIs into a unified OpenNode structure. Mappers are internal - developers work with OpenNodes transparently.
Files:
kito/src/nodes/OpenNode.js- Unified node wrapper classkito/src/nodes/FigmaMapper.js- Figma → OpenNode mapperkito/src/nodes/PenpotMapper.js- Penpot → OpenNode mapperkito/src/nodes/FramerMapper.js- Framer → OpenNode mapper (future)
OpenNode Structure (Basic v1):
class OpenNode {
constructor(nativeNode, designTool) {
this._native = nativeNode // Original node reference
this._tool = designTool // 'figma', 'penpot', 'framer'
}
// Common properties (auto-mapped)
get id() { ... }
get name() { ... }
set name(value) { ... }
get type() { ... } // 'FRAME', 'TEXT', 'GROUP', etc.
// Position & dimensions
get x() { ... }
set x(value) { ... }
get y() { ... }
set y(value) { ... }
get width() { ... }
set width(value) { ... }
get height() { ... }
set height(value) { ... }
// Hierarchy
get parent() { ... } // Returns OpenNode or null
get children() { ... } // Returns OpenNode[]
// Text operations (for TEXT nodes)
get characters() { ... }
async setCharacters(text) { ... } // Handles font loading internally
// Methods
remove() { ... }
// Access native node if needed (escape hatch)
getNative() { return this._native }
}Mapper Implementation: Object-based Pattern (Recommended)
Use abstract base mapper with platform-specific implementations that return plain OpenNode objects:
Base Mapper (Abstract Pattern):
// kito/src/nodes/OpenNodeMapper.js
/**
* NodeType enum for normalized node types
*/
export const NodeType = {
FRAME: 'FRAME',
GROUP: 'GROUP',
RECTANGLE: 'RECTANGLE',
TEXT: 'TEXT',
IMAGE: 'IMAGE',
INSTANCE: 'INSTANCE',
COMPONENT: 'COMPONENT',
ELLIPSE: 'ELLIPSE',
LINE: 'LINE',
VECTOR: 'VECTOR'
}
/**
* OpenNode mapper class - defines the contract for platform mappers
*/
export class OpenNodeMapper {
/**
* Map platform node to OpenNode
* @param {any} platformNode - Native platform node
* @returns {OpenNode} Normalized OpenNode object
*/
mapToOpenNode(platformNode) {
throw new Error('mapToOpenNode must be implemented by subclass')
}
/**
* Extract native platform node from OpenNode
* @param {OpenNode} openNode - OpenNode wrapper
* @returns {any} Native platform node
*/
mapFromOpenNode(openNode) {
return openNode._native
}
/**
* Get current selection as OpenNodes
* @returns {OpenNode[]} Array of selected OpenNodes
*/
getSelection() {
throw new Error('getSelection must be implemented by subclass')
}
/**
* Get current page as OpenNode
* @returns {OpenNode} Current page as OpenNode
*/
getCurrentPage() {
throw new Error('getCurrentPage must be implemented by subclass')
}
/**
* Create a new platform node
* @param {NodeType} type - Type of node to create
* @param {Object} options - Node creation options
* @returns {any} Native platform node
*/
createPlatformNode(type, options = {}) {
throw new Error('createPlatformNode must be implemented by subclass')
}
}Figma Mapper Implementation:
// kito/src/nodes/FigmaMapper.js
import { BaseNodeMapper, NodeType } from './BaseNodeMapper.js'
export class FigmaNodeMapper extends BaseNodeMapper {
mapToOpenNode(figmaNode) {
if (!figmaNode) return null
// Return OpenNode object with properties + methods
return {
// Store reference to native node
_native: figmaNode,
_platform: 'figma',
// Properties (direct mapping)
id: figmaNode.id,
name: figmaNode.name,
type: this.mapNodeType(figmaNode.type),
x: figmaNode.x,
y: figmaNode.y,
width: figmaNode.width,
height: figmaNode.height,
visible: figmaNode.visible,
locked: figmaNode.locked,
// Hierarchy (lazy map children/parent)
get parent() {
return figmaNode.parent ? this.mapToOpenNode(figmaNode.parent) : null
},
get children() {
return figmaNode.children?.map(child => this.mapToOpenNode(child)) || []
},
// Text-specific properties
get characters() {
return figmaNode.type === 'TEXT' ? figmaNode.characters : undefined
},
// Methods - Mutation operations
setPosition: (x, y) => {
figmaNode.x = x
figmaNode.y = y
},
setSize: (width, height) => {
figmaNode.resize(width, height)
},
setName: (name) => {
figmaNode.name = name
},
async setCharacters(text) {
if (figmaNode.type === 'TEXT') {
await figma.loadFontAsync(figmaNode.fontName)
figmaNode.characters = text
}
},
addChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
figmaNode.appendChild(nativeChild)
},
removeChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
nativeChild.remove()
},
clone: () => {
const cloned = figmaNode.clone()
return this.mapToOpenNode(cloned)
},
remove: () => {
figmaNode.remove()
},
getBounds: () => ({
x: figmaNode.x,
y: figmaNode.y,
width: figmaNode.width,
height: figmaNode.height
}),
// Escape hatch - get native node
getNative: () => figmaNode
}
}
mapNodeType(figmaType) {
const typeMap = {
'FRAME': NodeType.FRAME,
'GROUP': NodeType.GROUP,
'RECTANGLE': NodeType.RECTANGLE,
'TEXT': NodeType.TEXT,
'ELLIPSE': NodeType.ELLIPSE,
'LINE': NodeType.LINE,
'VECTOR': NodeType.VECTOR,
'INSTANCE': NodeType.INSTANCE,
'COMPONENT': NodeType.COMPONENT
}
return typeMap[figmaType] || NodeType.RECTANGLE
}
getSelection() {
return figma.currentPage.selection.map(node => this.mapToOpenNode(node))
}
getCurrentPage() {
return this.mapToOpenNode(figma.currentPage)
}
createPlatformNode(type, options = {}) {
switch (type) {
case NodeType.FRAME:
return figma.createFrame()
case NodeType.RECTANGLE:
return figma.createRectangle()
case NodeType.TEXT:
return figma.createText()
case NodeType.ELLIPSE:
return figma.createEllipse()
default:
return figma.createRectangle()
}
}
}Penpot Mapper Implementation:
// kito/src/nodes/PenpotMapper.js
import { BaseNodeMapper, NodeType } from './BaseNodeMapper.js'
export class PenpotNodeMapper extends BaseNodeMapper {
mapToOpenNode(penpotNode) {
if (!penpotNode) return null
return {
_native: penpotNode,
_platform: 'penpot',
// Properties (with normalization)
id: penpotNode.id,
name: penpotNode.name,
type: this.mapNodeType(penpotNode.type), // Normalize to uppercase
x: penpotNode.x,
y: penpotNode.y,
width: penpotNode.width,
height: penpotNode.height,
visible: penpotNode.visible,
locked: penpotNode.blocked, // Penpot uses 'blocked' instead of 'locked'
// Hierarchy
get parent() {
return penpotNode.parent ? this.mapToOpenNode(penpotNode.parent) : null
},
get children() {
return penpotNode.children?.map(child => this.mapToOpenNode(child)) || []
},
// Text properties
get characters() {
return penpotNode.type === 'text' ? penpotNode.characters : undefined
},
// Methods - Penpot-specific implementations
setPosition: (x, y) => {
penpotNode.x = x
penpotNode.y = y
},
setSize: (width, height) => {
penpotNode.resize(width, height)
},
setName: (name) => {
penpotNode.name = name
},
async setCharacters(text) {
if (penpotNode.type === 'text') {
penpotNode.characters = text // No font loading in Penpot
}
},
addChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
penpotNode.appendChild(nativeChild)
},
removeChild: (childNode) => {
const nativeChild = this.mapFromOpenNode(childNode)
nativeChild.remove()
},
clone: () => {
const cloned = penpotNode.clone()
return this.mapToOpenNode(cloned)
},
remove: () => {
penpotNode.remove()
},
getBounds: () => ({
x: penpotNode.x,
y: penpotNode.y,
width: penpotNode.width,
height: penpotNode.height
}),
getNative: () => penpotNode
}
}
mapNodeType(penpotType) {
// Penpot uses lowercase, normalize to uppercase
const typeMap = {
'frame': NodeType.FRAME,
'group': NodeType.GROUP,
'rect': NodeType.RECTANGLE,
'text': NodeType.TEXT,
'circle': NodeType.ELLIPSE,
'path': NodeType.VECTOR
}
return typeMap[penpotType] || NodeType.RECTANGLE
}
getSelection() {
return penpot.selection.map(node => this.mapToOpenNode(node))
}
getCurrentPage() {
return this.mapToOpenNode(penpot.currentPage)
}
createPlatformNode(type, options = {}) {
switch (type) {
case NodeType.FRAME:
return penpot.createBoard() // Penpot calls frames "boards"
case NodeType.RECTANGLE:
return penpot.createRectangle()
case NodeType.TEXT:
return penpot.createText()
case NodeType.ELLIPSE:
return penpot.createEllipse()
default:
return penpot.createRectangle()
}
}
}Benefits of Object-based Mapper:
- ✅ Explicit and clear: Each property and method is clearly defined
- ✅ Type-safe ready: Easy to add TypeScript types
- ✅ Better IDE support: Autocomplete works perfectly
- ✅ Testable: Easy to mock and test individual mappers
- ✅ Maintainable: Clear separation of platform-specific logic
- ✅ Extensible: Easy to add new platforms by extending BaseNodeMapper
- ✅ No magic: No Proxy tricks, just plain objects with methods
Internal Integration (Transparent to Developers):
Canvas methods automatically wrap/unwrap:
// kito/src/editor/Canvas.js
import { wrap, unwrap, wrapMany, unwrapMany } from '../nodes/mappers'
export default {
getCurrentSelection() {
const nativeNodes = getCurrentSelectionNative()
return wrapMany(nativeNodes) // Returns OpenNode[]
},
setSelection(nodes) {
// Accept OpenNode[] or native nodes
const nativeNodes = Array.isArray(nodes)
? nodes.map(n => n.getNative ? n.getNative() : n)
: nodes
setSelectionNative(nativeNodes)
},
createFrame() {
const nativeFrame = createFrameNative()
return wrap(nativeFrame) // Returns OpenNode
}
}Developer Experience (No Mapper Exposure):
// Developer code - no knowledge of mappers needed!
import { Canvas } from 'kito/editor'
const selection = Canvas.getCurrentSelection() // OpenNode[]
selection.forEach(node => {
console.log(node.name) // Works for Figma & Penpot
console.log(node.x, node.y) // Works for both
node.x += 100 // Works for both
if (node.type === 'TEXT') {
await node.setCharacters('Hello') // Font loading handled internally
}
})
const frame = Canvas.createFrame() // OpenNode
frame.name = 'My Frame'
frame.width = 200Phase 1 Properties (Minimum Viable):
Basic properties to implement in first version:
id,name,typex,y,width,heightparent,childrencharacters,setCharacters()(for TEXT nodes)remove()
Phase 2+ Properties (Future):
visible,locked,opacityfills,strokes,effectsrotation,constraintscomponentProperties- Layout properties (auto-layout, padding, etc.)
kito/README.md:
- Installation instructions
- API reference for Editor context (Actions, Storage, Messages, Canvas, History, Environment)
- API reference for UI context (Messages, Actions, Environment)
- OpenNode API reference (transparent mapping)
- Usage examples
- Migration guide from FigPen/Storage
- Clear separation: Explicit Editor vs UI imports prevent misuse
- npm-ready: Library structure ready for publishing
- Better DX:
sendToUI/sendToEditorvsnotifyUI/notifyEditor - No boilerplate: Zero instantiation, just import and use
- Type-safe ready: JSDoc annotations, easy TypeScript migration
- Clearer naming: "Editor" and "UI" instead of "Core" and "Plugin"
- Standalone: Can be extracted to separate repo for reuse
Build with TypeScript for better DX and type safety from day one.
✅ JS developers can use the library without TypeScript (just consume the compiled output)
✅ TS developers get automatic type checking and autocomplete
✅ No TypeScript required for consumers
Build output structure:
kito/
├── src/ # TypeScript source
├── dist/ # Compiled output
│ ├── editor.js
│ ├── editor.d.ts
│ ├── ui.js
│ ├── ui.d.ts
│ └── ...
Official Type Packages (Install as Dependencies):
npm install --save-dev @figma/plugin-typings @penpot/plugin-types typescript- Figma: @figma/plugin-typings - Official Figma Plugin API typings
- Penpot: @penpot/plugin-types - Official Penpot Plugin API typings
TypeScript Configuration (tsconfig.json):
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@figma",
"./node_modules/@penpot"
]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Build Tool: tsup (Recommended)
Use tsup for zero-config TypeScript library bundling:
npm install --save-dev tsup typescript @figma/plugin-typings @penpot/plugin-typestsup.config.ts:
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'src/index.ts',
editor: 'src/editor.ts',
ui: 'src/ui.ts'
},
format: ['esm'], // ESM only for modern bundlers
dts: true, // Generate .d.ts files
sourcemap: true,
clean: true,
splitting: false,
treeshake: true,
outDir: 'dist'
})Package.json Configuration:
{
"name": "kito",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./editor": {
"types": "./dist/editor.d.ts",
"default": "./dist/editor.js"
},
"./ui": {
"types": "./dist/ui.d.ts",
"default": "./dist/ui.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@figma/plugin-typings": "^1.x.x",
"@penpot/plugin-types": "^1.x.x",
"tsup": "^8.x.x",
"typescript": "^5.x.x"
}
}Alternative: TypeScript Compiler Only (Simpler, No Bundling)
If you prefer no bundling and just TypeScript compilation:
{
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@figma/plugin-typings": "^1.x.x",
"@penpot/plugin-types": "^1.x.x",
"typescript": "^5.x.x"
}
}Keep the same tsconfig.json as before. This outputs one .js file per .ts file.
Alternative: Rollup (Maximum Control)
For fine-grained control, use Rollup:
npm install --save-dev rollup @rollup/plugin-typescript rollup-plugin-dtsrollup.config.js:
import typescript from '@rollup/plugin-typescript'
import dts from 'rollup-plugin-dts'
export default [
// JavaScript builds
{
input: {
index: 'src/index.ts',
editor: 'src/editor.ts',
ui: 'src/ui.ts'
},
output: {
dir: 'dist',
format: 'es',
sourcemap: true,
preserveModules: false
},
plugins: [typescript()],
external: ['@figma/plugin-typings', '@penpot/plugin-types']
},
// Type definition builds
{
input: {
index: 'src/index.ts',
editor: 'src/editor.ts',
ui: 'src/ui.ts'
},
output: {
dir: 'dist',
format: 'es'
},
plugins: [dts()]
}
]Alternative: Bun (Fastest & Simplest)
Bun has built-in TypeScript compilation with zero config:
Pros:
- ✅ Blazing fast (faster than tsup/esbuild)
- ✅ Built-in TypeScript support
- ✅ Zero configuration needed
- ✅ Generates
.d.tsautomatically withbun build - ✅ Native bundler included
Package.json with Bun:
{
"name": "kito",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./editor": {
"types": "./dist/editor.d.ts",
"default": "./dist/editor.js"
},
"./ui": {
"types": "./dist/ui.d.ts",
"default": "./dist/ui.js"
}
},
"files": ["dist"],
"scripts": {
"build": "bun build.ts",
"dev": "bun --watch build.ts",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@figma/plugin-typings": "^1.x.x",
"@penpot/plugin-types": "^1.x.x",
"bun-types": "latest"
}
}build.ts:
import { build } from 'bun'
// Build each entry point
const entries = ['src/index.ts', 'src/editor.ts', 'src/ui.ts']
for (const entry of entries) {
await build({
entrypoints: [entry],
outdir: './dist',
target: 'browser',
format: 'esm',
sourcemap: 'external',
minify: false,
splitting: false
})
}
// Generate type definitions
await Bun.spawn(['bun', 'x', 'tsc', '--emitDeclarationOnly'], {
stdio: ['inherit', 'inherit', 'inherit']
})
console.log('✅ Build complete!')Or even simpler with bun's package-based build:
{
"scripts": {
"build": "bun build src/index.ts src/editor.ts src/ui.ts --outdir ./dist --target browser --format esm --sourcemap=external && bun x tsc --emitDeclarationOnly"
}
}Recommendation: Use Bun or tsup
For this library, both are excellent choices:
Choose Bun if:
- You want the absolute fastest builds
- You prefer minimal configuration
- Your team already uses Bun
- You want one tool for everything (runtime + bundler + package manager)
Choose tsup if:
- You need more ecosystem compatibility
- Your team uses npm/pnpm/yarn
- You want more build configuration options
- You're already familiar with esbuild ecosystem
Lightweight Type Definitions:
// kito/src/types/index.ts
import type { SceneNode as FigmaNode } from '@figma/plugin-typings'
// import type { Shape as PenpotNode } from '@penpot/plugin-types' // Add when available
// Design tool type
export type DesignTool = 'figma' | 'penpot' | 'framer'
// Node types (normalized across platforms)
export enum NodeType {
FRAME = 'FRAME',
GROUP = 'GROUP',
RECTANGLE = 'RECTANGLE',
TEXT = 'TEXT',
IMAGE = 'IMAGE',
INSTANCE = 'INSTANCE',
COMPONENT = 'COMPONENT',
ELLIPSE = 'ELLIPSE',
LINE = 'LINE',
VECTOR = 'VECTOR'
}
// OpenNode interface (Phase 1 - basic properties)
export interface OpenNode {
_native: any // Native node reference
_platform: DesignTool
// Properties
id: string
name: string
type: NodeType
x: number
y: number
width: number
height: number
visible: boolean
locked: boolean
// Hierarchy
readonly parent: OpenNode | null
readonly children: OpenNode[]
// Text properties (TEXT nodes only)
readonly characters?: string
// Methods
setPosition(x: number, y: number): void
setSize(width: number, height: number): void
setName(name: string): void
setCharacters(text: string): Promise<void>
addChild(node: OpenNode): void
removeChild(node: OpenNode): void
clone(): OpenNode
remove(): void
getBounds(): { x: number; y: number; width: number; height: number }
getNative(): any
}
// Message types
export interface Message<T = any> {
type: string
payload?: T
}
export type MessageHandler<T = any> = (payload: T) => void
// Storage types
export interface StorageKeys {
[key: string]: string
}Example: Storage Module with Types:
// kito/src/editor/Storage.ts
import type { StorageKeys } from '../types'
class StorageModule {
private keys: Map<string, string> = new Map()
private initialized: boolean = false
/**
* Set valid storage keys for integrity checks (optional)
*/
setKeys(keyDefinitions: StorageKeys): void {
this.keys.clear()
Object.entries(keyDefinitions).forEach(([name, value]) => {
this.keys.set(name, value)
})
this.initialized = true
}
/**
* Validate storage key
*/
private validateKey(key: string): void {
if (this.initialized && !Array.from(this.keys.values()).includes(key)) {
const keyNames = Array.from(this.keys.keys())
throw new Error(`Invalid storage key: ${key}. Valid keys: ${keyNames.join(', ')}`)
}
}
/**
* Get value from storage
*/
async get<T = any>(key: string): Promise<T | null> {
this.validateKey(key)
// Implementation...
return null
}
/**
* Set value in storage
*/
async set<T = any>(key: string, value: T): Promise<boolean> {
this.validateKey(key)
// Implementation...
return true
}
// ... other methods
}
export const Storage = new StorageModule()Example: Mapper with Types:
// kito/src/nodes/FigmaMapper.ts
import type { SceneNode } from '@figma/plugin-typings'
import { OpenNodeMapper } from './OpenNodeMapper'
import { NodeType, type OpenNode } from '../types'
export class FigmaNodeMapper extends OpenNodeMapper {
mapToOpenNode(figmaNode: SceneNode): OpenNode | null {
if (!figmaNode) return null
return {
_native: figmaNode,
_platform: 'figma',
id: figmaNode.id,
name: figmaNode.name,
type: this.mapNodeType(figmaNode.type),
x: 'x' in figmaNode ? figmaNode.x : 0,
y: 'y' in figmaNode ? figmaNode.y : 0,
width: 'width' in figmaNode ? figmaNode.width : 0,
height: 'height' in figmaNode ? figmaNode.height : 0,
visible: figmaNode.visible,
locked: figmaNode.locked,
// ... rest of implementation
} as OpenNode
}
private mapNodeType(figmaType: string): NodeType {
const typeMap: Record<string, NodeType> = {
'FRAME': NodeType.FRAME,
'GROUP': NodeType.GROUP,
'RECTANGLE': NodeType.RECTANGLE,
'TEXT': NodeType.TEXT,
// ... rest
}
return typeMap[figmaType] || NodeType.RECTANGLE
}
}Benefits of TypeScript from Start:
- ✅ Type safety: Catch errors at compile time
- ✅ Better refactoring: Rename/change APIs with confidence
- ✅ IDE autocomplete: Full IntelliSense for all APIs
- ✅ Self-documenting: Types serve as inline documentation
- ✅ Official types: Leverage Figma/Penpot type packages
- ✅ Universal consumption: Compiles to JS for all users
NEW (kito Library):
kito/package.jsonkito/README.mdkito/src/index.js(or .ts if using TypeScript)kito/src/editor.jskito/src/ui.jskito/src/editor/Actions.jskito/src/editor/Storage.jskito/src/editor/Messages.jskito/src/editor/Canvas.jskito/src/editor/History.jskito/src/editor/Environment.jskito/src/ui/Messages.jskito/src/ui/Actions.jskito/src/ui/Environment.jskito/src/nodes/OpenNode.jskito/src/nodes/FigmaMapper.jskito/src/nodes/PenpotMapper.jskito/src/shared/constants.jskito/src/shared/config.js
Goal: Ensure library evolution without breaking existing functionality while keeping tests simple and fast.
Recommended: Bun Test (Built-in, Zero Config)
Bun includes a fast Jest-compatible test runner built-in:
# No installation needed if using Bun!
bun testAlternative: Vitest (Fast, Modern)
If not using Bun, Vitest is an excellent choice:
npm install --save-dev vitestkito/
├── src/
│ ├── editor/
│ │ ├── Storage.ts
│ │ └── Storage.test.ts # Co-located tests
│ ├── nodes/
│ │ ├── FigmaMapper.ts
│ │ ├── FigmaMapper.test.ts
│ │ ├── PenpotMapper.ts
│ │ └── PenpotMapper.test.ts
│ └── types/
│ └── index.ts
└── tests/
├── integration/ # Integration tests
│ ├── editor-workflow.test.ts
│ └── ui-messaging.test.ts
└── mocks/ # Mock objects
├── figma.mock.ts
└── penpot.mock.ts
For Bun:
// package.json
{
"scripts": {
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage"
}
}For Vitest:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom', // For UI context tests
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/node_modules/**', '**/dist/**', '**/*.test.ts']
}
}
})// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "^1.x.x",
"@vitest/coverage-v8": "^1.x.x"
}
}1. Unit Tests (80% of tests)
Test individual modules in isolation with mocked dependencies.
Example: Storage Module Test
// src/editor/Storage.test.ts
import { describe, it, expect, beforeEach } from 'bun:test' // or 'vitest'
import { Storage } from './Storage'
describe('Storage Module', () => {
beforeEach(() => {
// Reset storage state before each test
Storage.setKeys({})
})
it('should validate keys when setKeys is called', () => {
Storage.setKeys({ UUID: 'uuid-key', PREFS: 'preferences' })
expect(() => Storage.get('invalid-key')).toThrow('Invalid storage key')
})
it('should allow valid keys', async () => {
Storage.setKeys({ UUID: 'uuid-key' })
// Mock figma.clientStorage
global.figma = {
clientStorage: {
getAsync: async (key: string) => 'test-value',
setAsync: async (key: string, value: any) => {}
}
}
const result = await Storage.get('uuid-key')
expect(result).toBe('test-value')
})
it('should work without setKeys for permissive mode', async () => {
global.figma = {
clientStorage: {
getAsync: async (key: string) => 'value',
setAsync: async (key: string, value: any) => {}
}
}
// Should not throw
const result = await Storage.get('any-key')
expect(result).toBe('value')
})
})Example: Figma Mapper Test
// src/nodes/FigmaMapper.test.ts
import { describe, it, expect } from 'bun:test'
import { FigmaNodeMapper } from './FigmaMapper'
import { NodeType } from './OpenNodeMapper'
import { createMockFigmaNode } from '../../tests/mocks/figma.mock'
describe('FigmaNodeMapper', () => {
const mapper = new FigmaNodeMapper()
it('should map basic node properties', () => {
const mockNode = createMockFigmaNode({
id: '1:1',
name: 'Test Frame',
type: 'FRAME',
x: 100,
y: 200,
width: 300,
height: 400
})
const openNode = mapper.mapToOpenNode(mockNode)
expect(openNode?.id).toBe('1:1')
expect(openNode?.name).toBe('Test Frame')
expect(openNode?.type).toBe(NodeType.FRAME)
expect(openNode?.x).toBe(100)
expect(openNode?.y).toBe(200)
})
it('should normalize node types correctly', () => {
const rectNode = createMockFigmaNode({ type: 'RECTANGLE' })
const openNode = mapper.mapToOpenNode(rectNode)
expect(openNode?.type).toBe(NodeType.RECTANGLE)
})
it('should handle text nodes with font loading', async () => {
const textNode = createMockFigmaNode({
type: 'TEXT',
characters: 'Hello',
fontName: { family: 'Inter', style: 'Regular' }
})
global.figma = {
loadFontAsync: async (fontName: any) => {}
}
const openNode = mapper.mapToOpenNode(textNode)
expect(openNode?.characters).toBe('Hello')
await openNode?.setCharacters('World')
expect(textNode.characters).toBe('World')
})
it('should return null for null input', () => {
const result = mapper.mapToOpenNode(null)
expect(result).toBeNull()
})
})2. Mock Objects
Create reusable mocks for Figma and Penpot APIs:
// tests/mocks/figma.mock.ts
export function createMockFigmaNode(overrides: Partial<any> = {}) {
return {
id: '1:1',
name: 'MockNode',
type: 'FRAME',
x: 0,
y: 0,
width: 100,
height: 100,
visible: true,
locked: false,
parent: null,
children: [],
remove: () => {},
resize: (w: number, h: number) => {},
clone: function() { return this },
...overrides
}
}
export function createMockFigma() {
return {
currentPage: {
selection: [],
name: 'Page 1'
},
clientStorage: {
getAsync: async (key: string) => null,
setAsync: async (key: string, value: any) => {}
},
ui: {
postMessage: (msg: any) => {},
onmessage: null,
resize: (w: number, h: number) => {}
},
notify: (text: string) => {},
closePlugin: () => {},
showUI: (html: string, options: any) => {},
createFrame: () => createMockFigmaNode({ type: 'FRAME' }),
createRectangle: () => createMockFigmaNode({ type: 'RECTANGLE' }),
createText: () => createMockFigmaNode({ type: 'TEXT' }),
loadFontAsync: async (fontName: any) => {}
}
}// tests/mocks/penpot.mock.ts
export function createMockPenpotNode(overrides: Partial<any> = {}) {
return {
id: 'uuid-1',
name: 'MockNode',
type: 'frame',
x: 0,
y: 0,
width: 100,
height: 100,
visible: true,
blocked: false,
parent: null,
children: [],
remove: () => {},
resize: (w: number, h: number) => {},
clone: function() { return this },
...overrides
}
}
export function createMockPenpot() {
return {
currentPage: {
name: 'Page 1'
},
selection: [],
localStorage: {
getItem: (key: string) => null,
setItem: (key: string, value: string) => {}
},
ui: {
sendMessage: (msg: any) => {},
onMessage: (callback: any) => {},
open: (name: string, url: string, options: any) => {}
},
createBoard: () => createMockPenpotNode({ type: 'frame' }),
createRectangle: () => createMockPenpotNode({ type: 'rect' }),
createText: () => createMockPenpotNode({ type: 'text' })
}
}3. Integration Tests (20% of tests)
Test how modules work together:
// tests/integration/editor-workflow.test.ts
import { describe, it, expect, beforeEach } from 'bun:test'
import { Actions, Storage, Canvas } from '../../src/editor'
import { createMockFigma } from '../mocks/figma.mock'
describe('Editor Workflow Integration', () => {
beforeEach(() => {
global.figma = createMockFigma()
})
it('should handle complete selection and modification workflow', async () => {
// Setup storage
Storage.setKeys({ PREFS: 'preferences' })
await Storage.set('PREFS', { theme: 'dark' })
// Get selection (returns OpenNodes)
const selection = Canvas.getCurrentSelection()
// Modify nodes
selection.forEach(node => {
node.setPosition(node.x + 10, node.y + 10)
node.setName('Modified')
})
// Verify mutations happened
expect(figma.currentPage.selection[0]?.name).toBe('Modified')
})
})4. Snapshot Tests (for API surface)
Ensure public API doesn't change unexpectedly:
// src/editor.test.ts
import { describe, it, expect } from 'bun:test'
import * as Editor from './editor'
describe('Editor API Surface', () => {
it('should export expected modules', () => {
expect(Object.keys(Editor).sort()).toMatchSnapshot()
})
it('should have stable Storage API', () => {
const storageAPI = Object.getOwnPropertyNames(Editor.Storage)
.filter(name => !name.startsWith('_'))
.sort()
expect(storageAPI).toMatchSnapshot()
})
})1. Test Pyramid:
- 80% Unit tests (fast, isolated)
- 15% Integration tests (workflow validation)
- 5% Snapshot tests (API stability)
2. Co-locate Tests:
Place .test.ts files next to source files for easy discovery and maintenance.
3. Test Naming Convention:
describe('ModuleName', () => {
it('should do something when condition', () => {})
})4. AAA Pattern:
it('should ...', () => {
// Arrange
const input = createMockData()
// Act
const result = functionUnderTest(input)
// Assert
expect(result).toBe(expected)
})5. Coverage Targets:
- Aim for 80%+ coverage on core modules (Storage, Mappers, Canvas)
- Don't obsess over 100% coverage
- Focus on testing behavior, not implementation
GitHub Actions Example:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun test
- run: bun run build{
"scripts": {
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"test:ui": "bun test --ui",
"precommit": "bun test && bun run build"
}
}High Priority:
- ✅ Storage validation and operations
- ✅ Node mappers (property mapping, type normalization)
- ✅ OpenNode mutation methods
- ✅ Message passing between Editor/UI
Medium Priority:
- ✅ Environment detection
- ✅ Canvas operations
- ✅ History operations
Low Priority:
- ✅ Constants and type definitions
- ✅ Simple getters/setters
Don't Test:
- ❌ Third-party libraries (Figma/Penpot APIs)
- ❌ Build configuration
- ❌ TypeScript compiler
Strategy: Start wide (basic structure, few features) and go deeper each iteration. Every iteration produces a fully functional, testable, and deployable library - even with limited functionality.
Goal: Create library structure, build pipeline, and basic environment detection.
What works after this iteration:
- Library builds successfully
- Can detect Figma vs Penpot environment
- Can import from
kito/editorandkito/ui
Tasks:
-
Create
kito/directory structure -
Set up
package.json,tsconfig.json,tsup.config.ts -
Install dependencies (TypeScript, tsup, @figma/plugin-typings, @penpot/plugin-types)
-
Create
src/types/index.tswith basic types (DesignTool enum) -
Create
src/shared/constants.ts(FIGMA, PENPOT constants) -
Create
src/editor/Environment.ts:- `getCurrent()` - detects figma/penpot global -
Create
src/ui/Environment.ts:- `getCurrent()` - detects from window context -
Create
src/editor.ts- exports{ Environment } -
Create
src/ui.ts- exports{ Environment } -
Create
src/index.ts- throws error message -
Run build:
bun run buildornpm run build -
Link to main project: Add
"kito": "file:./kito"to package.json -
Test in Core.js:
import { Environment } from 'kito/editor'→console.log(Environment.getCurrent())
Testing:
- Write unit test for Environment.getCurrent() with mocked globals
- Verify build output exists in dist/
Acceptance: Plugin logs correct environment (figma/penpot) on startup.
Goal: Enable Editor ↔ UI communication.
What works after this iteration:
- Full messaging between Editor and UI
- Can send/receive typed messages
- Environment detection + Messaging
Tasks:
-
Create
src/editor/Messages.ts:- `send(type, payload)` - wraps figma.ui.postMessage - `on(type, callback)` - wraps figma.ui.onmessage with type filtering -
Create
src/ui/Messages.ts:- `send(type, payload)` - wraps parent.postMessage - `on(type, callback)` - wraps window.addEventListener with type filtering -
Update
src/editor.ts- export{ Environment, Messages } -
Update
src/ui.ts- export{ Environment, Messages } -
Update Core.js: Replace ONE FigPen message handler with Messages.on()
-
Update App.js: Replace ONE FigPen message send with Messages.send()
-
Test bidirectional messaging works
Testing:
- Unit test Messages.send() and Messages.on()
- Integration test: Editor sends message → UI receives it
- Mock figma.ui.postMessage and window.postMessage
Acceptance: Can send "ping" from Editor, UI receives it and sends "pong" back.
Goal: Plugin lifecycle management (open, close, resize).
What works after this iteration:
- Can open/close plugin UI
- Can resize UI
- Can signal UI ready
- Environment + Messaging + Actions
Tasks:
-
Create
src/editor/Actions.ts:- `showUI(options)` - wraps figma.showUI - `closePlugin()` - wraps figma.closePlugin -
Create
src/ui/Actions.ts:- `ready()` - signals UI is ready to Editor - `resizeUI(width, height)` - wraps figma.ui.resize -
Update
src/editor.ts- export{ Environment, Messages, Actions } -
Update
src/ui.ts- export{ Environment, Messages, Actions } -
Update Core.js: Replace FigPen.openUI() with Actions.showUI()
-
Update Core.js: Replace FigPen.closePlugin() with Actions.closePlugin()
-
Update App.js: Replace FigPen.initializeUI() with Actions.ready()
-
Test plugin opens and closes correctly
Testing:
- Unit test Actions methods
- Integration test: showUI → ready signal → closePlugin flow
Acceptance: Plugin opens UI, signals ready, and closes without errors.
Goal: Validated storage with get/set operations.
What works after this iteration:
- Can read/write plugin preferences
- Storage key validation
- All previous features + Storage
Tasks:
-
Create
src/editor/Storage.ts:- `setKeys(keys)` - optional validation setup - `get(key)` - wraps figma.clientStorage.getAsync - `set(key, value)` - wraps figma.clientStorage.setAsync - `getMultiple(keys)` - batch get - `setMultiple(pairs)` - batch set - `remove(key)` - delete key - `getKey(name)` - get key by name from map -
Update
src/editor.ts- export{ Environment, Messages, Actions, Storage } -
Update Core.js: Replace ALL Storage. calls with kito Storage.
-
Update Core.js: Replace
Storage.init()withStorage.setKeys() -
Test reading UUID and preferences
Testing:
- Unit test Storage validation (valid/invalid keys)
- Unit test get/set operations with mocked clientStorage
- Integration test: set UUID → get UUID → verify match
Acceptance: Plugin reads existing UUID from storage, can save new preferences.
Goal: Basic canvas operations with native nodes (no OpenNode yet).
What works after this iteration:
- Can get current selection (returns native nodes)
- Can get current page
- Can show notifications
- Can listen to selection changes
- All previous features + Canvas basics
Tasks:
-
Create
src/editor/Canvas.ts:- `getCurrentSelection()` - returns native figma nodes - `setSelection(nodes)` - sets figma selection - `getCurrentPage()` - returns native page - `notify(text)` - wraps figma.notify - `onSelectionChange(callback)` - wraps figma.on('selectionchange') -
Update
src/editor.ts- export{ ..., Canvas } -
Update Core.js: Replace FigPen.currentSelection() with Canvas.getCurrentSelection()
-
Update Core.js: Replace FigPen.showNotification() with Canvas.notify()
-
Update Core.js: Replace FigPen.onSelectionChange() with Canvas.onSelectionChange()
-
Test selection and notifications work
Testing:
- Unit test Canvas methods with mocked figma globals
- Integration test: get selection → modify → set selection
Acceptance: Plugin can read selection, show notifications, works with existing LayoutUtils (native nodes).
Goal: Undo/redo block management.
What works after this iteration:
- Can create undo blocks
- All operations are undoable
- All previous features + History
Tasks:
-
Create
src/editor/History.ts:- `startUndoBlock()` - wraps figma undo (or penpot.history.undoBlockBegin) - `finishUndoBlock(id)` - wraps figma undo (or penpot.history.undoBlockFinish) -
Update
src/editor.ts- export{ ..., History } -
Update Core.js: Replace FigPen.startUndoBlock() with History.startUndoBlock()
-
Update Core.js: Replace FigPen.finishUndoBlock() with History.finishUndoBlock()
-
Test undo works after operations
Testing:
- Unit test History methods
- Integration test: perform operation in undo block → undo → verify reverted
Acceptance: Can undo tidy operations.
Goal: Node abstraction with basic read-only properties and position mutations.
What works after this iteration:
- Canvas returns OpenNodes instead of native nodes
- Can read: id, name, type, x, y, width, height
- Can mutate: setPosition(), setName(), resize()
- All previous features + OpenNode basics
Tasks:
-
Create
src/nodes/OpenNodeMapper.ts:- Base class with abstract methods - NodeType enum -
Create
src/nodes/FigmaMapper.ts:- `mapToOpenNode()` - Phase 1 properties only - `mapFromOpenNode()` - Methods: setPosition(), setName(), resize(), getNative() -
Create
src/nodes/PenpotMapper.ts:- Same as Figma but for Penpot -
Update
src/editor/Canvas.ts:- Wrap selection with mapper before returning - Unwrap nodes when setting selection -
Update Core.js LayoutUtils:
- Use `.getNative()` to get native nodes for legacy functions - Or update LayoutUtils to work with OpenNodes directly -
Test position/size operations work
Testing:
- Unit test FigmaMapper properties
- Unit test PenpotMapper properties
- Unit test type normalization (FRAME vs frame → NodeType.FRAME)
- Integration test: get OpenNode → setPosition → verify native node updated
Acceptance: Can get selection as OpenNodes, modify positions, tidy still works.
Goal: Text node operations with font loading.
What works after this iteration:
- Can read text: node.characters
- Can modify text: node.setCharacters()
- Font loading handled automatically for Figma
- All previous features + Text operations
Tasks:
-
Update
src/nodes/FigmaMapper.ts:- Add `characters` getter - Add `setCharacters(text)` method with font loading -
Update
src/nodes/PenpotMapper.ts:- Add `characters` getter - Add `setCharacters(text)` method (no font loading) -
Update Core.js cmdPager:
- Use `node.setCharacters()` instead of native API -
Test pager functionality
Testing:
- Unit test text operations with mocked font loading
- Integration test: create TEXT node → setCharacters → verify updated
Acceptance: Pager feature works with OpenNodes, font loading doesn't break.
Goal: Parent/child relationships and tree operations.
What works after this iteration:
- Can access: node.parent, node.children
- Can mutate: addChild(), removeChild(), clone()
- Can create: Canvas.createFrame() returns OpenNode
- All previous features + Hierarchy
Tasks:
-
Update
src/nodes/FigmaMapper.ts:- Add `parent` getter (lazy mapping) - Add `children` getter (lazy mapping) - Add `addChild()`, `removeChild()`, `clone()`, `remove()` -
Update
src/nodes/PenpotMapper.ts:- Same as Figma -
Update
src/editor/Canvas.ts:- Add `createFrame()` - returns wrapped OpenNode -
Test hierarchy operations
Testing:
- Unit test parent/children getters
- Unit test addChild/removeChild
- Integration test: create frame → add child → verify hierarchy
Acceptance: Can navigate node tree, create new frames.
Goal: Add test suite to protect against regressions.
What works after this iteration:
- Full test coverage for core modules
- CI/CD integration
- Snapshot tests for API stability
- All previous features + Testing
Tasks:
-
Set up test configuration (Bun test or Vitest)
-
Create mock objects:
- `tests/mocks/figma.mock.ts` - `tests/mocks/penpot.mock.ts` -
Write unit tests for each module:
- Storage.test.ts - Messages.test.ts - FigmaMapper.test.ts - PenpotMapper.test.ts -
Write integration tests:
- Editor workflow test - UI messaging test -
Write snapshot tests for API surface
-
Set up GitHub Actions CI
-
Run tests:
bun test
Testing:
- Achieve 80%+ coverage on core modules
- All tests pass in CI
Acceptance: bun test passes, coverage report shows >80%.
Goal: Migrate all UI components from FigPen to kito.
What works after this iteration:
- All views use kito
- MessageBus uses kito
- No FigPen dependencies in UI
- Entire plugin works end-to-end
Tasks:
-
Update
src/ui/views/form/FormView.js:- Replace FigPen with Messages and Actions - Remove beforeMount initialization -
Update
src/ui/views/preferences/PreferencesView.js:- Replace FigPen with Messages -
Update
src/ui/views/license/LicenseView.js(if exists):- Replace FigPen with Messages -
Update
src/ui/components/display/DisplayComponent.js:- Replace FigPen with Messages -
Update
src/payments/license.js:- Replace FigPen with Messages -
Update
src/utils/DisplayNetwork.js:- Replace FigPen with Messages (UI context) -
Update
src/utils/MessageBus.js:- Replace FigPen with Messages (Editor context) -
Test all views and features work
Testing:
- Manual test every view (Form, Preferences, License)
- Test all Super Tidy commands (tidy, rename, reorder, pager)
- Test countdown feature
- Test license activation
Acceptance: Full plugin works identically to before, no FigPen imports remain in UI.
Goal: Remove old code, document library, prepare for npm publishing.
What works after this iteration:
- Clean codebase with zero dead code
- Comprehensive documentation
- Ready for npm publishing
- Production-ready library
Tasks:
-
Delete old files:
- `src/utils/FigPen.js` - `src/utils/Storage.js` -
Search codebase for remaining imports:
- `grep -r "from.*FigPen" src/` - `grep -r "from.*Storage" src/` - Update any stragglers -
Create
kito/README.md:- Installation instructions - Quick start guide - API reference (Editor context) - API reference (UI context) - OpenNode API reference - Code examples - Migration guide -
Add JSDoc comments to all public APIs
-
Add LICENSE file (MIT)
-
Add CHANGELOG.md
-
Run full test suite:
bun test -
Run production build:
bun run build -
Verify bundle sizes
-
Test full plugin end-to-end
Testing:
- Full end-to-end test of all features
- Performance test (ensure no slowdowns)
- Bundle size check (should be smaller than before)
Acceptance: Plugin works flawlessly, no console errors, documentation complete.
- Add
visible,locked,opacity,rotation - Add
getBounds(),setVisible(),setLocked()
- Add
fills,strokes,effects - Add style mutation methods
- Add auto-layout properties
- Add padding, spacing, constraints
- Add component/instance-specific properties
- Add variant support
- Create FramerMapper.ts
- Test with Framer plugin API
- Publish to npm registry
- Update documentation with npm install instructions
- Version management strategy
Before moving to the next iteration, ensure:
- ✅ Build succeeds (
bun run build) - ✅ Tests pass (
bun test) - ✅ No console errors in dev tools
- ✅ Plugin loads and works in Figma/Penpot
- ✅ Commit changes with clear message
- ✅ Tag iteration in git (e.g.,
iteration-1,iteration-2)
If an iteration breaks something:
- Revert to previous git tag
- Identify the issue
- Fix in isolation
- Re-run iteration
Once stable, publish to npm:
cd kito
npm publishThen projects can use:
npm install kitoAnd import without local file reference:
import { Canvas, Storage } from 'kito/editor'
import { Messages, Actions } from 'kito/ui'