Skill: sullivangu/zeplin-to-prompt (version 1.0.1)

--- name: zeplin-to-prompt description: Export one or more Zeplin screen URLs into a structured layer tree with local assets and package the result as a zip file. Use when a user shares an app.zeplin.io screen link and wants a prompt-ready export for AI-driven UI implementation. --- # Zeplin Export Skill Export Zeplin designs into a structured layer tree plus local assets, then package the result as a zip file. Multiple screen links are supported. ## Execution Flow ### Step 1: Extract URLs and build the screen task list Run the following Bash command to extract all Zeplin URLs from the user message: ```bash printf '%s\n' "$INPUT" | grep -Eo 'https://app\.zeplin\.io/project/[^[:space:]]+' || true ``` If the output is empty, reply with: ```text Please provide one or more Zeplin Screen links, for example: /zeplin-to-prompt https://app.zeplin.io/project/xxx/screen/aaa https://app.zeplin.io/project/xxx/screen/bbb ``` Then stop. For each extracted URL, use the regex `/\/project\/([^\/]+)\/screen\/([^\/?#]+)/` to extract `projectId` and `screenId`: - If it matches a screen URL, add it to the task list: `[{url, projectId, screenId}, ...]` - If it does not include `/screen/` and is only a project URL, ask the user to provide a screen URL instead and skip that URL ### Step 2: Look up a token for each project Tokens are stored in `~/.zeplin-skill-config.json` as a `projectId -> token` mapping: ```json { "projects": { "<project-id-1>": "eyJhbG...", "<project-id-2>": "eyJhbG..." } } ``` For each distinct `projectId` in the task list, run: ```bash node -e " const fs = require('fs'); const p = process.env.HOME + '/.zeplin-skill-config.json'; try { const cfg = JSON.parse(fs.readFileSync(p, 'utf8')); const t = (cfg.projects || {})[process.env.PROJECT_ID]; if (t) { process.stdout.write(t); process.exit(0); } } catch {} process.exit(1); " PROJECT_ID="<projectId>" 2>/dev/null ``` - Exit code `0`: use that token for all screens under the project - Exit code `1`: reply with ```text No access token was found for project {projectId_masked}. Please provide a Zeplin Personal Access Token for this project: 1. Open Zeplin -> avatar menu -> Profile Settings 2. Open Personal access tokens -> Create new token 3. Copy the token and send it back ``` After receiving the token, save it with: ```bash node -e " const fs = require('fs'); const p = process.env.HOME + '/.zeplin-skill-config.json'; let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {} cfg.projects = cfg.projects || {}; cfg.projects[process.env.PROJECT_ID] = process.env.TOKEN; fs.writeFileSync(p, JSON.stringify(cfg, null, 2), {mode: 0o600}); " PROJECT_ID="<projectId>" [REDACTED_SECRET]" ``` Then continue exporting. ### Step 3: Export each screen For each screen task, run: ```bash ZEPLIN_TOKEN="<token for this projectId>" \ node "${CLAUDE_SKILL_DIR}/export_screen.mjs" \ "<url>" \ --no-open \ --quiet ``` - Capture the `workdir` from stdout, in the form `-> workdir: /path/to/xxx` - On success, add that workdir to the result list - On failure, record the reason and continue with the next screen ### Step 4: Create a zip package For a single screen: ```bash EXPORT_DIR="<workdir>" EXPORT_DIR="${EXPORT_DIR%/}" ZIP_PATH="${EXPORT_DIR}.zip" cd "$(dirname "$EXPORT_DIR")" && zip -r "$ZIP_PATH" "$(basename "$EXPORT_DIR")" -x "*.DS_Store" -q ``` For multiple screens: ```bash TIMESTAMP=$(date +%Y%m%d_%H%M%S) ZIP_PATH="${CLAUDE_SKILL_DIR}/build/export_${TIMESTAMP}.zip" cd "${CLAUDE_SKILL_DIR}/build" zip -r "$ZIP_PATH" <dir1> <dir2> ... -x "*.DS_Store" -q ``` ### Step 5: Reply to the user ```text Export completed (N screens) File: <ZIP_PATH> Export summary: OK ScreenName1 OK ScreenName2 FAILED ScreenName3: <reason> How to use: 1. Download and unzip the package 2. Open the generated layers_tree.html in a browser 3. Double-click or right-click layers to copy node JSON for AI usage ``` ## Notes - Match each screen to a token that has access to its project - Never print tokens in the conversation - When exporting multiple screens, keep the user updated with progress such as `Exporting screen X/N...`

path: export_project.mjs
content: import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { createLogger } from "./lib/logger.mjs";
import { ensureDir, writeText, sanitizeFileName } from "./lib/fsHelpers.mjs";
import { renderIndexHtml } from "./lib/renderIndex.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Argument parsing

const argv = process.argv.slice(2);
if (!argv.length) {
  console.error("Usage: node export_project.mjs <PROJECT_ID> --sections <S1,S2,...> --platform <ajk|bk> [--workdir <DIR>]");
  process.exit(1);
}

const projectId = argv.shift();
let sectionIds = [];
let sectionNames = [];
let screenNames = [];   // If provided, only export screens whose names are in this list.
let workdir = null;
let QUIET = false;

const parseNameList = (raw) =>
  raw.split(",").map(s => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);

for (let i = 0; i < argv.length; i++) {
  const arg = argv[i];
  if (arg === "--sections"      && argv[i + 1]) { sectionIds   = argv[++i].split(",").map(s => s.trim()).filter(Boolean); continue; }
  if (arg === "--section-names" && argv[i + 1]) { sectionNames = parseNameList(argv[++i]); continue; }
  if (arg === "--screen-names"  && argv[i + 1]) { screenNames  = parseNameList(argv[++i]); continue; }
  if (arg === "--workdir" && argv[i + 1]) { workdir = argv[++i]; continue; }
  if (arg === "--quiet") { QUIET = true; continue; }
}

// Token loading

const accessToken = process.env.zeplin_token || process.env.ZEPLIN_TOKEN;
if (!accessToken) {
  console.error("Missing [REDACTED_SECRET] set the ZEPLIN_TOKEN environment variable");
  process.exit(1);
}

const log = createLogger(QUIET);
const ZEPLIN_API = "https://api.zeplin.dev/v1";
const apiHeaders = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" };

// Zeplin API helpers

const zeplinGet = async (endpoint) => {
  const res = await fetch(`${ZEPLIN_API}${endpoint}`, { headers: apiHeaders });
  if (!res.ok) throw new Error(`Zeplin API ${res.status}: ${endpoint}`);
  return res.json();
};

const getProjectName = async () => {
  try {
    const data = await zeplinGet(`/projects/${projectId}`);
    return data.name || null;
  } catch { return null; }
};

const getSection = async (sectionId) => {
  return zeplinGet(`/projects/${projectId}/screen_sections/${sectionId}`);
};

const getSectionScreens = async (sectionId) => {
  const data = await zeplinGet(`/projects/${projectId}/screens?section_id=${sectionId}&limit=100`);
  return Array.isArray(data) ? data : (data.screens || []);
};

// Work directory

const buildWorkdir = (projectName) => {
  if (workdir) { ensureDir(workdir); return path.resolve(workdir); }
  const buildRoot = path.resolve(__dirname, "build");
  ensureDir(buildRoot);
  const dateStr = new Date().toISOString().slice(0, 16).replace("T", "_").replace(":", "-");
  const baseName = sanitizeFileName(projectName || projectId);
  const dir = path.join(buildRoot, `${baseName}_${dateStr}`);
  ensureDir(dir);
  return dir;
};

// Export a single screen by calling export_screen.mjs

const exportScreen = (screenId, screenWorkdir, screenName) => {
  const args = [
    "export_screen.mjs",
    projectId, screenId,
    "--workdir", screenWorkdir,
    "--no-open",
    "--quiet",
    "--back-url", "../../index.html",
  ];
  log(`  -> Exporting screen: ${screenName} (${screenId})`);
  execFileSync(process.execPath, args, {
    cwd: __dirname,
    stdio: QUIET ? "pipe" : "inherit"
  });
};

// Main flow

(async () => {
  // Resolve the section list.
  if (!sectionIds.length) {
    log("Fetching section list...");
    const data = await zeplinGet(`/projects/${projectId}/screen_sections`);
    const all = Array.isArray(data) ? data : (data.screen_sections || []);
    if (sectionNames.length) {
      // Filter by name.
      const nameSet = new Set(sectionNames);
      const matched = all.filter(s => nameSet.has(s.name));
      if (!matched.length) {
        console.error(`No matching section found: ${sectionNames.join(", ")}`);
        console.error(`Available: ${all.map(s => s.name).join(", ")}`);
        process.exit(1);
      }
      sectionIds = matched.map(s => s.id);
      log(`-> Matched ${sectionIds.length} section(s) by name`);
    } else {
      sectionIds = all.map(s => s.id);
      log(`-> Exporting all ${sectionIds.length} section(s)`);
    }
  }
  const projectName = await getProjectName();
  log(`START exporting project=${projectName || projectId}, ${sectionIds.length} section(s) total`);
  const wd = buildWorkdir(projectName);
  log(`-> Output directory: ${wd}`);

  const exportedAt = new Date().toISOString();
  const indexSections = [];

  for (const sectionId of sectionIds) {
    let sectionName;
    try {
      const section = await getSection(sectionId);
      sectionName = section.name || sectionId;
    } catch {
      sectionName = sectionId;
    }

    log(`\nSection: ${sectionName}`);
    const safeSectionName = sanitizeFileName(sectionName);
    const sectionDir = path.join(wd, safeSectionName);
    ensureDir(sectionDir);

    let screens;
    try {
      screens = await getSectionScreens(sectionId);
    } catch (err) {
      log(`  Warning: failed to fetch screen list: ${err.message}`);
      continue;
    }

    // Filter by screen name if requested.
    if (screenNames.length) {
      const nameSet = new Set(screenNames);
      screens = screens.filter(s => nameSet.has(s.name));
    }
    log(`  ${screens.length} screen(s)${screenNames.length ? " (filtered)" : ""}`);
    const indexScreens = [];

    for (const screen of screens) {
      const safeScreenName = sanitizeFileName(screen.name || screen.id);
      const screenDir = path.join(sectionDir, safeScreenName);
      ensureDir(screenDir);
      try {
        exportScreen(screen.id, screenDir, screen.name || screen.id);
        indexScreens.push({
          name: screen.name || screen.id,
          htmlPath: `${safeSectionName}/${safeScreenName}/layers_tree.html`,
          thumbnailUrl: screen.image?.thumbnails?.small || null
        });
      } catch (err) {
        log(`  Failed to export screen ${screen.name || screen.id}: ${err.message}`);
      }
    }

    indexSections.push({ name: sectionName, screens: indexScreens });
  }

  // Generate index.html.
  const indexHtml = renderIndexHtml({ projectId, exportedAt, sections: indexSections });
  const indexPath = path.join(wd, "index.html");
  writeText(indexPath, indexHtml);
  log(`\nIndex page: ${indexPath}`);

  // Open index.html.
  try {
    const { execSync } = await import("node:child_process");
    execSync(`open "${indexPath.replace(/"/g, '\\"')}"`);
  } catch {}

  log("DONE");
})().catch(err => {
  console.error("Export failed:", err?.message || err);
  process.exit(1);
});


