The project is in a healthy, maintained state
Ports shadcn/ui (base-nova) to Rails ViewComponent + Stimulus. Class contracts are extracted from the upstream registry and generated deterministically, so upstream drift is detected automatically.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Project Readme

shadcn_view_components

shadcn/ui(base-nova スタイル)を Rails ViewComponent + Stimulus として移植する Rails エンジンgem。

単なる一回の移植ではなく、shadcn/ui のバージョンアップへの追従コストを最小化することを第一の設計目標とする:

  1. 決定論的な生成パイプライン — upstream レジストリから機械的に導出できるもの(クラス文字列・バリアント定義・data-slot 構造・CSS変数)はすべて自動抽出・自動生成し、コミットされた生成物として扱う
  2. 自動的な乖離検知 — upstream が変わった際に、適合試験(conformance tests)が互換性の崩れを自動検出する
  3. 型付きコードベース — app/・lib/をSorbet(typed: strict)で検査

クライアントサイドのふるまいは React/Radix を持ち込まず、Stimulus + Hotwire + ネイティブHTML要素による Rails 流の再実装。クラス名・data-slot・ARIA属性といった「見た目と構造の契約」は upstream 由来の生成物として維持されるため、視覚的な追従は自動化される。

設計の詳細はドキュメント案内を参照。

ステータス

Phase 0〜4 完了 — vendor manifestの63アイテムを追跡し、61アイテムを実装済み。questionnaireとtoastは理由付き非対応(代替と再評価条件を文書化)。calendarは個別契約として提供する。初期実装計画はロードマップ(履歴)を参照。

  • 提供範囲(適合試験registryから生成): 実装済み 61 アイテム / 描画可能な公開 ViewComponent 322 クラス
  • 実装済みアイテム: accordion, alert, alert-dialog, aspect-ratio, attachment, avatar, badge, breadcrumb, bubble, button, button-group, calendar, card, carousel, chart, checkbox, collapsible, combobox, command, context-menu, dialog, direction, drawer, dropdown-menu, empty, field, form, hover-card, input, input-group, input-otp, item, kbd, label, marker, menubar, message, message-scroller, native-select, navigation-menu, pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner, spinner, switch, table, tabs, textarea, toggle, toggle-group, tooltip
  • 意図的に非対応のアイテム:
    • questionnaire: 具体的な共通要件なしにRails版の状態管理を定義すると、upstreamと異なる独自仕様を保守することになるため。 代替: form, field, input, radio-group, checkbox, button, progress。
    • toast: 独立したToastは既存のSonner通知基盤と責務が重複し、通知APIを二重に保守することになるため。 代替: sonner, alert。

全クラスのinitializer、slot、HTML属性の適用先、フォーム送信、状態、JavaScript要件、upstreamとの差異はコンポーネントAPIリファレンスで確認できる。 意図的に非対応のコンポーネント(questionnaire / toast)の理由、代替手段(サーバー主導の複数ステップフォーム、Sonner通知)、再評価条件は非対応コンポーネントと代替にまとめている。

  • calendar は react-day-picker の実行時クラス合成のため静的抽出の対象外。 契約は lib/shadcn_view_components/contracts/calendar.rb に個別契約として保守し、 コンポーネントは月テーブル(年月ナビ・日付ボタン)として提供する
  • インタラクティブふるまい(05 §3 ネイティブ最優先): toggle/toggle-group は Stimulus、 accordion/collapsible は <details>/<summary>、dialog系は <dialog> + showModal、 popover/tooltip/menu は Popover API
  • DropdownMenu / ContextMenuはARIA menu、Menubarは複数のARIA menuを束ねるmenubar、 NavigationMenuはネイティブのnav / リンクとして、それぞれ独立したキーボード操作を提供する
  • upstream 出所: vendor/shadcn/manifest.json が唯一の真実の源(現在: shadcn@4.19.0 系)

公開コンポーネントの境界

公開コンポーネントとして管理するクラスは、すべて.newして描画できるViewComponentである。 Shadcn::Chart、Shadcn::Form、Shadcn::Resizable、Shadcn::Sonnerは名前空間であり、 描画には上記一覧の配下クラスを使う。公開コンポーネントの一覧は spec/conformance/registry.ymlで管理し、各クラスの契約と最小構成での描画を自動検証する。 基底クラスと内部ナビゲーション用クラスは公開APIに含まれない。

