Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/csp-friendly.md
Original file line number Diff line number Diff line change
@@ -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 属性を出さない
1 change: 1 addition & 0 deletions docs/improvement/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
| 日付 | 種別 | 内容 | 再発防止 |
| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 2026-11-07 | 部品 | タッチで押したことが伝わるよう、hover だけだった部品(Link・Card・Accordion・Checkbox / Radio / Switch(ラベルを押しても出る)・入力欄・NotificationBanner の閉じる)に、hover と同じ見た目の `active:` を足した。ボタンの見た目(`buttonVariants`)をリンクに当てると `enabled:` が `<a>` に当たらず hover も押した見た目も出なかったので、Button を `not-disabled:` に替えた。端末のタップの塗りは消した。bouzu(#84・#89)での知見 | クラス監査に `enabled-variant`(enabled: を禁止)と `hover-without-active`(hover: だけで active: が無い部品)を追加。Button・Link のテスト。iOS Safari の touchstart の受け手を README に記載 |
| 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 で追従 |
Expand Down
15 changes: 15 additions & 0 deletions docs/rulebook/04-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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)

Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/components/ui/Checkbox/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Checkbox label="利用規約に同意する" />);
const input = container.querySelector('input[aria-hidden="true"]');
expect(input?.parentElement).toHaveClass('hide-native-input');
});
});
2 changes: 1 addition & 1 deletion packages/ui/src/components/ui/Checkbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const choiceVariants = tv({
label: [
'group/choice 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: [
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/components/ui/Skeleton/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Skeleton />);
const box = container.firstElementChild as HTMLElement;
expect(box).not.toHaveAttribute('style');
expect(box).toHaveClass('w-full', 'h-[1em]');
});
});
20 changes: 8 additions & 12 deletions packages/ui/src/components/ui/Skeleton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
],
Expand All @@ -22,23 +23,18 @@ export const skeletonVariants = tv({

export interface SkeletonProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>, VariantProps<typeof skeletonVariants> {
/** 幅。既定は親の幅いっぱい */
/** 幅。既定は親の幅いっぱい。指定すると style 属性になるので、CSP で style 属性を止めるなら className(`w-*`)で指定する */
width?: CSSProperties['width'];
/** 高さ。既定は 1 文字分(1em) */
/** 高さ。既定は 1 文字分(1em)。CSP で style 属性を止めるなら className(`h-*`)で指定する */
height?: CSSProperties['height'];
}

/**
* 読み込み中に、中身の形だけを先に見せる箱。読み上げには出さない(aria-hidden)ので、
* 読み込み中であることは Spinner や、待っている領域の aria-busy で別に伝える。
*/
export function Skeleton({ width = '100%', height = '1em', radius, className, style, ...rest }: SkeletonProps) {
return (
<span
aria-hidden="true"
className={skeletonVariants({ radius, className })}
style={{ width, height, ...style }}
{...rest}
/>
);
export function Skeleton({ width, height, radius, className, style, ...rest }: SkeletonProps) {
// 大きさを指定しないときは style 属性を出さない(CSP の style-src で止まらない)
const sized = width === undefined && height === undefined ? style : { width, height, ...style };
return <span aria-hidden="true" className={skeletonVariants({ radius, className })} style={sized} {...rest} />;
}
6 changes: 6 additions & 0 deletions packages/ui/src/components/ui/Switch/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ describe('Switch', () => {
await user.click(screen.getByText('自動保存'));
expect(screen.getByRole('switch', { name: '自動保存' })).toHaveAttribute('aria-checked', 'false');
});

it('隣のネイティブ入力は、style 属性が CSP で止まってもクラスで見えなくする', () => {
const { container } = render(<Switch label="自動保存" />);
const input = container.querySelector('input[aria-hidden="true"]');
expect(input?.parentElement).toHaveClass('hide-native-input');
});
});
2 changes: 1 addition & 1 deletion packages/ui/src/components/ui/Switch/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const switchVariants = tv({
label: [
'group/switch-label grid w-fit cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-x-gap-sm text-fg',
// ラベル全体がタップ領域になる。トラックの見た目は 44px 未満でも行の高さで 44px を確保する
'min-h-target-min',
'min-h-target-min hide-native-input',
'has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:text-fg-disabled',
],
track: [
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@
}
}

/*
* Base UI は Checkbox・Radio・Switch の隣に、同じ状態のネイティブ入力(aria-hidden)を描き、style 属性で見えなくする。
* サーバーで描画したページに style-src の CSP('unsafe-inline' なし)を掛けると、その style 属性が止まり、入力が見えてしまう。
* display:none にはしない(フォームへ送る入力として残す)。見た目だけを部品の側で隠す。
*/
@utility hide-native-input {
& > input[aria-hidden='true'] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
opacity: 0;
}
}

/* 見た目が小さい操作対象に、44px のタップ領域を擬似要素で足す */
@utility tap-target {
position: relative;
Expand Down
Loading