path: README.md
content: # zeplintopromptskill

Export a Zeplin screen into a local, prompt-ready package with previewable HTML and copyable JSON for AI-driven UI implementation.

## Install

Install dependencies before the first run:

```bash
npm install
```

If dependencies are missing, the script may fail with `ERR_MODULE_NOT_FOUND`.

## Export a Single Screen

```bash
ZEPLIN_TOKEN='your-token' node export_screen.mjs 'https://app.zeplin.io/project/<projectId>/screen/<screenId>' --no-open --quiet
```

Use a fixed output directory for repeated testing:

```bash
ZEPLIN_TOKEN='your-token' node export_screen.mjs 'https://app.zeplin.io/project/<projectId>/screen/<screenId>' --workdir ./build/test-attributedstring --no-open --quiet
```

Optional flags:

- `--workdir <DIR>`: set the export directory
- `--no-open`: do not auto-open Finder or the generated HTML
- `--quiet`: reduce logs
- `--verbose`: print detailed logs
- `--platform <key>`: load color and font mapping context; text style parsing still works without it

## Output

The export directory usually contains:

- `raw.json`: the original Zeplin response
- `layers_tree.json`: the minified structured layer tree
- `layers_tree.html`: visual preview and copy entry point
- `assets/`: downloaded local assets

When a Zeplin text layer includes multiple `textStyles` entries with `range` metadata, copied JSON includes multi-run text information. A run may contain some of these fields:

- `text`
- `color`
- `colorHex`
- `colorName`
- `fontSize`
- `fontWeight`
- `fontFamily`
- `fontName`

## Verify

1. Run the export command
2. Open the generated `layers_tree.html`
3. Right-click the target text layer and copy its JSON
4. Check whether the result contains multi-run text information

If it does not, check these first:

- You are opening the latest exported `layers_tree.html`
- The corresponding text layer in `raw.json` really contains multiple `textStyles[].range` entries


path: export_screen.mjs
content: import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
import { createLogger } from "./lib/logger.mjs";
import { ensureDir, writeJSON, writeJSONMin, writeText, sanitizeFileName } from "./lib/fsHelpers.mjs";
import { buildAssetLookup } from "./lib/assets.mjs";
import { buildLayerTree, computeBounds } from "./lib/layerTree.mjs";
import { renderDesignPreview, makeTreeHTMLDocument } from "./lib/renderHtml.mjs";
import { createStyleContext } from "./lib/styleMapping.mjs";
import { createZeplinClient, fetchLatestScreenVersion } from "./lib/zeplinClient.mjs";
import { minifyLayerTree } from "./lib/minifyLayout.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const argv = process.argv.slice(2);
if (!argv.length) {
  console.error("Usage: node export_screen.mjs <URL | PROJECT_ID> [SCREEN_ID] [--workdir <DIR>] [--quiet] [--verbose]");
  console.error("Example: node export_screen.mjs https://app.zeplin.io/project/xxx/screen/yyy");
  process.exit(1);
}

let inputUrlOrProjectId = argv.shift();
let screenIdCandidate = argv.length ? argv[0] : undefined;
let screenId = undefined;
if (screenIdCandidate && !screenIdCandidate.startsWith("-")) {
  screenId = screenIdCandidate;
  argv.shift();
}

let projectId = inputUrlOrProjectId;
const urlPattern = /\/project\/([^\/]+)\/screen\/([^\/?#]+)/;
const urlMatch = urlPattern.exec(projectId);
if (urlMatch) {
  projectId = urlMatch[1];
  screenId = urlMatch[2];
}

if (!projectId || !screenId) {
  console.error("Usage: node export_screen.mjs <URL | PROJECT_ID> [SCREEN_ID] [--workdir <DIR>] [--platform <c|b>] [--quiet] [--verbose]");
  console.error("Example: node export_screen.mjs https://app.zeplin.io/project/xxx/screen/yyy");
  process.exit(1);
}

let workdir = null;
let QUIET = false;
let VERBOSE = false;
let NO_OPEN = false;
let backUrl = null;
let platformKey = null;
for (let i = 0; i < argv.length; i++) {
  const arg = argv[i];
  if (arg === "--workdir" && argv[i + 1]) { workdir = argv[++i]; continue; }
  if (arg === "--platform" && argv[i + 1]) { platformKey = argv[++i]; continue; }
  if (arg === "--quiet") { QUIET = true; continue; }
  if (arg === "--verbose") { VERBOSE = true; continue; }
  if (arg === "--no-open") { NO_OPEN = true; continue; }
  if (arg === "--back-url" && argv[i + 1]) { backUrl = argv[++i]; continue; }
}

const accessToken = process.env.zeplin_token || process.env.ZEPLIN_TOKEN;
if (!accessToken) {
  console.error("Missing [REDACTED_SECRET] set the ZEPLIN_TOKEN environment variable");
  process.exit(1);
}

const log = createLogger(QUIET);

const buildWorkdir = (latestVersion) => {
  if (workdir) {
    ensureDir(workdir);
    return path.resolve(workdir);
  }
  const buildRoot = path.resolve(__dirname, "build");
  ensureDir(buildRoot);
  const dateStr = new Date().toISOString().slice(0, 16).replace("T", "_").replace(":", "-");
  const screenName = sanitizeFileName(latestVersion?.name || screenId || "screen");
  const auto = path.join(buildRoot, `${screenName}_${dateStr}`);
  ensureDir(auto);
  return auto;
};

const api = createZeplinClient(accessToken);
const phase = (index, message) => log(`${index} ${message}`);
const styleHelpers = createStyleContext(platformKey);

const openInFinder = (targetPath, log) => {
  try {
    execSync(`open "${targetPath.replace(/"/g, '\\"')}"`);
  } catch (err) {
    log(`Warning: failed to open ${targetPath}: ${err?.message || err}`);
  }
};

const normaliseScreenFrame = (latestVersion) => {
  const frame = latestVersion?.frame
    || latestVersion?.rect
    || latestVersion?.bounds
    || latestVersion?.size
    || latestVersion?.content?.frame
    || latestVersion?.content?.rect
    || latestVersion?.content?.bounds
    || latestVersion?.content?.size
    || null;

  const widthCandidate = frame?.width ?? latestVersion?.width ?? latestVersion?.content?.width;
  const heightCandidate = frame?.height ?? latestVersion?.height ?? latestVersion?.content?.height;
  const width = Number.isFinite(widthCandidate) ? widthCandidate : null;
  const height = Number.isFinite(heightCandidate) ? heightCandidate : null;
  if (width === null || height === null) return null;

  const xCandidate = frame?.x;
  const yCandidate = frame?.y;
  return {
    x: Number.isFinite(xCandidate) ? xCandidate : 0,
    y: Number.isFinite(yCandidate) ? yCandidate : 0,
    width,
    height
  };
};

const hasSingleScreenContainer = (layers, screenFrame, epsilon = 0.5) => {
  if (!Array.isArray(layers) || layers.length !== 1 || !screenFrame) return false;
  const root = layers[0];
  const rect = root?.rect;
  if (!rect || !Array.isArray(root?.layers) || !root.layers.length) return false;
  const dx = Math.abs((rect.x ?? 0) - screenFrame.x);
  const dy = Math.abs((rect.y ?? 0) - screenFrame.y);
  const dw = Math.abs((rect.width ?? 0) - screenFrame.width);
  const dh = Math.abs((rect.height ?? 0) - screenFrame.height);
  return dx <= epsilon && dy <= epsilon && dw <= epsilon && dh <= epsilon;
};

const MAX_SYNTHETIC_ROOT_IMAGE_WIDTH = 375;

const buildRenderableLayers = (latestVersion, screenFrame) => {
  const sourceLayers = latestVersion?.layers || latestVersion?.content?.layers || [];
  if (!screenFrame || !Array.isArray(sourceLayers) || !sourceLayers.length) return sourceLayers;
  if (!Number.isFinite(screenFrame.width) || screenFrame.width > MAX_SYNTHETIC_ROOT_IMAGE_WIDTH) {
    return sourceLayers;
  }
  if (hasSingleScreenContainer(sourceLayers, screenFrame)) return sourceLayers;

  return [{
    id: `__screen_root_${latestVersion?.id || screenId || "screen"}`,
    name: latestVersion?.name ? `${latestVersion.name} (Screen)` : "Screen",
    type: "screen",
    visible: true,
    rect: {
      x: screenFrame.x,
      y: screenFrame.y,
      width: screenFrame.width,
      height: screenFrame.height
    },
    layers: sourceLayers
  }];
};

(async () => {
  phase("START", "Starting export");

  try {
    phase("1", `Fetching screen data project=${projectId} screen=${screenId}`);
    const latestVersion = await fetchLatestScreenVersion(api, projectId, screenId);
    phase("2", "Creating export directory and writing raw data");
    const wd = buildWorkdir(latestVersion);
    process.env.ZEPLIN_LOG_FILE = path.join(wd, "export.log");
    if (VERBOSE) log(`→ log file: ${process.env.ZEPLIN_LOG_FILE}`);
    log(`→ workdir: ${wd}`);
    const rawPath = path.join(wd, "raw.json");
    writeJSON(rawPath, latestVersion, VERBOSE ? log : null);

    phase("3", "Downloading assets and building asset lookup");
    const assetsDir = path.join(wd, "assets");
    ensureDir(assetsDir);

    const assetLookup = await buildAssetLookup(latestVersion?.assets || [], {
      assetsDir,
      workdir: wd,
      log,
      verbose: VERBOSE
    });

    const screenFrame = normaliseScreenFrame(latestVersion);
    const renderableLayers = buildRenderableLayers(latestVersion, screenFrame);

    phase("4", "Building layer tree");
    const tree = buildLayerTree(
      renderableLayers,
      {
        debug: VERBOSE && !QUIET,
        verbose: VERBOSE,
        assetLookup,
        rootFrame: screenFrame,
        styleHelpers,
        log
      }
    );
    phase("5", "Computing canvas bounds and writing layout JSON");
    const bounds = computeBounds(tree);
    const layoutOutput = minifyLayerTree(tree, { bounds });
    const treePath = path.join(wd, "layers_tree.json");
    writeJSONMin(treePath, layoutOutput, VERBOSE ? log : null);

    const origin = { x: bounds.minX, y: bounds.minY };
    const screenImageUrl = latestVersion?.imageUrl
      || latestVersion?.content?.imageUrl
      || null;

    phase("6", "Rendering preview and HTML document");
    const preview = renderDesignPreview(
      tree,
      origin,
      bounds,
      {
        backgroundImage: screenImageUrl || undefined,
        backgroundImageFrame: screenFrame || undefined,
        assetLookup,
        styleHelpers,
        useNormalizedTree: true
      }
    );

    const html = makeTreeHTMLDocument(tree, {
      project: projectId,
      screen: latestVersion?.name || screenId,
      screenId,
      exportedAt: new Date().toISOString(),
      backUrl,
      preview
    }, {
      styleHelpers,
      minifiedTree: layoutOutput,
      minifiedRootWrapped: Array.isArray(tree) ? tree.length !== 1 : true
    });
    phase("7", "Writing HTML and opening the result");
    const htmlPath = path.join(wd, "layers_tree.html");
    writeText(htmlPath, html, VERBOSE ? log : null);
    if (!NO_OPEN) {
      openInFinder(wd, log);
      openInFinder(htmlPath, log);
    }
    phase("DONE", "Completed");
  } catch (err) {
    console.error("Request failed:", err?.response?.data || err?.message || err);
    process.exit(1);
  }
})();


path: package-lock.json
content: {
  "name": "zeplintest",
  "version": "1.0.1",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "zeplintest",
      "version": "1.0.1",
      "license": "ISC",
      "dependencies": {
        "@zeplin/sdk": "^1.34.0",
        "dotenv": "^17.2.3"
      }
    },
    "node_modules/@zeplin/sdk": {
      "version": "1.34.0",
      "resolved": "https://registry.npmjs.org/@zeplin/sdk/-/sdk-1.34.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "axios": "^1.6.8",
        "core-js": "^3.10.2",
        "form-data": "^4.0.0",
        "regenerator-runtime": "^0.13.7"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/axios": {
      "version": "1.12.2",
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "follow-redirects": "^1.15.6",
        "form-data": "^4.0.4",
        "proxy-from-env": "^1.1.0"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "delayed-stream": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/core-js": {
      "version": "3.46.0",
      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/core-js"
      }
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dotenv": {
      "version": "17.2.3",
      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://dotenvx.com"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/follow-redirects": {
      "version": "1.15.11",
      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/RubenVerborgh"
        }
      ],
      "license": "MIT",
      "engines": {
        "node": ">=4.0"
      },
      "peerDependenciesMeta": {
        "debug": {
          "optional": true
        }
      }
    },
    "node_modules/form-data": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.2",
        "mime-types": "^2.1.12"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/regenerator-runtime": {
      "version": "0.13.11",
      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "zeplintest",
  "version": "1.0.1",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@zeplin/sdk": "^1.34.0",
    "dotenv": "^17.2.3"
  }
}