サポート範囲

正本はsupport matrix。gemspec・CIも同じ範囲を表す。

種別 対象
Ruby 4.0系
Rails 8.1系(~> 8.1。major updateは検証後に緩和)
ViewComponent 4.1以降の4系(4.0系はRails 8.1と組み合わせ不能なため対象外)
Tailwind CSS v4(tailwindcss-rails ~> 4.3)
tailwind_merge 1.5系
ブラウザ Baseline 2024以上(拘束条件はPopover API: Chrome/Edge 114+ / Safari 17+ / Firefox 125+)。polyfillは提供しない

CIは最新リリース版で全検査を実行し、下限組み合わせ(gemfiles/minimum.gemfile)は rspec / tailwind-build job内の軽量spec再実行で検証する。Node・pnpm等の 開発toolchainの要件もsupport matrixを参照。

インストール

# Gemfile
gem "shadcn_view_components"

Ruby 4.0系・Rails 8.1系(サポート範囲参照)と、Tailwind CSS v4および tailwindcss-rails >= 4.3が必須。tailwindcss-railsは本gemの 実行時依存として導入される。ホストに標準入力がまだない場合は、先に作成する:

bundle install
bin/rails tailwindcss:install
npm install tw-animate-css   # または pnpm add / yarn add
bin/rails generate shadcn_view_components:install

インストーラはapp/assets/tailwind/application.cssだけを対象に、次の固定importを 冪等に配置する。標準入力がない場合はtailwindcss:installの実行を求めて失敗する:

@import "tailwindcss";
@import "tw-animate-css";

/* shadcn_view_components */
@import "../builds/tailwind/shadcn_view_components";

tailwindcss:buildとtailwindcss:watchは、先にtailwindcss:enginesを実行して app/assets/builds/tailwind/shadcn_view_components.cssを自動生成する。このwrapperが gem同梱のEngine CSSを読み、生成契約、Calendar個別契約、ViewComponent、配布JavaScriptを gem内部の相対@sourceで走査する。ホストCSSにgemの物理パスや@sourceは保存されず、 gem更新後にTailwindパス更新のためgeneratorを再実行する必要もない。

app/assets/builds/tailwind/shadcn_view_components.cssは生成物なので直接編集しない。 tw-animate-cssはホスト入力から解決するため、上記のnpm依存は必須。

JS(インタラクティブコンポーネント利用時)

importmap-rails利用時はエンジンが自動pinする:

// app/javascript/application.js
import { register } from "@supermomonga/shadcn-view-components"
register(application)

自動pinを使えないimportmap-rails環境では、次を config/importmap.rb に追加する:

pin_all_from ShadcnViewComponents::Engine.root.join("app/assets/javascripts/shadcn"),
             under: "@supermomonga/shadcn-view-components",
             to: "shadcn"

jsbundling-rails等のbundlerを使う場合は、generatorでESM packageをrepository内の 安定した相対pathへ同期し、そのlocal dependencyを追加する:

bin/rails generate shadcn_view_components:install --javascript=bundler
pnpm add ./vendor/shadcn_view_components/javascript
# npm install ./vendor/shadcn_view_components/javascript でも可

@hotwired/stimulus はホスト側のdependencyを使う。登録コードはimportmapと同じで、 import { register } from "@supermomonga/shadcn-view-components" に統一される。 gem更新後はgeneratorとpackage managerのinstallを再実行し、同期されたpackageをcommitする。

使い方

# 基本
render(Shadcn::Button.new) { "保存" }

# バリアント
render(Shadcn::Button.new(variant: :destructive, size: :sm, disabled: true)) { "削除" }

# リンクボタン(asChild代替 — タグ差し替え。data-slot・クラスは維持)
render(Shadcn::Button.new(tag: :a, href: post_path(post))) { "詳細" }

# クラスだけ欲しい場面(自前要素に適用)
Shadcn::Button.classes(variant: :link)

# 複合(ERBでは自然に書ける)
<%= render(Shadcn::Card.new) do %>
  <%= render(Shadcn::Card::Header.new) do %>
    <%= render(Shadcn::Card::Title.new) { "タイトル" } %>
  <% end %>
  <%= render(Shadcn::Card::Content.new) { "本文" } %>
<% end %>

