Skip to content

Latest commit

 

History

History
2848 lines (2202 loc) · 71.8 KB

File metadata and controls

2848 lines (2202 loc) · 71.8 KB

kito Library - Standalone npm Package

Overview

Transform the src/utils directory into a standalone kito library structured for npm distribution. The library provides:

  • Explicit context separation: kito/editor vs kito/ui imports
  • 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

Architecture Principles

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

API Design

Editor Context API

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 plugin

UI Context API

import { 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'

Implementation Steps

1. Create kito Library Structure

Create new directory: kito/ (alongside src/, not inside it)

Files to create:

  • kito/package.json - npm package config
  • kito/README.md - Library documentation
  • kito/src/index.js - Main entry (error message)
  • kito/src/editor.js - Editor context singleton
  • kito/src/ui.js - UI context singleton
  • kito/src/shared/constants.js - Design tool constants
  • kito/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"
  }
}

2. Implement Editor Context (kito/src/editor.js)

Merge from:

  • FigPen design tool methods (Figma/Penpot abstraction)
  • Storage functionality (validated storage operations)

Key features:

  • Singleton pattern with auto-initialization
  • Auto-detect figma vs penpot global
  • 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()

3. Implement UI Context (kito/src/ui.js)

Features:

  • Singleton pattern with auto-initialization
  • Auto-detect figma vs penpot from parent window messages
  • UI → Editor messaging only
  • No canvas/storage access

Renamed methods:

  • notifyEditor()sendToEditor()
  • onEditorMessage()onEditorMessage() (keep)
  • initializeUI()ready()

4. Implement Storage Module (kito/src/storage/Storage.js)

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)

5. Implement Node Mappers (Internal)

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 class
  • kito/src/nodes/FigmaMapper.js - Figma → OpenNode mapper
  • kito/src/nodes/PenpotMapper.js - Penpot → OpenNode mapper
  • kito/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, type
  • x, y, width, height
  • parent, children
  • characters, setCharacters() (for TEXT nodes)
  • remove()

Phase 2+ Properties (Future):

  • visible, locked, opacity
  • fills, strokes, effects
  • rotation, constraints
  • componentProperties
  • Layout properties (auto-layout, padding, etc.)

6. Create Main Export (kito/src/index.js)

// 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"'
)

6. Configure Bundlers (No Alias Needed)

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 imports

Parcel:

// No configuration needed
// Parcel handles package exports automatically
// Just add to package.json dependencies

For 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.

7. Implement Node Mappers (Internal)

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 class
  • kito/src/nodes/FigmaMapper.js - Figma → OpenNode mapper
  • kito/src/nodes/PenpotMapper.js - Penpot → OpenNode mapper
  • kito/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 = 200

Phase 1 Properties (Minimum Viable):

Basic properties to implement in first version:

  • id, name, type
  • x, y, width, height
  • parent, children
  • characters, setCharacters() (for TEXT nodes)
  • remove()

Phase 2+ Properties (Future):

  • visible, locked, opacity
  • fills, strokes, effects
  • rotation, constraints
  • componentProperties
  • Layout properties (auto-layout, padding, etc.)

11. Create Library Documentation

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

Benefits

  1. Clear separation: Explicit Editor vs UI imports prevent misuse
  2. npm-ready: Library structure ready for publishing
  3. Better DX: sendToUI/sendToEditor vs notifyUI/notifyEditor
  4. No boilerplate: Zero instantiation, just import and use
  5. Type-safe ready: JSDoc annotations, easy TypeScript migration
  6. Clearer naming: "Editor" and "UI" instead of "Core" and "Plugin"
  7. Standalone: Can be extracted to separate repo for reuse

TypeScript Implementation (From the Start)

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

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-types

tsup.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-dts

rollup.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.ts automatically with bun 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

Files Changed

NEW (kito Library):

  • kito/package.json
  • kito/README.md
  • kito/src/index.js (or .ts if using TypeScript)
  • kito/src/editor.js
  • kito/src/ui.js
  • kito/src/editor/Actions.js
  • kito/src/editor/Storage.js
  • kito/src/editor/Messages.js
  • kito/src/editor/Canvas.js
  • kito/src/editor/History.js
  • kito/src/editor/Environment.js
  • kito/src/ui/Messages.js
  • kito/src/ui/Actions.js
  • kito/src/ui/Environment.js
  • kito/src/nodes/OpenNode.js
  • kito/src/nodes/FigmaMapper.js
  • kito/src/nodes/PenpotMapper.js
  • kito/src/shared/constants.js
  • kito/src/shared/config.js

Testing Strategy (Lightweight & Effective)

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 test

Alternative: Vitest (Fast, Modern)

If not using Bun, Vitest is an excellent choice:

npm install --save-dev vitest

Test Structure

kito/
├── 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

