# Pro card customization: WYSIWYG research (R1, 2026-09-24, 23:05 ICT)

Question from James: Pro Companions (฿499 once, 12 months) should get about.me-style customization of `/c/<handle>`: drag the photo, pick a background, fonts, colors, layout, reorder blocks, edit text in place. Not a website builder. Is there an open-source project we can use or adapt, and what does building it cost?

Research only. Nothing in the repo was changed apart from this file.

## 1. Verdict: hybrid, mostly build. Build a small, constrained "look" editor on top of tonight's templates. Borrow MIT building blocks (dnd-kit), not a page builder.

No open-source project fits well enough to adopt as is. The builders (GrapesJS, Webstudio, Plasmic, Builder.io, Onlook) are made for designers editing free-form pages on desktops. That is the opposite of what we want: a Companion on a phone who must not be able to break the brand, the facts, the demo and Pro labels, or the LINE button. The link-in-bio clones (LinkStack, OpenBio, BioDrop) are whole competing products: AGPL or archived, and on the wrong stack. Puck is the only serious "use it" candidate (MIT, active, React 19, renders on the server). But Puck exists to edit a generic tree of components. Our card is about 8 facts and 5 sections. Its editor is desktop-first, adds about 165 KB gzipped plus Tiptap to the editor route, and every guard we need would still be our own code.

We already own most of a WYSIWYG editor. `components/editor/preview.tsx` renders the real `CardPage` and puts tap targets over its rows. `photo.focal` already exists. Tonight's `template` + `look` (with `lib/pro/entitlement.ts` `cardLook`) is the right shape for stored choices. The cheapest and safest path is to extend `look` into a small, typed, enum-only document and add a "Look" sheet to the phone editor. Keep Puck as the phase 3 fallback in case James later wants free-form sections.

## 2. Comparison of candidates

GitHub numbers come from the GitHub REST API, read 2026-09-24 around 23:10 ICT. "Fit" means fit for a constrained Pro card editor used on phones, with a server-rendered public page.

