SessionCardDesigner(@event/session-card)は ivs の /admin/session-card/[id] designer の忠実移植 + 汎用化である。Session / API 直結を外し、CardModel を編集して onChange(patch) を発行するだけの存在にした。永続化は呼び出し側の仕事で、onSave prop があるときだけ SAVE ボタンが出る。
機能一式
- 登壇者ピックアップ & 並び替え — native HTML5 DnD + ↑↓ ボタン。
allSpeakers(母集団)から show / hide を切り替え、表示中のみがmodel.speakersとして patch される - タイトル / 役職の
\n明示改行上書き — textarea 1行 = カード1行 - 倍率スライダー — 全体
scale(0.6–1.6)/titleScale/roleScale/ per-speakernameScales[id](0.5–1.6) - theme / lang / variant 切替 — variant は表示人数からの
recommendedVariantが基本で、明示的に多い枠(upsize)だけ選べる - ライブプレビューは
<ScaledCard>(ResizeObserver で実測 scale。calc(100cqw / 1920px)はブラウザ互換が不安定なため JS 実測方式)
内部編集状態は mount 時の model から初期化する defaultValue 方式なので、別カードへ切り替えるときは key={model.id} で remount する。
onResolved auto-prefill — 核心トリック
textarea の初期値問題: 「いまカードが実際にどう改行しているか」を近似計算で出すと、プレビューと textarea が微妙にずれる。ivs の解法はカード自身に教えさせることである。
- 画面外に「上書きを全部空にした純自動カード」(
autoModel)をもう1枚描画する - そのカードがレイアウト確定時に
onResolved: (r: ResolvedCardLines) => voidで自分が実際に描画した行割り(titleLines/roleLines: Record<speakerId, string[]>)を親へ報告する - designer はこれをユーザー未編集の textarea に流し込む。カードの実計算そのものなので、プレビューの改行と textarea が完全一致する
ユーザーが手で書き換えた行は editedRoles(Set<speakerId>)/ titleEdited で追跡し、プレフィルで潰さない。↺ ボタンで編集フラグを外すと auto カードの行割りに戻る。プレフィルは表示用であって上書きではないため、onChange は発行しない — 保存されるのは「ユーザーが編集した行」だけで、自動改行を上書きとして凍結しない。
永続化 — OverlayStore single-record PATCH
createContentOverlayStore({ store, kv? })(@event/session-card/persist、server 専用 subpath)が ivs の修正済みプロトコルを実装する:
- single-record PATCH: 対象セッション1件だけを read-modify-write する。旧実装の「配列全体を null hash で保存 → 他レコードを黙って消す」バグの根治
expectedHash楽観ロック + 競合時は最新を取り直して最大maxRetry(既定6)回リトライ- overlay の各フィールドは
undefined= 触らない /null= 消す のセマンティクス - 対象フィールドは
@event/schemaのCARD_OVERLAY_FIELDS登録簿駆動(content 読み込み・KV ミラーと同じ集合。ドリフト不能) - 成功後、全 overlay を KV(key
cms:session-overlay、TTL 15分)へミラーし、ISR を待たず公開側へ即時反映する。KV 失敗は握りつぶす(content には保存済み。リビルドで反映される)
cardSpeakerIds のタイムスタンプ規則
last-writer-wins(第6章)の手動側基準 cardSpeakerIdsUpdatedAt は、値が実際に変わった時だけサーバ時刻を刻む。同じ並びを保存し直しただけで時刻が更新されると、4S 側の新しい lineup 編集を不当に打ち負かすためである。選択をクリアした場合はタイムスタンプごと削除する。
starter の配線例
// CardStudio.tsx — CardModel → CardOverlay の写像(lang で JP/EN どちらへ書くか分岐)
function modelToOverlay(model: CardModel): CardOverlay {
const isEn = model.lang === "en";
return {
cardVariant: model.variant,
cardTheme: model.theme,
cardSpeakerIds: model.speakers.map((s) => s.id),
cardScale: model.scale,
cardTitleScale: model.titleScale,
cardRoleScale: model.roleScale,
cardNameScales: model.nameScales,
...(isEn
? { cardTitleEn: model.titleOverride ?? null, cardRolesEn: model.roleOverrides }
: { cardTitle: model.titleOverride ?? null, cardRoles: model.roleOverrides }),
} as CardOverlay;
}
server action 側は adminAuth.isAuthedFromValue(cookie) を再検査してから overlayStore.patch(sessionId, overlay) を呼ぶ。ページ側は listSessionsFromEvent(config, { overlay }) に content JSON の overlay を注入し、resolveCardModel(session, speakers) で描画用 model を組む。