Test Configuration

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"
  }
}

Testing Approach

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()
  })
})

Testing Best Practices

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

CI Integration

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

Test Scripts in package.json

{
  "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"
  }
}

What to Test (Priority)

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

Implementation Iterations

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.


Iteration 1: Foundation & Environment DetectionDeployable

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/editor and kito/ui

Tasks:

  1. Create kito/ directory structure

  2. Set up package.json, tsconfig.json, tsup.config.ts

  3. Install dependencies (TypeScript, tsup, @figma/plugin-typings, @penpot/plugin-types)

  4. Create src/types/index.ts with basic types (DesignTool enum)

  5. Create src/shared/constants.ts (FIGMA, PENPOT constants)

  6. Create src/editor/Environment.ts:

         - `getCurrent()` - detects figma/penpot global
    
  7. Create src/ui/Environment.ts:

         - `getCurrent()` - detects from window context
    
  8. Create src/editor.ts - exports { Environment }

  9. Create src/ui.ts - exports { Environment }

  10. Create src/index.ts - throws error message

  11. Run build: bun run build or npm run build

  12. Link to main project: Add "kito": "file:./kito" to package.json

  13. 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.


Iteration 2: Basic MessagingDeployable

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:

  1. Create src/editor/Messages.ts:

         - `send(type, payload)` - wraps figma.ui.postMessage
         - `on(type, callback)` - wraps figma.ui.onmessage with type filtering
    
  2. Create src/ui/Messages.ts:

         - `send(type, payload)` - wraps parent.postMessage
         - `on(type, callback)` - wraps window.addEventListener with type filtering
    
  3. Update src/editor.ts - export { Environment, Messages }

  4. Update src/ui.ts - export { Environment, Messages }

  5. Update Core.js: Replace ONE FigPen message handler with Messages.on()

  6. Update App.js: Replace ONE FigPen message send with Messages.send()

  7. 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.


Iteration 3: Actions & LifecycleDeployable

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:

  1. Create src/editor/Actions.ts:

         - `showUI(options)` - wraps figma.showUI
         - `closePlugin()` - wraps figma.closePlugin
    
  2. Create src/ui/Actions.ts:

         - `ready()` - signals UI is ready to Editor
         - `resizeUI(width, height)` - wraps figma.ui.resize
    
  3. Update src/editor.ts - export { Environment, Messages, Actions }

  4. Update src/ui.ts - export { Environment, Messages, Actions }

  5. Update Core.js: Replace FigPen.openUI() with Actions.showUI()

  6. Update Core.js: Replace FigPen.closePlugin() with Actions.closePlugin()

  7. Update App.js: Replace FigPen.initializeUI() with Actions.ready()

  8. 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.


Iteration 4: Storage OperationsDeployable

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:

  1. 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
    
  2. Update src/editor.ts - export { Environment, Messages, Actions, Storage }

  3. Update Core.js: Replace ALL Storage. calls with kito Storage.

  4. Update Core.js: Replace Storage.init() with Storage.setKeys()

  5. 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.


Iteration 5: Canvas - Basic (Native Nodes)Deployable

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:

  1. 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')
    
  2. Update src/editor.ts - export { ..., Canvas }

  3. Update Core.js: Replace FigPen.currentSelection() with Canvas.getCurrentSelection()

  4. Update Core.js: Replace FigPen.showNotification() with Canvas.notify()

  5. Update Core.js: Replace FigPen.onSelectionChange() with Canvas.onSelectionChange()

  6. 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).


Iteration 6: History OperationsDeployable

Goal: Undo/redo block management.

What works after this iteration:

  • Can create undo blocks
  • All operations are undoable
  • All previous features + History

Tasks:

  1. Create src/editor/History.ts:

         - `startUndoBlock()` - wraps figma undo (or penpot.history.undoBlockBegin)
         - `finishUndoBlock(id)` - wraps figma undo (or penpot.history.undoBlockFinish)
    
  2. Update src/editor.ts - export { ..., History }

  3. Update Core.js: Replace FigPen.startUndoBlock() with History.startUndoBlock()

  4. Update Core.js: Replace FigPen.finishUndoBlock() with History.finishUndoBlock()

  5. 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.


Iteration 7: OpenNode Mappers - Phase 1 (Basic Properties)Deployable

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:

  1. Create src/nodes/OpenNodeMapper.ts:

         - Base class with abstract methods
         - NodeType enum
    
  2. Create src/nodes/FigmaMapper.ts:

         - `mapToOpenNode()` - Phase 1 properties only
         - `mapFromOpenNode()`
         - Methods: setPosition(), setName(), resize(), getNative()
    
  3. Create src/nodes/PenpotMapper.ts:

         - Same as Figma but for Penpot
    
  4. Update src/editor/Canvas.ts:

         - Wrap selection with mapper before returning
         - Unwrap nodes when setting selection
    
  5. Update Core.js LayoutUtils:

         - Use `.getNative()` to get native nodes for legacy functions
         - Or update LayoutUtils to work with OpenNodes directly
    
  6. 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.


