Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .claude/skills/github-pr-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,17 @@ const example = "value";

Or use tildes:

````markdown
`````markdown
````suggestion
```javascript
const example = "value";
````
````
`````

```

```

````
```

## Common Mistakes
Expand Down Expand Up @@ -259,6 +262,7 @@ Stop if you're thinking:
First, analyze the PR and draft your comments. Then use AskUserQuestion:

```

I've reviewed PR #123 and found 3 issues. Here's what I'll post:

**Comment 1:** src/auth.ts line 20
Expand All @@ -277,7 +281,8 @@ Missing error case test...
**Overall message:** "Found 3 issues that need to be addressed before merging."

Ready to post this review?
```

````

**Step 2: After approval, post the review**

Expand Down Expand Up @@ -305,19 +310,24 @@ gh api repos/:owner/:repo/pulls/123/reviews/<REVIEW_ID>/events \
-X POST \
-f event="REQUEST_CHANGES" \
-f body="Found 3 issues that need to be addressed before merging."
```
````

## Real-World Impact

**Without this pattern:**

- Multiple separate notifications spam the PR author
- Can't batch feedback together
- Easy to forget issues while reviewing
- Inconsistent workflow based on perceived urgency

**With this pattern:**

- All feedback in one coherent review
- PR author gets one notification with full context
- Can refine comments before posting
- Professional, organized reviews
````

```

```
54 changes: 34 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ No copy-pasting class names. No rebuilding layouts from scratch. No manual style

## Tech stack

| Layer | Choice |
|---|---|
| Framework | Next.js 14 (App Router) + TypeScript |
| Styling | Tailwind CSS v4 + CSS variables |
| Code editor | CodeMirror 6 via `@uiw/react-codemirror` |
| Fonts | Syne (display) · DM Sans (body) · JetBrains Mono (code) |
| Hosting | Vercel |
| Package manager | pnpm |
| Layer | Choice |
| --------------- | ------------------------------------------------------- |
| Framework | Next.js 14 (App Router) + TypeScript |
| Styling | Tailwind CSS v4 + CSS variables |
| Code editor | CodeMirror 6 via `@uiw/react-codemirror` |
| Fonts | Syne (display) · DM Sans (body) · JetBrains Mono (code) |
| Hosting | Vercel |
| Package manager | pnpm |

---

Expand Down Expand Up @@ -112,14 +112,14 @@ src/

## Roadmap

| Phase | Status | Description |
|---|---|---|
| 1 — Foundation | ✅ Done | Project scaffold, design system, XscpData types |
| 2 — Editor UI | ✅ Done | CodeMirror 6 editor, tabs, convert button |
| 3 — Conversion engine | 🔄 Next | HTML parser → CSS parser → XscpData builder → clipboard |
| 4 — Landing page | ⏳ Pending | Hero, How it works, Features, animations |
| 5 — Testing & QA | ⏳ Pending | Webflow paste tests, Lighthouse audit |
| 6 — Launch | ⏳ Pending | Domain, OG image, Product Hunt |
| Phase | Status | Description |
| --------------------- | ---------- | ------------------------------------------------------- |
| 1 — Foundation | ✅ Done | Project scaffold, design system, XscpData types |
| 2 — Editor UI | ✅ Done | CodeMirror 6 editor, tabs, convert button |
| 3 — Conversion engine | 🔄 Next | HTML parser → CSS parser → XscpData builder → clipboard |
| 4 — Landing page | ⏳ Pending | Hero, How it works, Features, animations |
| 5 — Testing & QA | ⏳ Pending | Webflow paste tests, Lighthouse audit |
| 6 — Launch | ⏳ Pending | Domain, OG image, Product Hunt |

---

Expand All @@ -131,13 +131,27 @@ Codeflow outputs the `@webflow/XscpData` JSON structure:
{
"type": "@webflow/XscpData",
"payload": {
"nodes": [{ "_id": "uuid", "type": "Block", "tag": "div", "classes": ["style-uuid"], "children": [] }],
"styles": [{ "_id": "style-uuid", "name": "section_hero", "styleLess": "display: flex; padding: 4rem 2rem;" }],
"nodes": [
{ "_id": "uuid", "type": "Block", "tag": "div", "classes": ["style-uuid"], "children": [] }
],
"styles": [
{
"_id": "style-uuid",
"name": "section_hero",
"styleLess": "display: flex; padding: 4rem 2rem;"
}
],
"assets": [],
"ix1": [],
"ix2": { "interactions": [], "events": [], "actionLists": [] }
},
"meta": { "unlinkedSymbolCount": 0, "droppedLinks": 0, "dynBindRemovedCount": 0, "dynListBindRemovedCount": 0, "paginationRemovedCount": 0 }
"meta": {
"unlinkedSymbolCount": 0,
"droppedLinks": 0,
"dynBindRemovedCount": 0,
"dynListBindRemovedCount": 0,
"paginationRemovedCount": 0
}
}
```

Expand All @@ -151,4 +165,4 @@ MIT — free to use, fork, and build on.

---

