Initial implementation

This commit is contained in:
David Langley 2026-03-14 10:03:10 +00:00
parent d4fb08b392
commit bd1981fea5
15 changed files with 1154 additions and 0 deletions

1
.devcontainer/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.runtime/

View File

@ -0,0 +1,43 @@
{
"name": "element-web designer prototyping",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
"postCreateCommand": "corepack enable && pnpm install --frozen-lockfile",
"postAttachCommand": "pnpm design:start",
"forwardPorts": [6007],
"portsAttributes": {
"6007": {
"label": "Shared Components Storybook",
"onAutoForward": "openBrowser",
"requireLocalPort": false
}
},
"customizations": {
"vscode": {
"settings": {
"chat.mcp.autoStart": true,
"workbench.startupEditor": "none",
"workbench.tips.enabled": false,
"workbench.welcome.enabled": false,
"github.copilot.chat.openAtStartup": true
},
"extensions": [
"GitHub.copilot",
"GitHub.copilot-chat"
],
"mcp": {
"servers": {
"element-web-figma": {
"command": "node",
"args": [
"scripts/design/figma-mcp-server.mjs"
],
"env": {
"FIGMA_TOKEN": "${env:FIGMA_TOKEN}",
"FIGMA_FILE": "${env:FIGMA_FILE}"
}
}
}
}
}
}
}

View File

@ -0,0 +1,57 @@
# AI prototyping in Codespaces
This workflow gives designers a no-local-setup path for turning a Figma file into Storybook prototypes inside this repository.
## 1. Create a Figma API token
1. Open Figma and go to Settings.
2. Create a personal access token.
3. Copy the token value once and keep it private.
## 2. Add Codespaces user secrets
Create these user secrets in GitHub Codespaces before launching the workspace:
- `FIGMA_TOKEN`: your Figma personal access token.
- `FIGMA_FILE`: the Figma file key from the file URL.
The repository reads both values from environment variables. They are not stored in source control.
## 3. Open the Codespace
1. Open this repository in GitHub Codespaces.
2. Wait for the dev container to finish installing dependencies with `pnpm`.
3. Storybook starts automatically in the existing shared-components package and forwards port `6007` for preview.
4. GitHub Copilot Chat auto-registers the workspace Figma MCP server from the dev container configuration.
## 4. Verify the Figma connection
Run this command in the Codespace terminal:
```bash
pnpm figma:connect
```
It validates your token, fetches file metadata, and prints the available frames and components that Copilot can target.
## 5. Generate prototypes with Copilot
Prototype stories live under `packages/shared-components/src/prototypes/ai` and are already included in the current Storybook story glob.
Useful prompt pattern:
```text
Use the Figma MCP tools to inspect frame 12:34 from the current FIGMA_FILE.
Create or update a Storybook story in packages/shared-components/src/prototypes/ai that recreates the layout with existing shared-components first, then Compound Web primitives where needed.
Keep the story title under AI Prototypes.
```
## 6. See prototypes in Storybook
New or updated `*.stories.tsx` files under `packages/shared-components/src/prototypes/ai` appear automatically in Storybook under the `AI Prototypes` section.
Use the guidance files in this directory to help Copilot stay aligned with the existing component system:
- `component-mapping.md`
- `design-tokens.md`
- `storybook-guidelines.md`

View File

