From 8b96e564b156f817bf76b9d23d8ce75a956d9d8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:56:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(ui):=20style=20=E5=B1=9E=E6=80=A7=E3=82=92?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=82=8B=20CSP=20=E3=81=AE=E4=B8=8B=E3=81=A7?= =?UTF-8?q?=E3=82=82=E3=83=8D=E3=82=A4=E3=83=86=E3=82=A3=E3=83=96=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E3=81=A8=20Skeleton=20=E3=81=8C=E5=B4=A9=E3=82=8C?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkbox・Radio・Switch の隣のネイティブ入力を hide-native-input でクラスからも隠す - Skeleton の既定の大きさをクラスで持ち、指定が無いときは style 属性を出さない - ルールブック 04 に「CSP の下で使う」を追加(Base UI が出す style、ハッシュでの許し方、eval を使わない検証ライブラリ) bouzu(tkc310/bouzu#78・#82・#84)での知見を反映。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S5dPLGGVzZyRz8dob3cTmz --- .changeset/csp-friendly.md | 8 ++++++++ docs/improvement/log.md | 1 + docs/rulebook/04-components.md | 15 ++++++++++++++ .../src/components/ui/Checkbox/index.test.tsx | 6 ++++++ .../ui/src/components/ui/Checkbox/index.tsx | 2 +- .../src/components/ui/Skeleton/index.test.tsx | 7 +++++++ .../ui/src/components/ui/Skeleton/index.tsx | 20 ++++++++----------- .../src/components/ui/Switch/index.test.tsx | 6 ++++++ .../ui/src/components/ui/Switch/index.tsx | 2 +- packages/ui/src/styles/theme.css | 20 +++++++++++++++++++ 10 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 .changeset/csp-friendly.md diff --git a/.changeset/csp-friendly.md b/.changeset/csp-friendly.md new file mode 100644 index 00000000..605b2c5a --- /dev/null +++ b/.changeset/csp-friendly.md @@ -0,0 +1,8 @@ +--- +'8341-ui': patch +--- + +`style-src` に `'unsafe-inline'` を入れない CSP の下でも崩れないようにしました。 + +- Checkbox・Radio・Switch: Base UI が隣に描くネイティブ入力の style 属性が CSP で止まると、入力が見えていた。テーマの `hide-native-input` でクラスからも隠す +- Skeleton: 既定の大きさ(幅いっぱい・1em)をクラスで持ち、`width` / `height` を指定しないときは style 属性を出さない diff --git a/docs/improvement/log.md b/docs/improvement/log.md index 4e8c9fd6..9c06cf8c 100644 --- a/docs/improvement/log.md +++ b/docs/improvement/log.md @@ -4,6 +4,7 @@ | 日付 | 種別 | 内容 | 再発防止 | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 2026-11-07 | 部品 | 利用側が `style-src` に `'unsafe-inline'` を入れない CSP を掛けると、サーバーで描画した `style` 属性が止まり、Checkbox・Radio・Switch の隣のネイティブ入力が見えていた。`hide-native-input` でクラスからも隠し、Skeleton の既定の大きさをクラスに移した。ルールブック 04 に「CSP の下で使う」(Base UI が出す style、ハッシュでの許し方、eval を使わない検証ライブラリ)を追加。bouzu(#78・#82・#84)での知見 | Checkbox・Switch・Skeleton のテスト。ルールブック 04 に style 属性に頼らない規約を追加 | | 2026-11-02 | 保守 | Node.js を 26.13.0(LTS Lithium)に更新。`devEngines` を `>=26.13.0`、`@types/node` を 26 系(`^26.6.6`)に合わせた。他の依存の版は変えていない | ADR-0020。CI は `.tool-versions` の版を使う | | 2026-10-23 | 保守 | 全体のリファクタリング。古い参照(存在しない make ターゲット・パス・README の節名)を直し、トークンを変えた後の手順を `make generate` に統一。Makefile と重複していた pnpm スクリプト、design-review に混ざっていたコードレビュー・Next.js の項目、使われていない関数と export を削除。DESIGN.md・ルールブック 04・06・CONTRIBUTING・factory.md の重複を正本へのリンクにし、トークンの CSS 化の規則を `packages/tokens/scripts/lib/css.ts` に集めて build.ts とスライドで共有 | 規則を 1 か所にしたので、tokens.css とスライドの値の書き方がずれない。重複していた手順は正本(Makefile・COMPONENT_STRUCTURE.md・06 章・ds-factory)だけに置いた | | 2026-10-23 | 機能 | 8341-ui を使うプロダクトに、DESIGN.md と design-principles を配るレジストリ item `@8341/design-md` を追加。DESIGN.md はコードから読み取れない考え方(プロダクトの前提・画面領域ごとの方向性・大事にすること・外した必須)と原則の必須の要約だけにし、トークンや部品の一覧は載せない | ADR-0019。原則の要約は `scripts/sync-product-design.ts` が docs/design-principles から生成し、`make check` で鮮度を検査。配布する原則のファイル一覧も registry.json の --check で追従 | diff --git a/docs/rulebook/04-components.md b/docs/rulebook/04-components.md index f3d5d427..5536f4d3 100644 --- a/docs/rulebook/04-components.md +++ b/docs/rulebook/04-components.md @@ -41,6 +41,21 @@ - 強制カラーモードは `forced-colors:` で補う - 論理プロパティのクラスを使う(`ps-*`、`ms-*`、`border-s-*`) - クラス名を動的に組み立てない(`` `text-${size}` `` は Tailwind が検出できない)。variants に列挙する +- 見た目に欠かせない値を `style` 属性に頼らない。既定の値はクラスで持ち、`style` は利用者が値を指定したときだけ出す(下の「CSP の下で使う」)。動かす値(ドラッグ中の位置など)はスクリプトから CSSOM(`element.style.transform = …`)で変える + +## CSP の下で使う + +利用側が `style-src` に `'unsafe-inline'` を入れない CSP を掛けると、サーバーで描画した HTML の `style` 属性は止まる(スクリプトから CSSOM で変えた値は止まらない)。部品はそれでも崩れないように作る。 + +| 出どころ | 何が起きるか | 部品の側の対処 | +| --- | --- | --- | +| Base UI の Checkbox・Radio・Switch が隣に描くネイティブ入力(`aria-hidden`)の `style` | 見えないはずの入力が表示される | ラベルの `hide-native-input`(theme.css)でクラスからも隠す | +| Base UI の Accordion の閉じたパネルの `--accordion-panel-height:auto;…` | 違反が報告される(開閉の動きの初期値だけで、表示は崩れない) | なし(利用側でハッシュを許すか、報告を受け入れる) | +| Skeleton の `width` / `height` | 指定した大きさにならない | 既定の大きさはクラスで持つ。CSP を掛けるなら `className`(`w-*`・`h-*`)で指定する | + +利用側で違反の報告まで消したいときは、`'unsafe-inline'` ではなく `'unsafe-hashes'` と値ごとのハッシュで許す(`'sha256-…'` は style 属性の文字列そのものから作る。ルーターなど部品以外が出す style も同じ扱い)。ハッシュは依存の更新で変わりうるので、テストで文字列から計算して確かめる。 + +フォームの検証にスキーマのライブラリを使うときは、`eval` / `new Function` を使わないものを選ぶ。Zod 4 は `z.object()` を作るときに JIT のため `new Function` を試し、`script-src` に `'unsafe-eval'` が無いと違反が報告される(動作は続く)。`z.config({ jitless: true })` はスキーマを作るどの import よりも先に要るので、Valibot など eval を使わないものの方が扱いやすい。 ## 完了の定義(Definition of Done) diff --git a/packages/ui/src/components/ui/Checkbox/index.test.tsx b/packages/ui/src/components/ui/Checkbox/index.test.tsx index 4568ffa6..5ab82b7f 100644 --- a/packages/ui/src/components/ui/Checkbox/index.test.tsx +++ b/packages/ui/src/components/ui/Checkbox/index.test.tsx @@ -17,4 +17,10 @@ describe('Checkbox', () => { expect(icon).toHaveClass('block'); expect(icon?.parentElement).toHaveClass('flex', 'items-center', 'justify-center'); }); + + it('隣のネイティブ入力は、style 属性が CSP で止まってもクラスで見えなくする', () => { + const { container } = render(); + const input = container.querySelector('input[aria-hidden="true"]'); + expect(input?.parentElement).toHaveClass('hide-native-input'); + }); }); diff --git a/packages/ui/src/components/ui/Checkbox/index.tsx b/packages/ui/src/components/ui/Checkbox/index.tsx index 4bbc8c4b..c247ae70 100644 --- a/packages/ui/src/components/ui/Checkbox/index.tsx +++ b/packages/ui/src/components/ui/Checkbox/index.tsx @@ -10,7 +10,7 @@ export const choiceVariants = tv({ label: [ 'flex w-fit cursor-pointer items-start gap-gap-sm text-body-md text-fg', // 1 行の高さ(16px × 1.7)に上下の余白を足して 44px のタップ領域にする - 'min-h-target-min py-[calc((var(--8341-size-target-min)-var(--8341-text-body-md-font-size)*var(--8341-text-body-md-line-height))/2)]', + 'hide-native-input min-h-target-min py-[calc((var(--8341-size-target-min)-var(--8341-text-body-md-font-size)*var(--8341-text-body-md-line-height))/2)]', 'has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:text-fg-disabled', ], control: [ diff --git a/packages/ui/src/components/ui/Skeleton/index.test.tsx b/packages/ui/src/components/ui/Skeleton/index.test.tsx index 785234cd..c4952ccd 100644 --- a/packages/ui/src/components/ui/Skeleton/index.test.tsx +++ b/packages/ui/src/components/ui/Skeleton/index.test.tsx @@ -11,4 +11,11 @@ describe('Skeleton', () => { expect(box.style.height).toBe('2em'); expect(box).toHaveClass('rounded-full'); }); + + it('大きさを指定しなければ style 属性を出さない(CSP の style-src で止まらない)', () => { + const { container } = render(); + const box = container.firstElementChild as HTMLElement; + expect(box).not.toHaveAttribute('style'); + expect(box).toHaveClass('w-full', 'h-[1em]'); + }); }); diff --git a/packages/ui/src/components/ui/Skeleton/index.tsx b/packages/ui/src/components/ui/Skeleton/index.tsx index 99a63ed7..7de3e71d 100644 --- a/packages/ui/src/components/ui/Skeleton/index.tsx +++ b/packages/ui/src/components/ui/Skeleton/index.tsx @@ -3,7 +3,8 @@ import { type VariantProps, tv } from '@/lib/tv'; export const skeletonVariants = tv({ base: [ - 'block max-w-full animate-pulse bg-muted motion-reduce:animate-none', + // 既定の大きさ(親の幅いっぱい・1 文字分)はクラスで持つ。style 属性は CSP で止まることがあるため + 'block h-[1em] w-full max-w-full animate-pulse bg-muted motion-reduce:animate-none', // 強制カラーでは背景が消えるので、枠線で形を残す 'forced-colors:border forced-colors:border-[GrayText]', ], @@ -22,9 +23,9 @@ export const skeletonVariants = tv({ export interface SkeletonProps extends Omit, 'children'>, VariantProps { - /** 幅。既定は親の幅いっぱい */ + /** 幅。既定は親の幅いっぱい。指定すると style 属性になるので、CSP で style 属性を止めるなら className(`w-*`)で指定する */ width?: CSSProperties['width']; - /** 高さ。既定は 1 文字分(1em) */ + /** 高さ。既定は 1 文字分(1em)。CSP で style 属性を止めるなら className(`h-*`)で指定する */ height?: CSSProperties['height']; } @@ -32,13 +33,8 @@ export interface SkeletonProps * 読み込み中に、中身の形だけを先に見せる箱。読み上げには出さない(aria-hidden)ので、 * 読み込み中であることは Spinner や、待っている領域の aria-busy で別に伝える。 */ -export function Skeleton({ width = '100%', height = '1em', radius, className, style, ...rest }: SkeletonProps) { - return ( -