| Candidate | License (closed SaaS impact) | Activity / stars | React 19 / Next 16 / RSC | Editor weight / public page weight | Phone editing | Saved document | Constraining to our blocks | Fit | Main risk |
|---|---|---|---|---|---|---|---|---|---|
| **Puck** (`@puckeditor/core`) | MIT (fine) | v0.23.0 2026-08-07, pushed today, 13.4k | peer `react ^18 \|\| ^19`; `<Render>` + `resolveAllData` are RSC-safe | editor about 165 KB gz main chunk + about 85 KB gz second chunk (bundlephobia), pulls Tiptap and dnd-kit; public page renders with our own components, so near zero if the config is server-safe | 0.21 moved panels to the bottom on mobile; still a desktop-first canvas | JSON `{root, content[], zones}`, each item `{type, props}` | Good: you only register your own components and fields | Medium | Pre-1.0 API churn (0.21 renamed the package); generic tree is more than we need |
| **GrapesJS** | BSD-3 (fine) | v0.23.6 2026-08-26, 26.3k | framework-agnostic, not React; iframe canvas | npm unpacked 12.4 MB; outputs HTML + CSS | no touch out of the box; plugin has known tap, cursor and scroll bugs (issues #241, #2419, #4422) | HTML/CSS or project JSON | Hard: it edits arbitrary HTML/CSS | Poor | User CSS/HTML in output = XSS and CSS-injection surface; poor phones |
| **Craft.js** | MIT | last release 0.2.12 on 2025-02-14 (React 19 support was its last feature), 8.8k | React 19 yes; editor is client-only | about 480 KB unpacked core; you write the renderer | none built in | serialized node tree JSON | Good (your own components) | Low | Maintenance stalled for 19 months |
| **Builder.io SDK / Mitosis** | SDK MIT, but the visual editor is Builder's hosted SaaS; Mitosis is a compiler, not an editor | active, 8.8k / 14.4k | yes | editor lives on builder.io | hosted UI | content on Builder's cloud | Via registered components | Poor | Vendor lock-in and per-seat pricing; Companions would need Builder accounts |
| **Plasmic** | loader/SDK MIT; Studio platform AGPL-3.0 per community sources | active, 7.0k, no tagged releases | yes | Studio is a large app; self-hosting needs Docker + Postgres | desktop | Plasmic project model | Code components | Poor | AGPL Studio; built for teams, not end users |
| **Easyblocks** | AGPL-3.0 | last push 2024-08-15, 579 stars | React | n/a | no | JSON | Yes, designed for it | Poor | AGPL + abandoned |
| **Tiptap** (in-place text only) | MIT core (Pro extensions paid) | v3.31.3 2026-09-04, 38.5k | client-only | about 100+ KB gz with ProseMirror | contenteditable on iOS/Android | ProseMirror JSON | Schema is configurable | Low for us | Open IME bugs on WebKit (#7271, #2780); our text is plain, so rich text is unwanted |
| **Payload live preview** | MIT | v3, very active, 44.9k | Next App Router native | a whole CMS admin | admin panel | Payload collections | n/a | Poor | It is a CMS for staff editors, not a customer-facing editor; a second admin stack |
| **react-page (ory)** | MIT | v5.4.6 2026-07-28, 9.5k | React; last major built on older React | medium | weak | JSON cell tree | Plugins | Low | Grid-of-cells model, little momentum |
| **Webstudio** | AGPL-3.0 | 0.300.0 2026-09-22, 9.0k | its own app (Remix/React Router) | a whole Webflow-like app | desktop | its own | No (all CSS properties exposed) | Poor | AGPL + exposes all of CSS |
| **Onlook** | Apache-2.0 | v0.2.32 2025-07-17, pushed 2026-08-25, 26.8k | edits a developer's React codebase | desktop dev tool | no | writes code | n/a | None | Wrong category (a tool for designers to edit code) |
| **LinkStack** | AGPL-3.0 | v4.8.6 2026-02-17, 3.9k | PHP/Laravel | n/a | themes, no WYSIWYG | MySQL rows | n/a | None | AGPL, other stack, a competing product |
| **LittleLink** | MIT | v3.11.0 2026-07-29, 3.1k | static HTML/CSS | tiny | no editor | hand-edited HTML | n/a | Idea source only | No editor at all |
| **BioDrop** | MIT | **archived**, last push 2024-07-01, 5.7k | Next (Pages) | n/a | forms | JSON/Mongo | n/a | None | Archived |
| **OpenBio** (vanxh) | AGPL-3.0 | last push 2026-04-06, 361 | Next | bento drag grid | partial | Postgres | n/a | Idea source only | AGPL |
| **coleam00 link-in-bio builder** | no license (all rights reserved) | 2026-02-25, 227 | Next 15, React 19, dnd-kit | small | dnd-kit handles | Postgres rows | n/a | Pattern reference only | Cannot legally copy code |
| **dnd-kit** (primitive) | MIT | pushed 2026-09-12, 17.7k; `@dnd-kit/react` 0.5.0 | React 19 | about 240 KB unpacked, editor only | pointer and touch sensors, handles, keyboard | none (you own the state) | n/a (you own it) | **High, as a part** | New `@dnd-kit/react` API is 0.x; the stable `@dnd-kit/core` also works |

The license point in one line: MIT, BSD and Apache are fine for a closed SaaS. AGPL (Webstudio, Plasmic Studio, Easyblocks, LinkStack, OpenBio) would require publishing our modified source to every user who interacts with it over the network, so it is a no for Frenday.

## 3. Recommended architecture for Frenday

### 3.1 What already exists (read tonight)

- `components/editor/preview.tsx` renders the real `CardPage` scaled down, with tap targets per region (`photo, name, line, verbs, price, areas, lineId, links`), and each target opens its "ask". This is already tap-to-edit WYSIWYG. The preview IS the public component, so there is no drift between what the editor shows and what the page shows.
- `lib/card-model.ts` (tonight, P1): `template: "classic" | "poster" | "editorial" | "compact"` and `look: { accent?: CardAccent; lead?: "price" | "hours" }`. Both are stored for any card and honored only while Pro is active (`lib/pro/entitlement.ts` `cardLook`). A lapsed Pro silently falls back to `classic`, and nothing is deleted.
- `photo.focal: [x, y]` in 0..1, already used by `CardPage` for `object-position`.
- `lib/card-schema.ts`: zod `CardDraftSchema` with limits. The card lives in the kernel `records` table as data.

### 3.2 The model: extend `look`, enum ids only

```ts
// lib/card-model.ts (proposal; ids are stored, so they never change)
type CardBlock = "about" | "links" | "verbs" | "areas" | "gallery" | "quote";
interface CardLook {
  accent?: CardAccent;                 // tonight: 8 curated hues
  lead?: CardLead;                     // tonight
  ground?: "paper" | "tint" | "deep" | "backdrop"; // background: the person's paper, a hue tint, a dark ground, or one designed backdrop
  backdrop?: BackdropId;               // from lib/backdrops (8 designed scenes), only when ground = "backdrop"
  type?: "pridi" | "serif" | "round";  // a curated Thai+Latin pairing, never a font upload
  order?: CardBlock[];                 // the body blocks in the order they choose
  hidden?: CardBlock[];                // optional blocks they switched off
  photoZoom?: number;                  // 1.0..1.6, clamped; focal stays in photo.focal
}
```

Rules that keep the brand and the truth safe:

1. **Locked, not in the model:** the name, the primary LINE (or first) button with its LINE id line, the truth/trust line, the demo label ("โปรไฟล์ตัวอย่าง ตัวละครสมมติ"), the Pro label, the photo provenance/edits caption, the report link and the footer. `CardPage` always renders them in fixed positions. The editor cannot reorder or hide them, because they are not `CardBlock`s.
2. **No user CSS, no user HTML, ever.** Every knob is an enum id or a clamped number. `CardPage` maps ids to classes or custom properties through a server-side table (`components/card/look.ts`, which tonight's agent is already creating for accents). User strings never reach a `style` attribute or a stylesheet. Text stays plain strings rendered by React (escaped), with the existing length limits and the existing link normalization. This closes XSS and CSS injection by construction.
3. **Colors stay in OKLCH from a hue.** A Pro user picks an accent (curated) and a ground. The `.v-person` token math derives ink, paper and plate from it, so contrast holds (AA) by construction. Free-form hex is not offered (see question 2).
4. **Validation:** zod enums in `CardDraftSchema`. Unknown ids are dropped on read, so old cards and removed options degrade to defaults.

### 3.3 The phone editor

- Keep the step-by-step asks for content. Add one row to edit mode: **"หน้าตา / Look"** (Pro badge for free users). It opens a bottom sheet over the live preview with four tabs: Layout (tonight's 4 templates as thumbnails of the real card), Color (8 accent swatches + 3 or 4 grounds), Type (3 pairings, each shown as the person's own name), Order (block list).
- **Tap-to-edit stays the primary gesture.** Tapping the name, line or price on the preview opens the existing ask (a native `<input>`/`<textarea>`). There is no contenteditable. Native fields are the safest path for Thai input on iOS and Android keyboards, and they avoid the ProseMirror IME bugs listed above.
- **Photo:** "move and zoom" inside the 4:5 plate with pointer events (one finger pans the focal point, a slider zooms 1.0 to 1.6). This writes `photo.focal` and `look.photoZoom`. It is about 150 lines of our code, no library.
- **Block order:** a list with up/down buttons (44px) as the baseline, plus a drag handle via `@dnd-kit` (touch sensor with a press delay, so the page still scrolls). Dragging on the preview itself is not recommended on phones, because a scaled preview makes targets too small and fights scrolling.
- Every change previews instantly (client state), autosaves through the existing debounced `saveDraft`, and publishing uses the existing flow.

### 3.4 The public page stays server-rendered and light

`app/c/[handle]/page.tsx` stays a server component. `CardPage` reads `cardLook(card)` (the entitlement-resolved look), picks the template component, applies `data-template`, `data-ground`, `data-type` attributes and the accent's custom property, and renders `order` by mapping block ids to server components. It ships zero new client JS. Fonts: each pairing is a `next/font` Thai subset. A pairing's files load only on cards that use it (the classic Pridi + Anuphan stays preloaded), so a default card pays nothing. The editor bundle (dnd-kit, the sheet) loads only on the `/me` editor route.

### 3.5 Pro gating and the free fallback

This is the same rule as tonight's P1. The look is stored for everyone and resolved at render by `cardLook`: Pro active means the stored look; otherwise `classic` + photo hue + default order. Free users can open the Look sheet and preview choices ("see it on your card"), with a clear "Pro" line and the Pro door. Choices save but do not publish (their page shows classic). That is honest and it sells. Demo cards keep their demo label under every look. The kit images (`lib/kit`, Satori) should read the same resolved look in phase 2, so shared images match the page.

### 3.6 Relation to tonight's preset templates

Templates are the layout axis. Everything above sits on top of them: accent and lead are tonight's, and ground, type, order, hidden and zoom are added. Each template declares which knobs it honors. For example, `compact` might ignore `ground: "backdrop"`, and the sheet then greys that option out for it. Nothing replaces the P1 code.

## 4. Phased plan (days of Opus agent work)

**Phase 1: smallest useful slice, about 2.5 to 3.5 agent-days.**
- Look sheet in edit mode (Layout, Color, Order). Tonight's templates and accents become visual pickers on the live preview (1 day).
- Photo move and zoom on the plate, writing `focal` + `photoZoom` (0.5 day).
- Block order and hide for `about, links, verbs, areas` with up/down buttons (0.5 day). dnd-kit handle optional in this phase.
- Ground: paper / tint / deep (0.5 day, CSS on the existing tokens).
- Schema + `cardLook` + tests (zod enums, lapsed-Pro fallback, locked elements always present), editorial-rules test untouched, live check at 390px on a real phone (0.5 to 1 day).
- Risk: the four templates × grounds × accents matrix needs a contrast and screenshot sweep (the `scripts/shot.ts` recipe); budget for it.

**Phase 2: about 3 to 4 agent-days.**
- Type pairings (3, Thai-first, `next/font` subsets, measured on 4G).
- Designed backdrops as a ground (`lib/backdrops`).
- New optional blocks: `gallery` (up to 3 more of their own photos through the existing photo pipeline and consent doctrine) and `quote` (one line in their voice).
- dnd-kit drag handles.
- Kit images honor the look.
- Risk: more photos means more moderation and consent surface; fonts add weight to cards that use them.

**Phase 3: only if demand shows (about 5 to 8 agent-days).**
- Free-form section list (multiple text/photo/link sections) or a desktop editor. Re-evaluate Puck 1.x at that point, registering only our block components. It would plug in as the editor over the same JSON, since its `<Render>` is RSC-safe.
- A "look" analytics loop (which templates convert to LINE taps).
- Risk: this is where "not a website builder" erodes; hold the line with James.

## 5. Questions James must decide

1. **Phones: tap-to-edit plus buttons, or real drag-and-drop?** Recommendation: tap-to-edit for text and photo, up/down buttons for order in phase 1, drag handles in phase 2. Dragging directly on the scaled preview is not recommended.
2. **Colors: a curated palette only, or a free color picker?** Recommendation: curated (tonight's 8 accents + 3 or 4 grounds). A free picker breaks contrast and the "one sun, never text" system.
3. **Fonts: custom fonts yes or no?** Recommendation: no uploads and no Google Fonts search. Offer 3 curated Thai + Latin pairings in phase 2, or none if 4G weight matters more.
4. **Free users: preview Pro looks on their own card (saved, not published) or not see them at all?** Recommendation: preview, since it is the best sales moment.
5. **Can a Pro user hide optional blocks** (for example links or areas)? Recommendation: yes for optional blocks; never for the locked set in 3.2.
6. **Extra photos (a gallery block):** in scope for Pro at all, given the consent and moderation doctrine?
7. **Does the kit (share images) follow the page look,** or stay on the brand's classic look for recognizability?

## 6. Sources (all read 2026-09-24, 23:05 to 23:45 ICT)

- GitHub REST API repo and release data (stars, license, pushed_at, latest release) for puckeditor/puck, GrapesJS/grapesjs, prevwong/craft.js, BuilderIO/mitosis, BuilderIO/builder, plasmicapp/plasmic, easyblockshq/easyblocks, ueberdosis/tiptap, payloadcms/payload, react-page/react-page, webstudio-is/webstudio, onlook-dev/onlook, LinkStackOrg/LinkStack, sethcottle/littlelink, EddieHubCommunity/BioDrop, clauderic/dnd-kit, vanxh/openbio, coleam00/link-in-bio-page-builder: https://api.github.com/repos/{owner}/{repo} and /releases (read 2026-09-24)
- Craft.js commit history (React 19 support 2025-02-14, nothing since): https://api.github.com/repos/prevwong/craft.js/commits (2026-09-24)
- GrapesJS license (BSD-3): https://raw.githubusercontent.com/GrapesJS/grapesjs/dev/packages/core/LICENSE and https://registry.npmjs.org/grapesjs/latest (2026-09-24)
- Plasmic repo license (MIT): https://raw.githubusercontent.com/plasmicapp/plasmic/master/LICENSE.md (2026-09-24)
- npm metadata (versions, peer deps, unpacked size): https://registry.npmjs.org/@puckeditor/core , https://registry.npmjs.org/@craftjs/core/latest , https://registry.npmjs.org/@tiptap/react/latest , https://registry.npmjs.org/@dnd-kit/react/latest (2026-09-24)
- Puck bundle size: https://bundlephobia.com/api/size?package=@puckeditor/core@0.23.0 (2026-09-24)
- Puck RSC support: https://puckeditor.com/docs/integrating-puck/server-components (2026-09-24)
- Puck releases: https://github.com/puckeditor/puck/releases (2026-09-24)
- Puck 0.21 mobile layout: https://puckeditor.com/blog/puck-021 (2026-09-24)
- GrapesJS touch issues: https://github.com/GrapesJS/grapesjs/issues/241 , https://github.com/GrapesJS/grapesjs/issues/2419 , https://github.com/GrapesJS/grapesjs/issues/4422 (2026-09-24)
- Plasmic Studio licensing and self-hosting: https://forum.plasmic.app/t/is-plasmic-studio-open-source-and-can-i-self-host/11224 , https://openalternative.co/plasmic (2026-09-24)
- Tiptap/ProseMirror IME issues: https://github.com/ueberdosis/tiptap/issues/7271 , https://github.com/ueberdosis/tiptap/issues/2780 (2026-09-24)
- Builder.io quickstart (hosted editor + API key model): https://www.builder.io/c/docs/quickstart (2026-09-24)
- Payload live preview (admin-panel iframe for staff editors): https://payloadcms.com/docs/live-preview/overview (2026-09-24)
- Link-in-bio open-source landscape: https://dev.to/vanxh/introducing-openbio-an-open-source-link-in-bio-page-builder-okp , https://github.com/coleam00/link-in-bio-page-builder , https://github.com/topics/link-in-bio (2026-09-24)
- In-repo (read-only): `components/card/card-page.tsx`, `components/card/card.css`, `lib/card-schema.ts`, `lib/card-model.ts`, `lib/pro/entitlement.ts`, `components/editor/editor.tsx`, `components/editor/preview.tsx`, `docs/CARD-BRIEF.md`, `docs/design/DESIGN-SYSTEM-2026-09-24.md`, `docs/EDITORIAL-RULES.md`

Not verified tonight: GrapesJS minified bundle size (only the npm unpacked size was read); Tiptap's exact gzipped size; Thai-specific IME behavior in ProseMirror (the reports found are for CJK; Thai keyboards mostly type directly, but Gboard composes). A 30-minute phone spike would settle the last one if we ever need rich text.
