Tokens and Primitives
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”design system ที่ใช้ร่วมกัน @mosaic/design-system ประกอบด้วยสอง layer ที่ทุก framework ใน Mosaic consume ได้:
- Design tokens — ค่าดิบ (colour, spacing, radius) expose เป็น CSS custom property:
--m-color-primary,--m-space-3เป็นต้น - Primitives — custom element แบบ Web Component ไม่กี่ตัวที่สร้างบน token พวกนั้น:
<m-button>,<m-card>,<m-badge>
catalog เป็น React, cart เป็น Svelte, content site เป็น Astro ถ้าแต่ละทีม style ปุ่มของตัวเอง Mosaic จะดูเหมือนสาม product ต่างกันมาเย็บติดกัน — ซึ่งในเชิงภาพคือสิ่งที่เรากำลังพยายามเลี่ยงพอดี token บวก custom element ให้ปุ่มเดียว นิยามครั้งเดียว ที่ render เหมือนกันทุกที่
บทนี้สร้าง package พร้อม primitive สามตัว เราเจอ pattern custom-element มาแล้วใน Web Components Interop →; ตรงนี้เราใช้อย่างตั้งใจเป็น shared-UI layer
design system สำหรับแอป framework เดียว ง่าย: ship React component library ไป Mosaic ทำแบบนั้นไม่ได้ เพราะ React <Button> render ข้างใน Svelte cart หรือหน้า Astro ไม่ได้ เราต้องการ primitive ที่มี runtime เป็น browser เอง
สอง feature ของ web platform ทำให้เรื่องนี้ทำงานได้:
- Custom elements ห่อ structure และ behaviour ไว้หลัง DOM tag จริง (
<m-button>) framework ไหนก็ render tag ได้ - CSS custom property inherit ทะลุ shadow boundary token ที่ set บน
:rootเข้าถึงข้างใน shadow root ของทุก primitive ข้อเท็จจริงเดียวนั้นคือเหตุผลที่ token กับ shadow DOM ประกอบกันได้: primitive ยัง encapsulate อยู่ แต่ theme ยังไหลเข้าไปได้
การแบ่งจึงตั้งใจ token เปิด (global, themeable, override ต่อ surface ได้); ภายในของ primitive ปิด (encapsulate ใน shadow DOM เพื่อไม่ให้ stylesheet ของ remote ตัวไหนรั่วเข้ามาทำพัง)
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Tokens as CSS custom properties vs. tokens as a JS/TS object
- Pros: CSS variable เป็น live และ cascade — set
--m-color-primaryบน wrapper แล้วทุกอย่างข้างใต้ re-theme โดยไม่ต้องมี JS และ CSS variable ข้าม shadow boundary ได้ฟรี shadow-DOM primitive จึงอ่านค่าได้โดยไม่ต้องต่อสายอะไร - Cons: ไม่มี type checking ตอน import; พิมพ์ผิดแบบ
--m-colour-primaryfail เงียบ ๆ ไปที่ fallback คุณเสีย autocomplete ที่ typed token object จะให้
Primitives as Web Components vs. one component library per framework
- Pros: นิยามครั้งเดียว render โดย React, Svelte และ Astro เหมือนกัน ไม่ต้อง port ต่อ framework ให้คอย sync; ปุ่มจะ drift ระหว่างทีมไม่ได้
- Cons: custom element เขียนยืดกว่า framework component และ form participation (label,
disabled, focus) ต้องลงแรงตั้งใจซึ่ง native<button>ให้ฟรี
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. packages/design-system/package.json
หัวข้อที่มีชื่อว่า “1. packages/design-system/package.json”ESM package ธรรมดา ไม่ต้องมี build step ตอน dev — Vite consume .ts source ตรง ๆ ผ่าน workspace
{ "name": "@mosaic/design-system", "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.ts", "./tokens.css": "./src/tokens.css" }}2. packages/design-system/src/tokens.css
หัวข้อที่มีชื่อว่า “2. packages/design-system/src/tokens.css”token อยู่บน :root เพื่อให้ inherit ทุกที่ — รวมถึงเข้าไปใน shadow DOM ของทุก primitive
:root { /* Colour */ --m-color-primary: #8b5cf6; --m-color-primary-ink: #ffffff; --m-color-surface: #ffffff; --m-color-border: #e5e7eb; --m-color-text: #1f2937; --m-color-muted: #6b7280; --m-color-danger: #dc2626;
/* Spacing scale */ --m-space-1: 4px; --m-space-2: 8px; --m-space-3: 12px; --m-space-4: 16px;
/* Shape & type */ --m-radius: 10px; --m-font: system-ui, -apple-system, "Segoe UI", sans-serif;}3. packages/design-system/src/m-button.ts
หัวข้อที่มีชื่อว่า “3. packages/design-system/src/m-button.ts”custom element ที่มี shadow root แบบ encapsulate เราใช้ constructable stylesheet ที่ share ข้ามทุก instance และอ่าน token ทะลุ shadow boundary ตรง ๆ ผ่าน var(--m-...) — สังเกต fallback ไว้ด้วย เพื่อให้ปุ่มยังดูโอเคถ้า consumer ลืม import tokens.css
const sheet = new CSSStyleSheet();sheet.replaceSync(` :host { display: inline-block; } button { font: 600 14px/1 var(--m-font, sans-serif); color: var(--m-color-primary-ink, #fff); background: var(--m-color-primary, #8b5cf6); border: 0; border-radius: var(--m-radius, 8px); padding: var(--m-space-2, 8px) var(--m-space-4, 16px); cursor: pointer; } button:hover { filter: brightness(1.05); } button:disabled { opacity: 0.5; cursor: not-allowed; } :host([variant="ghost"]) button { background: transparent; color: var(--m-color-primary, #8b5cf6); box-shadow: inset 0 0 0 1px var(--m-color-border, #e5e7eb); }`);
export class MButton extends HTMLElement { static observedAttributes = ["disabled"];
connectedCallback() { if (this.shadowRoot) return; // already mounted const root = this.attachShadow({ mode: "open" }); root.adoptedStyleSheets = [sheet]; root.innerHTML = `<button><slot></slot></button>`; this.#sync(); }
attributeChangedCallback() { this.#sync(); }
#sync() { const btn = this.shadowRoot?.querySelector("button"); if (btn) btn.disabled = this.hasAttribute("disabled"); }}4. packages/design-system/src/m-card.ts and m-badge.ts
หัวข้อที่มีชื่อว่า “4. packages/design-system/src/m-card.ts and m-badge.ts”<m-card> เป็น container ที่มี slot; <m-badge> เป็น pill ติด label เล็ก ๆ พร้อม attribute tone
const cardSheet = new CSSStyleSheet();cardSheet.replaceSync(` :host { display: block; background: var(--m-color-surface, #fff); border: 1px solid var(--m-color-border, #e5e7eb); border-radius: var(--m-radius, 8px); padding: var(--m-space-4, 16px); color: var(--m-color-text, #1f2937); }`);
export class MCard extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; const root = this.attachShadow({ mode: "open" }); root.adoptedStyleSheets = [cardSheet]; root.innerHTML = `<slot></slot>`; }}const badgeSheet = new CSSStyleSheet();badgeSheet.replaceSync(` :host { display: inline-block; font: 600 12px/1 var(--m-font, sans-serif); padding: var(--m-space-1, 4px) var(--m-space-2, 8px); border-radius: 999px; background: var(--m-color-primary, #8b5cf6); color: var(--m-color-primary-ink, #fff); } :host([tone="danger"]) { background: var(--m-color-danger, #dc2626); }`);
export class MBadge extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; const root = this.attachShadow({ mode: "open" }); root.adoptedStyleSheets = [badgeSheet]; root.innerHTML = `<slot></slot>`; }}5. packages/design-system/src/index.ts
หัวข้อที่มีชื่อว่า “5. packages/design-system/src/index.ts”entry point register ทุก primitive ครั้งเดียว guard customElements.get สำคัญใน micro-frontend: remote สองตัวอาจต่าง import package และการ define tag เดิมซ้ำจะ throw
import { MButton } from "./m-button.js";import { MCard } from "./m-card.js";import { MBadge } from "./m-badge.js";
function define(tag: string, ctor: CustomElementConstructor) { if (!customElements.get(tag)) customElements.define(tag, ctor);}
define("m-button", MButton);define("m-card", MCard);define("m-badge", MBadge);
export { MButton, MCard, MBadge };ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”install workspace แล้ว register primitive จากแอปตัวไหนก็ได้ที่พึ่ง @mosaic/design-system
pnpm --filter @mosaic/design-system installในหน้า HTML แบบ scratch (หรือ index.html ของ shell) import token และตัว registrar แล้ววาง tag ลงไป:
<link rel="stylesheet" href="/@fs/.../packages/design-system/src/tokens.css" /><script type="module"> import "@mosaic/design-system";</script>
<m-card> <m-badge>New</m-badge> <p>Runtime-composed storefront</p> <m-button>Add to cart</m-button> <m-button variant="ghost">Details</m-button></m-card>คุณควรเห็น:
- card ที่มี border, pill สีม่วงอ่านว่า New, ปุ่มสีม่วงทึบ และปุ่ม ghost — ทั้งหมดดึงจาก
--m-color-primary: #8b5cf6 - ใน DevTools แต่ละ
<m-button>มี#shadow-root (open)พร้อม<button>ของตัวเองข้างใน - เปลี่ยน
--m-color-primaryใน Elements panel แล้ว primitive ทุกตัว re-theme ทันที — พิสูจน์ว่า token ข้าม shadow boundary ไปแล้ว
ยืนยันว่า package type-check ผ่านและ workspace build สะอาด:
pnpm --filter @mosaic/design-system exec tsc --noEmit# then, from the shell:pnpm --filter shell build # succeeds; the design-system source resolves through the workspaceCheck your understanding:
- ทำไม CSS custom property ที่ set บน
:rootstyle ข้างใน shadow DOM ของ primitive ได้ ในเมื่อ class selector ธรรมดาทำไม่ได้? - อะไรพังถ้า remote สองตัวต่างเรียก
customElements.define("m-button", ...)และ guard ในindex.tsป้องกันเรื่องนี้ยังไง? - ทำไม token จึงตั้งใจให้ global (เปิดให้ override) ส่วน internal style ของ primitive ตั้งใจให้ encapsulate (shadow DOM)?
var(--m-color-primary, #8b5cf6)แต่ละตัวพก fallback ที่ hard-code ไว้ fallback นั้นป้องกัน failure แบบไหน?
เราสร้าง @mosaic/design-system: token เป็น CSS custom property บน :root และ primitive สามตัว — <m-button>, <m-card>, <m-badge> — เป็น custom element แบบ encapsulate ที่อ่าน token พวกนั้นทะลุ shadow boundary นิยามเดียว หน้าตาเดียว ไม่มี copy ต่อ framework การ register เป็น idempotent เพื่อให้หลาย remote import ได้อย่างปลอดภัย
ต่อไปเราเอา tag ชุดเดียวกันนี้ไปใช้งานในทั้งสาม framework พร้อมกัน: Consuming Everywhere →