高密度なデスクトップクラス Web アプリのスペーシングアーキテクチャ
文章主体のサイト向けのスペーシング指針は、ツールバーやダイアログが密集するアプリ UI では通用しなくなる。素のスケールステップではなくロールトークン、2本のラダーによるペアリングルール、ディスプレイスケールの黄金律とその例外、そしてラチェット lint による移行パターン。
問題
このサイトの他の場所にあるスペーシングの指針 ―― タイトなセマンティックスケール、hsp/vsp 軸、ゆったりしたリズム ―― は、文章主体のサイトの経験から書かれたものです。1 カラムのドキュメント、セクション、見出し、段落があり、最大のトークンでも 100〜250px に届く程度の世界です。デスクトップクラスのアプリはまったく別の生き物です。エディタ、メールクライアント、IDE のようなツールは、その大部分がアプリの外装(chrome)です ―― ツールバー、ダイアログ、コンテキストメニュー、ピッカー、ツリー、ボードビュー。高さ 25〜50px の行が数十個も 1 つのビューポートを共有し、しかもユーザーが操作できるディスプレイスケールがあらゆる寸法に掛け合わされることも珍しくありません(ディスプレイスケール戦略を参照)。
そうしたアプリ 1 つのスペーシングを端から端まで再設計し ―― スペーシングトークンの使用箇所はおよそ 3,000 件、getComputedStyle で前後を実測しました ―― 文章主体のサイトの指針では予測できない 2 つの発見がありました。
目に見えるバグが不正な値だったことは一度もありませんでした。 壊れていた面はどれも正当なトークンを使っていました。失敗していたのは関係です。カード間のギャップ(約 25px)がカード内の行リズム(約 24px)と一致してしまい、カードが 1 つのリストに融合したタイムライン。「縦のリストから 1 行を選ぶ」というたった 1 つのロールに対して、5 種類の行の密度(高さ 25、29、32、49、56px)。
トークンレベルの不整合はほとんど不可視でした。 ダイアログの水平インセットが 4 種類(24/20/16/12px)も何ヶ月も共存していましたが、誰も気づきませんでした ―― 画面に表示されるダイアログは一度に 1 つだけだからです。2px 違うツールバーのパディング、1px 違うバッジ ―― 実測できても知覚できません。
この 2 つを合わせると、通常のクリーンアップの予算配分がひっくり返ります。あらゆるトークンの不整合を追いかけるのはほとんど無駄な作業であり、成果が生まれるのは、トークン監査では表現すらできない一握りの比率・グルーピング・ロールの失敗のほうです。
この記事の根拠について
この記事に出てくる実測値、件数、比率は、すべて1 つのアプリ ―― Tauri v2 のデスクトップツールを getComputedStyle で前後計測したもの ―― から得たものです。これらは例示のための数字ではなく実際の数字ですが、あくまで 1 プロジェクトの数字です。問題の形を示す実例として読み、そのままコピーできる閾値としては扱わないでください。とくに知覚に関する判定 ―― 「融合する」「知覚できない」「1 つのグループとして読まれる」 ―― は、チームによる目視レビューの判断であり、ユーザー調査の結果ではありません。この記事がルールを述べている箇所は、3,000 件の使用箇所との接触を生き延びたハウスルール(house heuristic)です。採用する前に、自分のアプリのレンダリング結果で検証してください。
解決方法
アプリのスペーシングアーキテクチャには「スケールを使え」とは別の仕組みが必要です。次の 5 つの要素があり、それぞれが以降のセクションに対応します。
| 要素 | 何を決めるか |
|---|---|
| レンダリングされたピクセルの診断 | 実際に壊れているのはどこで、どこには手を出さないか |
| 2 本のラダーによるペアリングルール | グルーピングの境界でどの 2 つのギャップが隣り合ってよいか |
| ロールトークン | 頻出する面を何と呼ぶか。それによってずれが止まる |
| 共通領域 | グループ内のリズムを自分で決められないときに、どうグルーピングするか |
| ディスプレイスケールの契約 | ズームに合わせて掛け合わされる寸法はどれで、掛け合わせてはいけない寸法はどれか |
そのうえで、移行に耐える lint(ラチェット)が結果をその場に固定します。順序が重要です。まず診断してください ―― 残りのどれが実際に必要かを教えてくれるのは、診断だからです。
設計する前にレンダリングされたピクセルを診断する
grep のヒット数は、視覚的なインパクトについて両方向に嘘をつきます。何かを決める前に、ユーザーが見ているとおりにアプリを計測しましょう。すべての面を固定のマトリクス(空 / データあり / オーバーレイ表示中 / 長文 / フォーカス状態、デスクトップ幅とモバイル幅、複数のディスプレイスケール)でスクリーンショットに撮り、実際のギャップを getComputedStyle / getBoundingClientRect で読み取ります。そのうえで、画面上でグルーピングがどれだけひどく誤読されるかで問題を順位付けします ―― その不整合が何ファイルにまたがっているかではありません。
きっかけとなったプロジェクトでは、実測による順位はこうなりました。
| 順位 | 対象 | 実測値 | 判定 |
|---|---|---|---|
| 1 | タイムラインのカード | カード間 約 25px vs カード内リズム 約 24px(比率 ≈ 1.0×) | カードが 1 つのリストに融合する |
| 2 | メニューの行 | 1 つのロールに 5 つの密度: h25/h29/h32/h49/h56 | 密度の混沌 |
| 3 | コマンドパレット | py-12/h49 vs ドロップダウンの py-4/h29 | 2× の外れ値の行 |
| 4 | 区切り線 | 線の上 約 20px / 下 12px | セパレーターの役割が曖昧 |
| — | ダイアログのインセット 24/20/16/12 | 表示されるダイアログは一度に 1 つ | 不可視 ―― 優先度を下げる |
| — | ツールバー 2px / バッジ 1px の差 | 知覚の閾値以下 | 不可視 ―― 優先度を下げる |
すでに正しいと実測された 2 つの面は、その後のあらゆる判断の基準点になりました。カンバンのカードリスト(カード↔カード 6px vs カード↔追加アフォーダンス 26px = 4.3×)と、設定フォーム(ラベル↔コントロール 10px vs グループ↔グループ 28px = 2.8×)です。自分のアプリで検証済みの比率を見つけたら、書き留めておきましょう ―― 「明確なグルーピングの境界はここではおよそ 2.5×〜4.5× に収まり、決して 2× を下回らない」は、「いい感じに空ける」よりはるかに具体的な指示であり、しかも経験則ではなく自分の面から導かれたものです。
ここから導かれる原則はこうです。ピクセルが目に見えて壊れているところにだけ、複雑さを費やす。 診断によって欠陥が不可視だと判明したところは、保守性のために 1 つの値へ統一して、そこで終わりにします ―― ロールトークンも、再設計も、議論も不要です。
2 本のラダーによる比率のドクトリン
スペーシングの設計思想は近接のルールを定めています。グループ間のギャップはグループ内のギャップより明らかに大きく、目安として 2 倍以上でなければなりません。アプリで難しいのは、そのルールを密なスケールの上で運用可能にすることです。アプリの chrome は細かい制御のために近接したステップ(2、4、6、8、12px…)を必要とします ―― そして密なスケールでは隣り合うステップの差は 1.2〜1.5× であり、これはまさに読み取れない比率のバンドです。比率を広げるためにステップを削除するのは、何千もの正当なグループ内の使用箇所がそれらに依存している以上、純粋な無駄作業です。
解決策は、スケールを小さくすることではなく、構成のトリックです。倍々で組み立てられた密なスケールには、通常 2 本の ×2 ラダーが交互に織り込まれています。
Ladder A (×2): 4 → 8 → 16 → 32 (2 extends it downward)
Ladder B (×2): 6 → 12 → 24 → 48
Off-ladder: 20 (a fitting value between 16 and 24)ヒューリスティックはこうです。どのグルーピングの境界でも、グループ内のギャップとグループ間のギャップを同じラダーから、1 段以上離して選ぶ。 1 段はちょうど 2× です ―― これで近接の比率は自動的に満たされ、しかもレビューで適用できるくらい覚えやすいルールになります(「同じラダーで 1 段上」)。これが効くのは余白がグルーピングの主要な手がかりになっている場合です。ボーダーや塗り、境界のある領域がグルーピングを担っている場合は、そちらが読み取りを決めるので、ギャップの比率の重要度は下がります(後述の共通領域のセクションを参照)。
| グループ内 | 有効なグループ間(同じラダー、1 段以上上) | 比率 |
|---|---|---|
| 4 (A) | 8 / 16 / 32 | 2× / 4× / 8× |
| 6 (B) | 12 / 24 / 48 | 2× / 4× / 8× |
| 8 (A) | 16 / 32 | 2× / 4× |
| 12 (B) | 24 / 48 | 2× / 4× |
避けるべきペアリングも同じくらい教えやすいものです。隣り合う2 つのステップをグループ内 / グループ間として使うと(4↔6、6↔8、8↔12、12↔16、16↔20…)、1 つのグループとして読まれがちです。比率が 1.2〜1.5× のバンドに入ってしまい、目がそれを境界として解像できないからです。ラダーから外れたステップ(20)が居場所を得るのは絶対的なフィッティングです ―― 特定のコントロールをかわすためのインセットなど ―― そして、同じラダーに組み合わせられる段がないため、グルーピングの相棒としては不向きです。グループ内 16 とグループ間 20 を組み合わせそうになったら、ラダーの出す答えは 32 です。
どちらの列も各グループ内では 16px を使っています。左はそれをグループ間の 20px と組み合わせています ―― ラダーから外れた正当なステップですが、グループ内ギャップのわずか 1.25× なので、4 つの行すべてが 1 つのリストとして読まれます。右は同じ 16px を、同じラダーの 1 段上である 32px と組み合わせています ―― 紛れもなく 2 つのグループです。両方を目を細めて見れば、違いはすぐに分かります。
強制についても 2 点。第一に、値を検査するリンターは比率を見られません ―― 値を単体で検査するだけであり、壊れたペアリングでもすべての値は正当だからです。このドクトリンはレビューと目を細めるテストによって適用されます。第二に、このドクトリンがあったからこそベーススケールを変える必要がありませんでした。移行ではトークンの語彙と約 3,000 件の使用箇所をすべてそのまま残し、ギャップが明示的に書かれていた境界は、ペアリングを組み直すだけですべて修正できました。これがプロジェクトを扱いやすくした要因です。ただしこれで全部というわけではありません。グループ内のリズムが明示的に書かれたものではなく内在的だった箇所では、ペアリングの組み直しでは手が届かず、別の仕組みが必要でした。それは次のセクションで扱います。
素のスケールステップよりロールトークン
メニュー行の混沌(1 つのロールに 5 つの密度)は、より良いスケールステップを選ぶことでは直りません ―― ファイルごとに選ばれたステップは、また必ずドリフトします。直すにはロールに名前を付け、そのロールに属するすべてのものにその名前を消費させます。
このデモは実測した高さをそのまま再現したものではなく、ドリフトの形を示すためのものです。左の列は、管理されないドリフトが生み出すものです。どの行も正当なトークンのペアリングなのに、リストは依然として 5 つの無関係なものとして読まれます。右の列は同じロールを名前付きの 2 段に畳み込んでいます ―― そして両者の間の意図的な差は、グルーピングの境界ではなく密度の選択として読まれます。すべてのケースは 2 つの提供手段でカバーできます。
共有クラス定数 ―― ロールが既存のベーススケールユーティリティの組み合わせである場合。行は px+py のペア、ダイアログヘッダーは px+py+border です。Tailwind のスペーシングトークン 1 つが名前を与えられるのは 1 つの値だけなので、ロールが必要とする複数のユーティリティを束ねることはできません。名前を付けてエクスポートした定数ならできます。
// exported from the shared UI package
export const menuItemCls = "px-md py-xs max-mobile:min-h-touch-min"; // compact menu row
export const pickerRowCls = "px-lg py-sm min-h-touch-min"; // search-overlay row
export const dialogHeaderCls =
"flex items-center justify-between px-3xl py-xl border-b border-edge";新しいトークンは不要で、lint は単純なままであり(下地のクラスはすでに有効なトークンです)、grep menuItemCls によって導入状況を監査できます ―― そのロールを採用したすべての箇所が分かります。ただし、独自のパディングを手書きしている行が 1 つも残っていないことを証明できるわけではありません。きっかけとなったアプリでは、5 つの行の密度を名前付きの2 段(コンパクトメニュー 8/4、ピッカー行 12/6 に 44px のタッチ下限)に畳み込むことで、行のパディングが 5 種類からちょうど 2 種類になり、computed style で検証できました。
コンポーネントスペーシングトークン ―― ロールの値がベーススケールから外れている場合、または独立したスケーリング挙動を必要とする場合。
--spacing-close-btn: 28px; /* the dialog close-X hit box */
--spacing-section-gap: 28px; /* between-group boundary in dialog/settings bodies */
--spacing-card-gap: 16px; /* between bordered cards on board surfaces */
--spacing-touch-min: 44px; /* iOS-HIG touch floor — scales with a floor, see below */これを作る基準は、コンポーネントトークンと任意の値と同じアーキテクチャ上の判断です。その値はデザイン判断に名前を与えるものでなければならず、単に何度も出てくるだけの値であってはいけません。苦労して得た系が 2 つあります。
段数は思っているより少なくてよい。 3 段(menu / listbox / palette)にしたい誘惑がありました。しかし 3 段は、呼び出し側にとって判断が 1 つ多すぎます ―― 「menu」と「listbox」の間の曖昧さこそが、あの混沌を生んだ張本人です。生き残ったのは 2 段でした。
値が偶然一致しても結合してはいけません。 このアプリには 28px の設定ヒントのインデントと、まったく無関係な 28px の文章ステップのインデントがありました。同じ数字、違うロールです ―― 1 つのトークンを共有すれば 2 つの判断が溶接され、どちらも動かせなくなります。理由が異なるなら、値が等しくても別々の名前を与えるべきです(あるいは一方はドキュメント化された任意の値のままにします)。
リズムが内在的なときは、共通領域がギャップの比率に勝る
近接のルールには境界条件があり、密なアプリの面はそこに絶えず突き当たります ―― その一般的な説明と、同じタイムラインの例はスペーシングの設計思想にあります。このセクションはそれをアプリの規模で読み解いたものです。タイムラインのカード内の「ギャップ」約 24px は gap ではありませんでした ―― 積み重なったラベル / タイトル / 本文テキストの内在的な行の高さだったのです。可読性を損なわずに縮めることはできず、それを比率で上回ろうとすればカード間に約 48px が必要になり、ビューポートの半分を空白に費やすことになります。
グループ内のリズムが内在的な場合は、スペーシングで戦うのをやめて、ゲシュタルトの原理を切り替えます ―― 近接から共通領域(common region)へ。これは、1 つの境界で囲まれた領域の中にある要素は、内部のスペーシングに関係なく 1 つのまとまりとして読まれる、という原理です。各カードに境界のあるボックス ―― 全周のボーダー、角丸、塗り ―― を与え、ボックス同士は控えめなギャップで並べます。(塗りは図と地(figure/ground)の手がかりも加え、カードを背景から区別します。ただし主役となるのは共通領域の原理であり、塗りはテーマがコントラストを提供する場面でそれを補強します。)
カード内のテキストのリズムはどちらの列でも同一です ―― 手を付けていません。変えたのは境界の扱いだけです。きっかけとなったアプリでは、これによってタイムラインが「8 枚のカードが 1 つのスクロールリストに融合する」状態から、実測 16px のギャップを持つ独立した角丸ボックスへと変わり、4 つのディスプレイスケールで再計測して確認しました。
テーマ依存の細かい点が 1 つあります。ダークテーマではカードの塗りがボードの背景と同じ値に計算されることがあり(surface == bg)、その場合は図と地の手がかりが完全になくなります ―― カードと背景のコントラストがもう残っていないのです。それでも共通領域は成り立ちます。ボーダー + 角丸 + ギャップだけで領域を囲めるからです。それで構いません ―― このアプリの検証済みカンバンの参照実装がまさにその computed 状態でした ―― ただしこれは、「カードの背景はコンテナと異なっていなければならない」という検証条件がテーマ限定になり、見当違いのものを検査していることを意味します。塗りのクラスはいずれにせよ適用し(トークンが分岐するテーマでは図と地の手がかりが無料で手に入ります)、保証はボーダー + ギャップ + 角丸に担わせましょう。
ディスプレイスケールの黄金律とその例外
ディスプレイスケールを備えたアプリ(ディスプレイスケール戦略)では、スペーシングシステムには 1 つの黄金律があります。ユーザーが UI として知覚するあらゆる寸法は calc(Npx * var(--display-scale)) である ―― そして、スケーリングが間違いになる例外の、短く筋の通ったリストがあります。
| 例外 | 扱い | 理由 |
|---|---|---|
| ヘアライン(1px のボーダー、区切り線、ツリーのコネクター) | 固定の 1px、決して掛けない | どのスケールでもシャープであるべき。スケーリングされるのはその位置だけ |
| タッチターゲットの下限 | max(44px, calc(44px * var(--display-scale))) | 下限はスケーリングされる値ではなくプロダクトとしての判断。0.75× ではスケーリングされた 44 が 33px になる。WCAG 2.2 は AA の最小値として 24×24 CSS px を、AAA の強化目標として 44×44 を定めている ―― 自分たちの下限を意識して選び、1.0× 以下では保ち、それより上では伸ばす |
| 4px 未満のバー(アクセントストライプ、ドラッグ用のつまみ、挿入インジケーター) | max(2px, round(3px * var(--display-scale), 1px)) | 素の 3px×0.75 = 2.25px はブラウザがアンチエイリアスせざるを得ない端数の値であり、3px×1.5 = 4.5px は半ピクセルに落ちる。下限 + 整数丸めで値を整数に保つ(実測: 0.75/1.0/1.5/2.0 で 2/3/5/6)。整数の CSS px はシャープさの保証ではない ―― デバイスピクセルへの整列は DPR と位置にも依存する ―― が、端数サイズという原因は取り除ける。round() のない WebView のために、丸めない宣言をフォールバックとして残しておく |
| オーバーレイの垂直アンカー | スケーリングせず素の vh | 設計上ビューポート相対 |
| 文章コンテンツ | em ベース、トークンシステムの完全に外 | フォントサイズに乗り、それは独自の軸でスケーリングされる |
| プラットフォーム由来の px(iOS の 16px ズーム閾値、セーフエリアの計算) | 固定、ファイル内にコメントで記録 | デザインではなくプラットフォームから課されたもの |
スケールを順に切り替えてみてください。トークン化された四角は 21/28/42/56 と伸びるのに対し、赤い w-[28px] の任意の値は永遠に 28px のままです ―― 1.0× では見分けがつかず、だからこそレビューを生き延びてしまいます。アクセントバーは 2.25px で滲む代わりに 2px を下限とし、タッチターゲットは 0.75× でも 44px を下回ることを拒み、ヘアラインはどのステップでも 1px のままです。
導出された値は導出されなければなりません。バーのセンタリングオフセットは calc(var(--spacing-accent-bar) / -2) であり、ハードコードされた -1.5px ではありません。そして幾何計算を JS で行う場合(深さごとのツリーインデントなど)は、計算値ごとに一度だけ丸めます ―― Math.round((base + step * depth) * scale) ―― こうすることで、端数のあるスケールでもすべての行とそのガイド線が同じ整数デバイスピクセルに乗ります。これが捕まえるバグの類型が 1 つあります。乗数に一度も繋がれていなかったコンポーネントです。きっかけとなったアプリでは、5 つあるツリー実装のうち 1 つ(設定サイドバー)がそもそもスケーリングしておらず ―― どのズームレベルでも 1.0× の幾何のまま固まっていました ―― ズームを変えたときにしかずれないため誰も気づいていませんでした。5 つのツリーすべてを 1 つの treeIndentStyle(depth) ヘルパーに統一したことで副作用として直り、0.75/1.0/1.5(インデント 9/12/18)で検証しました。
Tailwind v4 のクセ: このサイトがディスプレイスケール戦略で説明している 2 ブロック構成では、スケーリングされる各トークンは2 回宣言されます ―― @theme にスケーリング前のベース値(Tailwind がユーティリティクラスを生成するために必要とする値です)、そして :root に calc() のオーバーライド(実行時にカスケードで勝ちます)。(これはこの構成が持つ性質であって、@theme の絶対的な制約ではありません。Tailwind v4 はテーマの値の中で変数を参照することを許しており、@theme inline は出力のされ方を変えます。この分割が必須だと決めつける前に、自分のツールチェーンを確認してください。)この分割を採用している場合、それは構造を支えるものであり、同時に落とし穴でもあります。片方のブロックにだけトークンを追加すると、死んだユーティリティかスケーリングされない値のどちらかが生まれます。2 つのブロックの --spacing-* キー集合が、ドキュメント化された固定トークンの許可リストを除いて等しいことを表明するユニットテストで守りましょう。
名前を付ける価値のある頻出パターン
これらのロールは、監査したアプリのあらゆる面で繰り返し現れました。おそらくあなたのアプリにも存在します。先に名前を付けておけば、多くのドリフトを未然に断ち切れます。
| パターン | 形 | 補足 |
|---|---|---|
close-btn | すべてのダイアログの閉じる X に共通する正方形のヒットボックスサイズ | アプリ内で最もコピペされたリテラル(w-[28px] h-[28px] が数十箇所)―― 広がる前にトークン化する |
touch-min | すべてのインタラクティブな行 / ボタンに対する 44px 下限の最小値 | 下限であることが要点 ―― 例外の表を参照 |
card-gap | ボード的な面における境界のあるカード同士のギャップ | 共通領域のレシピと組で使う |
section-gap | ダイアログ / 設定の本文内でのグループ間の境界 | 検証済みのグループ内↔グループ間の基準点に名前を与える(ここでは 10↔28 ≈ 2.8×) |
tree-indent ファミリー | base + step × depth、加えて固定 1px のコネクター | すべてのツリーに 1 つのヘルパー。深さごとに JS で丸める |
hint-indent | チェックボックスのラベルの下にヘルプテキストを揃えるインデント | 視覚調整の合成値であることが多い(チェックボックス 16 + ギャップ 8 + 4px)―― その構成をコメントに記録する |
accent-bar | 3px の優先度ストライプ / ドラッグインジケーター | 下限 + 丸めを適用し、border-width または w/h として消費する |
icon-btn-mini | 標準の正方形より小さい 2 番目の階層のアイコンボタン | 22/24px のニアデュープを防ぐ |
overlay-offset-top | 先行入力オーバーレイのファミリー全体で 1 つの vh アンカー | 各オーバーレイが独自の 5/10/20vh を選ぶ前に統一する |
これらはどれもベーススケールには載っていません ―― そこが要点です。これらはスケールのステップではなく、名前を持ったデザイン判断です(2層サイズ戦略は要素のサイジングについて同じ主張をしています)。
ラチェット lint による移行パターン
成熟したコードベースで厳格なスペーシング lint を有効にしようとすると、素朴な 2 つの形はどちらも失敗します。「まず全部直す」は永遠に着地せず、「警告のみ」は永遠に無視されます。この移行を支えたパターン ―― 6 つの並行ロールアウト領域、マージコンフリクトゼロ ―― は領域ごとのラチェットです。初日にルールと違反すべてのベースラインを同時に投入してグリーンにし、ロールアウト領域ごとに独自の許可リストファイルを与えて並行するブランチが決して衝突しないようにし、承認済みの calc(Npx*var(--display-scale)) という正しい道をエントリ単位ではなくパターンで自動承認し、最後に生き残りを統合してルールをハードエラーに固定します。Design Token Lintに完全な仕組み ―― ベースラインのキーの取り方、式全体の検証、数字始まりトークンの正規表現の罠 ―― があります。このセクションはアプリ規模のスペーシング移行に固有の部分だけを扱います。
ここでの実測: 5 つの領域ファイルにまたがる 31 件のベースライン許可リストエントリ → 恒久的な生き残りは2 件(どちらも mx-[0.25em]、原理的に例外となる em ベースのインライン微調整)、最終的に 1,042 ファイルで lint はクリーンになりました。これらすべてを通じてベーススケールが凍結されたままでいられた理由は、上記の 2 本のラダーのドクトリンにあります。この移行は境界での組み合わせを組み直しただけで、スケールの番号を振り直したわけではありません。そのためラチェットは常に許可リストを縮めるだけで済み、何千もの呼び出し箇所を書き換える必要は一度もありませんでした。
アプリに固有の唯一の注意点は、lint がなお防げないものです。design-token lint がグリーンでも、それが証明するのは値であってレイアウトではありません。そのため 2 つの欠陥クラスがそのまま通過してしまいます。比率 / グルーピングの失敗(どの値も正当で、壊れているのは関係のほう)と、ディスプレイスケールをオプトアウトする固定 px のサイジング任意値(w-[28px] は2層サイズ戦略の契約が設計上許可しています)です。このプロジェクトの lint はグリーンでしたが、その裏でスケーリングしない閉じるボタンのリテラルが 41 件出荷されていました。これを補完するのは、アプリ固有の computed style の検証条件を確認するパス ―― 「すべての閉じる X は --spacing-close-btn × scale に計算される」「2 つのオーバーレイが異なるオフセットにアンカーされていない」 ―― と、複数のスケールでの目を細めるテストです。
クイックリファレンス
| シナリオ | テクニック |
|---|---|
| 既存のアプリで何を直すか決める | レンダリングされたピクセルを実測する。視覚的な誤読で順位付けする。不可視なものは統一して終わりにする |
| 密なスケールでグループ内 / グループ間のギャップを組み合わせる | 同じ ×2 ラダーで 1 段以上離す(2× 以上が自動的に成立)。隣り合うステップを境界をまたいで組み合わせない |
| 同じロールなのに密度がドリフトする(メニュー、行、ダイアログの chrome) | ロールごとに共有クラス定数を 1 つ。段数は自然に感じるより少なく |
| グルーピングが失敗しているが、グループ内のリズムが行の高さである | 共通領域: 境界のあるボックス(ボーダー + 角丸 + 塗り + ギャップ)。マージンを足すのではなく |
| ディスプレイスケールのあるアプリのあらゆる寸法 | ヘアライン、タッチ下限、4px 未満のバー、vh、em の文章、プラットフォーム由来のクセでない限り calc(Npx * var(--display-scale)) |
| 2 つのロールが 1 つのピクセル値を共有する | 名前を分ける ―― 偶然の一致で判断を結合してはいけない |
| 成熟したコードベースで厳格な lint を有効にする | 領域ごとのラチェット許可リスト → パターンによる自動承認 → 統合 → 固定 |
| 結果を検証する | lint の出力ではなく、computed style の検証条件 + 0.75/1.0/1.5/2.0 での目を細めるテスト |
AIがよくやるミス
「すべての値が有効なトークン」を「レイアウトが正しい」と思い込む。 実測された最悪のバグ(比率 1.0× でカードが融合する)は、正当なトークンしか使っていませんでした。値ではなく関係を確認してください。
不可視な不整合を、目に見えるものと同じ熱量で直す。 4 種類のダイアログインセットや 2px のツールバーの差は保守性のクリーンアップであって、視覚的なバグではありません ―― 静かに統一しましょう。ロールトークンや再設計を費やしてはいけません。
グルーピングを「直す」ためにカード内のテキストのリズムを縮める。 グループ内のギャップが内在的な行の高さなら、それを縮めることはグルーピングのバグを可読性のバグと交換するだけです。境界のあるボックス(共通領域)に切り替えましょう。
ディスプレイスケールのあるアプリで固定 px の任意値を書く。
w-[28px]は UI の他の部分がスケーリングしてもどのズームレベルでも固まったままで、しかもサイジングの lint はそれを捕まえません。許可される形はw-[calc(28px*var(--display-scale))]かトークンです。見栄えのためにベーススケールのステップをリネームしたり削除したりする。 中央が密なのはペアリングの問題であってスケールの問題ではありません。ステップ名に触れれば、既存の使用箇所の数だけ差分が膨らみます。
例外まで含めてすべてをスケーリングする。 スケーリングされた 44px のタッチターゲットは 0.75× で 33px まで縮み、プロダクトが選んだ下限を割り込みます。スケーリングされた 3px のバーは端数の 2.25px に落ち、スケーリングされた 1px のヘアラインはもはやヘアラインではなくなります。下限と固定値はシステムの一部であって、システムへの違反ではありません。
並行移行に中央集約の lint 許可リストを 1 つ使う。 すべてのブランチが同じファイルを編集するため、改善が届く前にマージコンフリクトが届きます。
使い分け
適しているケース
chrome の密度が高いアプリ UI ―― エディタ、ダッシュボード、IDE のようなツールなど、ピクセルの大半が文章ではなくツールバー、行、ダイアログ、ツリーであるもの。
ディスプレイスケールのある WebView アプリ(Tauri、Electron)―― 黄金律とその例外リストは
--display-scale乗数の存在を前提としています。スペーシングの移行に直面した成熟したコードベース ―― 診断優先の予算配分、変えないベーススケール、ラチェット lint は、いずれも移行を扱いやすくするための道具です。
不要なケース
文章主体のサイトやマーケティングサイト ―― スペーシングの設計思想とタイトトークン戦略がカバーします。持っていないダイアログの chrome のためのロールトークンの仕組みは、単なるオーバーヘッドです。
グリーンフィールドのプロトタイプ ―― 計測してから設計するというやり方は、計測する対象があることを前提にしています。ペアリングのルールと黄金律から始めて、面が増えてきたら残りを足しましょう。
リファレンス
スペーシングの設計思想 ―― この記事がアプリの chrome 向けに運用可能にした近接 / 比率のルール
ディスプレイスケール戦略 ―― 黄金律が土台とする
--display-scaleシステム2層サイズ戦略 ―― 要素のサイジング。Tier 2 の任意値におけるディスプレイスケールの注意点
コンポーネントトークンと任意の値 ―― ロールトークン層が適用する、トークンと任意値の判断フレームワーク
Design Token Lint ―― ラチェットパターンが拡張する強制の層