# 浮動要素の配置(Popover / Tooltip / HoverCard / Menu系Contentで共通)
render(Shadcn::Popover::Content.new(
  side: :right,
  align: :start,
  side_offset: 8,
  align_offset: 0,
  collision_padding: 5
)) { "内容" }

# JSで操作する装飾Select。default_valueはhidden inputへ入り、nameでフォーム送信される
render(Shadcn::Select.new(name: "framework", default_value: "rails")) do
  safe_join([
    render(Shadcn::Select::Trigger.new) do
      render(Shadcn::Select::Value.new(placeholder: "選択してください"))
    end,
    render(Shadcn::Select::Content.new) do
      safe_join([
        render(Shadcn::Select::Item.new(value: "rails")) { "Ruby on Rails" },
        render(Shadcn::Select::Item.new(value: "hanami")) { "Hanami" }
      ])
    end
  ])
end

ComboboxをRailsフォームで使う

Comboboxルートが確定値を持ち、Input / ChipsInputは候補を検索する表示用inputとして働く。 name:、default_value:、disabled:、required:、form:はルートへ渡す。 候補の表示ラベルと送信値はItemの本文とvalue:で分ける。

<%= render(Shadcn::Combobox.new(
  name: "profile[framework]",
  default_value: "rails",
  required: true
)) do %>
  <%= render(Shadcn::Combobox::Input.new(placeholder: "検索…")) %>
  <%= render(Shadcn::Combobox::Content.new) do %>
    <%= render(Shadcn::Combobox::List.new) do %>
      <%= render(Shadcn::Combobox::Item.new(value: "rails")) { "Ruby on Rails" } %>
      <%= render(Shadcn::Combobox::Item.new(value: "hanami")) { "Hanami" } %>
    <% end %>
  <% end %>
<% end %>

<%= render(Shadcn::Combobox.new(
  name: "profile[tags][]",
  default_value: %w[rails hanami],
  multiple: true
)) do %>
  <%= render(Shadcn::Combobox::Chips.new) do %>
    <%= render(Shadcn::Combobox::Chip.new(value: "rails")) { "Rails" } %>
    <%= render(Shadcn::Combobox::Chip.new(value: "hanami")) { "Hanami" } %>
    <%= render(Shadcn::Combobox::ChipsInput.new(placeholder: "追加…")) %>
  <% end %>
<% end %>

複数値の[]は自動付与しないため、Railsの配列パラメータには上例のようなname:を指定する。 単一選択を空にすると空文字を送信する。chipsをすべて削除した場合もRailsへキーを送るため、 空文字のhidden inputを有効にする。受信側ではArray(params.dig(:profile, :tags)).compact_blankのように 空文字を除いて空配列へ正規化する。空の自由入力と既存値の重複は無視される。 単一選択では未確定の検索文字列だけでは送信値を変えず、検索入力を空にしたときに空文字へ更新する。 確定値が実際に変わったときだけ、ルート内の select[data-slot="combobox-form-control"]から、bubblingするinput、changeの順で発火する。 初期描画、Turboによる再接続、同じ値の再選択では発火しない。バリデーションエラー時は、 サーバへ届いた値をdefault_value:へ戻して再描画する。

InputOTPをフォームで使う

InputOTPは実際のinput[type="text"]を値と選択範囲の唯一の情報源にし、各桁のSlotを 表示用として同期する。ブロックを省略すると、length:個のSlotを1つのGroupに入れた 標準構成を自動で描画する。id、name、form、required、disabled、ARIA、data、 イベント属性は実inputへ渡るため、Railsフォーム、label[for]、エラー要素と直接結び付けられる。

<%= render(Shadcn::Form::Item.new(invalid: @code_error.present?)) do %>
  <%= render(Shadcn::Field::Label.new(for: "verification-code")) { "認証コード" } %>
  <%= render(Shadcn::InputOTP.new(
    id: "verification-code",
    name: "verification[code]",
    length: 6,
    value: params.dig(:verification, :code),
    pattern: '^\d+$',
    required: true,
    aria: {
      invalid: @code_error.present?.to_s,
      describedby: ("verification-code-error" if @code_error.present?)
    }
  )) %>
  <%= render(Shadcn::Form::Error.new(id: "verification-code-error", message: @code_error)) %>
<% end %>

