
2026/04/23 21:37
数年間、CSS のステート予測可能性向上のために尽力しました。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Tasty は、開発者が複雑な CSS ステートを管理する方法を革新し、宣言的な状態マップを互いに排他的なセレクタにコンパイルすることで、カスケードにおける従来のソース順序依存性に起因する曖昧さや意図しないバグを効果的に排除します。静的モデルから動的セレクタ生成へシフトさせることで、Tasty は疑似クラスやメディアクエリを超えて確定的なロジックを保証し、不安定なレイアウトのトリックに頼らず動作します。このソリューションは、Cube UI Kit 向けにボタンやドロップダウンなどのスケーラブルな設計プリミティブを構築する過程で直面した現実的な課題から生まれました。現在、Enterprise 環境での本番利用において 100 を超えるコンポーネントをサポートしています。ツールは、単なるオブジェクト形式から、高い制約のあるデザインシステムの要件を压力下でも扱うことができる堅牢な原子 CSS ジェネレーターへと成功して移行しました。GitHub Issues を通じたアクティブなコミュニティのフィードバックが将来の開発方向を形作りつつ、Tasty はワンオフのページではなく長期的なコンポーネント改修を必要とするチームにとって持続可能な前進の道筋を提供します。採用者は、インターフェースが複雑化するにつれて、有意に少ないリグレッションとより予測可能で安定した動作を期待できます。
本文
CSS の制約からの解放:Tasty というアプローチ
2 つの CSS ルールの順序を入れ替えるだけで、ロジックを変更せずにコンポーネントを壊した経験はありませんか?
.btn:hover { background: dodgerblue; }.btn[disabled] { background: gray; }
これらのセレクターはすべて特定性(0, 1, 1)を持っています。ボタンがホバー状態かつ無効化されている場合、ブラウザはソースコードの順序に従って解決します。
:hover ルールが後に来る場合、無効化されたボタンが青色になります。逆に [disabled] ルールが後にある場合は、そのままグレーのままです。
一見小さな問題に見えますが、これはより大きな課題を示しています。CSS におけるコンポーネントの状態管理は、多くの場合「オーバーラップ」に依存しています。
コンポーネントの状態が一つや二つのみであれば、その重なり具合はまだ管理可能です。しかし
:hover や :active、無効化状態、ダークモード対応、レスポンシブ幅、データ属性、コンテナクエリ、オーバライドなどを加えればすぐに管理性が失われます。もはや単にスタイルを書いているだけではなくなりますが、頭の中で「解決システム」を維持していることになります。
その問題は、偶発的な競合として現れるだけでなく、実際の要件が積み重なるにつれて、既存のコンポーネントを安全にカスタマイズするのが次第に難しくなっていきます。
私はコンポーネントシステムを構築する際、繰り返し直面した課題でした。サンプルコードや玩具のような例ではありません。実際のボタン、入力フィールド、パネル、ドロップダウンリスト、そして設計システムの基本要素などで経験しました。最も難しいのは、最初バージョンのコンポーネントを書くことではなく、その後で拡張を行う際に「全体の状態解決問題」を再び開かないようにすることです。
ある時点で、「このセレクターを書くにはどうすれば?」と考えるのをやめ、より良い問いを立てるようになりました:
もしコンポーネントの状態を宣言的に表現でき、それを決定論的にするためには.compiler(コンパイラ)がセレクターロジックを処理するならば?
その問いが最終的には Tasty へと発展しました。
1 分間で行うアイデア説明
競合しながらカスケードと特定性で勝敗を決めるセレクターを書くのではなく、プロパティの可能な状態を「マップ」として記述したいと考えました:
import { tasty } from '@tenphi/tasty'; const Button = tasty({ as: 'button', styles: { fill: { '': '#primary', ':hover': '#primary-hover', ':active': '#primary-pressed', '[disabled]': '#surface', }, }, });
優先順に適用されると、以下の意味を持ちます:
- 無効化されている場合は
を使用#surface - それ以外でアクティブ(クリック中)の場合は
を使用#primary-pressed - それ以外でホバーしている場合は
を使用#primary-hover - それ以外の場合は
を使用#primary
重要なのはその後です。Tasty はこの状態マップを、重なり合うことが不可能なセレクターに変換します:
/* [disabled] が確実に優先される */ .t0[disabled] { background: var(--surface-color); } /* :active は無効化されている場合は除外される */ .t0:active:not([disabled]) { background: var(--primary-pressed-color); } /* :hover は :active または [disabled] の場合を除外される */ .t0:hover:not(:active):not([disabled]) { background: var(--primary-hover-color); } /* デフォルトは上記いずれかが合致する場合は除外される */ .t0:not(:hover):not(:active):not([disabled]) { background: var(--primary-color); }
これでカスケードによる競合の余地は一切なくなり、同時にどのブランチが一致してもよいという状態はなくなります。
そして本当に報われるのは後です。このマップを拡張したり変更したりするのは、従来の CSS で同等のセレクターロジックを再検討することよりも遥かに容易になります。
これが全体の本質です:著者が既に優先順位を定義した場合、生成されるセレクターはその優先順位を明確にすべきである。
ボタンの例を示唆する以上の理由
ホバー状態かつ無効化されたボタンは問題を見やすくするための最も簡単なケースに過ぎません。本質的な痛みの始まりは、より微妙な状態で状態同士が交差する場合です。
- ダークモードはルート属性から来るのか、
から来るのか、あるいは両方から来るのかprefers-color-scheme - 狭いコンテナ内での間隔変更だが、タブレット幅のみで適用される場合
- 破損系(destructive)バリエントはホバー時には振る舞いが異なるが、ローディング時は異なる場合
- 親テーマが子へのオーバライドを切り替えさせる場合
それぞれルール単独では理解可能ですが、問題なのはそれらの間の相互作用界面です。その相互作用界面こそが CSS が脆く感じる起点となります。小さな変更でもどのブランチが重なるかが変わる可能性があります。無害なリファクタリングさえもソースコード順序によるバグへと転換することがあります。既存のコンポーネントを拡張すると、以前は解決済みと思っていたセレクターロジックを再び開かされることもあります。
私は「新しい状態を追加する際に、頭の中で全体のセレクター行列を再導出する必要がない」というモデルを目指しました。
なぜこれほど時間がかかったのか
核心的なアイデア自体はシンプルです。しかしそれを実際のツールに変えるのは難しいでした。「単純な状態条件では動作する」から「現実世界のコンポーネントシステムを支援できるものになる」までには、数年と数百回の反復が必要でした。
困難だったのは、一つのうまいセレクターを生み出すことではなく、これらがすべて同時に出現した際にも整合性を保つシステムを構築することです:
や:hover
などの擬制クラス:active- 属性、ブール修飾子、値に基づく修飾子
- ルートレベルの状態
- メディアクエリ
- コンテナクエリ
- ネスト型や複合セレクター
- スタイルの拡張と安全なオーバライド
- ステyling モデルの上層に並べられた型付けされた API
モデルが広がっていくたびに、当初のアイデアがまだ成立しているか確認する必要がありました。時には成り立つこともあれば、非常に成り立たないこともあります。
DSL を破断させ、内部で状態をどのように表現すべきかを再考し、コンパイラの大きな部分を再構築して同じ約束(著者が優先順位を定義すれば、生成されるセレクターはその優先順位を明確にする)を守る必要がある時期もありました。
困難さは技術的な部分と概念的な部分の両方から来ます:
- 技術的な側面 は、パース、正規化、セレクター生成、キャッシュ、拡張ルール、そして実用的であるための出力速度の最適化などに関わります。
- 概念的な側面 がより困難でした。Tasty の本質とは何かを常に決断し続ける必要がありました。「より使いやすき CSS オブジェクト形式なのか」「アトミック CSS ジェネレーターなのか」「設計システムの言語なのか」「状態を持つコンポーネントスタイルのためのコンパイラなのか」など。実践的には、それらすべてが一度に発展していくため、全体が内部的に整合性を持つと感じるまで、境界線を何回も再描く必要がありました。
長らく、このアイデアが十分にスケールし、その労力を正当化できるものになるか確信を持っていませんでした。初期には局所的には機能していました。しかし設計システム全体で信頼できるツールに発展させるまでには長い道のりがありました。
これは抽象的な実験だけではありません。Tasty は Cube UI Kit の初めからそれを支えてきました。現在は 100 以上のコンポーネントを跨ぎ、Cube Cloud という実際のエンタープライズ製品の背後にあるシステムになっています。初期バージョンは内部で完全に実験的でしたが、本番環境でのプレッシャーとチームからのフィードバックを通じて、モデルは自らの形を得ました。
私にとって最も重要な部分
私は「互いに排他的なセレクター」が興味深いのは因为它们がうまいからだと考えません。むしろ、著者が本来負うべきではない種類の曖昧さを排除するためであると信じています。
コンポーネントをスタイル化する際、私は各意味のある状態での外観を記述したいと思っています。常に状態同士が交差するたびに、ブラウザのタイブレーカーロジックを手動でエンコードしたくないのです。Tasty が追い求めているのは以下の成果です:
- コンポーネントの振る舞いが予測可能になる
- ソースコード順序による偶発的な後退が減少する
- 既存コンポーネントの拡張が容易になる
- デザインシステムが複雑化するほどに価値が増えるスタイルモデルを得られる
もし小さなランディングページをスタイリングしているのであれば、これはおそらく過剰な仕組みと言えます。単純な CSS が適切な答えであることが多いです。しかし数年間の反復やバリエーションの増加、テーマの拡張、複数作者によるコンポーネント構築を行う場合、予測可能性は非常に実践的な方法で複利効果を生み出します。
少し大きな例
いくつか追加の変数を持つ同じアイデア:
const Panel = tasty({ styles: { flow: { '': 'column', '@media(w >= 768px)': 'row', }, fill: { '': '#surface', 'theme=danger & :hover': '#danger-hover', '@root(schema=dark)': '#surface-dark', }, padding: { '': '4x', '@(sidebar, w < 300px)': '2x', }, }, });
ここで、モデルが通常のセレクター記述よりも有用だと感じるポイントです。三つのプロパティがあり、それぞれ異なる関心事を持ちます——メディアクエリ、コンテナクエリ、修飾子、ルート状態、擬制クラス——著者はそれらがどのように相互作用するかを一度も考える必要がありません。コンパイラがすでに理解しています。
この投稿とは、そして何ではないか
これは Tasty の完全版ガイドではありません。Tasty は他にも型付けされたコンポーネント API、サブ要素、SSR 統合、ランタイムゼロ抽出、エディタツール、リンティング、トークン、レシピなど多数の機能を持っています。それらも全て重要であり、ツールが実用上有用な理由の一部です。しかしこれらは主要なアイデアの下流にあります。
主要なアイデアは依然としてこの通りです:コンポーネントの状態は記述しやすく、曖昧になることが困難であるべき。
この一文を「リリースに安心して使えるツール」に変えるまでには数年かかりました。
この内容に共感いただけた場合
ブラウザ上のプレイグラウンドで Tasty を試すことができます。あるいは、完全な言語と機能セットについてはドキュメントを読むことも可能です。
実際に試してくださった場合、率直なフィードバックを心から歓迎します。最も有用なフィードバックは「これはかっこいい」ではなく、より具体的なものが多いです:
- モデルがどこですぐに理解できたか
- 不慣れに感じた箇所はどこか
- 命名が混乱した場所はどこか
- ドキュメントでスキップされた思考プロセスがあるか
- 抽象化が実際の問題を解決するか、あるいは失敗しているか
そのようなフィードバックはプロジェクトの始まりから形づくりに影響を与え、今もなお影響を与えています。何か不親切、不自然、不足を感じる場合は、GitHub Issues で共有していただくのが最適です。
ここまで読んでいただきありがとうございました。この投稿には私にとって大きな意味があります。なぜなら、長年取り組んできた問題についてだからです。