From 17c77e28d77b96dc539f8aed8365d3fcb403a0ea Mon Sep 17 00:00:00 2001 From: fyui001 Date: Fri, 10 Jul 2026 01:44:17 +0900 Subject: [PATCH 1/3] Refresh design tokens on a neutral zinc palette Unify the color system on zinc, dropping the blue-tinted primary. Add class-based dark variant wiring, color-scheme declarations, success/warning/info and table-header tokens, a high-contrast focus ring, readable destructive foreground in both modes, and a number-input spinner reset. --- app/globals.css | 137 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 91 insertions(+), 46 deletions(-) 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); From a05be46bcabea25739e6bc06d9d65f48bc2ee520 Mon Sep 17 00:00:00 2001 From: fyui001 Date: Fri, 10 Jul 2026 01:52:56 +0900 Subject: [PATCH 2/3] Add system theme preference with SSR no-flash wiring Extend the theme from a light/dark toggle to a light/dark/system preference. The middleware forwards the cookie on every page (the login page previously never received the theme header and always rendered light), the layout sets the dark class at SSR for explicit choices, and an inline no-flash script resolves the OS scheme before hydration when the preference is system. The header toggle becomes a three-option dropdown, the display settings switch becomes a select, and Storybook gains a light/dark toolbar plus the a11y addon. --- .storybook/main.ts | 1 + .storybook/preview.tsx | 53 +++++++++++- app/layout.tsx | 27 ++++-- components/layout/AppShell.tsx | 18 +--- components/page-component/SettingsPage.tsx | 7 +- components/settings/DisplaySettings.tsx | 51 +++++++---- components/theme/ThemeProvider.tsx | 73 ++++++++++++---- components/theme/ThemeToggle.tsx | 85 +++++++++++++++++++ components/theme/noFlashThemeScript.ts | 17 ++++ middleware.ts | 15 +++- package.json | 1 + stories/decorators.tsx | 15 +--- stories/layout/AppShell.stories.tsx | 2 - stories/settings/AccountSettings.stories.tsx | 2 - stories/settings/DisplaySettings.stories.tsx | 21 +++-- .../ExternalServiceSettings.stories.tsx | 2 - stories/settings/SettingsPage.stories.tsx | 3 +- stories/theme/ThemeToggle.stories.tsx | 21 +++++ yarn.lock | 10 ++- 19 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 components/theme/ThemeToggle.tsx create mode 100644 components/theme/noFlashThemeScript.ts create mode 100644 stories/theme/ThemeToggle.stories.tsx 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..7c5ffda 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,33 @@ 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]) + + 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 +43,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 +69,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/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 リスクなし) +