ガイド 第44

デザイン専用カスタマイズ

design.ts・theme token・スロット

@event/design

イベントの見た目は apps/<event>/src/app/globals.css の CSS 変数 1 組で決まる(--bg / --accent / --ink …)。recipe「新イベント」§4 でも「唯一のデザイン面 = globals.css の CSS 変数」と書いた通りで、そこは変わらない。@event/design はその変数を 型付きで宣言し、:root{…} 文字列を生成する薄い純関数層である。React にも DOM にも依存せず、新規外部依存もゼロ。手書き CSS の代わりに「イベントはこの 1 ファイルだけ触ればデザインが変わる」を型安全に成立させる。

@event/visuals の EventBrand(第17章)が ビジュアル生成(SNS / 会場パネル)の reskin surfaceで名前空間が --vis-* なのに対し、@event/designサイト本体(LP / 管理画面)の reskin surfaceで、名前空間は globals.css の実変数名そのもの。両者は「DEFAULT を base に部分定義をマージ → CSS 変数注入」という同じ骨格を共有する姉妹パッケージである。

DesignTokens — globals.css 変数への写像

DesignTokens は 4 グループ(colors / fonts / radius / space)。色トークンは globals.css の 8 変数に 1:1 対応する。写像は tokens.tsCOLOR_VARS / FONT_VARS / … マップが唯一の真実源:

DesignTokensCSS 変数既存 / 追加
colors.bg--bg既存
colors.surface--surface既存
colors.ink--ink既存
colors.ink2--ink-2既存
colors.ink3--ink-3既存
colors.line--line既存
colors.accent--accent既存
colors.accentSoft--accent-soft既存
fonts.mono--mono既存
fonts.sans--sans追加(既定 = globals.css body の font-family)
fonts.display--display追加(既定 = sans)
radius.{sm,md,lg}--radius-*追加
space.{xs..xl}--space-*追加

「既存」= globals.css に実在する変数なので、同名で注入すると既定を差分だけ上書きする(別名の --color-primary のような命名は作らない = 実在に揃えるのが肝)。「追加」= globals.css にまだ無い変数で、注入しても既存要素は誰も参照していないため見た目は変わらない(イベントが自分の component で var(--sans) を読み始めた時に効く、inert なトークン)。

defineDesign — 部分宣言を既定で補完

// apps/starter/src/design.ts — イベントが触る唯一のデザイン面
import { defineDesign, type EventDesign } from "@event/design";

export const eventDesign: EventDesign = defineDesign({
  name: "starter",
  tokens: { colors: { accent: "#0b6ea8", accentSoft: "rgba(11,110,168,0.08)" } },
  dark: { colors: { accent: "#4bb6e8", accentSoft: "rgba(75,182,232,0.12)" } },
});

defineDesign(partial?) は各グループを DEFAULT_LIGHT_TOKENS / DEFAULT_DARK_TOKENS(= globals.css の実値)ベースに浅くマージする。上の例はアクセントだけ差し替え、bg / ink / surface / line は未指定 = 既定のまま。返り値 EventDesign は identity(name)+ 解決済み tokens(ライト)+ dark(ダーク or null)+ slots を持つ。

tokensToCssVars — 注入用 CSS を生成

tokensToCssVars(eventDesign);
// :root {
//   --bg: #f1f3ef; --surface: #fafbf9; … --accent: #0b6ea8; …
// }
// @media (prefers-color-scheme: dark) {
//   :root { … --accent: #4bb6e8; … }
// }

tokensToCssVars(design, selector = ":root")globals.css と同じ構造:root{} + @media (prefers-color-scheme: dark){ :root{} })を吐く純関数。ダークも同じセレクタで emit するので、注入後も OS のライト/ダーク切替に追従したまま壊れない。darknull にするとダークブロックを出さない(ライト固定)。

layout.tsx への注入

// apps/starter/src/app/layout.tsx
import { tokensToCssVars } from "@event/design";
import { eventDesign } from "@/design";
import "./globals.css";

// <head> 内、globals.css import の後に:
<style dangerouslySetInnerHTML={{ __html: tokensToCssVars(eventDesign) }} />

