無限キャンバスとデザインプロンプトを、再構成して分かったこと
元記事(オリジナル実装)を見る
まず元の実装ページを先に触りたい場合はこちら。
元記事を開く
画面を一枚作って終わりではなく、作りながら思考を広げられるUIをどう組み立てるか。
今回は、あるAIデザインツールで印象に残った要素を分解し、midori320として再構成した。この記事では、その実装と判断理由をまとめる。
狙いは三つ。無限キャンバス、自然文からのデザイン反映、そしてトークンボードとプレビューの接続だ。
ただ、この記事で本当に重要なのは見た目そのものよりプロセスにある。
生成AIで固定スキーマを作り、そのスキーマを HTML の表示ルールへ適用する。
この「生成→検証→反映」の流れを設計の中心に置くと、試行錯誤の速度と再現性が両立しやすくなる。
ここでいう「トークン」は、色・余白・角丸・フォントサイズのような、見た目のルールを名前付きで管理する最小単位の値を指している。
今回の到達点
このページは「似せた見た目」を作ることより、なぜそういう構造が必要かを掘ることを優先しています。
実装は index.html 1枚に寄せつつ、ロジックは infinite-canvas.js、design-language-input.js、design-system-board.js に分離。
さらに stitch_research 配下で調べた内容を、仕様メモではなく設計判断に落とし込みました。
まず触るならここから
最初に1つだけ置く。ここで全体の雰囲気を掴んでから、各章で「デモ→説明」の順に進める。
このあと詳しく扱う三本柱は、(1) 無限キャンバス、(2) デザインプロンプト、(3) トークンボード連動。
まずは全体フローを見て、どこで三つが接続されるかを掴んでほしい。
ここでの核心
「入力(文章)」「ルール(固定スキーマ)」「表示(CSS変数)」を分けると、試行錯誤の速度が上がる。
最初の課題は「雰囲気を変えたいのに、手作業が多すぎる」だった
最初は「雰囲気を言葉で変えたい」のに、実際の作業は色コードと余白の手作業になっていた。
これはUIを作っているようで、実際は設定ファイルを直接いじっている感覚に近い。
作業の手応えが薄く、どこを触ると何が変わるのかも追いにくい。
だから今回は、次の順番に固定した。
- 自然文を受け取る
- 固定スキーマへ正規化する
- バリデーション通過値だけを画面に反映する
この順番にした理由は単純で、途中の状態を可視化できるから。
raw と validated を分けて表示したのも、失敗を観測しやすくするためだ。
ここでやりたかったのは、直接 CSS に色や数値を打ち込む運用から少し離れることだった。
人間は自然言語で意図を説明し、AI はその説明を固定スキーマへ数値化する。
そのスキーマだけを HTML 側へ適用すれば、意図の変更と実装の変更を分離しやすくなる。
@alert[未公開部分の扱い]
調査ログでは、外部ツール側の内部データ構造や永続化の細部まで断定できない箇所があった。
そのため本文では、公開情報で確認できる事実と、自作側で採用した設計を分けて書いています。
@endalert
無限キャンバスは「広い面積」ではなく「座標系の設計」
実装で効いたのは、キャンバスを大きくすることではなく、world と viewport を分離する考え方だった。
この分離がないと、ズーム時に操作感が崩れる。
このデモでは、パンとズームの基本挙動だけに絞って確認できる。
var tx = 0, ty = 0, scale = 1;
function apply() {
world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
function zoomAt(lx, ly, factor) {
var ns = scale * factor;
var wx = (lx - tx) / scale;
var wy = (ly - ty) / scale;
scale = Math.min(MAX, Math.max(MIN, ns));
tx = lx - wx * scale;
ty = ly - wy * scale;
apply();
}
ここで重要なのは、wx/wy を先に計算している点。
「いまカーソルが指しているワールド座標」を保持してから拡大率を変えるので、ズーム後も注目点が飛びにくい。
同じ処理を中心固定だけで作ると、拡大中に視線が毎回ずれて、細かな調整が面倒になる。
次のデモは、まさにこの「カーソル位置固定」の差を体感するためのもの。
試した順序は三段階だった。
- 第1段階: 画面中央固定ズームだけ実装(簡単だが細部調整がつらい)
- 第2段階: カーソル中心ズームへ変更(操作感が一気に改善)
- 第3段階: ピンチと選択モードを共存(競合を避けるため
interactionMode を追加)
この段階分けで分かったのは、「機能追加」より「状態の衝突管理」が難しいということ。
パン・ピンチ・カードドラッグ・マーキー選択は、全部がポインタイベントを取り合う。
だから gesture、cardDrag、interactionMode を分離し、いつ何を優先するかを明示した。
以下のデモは、選択モードでの範囲指定に焦点を当てている。
自然文入力は自由に見えて、内部はかなり厳しくする
design-language-input.js 側でやっていることは、生成そのものよりも制約の付与に近い。
実装上は次の4つを固定した。
このデモは、自然文がそのままUIに流れず、固定スキーマを経由する前提を確認するために置いている。
加えて、ここは使うLLMの精度差がそのまま体験に出る。最初は Gemini 2.5 系で試したが、JSONの安定性や enum の収まりが弱く、再試行率が高かった。
Gemini 3 系へ切り替えると、固定スキーマへの収束が安定し、repair に頼る回数も減った。
- スキーマキーを固定(不足・余計なキーを許さない)
- enum 値を固定(例:
density は compact|standard|relaxed のみ)
- 色は
#RRGGBB に正規化
- 失敗時に repair を1回挟む
var SCHEMA = {
surfacePreset: ['paper', 'mist', 'night'],
density: ['compact', 'standard', 'relaxed'],
radiusSet: ['sm', 'md', 'lg'],
elevation: ['none', 'hard', 'soft'],
headingFont: ['poppins', 'inter', 'serif'],
ctaVariant: ['primary', 'secondary', 'inverted', 'outlined']
};
この方式にした理由は、トークンボード側を単純に保つため。
受け口を緩くすると、後段のUIが常に防御的なコードになってしまう。
逆に入口で厳しく整形すれば、適用側は「正しい値が来る」前提で書ける。
もうひとつ効いたのは、言語入力側とボード側を直接つながず、イベントで受け渡す構成にしたこと。
この分離によって、入力方式だけを差し替えたり、ボード側だけを改修したりしても全体が壊れにくい。
結果として「生成の入口」と「表示の出口」を別々に改善できるようになった。
次の3層デモは、この章で書いた「入力・ルール・表示の分離」を図として固定する役割。
一回目で整ったとき
- parse -> validate -> apply の一直線
- 処理が短く、デバッグも軽い
一回目で崩れたとき
- parse失敗 -> repair prompt -> 再parse -> 再validate -> apply
- 遅延は増えるが、失敗を操作不能にしない
LLMでスキーマ生成するとき、repair を入れたのは見た目より運用のため
この処理は、自然文から作った結果が「意味は合っているのに形式だけ崩れる」場面を救うために入れている。
最初の変換でうまく整っていれば、そのまま反映する。
もし形式が崩れていたら、壊れた箇所を手がかりにして、もう一度だけ整え直してから反映する。
つまり repair は常に使う機能ではなく、失敗時の復旧ルート。
これがないと、少し崩れただけで画面は更新されず、操作が止まったように見える。
repair を挟むと「完全失敗」ではなく「整えて続行」に持っていけるので、体験が途切れにくくなる。
下のデモは、この分岐の位置をシンプルに示している。
大事なのは「どこで崩れたか」を2回目に渡すこと。
手がかりなしで再試行すると同じ失敗を繰り返しやすいが、崩れた箇所を添えると整った形に戻りやすい。
トークンボードは「ルール層」、キャンバスは「レイアウト層」
調査メモを書いている時にいちばん整理できたのは、この分離だった。
見た目ルールと配置情報を同じ箱に入れると、変更のたびに衝突する。
このデモは、章の主張をそのまま左右比較にしたもの。
- ルール層: 色、密度、半径、影、フォント、フォーカスリング
- レイアウト層: ノード座標、サイズ、選択状態、遷移関係
design-system-board.js でやっているのは前者。
infinite-canvas.js でやっているのは後者。
この分離を守ると、「配色だけ変える」「配置だけ触る」が独立して高速になる。
さらに実運用では、トークンをあとで書き出して再利用できる形にしておくと強い。
つまりこの層分離は、見た目を整理するだけでなく、他画面へ持ち回すための土台にもなる。
1) 生成結果の適用対象が明確になる
2) 保存形式を分けやすくなる(トークンJSONと座標JSON)
3) 将来API連携する時、差分送信の単位を分けられる
ボードの適用処理は地味だが、ここが品質を決める
applyFromEl() でやっているのは「クリックした要素の data 属性を CSS 変数へ流し込む」こと。
単純に見えるが、ここで次の要件を同時に満たしている。
ここでは、操作と反映が一体で見えるライブプレビューを置いている。
- 選択状態の見た目更新(
is-active)
- カラーピッカーとの双方向同期
- プレビュー部品(タイトル、ボタン variant)への連動
- readout テキスト更新
if (el.dataset.dsPrimary) {
connected.style.setProperty('--ds-primary', el.dataset.dsPrimary);
setActiveAll('[data-ds-primary]', el);
}
if (el.dataset.dsDensity) {
var d = DENSITY[el.dataset.dsDensity];
connected.style.setProperty('--ds-padding-outer', d.outer);
connected.style.setProperty('--ds-gap-row', d.gap);
}
ここで意識したのは、「状態の単一化」。
最終状態はCSS変数に集約し、表示側は getComputedStyle() で観測できる形に寄せた。
これをやらないと、要素ごとに別々の状態を持って不整合が起きやすい。
モバイルを後回しにしないための最低ライン
midori320 を見直すと、モバイルを意識した実装はすでに入っている。
このデモは、その要点を確認するための補助として置いている。
meta viewport を設定し、画面幅基準でレイアウトする
- 無限キャンバス側で
touch-action: none を指定し、ジェスチャー競合を抑える
pointerdown / pointermove / pointerup を共通入力として扱い、PCとタッチを同じ系で処理する
- 2点入力を検出してピンチズームへ分岐し、1点入力はパン/選択へ分ける
- 複数の
@media で幅に応じてレイアウトを縮退させる(design-system-board.css)
- ツールバー操作ではイベント伝播を止め、キャンバス移動の誤作動を避ける
無限キャンバス系はデスクトップ前提で作りがちだけれど、スマホで触ると問題がすぐ見える。
とくに「タップ判定」「ドラッグ開始閾値」「スクロールとの競合」の3つ。
この検証を先に入れておくと、記事の説得力が上がる。
設計思想として見ると、今回やっていることは三つの分離に近い
少し理屈っぽく言うと、この実装は単に UI を作っているのではなく、三種類の情報を分けて扱っている。
1) 意図と数値の分離
人間が最初に持っているのは、「落ち着いた感じにしたい」「読みやすくしたい」のような曖昧な意図だ。
これはそのままでは CSS に入らない。
だから一度、色・間隔・角丸・見出しウェイトのような固定スキーマへ変換する。
この中間表現を挟むと、感覚的な説明をそのまま保存するのではなく、再利用しやすい設計ルールとして持てる。
2) ルールと描画の分離
トークンは「どう見せるか」を決めるルール層で、実際の HTML はその結果を受けて描画される表示層になる。
この分離は、宣言と実行を分ける考え方に近い。
先にルールだけを変えられるようにしておけば、同じ画面構造でも雰囲気だけを安全に差し替えやすい。
3) ワールド座標と画面の分離
無限キャンバス側では、カードの位置そのものと、今どこを見ているかを分けている。
位置はワールド座標として保持し、パンやズームは「カメラがどう動くか」として扱う。
この見方にすると、キャンバスが広いこと自体より、座標系とビュー変換を分けることの方が本質だと分かる。
補足: コンテキストをどこまで渡すか
この種のUIをLLMと組み合わせるときは、キャンバス全体を毎回渡すより、選択中の要素とその近傍を要約して渡す方が現実的になる。
情報を絞ると応答コストを抑えやすく、操作の意図も伝わりやすい。
つまり、空間の扱いと同時に「どの文脈を渡すか」も設計対象になる。
この三つをまとめると、今回のページは「自然言語の意図を中間表現へ落とし、そのルールを表示と操作へ配る」実験だと言える。
見た目は軽いデモでも、内部ではかなり設計寄りの考え方をしている。
ここまでの実装を整理すると
1) 操作系はモード分離が前提
無限キャンバスでは、パン・選択・カード移動が同時に存在する。
そのため interactionMode を明示して、pointerdown の入口で処理を分ける設計を採用した。
この分離によって、操作の意図と実際の挙動が一致しやすくなった。
2) 生成系は「自由入力 + 固定スキーマ」で安定する
自然文の入力自体は自由でも、適用前には extractJson() とスキーマ検証を通す。
さらに必要時だけ repair を挟むことで、変換の成功率を上げつつ挙動を安定させた。
UI 側は setStatus と setValidated(null) を持つことで、失敗時も状態が明確に残る。
3) 表示更新は最終状態を一箇所に集約する
トークン反映は applyFromEl() を中心にまとめ、最後に updateReadout() を通して整合性を取る。
色・余白・タイポグラフィを同じ更新経路に集約したことで、見た目と内部状態のズレを抑えられた。
この一貫した更新経路が、あとから機能を足す時にも効く。
関連記事
今回の内容に近い話題として、以下の記事も合わせて読むと流れがつながる。
最後に残した設計メモ
今回の再構成で得られた要点は、次の5つにまとまる。
- 無限キャンバスは、広さより座標変換の一貫性が効く
- 自然文入力は「自由入力のUI + 厳格スキーマ」の組み合わせが安定する
- repair はエラー処理ではなく、体験維持の設計要素
- ルール層とレイアウト層を分けると、改修速度が落ちにくい
- 調査メモは引用ではなく、実装判断へ変換して初めて価値が出る
次の拡張候補は、レイアウト層の永続化と、差分適用の履歴表示。
ここまで進めると、ただのデモではなく、試行錯誤そのものを再利用できる道具に近づく。