inputmode: "numeric"はモバイル端末へ数字キーボードを示すヒントであり、文字種を制限しない。 数字だけに制限する場合は、上例のようにupstreamのREGEXP_ONLY_DIGITSと同じ pattern: '^\d+$'を指定する。patternに一致しない入力・貼り付けは、不正文字だけを除くのではなく 変更全体を拒否して直前の値を保つ。patternを省略すれば任意の文字を入力できる。 入力中の判定はJavaScriptのRegExpを使うため、patternには^と$を含めて全体一致を明示する。 これにより、HTMLのpattern制約によるフォーム送信時の全体一致判定とも結果が一致する。

独自の区切り方が必要な場合は、ブロック内にGroup、Slot.new(index:)、Separatorを明示する。 container_class:は実inputを覆う表示コンテナへ、class:は実inputへ追加される。初期値、入力、削除、 貼り付け、one-time-codeの自動入力、caretはStimulusが同じ実inputから各Slotへ反映する。 JavaScriptが無効な場合は、同梱のnoscriptスタイルによって実input自体を通常のテキスト欄として表示する。

Checkbox、RadioGroup、Switchをフォームで使う

Checkbox、RadioGroup::Item、Switchは、ネイティブinputのcheckedプロパティを状態の唯一の 情報源にする。data-checked / data-uncheckedは見た目と外部コード向けの投影であり、初期描画、 利用者操作、フォームのreset、Turboによる再接続のたびに現在のcheckedへ同期される。プログラムから input.checkedを変更した場合、ブラウザはchangeイベントを自動では発火しないため、変更後に bubblingするchangeイベントをdispatchする。

<%= render(Shadcn::Checkbox.new(
  id: "terms",
  name: "account[terms]",
  value: "accepted",
  required: true,
  aria: { label: "利用規約に同意する" }
)) %>

<%= render(Shadcn::RadioGroup.new(aria: { label: "プラン" })) do %>
  <%= render(Shadcn::RadioGroup::Item.new(name: "account[plan]", value: "free", checked: true, aria: { label: "無料" })) %>
  <%= render(Shadcn::RadioGroup::Item.new(name: "account[plan]", value: "pro", aria: { label: "プロ" })) %>
<% end %>

<%= render(Shadcn::Switch.new(name: "account[notifications]", value: "enabled", aria: { label: "通知" })) %>

Radioのname、value、checked、required、disabled、formはRadioGroupではなく各Itemへ 指定する。同じフォーム所有者とnameを持つItemは、ブラウザのネイティブな排他グループになる。 CheckboxとSwitchは未選択時にはフォーム値を送信せず、Radioは選択されたItemの値だけを送信する。 未選択値も必要な場合はRailsのフォームヘルパと同様にhidden inputを別途置く。checked属性は初期値と reset後の復帰先を表す。JavaScriptが無効でも、選択・キーボード操作・フォーム送信・表示色と印の切替は ネイティブinputと:checked CSSで動作する。

Sliderをフォームで使う

Sliderはネイティブのinput[type="range"]を値の唯一の情報源にする。id、name、form、 disabled、required、aria:、data:、イベント属性は実際のinputへ渡り、class:、style:、 tag:だけが見た目を構成するRootへ渡る。

<%= render(Shadcn::Label.new(for: "volume")) { "音量" } %>
<%= render(Shadcn::Slider.new(
  id: "volume",
  name: "settings[volume]",
  min: 0,
  max: 100,
  step: 5,
  value: 40,
  aria: { label: "音量" }
)) %>

min / max / step / valueは有限数として検証し、max > min、step > 0、値域、 minを基準にしたstepとの一致を満たさない値はArgumentErrorにする。value: nilではブラウザ標準の step調整済み中間値を使う。orientation: :verticalではRootへ高さをstyle:またはclass:で指定する。 ポインタ・矢印キー・Home / Endの値変更はネイティブinputへ委ね、Stimulusはrangeとthumbの表示だけを同期する。

Carouselの向きと書字方向を指定する

CarouselはRootのorientation:とdirection:をレイアウトと移動方向の唯一の指定箇所にする。 子のContent、Item、Previous、Nextへ同じ値を繰り返し渡す必要はない。direction:はRootの dir属性にも反映され、ブラウザのRTLレイアウトとcontrollerのスクロール位置正規化を一致させる。