*Built with [Claude Code](https://claude.com/claude-code)*
_Built with [Claude Code](https://claude.com/claude-code)_
20 changes: 15 additions & 5 deletions src/components/Editor/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
import EditorTabs from "./EditorTabs";
import ConvertButton from "./ConvertButton";
import { STARTER_HTML, STARTER_CSS, STARTER_JS } from "@/lib/editor/starterCode";
import { convert } from "@/lib/converter";
import type { EditorLanguage } from "./CodeEditor";

// Dynamically import CodeEditor to avoid SSR issues with CodeMirror
Expand Down Expand Up @@ -52,12 +53,21 @@ export default function EditorPanel() {
const handleConvert = async () => {
setIsConverting(true);
try {
// Conversion engine is built in Phase 3.
// For now, show a placeholder success to validate the clipboard flow.
await new Promise((r) => setTimeout(r, 600)); // simulate async work
showToast({ type: "success", message: "Copied! Paste into Webflow Designer (Ctrl+V)" });
const result = await convert({ html: htmlCode, css: cssCode, js: jsCode });
if (result.success) {
const msg =
result.warnings && result.warnings.length > 0
? `Copied! ${result.warnings.length} warning(s) — check Webflow`
: "Copied! Paste into Webflow Designer (Ctrl+V)";
showToast({ type: "success", message: msg });
} else {
showToast({
type: "error",
message: result.error ?? "Conversion failed. Check your HTML/CSS.",
});
}
} catch {
showToast({ type: "error", message: "Conversion failed. Check your HTML/CSS syntax." });
showToast({ type: "error", message: "Unexpected error during conversion." });
} finally {
setIsConverting(false);
}
Expand Down
91 changes: 91 additions & 0 deletions src/lib/converter/client-first.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// src/lib/converter/client-first.ts
// Validates and transforms class names to follow Finsweet Client-First conventions.
// Reference: https://finsweet.com/client-first/docs/classes-strategy-1
//
// Rules:
// Custom classes → underscore separator: section_hero, hero_content
// Utility classes → hyphen only (no underscore): text-size-large, margin-top-medium
// General → specific: hero_heading-large (NOT large-heading-hero)
// No abbreviations: section_header (NOT sec-h)

// HTML tag → default Client-First base name
const TAG_CF_DEFAULTS: Record<string, string> = {
section: "section",
header: "section_header",
footer: "section_footer",
nav: "navbar_component",
main: "page_main",
article: "article",
aside: "sidebar",
form: "form_component",
ul: "list",
ol: "list",
li: "list_item",
figure: "figure",
figcaption: "figure_caption",
};

// Webflow utility class names that should not get the folder prefix
const UTILITY_PATTERNS = [
/^text-/,
/^margin-/,
/^padding-/,
/^background-/,
/^border-/,
/^display-/,
/^flex-/,
/^grid-/,
/^gap-/,
/^width-/,
/^height-/,
/^overflow-/,
/^position-/,
/^z-index-/,
/^opacity-/,
/^container-/,
/^button$/,
/^is-/,
/^hide$/,
/^show$/,
];

export function isUtilityClass(name: string): boolean {
return UTILITY_PATTERNS.some((pattern) => pattern.test(name));
}

export function isClientFirstCompliant(name: string): boolean {
// Utility classes: hyphens only, no underscore
if (isUtilityClass(name)) return !name.includes("_");
// Custom classes: must have exactly one underscore (folder_name)
const parts = name.split("_");
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
}

// Given a raw class name, return the most Client-First version we can infer.
// If the name is already CF-compliant, return it unchanged.
export function toClientFirst(rawName: string): string {
// Already has underscore folder structure — leave as-is
if (rawName.includes("_")) return rawName;

// Known utility pattern — leave as-is
if (isUtilityClass(rawName)) return rawName;

// Generic single word (e.g. "hero", "wrapper", "content") — treat as
// a component name and let the context add the folder in the builder.
return rawName;
}

// Generate a sensible CF class name for an element that has no classes.
// Uses tag name and an optional context hint (parent class name).
export function generateCFName(tag: string, context?: string): string {
const base = TAG_CF_DEFAULTS[tag];
if (base) return base;

// Use context (parent class) as the folder prefix
if (context) {
const folder = context.split("_")[0] || context;
return `${folder}_${tag}`;
}

return `${tag}_component`;
}
41 changes: 41 additions & 0 deletions src/lib/converter/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// src/lib/converter/clipboard.ts
// Copies Webflow XscpData JSON to the clipboard.
// Webflow Designer reads the application/json MIME slot specifically.
// navigator.clipboard.write() blocks non-safe MIME types, but
// execCommand('copy') + clipboardData.setData() can write application/json
// when called within a user gesture (button click).

import type { WebflowXscpData } from "./types";

export async function copyToWebflowClipboard(data: WebflowXscpData): Promise<void> {
const json = JSON.stringify(data);

// Primary: execCommand copy — can write application/json MIME type
// (navigator.clipboard.write() blocks non-safe MIME types in all browsers)
const execResult = await new Promise<boolean>((resolve) => {
const handler = (e: ClipboardEvent) => {
try {
e.clipboardData?.setData("application/json", json);
e.clipboardData?.setData("text/plain", json); // belt-and-suspenders
e.preventDefault();
resolve(true);
} catch {
resolve(false);
}
};
document.addEventListener("copy", handler, { once: true });
const success = document.execCommand("copy");
if (!success) {
document.removeEventListener("copy", handler);
resolve(false);
}
});

if (execResult) return;

// Fallback: writeText — works everywhere but only writes text/plain.
// Webflow may not read this, but it's better than a hard failure.
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(json);
}
}
Loading