Iteration 8: OpenNode - Text SupportDeployable

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:

  1. Update src/nodes/FigmaMapper.ts:

         - Add `characters` getter
         - Add `setCharacters(text)` method with font loading
    
  2. Update src/nodes/PenpotMapper.ts:

         - Add `characters` getter
         - Add `setCharacters(text)` method (no font loading)
    
  3. Update Core.js cmdPager:

         - Use `node.setCharacters()` instead of native API
    
  4. 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.


Iteration 9: OpenNode - HierarchyDeployable

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:

  1. Update src/nodes/FigmaMapper.ts:

         - Add `parent` getter (lazy mapping)
         - Add `children` getter (lazy mapping)
         - Add `addChild()`, `removeChild()`, `clone()`, `remove()`
    
  2. Update src/nodes/PenpotMapper.ts:

         - Same as Figma
    
  3. Update src/editor/Canvas.ts:

         - Add `createFrame()` - returns wrapped OpenNode
    
  4. 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.


Iteration 10: Comprehensive TestingDeployable

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:

  1. Set up test configuration (Bun test or Vitest)

  2. Create mock objects:

         - `tests/mocks/figma.mock.ts`
         - `tests/mocks/penpot.mock.ts`
    
  3. Write unit tests for each module:

         - Storage.test.ts
         - Messages.test.ts
         - FigmaMapper.test.ts
         - PenpotMapper.test.ts
    
  4. Write integration tests:

         - Editor workflow test
         - UI messaging test
    
  5. Write snapshot tests for API surface

  6. Set up GitHub Actions CI

  7. Run tests: bun test

Testing:

  • Achieve 80%+ coverage on core modules
  • All tests pass in CI

Acceptance: bun test passes, coverage report shows >80%.


Iteration 11: Update All UI ComponentsDeployable

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:

  1. Update src/ui/views/form/FormView.js:

         - Replace FigPen with Messages and Actions
         - Remove beforeMount initialization
    
  2. Update src/ui/views/preferences/PreferencesView.js:

         - Replace FigPen with Messages
    
  3. Update src/ui/views/license/LicenseView.js (if exists):

         - Replace FigPen with Messages
    
  4. Update src/ui/components/display/DisplayComponent.js:

         - Replace FigPen with Messages
    
  5. Update src/payments/license.js:

         - Replace FigPen with Messages
    
  6. Update src/utils/DisplayNetwork.js:

         - Replace FigPen with Messages (UI context)
    
  7. Update src/utils/MessageBus.js:

         - Replace FigPen with Messages (Editor context)
    
  8. 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.


Iteration 12: Cleanup & DocumentationProduction Ready

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:

  1. Delete old files:

         - `src/utils/FigPen.js`
         - `src/utils/Storage.js`
    
  2. Search codebase for remaining imports:

         - `grep -r "from.*FigPen" src/`
         - `grep -r "from.*Storage" src/`
         - Update any stragglers
    
  3. Create kito/README.md:

         - Installation instructions
         - Quick start guide
         - API reference (Editor context)
         - API reference (UI context)
         - OpenNode API reference
         - Code examples
         - Migration guide
    
  4. Add JSDoc comments to all public APIs

  5. Add LICENSE file (MIT)

  6. Add CHANGELOG.md

  7. Run full test suite: bun test

  8. Run production build: bun run build

  9. Verify bundle sizes

  10. 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.


Future Iterations (Optional Enhancements)

Iteration 13: Extended OpenNode Properties

  • Add visible, locked, opacity, rotation
  • Add getBounds(), setVisible(), setLocked()

Iteration 14: Style Properties

  • Add fills, strokes, effects
  • Add style mutation methods

Iteration 15: Layout Properties

  • Add auto-layout properties
  • Add padding, spacing, constraints

Iteration 16: Component Properties

  • Add component/instance-specific properties
  • Add variant support

Iteration 17: Framer Support

  • Create FramerMapper.ts
  • Test with Framer plugin API

Iteration 18: npm Publishing

  • Publish to npm registry
  • Update documentation with npm install instructions
  • Version management strategy

Deployment Checklist (After Each Iteration)

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)

Rollback Strategy

If an iteration breaks something:

  1. Revert to previous git tag
  2. Identify the issue
  3. Fix in isolation
  4. Re-run iteration

Future npm Publishing

Once stable, publish to npm:

cd kito
npm publish

Then projects can use:

npm install kito

And import without local file reference:

import { Canvas, Storage } from 'kito/editor'
import { Messages, Actions } from 'kito/ui'