path: lib/previewStyles.css
content: html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; margin: 0; padding: 24px; box-sizing: border-box; }
.layout-left { display: flex; flex-direction: column; gap: 24px; background: #fff; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.12); padding: 16px; overflow: auto; max-height: calc(100vh - 48px); }
.layout-right { position: fixed; right: 0; top: 0; height: 100vh; width: 560px; display: flex; flex-direction: column; background: #fff; box-shadow: -2px 0 20px rgba(0,0,0,0.18); z-index: 1000; transform: translateX(100%); transition: transform 0.25s ease; overflow: hidden; }
.layout-right.open { transform: translateX(0); }
.layout-right > * { padding: 16px; overflow-y: auto; overflow-x: hidden; flex: 1; }
.layout-right h3 { margin-top: 0; }
.sidebar-btns { position: fixed; right: 0; top: 50%; transform: translateY(-50%); z-index: 1001; display: flex; flex-direction: column; gap: 4px; }
.debug-toggle-btn { background: #1a73e8; color: #fff; border: none; border-radius: 6px 0 0 6px; padding: 14px 6px; cursor: pointer; writing-mode: vertical-rl; font-size: 12px; font-weight: 600; letter-spacing: 0.05em; box-shadow: -2px 0 8px rgba(0,0,0,0.15); transition: background 0.15s ease; }
.debug-toggle-btn:hover { background: #1557b0; }
.debug-toggle-btn.active { background: #374151; }
.multi-select-btn { background: #059669; color: #fff; border: none; border-radius: 6px 0 0 6px; padding: 14px 6px; cursor: pointer; writing-mode: vertical-rl; font-size: 12px; font-weight: 600; letter-spacing: 0.05em; box-shadow: -2px 0 8px rgba(0,0,0,0.15); transition: background 0.15s ease; }
.multi-select-btn:hover { background: #047857; }
.multi-select-btn.active { background: #374151; }
.layer.multi-selected { outline: 2px solid #059669 !important; box-shadow: 0 0 0 2px rgba(5,150,105,0.3) !important; }
.layer.multi-selected::before { content: "✓"; position: absolute; left: 2px; top: 2px; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 999px; background: #059669; color: #fff; font-size: 10px; line-height: 14px; font-weight: 700; text-align: center; pointer-events: none; z-index: 2; }
.multi-select-toolbar { position: sticky; bottom: 0; left: 0; z-index: 1999; margin-top: 8px; padding: 8px 12px; background: #1e293b; border-radius: 8px; display: none; align-items: center; gap: 8px; flex-wrap: wrap; }
.multi-select-toolbar.visible { display: flex; }
.multi-select-count { color: #94a3b8; font-size: 12px; flex: 1; min-width: 60px; }
.multi-select-toolbar button { border: none; border-radius: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer; background: #2563eb; color: #fff; }
.multi-select-toolbar .btn-clear { background: #475569; }
.multi-select-toolbar button:hover { opacity: 0.85; }
.page-header { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.back-link { font-size: 12px; color: #6b7280; text-decoration: none; display: inline-flex; align-items: center; gap: 4px; margin-bottom: 2px; }
.back-link:hover { color: #1a73e8; }
h1 { margin: 0; font-size: 18px; }
.zeplin-link { font-size: 12px; color: #1a73e8; text-decoration: none; word-break: break-all; }
.zeplin-link:hover { text-decoration: underline; }
.meta { color: #888; font-size: 12px; margin: 0; }
h2 { margin-top: 0; margin-bottom: 0; font-size: 14px; color: #555; }
ul.tree { list-style: none; padding-left: 0; margin-left: 0; }
ul.tree > li { margin-bottom: 4px; }
details > summary { cursor: pointer; padding: 4px 8px; background: #fff; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
details[open] > summary { background: #e8f0ff; }
details > ul { margin-left: 20px; }
summary::-webkit-details-marker { display: none; }
.preview { background: #fff; padding: 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); display: inline-block; }
.canvas { position: relative; background: #fdfdfd; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); overflow: hidden; }
.layer { position: absolute; box-sizing: border-box; }
.screen-bg { position: absolute; left: 0; top: 0; display: block; pointer-events: none; z-index: 0; }
.layer:hover { outline: 1px dashed rgba(0,0,0,0.2); }
.layer.hover-target { outline: 2px solid #1a73e8 !important; box-shadow: 0 0 0 2px rgba(26,115,232,0.2); }
.layer.hover-descendant { outline: 1px dashed rgba(26,115,232,0.6); }
.layer.selected-target { outline: 2px solid #fbbc04 !important; box-shadow: 0 0 0 2px rgba(251,188,4,0.25); }
.layer.selected-descendant { outline: 1px dashed rgba(251,188,4,0.55); }
.layer.has-instruction { box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.85); }
.layer.has-instruction::after { content: "P"; position: absolute; right: 2px; top: 2px; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 999px; background: #2563eb; color: #fff; font-size: 10px; line-height: 14px; font-weight: 700; text-align: center; pointer-events: none; z-index: 1; }
.layer-desc { padding: 4px 0; border-radius: 6px; transition: background 0.2s ease, box-shadow 0.2s ease; }
.layer-desc.hovered { background: rgba(26,115,232,0.08); box-shadow: inset 0 0 0 1px rgba(26,115,232,0.35); }
.layer-desc.selected { background: rgba(251,188,4,0.16); box-shadow: inset 0 0 0 1px rgba(251,188,4,0.45); }
.layer-desc > details > summary { outline: none; }
.layer-desc.has-instruction > details > summary::after { content: "instruction"; display: inline-block; margin-left: 8px; padding: 1px 6px; border-radius: 999px; background: #dbeafe; color: #1d4ed8; font-size: 11px; font-weight: 600; vertical-align: middle; }
.preview-section { position: relative; isolation: isolate; }
.preview-instruction-editor { position: sticky; left: 0; bottom: 0; z-index: 2000; margin-top: 12px; padding: 12px; border-radius: 10px; background: #f8fafc; border: 1px solid #dbeafe; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); display: none; pointer-events: auto; }
.preview-instruction-editor.visible { display: block; }
.preview-instruction-title { font-size: 13px; font-weight: 600; color: #0f172a; margin-bottom: 8px; }
.preview-instruction-editor textarea { width: 100%; min-height: 88px; resize: vertical; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 6px; padding: 8px 10px; font: inherit; line-height: 1.5; background: #fff; color: #0f172a; }
.preview-instruction-toolbar { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.preview-instruction-hint { color: #64748b; font-size: 12px; }
.preview-instruction-actions { display: flex; gap: 8px; }
.preview-instruction-actions button { border: none; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; }
.preview-instruction-actions .instruction-close { background: #e2e8f0; color: #0f172a; }
.preview-instruction-actions .instruction-save { background: #2563eb; color: #fff; }
.preview-instruction-editor,
.preview-instruction-editor * { pointer-events: auto; }
.description-panel { flex: 1 1 auto; }
.tooltip-panel { flex: 0 0 auto; }
.text-content { pointer-events: none; }
.layer-image { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }
#layerHighlight { position: absolute; pointer-events: none; border: 1px solid #1a73e8; box-shadow: 0 0 0 1px rgba(26,115,232,0.3); background: rgba(26,115,232,0.08); display: none; }
#layerTooltip { position: fixed; z-index: 9999; background: rgba(26,32,44,0.9); color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 12px; pointer-events: none; max-width: 360px; max-height: 60vh; overflow: auto; line-height: 1.4; display: none; white-space: pre-line; }
.copied-toast { position: fixed; right: 20px; bottom: 20px; background: rgba(0,0,0,0.8); color: #fff; padding: 8px 12px; border-radius: 4px; font-size: 12px; opacity: 0; transition: opacity 0.2s ease; }
.copied-toast.visible { opacity: 1; }
.context-copy-menu { position: fixed; z-index: 10000; background: #111827; color: #fff; border-radius: 6px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); padding: 8px; font-size: 12px; display: none; flex-direction: column; gap: 6px; }
.context-copy-menu button { border: none; background: #2563eb; color: #fff; padding: 6px 10px; border-radius: 4px; cursor: pointer; }
.context-copy-menu button:hover { background: #1d4ed8; }


path: lib/zeplinClient.mjs
content: import { ZeplinApi, Configuration } from "@zeplin/sdk";

export const createZeplinClient = (accessToken) => new ZeplinApi(new Configuration({ accessToken }));

export const fetchLatestScreenVersion = async (api, projectId, screenId) => {
  const res = await api.screens.getLatestScreenVersion(projectId, screenId);
  return res?.data || res;
};


path: lib/fsHelpers.mjs
content: import fs from "node:fs";
import path from "node:path";

export const ensureDir = (dirPath) => fs.mkdirSync(dirPath, { recursive: true });

export const writeJSON = (filepath, data, log = null) => {
  fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
  if (typeof log === "function") log(`  -> Wrote ${filepath}`);
};

export const writeJSONMin = (filepath, data, log = null) => {
  fs.writeFileSync(filepath, JSON.stringify(data));
  if (typeof log === "function") log(`  -> Wrote ${filepath}`);
};

export const writeText = (filepath, content, log = null) => {
  fs.writeFileSync(filepath, content, "utf-8");
  if (typeof log === "function") log(`  -> Wrote ${filepath}`);
};

export const sanitizeFileName = (input = "") => String(input)
  .normalize("NFKD")
  .replace(/[\u0300-\u036f]/g, "")
  .replace(/[^a-zA-Z0-9@._-]+/g, "_")
  .replace(/_{2,}/g, "_")
  .replace(/^_+|_+$/g, "")
  || "asset";

export const toPosixPath = (filePath) => filePath.split(path.sep).join("/");


path: lib/styleMapping.mjs
content: import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");

const toNumberChannel = (value) => {
  if (typeof value !== "number" || Number.isNaN(value)) return 0;
  if (value > 1) return Math.round(Math.max(0, Math.min(255, value)));
  return Math.round(Math.max(0, Math.min(255, value * 255)));
};

const toHex = (channel) => channel.toString(16).padStart(2, "0");

const colorObjectToHex = (color) => {
  if (!color) return null;
  const r = toNumberChannel(color.r ?? color.red ?? 0);
  const g = toNumberChannel(color.g ?? color.green ?? 0);
  const b = toNumberChannel(color.b ?? color.blue ?? 0);
  const alphaRaw = color.a ?? color.alpha;
  let alpha = 1;
  if (typeof alphaRaw === "number" && !Number.isNaN(alphaRaw)) {
    alpha = alphaRaw > 1 ? Math.max(0, Math.min(1, alphaRaw / 255)) : Math.max(0, Math.min(1, alphaRaw));
  }
  const base = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
  if (alpha >= 0.999) return base.toLowerCase();
  return `${base}${toHex(Math.round(alpha * 255))}`.toLowerCase();
};

const colorComponent = (value, fallback = 0) => {
  if (typeof value === "number" && !Number.isNaN(value)) return value;
  return fallback;
};

const colorToCss = (color) => {
  if (!color) return null;
  const r = toNumberChannel(color.r ?? color.red);
  const g = toNumberChannel(color.g ?? color.green);
  const b = toNumberChannel(color.b ?? color.blue);
  let a = colorComponent(color.a ?? color.alpha, 1);
  if (a > 1) a = a / 255;
  if (a < 0) a = 0;
  if (a > 1) a = 1;
  return `rgba(${r}, ${g}, ${b}, ${a})`;
};

const borderToCss = (borders = []) => {
  const border = Array.isArray(borders)
    ? borders.find(b => b?.fill?.color && Number.isFinite(b.thickness))
    : null;
  if (!border) return null;
  const color = colorToCss(border.fill.color);
  if (!color) return null;
  return `${border.thickness}px solid ${color}`;
};

const shadowToCss = (shadows = []) => {
  const shadow = Array.isArray(shadows)
    ? shadows.find(s => s?.color)
    : null;
  if (!shadow) return null;
  const color = colorToCss(shadow.color);
  if (!color) return null;
  const x = shadow.offsetX ?? shadow.x ?? 0;
  const y = shadow.offsetY ?? shadow.y ?? 0;
  const blur = shadow.blurRadius ?? shadow.blur ?? 0;
  const spread = shadow.spread ?? 0;
  return `${x}px ${y}px ${blur}px ${spread}px ${color}`;
};

const formatNumeric = (value) => {
  if (!Number.isFinite(value)) return null;
  if (Math.abs(value - Math.round(value)) < 1e-6) return String(Math.round(value));
  const precision = Math.abs(value) >= 1 ? 2 : 3;
  return parseFloat(value.toFixed(precision)).toString();
};

const toPercentString = (value) => {
  if (!Number.isFinite(value)) return null;
  const raw = value > 1 ? value : value * 100;
  const formatted = formatNumeric(raw);
  return formatted == null ? null : `${formatted}%`;
};

const describeConstraints = (frame, parentFrame) => {
  if (!frame) return null;
  const entries = [];
  const map = {};
  const roundValue = (value) => {
    if (!Number.isFinite(value)) return null;
    const rounded = Math.round(value * 1000) / 1000;
    return Math.abs(rounded) < 1e-6 ? 0 : rounded;
  };
  const pushMetric = (key, value) => {
    const rounded = roundValue(value);
    if (rounded === null) return;
    const display = formatNumeric(rounded);
    if (display == null) return;
    entries.push(`${key}=${display}`);
    map[key] = rounded;
  };

  pushMetric("top", frame.y);
  pushMetric("left", frame.x);

  const parentWidth = Number.isFinite(parentFrame?.width) ? parentFrame.width : null;
  const parentHeight = Number.isFinite(parentFrame?.height) ? parentFrame.height : null;

  if (parentHeight != null && Number.isFinite(frame.height)) {
    pushMetric("bottom", parentHeight - (frame.y ?? 0) - (frame.height ?? 0));
  }

  if (parentWidth != null && Number.isFinite(frame.width)) {
    pushMetric("right", parentWidth - (frame.x ?? 0) - (frame.width ?? 0));
  }

  const centerTolerance = 0.5;
  if (parentWidth != null && Number.isFinite(frame.width) && Number.isFinite(frame.x)) {
    const childCenterX = frame.x + frame.width / 2;
    const parentCenterX = parentWidth / 2;
    if (Number.isFinite(childCenterX) && Math.abs(childCenterX - parentCenterX) <= centerTolerance) {
      entries.push("centerX=super.centerX");
      map.centerX = "super.centerX";
    }
  }

  if (parentHeight != null && Number.isFinite(frame.height) && Number.isFinite(frame.y)) {
    const childCenterY = frame.y + frame.height / 2;
    const parentCenterY = parentHeight / 2;
    if (Number.isFinite(childCenterY) && Math.abs(childCenterY - parentCenterY) <= centerTolerance) {
      entries.push("centerY=super.centerY");
      map.centerY = "super.centerY";
    }
  }

  if (!entries.length) return null;
  return { entries, map };
};

const shouldDisplayBorderRadius = (value) => {
  if (value === undefined || value === null) return false;
  if (typeof value === "number") return Math.abs(value) > 1e-6;
  if (typeof value === "string") return value.trim() !== "0" && value.trim() !== "0px";
  return true;
};

const shouldDisplayOpacity = (value) => {
  if (value === undefined || value === null) return false;
  if (typeof value === "number") return Math.abs(value - 1) > 1e-6;
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!trimmed) return false;
    const asNumber = Number(trimmed);
    if (!Number.isNaN(asNumber)) return Math.abs(asNumber - 1) > 1e-6;
    return true;
  }
  return true;
};

const normalizeFontFamilyName = (name) => {
  if (!name) return null;
  let normalized = String(name).trim();
  if (!normalized) return null;
  normalized = normalized.replace(/\b(PingFangSC|PingFangTC|PingFangHK)\b/gi, (match) => {
    if (/PingFangSC/i.test(match)) return "PingFang SC";
    if (/PingFangTC/i.test(match)) return "PingFang TC";
    if (/PingFangHK/i.test(match)) return "PingFang HK";
    return match;
  });
  normalized = normalized.replace(/-?(Regular|Medium|Semibold|SemiBold|Bold|Light|Ultralight|UltraLight|Thin|Black|Heavy|Italic|Oblique|Condensed|Cond)\b/gi, "");
  normalized = normalized.replace(/\s{2,}/g, " ").trim();
  return normalized || null;
};

const buildFontFamilyStack = (style = {}) => {
  const stack = [];
  const push = (value) => {
    if (!value) return;
    const trimmed = String(value).trim();
    if (!trimmed) return;
    if (!stack.includes(trimmed)) stack.push(trimmed);
  };
  push(style.fontFamily);
  push(style.fontPostscriptName);
  const normalized = normalizeFontFamilyName(style.fontPostscriptName || style.fontFamily);
  push(normalized);
  if (/PingFangSC/i.test(style.fontFamily || style.fontPostscriptName || "")) {
    push("PingFang SC");
  }
  if (!stack.length) return null;
  return stack;
};


const pruneUndefined = (obj = {}) => Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined && value !== null && value !== ""));

const evaluateExpression = (expr) => {
  const sanitized = expr.replace(/\s+/g, "");
  if (!/^[-+*/0-9.()]+$/.test(sanitized)) return NaN;
  try {
    // eslint-disable-next-line no-new-func
    return Function(`"use strict";return (${sanitized});`)();
  } catch {
    return NaN;
  }
};

const loadColorMapping = (platformKey) => {
  const map = new Map();
  if (!platformKey) return map;
  const platformDir = path.resolve(repoRoot, "Apps", platformKey.toLowerCase());

  const jsonPath = path.join(platformDir, "color.json");
  if (fs.existsSync(jsonPath)) {
    try {
      const json = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
      for (const [rawHex, name] of Object.entries(json)) {
        if (typeof rawHex !== "string" || typeof name !== "string") continue;
        const normalizedHex = rawHex.trim().toLowerCase();
        if (!normalizedHex) continue;
        map.set(normalizedHex, name.trim() || name);
      }
    } catch (err) {
      console.warn(`Warning: failed to parse color mapping (${jsonPath}): ${err?.message || err}`);
    }
  }

  if (map.size) return map;

  const colorsPath = path.join(platformDir, "Color+Additions.swift");
  if (!fs.existsSync(colorsPath)) return map;
  const content = fs.readFileSync(colorsPath, "utf-8");
  const lines = content.split(/\r?\n/);
  const colorLineRegex = /static let (\w+) = Color\(([^)]+)\)/;
  for (const line of lines) {
    const trimmed = line.trim();
    const match = colorLineRegex.exec(trimmed);
    if (!match) continue;
    const [, name, params] = match;
    const args = Object.fromEntries(params.split(",").map(part => {
      const [key, value] = part.split(":").map(str => str.trim());
      return [key, value];
    }));
    let r; let g; let b; let a;
    if ("white" in args) {
      const greyExpr = args.white;
      const greyVal = evaluateExpression(greyExpr);
      if (Number.isNaN(greyVal)) continue;
      const channel = toNumberChannel(greyVal);
      r = g = b = channel;
    } else if ("red" in args && "green" in args && "blue" in args) {
      const redVal = evaluateExpression(args.red);
      const greenVal = evaluateExpression(args.green);
      const blueVal = evaluateExpression(args.blue);
      if ([redVal, greenVal, blueVal].some(Number.isNaN)) continue;
      r = toNumberChannel(redVal);
      g = toNumberChannel(greenVal);
      b = toNumberChannel(blueVal);
    } else {
      continue;
    }
    if ("opacity" in args) {
      const alphaVal = evaluateExpression(args.opacity);
      if (!Number.isNaN(alphaVal)) a = alphaVal;
    }
    const hex = colorObjectToHex({ r, g, b, a });
    if (!hex) continue;
    map.set(hex.toLowerCase(), name);
  }
  return map;
};

const loadFontMapping = (platformKey) => {
  const map = new Map();
  if (!platformKey) return map;
  const platformDir = path.resolve(repoRoot, "Apps", platformKey.toLowerCase());

  const jsonPath = path.join(platformDir, "font.json");
  if (fs.existsSync(jsonPath)) {
    try {
      const json = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
      for (const [key, name] of Object.entries(json)) {
        if (typeof key !== "string" || typeof name !== "string") continue;
        const normalizedKey = key.trim();
        if (!normalizedKey) continue;
        map.set(normalizedKey, name.trim() || name);
      }
    } catch (err) {
      console.warn(`Warning: failed to parse font mapping (${jsonPath}): ${err?.message || err}`);
    }
  }

  if (map.size) return map;

  const fontPath = path.join(platformDir, "Font+Additions.swift");
  if (!fs.existsSync(fontPath)) return map;
  const content = fs.readFileSync(fontPath, "utf-8");
  const regex = /static let (\w+) = Font\.custom\("([^"]+)",\s*size:\s*([0-9.]+)/g;
  let match;
  while ((match = regex.exec(content))) {
    const [, alias, postscript, sizeStr] = match;
    const size = parseFloat(sizeStr);
    if (Number.isNaN(size)) continue;
    const key = `${postscript} ${Number.isInteger(size) ? size : size.toString()}`;
    map.set(key, alias);
  }
  return map;
};

const extractBorderInfo = (borders = [], resolveColorName = () => undefined) => {
  if (!Array.isArray(borders)) return null;
  const border = borders.find(item => item?.fill?.color && Number.isFinite(item.thickness));
  if (!border) return null;
  const width = border.thickness;
  const colorHex = colorObjectToHex(border.fill?.color);
  const normalizedHex = colorHex ? colorHex.toLowerCase() : undefined;
  const colorName = normalizedHex ? resolveColorName(normalizedHex) : undefined;
  return {
    width,
    colorHex: normalizedHex,
    colorName,
    color: colorName || normalizedHex
  };
};

const extractGradientInfo = (fills = [], resolveColorName = () => undefined) => {
  if (!Array.isArray(fills)) return null;
  const gradientFill = fills.find(fill => fill?.type === "gradient" && fill.gradient?.colorStops?.length);
  if (!gradientFill) return null;

  const gradient = gradientFill.gradient || {};
  const stops = [];

  for (const stop of gradient.colorStops || []) {
    if (!stop?.color) continue;
    const hex = colorObjectToHex(stop.color);
    const normalizedHex = hex ? hex.toLowerCase() : undefined;
    const displayColor = normalizedHex || colorToCss(stop.color);
    if (!displayColor) continue;
    const colorName = normalizedHex ? resolveColorName(normalizedHex) : undefined;
    const position = typeof stop.position === "number" && Number.isFinite(stop.position)
      ? stop.position
      : undefined;
    const positionDisplay = position !== undefined ? formatNumeric(position) : undefined;
    const entry = {
      color: displayColor,
      ...(normalizedHex ? { hex: normalizedHex } : {}),
      ...(colorName ? { colorName } : {}),
      ...(position !== undefined ? { position } : {})
    };
    if (positionDisplay !== undefined) entry.positionDisplay = positionDisplay;
    stops.push(entry);
  }

  if (!stops.length) return null;

  const description = stops.map(stop => {
    const colorPart = stop.hex || stop.color;
    if (stop.positionDisplay !== undefined) {
      return `${colorPart} (${stop.positionDisplay})`;
    }
    if (stop.position !== undefined) {
      const fallback = formatNumeric(stop.position);
      return `${colorPart} (${fallback ?? stop.position})`;
    }
    return colorPart;
  }).join(" -> ");

  const cssStops = stops.map(stop => {
    const colorPart = stop.hex || stop.color;
    if (!colorPart) return null;
    if (stop.position !== undefined) {
      const percent = toPercentString(stop.position);
      return percent ? `${colorPart} ${percent}` : colorPart;
    }
    return colorPart;
  }).filter(Boolean);

  let css = null;
  if (cssStops.length >= 2) {
    const gradientType = (gradient.type || "").toLowerCase();
    if (gradientType === "radial") {
      css = `radial-gradient(${cssStops.join(", ")})`;
    } else {
      css = `linear-gradient(180deg, ${cssStops.join(", ")})`;
    }
  }

  return {
    type: gradient.type || "linear",
    stops,
    description,
    ...(css ? { css } : {})
  };
};

const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);

const extractFillColor = (fills = [], resolveColorName = () => undefined) => {
  if (!Array.isArray(fills)) return null;
  const fill = fills.find(item => item?.type === "color" && item.color);
  if (!fill) return null;
  const colorHex = colorObjectToHex(fill.color);
  const normalizedHex = colorHex ? colorHex.toLowerCase() : undefined;
  const colorName = normalizedHex ? resolveColorName(normalizedHex) : undefined;
  const color = colorToCss(fill.color);
  if (!color && !normalizedHex) return null;
  return {
    color,
    colorHex: normalizedHex,
    colorName,
    display: colorName || normalizedHex || color
  };
};

const buildTextStyleSnapshot = (style = {}, { resolveColorName = () => undefined, fontNameMap = new Map() } = {}) => {
  const postscriptName = style.postscriptName || style.fontPostscriptName || style.font_postscript_name;
  const fontFamily = style.fontFamily || style.font_family || postscriptName;
  const sizeRaw = style.size ?? style.fontSize ?? style.font_size;
  const fontSize = typeof sizeRaw === "number" ? sizeRaw : (sizeRaw ? parseFloat(sizeRaw) : undefined);
  const fontWeight = style.fontWeight ?? style.font_weight;
  const fontStyle = style.fontStyle ?? style.font_style;
  const lineHeight = style.lineHeight ?? style.line_height;
  const letterSpacing = style.letterSpacing ?? style.letter_spacing;
  const textAlign = style.textAlign
    || style.text_alignment
    || style.textAlignment
    || style.alignment
    || style.align;
  const colorCss = colorToCss(style.color);
  const colorHex = colorObjectToHex(style.color);
  const normalizedHex = colorHex ? colorHex.toLowerCase() : undefined;
  const colorName = colorHex ? resolveColorName(colorHex) : undefined;

  let fontName;
  let fontLookupKey;
  const rawSizeString = sizeRaw != null ? String(sizeRaw).trim() : undefined;
  if (postscriptName && (Number.isFinite(fontSize) || rawSizeString)) {
    const trimmedPostscript = String(postscriptName).trim();
    const sizeVariants = new Set();
    if (rawSizeString) {
      sizeVariants.add(rawSizeString);
      sizeVariants.add(rawSizeString.replace(/px$/i, ""));
    }
    if (Number.isFinite(fontSize)) {
      const baseSizeStr = String(fontSize);
      sizeVariants.add(baseSizeStr);
      sizeVariants.add(baseSizeStr.replace(/\.0+$/, ""));
      sizeVariants.add(Number(fontSize.toFixed(2)).toString());
      sizeVariants.add(Number(fontSize.toFixed(1)).toString());
      sizeVariants.add(Math.round(fontSize).toString());
    }
    const candidates = Array.from(sizeVariants)
      .filter(sizeStr => sizeStr && sizeStr !== "NaN" && sizeStr !== "Infinity" && sizeStr !== "-Infinity")
      .sort((a, b) => a.length - b.length || a.localeCompare(b));
    for (const sizeStr of candidates) {
      const candidateKey = `${trimmedPostscript} ${sizeStr}`;
      const candidateName = fontNameMap.get(candidateKey);
      if (candidateName) {
        fontLookupKey = candidateKey;
        fontName = candidateName;
        break;
      }
    }
  }

  return pruneUndefined({
    fontFamily: fontFamily ? String(fontFamily) : undefined,
    fontPostscriptName: postscriptName ? String(postscriptName) : undefined,
    fontSize: isFiniteNumber(fontSize) ? fontSize : undefined,
    fontWeight: fontWeight ? String(fontWeight) : undefined,
    fontStyle: fontStyle ? String(fontStyle) : undefined,
    lineHeight: isFiniteNumber(lineHeight) ? lineHeight : undefined,
    letterSpacing: isFiniteNumber(letterSpacing) ? letterSpacing : undefined,
    textAlign: textAlign ? String(textAlign) : undefined,
    color: colorCss,
    colorHex: colorHex ? colorHex.toLowerCase() : undefined,
    colorName,
    fontName,
    fontKey: fontLookupKey
  });
};

const extractTextInfo = (layer, options = {}) => {
  if (!layer) return null;
  const { resolveColorName = () => undefined, fontNameMap = new Map() } = options;
  const styleOptions = { resolveColorName, fontNameMap };
  const style = layer.textStyles?.[0]?.style || layer.text?.style || {};
  const content = layer.content ?? layer.plainText ?? layer.text?.content ?? "";
  const styleSnapshot = buildTextStyleSnapshot(style, styleOptions);

  if (!content && !Object.keys(styleSnapshot).length) return null;

  let runs = null;
  const rawTextStyles = layer.textStyles;
  if (Array.isArray(rawTextStyles) && rawTextStyles.length > 1 && content) {
    const sortedTextStyles = rawTextStyles
      .filter(item => item?.range && typeof item.range.location === "number" && typeof item.range.length === "number")
      .sort((a, b) => a.range.location - b.range.location);

    if (sortedTextStyles.length > 1) {
      const builtRuns = [];
      let cursor = 0;
      for (const textStyle of sortedTextStyles) {
        const start = Math.max(0, textStyle.range.location);
        const end = Math.max(start, start + textStyle.range.length);
        if (start > cursor) {
          builtRuns.push({
            text: content.slice(cursor, start),
            style: styleSnapshot
          });
        }
        if (end > start) {
          builtRuns.push({
            text: content.slice(start, end),
            style: buildTextStyleSnapshot(textStyle.style || {}, styleOptions)
          });
        }
        cursor = end;
      }
      if (cursor < content.length) {
        builtRuns.push({
          text: content.slice(cursor),
          style: styleSnapshot
        });
      }
      if (builtRuns.length > 1) {
        runs = builtRuns.filter(run => run.text);
      }
    }
  }

  return {
    content,
    style: styleSnapshot,
    ...(runs?.length ? { runs } : {})
  };
};

const createStyleContext = (platformKey) => {
  const colorNameMap = loadColorMapping(platformKey);
  const fontNameMap = loadFontMapping(platformKey);
  const resolveColorName = (hex) => {
    if (!hex) return undefined;
    const normalized = hex.trim().toLowerCase();
    return colorNameMap.get(normalized);
  };

  return {
    colorNameMap,
    fontNameMap,
    resolveColorName,
    colorToCss,
    colorObjectToHex,
    borderToCss,
    shadowToCss,
    formatNumeric,
    toPercentString,
    describeConstraints,
    shouldDisplayBorderRadius,
    shouldDisplayOpacity,
    buildFontFamilyStack,
    extractGradientInfo: (fills = []) => extractGradientInfo(fills, resolveColorName),
    extractBorderInfo: (borders = []) => extractBorderInfo(borders, resolveColorName),
    extractFillColor: (fills = []) => extractFillColor(fills, resolveColorName),
    extractTextInfo: (layer) => extractTextInfo(layer, { resolveColorName, fontNameMap })
  };
};

export {
  borderToCss,
  colorObjectToHex,
  colorToCss,
  createStyleContext,
  describeConstraints,
  extractBorderInfo,
  extractFillColor,
  extractGradientInfo,
  extractTextInfo,
  formatNumeric,
  loadColorMapping,
  loadFontMapping,
  shadowToCss,
  shouldDisplayBorderRadius,
  shouldDisplayOpacity,
  buildFontFamilyStack,
  toNumberChannel,
  toPercentString
};


path: lib/renderLayer.mjs
content: import { extractImageInfo } from "./assets.mjs";
import { toFrame } from "./layerTree.mjs";
import { escapeHtml } from "./renderTree.mjs";

const renderLayerPreview = (layer, parentAbs, origin, zIndexRef, ctx = {}, depth = 0) => {
  const styleHelpers = ctx.styleHelpers || {};
  const previewOnlyHierarchy = ctx.previewOnlyHierarchy === true;
  const useNormalizedTree = ctx.useNormalizedTree === true;
  const {
    extractGradientInfo = () => null,
    colorToCss = () => null,
    borderToCss = () => null,
    shadowToCss = () => null,
    shouldDisplayBorderRadius = () => false,
    shouldDisplayOpacity = () => false,
    extractBorderInfo = () => null,
    extractTextInfo = () => null,
    describeConstraints = () => null,
    buildFontFamilyStack = null
  } = styleHelpers;

  if (!layer || layer.visible === false) return "";
  const frame = useNormalizedTree
    ? (layer.frame || toFrame(layer.rect))
    : toFrame(layer.rect);
  const abs = useNormalizedTree
    ? (
        layer.absoluteFrame ||
        (frame ? {
          x: parentAbs.x + (frame.x ?? 0),
          y: parentAbs.y + (frame.y ?? 0),
          width: frame.width ?? 0,
          height: frame.height ?? 0
        } : parentAbs)
      )
    : (
        frame ? ({
          x: parentAbs.x + (frame.x ?? 0),
          y: parentAbs.y + (frame.y ?? 0),
          width: frame.width ?? 0,
          height: frame.height ?? 0
        }) : parentAbs
      );

  const styles = ["position:absolute", "box-sizing:border-box"];
  const relativeLeft = frame ? (depth === 0 ? (abs.x - origin.x) : (frame.x ?? 0)) : (depth === 0 ? abs?.x - origin.x : 0);
  const relativeTop = frame ? (depth === 0 ? (abs.y - origin.y) : (frame.y ?? 0)) : (depth === 0 ? abs?.y - origin.y : 0);
  if (Number.isFinite(relativeLeft)) styles.push(`left:${relativeLeft}px`);
  if (Number.isFinite(relativeTop)) styles.push(`top:${relativeTop}px`);
  const widthValue = frame?.width ?? (depth === 0 ? abs?.width : undefined);
  const heightValue = frame?.height ?? (depth === 0 ? abs?.height : undefined);
  if (Number.isFinite(widthValue)) styles.push(`width:${widthValue}px`);
  if (Number.isFinite(heightValue)) styles.push(`height:${heightValue}px`);
  if (!previewOnlyHierarchy && shouldDisplayOpacity(layer.opacity)) styles.push(`opacity:${layer.opacity}`);
  if (layer.rotation) styles.push(`transform:rotate(${layer.rotation}deg)`);
  if (!previewOnlyHierarchy && layer.type === "text") styles.push("overflow:hidden");

  const textInfo = useNormalizedTree
    ? (layer.textContent ? { content: layer.textContent, style: layer.textStyle || {}, ...(layer.textRuns ? { runs: layer.textRuns } : {}) } : null)
    : (layer.type === "text" ? extractTextInfo(layer) : null);
  const imageInfo = useNormalizedTree
    ? (layer.image || null)
    : (extractImageInfo(layer, ctx) || layer.__imageInfo || null);
  const fillInfo = useNormalizedTree
    ? (
        layer.fillColor || layer.fillColorHex || layer.fillColorName
          ? {
              color: layer.fillColor,
              colorHex: layer.fillColorHex,
              colorName: layer.fillColorName,
              display: layer.fillColorName || layer.fillColorHex || layer.fillColor
            }
          : null
      )
    : (styleHelpers.extractFillColor ? styleHelpers.extractFillColor(layer.fills) : null);
  const gradientInfo = useNormalizedTree ? (layer.gradient || null) : extractGradientInfo(layer.fills);
  if (!previewOnlyHierarchy) {
    if (gradientInfo?.css) {
      styles.push(`background:${gradientInfo.css}`);
    } else if (!useNormalizedTree) {
      const fill = (layer.fills || []).find(f => f?.type === "color" && f.color);
      if (fill) {
        const fillColor = colorToCss(fill.color);
        if (fillColor) styles.push(`background:${fillColor}`);
      }
    }
  }

  const border = useNormalizedTree
    ? (layer.borderWidth !== undefined && (layer.borderColor || layer.borderColorName)
        ? `${layer.borderWidth}px solid ${layer.borderColor || layer.borderColorName}`
        : null)
    : borderToCss(layer.borders);
  if (!previewOnlyHierarchy && border) styles.push(`border:${border}`);

  const borderInfo = useNormalizedTree
    ? (
        layer.borderWidth !== undefined || layer.borderColor || layer.borderColorName
          ? {
              width: layer.borderWidth,
              colorHex: layer.borderColor,
              colorName: layer.borderColorName
            }
          : null
      )
    : extractBorderInfo(layer.borders);

  if (!previewOnlyHierarchy && shouldDisplayBorderRadius(layer.borderRadius)) styles.push(`border-radius:${layer.borderRadius}px`);

  const shadow = useNormalizedTree ? (layer.shadow || null) : shadowToCss(layer.shadows);
  if (!previewOnlyHierarchy && shadow) styles.push(`box-shadow:${shadow}`);

  styles.push(`z-index:${zIndexRef.value++}`);

  const generatedId = layer.generatedId || layer.descId || layer.__generatedId || layer.id || layer.sourceId || `layer_${Math.random().toString(36).slice(2)}`;
  const descId = generatedId;
  const dataAttrs = [
    generatedId ? `data-layer-id="${escapeHtml(generatedId)}"` : "",
    layer.id ? `data-layer-original-id="${escapeHtml(layer.id)}"` : "",
    layer.name ? `data-layer-name="${escapeHtml(layer.name)}"` : "",
    descId ? `data-layer-desc-id="${escapeHtml(descId)}"` : ""
  ].filter(Boolean);

  const stylesForCopy = Object.fromEntries(styles.map(s => {
    const idx = s.indexOf(":");
    if (idx === -1) return [s.trim(), ""];
    return [s.slice(0, idx).trim(), s.slice(idx + 1).trim()];
  }));
  dataAttrs.push(`data-layer-style="${escapeHtml(JSON.stringify(stylesForCopy))}"`);

  if (imageInfo) {
    dataAttrs.push(`data-layer-image="${escapeHtml(JSON.stringify(imageInfo))}"`);
  }
  if (gradientInfo) {
    dataAttrs.push(`data-layer-gradient="${escapeHtml(JSON.stringify(gradientInfo))}"`);
  }
  if (textInfo?.runs) {
    dataAttrs.push(`data-layer-text-runs="${escapeHtml(JSON.stringify(textInfo.runs))}"`);
  }

  const contentParts = [];
  if (!previewOnlyHierarchy && imageInfo?.url) {
    contentParts.push(`<img class="layer-image" src="${escapeHtml(imageInfo.url)}" alt="" />`);
  }

  if (!previewOnlyHierarchy && textInfo) {
    const textStyles = [
      "position:absolute",
      "left:0",
      "top:0",
      "width:100%",
      "height:100%",
      "white-space:pre-wrap",
      "display:block",
      "box-sizing:border-box",
      "padding:0",
      "overflow:hidden"
    ];
    if (textInfo.style.fontFamily || textInfo.style.fontPostscriptName) {
      const stack = buildFontFamilyStack
        ? buildFontFamilyStack(textInfo.style)
        : [textInfo.style.fontFamily || textInfo.style.fontPostscriptName];
      const fontStack = (stack || [])
        .map(value => String(value).replace(/'/g, "\\'"))
        .map(value => `'${value}'`)
        .join(", ");
      if (fontStack) textStyles.push(`font-family:${fontStack}`);
    }
    if (Number.isFinite(textInfo.style.fontSize)) textStyles.push(`font-size:${textInfo.style.fontSize}px`);
    if (textInfo.style.fontWeight) textStyles.push(`font-weight:${textInfo.style.fontWeight}`);
    if (textInfo.style.fontStyle) textStyles.push(`font-style:${textInfo.style.fontStyle}`);
    if (Number.isFinite(textInfo.style.lineHeight)) textStyles.push(`line-height:${textInfo.style.lineHeight}px`);
    if (Number.isFinite(textInfo.style.letterSpacing)) textStyles.push(`letter-spacing:${textInfo.style.letterSpacing}px`);
    if (textInfo.style.textAlign) textStyles.push(`text-align:${textInfo.style.textAlign}`);
    if (textInfo.style.color) textStyles.push(`color:${textInfo.style.color}`);

    dataAttrs.push(`data-layer-text-style="${escapeHtml(JSON.stringify(textInfo.style))}"`);
    if (textInfo.content) {
      dataAttrs.push(`data-layer-text="${escapeHtml(textInfo.content)}"`);
    }
    if (textInfo.runs && textInfo.runs.length > 1) {
      const buildRunCss = (runStyle = {}) => {
        const runStyles = [];
        if (runStyle.fontFamily || runStyle.fontPostscriptName) {
          const stack = buildFontFamilyStack
            ? buildFontFamilyStack(runStyle)
            : [runStyle.fontFamily || runStyle.fontPostscriptName];
          const fontStack = (stack || [])
            .map(value => String(value).replace(/'/g, "\\'"))
            .map(value => `'${value}'`)
            .join(", ");
          if (fontStack) runStyles.push(`font-family:${fontStack}`);
        }
        if (Number.isFinite(runStyle.fontSize)) runStyles.push(`font-size:${runStyle.fontSize}px`);
        if (runStyle.fontWeight) runStyles.push(`font-weight:${runStyle.fontWeight}`);
        if (runStyle.fontStyle) runStyles.push(`font-style:${runStyle.fontStyle}`);
        if (Number.isFinite(runStyle.lineHeight)) runStyles.push(`line-height:${runStyle.lineHeight}px`);
        if (Number.isFinite(runStyle.letterSpacing)) runStyles.push(`letter-spacing:${runStyle.letterSpacing}px`);
        if (runStyle.color) runStyles.push(`color:${runStyle.color}`);
        return runStyles.join(";");
      };
      const runContent = textInfo.runs
        .map(run => `<span style="${buildRunCss(run.style)}">${escapeHtml(run.text || "")}</span>`)
        .join("");
      contentParts.push(`<span class="text-content" style="${textStyles.join(";")}">${runContent}</span>`);
    } else {
      contentParts.push(`<span class="text-content" style="${textStyles.join(";")}">${escapeHtml(textInfo.content || "")}</span>`);
    }
  }

  const isFillOnlyChild = (child) => {
    if (!child) return false;
    if (child.type === "text") return false;
    const textChild = child.type === "text" || child.text || child.textContent || (child.textStyles && child.textStyles.length);
    if (textChild) return false;
    const childGradient = extractGradientInfo(child.fills);
    if (childGradient) return false;
    const childBorder = extractBorderInfo(child.borders);
    if (childBorder?.width) return false;
    const childShadow = shadowToCss(child.shadows);
    if (childShadow) return false;
    const childImage = extractImageInfo(child, ctx);
    if (childImage) return false;
    const childFill = styleHelpers.extractFillColor ? styleHelpers.extractFillColor(child.fills) : null;
    if (childFill) return true;
    return Array.isArray(child.fills) && child.fills.some(f => f?.type === "color" && f.color);
  };

  let childLayers = Array.isArray(layer.children)
    ? layer.children
    : (Array.isArray(layer.layers) ? layer.layers : []);
  if (!useNormalizedTree && !previewOnlyHierarchy && imageInfo && childLayers.length) {
    childLayers = childLayers.filter(child => {
      if (!isFillOnlyChild(child)) return true;
      const childFrame = toFrame(child.rect) || child.absoluteFrame || child.frame || null;
      const parentFrame = frame || { x: 0, y: 0, width: abs?.width, height: abs?.height };
      const contained = parentFrame && childFrame
        ? (
            childFrame.x >= 0 &&
            childFrame.y >= 0 &&
            (childFrame.x + (childFrame.width ?? 0)) <= (parentFrame.width ?? Infinity) &&
            (childFrame.y + (childFrame.height ?? 0)) <= (parentFrame.height ?? Infinity)
          )
        : true;
      return !contained;
    });
  }

  const childrenHtml = childLayers.length
    ? childLayers.map(child => renderLayerPreview(child, abs || parentAbs, origin, zIndexRef, ctx, depth + 1)).join("")
    : "";

  const constraintInfo = frame ? describeConstraints(frame, parentAbs) : null;
  const constraintDescription = constraintInfo ? `constraints: ${constraintInfo.entries.join(", ")}` : null;

  const descriptionParts = [];
  if (layer.name) descriptionParts.push(`Name: ${layer.name}`);
  if (layer.type) descriptionParts.push(`Type: ${layer.type}`);
  if (abs) {
    const coords = [abs.x, abs.y, abs.width, abs.height]
      .map(value => Number.isFinite(value) ? Math.round(value * 100) / 100 : value ?? "-")
      .join(",");
    descriptionParts.push(`frame: ${coords}`);
  }
  if (textInfo?.content) {
    const snippet = textInfo.content.length > 80
      ? `${textInfo.content.slice(0, 77)}...`
      : textInfo.content;
    descriptionParts.push(`Text: ${snippet}`);
  }
  if (textInfo?.style) {
    if (textInfo.style.fontFamily) descriptionParts.push(`font: ${textInfo.style.fontFamily}`);
    if (textInfo.style.fontName) descriptionParts.push(`fontName: ${textInfo.style.fontName}`);
    if (textInfo.style.fontSize) descriptionParts.push(`fontSize: ${textInfo.style.fontSize}px`);
    if (textInfo.style.fontWeight) descriptionParts.push(`fontWeight: ${textInfo.style.fontWeight}`);
    if (textInfo.style.lineHeight) {
      const lhValue = textInfo.style.lineHeight;
      const lhDisplay = typeof lhValue === "number" ? `${lhValue}px` : `${lhValue}`;
      descriptionParts.push(`lineHeight: ${lhDisplay}`);
    }
    if (textInfo.style.textAlign) descriptionParts.push(`textAlign: ${textInfo.style.textAlign}`);
    if (textInfo.style.colorName) descriptionParts.push(`colorName: ${textInfo.style.colorName}`);
    if (textInfo.style.color) descriptionParts.push(`color: ${textInfo.style.color}`);
  }
  if (shadow) descriptionParts.push(`shadow=${shadow}`);
  if (shouldDisplayOpacity(layer.opacity)) {
    const opacityDisplay = styleHelpers.formatNumeric
      ? styleHelpers.formatNumeric(layer.opacity)
      : String(layer.opacity);
    descriptionParts.push(`opacity=${opacityDisplay ?? layer.opacity}`);
  }
  if (shouldDisplayBorderRadius(layer.borderRadius)) descriptionParts.push(`borderRadius=${layer.borderRadius}`);
  if (borderInfo?.width !== undefined) descriptionParts.push(`borderWidth=${borderInfo.width}`);
  if (borderInfo?.colorName) {
    descriptionParts.push(`borderColor=${borderInfo.colorName}`);
  } else if (borderInfo?.colorHex) {
    descriptionParts.push(`borderColor=${borderInfo.colorHex}`);
  }
  if (fillInfo?.display) {
    descriptionParts.push(`fillColor=${fillInfo.display}`);
  }
  if (constraintDescription) descriptionParts.push(constraintDescription);
  if (gradientInfo?.description) {
    descriptionParts.push(`gradientColor: ${gradientInfo.description}`);
  }
  if (imageInfo?.assetName) descriptionParts.push(`imageName=${imageInfo.assetName}`);
  if (descriptionParts.length) {
    dataAttrs.push(`data-layer-desc="${escapeHtml(descriptionParts.join(" · "))}"`);
  }
  if (constraintInfo?.map && Object.keys(constraintInfo.map).length) {
    dataAttrs.push(`data-layer-constraints="${escapeHtml(JSON.stringify(constraintInfo.map))}"`);
  }

  const attrString = dataAttrs.join(" ");
  return `<div class="layer layer-${escapeHtml(layer.type || "unknown")}" ${attrString} style="${styles.join(";")}">${contentParts.join("")}${childrenHtml}</div>`;
};

export const renderDesignPreview = (layers, origin, bounds, options = {}) => {
  const zIndexRef = { value: 1 };
  const ctx = {
    assetLookup: options.assetLookup,
    styleHelpers: options.styleHelpers || {},
    previewOnlyHierarchy: options.previewOnlyHierarchy !== false,
    useNormalizedTree: options.useNormalizedTree === true
  };
  const width = Math.max(0, Math.round(bounds.maxX - bounds.minX));
  const height = Math.max(0, Math.round(bounds.maxY - bounds.minY));
  const rootParent = { x: origin.x ?? 0, y: origin.y ?? 0, width, height };
  const backgroundImage = options.backgroundImage || null;
  const backgroundFrame = options.backgroundImageFrame || null;
  let backgroundImageHtml = "";
  if (backgroundImage) {
    const frameX = Number.isFinite(backgroundFrame?.x) ? backgroundFrame.x : 0;
    const frameY = Number.isFinite(backgroundFrame?.y) ? backgroundFrame.y : 0;
    const frameWidth = Number.isFinite(backgroundFrame?.width) ? backgroundFrame.width : width;
    const frameHeight = Number.isFinite(backgroundFrame?.height) ? backgroundFrame.height : height;
    const left = frameX - (origin.x ?? 0);
    const top = frameY - (origin.y ?? 0);
    backgroundImageHtml = `<img class="screen-bg" src="${escapeHtml(backgroundImage)}" alt="" style="left:${left}px;top:${top}px;width:${frameWidth}px;height:auto;" data-frame-height="${frameHeight}" />`;
  }
  const body = (layers || []).map(layer => renderLayerPreview(layer, rootParent, origin, zIndexRef, ctx)).join("");
  const background = options.background ? `background:${options.background};` : "";
  return `<div class="preview"><div class="canvas" style="width:${width}px;height:${height}px;${background}">${backgroundImageHtml}${body}</div></div>`;
};


path: lib/assets.mjs
content: import https from "node:https";
import path from "node:path";
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import { ensureDir, sanitizeFileName, toPosixPath } from "./fsHelpers.mjs";

const MAX_DOWNLOAD_REDIRECTS = 5;

const assetFormatPriority = (format) => {
  const fmt = String(format || "").toLowerCase();
  switch (fmt) {
    case "svg": return 0;
    case "png": return 1;
    case "jpg":
    case "jpeg": return 2;
    case "webp": return 3;
    case "gif": return 4;
    case "pdf": return 5;
    default: return 10;
  }
};

const downloadToFile = (url, destination, redirectCount = 0) => new Promise((resolve, reject) => {
  if (!url) {
    reject(new Error("Missing URL for download"));
    return;
  }
  if (fs.existsSync(destination)) {
    resolve(destination);
    return;
  }

  const tempDestination = `${destination}.download`;
  const fileStream = fs.createWriteStream(tempDestination);

  const handleResponse = (res) => {
    const { statusCode, headers } = res;
    if (statusCode && statusCode >= 300 && statusCode < 400 && headers.location) {
      if (redirectCount >= MAX_DOWNLOAD_REDIRECTS) {
        reject(new Error(`Too many redirects for ${url}`));
        return;
      }
      res.destroy();
      fs.unlink(tempDestination, () => {
        downloadToFile(headers.location, destination, redirectCount + 1).then(resolve).catch(reject);
      });
      return;
    }

    if (statusCode && statusCode >= 400) {
      res.resume();
      fs.unlink(tempDestination, () => reject(new Error(`Request failed with status ${statusCode} for ${url}`)));
      return;
    }

    res.pipe(fileStream);
    fileStream.on("finish", () => {
      fileStream.close(() => {
        fs.rename(tempDestination, destination, (err) => {
          if (err) {
            reject(err);
            return;
          }
          resolve(destination);
        });
      });
    });
  };

  fileStream.on("error", (err) => {
    fs.unlink(tempDestination, () => reject(err));
  });

  try {
    https.get(url, handleResponse).on("error", (err) => {
      fs.unlink(tempDestination, () => reject(err));
    });
  } catch (err) {
    fs.unlink(tempDestination, () => reject(err));
  }
});

const pickAssetContent = (contents = []) => {
  const candidates = contents
    .filter(item => item && item.url)
    .map(item => ({
      url: item.url,
      format: item.format || item.type,
      density: typeof item.density === "number" ? item.density : 0
    }));
  if (!candidates.length) return null;
  candidates.sort((a, b) => {
    const priorityDiff = assetFormatPriority(a.format) - assetFormatPriority(b.format);
    if (priorityDiff !== 0) return priorityDiff;
    return (b.density ?? 0) - (a.density ?? 0);
  });
  return candidates[0];
};

const buildAssetFileSuffix = (asset, key) => {
  const raw = String(asset?.layerSourceId || asset?.layerId || asset?.id || key || "")
    .replace(/[^a-zA-Z0-9]/g, "")
    .toLowerCase();
  if (!raw) return "asset";
  return raw.slice(-12);
};

export const extractImageInfo = (layer, ctx = {}) => {
  if (!layer) return null;
  const assetLookup = ctx.assetLookup;
  const hasChildren = Array.isArray(layer.layers) && layer.layers.length > 0;

  const assetKey = layer.sourceId || layer.id;
  if (assetLookup && assetKey) {
    const asset = assetLookup.get(assetKey);
    if (asset) {
      const assetNameBase = asset.normalizedName || asset.layerName || asset.displayName || asset.layerSourceId || assetKey;
      const localPreferred = asset.localFiles?.[3] || asset.localFiles?.[2];
      if (localPreferred?.relPath) {
        const relPath = localPreferred.relPath.startsWith(".") ? localPreferred.relPath : `./${localPreferred.relPath}`;
        return {
          url: relPath,
          format: localPreferred.format,
          density: localPreferred === asset.localFiles?.[3] ? 3 : 2,
          source: "local",
          assetName: localPreferred.fileName || `${assetNameBase}@${localPreferred === asset.localFiles?.[3] ? 3 : 2}x.${localPreferred.format}`,
          originalUrl: localPreferred.sourceUrl,
          ...(localPreferred.imgUrl ? { imgUrl: localPreferred.imgUrl } : {})
        };
      }
      const picked = pickAssetContent(asset.contents || asset.images || []);
      if (picked?.url) {
        return {
          url: picked.url,
          format: picked.format,
          density: picked.density,
          source: "remote",
          assetName: assetNameBase
        };
      }
    }
  }

  const prefer = (url, format, extra = {}) => {
    if (!url) return null;
    return {
      url,
      format: format ? String(format).toLowerCase() : undefined,
      source: "remote",
      ...extra
    };
  };

  const directUrl = layer.imageUrl || layer.previewUrl;
  if (directUrl) return prefer(directUrl, layer.imageFormat || layer.format);

  if (layer.image?.url) {
    return prefer(layer.image.url, layer.image.format || layer.image.type);
  }

  if (Array.isArray(layer.images) && layer.images.length) {
    const item = layer.images.find(img => img?.url) || layer.images[0];
    if (item) return prefer(item.url, item.format || item.type);
  }

  const fills = layer.fills || [];
  for (const fill of fills) {
    if (fill?.image?.url) {
      return prefer(fill.image.url, fill.image.format || fill.image.type);
    }
    if (fill?.pattern?.image?.url) {
      return prefer(fill.pattern.image.url, fill.pattern.image.format || fill.pattern.image.type);
    }
    if (fill?.pattern?.url) {
      return prefer(fill.pattern.url, fill.pattern.format || fill.pattern.type);
    }
  }

  if (!hasChildren && assetLookup && assetKey) {
    const asset = assetLookup.get(assetKey);
    if (asset) {
      const assetNameBase = asset.normalizedName || asset.layerName || asset.displayName || asset.layerSourceId || assetKey;
      const picked = pickAssetContent(asset.contents || asset.images || []);
      if (picked?.url) {
        return {
          url: picked.url,
          format: picked.format,
          density: picked.density,
          source: "remote",
          assetName: assetNameBase
        };
      }
    }
  }

  return null;
};

export const buildAssetLookup = async (assets = [], options = {}) => {
  const lookup = new Map();
  const assetsDir = options.assetsDir;
  const workdir = options.workdir || process.cwd();
  const log = typeof options.log === "function" ? options.log : () => {};
  const verbose = options.verbose === true;

  if (assetsDir) ensureDir(assetsDir);

  for (const asset of assets || []) {
    if (!asset) continue;
    const key = asset.layerSourceId || asset.layerId || asset.id;
    if (!key) continue;

    if (lookup.has(key)) return;

    const record = {
      ...asset,
      localFiles: {},
      normalizedName: undefined
    };
    lookup.set(key, record);

    const uuid = randomUUID();
    const shouldReplaceName = (name) => {
      if (!name) return true;
      const trimmed = String(name).trim();
      if (!trimmed) return true;
      return trimmed.length < 3 || trimmed.toLowerCase() === "asset";
    };
    const pickSafeName = (...candidates) => {
      for (const candidate of candidates) {
        const sanitized = sanitizeFileName(candidate);
        if (!shouldReplaceName(sanitized)) return sanitized;
      }
      return sanitizeFileName(uuid);
    };

    const preferredNameRaw = asset.layerName || asset.displayName || asset.layerSourceId || asset.layerId || asset.id || key;
    record.normalizedName = pickSafeName(preferredNameRaw, asset.layerSourceId || asset.layerId || asset.id, `asset_${key}`, uuid);
    const assetSuffix = buildAssetFileSuffix(asset, key);

    const contents = Array.isArray(asset.contents) ? asset.contents : Array.isArray(asset.images) ? asset.images : [];
    const preferredContents = [...contents].sort((a, b) => Number(b?.density || 0) - Number(a?.density || 0));
    for (const content of preferredContents) {
      const density = Number(content?.density);
      if (![2, 3].includes(density)) continue;
      if (!content?.url) continue;

      const formatRaw = content.format || content.type;
      const format = formatRaw ? String(formatRaw).toLowerCase() : "png";
      if (format !== "png") continue;
      const baseName = pickSafeName(asset.layerName || asset.displayName, asset.layerSourceId || asset.layerId || asset.id, record.normalizedName, `asset_${key}`, uuid);
      if (!record.normalizedName || record.normalizedName.toLowerCase() === "asset") {
        record.normalizedName = baseName;
      }
      const fileName = sanitizeFileName(`${baseName}_${assetSuffix}@${density}x.${format}`);
      const destination = assetsDir ? path.join(assetsDir, fileName) : path.join(workdir, fileName);

      try {
        await downloadToFile(content.url, destination);
        if (verbose) log(`  -> Downloaded asset density=${density} -> ${destination}`);
        const relPath = toPosixPath(path.relative(workdir, destination));
        record.localFiles[density] = {
          relPath,
          absPath: destination,
          format,
          sourceUrl: content.url,
          fileName
        };
      } catch (err) {
        log(`  Warning: failed to download asset density=${density} url=${content.url}: ${err?.message || err}`);
      }
    }

  }

  return lookup;
};

export {
  MAX_DOWNLOAD_REDIRECTS,
  pickAssetContent
};


path: lib/renderHtml.mjs
content: // Re-exports to keep the public interface unchanged.
export { escapeHtml, renderTreeHTML } from "./renderTree.mjs";
export { renderDesignPreview } from "./renderLayer.mjs";
export { makeTreeHTMLDocument } from "./htmlDocument.mjs";


path: lib/renderTree.mjs
content: export const escapeHtml = (text = "") => String(text)
  .replace(/&/g, "&amp;")
  .replace(/</g, "&lt;")
  .replace(/>/g, "&gt;")
  .replace(/"/g, "&quot;")
  .replace(/'/g, "&#39;");

export const renderTreeHTML = (nodes, styleHelpers = {}) => {
  const {
    formatNumeric = (value) => Number.isFinite(value) ? String(value) : null,
    shouldDisplayBorderRadius = () => false,
    shouldDisplayOpacity = () => false
  } = styleHelpers;
  const renderNode = (node, path = []) => {
    const rel = node.frame ? `(${node.frame.x}, ${node.frame.y}, ${node.frame.width}, ${node.frame.height})` : "(none)";
    const metaParts = [
      `type=${escapeHtml(node.type || "")}`
    ];
    if (node.rotation) metaParts.push(`rotation=${node.rotation}`);
    if (shouldDisplayOpacity(node.opacity)) {
      const opacityDisplay = formatNumeric(node.opacity);
      metaParts.push(`opacity=${opacityDisplay ?? node.opacity}`);
    }
    if (node.shadow) metaParts.push(`shadow=${escapeHtml(node.shadow)}`);
    if (shouldDisplayBorderRadius(node.borderRadius)) metaParts.push(`borderRadius=${node.borderRadius}`);
    if (node.borderWidth !== undefined) metaParts.push(`borderWidth=${node.borderWidth}`);
    if (node.borderColorName) {
      metaParts.push(`borderColorName=${escapeHtml(node.borderColorName)}`);
    } else if (node.borderColor) {
      metaParts.push(`borderColor=${escapeHtml(node.borderColor)}`);
    }
    if (node.fillColorName) {
      metaParts.push(`fillColorName=${escapeHtml(node.fillColorName)}`);
    } else if (node.fillColorHex) {
      metaParts.push(`fillColor=${escapeHtml(node.fillColorHex)}`);
    } else if (node.fillColor) {
      metaParts.push(`fillColor=${escapeHtml(node.fillColor)}`);
    }
    if (node.constraints && Object.keys(node.constraints).length) {
      const constraintPairs = Object.entries(node.constraints).map(([key, value]) => {
        if (typeof value === "number") {
          const display = formatNumeric(value);
          return `${escapeHtml(key)}=${escapeHtml(display ?? String(value))}`;
        }
        return `${escapeHtml(key)}=${escapeHtml(String(value))}`;
      });
      if (constraintPairs.length) metaParts.push(`constraints:${constraintPairs.join(", ")}`);
    }
    if (node.textContent) {
      const textSnippet = node.textContent.length > 100
        ? `${node.textContent.slice(0, 97)}...`
        : node.textContent;
      metaParts.push(`text="${escapeHtml(textSnippet)}"`);
    }
    if (node.textStyle) {
      if (node.textStyle.fontFamily) metaParts.push(`font=${escapeHtml(node.textStyle.fontFamily)}`);
      if (node.textStyle.fontName) metaParts.push(`fontName=${escapeHtml(node.textStyle.fontName)}`);
      if (node.textStyle.fontSize) metaParts.push(`size=${node.textStyle.fontSize}px`);
      if (node.textStyle.fontWeight) metaParts.push(`weight=${escapeHtml(node.textStyle.fontWeight)}`);
      if (node.textStyle.lineHeight) {
        const lh = node.textStyle.lineHeight;
        const lhDisplay = typeof lh === "number" ? `${lh}px` : String(lh);
        metaParts.push(`lineHeight=${escapeHtml(lhDisplay)}`);
      }
      if (node.textStyle.textAlign) metaParts.push(`textAlign=${escapeHtml(node.textStyle.textAlign)}`);
      if (node.textStyle.colorName) metaParts.push(`colorName=${escapeHtml(node.textStyle.colorName)}`);
      if (node.textStyle.color) metaParts.push(`color=${escapeHtml(node.textStyle.color)}`);
    }
    if (node.image) {
      if (node.image.assetName) {
        metaParts.push(`imageName=${escapeHtml(node.image.assetName)}`);
      }
    }
    if (node.gradientColor) {
      metaParts.push(`gradientColor=${escapeHtml(node.gradientColor)}`);
    } else if (node.gradient?.description) {
      metaParts.push(`gradientColor=${escapeHtml(node.gradient.description)}`);
    }
    const meta = metaParts.join(" · ");
    const indentDots = path.length ? `${"• ".repeat(path.length)}` : "";
    const summary = `${indentDots}${escapeHtml(node.name || node.id || "")} ${meta}` +
      ` · frame=${rel}`;
    const childList = Array.isArray(node.children) ? node.children : [];
    const children = childList.length
      ? `<ul>${childList.map((child, index) => renderNode(child, path.concat(index + 1))).join("")}</ul>`
      : "";
    const descId = escapeHtml(node.generatedId || node.descId || node.id || "");
    const originalId = escapeHtml(node.id || "");
    const idAttrParts = [];
    if (descId) idAttrParts.push(`data-layer-desc-id="${descId}"`, `id="desc-${descId}"`);
    if (originalId) idAttrParts.push(`data-layer-original-id="${originalId}"`);
    if (path.length) idAttrParts.push(`data-layer-path="${path.join(".")}"`);
    const idAttr = idAttrParts.join(" ");
    return `<li class="layer-desc" ${idAttr}><details open><summary>${summary}</summary>${children}</details></li>`;
  };
  const rootList = Array.isArray(nodes) ? nodes : [];
  return `<ul class="tree">${rootList.map((node, index) => renderNode(node, [index + 1])).join("")}</ul>`;
};


path: lib/logger.mjs
content: import fs from "node:fs";

const stamp = () => new Date().toISOString().replace("T", " ").replace("Z", "");

export const createLogger = (quiet = false) => {
  const logFilePath = process.env.ZEPLIN_LOG_FILE || process.env.LOG_FILE || process.env.log_file;
  let fileStream = null;
  const writeToFile = (line) => {
    if (!logFilePath) return;
    try {
      if (!fileStream) {
        fileStream = fs.createWriteStream(logFilePath, { flags: "a" });
      }
      fileStream.write(line + "\n");
    } catch {
      // ignore file logging errors
    }
  };

  return (...args) => {
    const line = `[${stamp()}] ${args.map(arg => {
      if (typeof arg === "string") return arg;
      try {
        return JSON.stringify(arg);
      } catch {
        return String(arg);
      }
    }).join(" ")}`;
    if (!quiet) console.log(line);
    writeToFile(line);
  };
};

export { stamp };


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)