@the-portland-company/canvas-editor 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ Initial extraction of the shared VRM Canvas Editor substrate from the V1 email
6
+ canvas (`politogy-email`), per Ben Edtl's canvas-editor handoff (2026-09-10).
7
+
8
+ - **Document model** (`CanvasDocument`, `Block`, `BlockType`, `EmailSettings`)
9
+ and block registry (`BLOCK_LIBRARY`, `BlockDefinition`) — extracted verbatim.
10
+ - **Renderer** (`renderEmailHtml` / `renderEmailText` / `pinBlockSchemaVersion`)
11
+ on the server-safe root and `/renderer` subpaths.
12
+ - **Editor surface** (`<CanvasEditor>`) on the `"use client"` `/editor` subpath:
13
+ palette, canvas, selection, inline text toolbar, hover-`+` insert, per-block
14
+ properties with padding + mobile overrides, Desktop/Mobile/Dark preview,
15
+ media library, template picker, autosave with conflict detection, footer
16
+ (CAN-SPAM) block.
17
+ - **Adapter boundary** (`CanvasHostAdapter`): persistence, preview, send-test,
18
+ templates, media, personalization tokens, brand — injected by the host. The
19
+ package imports no `next/*`, `@supabase/*`, or app code.
20
+ - Host-specific metadata (email subject/preheader + deliverability panel) moved
21
+ out of the package into a host `metaPanel` slot; carried opaquely in the
22
+ document `meta` bag.
23
+ - React 18 || 19 peer; `@dnd-kit` and `vrm-builder` (skeletons) as peers.
24
+ - 66 CORE parity unit tests ported from the email app — all passing.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @the-portland-company/canvas-editor
2
+
3
+ The shared **VRM Canvas Editor** substrate for Politogy VRM apps: one block/layout
4
+ document model, one block registry, one HTML renderer, and one drag-and-drop
5
+ editor surface. Every module that needs canvas editing (Email, and later Forms,
6
+ Surveys, CTAs, landing pages) binds to this package and registers its own blocks
7
+ — nobody rebuilds the editor.
8
+
9
+ Framework-agnostic by construction: the package **never imports `next/*`,
10
+ `@supabase/*`, `@the-portland-company/shell`, or any app code**. All persistence,
11
+ media, templates, brand tokens, and merge-field resolution are injected by the
12
+ host through a single `CanvasHostAdapter`. It runs in a Next.js (React 19) app
13
+ and a Vite (React 18) app alike.
14
+
15
+ > Extracted verbatim from the V1 email canvas (`politogy-email`) so behavior is
16
+ > identical. See the audit in `politogy-email/CANVAS_EDITOR_AUDIT.md`.
17
+
18
+ ## Entry points
19
+
20
+ | Import | Contents | Environment |
21
+ | --- | --- | --- |
22
+ | `@the-portland-company/canvas-editor` | Document model, block registry, tree ops, HTML renderer, adapter **types** | **Server-safe** — pure logic, no React, no `@dnd-kit`. Import from Server Components / server actions / API routes. |
23
+ | `@the-portland-company/canvas-editor/editor` | `<CanvasEditor>` + hooks | **`"use client"`** — pulls `@dnd-kit`. Client only. |
24
+ | `@the-portland-company/canvas-editor/renderer` | `renderEmailHtml` / `renderEmailText` / `pinBlockSchemaVersion` | Server-safe. |
25
+ | `@the-portland-company/canvas-editor/skeletons` | `Skeleton` / `SkeletonBar` (self-contained, token-driven) | Server-renderable. |
26
+ | `@the-portland-company/canvas-editor/css` | Primitive component classes (`.card`/`.input`/`.btn`/`.skeleton`…) | Import once in the app. |
27
+
28
+ **Never import `/editor` from a server module** — `@dnd-kit` evaluates
29
+ `React.createContext` at module load and will crash in an RSC / server-action
30
+ context. Server code that needs block logic imports the root (`.`) only.
31
+
32
+ ## Consuming it
33
+
34
+ 1. **Install** from the registry (never `npm link`; use `npm pack` tarballs for
35
+ local dev against an unpublished change):
36
+ ```
37
+ npm i @the-portland-company/canvas-editor
38
+ ```
39
+
40
+ 2. **Tailwind `@source`** — the editor ships as Tailwind utility classes in
41
+ `dist`, and Tailwind v4 does not scan `node_modules`. Add to your
42
+ `app/globals.css` or it renders unstyled (silent — builds green, diagnose
43
+ against the live DOM):
44
+ ```css
45
+ @source "../node_modules/@the-portland-company/canvas-editor/dist";
46
+ ```
47
+
48
+ 3. **CSS + brand tokens** — import the primitive CSS, and make sure your app
49
+ already loads the runtime brand-tokens `<link>` (shell requirement). The
50
+ package paints only through `var(--token, fallback)`; colors and fonts come
51
+ from the app's runtime tokens, and dark mode flips automatically under your
52
+ app's `.dark` ancestor. The package defines **no** tokens.
53
+ ```ts
54
+ import "@the-portland-company/canvas-editor/css";
55
+ ```
56
+
57
+ 4. **Implement the adapter** and render the editor:
58
+ ```tsx
59
+ "use client";
60
+ import { CanvasEditor } from "@the-portland-company/canvas-editor/editor";
61
+ import type { CanvasHostAdapter } from "@the-portland-company/canvas-editor";
62
+
63
+ const adapter: CanvasHostAdapter = {
64
+ save: (id, doc, expectedUpdatedAt) => myServerActions.save(id, doc, expectedUpdatedAt),
65
+ renderPreview: (id, doc, opts) => myServerActions.renderPreview(id, doc, opts),
66
+ sendTest: (id, addrs) => myServerActions.sendTest(id, addrs),
67
+ templates: { list, loadInto, saveAs, resetToBlank },
68
+ media: { list, upload, updateAltText, delete: del, usageCount, trackUsage },
69
+ personalizationTokens: () => myServerActions.tokens(),
70
+ brand: () => myServerActions.brand(),
71
+ };
72
+
73
+ <CanvasEditor
74
+ adapter={adapter}
75
+ documentId={id}
76
+ initialDocument={{ name, blocks, settings, meta: { subject, preheader } }}
77
+ initialUpdatedAt={updatedAt}
78
+ nextAction={{ label: "Next: Sender →", href: senderHref }}
79
+ metaPanel={({ doc, setMeta }) => <MyEmailMetaPanel doc={doc} setMeta={setMeta} />}
80
+ />
81
+ ```
82
+
83
+ ## The boundary
84
+
85
+ - **The package owns:** the document model (`CanvasDocument` = `name` + `blocks`
86
+ + `settings` + opaque `meta`), the block registry (core blocks pre-registered),
87
+ the editor UI (palette, canvas, selection, inline toolbar, insert model,
88
+ properties panel, mobile overrides, preview modes, autosave), and the renderer.
89
+ - **The host supplies** (via `CanvasHostAdapter`): persistence, server-rendered
90
+ preview, send-test, templates, media library, personalization tokens, and
91
+ brand info. Host-specific fields (e.g. an email's subject/preheader and its
92
+ deliverability panel) live in the host and mount into the editor through the
93
+ `metaPanel` slot — they are carried in the document's opaque `meta` bag, which
94
+ the package never interprets.
95
+ - **Merge-field / UVP resolution stays in the host adapter** — provenance and
96
+ exposure are enforced at the host's data layer, never in this package.
97
+
98
+ ## Development
99
+
100
+ ```
101
+ npm run type-check # tsc --noEmit
102
+ npm test # vitest (CORE parity tests ported from the email app)
103
+ npm run build # tsup dual-build + re-assert "use client" on the editor entry
104
+ ```
105
+
106
+ Publishing is a tag-driven CI lane (`v*` tag → npm). `prepublishOnly` runs
107
+ type-check + `npm audit --audit-level=high` + tests + build.
@@ -0,0 +1,131 @@
1
+ import { C as CanvasDocument, B as Block } from './types-DJm_CBQt.js';
2
+
3
+ /** A media asset the editor can place. Mirrors the host's media row, trimmed to
4
+ * what the editor needs. */
5
+ type MediaAsset = {
6
+ id: string;
7
+ url: string;
8
+ content_type: string;
9
+ filename: string;
10
+ size_bytes: number;
11
+ alt_text: string | null;
12
+ };
13
+ /** A template the host offers in the Load Template picker. Carries `blocks` so
14
+ * the picker can render a live thumbnail, and `created_at` for the date label. */
15
+ type TemplateSummary = {
16
+ id: string;
17
+ name: string;
18
+ category: string | null;
19
+ is_curated: boolean;
20
+ blocks: Block[];
21
+ created_at: string;
22
+ };
23
+ /** Media list + the account's storage quota (host-specific), for the readout. */
24
+ type MediaListResult = {
25
+ assets: MediaAsset[];
26
+ quota: {
27
+ usedBytes: number;
28
+ quotaBytes: number;
29
+ } | null;
30
+ };
31
+ /** A personalization/merge token the host exposes. The host decides which tokens
32
+ * exist and is solely responsible for resolving them against its data at
33
+ * send/preview time (provenance + exposure enforced in the host, never here). */
34
+ type PersonalizationToken = {
35
+ token: string;
36
+ label: string;
37
+ };
38
+ /** A brand swatch surfaced in color pickers. */
39
+ type PaletteSwatch = {
40
+ label: string;
41
+ hex: string;
42
+ };
43
+ /** Brand context the editor reflects (colors + body font). `available:false`
44
+ * lets the UI show an honest "brand not loaded" note instead of fabricating. */
45
+ type BrandInfo = {
46
+ swatches: PaletteSwatch[];
47
+ fontFamily: string | null;
48
+ available: boolean;
49
+ };
50
+ type SaveResult = {
51
+ ok: true;
52
+ updatedAt: string;
53
+ document: CanvasDocument;
54
+ } | {
55
+ ok: false;
56
+ conflict: true;
57
+ current: CanvasDocument;
58
+ updatedAt: string;
59
+ } | {
60
+ ok: false;
61
+ conflict: false;
62
+ error: string;
63
+ };
64
+ type SendTestResult = {
65
+ ok: true;
66
+ } | {
67
+ ok: false;
68
+ unavailable: true;
69
+ error: string;
70
+ } | {
71
+ ok: false;
72
+ unavailable: false;
73
+ error: string;
74
+ };
75
+ type UploadMediaResult = {
76
+ ok: true;
77
+ assetId: string;
78
+ url: string;
79
+ contentType: string;
80
+ filename: string;
81
+ } | {
82
+ ok: false;
83
+ error: string;
84
+ };
85
+ type SimpleResult = {
86
+ ok: true;
87
+ } | {
88
+ ok: false;
89
+ error: string;
90
+ };
91
+ type RenderedPreview = {
92
+ html: string;
93
+ text: string;
94
+ };
95
+ interface CanvasHostAdapter {
96
+ /** Persist the document. The editor autosaves on a debounce and passes the
97
+ * `updated_at` it last saw for conflict-safety (never a hard overwrite). */
98
+ save(documentId: string, doc: CanvasDocument, expectedUpdatedAt: string): Promise<SaveResult>;
99
+ /** Server-rendered preview HTML/text (host injects account context: physical
100
+ * address, unsubscribe URL, brand font). Keeps merge-field/UVP resolution in
101
+ * the host. */
102
+ renderPreview(documentId: string, doc: CanvasDocument, opts?: {
103
+ darkMode?: boolean;
104
+ }): Promise<RenderedPreview>;
105
+ /** Send a test to the given addresses. `unavailable:true` distinguishes a
106
+ * provider/credits outage from a validation error. */
107
+ sendTest(documentId: string, addresses: string[]): Promise<SendTestResult>;
108
+ templates: {
109
+ list(): Promise<TemplateSummary[]>;
110
+ loadInto(documentId: string, templateId: string): Promise<SaveResult>;
111
+ saveAs(documentId: string, name: string): Promise<SimpleResult>;
112
+ resetToBlank(documentId: string): Promise<SaveResult>;
113
+ };
114
+ media: {
115
+ list(search?: string): Promise<MediaListResult>;
116
+ upload(file: File): Promise<UploadMediaResult>;
117
+ updateAltText(assetId: string, altText: string): Promise<SimpleResult>;
118
+ delete(assetId: string): Promise<SimpleResult>;
119
+ usageCount(assetId: string): Promise<number>;
120
+ trackUsage(assetId: string, documentId: string): Promise<void>;
121
+ };
122
+ /** Tokens the host exposes for insertion; host resolves them at render time. */
123
+ personalizationTokens(): Promise<PersonalizationToken[]>;
124
+ /** Brand colors + body font for the active org, consumed live. */
125
+ brand(): Promise<BrandInfo>;
126
+ }
127
+ /** Default personalization tokens (the V1 email set) — a host may return these
128
+ * verbatim from `personalizationTokens()` or supply its own. */
129
+ declare const DEFAULT_PERSONALIZATION_TOKENS: PersonalizationToken[];
130
+
131
+ export { type BrandInfo as B, type CanvasHostAdapter as C, DEFAULT_PERSONALIZATION_TOKENS as D, type MediaAsset as M, type PersonalizationToken as P, type RenderedPreview as R, type SaveResult as S, type TemplateSummary as T, type UploadMediaResult as U, type MediaListResult as a, type PaletteSwatch as b, type SendTestResult as c, type SimpleResult as d };
@@ -0,0 +1,131 @@
1
+ import { C as CanvasDocument, B as Block } from './types-DJm_CBQt.cjs';
2
+
3
+ /** A media asset the editor can place. Mirrors the host's media row, trimmed to
4
+ * what the editor needs. */
5
+ type MediaAsset = {
6
+ id: string;
7
+ url: string;
8
+ content_type: string;
9
+ filename: string;
10
+ size_bytes: number;
11
+ alt_text: string | null;
12
+ };
13
+ /** A template the host offers in the Load Template picker. Carries `blocks` so
14
+ * the picker can render a live thumbnail, and `created_at` for the date label. */
15
+ type TemplateSummary = {
16
+ id: string;
17
+ name: string;
18
+ category: string | null;
19
+ is_curated: boolean;
20
+ blocks: Block[];
21
+ created_at: string;
22
+ };
23
+ /** Media list + the account's storage quota (host-specific), for the readout. */
24
+ type MediaListResult = {
25
+ assets: MediaAsset[];
26
+ quota: {
27
+ usedBytes: number;
28
+ quotaBytes: number;
29
+ } | null;
30
+ };
31
+ /** A personalization/merge token the host exposes. The host decides which tokens
32
+ * exist and is solely responsible for resolving them against its data at
33
+ * send/preview time (provenance + exposure enforced in the host, never here). */
34
+ type PersonalizationToken = {
35
+ token: string;
36
+ label: string;
37
+ };
38
+ /** A brand swatch surfaced in color pickers. */
39
+ type PaletteSwatch = {
40
+ label: string;
41
+ hex: string;
42
+ };
43
+ /** Brand context the editor reflects (colors + body font). `available:false`
44
+ * lets the UI show an honest "brand not loaded" note instead of fabricating. */
45
+ type BrandInfo = {
46
+ swatches: PaletteSwatch[];
47
+ fontFamily: string | null;
48
+ available: boolean;
49
+ };
50
+ type SaveResult = {
51
+ ok: true;
52
+ updatedAt: string;
53
+ document: CanvasDocument;
54
+ } | {
55
+ ok: false;
56
+ conflict: true;
57
+ current: CanvasDocument;
58
+ updatedAt: string;
59
+ } | {
60
+ ok: false;
61
+ conflict: false;
62
+ error: string;
63
+ };
64
+ type SendTestResult = {
65
+ ok: true;
66
+ } | {
67
+ ok: false;
68
+ unavailable: true;
69
+ error: string;
70
+ } | {
71
+ ok: false;
72
+ unavailable: false;
73
+ error: string;
74
+ };
75
+ type UploadMediaResult = {
76
+ ok: true;
77
+ assetId: string;
78
+ url: string;
79
+ contentType: string;
80
+ filename: string;
81
+ } | {
82
+ ok: false;
83
+ error: string;
84
+ };
85
+ type SimpleResult = {
86
+ ok: true;
87
+ } | {
88
+ ok: false;
89
+ error: string;
90
+ };
91
+ type RenderedPreview = {
92
+ html: string;
93
+ text: string;
94
+ };
95
+ interface CanvasHostAdapter {
96
+ /** Persist the document. The editor autosaves on a debounce and passes the
97
+ * `updated_at` it last saw for conflict-safety (never a hard overwrite). */
98
+ save(documentId: string, doc: CanvasDocument, expectedUpdatedAt: string): Promise<SaveResult>;
99
+ /** Server-rendered preview HTML/text (host injects account context: physical
100
+ * address, unsubscribe URL, brand font). Keeps merge-field/UVP resolution in
101
+ * the host. */
102
+ renderPreview(documentId: string, doc: CanvasDocument, opts?: {
103
+ darkMode?: boolean;
104
+ }): Promise<RenderedPreview>;
105
+ /** Send a test to the given addresses. `unavailable:true` distinguishes a
106
+ * provider/credits outage from a validation error. */
107
+ sendTest(documentId: string, addresses: string[]): Promise<SendTestResult>;
108
+ templates: {
109
+ list(): Promise<TemplateSummary[]>;
110
+ loadInto(documentId: string, templateId: string): Promise<SaveResult>;
111
+ saveAs(documentId: string, name: string): Promise<SimpleResult>;
112
+ resetToBlank(documentId: string): Promise<SaveResult>;
113
+ };
114
+ media: {
115
+ list(search?: string): Promise<MediaListResult>;
116
+ upload(file: File): Promise<UploadMediaResult>;
117
+ updateAltText(assetId: string, altText: string): Promise<SimpleResult>;
118
+ delete(assetId: string): Promise<SimpleResult>;
119
+ usageCount(assetId: string): Promise<number>;
120
+ trackUsage(assetId: string, documentId: string): Promise<void>;
121
+ };
122
+ /** Tokens the host exposes for insertion; host resolves them at render time. */
123
+ personalizationTokens(): Promise<PersonalizationToken[]>;
124
+ /** Brand colors + body font for the active org, consumed live. */
125
+ brand(): Promise<BrandInfo>;
126
+ }
127
+ /** Default personalization tokens (the V1 email set) — a host may return these
128
+ * verbatim from `personalizationTokens()` or supply its own. */
129
+ declare const DEFAULT_PERSONALIZATION_TOKENS: PersonalizationToken[];
130
+
131
+ export { type BrandInfo as B, type CanvasHostAdapter as C, DEFAULT_PERSONALIZATION_TOKENS as D, type MediaAsset as M, type PersonalizationToken as P, type RenderedPreview as R, type SaveResult as S, type TemplateSummary as T, type UploadMediaResult as U, type MediaListResult as a, type PaletteSwatch as b, type SendTestResult as c, type SimpleResult as d };
@@ -0,0 +1,67 @@
1
+ /* Canvas Editor component classes — token-driven primitives extracted from V1 email app.
2
+ These classes are the base design-system components used by canvas-editor components.
3
+ All colors/fonts reference CSS custom properties (--color-*, --font-family) provided
4
+ at runtime by the consuming app's brand tokens. Dark mode is inherited from the app's
5
+ .dark ancestor class (not a media query), so dark theming "just works" when the app's
6
+ token values flip. */
7
+
8
+ @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
9
+ .skeleton {
10
+ display: inline-block;
11
+ border-radius: 4px;
12
+ background: linear-gradient(90deg, var(--color-surface-2) 25%, var(--color-border) 37%, var(--color-surface-2) 63%);
13
+ background-size: 200% 100%;
14
+ animation: shimmer 1.4s ease-in-out infinite;
15
+ color: transparent !important;
16
+ }
17
+
18
+ .card {
19
+ background: var(--color-surface);
20
+ border: 1px solid var(--color-border);
21
+ border-radius: var(--radius);
22
+ box-shadow: var(--shadow-sm);
23
+ }
24
+
25
+ .btn {
26
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.375rem;
27
+ height: 2.25rem; padding: 0 0.85rem; border-radius: 0.375rem;
28
+ font-size: 0.85rem; font-weight: 500; cursor: pointer;
29
+ border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text);
30
+ transition: background 0.12s, border-color 0.12s;
31
+ }
32
+
33
+ .btn:hover { background: var(--color-surface-2); }
34
+
35
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
36
+
37
+ .btn-primary { background: var(--color-accent); border-color: var(--color-accent); color: #fff; }
38
+
39
+ .btn-primary:hover { background: var(--color-accent-hover); }
40
+
41
+ .btn-danger { background: color-mix(in srgb, var(--color-red) 90%, black); border-color: var(--color-red); color: #fff; }
42
+
43
+ .btn-sm { height: 1.85rem; padding: 0 0.6rem; font-size: 0.78rem; }
44
+
45
+ .input, .textarea, .select {
46
+ width: 100%; background: var(--color-surface); color: var(--color-text);
47
+ border: 1px solid var(--color-border); border-radius: 0.375rem;
48
+ padding: 0.5rem 0.65rem; font-size: 0.875rem;
49
+ }
50
+
51
+ .textarea { resize: vertical; min-height: 6rem; line-height: 1.5; }
52
+
53
+ .input:focus, .textarea:focus, .select:focus { outline: 2px solid color-mix(in srgb, var(--color-accent) 40%, transparent); outline-offset: 0; border-color: var(--color-accent); }
54
+
55
+ .label { font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); margin-bottom: 0.25rem; display: block; }
56
+
57
+ .muted { color: var(--color-text-muted); }
58
+
59
+ .pill { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; font-weight: 600; padding: 0.12rem 0.5rem; border-radius: 999px; white-space: nowrap; }
60
+
61
+ .chip { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.7rem; font-weight: 600; padding: 0.1rem 0.4rem; border-radius: 4px; background: var(--color-surface-2); color: var(--color-text-muted); }
62
+
63
+ .phone-frame {
64
+ width: 100%; max-width: 20rem; margin: 0 auto;
65
+ border: 8px solid var(--color-border); border-radius: 2rem;
66
+ background: var(--color-surface-2); padding: 1rem 0.75rem; min-height: 12rem;
67
+ }