Skip to content
fandhe-frontend
GitHub

pre-styled-ui slot recipe API

1. 目的とトレーサビリティ

本ドキュメントは fandhe-frontend-pre-styled-ui に実装した slot recipe 相当の variant API(SlotRecipe/VariantValue)と静的 CSS 生成の仕様を 記録する。chakra-ui の recipe / slot recipe を参考に、複数 anatomy パーツ (slot)を横断する variant(size / variant / colorPalette 相当)を型安全な Rust API(enum ベース)で定義し、クラス名と静的 CSS を決定的に生成する基盤である。

  • 実装: crates/pre-styled-ui/src/css.rs(低レベル宣言・検証・シリアライズ)・ crates/pre-styled-ui/src/recipe.rsSlotRecipe/VariantValue/Size、 compoundVariants 相当は VariantCondition/when/SlotRecipe::compound_variant
  • テスト: crates/pre-styled-ui/tests/recipe_css.rs(golden・headless 接続・ fail-closed・compound variant)・crates/pre-styled-ui/tests/recipe_determinism.rs (決定性、compound variant を含む)

2. 公開 API

// fandhe_frontend_pre_styled_ui::css
pub struct Declaration { /* property, value: &'static str */ }
pub const fn decl(property: &'static str, value: &'static str) -> Declaration;

// fandhe_frontend_pre_styled_ui::recipe
pub trait VariantValue: Copy {
    fn axis(self) -> &'static str;   // 例: "size"
    fn value(self) -> &'static str;  // 例: "sm"
}

pub enum Size { Sm, Md, Lg } // axis = "size"

// 標準 colorPalette 軸。crate::theme のセマンティック色
// (accent/info/success/warning/danger)と 1:1 対応する。
pub enum ColorPalette { Accent, Info, Success, Warning, Danger } // axis = "color-palette"
pub fn palette_declarations(p: ColorPalette) -> Vec<Declaration>;