<h2 id="recommendations-title">おすすめ</h2>
<%= render(Shadcn::Carousel.new(
  orientation: :vertical,
  direction: :rtl,
  class: "h-80 max-w-xs",
  aria: { labelledby: "recommendations-title" }
)) do %>
  <%= render(Shadcn::Carousel::Content.new(class: "h-64")) do %>
    <% recommendations.each_with_index do |recommendation, index| %>
      <%= render(Shadcn::Carousel::Item.new(
        aria: { label: "#{index + 1} of #{recommendations.size}" }
      )) { recommendation.name } %>
    <% end %>
  <% end %>
  <%= render(Shadcn::Carousel::Previous.new) %>
  <%= render(Shadcn::Carousel::Next.new) %>
<% end %>

縦向きでは上例のように表示領域の高さを指定する。移動先はviewport幅・高さの固定量ではなく、 実際の各Itemの位置から決めるため、Itemの寸法が異なる場合やレスポンシブ変更後も同じAPIを使える。 横LTRでは左矢印が前、右矢印が次、横RTLではその対応が逆になり、縦向きは書字方向にかかわらず 上矢印が前、下矢印が次になる。Previous / Nextは現在の論理スクロール位置が先頭・末尾に達したとき、 ネイティブのdisabled状態へ同期する。Rootは既定でtabindex="0"となるため、Rootへフォーカスして 矢印キーを使える。既存のフォーカス設計へ組み込む場合はtabindex:で明示的に上書きできる。

Rootには内容を表すaria-labelまたはaria-labelledbyを指定する。各Itemにも内容名、または上例の 1 of Nのような位置を表す名前を指定できる。複数Itemが同時に見える構成もあるため、ライブラリは Itemへ一律のaria-currentやaria-hiddenを付けない。 WAI-ARIA Carousel Patternに従い、Rootの role="region" / aria-roledescription="carousel"、Itemのrole="group" / aria-roledescription="slide"、ネイティブボタンの操作を保つ。

複合コンポーネントのアクセシビリティ契約

JavaScriptを使う複合コンポーネントは、ルートごとに一意なIDを生成し、子要素間のARIA参照を 接続時に補完する。利用者が指定したid、aria-controls、aria-labelledby、 aria-describedbyなどは上書きしない。fragment cacheなどによって自動生成IDだけが重複した場合は、 そのルートと自動生成した参照先だけを再採番する。入れ子にした同種コンポーネントは、それぞれの controllerが直近のルートだけを管理する。 Tabsの向きはShadcn::Tabs.new(orientation: :horizontal | :vertical)へ指定し、Listの aria-orientationと矢印キーの方向を同じ値から補完する。

コンポーネント ARIA参照・状態 キーボード操作
Dialog / AlertDialog / Sheet / Drawer Triggerとdialogをaria-controlsで結び、TitleとDescriptionをaria-labelledby / aria-describedbyで参照する。開閉時はaria-expandedを同期する ネイティブ<dialog>のモーダルフォーカス管理、Tab巡回、Escapeで閉じてTriggerへ戻る
Tabs TriggerとPanelをaria-controls / aria-labelledbyで相互参照し、aria-selected、tabindex、hiddenを同期する 向きに応じた矢印キー、Home / Endで選択とフォーカスを移動する
Combobox Input、Listbox、Optionをaria-controls / aria-labelledby / aria-activedescendantで結び、候補のaria-selectedを同期する Inputにフォーカスを保ち、矢印キー、Home / End、Enter、Escapeで候補を操作する
Select Trigger、Listbox、Optionをaria-controls / aria-labelledby / aria-activedescendantで結び、開閉・選択状態を同期する Triggerにフォーカスを保ち、矢印キー、Home / End、Enter / Space、Escape、Tabで操作する
Accordion TriggerとContentをaria-controls / aria-labelledbyで相互参照し、aria-expandedを同期する ネイティブ<summary>のEnter / Space操作を保つ
Carousel Rootをregion、Itemをgroupとして識別し、Previous / Nextのdisabledを論理スクロール位置へ同期する 横LTRは左 / 右、横RTLは右 / 左、縦は上 / 下矢印で前後へ移動する。ボタンはEnter / Spaceでも操作できる
Resizable Handleをseparatorとして前方Panelへ結び、aria-valuemin / aria-valuemax / aria-valuenowを同期する 向きに応じた矢印キーで5%ずつ、Home / Endで最小値・最大値へ変更する
Calendar GridをCaptionへ結び、日付セルのaria-selected、当日のaria-current、日付ボタンの読み上げ名を設定する 表示中の月表内で矢印キーを日・週単位、Home / Endを行の先頭・末尾への移動に使う