@ -0,0 +1,31 @@
# Design tokens
The shared-components package is styled with Compound design tokens. Prototype stories should use those token variables instead of hard-coded colors, spacing, or typography.
## Spacing
- Use `var(--cpd-space-1x)` through `var(--cpd-space-8x)` for gaps, padding, and radii.
- Common patterns in this repository use `var(--cpd-space-2x)` to `var(--cpd-space-5x)`.
## Typography
- Use font shorthands such as `var(--cpd-font-body-md-regular)`, `var(--cpd-font-body-md-semibold)`, and `var(--cpd-font-heading-sm-semibold)`.
- Use text colors like `var(--cpd-color-text-primary)` and `var(--cpd-color-text-secondary)`.
## Surfaces and borders
- Default panel background: `var(--cpd-color-bg-canvas-default)`.
- Subtle surface background: `var(--cpd-color-bg-subtle-secondary)` or `var(--cpd-color-bg-subtle-primary)`.
- Borders: `var(--cpd-color-border-interactive-secondary)` for neutral framing.
## Actions and accents
- Accent backgrounds: `var(--cpd-color-bg-accent-rest)`.
- On-accent text: `var(--cpd-color-text-on-solid-primary)`.
- Links and emphasis should stay within the token palette rather than introducing custom brand colors.
## Guidance
- Prefer token-driven CSS modules or inline styles using `var(--cpd-...)`.
- Avoid raw hex values unless the underlying component API requires them.
- Match existing shared-components visuals before inventing new theme primitives in a prototype.

View File

@ -0,0 +1,30 @@
# Storybook guidelines
Follow these rules when generating prototype stories for this repository.
## Placement
- Put experimental prototype stories in `packages/shared-components/src/prototypes/ai`.
- Keep the existing Storybook instance. Do not add another `.storybook` directory or another Storybook package.
## Naming
- Use filenames that end in `.stories.tsx` so the current Storybook config picks them up.
- Keep story titles under the `AI Prototypes/` namespace.
## Composition
- Import existing components from the local shared-components package first.
- Use `@vector-im/compound-web` only when the package does not already expose an equivalent higher-level component.
- Prefer a small number of well-named stories over one huge catch-all story.
## Prototype quality bar
- Mirror the Figma frame hierarchy with readable sections.
- Keep layout tokens explicit so Copilot can iterate predictably.
- Make prototype-only assumptions obvious inside the story content, not in production components.
## Promotion path
- If a prototype becomes product code, move it out of `src/prototypes/ai` and into the relevant component folder.
- Replace exploratory mock content with typed props or view-model-backed stories before promotion.

View File

@ -13,6 +13,10 @@
"i18n": "pnpm -r i18n",
"i18n:sort": "pnpm -r i18n:sort",
"i18n:lint": "pnpm -r i18n:lint",
"storybook:design": "node scripts/design/run-storybook.mjs",
"design:start": "node scripts/design/start-design-environment.mjs",
"figma:connect": "node scripts/design/figma-connect.mjs",
"figma:mcp": "node scripts/design/figma-mcp-server.mjs",
"lint": "pnpm -r lint:types && pnpm lint:prettier && pnpm -r lint:js && pnpm -r lint:style && pnpm lint:workflows && pnpm lint:knip",
"lint:prettier": "prettier --check .",
"lint:prettier-fix": "prettier --log-level=warn --write .",

View File