globals.css を読み込んだ後の <head> で同名変数を出すので、書いた項目だけが既定を上書きする。未指定トークンは defineDesign が既定で埋めるため注入 CSS は globals.css と同値になり、既存の見た目は壊れない。

slots — セクション別の上書き

ヘッダやフッタだけトークンを差し替えたいときは slots を使う。scope した CSS セレクタに、指定したトークン「だけ」を emit する:

defineDesign({
  name: "starter",
  slots: [
    { selector: "header", tokens: { colors: { surface: "#0e1512", ink: "#e5eae6" } } },
    { selector: '[data-region="footer"]', tokens: { colors: { accent: "#c0492f" } } },
  ],
});
// → header { --surface: #0e1512; --ink: #e5eae6; }
//   [data-region="footer"] { --accent: #c0492f; }

slot は部分トークンなので、指定キーだけがそのセレクタ配下で上書きされる(未指定は :root の値を継承)。

DESIGN_PRESETS — 丸ごと切り替える

DESIGN_PRESETSsystem / light / noir / warm)は defineDesign への部分入力。1 行差し替えで基調が変わり、更にイベント固有色を合成できる:

defineDesign(DESIGN_PRESETS.noir);                       // 黒基調・琥珀アクセント
defineDesign({ ...DESIGN_PRESETS.warm, name: "spring" }); // 生成り紙 + 朱、名前だけ上書き

Worldview — 世界観1宣言で4面すべてへ(上位層)

デザイン面はサイト本体だけではない。ビジュアル(@event/visuals EventBrand)・セッションカード (@event/session-card CardTheme)・メール(@event/mailer Brand)を含めて 4面ある。 defineWorldview はその上流の単一宣言で、純関数の派生器が各面の入力形を導出する:

import { defineWorldview, worldviewToDesignInput, worldviewToBrand,
         worldviewToCardTheme, worldviewToMailBrand } from "@event/design";

const worldview = defineWorldview({
  name: "myevent",
  palette: { bg: "#0c0e12", surface: "#14161c", ink: "#f2f0ea", accent: "#c8a24b" },
  typography: { display: '"Shippori Mincho B1", serif', body: '"Noto Sans JP", sans-serif' },
  mood: { radius: "sharp", visualsScheme: "auto", watermarkGlyph: "M" },
});

defineDesign(worldviewToDesignInput(worldview)); // 面1: サイト CSS 変数
resolveBrand(worldviewToBrand(worldview));       // 面2: ビジュアル EventBrand
worldviewToCardTheme(worldview);                 // 面3: CardTheme(themes prop へ)
worldviewToMailBrand(worldview);                 // 面4: メール Brand(env が上書き優先)

派生の要点:

  • hex 前提の色演算withAlpha / mix / isDark も export)。ink2/ink3/line/hair 系は

ink・fg の alpha から、accentDeep/dark アクセントは混色から導出

  • bg が暗色なら世界全体が暗くなる — design 面は dark: null(OS ダークですり替えない)、

舞台・plate 色も自動反転

  • mood.visualsScheme(dark/light/auto)でカード・書き出し画像だけダーク舞台に置ける

(サイトは明るく、紙・SNS 画像は暗くという定石を1ノブで)

  • メールは暗色世界ではライト shell に accent だけ持ち込む(到達性優先の設計判断)
  • 派生先の型は構造的互換(@event/design は他パッケージに依存しない)。適合はイベント app の

typecheck が証明する

配線の完全手順はレシピ apply-worldview。カバー範囲はトークンまで — ページ構成・レイアウト・ モーションは worldview では決まらない(参照実装とイベント側コードの責務)。

まとめ — イベントが触るのは worldview.ts(または design.ts)だけ

  • エンジン(@event/design)はトークン型と写像と生成関数だけを持ち、色や書体は焼かない
  • 推奨は src/worldview.ts の1宣言 → 4面派生(レシピ apply-worldview)。design.ts 単面の

defineDesign({...}) 直書きも従来どおり有効

  • 以後デザイン変更は worldview.ts の値を変えるだけ。globals.css の既定と同じ変数名に写像されるので、差分だけが効き、既存レイアウトは壊れない