pub struct SlotRecipe { /* ... */ }
impl SlotRecipe {
    pub const fn new(scope: &'static str, slots: &'static [&'static str]) -> Self;
    pub fn base(self, slot: &'static str, declarations: Vec<Declaration>) -> Self;
    pub fn variant<V: VariantValue>(self, v: V, slot: &'static str, declarations: Vec<Declaration>) -> Self;
    pub fn default_variant<V: VariantValue>(self, v: V) -> Self;
    pub fn compound_variant(self, conditions: Vec<VariantCondition>, slot: &'static str, declarations: Vec<Declaration>) -> Self;
    pub fn css(&self) -> String;
    pub fn variant_class<V: VariantValue>(&self, v: V) -> String;
    pub fn variant_classes(&self, selection: &[(&str, &str)]) -> String;
}

// compoundVariants 相当
pub struct VariantCondition { /* axis, value: &'static str(型消去済み) */ }
pub fn when<V: VariantValue>(v: V) -> VariantCondition;

SlotRecipe::new/base/variant/default_variant は自己消費の builder (chakra-ui の defineSlotRecipe({ base, variants, defaultVariants }) 相当の 宣言を、通常の Rust メソッドチェーンで表現する。マクロ DSL は採用しない、REQ-5)。

colorPalette 相当は独立の仕組みではなく通常の variant 軸として表現できる (docs/api 掲載の例・tests/recipe_css.rsColorPalette enum 参照)。

compoundVariants 相当(複数軸の組み合わせ条件スタイル)は [SlotRecipe::compound_variant] で表現する。条件部は [when()] で [VariantValue] 実装 enum から作った [VariantCondition] の Vec(AND 条件)として渡す:

recipe.compound_variant(
    vec![when(Size::Sm), when(ColorPalette::Red)],
    "trigger",
    vec![decl("font-weight", "bold")],
)

生の文字列ではなく when() を介した enum ベースの構築のみを許すことで、 variant() と同じ型安全性を条件部でも保つ(tests/recipe_css.rstabs_recipe() 参照)。

3. scope と headless 層との契約

SlotRecipe::new(scope, slots)scope は、対応する fandhe-frontend-headless-uiAnatomy::new(scope)(例: crates/headless-ui/src/tabs.rsconst ANATOMY: Anatomy = anatomy("tabs");) と同じ値を渡す契約とする。slots は同コンポーネントの anatomy part 名一覧 (Tabs であれば root/list/trigger/content)と一致させる。

この契約により、SlotRecipe::css() が生成するセレクタ [data-scope="<scope>"][data-part="<slot>"] が、headless 層が Anatomy::part() を通じて実際にレンダリングする属性と一致する (crates/pre-styled-ui/tests/recipe_css.rs::base_selectors_match_actual_headless_markupfandhe_frontend_headless_ui::tabs::tabs() の実マークアップと照合して固定する)。

scope はセレクタ・クラス名へ slot/axis/value と同様にそのまま埋め込まれるため、 SlotRecipe::css()/variant_class()/variant_classes() はいずれも呼び出し時に scopeis_valid_identifiercss.rs)で検証し、不正な場合は空文字列を fail-closed で返す(slot/axis/value 側の検証だけでは scope 経由の セレクタ脱出・</style> 混入を防げないため、scope にも同じ検証を適用する)。

4. セレクタ・クラス命名規則・出力書式(凍結)

  • base セレクタ: [data-scope="<scope>"][data-part="<slot>"](詳細度 (0,2,0))
  • variant セレクタ: [data-scope="<scope>"][data-part="<slot>"].fd-<scope>--<axis>-<value> (詳細度 (0,3,0)。base に必ず勝つため、CSS 記述順に依存しない上書きを保証する)
  • compound variant セレクタ: [data-scope="<scope>"][data-part="<slot>"].fd-<scope>--<a1>-<v1>.fd-<scope>--<a2>-<v2>...conditions の登録順に条件クラスを連結する。新しいクラス名は生成せず、 variant_classes() が emit する既存の軸別クラスの共起にセレクタとして 反応するだけなので、HTML 側への影響はない)
  • クラス名形式: fd-{scope}--{axis}-{value}(prefix fd はライブラリ固定。変更用 API は設けない)
  • 出力書式(golden テストの前提、変更しない):
    • 規則単位: <selector> {\n <property>: <value>;\n ...\n}\n(インデント 2 スペース、1 宣言 1 行)
    • 規則間は空行 1 つ
    • SlotRecipe::css() 全体の出力順: base(slots 宣言順)→ variants (登録順)→ compound variants(登録順)

4.1 compound variant の上書き保証(2 段)

  • 条件 2 個以上: セレクタの詳細度が (0,4,0) 以上となり、単一 variant セレクタ (0,3,0) に記述順へ依存せず必ず勝つ
  • 条件 1 個: 詳細度は単一 variant と同じ (0,3,0) だが、compound ブロックを variants ブロックより後に出力するため CSS カスケードの後勝ちで上書きされる

chakra-ui の「compoundVariants は variants を上書きする」という意味論に この 2 段の保証で対応する。

5. 順序規約・決定性

  • 内部ストレージは Vec のみ。HashMap/HashSet は使わない(反復順序がプロセスごとに 変わりうる型を持ち込まない)
  • 同一 slot・同一 axis/value への複数回登録は「後に登録された規則が CSS 中で後に 出力される」(CSS のカスケードにおいて後勝ちになる)という規約に従う。これより 複雑な優先順位判定は行わない
  • variant_classes(selection)selection で指定されなかった axis を default_variant で補完する。戻り値は axis の登録順(variant/default_variant で最初に現れた順)で連結したクラス文字列
  • 決定性は crates/pre-styled-ui/tests/recipe_determinism.rs が固定する: 同一入力 から独立に構築した 2 インスタンスの css()/variant_classes() が byte 一致する こと、同一インスタンスへの繰り返し呼び出しが安定していること(compound variant を含む場合も同様)

6. fail-closed 検証ポリシー

crates/core/src/lib.rs が不正なタグ名・属性名を「panic させず出力からスキップ」 する規約(.claude/rules/coding-rust.md の panic 回避方針)を踏襲する。

  • 識別子(scope / slot / axis / value): [a-z][a-z0-9-]* に一致しない場合、その 規則・クラスを出力からスキップする
  • プロパティ名: 通常のプロパティ名に加えカスタムプロパティ(--fd-* プレフィックス) を許容する(テーマトークン参照 var(--fd-color-primary) を見越した設計)
  • 宣言値: { } ; < および制御文字を含む場合、その宣言をスキップする。 < の拒否は、下流(styled 部品・examples 等)が生成 CSS を <style> へ インライン埋め込みした場合の </style> 突破(HTML コンテキスト脱出)を防ぐ セキュリティ上の不変条件である
  • slots に宣言していない slot への base/variant/compound_variant 登録は 出力から除外する
  • compound variant 固有の検証(crates/pre-styled-ui/tests/recipe_css.rs::compound_variant_fail_closed_cases_are_skipped_not_panicking が固定する):
    • conditions が空の規則は base と同義になる無意味な規則として除外する
    • conditions 内に同一 axis が重複する規則は、variant_classes() が 1 軸 につき高々 1 クラスしか emit しないため決して同時に一致しない矛盾条件で あるとみなし、dead CSS の混入防止として除外する
    • 条件の (axis, value) の組が variant()/default_variant() のいずれにも 未登録の規則は、axis/value のタイポによる dead CSS の混入防止として除外する (検証は css() 呼び出し時に行うため builder の呼び出し順には依存しない)
  • いずれも panic なし・スキップ動作。crates/pre-styled-ui/tests/recipe_css.rs::invalid_identifiers_and_structural_chars_are_skipped_not_panicking が固定する

7. テーマトークンとの関係

宣言値は不透明な &'static str として扱うため、トークン参照は decl("color", "var(--fd-color-primary)") のような値として自然に載る。

colorPalette 軸実配線時、palette_declarationscrate::theme が生成する --fandhe-color-*(テーマ層の名前空間)とは別の --fandhe-palette-* 名前空間へ、 選択された palette に対応する accent/info/success/warning/danger の 3 役割(base/emphasized/fg)を var() 参照として束ねる。styled 部品 (Button/Badge/Spinner、crates/pre-styled-ui/src/button.rs 等)は var(--fandhe-palette) 等を参照するだけで、palette variant の選択に応じて 色が切り替わる。名前空間を分離しているため、ユーザーがカスタムテーマへ Theme::push_color("palette", ...) のような独自トークンを追加しても --fandhe-palette-* の生成とは衝突しない。既定トークンの上書きは push_* ではなく upsert_* を使う(詳細は pre-styled-ui-api.md §4l)。

crate::theme は加えて radii(--fandhe-radius-<name>)・shadow (--fandhe-shadow-<name>、light/dark 2 値)トークングループを持つ。 styled 部品は border-radius/box-shadow の値としてこれらを参照する (例: decl("border-radius", "var(--fandhe-radius-md)"))。

関連ドキュメント

  • docs/api/pre-styled-ui-api.md: 本 API の上層 (styled 部品・stylesheet::StyleSheet
  • docs/internal/pre-styled-recipe-implementation-notes.md: 実装経緯・ スコープ外事項・トレーサビリティの記録(docs サイト非掲載のためリンク化 しない)