@ -0,0 +1,230 @@
/*
* Designer Setup Guide AI Prototyping Environment
* All sizing and colour values use Compound design tokens exclusively.
*/
/* ─── Page canvas ─────────────────────────────────────────────────────────── */
.canvas {
background: var(--cpd-color-bg-canvas-default);
display: flex;
justify-content: center;
padding: var(--cpd-space-14x) var(--cpd-space-6x);
box-sizing: border-box;
}
/* ─── Content surface ─────────────────────────────────────────────────────── */
.surface {
width: 100%;
max-width: 720px;
}
/* ─── Header ──────────────────────────────────────────────────────────────── */
.header {
padding-bottom: var(--cpd-space-8x);
}
.badge {
display: inline-block;
padding: var(--cpd-space-1x) var(--cpd-space-3x);
border-radius: 999px;
background: color-mix(in srgb, var(--cpd-color-icon-accent-primary) 12%, transparent);
color: var(--cpd-color-icon-accent-primary);
font: var(--cpd-font-body-xs-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.headline {
margin: var(--cpd-space-4x) 0 0;
color: var(--cpd-color-text-primary);
font: var(--cpd-font-heading-xl-semibold);
}
.lead {
margin: var(--cpd-space-3x) 0 0;
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-lg-regular);
line-height: 1.6;
max-width: 60ch;
}
/* ─── Divider ─────────────────────────────────────────────────────────────── */
.divider {
border: none;
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
margin: 0 0 var(--cpd-space-8x);
}
/* ─── Steps container ─────────────────────────────────────────────────────── */
.steps {
display: flex;
flex-direction: column;
}
/* ─── Individual step ─────────────────────────────────────────────────────── */
.step {
display: flex;
align-items: stretch;
gap: var(--cpd-space-5x);
}
/* Left rail: badge + connector line */
.stepAside {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 36px;
padding-top: var(--cpd-space-1x);
}
.stepBadge {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--cpd-color-icon-accent-primary);
color: #fff;
font: var(--cpd-font-body-md-semibold);
line-height: 1;
}
.stepLine {
flex: 1;
width: 2px;
margin: var(--cpd-space-2x) 0;
min-height: var(--cpd-space-6x);
background: var(--cpd-color-border-interactive-secondary);
border-radius: 1px;
}
/* Step content */
.stepBody {
flex: 1;
padding-bottom: var(--cpd-space-10x);
}
.stepTitle {
margin: var(--cpd-space-1x) 0 0;
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-lg-semibold);
}
.stepDesc {
margin: var(--cpd-space-2x) 0 0;
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-md-regular);
line-height: 1.6;
max-width: 58ch;
}
/* ─── Code block ──────────────────────────────────────────────────────────── */
.code {
margin: var(--cpd-space-4x) 0 0;
padding: var(--cpd-space-4x) var(--cpd-space-5x);
border-radius: var(--cpd-space-3x);
background: #0d1117;
color: #c9d1d9;
font-family: ui-monospace, "SFMono-Regular", "Fira Code", "Cascadia Code", monospace;
font-size: 0.8125rem;
line-height: 1.7;
white-space: pre;
overflow-x: auto;
}
/* ─── Prompt block ────────────────────────────────────────────────────────── */
.prompt {
margin: var(--cpd-space-4x) 0 0;
padding: var(--cpd-space-4x) var(--cpd-space-5x);
border-radius: var(--cpd-space-3x);
border-left: 3px solid var(--cpd-color-icon-accent-primary);
background: color-mix(in srgb, var(--cpd-color-icon-accent-primary) 6%, var(--cpd-color-bg-canvas-default));
}
.promptLabel {
display: block;
color: var(--cpd-color-icon-accent-primary);
font: var(--cpd-font-body-xs-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: var(--cpd-space-2x);
}
.promptText {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", "Fira Code", "Cascadia Code", monospace;
font-size: 0.8125rem;
color: var(--cpd-color-text-primary);
line-height: 1.65;
}
/* ─── Step footer: action + hint ──────────────────────────────────────────── */
.stepFooter {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--cpd-space-3x) var(--cpd-space-5x);
margin-top: var(--cpd-space-4x);
}
.hint {
margin: 0;
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-regular);
font-style: italic;
max-width: 50ch;
}
/* ─── Footer ──────────────────────────────────────────────────────────────── */
.footer {
padding-top: var(--cpd-space-6x);
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
margin-top: var(--cpd-space-2x);
}
.footerText {
margin: 0;
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-regular);
line-height: 1.6;
}
.inlineCode {
padding: 0.1em 0.35em;
border-radius: 4px;
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-text-primary);
font-family: ui-monospace, "SFMono-Regular", monospace;
font-size: 0.85em;
}
/* ─── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.canvas {
padding: var(--cpd-space-8x) var(--cpd-space-4x);
}
.headline {
font: var(--cpd-font-heading-lg-semibold);
}
.lead {
font: var(--cpd-font-body-md-regular);
}
}

View File

@ -0,0 +1,162 @@
/*
* Copyright 2026 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import styles from "./FigmaPrototypeSandbox.module.css";
type StepData = {
number: number;
title: string;
description: string;
code?: string;
prompt?: string;
action?: { label: string; url: string };
hint?: string;
};
const SETUP_STEPS: StepData[] = [
{
number: 1,
title: "Get your Figma access token",
description:
"Create a personal access token so Copilot can read your Figma files. In Figma: open the main menu \u2192 Account settings \u2192 Security \u2192 Personal access tokens \u2192 Generate new token.",
action: {
label: "Open Figma account settings",
url: "https://www.figma.com/settings",
},
hint: "Copy the token immediately \u2014 Figma only shows it once.",
},
{
number: 2,
title: "Store your secrets in this Codespace",
description:
"Add two Codespace secrets so the Figma MCP tools are available automatically every time this environment starts. Navigate to GitHub \u2192 Settings \u2192 Codespaces \u2192 Secrets, then add each secret and allow this repository.",
code: "FIGMA_TOKEN \u2014 your personal access token\nFIGMA_FILE \u2014 the file ID from the Figma URL",
action: {
label: "Open Codespaces secrets",
url: "https://github.com/settings/codespaces",
},
hint: "The file ID is the alphanumeric string between /design/ and the next / in a Figma share URL.",
},
{
number: 3,
title: "Verify the connection",
description:
"Run the validation command in the integrated terminal. A successful run lists your file\u2019s pages and top-level components, confirming Copilot can reach Figma.",
code: "pnpm figma:connect",
hint: "Press Ctrl+\` (backtick) to open the terminal if it is not already visible at the bottom of the screen.",
},
{
number: 4,
title: "Ask Copilot to build a prototype",
description:
"Open Copilot Chat (\u2318\u21e7I on Mac \u00b7 Ctrl+Shift+I on Windows/Linux), switch to Agent mode, then describe the frame you want to translate into a story.",
prompt:
"Use get_figma_file to inspect my design file, then create a new Storybook story under AI Prototypes that recreates the layout with shared-components.",
hint: "Your story appears in this Storybook panel under AI Prototypes the moment Copilot saves the file \u2014 no commit needed.",
},
];
function CodeBlock({ children }: { children: string }): JSX.Element {
return (
<pre className={styles.code}>
<code>{children}</code>
</pre>
);
}
function PromptBlock({ children }: { children: string }): JSX.Element {
return (
<div className={styles.prompt}>
<span className={styles.promptLabel}>Prompt for Copilot Chat</span>
<p className={styles.promptText}>{children}</p>
</div>
);
}
function StepCard({ step, isLast }: { step: StepData; isLast: boolean }): JSX.Element {
return (
<div className={styles.step}>
<div className={styles.stepAside}>
<span className={styles.stepBadge}>{step.number}</span>
{!isLast && <span className={styles.stepLine} />}
</div>
<div className={styles.stepBody}>
<h3 className={styles.stepTitle}>{step.title}</h3>
<p className={styles.stepDesc}>{step.description}</p>
{step.code && <CodeBlock>{step.code}</CodeBlock>}
{step.prompt && <PromptBlock>{step.prompt}</PromptBlock>}
<div className={styles.stepFooter}>
{step.action && (
<Button
as="a"
href={step.action.url}
target="_blank"
rel="noopener noreferrer"
kind="secondary"
size="sm"
>
{step.action.label}
</Button>
)}
{step.hint && <p className={styles.hint}>{step.hint}</p>}
</div>
</div>
</div>
);
}
function DesignerSetupGuide(): JSX.Element {
return (
<div className={styles.canvas}>
<div className={styles.surface}>
<header className={styles.header}>
<span className={styles.badge}>AI Prototyping Environment</span>
<h1 className={styles.headline}>Welcome lets get you set up</h1>
<p className={styles.lead}>
This Codespace connects Figma to a live Storybook via Copilot Chat. Follow the four steps below
and you will be translating designs into interactive component stories in minutes no engineering
background required.
</p>
</header>
<hr className={styles.divider} />
<div className={styles.steps}>
{SETUP_STEPS.map((step, i) => (
<StepCard key={step.number} step={step} isLast={i === SETUP_STEPS.length - 1} />
))}
</div>
<footer className={styles.footer}>
<p className={styles.footerText}>
Generated stories live in{" "}
<code className={styles.inlineCode}>packages/shared-components/src/prototypes/ai/</code>. See{" "}
<code className={styles.inlineCode}>docs/ai-prototyping/</code> for the full designer guide.
</p>
</footer>
</div>
</div>
);
}
const meta = {
title: "AI Prototypes/Get Started",
component: DesignerSetupGuide,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
} satisfies Meta<typeof DesignerSetupGuide>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SetupGuide: Story = {};

View File

@ -0,0 +1,8 @@
# AI Prototypes
Keep AI-generated Storybook experiments in this directory so they are picked up by the existing story glob without mixing them into production component stories.
- Add new `*.stories.tsx` files here or in subfolders beneath this directory.
- Keep titles under the `AI Prototypes/` namespace.
- Prefer composing from `@element-hq/web-shared-components` and `@vector-im/compound-web` instead of adding one-off primitives.
- Treat these stories as disposable prototypes unless they are promoted into a production component folder.

View File

@ -0,0 +1,230 @@
const FIGMA_API_ROOT = "https://api.figma.com/v1";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
async function figmaRequest(pathname, searchParams) {
const token = requireEnv("FIGMA_TOKEN");
const url = new URL(`${FIGMA_API_ROOT}${pathname}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
const response = await fetch(url, {
headers: {
"X-Figma-Token": token,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Figma API request failed (${response.status} ${response.statusText}): ${text.slice(0, 400)}`);
}
return response.json();
}
function compactRecord(entries) {
return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined && value !== null));
}
function simplifyPaint(paint) {
if (!paint || paint.visible === false) {
return undefined;
}
return compactRecord({
type: paint.type,
opacity: paint.opacity,
color: paint.color
? {
r: Number(paint.color.r.toFixed(4)),
g: Number(paint.color.g.toFixed(4)),
b: Number(paint.color.b.toFixed(4)),
}
: undefined,
blendMode: paint.blendMode,
});
}
function simplifyEffect(effect) {
if (!effect || effect.visible === false) {
return undefined;
}
return compactRecord({
type: effect.type,
radius: effect.radius,
spread: effect.spread,
offset: effect.offset,
color: effect.color,
});
}
export function simplifyNode(node, depth = 2) {
if (!node) {
return undefined;
}
const children = depth > 0 ? node.children?.map((child) => simplifyNode(child, depth - 1)).filter(Boolean) : undefined;
const fills = node.fills?.map(simplifyPaint).filter(Boolean);
const strokes = node.strokes?.map(simplifyPaint).filter(Boolean);
const effects = node.effects?.map(simplifyEffect).filter(Boolean);
return compactRecord({
id: node.id,
name: node.name,
type: node.type,
visible: node.visible,
componentId: node.componentId,
componentSetId: node.componentSetId,
description: node.description,
size: node.absoluteBoundingBox
? {
x: Number(node.absoluteBoundingBox.x.toFixed(2)),
y: Number(node.absoluteBoundingBox.y.toFixed(2)),
width: Number(node.absoluteBoundingBox.width.toFixed(2)),
height: Number(node.absoluteBoundingBox.height.toFixed(2)),
}
: undefined,
layout: compactRecord({
layoutMode: node.layoutMode,
layoutWrap: node.layoutWrap,
primaryAxisAlignItems: node.primaryAxisAlignItems,
counterAxisAlignItems: node.counterAxisAlignItems,
primaryAxisSizingMode: node.primaryAxisSizingMode,
counterAxisSizingMode: node.counterAxisSizingMode,
itemSpacing: node.itemSpacing,
paddingTop: node.paddingTop,
paddingRight: node.paddingRight,
paddingBottom: node.paddingBottom,
paddingLeft: node.paddingLeft,
layoutSizingHorizontal: node.layoutSizingHorizontal,
layoutSizingVertical: node.layoutSizingVertical,
layoutGrow: node.layoutGrow,
layoutAlign: node.layoutAlign,
}),
constraints: node.constraints,
borderRadius: node.cornerRadius,
individualStrokeWeights: node.individualStrokeWeights,
fills: fills?.length ? fills : undefined,
strokes: strokes?.length ? strokes : undefined,
effects: effects?.length ? effects : undefined,
styles: node.styles,
text:
node.characters || node.style
? compactRecord({
characters: node.characters ? node.characters.slice(0, 300) : undefined,
style: node.style
? compactRecord({
fontFamily: node.style.fontFamily,
fontWeight: node.style.fontWeight,
fontSize: node.style.fontSize,
lineHeightPx: node.style.lineHeightPx,
letterSpacing: node.style.letterSpacing,
textAlignHorizontal: node.style.textAlignHorizontal,
textAlignVertical: node.style.textAlignVertical,
textCase: node.style.textCase,
textDecoration: node.style.textDecoration,
})
: undefined,
})
: undefined,
children: children?.length ? children : undefined,
});
}
function collectNodesByType(node, acceptedTypes, trail = []) {
if (!node) {
return [];
}
const path = [...trail, node.name].filter(Boolean);
const own = acceptedTypes.has(node.type)
? [
compactRecord({
id: node.id,
name: node.name,
type: node.type,
path: path.join(" / "),
}),
]
: [];
const descendants = node.children?.flatMap((child) => collectNodesByType(child, acceptedTypes, path)) ?? [];
return [...own, ...descendants];
}
export async function getFigmaMe() {
return figmaRequest("/me");
}
export async function getFigmaFile() {
const fileKey = requireEnv("FIGMA_FILE");
const file = await figmaRequest(`/files/${fileKey}`);
return {
key: fileKey,
name: file.name,
version: file.version,
lastModified: file.lastModified,
thumbnailUrl: file.thumbnailUrl,
role: file.role,
pages: file.document?.children?.map((page) => simplifyNode(page, 1)) ?? [],
frames: collectNodesByType(file.document, new Set(["FRAME", "SECTION", "COMPONENT", "COMPONENT_SET"])),
};
}
export async function getFigmaNode(nodeId, depth = 3) {
const fileKey = requireEnv("FIGMA_FILE");
const response = await figmaRequest(`/files/${fileKey}/nodes`, {
ids: nodeId,
depth,
});
const document = response.nodes?.[nodeId]?.document;
if (!document) {
throw new Error(`Node ${nodeId} was not found in file ${fileKey}.`);
}
return {
fileKey,
nodeId,
node: simplifyNode(document, depth),
};
}
export async function getFigmaComponents(limit) {
const fileKey = requireEnv("FIGMA_FILE");
const response = await figmaRequest(`/files/${fileKey}/components`);
const components = Object.values(response.meta?.components ?? {})
.map((component) =>
compactRecord({
key: component.key,
name: component.name,
description: component.description,
nodeId: component.node_id,
pageId: component.containing_frame?.pageId,
pageName: component.containing_frame?.pageName,
containingFrame: component.containing_frame?.name,
}),
)
.sort((left, right) => left.name.localeCompare(right.name));
return {
fileKey,
count: components.length,
components: typeof limit === "number" ? components.slice(0, limit) : components,
};
}

View File

@ -0,0 +1,32 @@
import { getFigmaComponents, getFigmaFile, getFigmaMe } from "./figma-api.mjs";
function printSection(title) {
console.log(`\n${title}`);
console.log("-".repeat(title.length));
}
try {
const [me, file, components] = await Promise.all([getFigmaMe(), getFigmaFile(), getFigmaComponents(20)]);
printSection("Figma authentication");
console.log(`User: ${me.email ?? me.handle ?? me.id}`);
printSection("Selected file");
console.log(`Name: ${file.name}`);
console.log(`Key: ${file.key}`);
console.log(`Version: ${file.version}`);
console.log(`Last modified: ${file.lastModified}`);
printSection("Available frames and sections");
for (const frame of file.frames.slice(0, 25)) {
console.log(`${frame.type.padEnd(14)} ${frame.id.padEnd(14)} ${frame.path}`);
}
printSection("Available components");
for (const component of components.components) {
console.log(`${component.nodeId.padEnd(14)} ${component.name}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}

View File

@ -0,0 +1,171 @@
import { getFigmaComponents, getFigmaFile, getFigmaNode } from "./figma-api.mjs";
const tools = [
{
name: "get_figma_file",
description: "Fetch the active Figma file and return a simplified page and frame outline.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
{
name: "get_figma_node",
description: "Fetch a specific Figma node by id and return a simplified layout tree.",
inputSchema: {
type: "object",
properties: {
nodeId: {
type: "string",
description: "The Figma node id to fetch, for example 12:34.",
},
depth: {
type: "integer",
minimum: 1,
maximum: 6,
description: "How many nested child levels to include. Defaults to 3.",
},
},
required: ["nodeId"],
additionalProperties: false,
},
},
{
name: "get_figma_components",
description: "List components defined in the active Figma file.",
inputSchema: {
type: "object",
properties: {
limit: {
type: "integer",
minimum: 1,
maximum: 200,
description: "Maximum number of components to return.",
},
},
additionalProperties: false,
},
},
];
const serverInfo = {
name: "element-web-figma",
version: "0.1.0",
};
function sendMessage(message) {
const json = JSON.stringify(message);
process.stdout.write(`Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`);
}
function sendResult(id, result) {
sendMessage({ jsonrpc: "2.0", id, result });
}
function sendError(id, code, message) {
sendMessage({
jsonrpc: "2.0",
id,
error: {
code,
message,
},
});
}
async function callTool(name, arguments_) {
switch (name) {
case "get_figma_file":
return getFigmaFile();
case "get_figma_node":
return getFigmaNode(arguments_?.nodeId, arguments_?.depth ?? 3);
case "get_figma_components":
return getFigmaComponents(arguments_?.limit);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message) {
if (!message || typeof message !== "object") {
return;
}
const { id, method, params } = message;
try {
switch (method) {
case "initialize":
sendResult(id, {
protocolVersion: params?.protocolVersion ?? "2024-11-05",
capabilities: {
tools: {},
},
serverInfo,
});
return;
case "notifications/initialized":
return;
case "ping":
sendResult(id, {});
return;
case "tools/list":
sendResult(id, { tools });
return;
case "tools/call": {
const result = await callTool(params?.name, params?.arguments ?? {});
sendResult(id, {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
structuredContent: result,
});
return;
}
default:
sendError(id, -32601, `Method not found: ${method}`);
}
} catch (error) {
sendError(id, -32000, error instanceof Error ? error.message : String(error));
}
}
let buffer = Buffer.alloc(0);
process.stdin.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
return;
}
const headerText = buffer.subarray(0, headerEnd).toString("utf8");
const contentLengthHeader = headerText
.split("\r\n")
.map((line) => line.split(":", 2))
.find(([name]) => name.toLowerCase() === "content-length");
if (!contentLengthHeader) {
buffer = buffer.subarray(headerEnd + 4);
continue;
}
const contentLength = Number.parseInt(contentLengthHeader[1].trim(), 10);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (buffer.length < messageEnd) {
return;
}
const body = buffer.subarray(messageStart, messageEnd).toString("utf8");
buffer = buffer.subarray(messageEnd);
handleMessage(JSON.parse(body));
}
});

View File

@ -0,0 +1,21 @@
import { spawn } from "node:child_process";
import { findSinglePackageByScript, repoRoot } from "./workspace-utils.mjs";
const storybookPackage = findSinglePackageByScript("storybook");
console.log(`Starting Storybook from ${storybookPackage.name} (${storybookPackage.relativeDir}).`);
const child = spawn("pnpm", ["--filter", storybookPackage.name, "run", "storybook"], {
cwd: repoRoot,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

View File

@ -0,0 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { repoRoot } from "./workspace-utils.mjs";
const runtimeDir = path.join(repoRoot, ".devcontainer", ".runtime");
const pidFile = path.join(runtimeDir, "storybook.pid");
const logFile = path.join(runtimeDir, "storybook.log");
function isRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
fs.mkdirSync(runtimeDir, { recursive: true });
if (fs.existsSync(pidFile)) {
const currentPid = Number.parseInt(fs.readFileSync(pidFile, "utf8"), 10);
if (Number.isInteger(currentPid) && isRunning(currentPid)) {
console.log(`Storybook is already running (pid ${currentPid}).`);
process.exit(0);
}
}
const logStream = fs.openSync(logFile, "a");
const child = spawn("pnpm", ["run", "storybook:design"], {
cwd: repoRoot,
detached: true,
stdio: ["ignore", logStream, logStream],
});
child.unref();
fs.writeFileSync(pidFile, `${child.pid}\n`);
console.log(`Storybook launch requested (pid ${child.pid}). Logs: ${path.relative(repoRoot, logFile)}`);

View File

@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(currentDir, "../..");
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function parseWorkspacePatterns() {
const workspaceFile = path.join(repoRoot, "pnpm-workspace.yaml");
const contents = fs.readFileSync(workspaceFile, "utf8");
const patterns = [];
for (const line of contents.split(/\r?\n/u)) {
const match = line.match(/^\s*-\s*"([^"]+)"\s*$/u);
if (match) {
patterns.push(match[1]);
}
}
return patterns;
}
function expandWorkspacePattern(pattern) {
if (!pattern.endsWith("/*")) {
throw new Error(`Unsupported workspace pattern: ${pattern}`);
}
const baseDir = pattern.slice(0, -2);
const absoluteBaseDir = path.join(repoRoot, baseDir);
if (!fs.existsSync(absoluteBaseDir)) {
return [];
}
return fs
.readdirSync(absoluteBaseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(absoluteBaseDir, entry.name));
}
export function getWorkspacePackages() {
const packages = [];
const rootPackagePath = path.join(repoRoot, "package.json");
const rootPackage = readJson(rootPackagePath);
packages.push({
name: rootPackage.name,
dir: repoRoot,
relativeDir: ".",
packageJsonPath: rootPackagePath,
packageJson: rootPackage,
});
for (const pattern of parseWorkspacePatterns()) {
for (const packageDir of expandWorkspacePattern(pattern)) {
const packageJsonPath = path.join(packageDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
packages.push({
name: readJson(packageJsonPath).name,
dir: packageDir,
relativeDir: path.relative(repoRoot, packageDir),
packageJsonPath,
packageJson: readJson(packageJsonPath),
});
}
}
return packages;
}
export function findPackagesByScript(scriptName) {
return getWorkspacePackages().filter((pkg) => pkg.packageJson.scripts?.[scriptName]);
}
export function findSinglePackageByScript(scriptName) {
const matches = findPackagesByScript(scriptName);
if (matches.length === 0) {
throw new Error(`No workspace package exposes a ${scriptName} script.`);
}
if (matches.length > 1) {
const packageList = matches.map((pkg) => `${pkg.name} (${pkg.relativeDir})`).join(", ");
throw new Error(`Multiple workspace packages expose a ${scriptName} script: ${packageList}`);
}
return matches[0];
}