@autono/open-pages 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-CCoxDxL-.js → build-Cpod-K9R.js} +9 -4
- package/dist/cli/bin.js +5 -5
- package/dist/{config-D_U_aCQz.js → config-CYLBzMMb.js} +1077 -18
- package/dist/{config-p-TWNXbv.d.ts → config-IkwICvOe.d.ts} +2 -1
- package/dist/{dev-CTrB263u.js → dev-B7x-G-wk.js} +1 -1
- package/dist/{export-enKL9R7x.js → export-BxMksWEc.js} +74 -12
- package/dist/index.d.ts +23 -3
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +96 -0
- package/dist/{preview-Dmzyd1cf.js → preview-CMv1lGOC.js} +1 -1
- package/dist/{types-DDdUFUIS.d.ts → types-B-9WXL93.d.ts} +24 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/apply-comments/SKILL.md +6 -4
- package/skills/create-email/SKILL.md +166 -0
- package/skills/create-theme/SKILL.md +31 -7
- package/skills/create-theme/references/email-theme-from-css.mjs +185 -0
- package/skills/create-theme/references/email-theme.md +48 -0
- package/skills/current-page/SKILL.md +3 -3
- package/src/app/app.tsx +4 -0
- package/src/app/components/command/home-command-menu.tsx +19 -2
- package/src/app/components/selection-note.tsx +71 -0
- package/src/app/components/sidebar/folder-item.tsx +9 -2
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/frame/email-inspect.ts +17 -0
- package/src/app/lib/emails.ts +17 -0
- package/src/app/lib/frame.ts +15 -4
- package/src/app/lib/page-thumb.tsx +11 -3
- package/src/app/lib/sdk.ts +22 -0
- package/src/app/routes/email.tsx +424 -0
- package/src/app/routes/emails.tsx +94 -0
- package/src/app/routes/home-shell.tsx +21 -2
- package/src/app/routes/page.tsx +9 -50
- package/src/app/virtual.d.ts +10 -0
- package/src/locale/en.ts +25 -0
- package/src/locale/ja.ts +25 -0
- package/src/locale/types.ts +24 -0
- package/src/locale/zh-cn.ts +25 -0
- package/src/locale/zh-tw.ts +25 -0
- package/workspace/components.json +3 -1
- package/workspace/styles/globals.css +8 -2
- package/dist/open-pages-plugin-C8cFhMB9.js +0 -662
- package/dist/version-DZrXLoAD.js +0 -42
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: create-theme
|
|
3
|
-
description: Use this skill when the user wants to create, draft, author, or extract a theme in this open-pages repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <page>", "match our brand", "build a design system from these screenshots", "apply this shadcn preset". Produces a
|
|
3
|
+
description: Use this skill when the user wants to create, draft, author, or extract a theme in this open-pages repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <page>", "match our brand", "build a design system from these screenshots", "apply this shadcn preset", "email theme for X". Produces a bundle under `themes/` — `<id>.md` (direction, fonts, component notes, token table), `<id>.css` (the shadcn token overrides), and `<id>.demo.tsx` (a demo page composed from `ui/` components that the workspace's Themes panel previews live) — plus `components/email/theme-<id>.ts`, the same palette as an emailcn EmailTheme so emails match. Do NOT use for editing real pages — only for authoring the theme bundle.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Create a theme
|
|
7
7
|
|
|
8
8
|
A theme is a **token set**. Every workspace ships the full shadcn/ui set under `ui/`, and every component reads its colors, radius, and fonts from the CSS variables in `styles/globals.css` (`--background`, `--primary`, `--radius`, …). A theme overrides those variables; every component and every token-styled page restyles at once. Nothing is copied into pages.
|
|
9
9
|
|
|
10
|
-
The bundle is three files sharing one stem under `themes/`:
|
|
10
|
+
The bundle is three files sharing one stem under `themes/`, plus one under `components/email/`:
|
|
11
11
|
|
|
12
12
|
1. `themes/<id>.md` — agent-facing direction: aesthetic, fonts, how to use the components in this theme, and a table of token → value. This is what `create-page` reads when an author picks the theme.
|
|
13
13
|
2. `themes/<id>.css` — **only** `:root { … }` and `.dark { … }` blocks overriding the shadcn tokens (plus an optional Google Fonts `@import url(…)` at the very top). No `@theme` block, no Tailwind import, no selectors beyond those two.
|
|
14
14
|
3. `themes/<id>.demo.tsx` — a runnable page module (same shape as `pages/<id>/index.tsx`, **one default-exported component**) composed from `@/ui/*` components so the tokens are shown on real parts. The Themes panel renders it with `<id>.css` injected automatically; the demo does not set `meta.theme`.
|
|
15
|
+
4. `components/email/theme-<id>.ts` — the same palette as an emailcn `EmailTheme` object (hex colors, px sizes, font stacks), generated from the CSS by a script (Step 6b). Mail clients cannot read CSS variables or `oklch()`, so this is how emails under `emails/` pick the theme up; the `create-email` skill consumes it.
|
|
15
16
|
|
|
16
|
-
A page opts in with `meta.theme: '<id>'`; the runtime injects `themes/<id>.css` into that page's preview frame and into its export.
|
|
17
|
+
A page opts in with `meta.theme: '<id>'`; the runtime injects `themes/<id>.css` into that page's preview frame and into its export. An email opts in by passing the exported theme object to `createEmailTailwindConfig` (or to a section's `theme` prop).
|
|
17
18
|
|
|
18
|
-
You only write
|
|
19
|
+
You only write these four files. Never modify pages, emails, `ui/`, `styles/globals.css`, or configuration. The token names and how pages consume them live in the **`page-authoring`** skill (`references/typography-and-color.md`) — read it first. Component props and variants: the **`shadcn`** skill.
|
|
19
20
|
|
|
20
21
|
## Step 1 — Identify the input source
|
|
21
22
|
|
|
@@ -150,6 +151,11 @@ Notes on how to use the `ui/` set so pages feel like this theme — which varian
|
|
|
150
151
|
- Feedback: `<Alert>`, `sonner` toasts.
|
|
151
152
|
- Avoid: gradients | shadows | more than one accent | … (whatever the theme forbids).
|
|
152
153
|
|
|
154
|
+
## Email
|
|
155
|
+
|
|
156
|
+
- `components/email/theme-<id>.ts` exports `<id>Theme`, generated from this CSS. Emails pass it to `createEmailTailwindConfig` or to a section's `theme` prop.
|
|
157
|
+
- Any email-specific notes: which emailcn sections suit the brand, whether headings keep the serif via `<Font>`, what the footer must carry.
|
|
158
|
+
|
|
153
159
|
## Aesthetic
|
|
154
160
|
|
|
155
161
|
One paragraph. What it feels like, the references it draws on, what to avoid. Commit to a single direction.
|
|
@@ -179,6 +185,21 @@ Contract:
|
|
|
179
185
|
- Root element: `min-h-screen bg-background text-foreground` (with `dark` if the theme is dark by default). Everything token-styled — the demo is the proof that pages need no raw colors.
|
|
180
186
|
- Realistic content, not lorem ipsum. Must look right at Mobile (390px) as well as Desktop. Self-contained: no page-local assets.
|
|
181
187
|
|
|
188
|
+
## Step 6b — Generate `components/email/theme-<id>.ts`
|
|
189
|
+
|
|
190
|
+
Emails are rendered by react-email with emailcn's `createEmailTailwindConfig(theme)`, which takes a plain `EmailTheme` object, not CSS variables. Derive it from the finished CSS with the script that ships with this skill:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# once per workspace, if components/email/email-theme.ts does not exist yet
|
|
194
|
+
npx shadcn@latest add @emailcn/react-email/theme-default
|
|
195
|
+
|
|
196
|
+
node .agents/skills/create-theme/references/email-theme-from-css.mjs themes/<id>.css <id> > components/email/theme-<id>.ts
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The script reads the `:root` block (emails are light documents; the `.dark` block is not used), converts every color to hex, and maps roles by `references/email-theme.md`: `--background` → `colorBackground`, `--foreground` → `colorText`, `--muted` / `--muted-foreground` → `colorBackgroundMuted` / `colorTextMuted`, `--secondary` → `colorBackgroundSubtle`, `--primary` / `--primary-foreground` → `colorPrimary` / `colorPrimaryForeground` and the primary button, `--border` → `colorBorder` and the secondary button's border, `--destructive` → `colorDanger`, `--radius` → `borderRadius` in px, `--font-sans` / `--font-mono` → `fontFamily` / `fontFamilyMono` with `var()` entries dropped. Success, warning, the type scale, and spacing keep emailcn's defaults because the CSS has no opinion on them.
|
|
200
|
+
|
|
201
|
+
Open the generated file and review it: a web font in `fontFamily` needs a web-safe fallback after it (the script keeps whatever fallbacks the CSS listed), and `colorPrimaryHover` / `colorTextSubtle` are shifted from their base tokens, so glance that they still read well. Do not restyle it by hand beyond that; re-run the script after changing the CSS so the two never drift.
|
|
202
|
+
|
|
182
203
|
## Step 7 — Self-review
|
|
183
204
|
|
|
184
205
|
- [ ] `themes/<id>.css` overrides every shadcn token in both `:root` and `.dark`, in OKLCH, with only those two selectors (plus an optional font `@import`).
|
|
@@ -187,15 +208,17 @@ Contract:
|
|
|
187
208
|
- [ ] Typography names only fonts the CSS loads (or the system stack).
|
|
188
209
|
- [ ] "Components in this theme" gives variant guidance for buttons, cards, nav, data, feedback.
|
|
189
210
|
- [ ] Demo `.tsx` default-exports one component, imports only `@/ui/*`, uses semantic tokens only, shows both modes, nothing overflows on mobile.
|
|
190
|
-
- [ ]
|
|
211
|
+
- [ ] `components/email/theme-<id>.ts` generated from the CSS, exports `<id>Theme` typed as `EmailTheme`, hex colors only, and its font stacks end in a web-safe family.
|
|
212
|
+
- [ ] All four files written with the same stem. No page or email changes, no `ui/` changes, no `styles/globals.css` changes.
|
|
191
213
|
|
|
192
214
|
## Step 8 — Hand off
|
|
193
215
|
|
|
194
216
|
Tell the user:
|
|
195
217
|
|
|
196
|
-
- The theme id and the
|
|
218
|
+
- The theme id and the four file paths.
|
|
197
219
|
- That the Themes panel in the workspace (`http://localhost:5173/themes`) previews the demo live, and `/create-page` will list the theme as a picker option on its next run.
|
|
198
220
|
- That any existing page can adopt it by setting `meta.theme: '<id>'` — no other change.
|
|
221
|
+
- That emails adopt it by importing `<id>Theme` from `@/components/email/theme-<id>` and passing it to `createEmailTailwindConfig` (or to an emailcn section's `theme` prop); `/create-email` offers it whenever the file exists.
|
|
199
222
|
- A one-line summary of the look (accent, mode, radius, fonts).
|
|
200
223
|
|
|
201
224
|
Do not run the dev server. Do not modify real pages — the demo `.tsx` is the demonstration.
|
|
@@ -205,7 +228,8 @@ Do not run the dev server. Do not modify real pages — the demo `.tsx` is the d
|
|
|
205
228
|
- ❌ Pasting token values into pages, or theme markdown full of `bg-[#hex]` snippets. Tokens live in the CSS; pages stay semantic.
|
|
206
229
|
- ❌ `@theme inline`, `@import "tailwindcss"`, `@source`, or selectors other than `:root`/`.dark` in `themes/<id>.css`.
|
|
207
230
|
- ❌ Editing `styles/globals.css` or running `npx shadcn apply` against the workspace — that silently rethemes every page.
|
|
208
|
-
- ❌ Producing
|
|
231
|
+
- ❌ Producing a subset of the files. A theme is the **bundle** — `.md`, `.css`, `.demo.tsx`, and `components/email/theme-<id>.ts`, every time.
|
|
232
|
+
- ❌ Hand-typing hex values into the email theme. Generate it from the CSS with the script so the page and email palettes cannot disagree.
|
|
209
233
|
- ❌ A demo with raw colors or hand-rolled buttons — it must prove the tokens carry the look through `ui/`.
|
|
210
234
|
- ❌ Naming font families the CSS never loads.
|
|
211
235
|
- ❌ Inventing values when the user supplied a preset, images, or an existing page. Extract, don't fabricate.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Derives an emailcn EmailTheme module from a workspace theme's CSS.
|
|
3
|
+
//
|
|
4
|
+
// node <this file> themes/<id>.css <id> > components/email/theme-<id>.ts
|
|
5
|
+
//
|
|
6
|
+
// Reads the :root block (emails are light-mode documents), converts each
|
|
7
|
+
// OKLCH token to hex, and maps the shadcn roles onto EmailTheme fields.
|
|
8
|
+
// Fields the CSS cannot decide (success, warning, type scale, spacing) take
|
|
9
|
+
// emailcn's defaults so the file stays a drop-in for createEmailTailwindConfig.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
const [, , cssPath, rawId] = process.argv;
|
|
14
|
+
if (!cssPath || !rawId) {
|
|
15
|
+
process.stderr.write('usage: email-theme-from-css.mjs themes/<id>.css <id>\n');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const css = readFileSync(cssPath, 'utf8');
|
|
20
|
+
const rootBlock = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
|
21
|
+
const tokens = {};
|
|
22
|
+
for (const m of rootBlock.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) tokens[m[1]] = m[2].trim();
|
|
23
|
+
|
|
24
|
+
const missing = ['background', 'foreground', 'primary', 'primary-foreground', 'border'].filter(
|
|
25
|
+
(t) => !tokens[t],
|
|
26
|
+
);
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
process.stderr.write(`${cssPath} :root is missing ${missing.map((t) => `--${t}`).join(', ')}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clamp01(x) {
|
|
33
|
+
return Math.min(1, Math.max(0, x));
|
|
34
|
+
}
|
|
35
|
+
function linToSrgb(c) {
|
|
36
|
+
const v = clamp01(c);
|
|
37
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
38
|
+
}
|
|
39
|
+
function oklchToRgb(L, C, H) {
|
|
40
|
+
const h = (H * Math.PI) / 180;
|
|
41
|
+
const a = C * Math.cos(h);
|
|
42
|
+
const b = C * Math.sin(h);
|
|
43
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
44
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
45
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
46
|
+
const l = l_ ** 3;
|
|
47
|
+
const m = m_ ** 3;
|
|
48
|
+
const s = s_ ** 3;
|
|
49
|
+
return [
|
|
50
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
51
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
52
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
53
|
+
].map(linToSrgb);
|
|
54
|
+
}
|
|
55
|
+
function toHex([r, g, b]) {
|
|
56
|
+
return `#${[r, g, b]
|
|
57
|
+
.map((v) =>
|
|
58
|
+
Math.round(v * 255)
|
|
59
|
+
.toString(16)
|
|
60
|
+
.padStart(2, '0'),
|
|
61
|
+
)
|
|
62
|
+
.join('')}`;
|
|
63
|
+
}
|
|
64
|
+
function parseOklch(value) {
|
|
65
|
+
const m = value.match(/oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)/);
|
|
66
|
+
if (!m) return null;
|
|
67
|
+
let L = Number(m[1]);
|
|
68
|
+
if (value.includes('%')) L /= 100;
|
|
69
|
+
return { L, C: Number(m[2]), H: Number(m[3]) };
|
|
70
|
+
}
|
|
71
|
+
function hex(value) {
|
|
72
|
+
if (!value) return null;
|
|
73
|
+
if (/^#[0-9a-f]{6}$/i.test(value)) return value.toLowerCase();
|
|
74
|
+
const m = value.match(/rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
|
|
75
|
+
if (m) return toHex([m[1], m[2], m[3]].map((n) => Number(n) / 255));
|
|
76
|
+
const o = parseOklch(value);
|
|
77
|
+
if (!o) return null;
|
|
78
|
+
return toHex(oklchToRgb(o.L, o.C, o.H));
|
|
79
|
+
}
|
|
80
|
+
function shifted(value, dL) {
|
|
81
|
+
const o = parseOklch(value);
|
|
82
|
+
if (!o) return hex(value);
|
|
83
|
+
return toHex(oklchToRgb(clamp01(o.L + dL), o.C, o.H));
|
|
84
|
+
}
|
|
85
|
+
function px(value, fallback) {
|
|
86
|
+
if (!value) return fallback;
|
|
87
|
+
const m = value.match(/([\d.]+)\s*(rem|px)/);
|
|
88
|
+
if (!m) return fallback;
|
|
89
|
+
const n = Number(m[1]);
|
|
90
|
+
return `${Math.round(m[2] === 'rem' ? n * 16 : n)}px`;
|
|
91
|
+
}
|
|
92
|
+
function fontStack(value, fallback) {
|
|
93
|
+
if (!value) return fallback;
|
|
94
|
+
return value
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((f) => f.trim())
|
|
97
|
+
.filter((f) => !f.startsWith('var('))
|
|
98
|
+
.map((f) => f.replace(/^'(.*)'$/, '"$1"'))
|
|
99
|
+
.join(', ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const color = (name, fallback) => hex(tokens[name]) ?? fallback;
|
|
103
|
+
const background = color('background', '#ffffff');
|
|
104
|
+
const foreground = color('foreground', '#111827');
|
|
105
|
+
const primary = color('primary', '#111827');
|
|
106
|
+
const primaryForeground = color('primary-foreground', '#ffffff');
|
|
107
|
+
const border = color('border', '#e5e7eb');
|
|
108
|
+
const muted = color('muted', '#f9fafb');
|
|
109
|
+
const mutedForeground = color('muted-foreground', '#6b7280');
|
|
110
|
+
const secondary = color('secondary', '#f3f4f6');
|
|
111
|
+
const radius = px(tokens.radius, '6px');
|
|
112
|
+
const radiusPx = Number.parseInt(radius, 10);
|
|
113
|
+
|
|
114
|
+
const theme = {
|
|
115
|
+
borderRadius: radius,
|
|
116
|
+
borderRadiusLg: `${radiusPx * 2}px`,
|
|
117
|
+
button: {
|
|
118
|
+
primary: {
|
|
119
|
+
backgroundColor: primary,
|
|
120
|
+
borderRadius: radius,
|
|
121
|
+
color: primaryForeground,
|
|
122
|
+
fontSize: '14px',
|
|
123
|
+
fontWeight: '500',
|
|
124
|
+
paddingX: '24px',
|
|
125
|
+
paddingY: '12px',
|
|
126
|
+
},
|
|
127
|
+
secondary: {
|
|
128
|
+
backgroundColor: 'transparent',
|
|
129
|
+
border: `1px solid ${border}`,
|
|
130
|
+
borderRadius: radius,
|
|
131
|
+
color: foreground,
|
|
132
|
+
fontSize: '14px',
|
|
133
|
+
fontWeight: '500',
|
|
134
|
+
paddingX: '24px',
|
|
135
|
+
paddingY: '12px',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
colorBackground: background,
|
|
139
|
+
colorBackgroundMuted: muted,
|
|
140
|
+
colorBackgroundSubtle: secondary,
|
|
141
|
+
colorBorder: border,
|
|
142
|
+
colorBorderSubtle: tokens.input ? color('input', border) : muted,
|
|
143
|
+
colorDanger: color('destructive', '#ef4444'),
|
|
144
|
+
colorPrimary: primary,
|
|
145
|
+
colorPrimaryForeground: primaryForeground,
|
|
146
|
+
colorPrimaryHover: tokens.primary ? shifted(tokens.primary, -0.08) : '#374151',
|
|
147
|
+
colorSuccess: '#10b981',
|
|
148
|
+
colorText: foreground,
|
|
149
|
+
colorTextMuted: mutedForeground,
|
|
150
|
+
colorTextSubtle: tokens['muted-foreground']
|
|
151
|
+
? shifted(tokens['muted-foreground'], 0.14)
|
|
152
|
+
: '#9ca3af',
|
|
153
|
+
colorWarning: '#f59e0b',
|
|
154
|
+
containerWidth: '600px',
|
|
155
|
+
fontFamily: fontStack(
|
|
156
|
+
tokens['font-sans'],
|
|
157
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
158
|
+
),
|
|
159
|
+
fontFamilyMono: fontStack(tokens['font-mono'], '"Menlo", "Monaco", "Courier New", monospace'),
|
|
160
|
+
fontSizeBase: '14px',
|
|
161
|
+
fontSizeHeading: '28px',
|
|
162
|
+
fontSizeLg: '16px',
|
|
163
|
+
fontSizeSm: '12px',
|
|
164
|
+
fontSizeXl: '20px',
|
|
165
|
+
fontWeightBold: '600',
|
|
166
|
+
fontWeightMedium: '500',
|
|
167
|
+
fontWeightNormal: '400',
|
|
168
|
+
lineHeightBase: '1.6',
|
|
169
|
+
spacingBase: '24px',
|
|
170
|
+
spacingLg: '32px',
|
|
171
|
+
spacingXl: '48px',
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const id = rawId.replace(/\.css$/, '');
|
|
175
|
+
const exportName = `${id.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())}Theme`;
|
|
176
|
+
// Unquoted keys, single-quoted strings unless the string itself holds one.
|
|
177
|
+
const body = JSON.stringify(theme, null, 2)
|
|
178
|
+
.replace(/"([a-zA-Z]+)":/g, '$1:')
|
|
179
|
+
.replace(/"((?:[^"\\]|\\.)*)"/g, (_, str) =>
|
|
180
|
+
str.includes("'") ? `"${str}"` : `'${str.replace(/\\"/g, '"')}'`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
`import type { EmailTheme } from '@/components/email/email-theme';\n\n// Derived from themes/${id}.css by the create-theme skill. Re-run the script\n// after changing the CSS rather than editing colors here by hand.\nexport const ${exportName}: EmailTheme = ${body};\n`,
|
|
185
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Email theme mapping
|
|
2
|
+
|
|
3
|
+
`components/email/theme-<id>.ts` is the workspace theme expressed as an emailcn `EmailTheme`: hex colors, pixel sizes, plain font stacks. `email-theme-from-css.mjs` in this folder generates it from `themes/<id>.css`; this file records what it does so the output can be reviewed.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
node .agents/skills/create-theme/references/email-theme-from-css.mjs themes/<id>.css <id> > components/email/theme-<id>.ts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Only the `:root` block is read. Emails are light-mode documents; mail clients that force dark mode recolor on their own and cannot be steered by a `.dark` block.
|
|
10
|
+
|
|
11
|
+
## Token → field
|
|
12
|
+
|
|
13
|
+
| CSS token (`:root`) | EmailTheme field | Notes |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `--background` | `colorBackground` | body and container fill |
|
|
16
|
+
| `--foreground` | `colorText` | body copy, and the secondary button's text |
|
|
17
|
+
| `--muted` | `colorBackgroundMuted` | bands, table stripes |
|
|
18
|
+
| `--secondary` | `colorBackgroundSubtle` | quieter fills |
|
|
19
|
+
| `--muted-foreground` | `colorTextMuted` | supporting copy |
|
|
20
|
+
| `--muted-foreground` lightened (+0.14 L) | `colorTextSubtle` | captions, legal lines |
|
|
21
|
+
| `--primary` | `colorPrimary`, `button.primary.backgroundColor` | the accent and the CTA fill |
|
|
22
|
+
| `--primary` darkened (−0.08 L) | `colorPrimaryHover` | link hover where clients honor it |
|
|
23
|
+
| `--primary-foreground` | `colorPrimaryForeground`, `button.primary.color` | text on the accent |
|
|
24
|
+
| `--border` | `colorBorder`, `button.secondary.border` | hairlines, outline buttons |
|
|
25
|
+
| `--input` (else `--muted`) | `colorBorderSubtle` | softer rules |
|
|
26
|
+
| `--destructive` | `colorDanger` | errors |
|
|
27
|
+
| `--radius` (rem × 16) | `borderRadius`, both buttons' `borderRadius`; ×2 → `borderRadiusLg` | |
|
|
28
|
+
| `--font-sans` | `fontFamily` | `var()` entries dropped; keep a web-safe family at the end |
|
|
29
|
+
| `--font-mono` | `fontFamilyMono` | same |
|
|
30
|
+
|
|
31
|
+
Fixed at emailcn's defaults because the CSS has no equivalent: `colorSuccess` `#10b981`, `colorWarning` `#f59e0b`, `containerWidth` `600px`, the `fontSize*`, `fontWeight*`, `lineHeightBase`, and `spacing*` fields, and the button padding.
|
|
32
|
+
|
|
33
|
+
## Review after generating
|
|
34
|
+
|
|
35
|
+
- Web fonts: Gmail ignores them. The stack must end in `Arial, sans-serif` or a similar system family; the script keeps whatever the CSS listed, so add one if the CSS relied on `system-ui` alone.
|
|
36
|
+
- Contrast: `colorTextMuted` on `colorBackground` and `colorPrimaryForeground` on `colorPrimary` are the pairs to check; they inherit the CSS values, so they pass when the CSS did.
|
|
37
|
+
- Serif headings: `EmailTheme` has one `fontFamily`. If the page theme uses a serif `--font-heading`, emails set it per heading with `<Font>` plus a `font-serif` class or an explicit `style`, and the theme's `.md` should say so under "Email".
|
|
38
|
+
|
|
39
|
+
## Using the theme in an email
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { createEmailTailwindConfig } from '@/components/email/email-theme';
|
|
43
|
+
import { midnightSaasTheme } from '@/components/email/theme-midnight-saas';
|
|
44
|
+
|
|
45
|
+
<Tailwind config={createEmailTailwindConfig(midnightSaasTheme)}>…</Tailwind>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
emailcn sections take a `theme` prop; blocks pin their theme inside the file and need a copy under `emails/<id>/components/` to swap it. The `create-email` skill covers both.
|
|
@@ -43,10 +43,10 @@ Path is relative to the project root (the user's `cwd`, the directory that conta
|
|
|
43
43
|
}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
- `pageId` — folder name under `pages
|
|
46
|
+
- `pageId` — folder name under `pages/` (or under `emails/` when `view` is `"emails"`). Use as-is for any `/__pages/<id>/...` API or as the URL segment (`/p/<id>`, `/e/<id>` for an email).
|
|
47
47
|
- `pageTitle` — the page's `meta.title` (or `<title>` for an HTML page), falling back to the id.
|
|
48
|
-
- `pagePath` —
|
|
49
|
-
- `view` — `"pages"` when the user is viewing
|
|
48
|
+
- `pagePath` — entry path **relative to the project root**: `pages/<id>/index.tsx`, `pages/<id>/index.html` for a plain HTML page, or `emails/<id>/index.tsx` for an email. Prefix it with the project root before handing it to `Read` / `Edit`. Note the selection may point into a file under `pages/<id>/components/` (or `emails/<id>/components/`) if the entry is split — match the line against the file whose JSX contains that tag and text.
|
|
49
|
+
- `view` — `"pages"` when the user is viewing a page, `"assets"` when they are browsing that page's files in the asset manager, `"emails"` when they are viewing an email template. An email means the `create-email` skill's rules apply (react-email components, no `ui/`), not `page-authoring`.
|
|
50
50
|
- `selection` — `null` if nothing is selected. Otherwise, the JSX element the user picked in the inspector:
|
|
51
51
|
- `line` (1-indexed) and `column` (0-indexed) point to the JSX opening tag in the page source. This is the canonical handle — match against the source line.
|
|
52
52
|
- `tagName` is the rendered HTML tag, lowercased (`"h1"`, `"div"`, `"button"`). The source line it points at may be a shadcn component rather than that tag — a `"button"` selection usually lands on a `<Button>` line in the page, because `ui/` components spread the inspector tag onto their root. Edit the page line; never follow it into `ui/*.tsx`.
|
package/src/app/app.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import { Toaster } from './components/ui/sonner';
|
|
|
4
4
|
import { TooltipProvider } from './components/ui/tooltip';
|
|
5
5
|
import { useLocale } from './lib/use-locale';
|
|
6
6
|
import { AssetsPage } from './routes/assets';
|
|
7
|
+
import { EmailView } from './routes/email';
|
|
8
|
+
import { EmailsGalleryPage } from './routes/emails';
|
|
7
9
|
import { Home } from './routes/home';
|
|
8
10
|
import { HomeShell } from './routes/home-shell';
|
|
9
11
|
import { PageView } from './routes/page';
|
|
@@ -22,11 +24,13 @@ export function App() {
|
|
|
22
24
|
<Route path="/themes" element={<ThemesGalleryPage />} />
|
|
23
25
|
<Route path="/themes/:themeId" element={<ThemeDetailPage />} />
|
|
24
26
|
<Route path="/assets" element={<AssetsPage />} />
|
|
27
|
+
<Route path="/emails" element={<EmailsGalleryPage />} />
|
|
25
28
|
</Route>
|
|
26
29
|
) : (
|
|
27
30
|
<Route path="/" element={<NotFound />} />
|
|
28
31
|
)}
|
|
29
32
|
<Route path="/p/:pageId" element={<PageView />} />
|
|
33
|
+
<Route path="/e/:emailId" element={<EmailView />} />
|
|
30
34
|
<Route path="*" element={<NotFound />} />
|
|
31
35
|
</Routes>
|
|
32
36
|
</TooltipProvider>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { Presentation } from 'lucide-react';
|
|
1
|
+
import { Mail, Presentation } from 'lucide-react';
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { useNavigate } from 'react-router-dom';
|
|
4
4
|
import { useLocale } from '~/lib/use-locale';
|
|
5
5
|
import { usePageTitles } from '~/lib/use-page-titles';
|
|
6
|
+
import { emailIds, emailMeta } from '../../lib/emails';
|
|
6
7
|
import { pageIds } from '../../lib/pages';
|
|
7
8
|
import type { Folder } from '../../lib/sdk';
|
|
8
9
|
import { FolderIconChip, SystemViewIcon } from '../sidebar/folder-item';
|
|
9
|
-
import { ALL_DOCS_ID, ASSETS_ID, DRAFT_ID, THEMES_ID } from '../sidebar/sidebar';
|
|
10
|
+
import { ALL_DOCS_ID, ASSETS_ID, DRAFT_ID, EMAILS_ID, THEMES_ID } from '../sidebar/sidebar';
|
|
10
11
|
import { type CommandGroupSpec, CommandMenu, type CommandSpec } from './command-menu';
|
|
11
12
|
|
|
12
13
|
export function HomeCommandMenu({
|
|
@@ -35,6 +36,14 @@ export function HomeCommandMenu({
|
|
|
35
36
|
run: () => navigate(`/p/${id}`),
|
|
36
37
|
}));
|
|
37
38
|
|
|
39
|
+
const emails: CommandSpec[] = emailIds.map((id) => ({
|
|
40
|
+
id: `email-${id}`,
|
|
41
|
+
label: emailMeta[id]?.title ?? id,
|
|
42
|
+
icon: <Mail />,
|
|
43
|
+
keywords: [id, 'email', emailMeta[id]?.subject ?? ''],
|
|
44
|
+
run: () => navigate(`/e/${id}`),
|
|
45
|
+
}));
|
|
46
|
+
|
|
38
47
|
const folderItems: CommandSpec[] = [
|
|
39
48
|
{
|
|
40
49
|
id: `view-${ALL_DOCS_ID}`,
|
|
@@ -67,6 +76,13 @@ export function HomeCommandMenu({
|
|
|
67
76
|
keywords: ['themes', 'design'],
|
|
68
77
|
run: () => onSelectView(THEMES_ID),
|
|
69
78
|
},
|
|
79
|
+
{
|
|
80
|
+
id: `view-${EMAILS_ID}`,
|
|
81
|
+
label: t.home.emails,
|
|
82
|
+
icon: <SystemViewIcon kind="emails" />,
|
|
83
|
+
keywords: ['emails', 'mail', 'templates'],
|
|
84
|
+
run: () => onSelectView(EMAILS_ID),
|
|
85
|
+
},
|
|
70
86
|
];
|
|
71
87
|
if (import.meta.env.DEV) {
|
|
72
88
|
navigation.push({
|
|
@@ -80,6 +96,7 @@ export function HomeCommandMenu({
|
|
|
80
96
|
|
|
81
97
|
return [
|
|
82
98
|
{ id: 'pages', heading: t.commandMenu.groupDocs, items: pages },
|
|
99
|
+
{ id: 'emails', heading: t.commandMenu.groupEmails, items: emails },
|
|
83
100
|
{ id: 'folders', heading: t.commandMenu.groupFolders, items: folderItems },
|
|
84
101
|
{ id: 'navigation', heading: t.commandMenu.groupNavigation, items: navigation },
|
|
85
102
|
];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Loader2, MessageSquarePlus, X } from 'lucide-react';
|
|
2
|
+
import { Button } from '~/components/ui/button';
|
|
3
|
+
|
|
4
|
+
export type Selection = { loc: string; tag: string; text: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The floating note box that follows an inspector pick: shows the picked
|
|
8
|
+
* element and lets the user leave a comment for their agent.
|
|
9
|
+
*/
|
|
10
|
+
export function SelectionNote({
|
|
11
|
+
selection,
|
|
12
|
+
note,
|
|
13
|
+
saving,
|
|
14
|
+
onNoteChange,
|
|
15
|
+
onSubmit,
|
|
16
|
+
onClear,
|
|
17
|
+
}: {
|
|
18
|
+
selection: Selection;
|
|
19
|
+
note: string;
|
|
20
|
+
saving: boolean;
|
|
21
|
+
onNoteChange: (note: string) => void;
|
|
22
|
+
onSubmit: () => void;
|
|
23
|
+
onClear: () => void;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="fixed bottom-6 right-6 z-20 w-[340px] rounded-lg border bg-background p-3 shadow-xl">
|
|
27
|
+
<div className="flex items-center gap-2">
|
|
28
|
+
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">
|
|
29
|
+
{selection.tag}
|
|
30
|
+
</span>
|
|
31
|
+
<span className="font-mono text-[11px] text-muted-foreground">
|
|
32
|
+
line {selection.loc.replace(':', ', col ')}
|
|
33
|
+
</span>
|
|
34
|
+
<Button
|
|
35
|
+
variant="ghost"
|
|
36
|
+
size="icon"
|
|
37
|
+
className="ml-auto size-6"
|
|
38
|
+
aria-label="Clear selection"
|
|
39
|
+
onClick={onClear}
|
|
40
|
+
>
|
|
41
|
+
<X className="size-3.5" />
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
{selection.text && (
|
|
45
|
+
<p className="mt-1.5 line-clamp-2 text-xs text-muted-foreground">“{selection.text}”</p>
|
|
46
|
+
)}
|
|
47
|
+
<textarea
|
|
48
|
+
value={note}
|
|
49
|
+
onChange={(e) => onNoteChange(e.target.value)}
|
|
50
|
+
placeholder="Leave a note for your agent — e.g. “make this bold”"
|
|
51
|
+
rows={2}
|
|
52
|
+
className="mt-2 w-full resize-none rounded-md border bg-transparent px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
|
53
|
+
/>
|
|
54
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
55
|
+
<Button
|
|
56
|
+
size="sm"
|
|
57
|
+
onClick={onSubmit}
|
|
58
|
+
disabled={!note.trim() || saving}
|
|
59
|
+
aria-label="Save comment"
|
|
60
|
+
>
|
|
61
|
+
{saving ? (
|
|
62
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
63
|
+
) : (
|
|
64
|
+
<MessageSquarePlus className="size-3.5" />
|
|
65
|
+
)}
|
|
66
|
+
Comment
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
FolderOpen,
|
|
3
3
|
LayoutGrid,
|
|
4
4
|
type LucideIcon,
|
|
5
|
+
Mail,
|
|
5
6
|
MoreHorizontal,
|
|
6
7
|
Palette,
|
|
7
8
|
Pencil,
|
|
@@ -42,13 +43,14 @@ function useDocDragActive() {
|
|
|
42
43
|
return active;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export type SystemViewKind = 'all' | 'draft' | 'themes' | 'assets';
|
|
46
|
+
export type SystemViewKind = 'all' | 'draft' | 'themes' | 'assets' | 'emails';
|
|
46
47
|
|
|
47
48
|
const SYSTEM_VIEW_ICONS: Record<SystemViewKind, LucideIcon> = {
|
|
48
49
|
all: LayoutGrid,
|
|
49
50
|
draft: PenLine,
|
|
50
51
|
themes: Palette,
|
|
51
52
|
assets: FolderOpen,
|
|
53
|
+
emails: Mail,
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
export function SystemViewIcon({ kind, className }: { kind: SystemViewKind; className?: string }) {
|
|
@@ -103,6 +105,9 @@ type Row =
|
|
|
103
105
|
}
|
|
104
106
|
| {
|
|
105
107
|
kind: 'assets';
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
kind: 'emails';
|
|
106
111
|
};
|
|
107
112
|
|
|
108
113
|
export function FolderItem({
|
|
@@ -168,7 +173,9 @@ export function FolderItem({
|
|
|
168
173
|
? t.home.themes
|
|
169
174
|
: row.kind === 'assets'
|
|
170
175
|
? t.home.assets
|
|
171
|
-
: row.
|
|
176
|
+
: row.kind === 'emails'
|
|
177
|
+
? t.home.emails
|
|
178
|
+
: row.folder.name;
|
|
172
179
|
|
|
173
180
|
const commitRename = () => {
|
|
174
181
|
if (row.kind !== 'folder') return;
|
|
@@ -15,6 +15,7 @@ export const ALL_DOCS_ID = '__all__';
|
|
|
15
15
|
export const DRAFT_ID = 'draft';
|
|
16
16
|
export const THEMES_ID = '__themes__';
|
|
17
17
|
export const ASSETS_ID = '__assets__';
|
|
18
|
+
export const EMAILS_ID = '__emails__';
|
|
18
19
|
|
|
19
20
|
export const FOLDER_DND_MIME = 'application/x-folder-id';
|
|
20
21
|
|
|
@@ -24,6 +25,7 @@ export function Sidebar({
|
|
|
24
25
|
allCount,
|
|
25
26
|
themesCount,
|
|
26
27
|
assetsCount,
|
|
28
|
+
emailsCount,
|
|
27
29
|
selectedId,
|
|
28
30
|
onSelect,
|
|
29
31
|
onCreate,
|
|
@@ -40,6 +42,7 @@ export function Sidebar({
|
|
|
40
42
|
allCount: number;
|
|
41
43
|
themesCount: number;
|
|
42
44
|
assetsCount: number;
|
|
45
|
+
emailsCount: number;
|
|
43
46
|
selectedId: string;
|
|
44
47
|
onSelect: (id: string) => void;
|
|
45
48
|
onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
|
|
@@ -178,6 +181,13 @@ export function Sidebar({
|
|
|
178
181
|
onSelect={() => onSelect(THEMES_ID)}
|
|
179
182
|
onDropDoc={() => {}}
|
|
180
183
|
/>
|
|
184
|
+
<FolderItem
|
|
185
|
+
row={{ kind: 'emails' }}
|
|
186
|
+
count={emailsCount}
|
|
187
|
+
selected={selectedId === EMAILS_ID}
|
|
188
|
+
onSelect={() => onSelect(EMAILS_ID)}
|
|
189
|
+
onDropDoc={() => {}}
|
|
190
|
+
/>
|
|
181
191
|
{import.meta.env.DEV && (
|
|
182
192
|
<FolderItem
|
|
183
193
|
row={{ kind: 'assets' }}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Attached to a rendered email when the workspace embeds it: the inspector
|
|
2
|
+
// alone, since the document is static HTML and needs no React mount.
|
|
3
|
+
import type { FrameMessage, WorkspaceMessage } from '../lib/frame';
|
|
4
|
+
import { installInspector } from './inspect';
|
|
5
|
+
|
|
6
|
+
if (window.parent !== window) {
|
|
7
|
+
const post = (msg: FrameMessage) => window.parent.postMessage(msg, '*');
|
|
8
|
+
const inspector = installInspector(post);
|
|
9
|
+
window.addEventListener('message', (event: MessageEvent<WorkspaceMessage>) => {
|
|
10
|
+
if (event.source !== window.parent) return;
|
|
11
|
+
const msg = event.data;
|
|
12
|
+
if (!msg || typeof msg !== 'object') return;
|
|
13
|
+
if (msg.type === 'op:inspect') inspector.setInspecting(msg.on);
|
|
14
|
+
else if (msg.type === 'op:select') inspector.setSelected(msg.loc);
|
|
15
|
+
});
|
|
16
|
+
post({ type: 'op:ready', title: document.title });
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emailCreatedAt as createdAt,
|
|
3
|
+
emailIds as ids,
|
|
4
|
+
emailMeta as meta,
|
|
5
|
+
} from 'virtual:open-pages/emails';
|
|
6
|
+
|
|
7
|
+
export type EmailInfo = { title: string; subject: string | null; description: string | null };
|
|
8
|
+
|
|
9
|
+
export const emailIds: string[] = ids;
|
|
10
|
+
export const emailMeta: Record<string, EmailInfo> = meta;
|
|
11
|
+
export const emailCreatedAt: Record<string, number> = createdAt;
|
|
12
|
+
|
|
13
|
+
export function emailChangeIncludes(data: unknown, emailId: string): boolean {
|
|
14
|
+
if (!data || typeof data !== 'object') return false;
|
|
15
|
+
const payload = data as { emailIds?: unknown };
|
|
16
|
+
return Array.isArray(payload.emailIds) && payload.emailIds.includes(emailId);
|
|
17
|
+
}
|
package/src/app/lib/frame.ts
CHANGED
|
@@ -12,22 +12,33 @@ export type WorkspaceMessage =
|
|
|
12
12
|
| { type: 'op:inspect'; on: boolean }
|
|
13
13
|
| { type: 'op:select'; loc: string | null };
|
|
14
14
|
|
|
15
|
-
export type FrameSource =
|
|
15
|
+
export type FrameSource =
|
|
16
|
+
| { pageId: string; kind?: PageKind }
|
|
17
|
+
| { themeId: string }
|
|
18
|
+
| { emailId: string };
|
|
16
19
|
|
|
17
20
|
const BASE = import.meta.env.BASE_URL;
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
|
-
* URL the workspace loads into an iframe for a page
|
|
21
|
-
* pages mount through `frame.html`; `index.html` pages are served
|
|
22
|
-
* (dev middleware, or the per-page build under `__page/` in a static
|
|
23
|
+
* URL the workspace loads into an iframe for a page, a theme demo, or an
|
|
24
|
+
* email. React pages mount through `frame.html`; `index.html` pages are served
|
|
25
|
+
* verbatim (dev middleware, or the per-page build under `__page/` in a static
|
|
26
|
+
* build); emails are rendered on the server under `__email/`.
|
|
23
27
|
*/
|
|
24
28
|
export function frameUrl(source: FrameSource): string {
|
|
25
29
|
if ('themeId' in source) return `${BASE}frame.html?theme=${encodeURIComponent(source.themeId)}`;
|
|
30
|
+
if ('emailId' in source) return emailUrl(source.emailId, 'html');
|
|
26
31
|
if (source.kind === 'html')
|
|
27
32
|
return `${BASE}__page/${encodeURIComponent(source.pageId)}/index.html`;
|
|
28
33
|
return `${BASE}frame.html?page=${encodeURIComponent(source.pageId)}`;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** The rendered email document, its plain-text alternative, or its render report. */
|
|
37
|
+
export function emailUrl(emailId: string, format: 'html' | 'text' | 'json'): string {
|
|
38
|
+
const ext = format === 'text' ? 'txt' : format;
|
|
39
|
+
return `${BASE}__email/${encodeURIComponent(emailId)}/index.${ext}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
export function isFrameMessage(data: unknown): data is FrameMessage {
|
|
32
43
|
return (
|
|
33
44
|
!!data &&
|