diff --git a/.storybook/main.ts b/.storybook/main.ts index 1bed9c0..4239a54 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,6 +5,7 @@ const config: StorybookConfig = { '../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], + addons: ['@storybook/addon-a11y'], framework: { name: '@storybook/nextjs', options: {}, diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index bc5e780..8bac484 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,38 @@ import React, { useEffect } from 'react' import type { Preview } from '@storybook/react' import '../app/globals.css' -import { ThemeProvider } from '../components/theme/ThemeProvider' +import { + ThemeProvider, + type ThemePreference, +} from '../components/theme/ThemeProvider' + +/** + * Storybook ツールバーの `theme` グローバルに応じて preview ドキュメントに + * `dark` クラスを適用し、ThemeProvider でラップする。実アプリでは SSR の + * layout が に初期クラスを設定するが、Storybook に SSR は無いため + * ここで設定する。 + */ +function WithTheme({ + theme, + children, +}: { + theme: ThemePreference + children: React.ReactNode +}) { + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark') + }, [theme]) + + // key でツールバー切替時に remount し、Provider の state を初期化し直す + return ( + + {children} + + ) +} const globalDecorators: Preview['decorators'] = [ - (Story) => { + (Story, context) => { useEffect(() => { const handler = (e: MouseEvent) => { const anchor = (e.target as HTMLElement).closest('a[href]') @@ -20,10 +48,13 @@ const globalDecorators: Preview['decorators'] = [ document.addEventListener('click', handler, true) return () => document.removeEventListener('click', handler, true) }, []) + + const theme: ThemePreference = + context.globals.theme === 'dark' ? 'dark' : 'light' return ( - + - + ) }, ] @@ -43,6 +74,25 @@ const preview: Preview = { pathname: '/', }, }, + a11y: { + // a11y 違反は UI 上に表示するだけで、まだ実行は失敗させない + test: 'todo', + }, + }, + globalTypes: { + theme: { + description: 'テーマ', + defaultValue: 'light', + toolbar: { + title: 'Theme', + icon: 'sun', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, }, } diff --git a/app/globals.css b/app/globals.css index b3415a2..f94d9fa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; +@custom-variant dark (&:where(.dark, .dark *)); + @layer base { *, ::after, @@ -14,65 +16,100 @@ color: var(--color-foreground); } + input[type='number'] { + -moz-appearance: textfield; + appearance: textfield; + } + + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + } + + /* + * shadcn/ui "new-york" / zinc base color (Tailwind v4 CSS variables). + * Extended with success / warning / info and table-header tokens. + * --ring is set to the primary color so focus rings keep a >= 3:1 contrast + * ratio (WCAG 2.4.11) instead of the low-contrast neutral default. + */ :root { + color-scheme: light; --background: oklch(1 0 0); - --foreground: oklch(0.145 0.004 285.823); + --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0.004 285.823); + --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0.004 285.823); - --primary: oklch(0.205 0.042 265.755); - --primary-foreground: oklch(0.985 0.002 285.823); - --secondary: oklch(0.97 0.001 285.823); - --secondary-foreground: oklch(0.205 0.042 265.755); - --muted: oklch(0.97 0.001 285.823); - --muted-foreground: oklch(0.556 0.016 285.938); - --accent: oklch(0.97 0.001 285.823); - --accent-foreground: oklch(0.205 0.042 265.755); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0.004 285.823); - --input: oklch(0.922 0.004 285.823); - --ring: oklch(0.708 0.028 285.823); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.21 0.006 285.885); --radius: 0.625rem; - --sidebar-background: oklch(0.985 0.002 285.823); - --sidebar-foreground: oklch(0.145 0.004 285.823); - --sidebar-primary: oklch(0.205 0.042 265.755); - --sidebar-primary-foreground: oklch(0.985 0.002 285.823); - --sidebar-accent: oklch(0.97 0.001 285.823); - --sidebar-accent-foreground: oklch(0.205 0.042 265.755); - --sidebar-border: oklch(0.922 0.004 285.823); - --sidebar-ring: oklch(0.708 0.028 285.823); + --success: oklch(0.448 0.119 151.328); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.769 0.188 70.08); + --warning-foreground: oklch(0.344 0.065 58.147); + --info: oklch(0.541 0.1 222.717); + --info-foreground: oklch(1 0 0); + --table-header: oklch(0.985 0.002 247.858); + --table-header-foreground: oklch(0.208 0.042 265.755); + --sidebar-background: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.21 0.006 285.885); } .dark { - --background: oklch(0.145 0.004 285.823); - --foreground: oklch(0.985 0.002 285.823); - --card: oklch(0.205 0.006 285.885); - --card-foreground: oklch(0.985 0.002 285.823); - --popover: oklch(0.205 0.006 285.885); - --popover-foreground: oklch(0.985 0.002 285.823); - --primary: oklch(0.985 0.002 285.823); - --primary-foreground: oklch(0.205 0.042 265.755); - --secondary: oklch(0.274 0.006 285.885); - --secondary-foreground: oklch(0.985 0.002 285.823); - --muted: oklch(0.274 0.006 285.885); - --muted-foreground: oklch(0.655 0.015 285.823); - --accent: oklch(0.335 0.006 285.885); - --accent-foreground: oklch(0.985 0.002 285.823); + color-scheme: dark; + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0.01 285.885); - --sidebar-background: oklch(0.175 0.004 285.823); - --sidebar-foreground: oklch(0.985 0.002 285.823); - --sidebar-primary: oklch(0.985 0.002 285.823); - --sidebar-primary-foreground: oklch(0.205 0.006 285.885); - --sidebar-accent: oklch(0.274 0.006 285.885); - --sidebar-accent-foreground: oklch(0.985 0.002 285.823); + --ring: oklch(0.92 0.004 286.32); + --success: oklch(0.596 0.145 151.328); + --success-foreground: oklch(0.145 0.03 151.328); + --warning: oklch(0.82 0.14 70.08); + --warning-foreground: oklch(0.2 0.04 70.08); + --info: oklch(0.646 0.116 222.717); + --info-foreground: oklch(0.145 0.03 222.717); + --table-header: oklch(0.22 0.015 249.2); + --table-header-foreground: oklch(0.85 0.02 249.2); + --sidebar-background: oklch(0.18 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.92 0.004 286.32); + --sidebar-primary-foreground: oklch(0.21 0.006 285.885); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.5 0.01 285.885); + --sidebar-ring: oklch(0.92 0.004 286.32); } } @@ -96,6 +133,14 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-table-header: var(--table-header); + --color-table-header-foreground: var(--table-header-foreground); --color-sidebar: var(--sidebar-background); --color-sidebar-background: var(--sidebar-background); --color-sidebar-foreground: var(--sidebar-foreground); diff --git a/app/layout.tsx b/app/layout.tsx index 0f57bc1..f29de53 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { headers } from 'next/headers' import './globals.css' import { ThemeProvider } from 'components/theme/ThemeProvider' +import { NO_FLASH_THEME_SCRIPT } from 'components/theme/noFlashThemeScript' export const metadata: Metadata = { title: 'Grace', @@ -14,13 +15,29 @@ export default async function RootLayout({ children: React.ReactNode }>) { const headerStore = await headers() - const initialMode = - headerStore.get('x-theme-mode') === 'dark' ? 'dark' : 'light' + const themeHeader = headerStore.get('x-theme-mode') + const explicitMode: 'light' | 'dark' | null = + themeHeader === 'dark' ? 'dark' : themeHeader === 'light' ? 'light' : null + const initialPreference: 'light' | 'dark' | 'system' = + explicitMode ?? 'system' return ( - - - {children} + + + {explicitMode === null && ( + // 定数のちらつき防止スクリプトを注入(ユーザー入力なし・XSS リスクなし) +