注意: 純Rubyのコード(#call 内等)で複数の子を render で連ねるときはブロックの 戻り値しか使われないため safe_join([...]) で連結する(ERBでは出力バッファが連結するため不要)。

  • バリアント値は Symbol / String 両方を受け付ける。契約に存在しない値は ArgumentError(fail-fast)
  • data属性・ARIA・CSS値になる意味的プロパティも同様に描画前に検証する。たとえば orientation は horizontal/vertical、Toggle状態は off/on、Sheetの side は top/right/bottom/left、Toggle Groupの type は single/multiple
  • 数値プロパティは有限の数値または厳密な数値文字列だけを受け付ける。nil、上下限、既定値を含む 個別の制約は Shadcn::Progress.property_contract(:value) のように確認できる
  • 浮動要素の side は top/right/bottom/left/inline-start/inline-end、align は start/center/end。画面端では実配置を反転・調整し、scrollやresizeにも追従する
  • Select は単一値の装飾listboxで、Stimulus登録が必要。JS不要、multiple、ブラウザ標準の 制約検証が必要なフォームには、実際の<select>を描くNativeSelectを使う
  • class: で渡した追加クラスは tailwind_merge により契約クラスと統合される(利用者の上書きが後勝ち)
  • テーマのカスタマイズは CSS変数の上書きが唯一の公式経路(@import より後に書く)

開発

bin/setup              # mise toolchain + Ruby + 全JavaScript依存を導入
                       # (Ruby 4.0 / Node 24 / pnpm 10.22.0 — support matrix参照)
bundle exec rake verify # CI必須検査を同じRake taskで順に実行

bundle exec rake verify:spec           # component/conformance/contract/generator/request
bundle exec rake verify:system         # ふるまい(Cuprite + Chrome)
bundle exec rake verify:parity         # visual + animation parity
bundle exec rake verify:javascript     # extractor + distributed JS lint/typecheck/DOM/bundle tests
bundle exec rake verify:sorbet         # Sorbet + RBI freshness
bundle exec rake verify:generated      # コード生成物と生成ドキュメントの決定性
bundle exec rake verify:tailwind       # install 済み gem の consumer 検証と Tailwind build
bundle exec rake verify:rubocop        # Ruby lint
mise run lookbook                      # プレビュー(http://localhost:9292/)
mise run build-css                     # Lookbook用の静的スタイル再生成

CI検査を追加する場合は、.github/workflows/ci.ymlのjobへ対応する LOCAL_VERIFY_TASKを宣言し、同じtaskをverify:fullの依存に追加する。 契約specが全jobとverify:fullの完全一致を検査するため、どちらか一方だけの追加はCIで失敗する。 検査コマンドをworkflowへ直接追加せず、対応するverify:* task内へ実装する。 このworkflowは検証専用とし、他のstepはsetup_*またはartifact_*のIDを付ける。 未分類stepは契約specが拒否し、releaseやdeployのjobは目的別のworkflowへ分ける。 通常CIとupstream同期は、依存関係の導入にも文書と同じbin/setupを使用する。

Lookbook は dummy アプリのルートパス(/)で開く。プレビューツールバーには Theme トグルボタン(月/太陽アイコン) があり、プレビューの ライト/ダークを切り替えられる(選択はクッキーに永続化)。反映はプレビュー専用レイアウト (spec/dummy/app/views/layouts/preview.html.erb)が <html class="dark"> として行う。 ボタンは Lookbook の display option「theme」(select)のテンプレートを差し替えたもので (spec/dummy/config/initializers/lookbook_theme_toggle.rb 参照)、動作経路は Lookbook 組み込みのままである。

コンポーネント別テスト範囲

spec/coverage/registry.yml は、実装済みの全61 registry itemについて、最小描画、 Lookbookプレビュー、upstreamとの見た目比較、ブラウザ操作テストの対応を管理する正本である。 見た目比較またはブラウザ操作テストを行わない項目にも、機械検査できる除外理由を必ず記載する。 契約registryへ実装済みitemを追加したのにcoverage行がない場合、存在しないpreview・controller・ system specを指定した場合、あるいはsystem spec側の対象・確認項目と台帳が一致しない場合はCIが失敗する。

全公開exportの最小構成はcomponent contract specが実際に描画し、全preview exampleはrequest specが HTTP描画する。操作を持つコンポーネントはsystem specのcomponent_coverage metadataで、pointer、 keyboard、state、form、reset、reconnect、no-JS、accessibilityなど、実際に確認するふるまいを宣言する。

生成パイプライン

rake shadcn:sync      # upstream レジストリを取得 → vendor/shadcn/(ネットワーク使用)
rake shadcn:generate  # vendor から契約JSON + Ruby + CSS を生成(オフライン)
rake shadcn:update    # sync + generate(追従作業のフルセット)
rake shadcn:check     # 決定論性検証(一時ディレクトリ生成とコミット済み生成物のバイト比較)

shadcn:sync は、upstream の内容と revision が前回から変わらない場合、manifest の fetched_at / checked_at を保持する。同じ入力を再同期しても時刻だけの差分は作られない。 同期時は unversioned なregistry一式とGitHub release metadataを処理の前後で二度確認し、 内容が安定している場合だけ完成済みstaging directoryを vendor/shadcn へ置換する。404、 通信失敗、JSON/schema不正、同期中の変更では既存snapshotを変更せず、[sync:<種別>] と retryable=true|false をエラーへ出す。

編集ポリシー(詳細はアーキテクチャとリポジトリ構成):

パス 性質 編集
app/components/shadcn/ 手書き 可
app/assets/javascripts/shadcn/ 手書き 可
tools/extractor/ 手書き(パイプライン本体) 可
vendor/shadcn/ スナップショット rake shadcn:sync のみ
gen/contracts/, lib/shadcn_view_components/generated/, app/assets/stylesheets/shadcn/ 生成物 禁止(再生成)

新規コンポーネント追加

  1. tools/extractor/config/targets.json にアイテム名を追加 → rake shadcn:generate
  2. app/components/shadcn/<name>.rb + <name>.html.erb を実装(クラスは Classes.resolve 経由のみ。04 §7のチェックリスト参照)
  3. spec/conformance/registry.yml に1行追加(該当アイテムを非対応の unsupported から exports へ)→ 適合試験が自動的に全組み合わせを検証
  4. spec/coverage/registry.yml にpreview、見た目比較、操作テストの対応を追加する。対象外にする検証には具体的な理由を書く

週次upstream追従

.github/workflows/upstream-drift.yml が毎週月曜 09:00 JST に rake shadcn:update を実行する。差分があれば、Chromeを含む全依存を用意して bundle exec rake verify の8検査を先に実行し、全て成功した場合だけ自動PR (chore/upstream-sync) を作成する。検証失敗はworkflow自体の失敗となり、PRは作成しない。

PR作成は github.token に固定し、最初にdraftで作成する。同じhead branchを指定して CI workflowを workflow_dispatch する。GitHubの再帰防止により、github.token が 作成したPRの pull_request eventが起動しない場合でも、PR headに必須の 8 status checkが付く。その8個が実際に作成された後だけreview readyにする。 PR本文とActions summaryには、upstreamの完全なcommit SHA、 registry snapshot hash、取得日時、先行検証の結果とworkflow URLを記録する。手動実行は workflow_dispatch から行う。

リポジトリ設定ではActionsにPull Request作成を許可し、mainの必須checkを lint-ruby, lint-js, sorbet, rspec, system, parity, determinism, tailwind-build に固定する。専用PATやApp tokenは使用しない。

見た目のupstreamパリティ検証(visual parity)

各コンポーネントの見た目が元のshadcn/ui実装(vendor/shadcn のReact実装)と一致するかをピクセル比較で機械判定する仕組み。契約による「クラス文字列の一致」、コンポーネントspecによる「DOM構造の一致」の先にある最終段(「CSS適用結果を含めた見た目の一致」)を検証する。

bundle exec rake parity:run                      # または mise run parity(ハーネスを明示的に再ビルド)
bundle exec rake parity:update                   # 追跡baselineを意図して更新するときだけ実行

仕組み: vendor/shadcn の tsx を tools/visual-parity で展開し、Vite + React で実際に描画(upstream側)。dummy の Lookbook プレビュー(うち側)と同じ Chromium(Cuprite)でスクリーンショットを撮り、画像寸法と復号後の全RGBA値の完全一致を判定する。upstream側のスタイルは upstream 実アプリの globals.css 相当のみを抽出器が生成した tools/visual-parity/src/upstream_theme.css(トークン + npm shadcn/tailwind.css の verbatim取り込み)から与えられ、gem の shadcn.css とは独立している。これにより shadcn.css への移植漏れ・移植ミス(例: カスタムバリアント未定義でクラスが沈黙する)が upstream 側との差分として検出される(共有してしまうと両側が同じだけ壊れて差分が消えるため)。両側で撮影前にレイアウトとアニメーションを揃え、同一ブラウザ・同一フォントで比較する。各シナリオは light/dark 両カラースキームで撮影する(dark は両側の <html> に .dark を付与。dark:bg-destructive/60 等の dark時ユーティリティや .dark トークンの差分はこのモードでしか検出できない)。

既定の許容差分は0px。画像寸法が異なる場合も失敗する。実装上避けられない差分だけ、 spec/coverage/registry.yml に理由と許容する矩形領域を記録する。現在の例外は、 ネイティブCSSスクロールバーを使うScrollAreaの右端だけ。領域外のRGBAは常に完全一致を要求する。 CIのparity jobはUbuntu 24.04とChromiumのrevisionを固定し、system jobは最新Chromeで動作を検証する。

さらにアニメーションパリティ(spec/visual/animation_parity_spec.rb)では、両側のコンポーネントを開いた直後に WAAPI でアニメーションを取得・停止し、currentTime を同一チェックポイント(0/25/50/75/100%)に固定した上で補間値(opacity / transform / 高さ)とアニメーション名・持続時間・イージングを比較する。時間を仮想化するため実行タイミングに影響されない(drawer は upstream が vaul のJSバネ物理で動くため対象外)。

  • 素の bundle exec rspec でも常時実行される。upstream参照サーバ(vite preview)のキャッシュビルドと起動・停止は spec/support/parity_server.rb が自動で行う(ビルド入力のハッシュが変わらなければ再ビルドを省略)。明示的に外したいときだけ PARITY=0 bundle exec rspec
  • CI でも必須ジョブ(parity)として実行される
  • 通常実行の成果物はgitignore済みの tmp/visual-parity/run-<pid>/<demo>/<light|dark>/ (ours.png / upstream.png / diff.png / report.json)へ出力する。CI失敗時は同じ内容を visual-parity-<run id>-<attempt> artifactとして7日間保存する
  • 追跡中の spec/visual/baselines/ は通常テストから変更しない。 bundle exec rake parity:update を明示的に実行した場合だけ更新し、画像差分をレビューしてCommitする
  • 対象は spec/coverage/registry.yml が管理する99シナリオ。各シナリオをlight/darkで比較し、対象外5アイテムは同一状態を比較できない設計差の理由を同registryへ記録する

シナリオの追加手順:

  1. 対象コンポーネントの Lookbook プレビュー(spec/dummy/app/components/previews/shadcn/*_preview.rb)を用意
  2. tools/visual-parity/src/demos.tsx に同じテキスト・props・並びのJSXデモを追加(キーは <コンポーネント>/<シナリオ>)
  3. spec/coverage/registry.yml の対象itemのparity.scenariosにプレビューのパスとデモIDを追加

tools/visual-parity/src/components/ui/(展開したupstreamソース)と dist/ は gitignore 済みで、pnpm run unpack / vite build が常に vendor/shadcn から再生成する(sha256検証つき)。

トラブルシューティング

症状 原因 対処
スタイルがまったく当たらない Engine wrapperが未生成、または固定importがない インストーラを実行し、bin/rails tailwindcss:buildを実行
ダークモードが効かない .dark の付与先が <html> 以外 <html class="dark"> に付与
変数を上書きしたのに反映されない 上書き位置が @import より前 importより後に書く
クラスの競合が意図どおりに解決されない tailwind_merge gemのバージョン差 issueで報告

ライセンス

MIT License。本プロジェクトと shadcn/ui 由来の配布物のライセンス本文は、gem に同梱した LICENSE を参照してください。