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
1 change: 1 addition & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
58 changes: 54 additions & 4 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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 が <html> に初期クラスを設定するが、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 (
<ThemeProvider key={theme} initialPreference={theme}>
{children}
</ThemeProvider>
)
}

const globalDecorators: Preview['decorators'] = [
(Story) => {
(Story, context) => {
useEffect(() => {
const handler = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('a[href]')
Expand All @@ -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 (
<ThemeProvider initialMode="light">
<WithTheme theme={theme}>
<Story />
</ThemeProvider>
</WithTheme>
)
},
]
Expand All @@ -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,
},
},
},
}

Expand Down
137 changes: 91 additions & 46 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

@layer base {
*,
::after,
Expand All @@ -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);
}
}

Expand All @@ -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);
Expand Down
27 changes: 22 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 (
<html lang="ja" className={initialMode === 'dark' ? 'dark' : ''}>
<body>
<ThemeProvider initialMode={initialMode}>{children}</ThemeProvider>
<html
lang="ja"
className={explicitMode === 'dark' ? 'dark' : undefined}
suppressHydrationWarning
>
<body suppressHydrationWarning>
{explicitMode === null && (
// 定数のちらつき防止スクリプトを注入(ユーザー入力なし・XSS リスクなし)
<script
id="no-flash-theme-init"
dangerouslySetInnerHTML={{ __html: NO_FLASH_THEME_SCRIPT }}
/>
)}
<ThemeProvider initialPreference={initialPreference}>
{children}
</ThemeProvider>
</body>
</html>
)
Expand Down
20 changes: 3 additions & 17 deletions components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,14 @@ import {
ChevronDown,
ChevronRight,
User,
Sun,
Moon,
Settings,
LogOut,
X,
LayoutDashboard,
Pill,
ClipboardList,
} from 'lucide-react'
import { useTheme } from 'components/theme/ThemeProvider'
import { ThemeToggle } from 'components/theme/ThemeToggle'
import { cn } from '@/lib/utils'

const SIDEBAR_WIDTH = 280
Expand Down Expand Up @@ -115,14 +113,13 @@ export default function AppShell({
const pathname = usePathname()
const router = useRouter()
const isMobile = useIsMobile()
const { mode, toggleMode } = useTheme()
const [sidebarOpen, setSidebarOpenState] = useState(initialSidebarOpen)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [groupOpen, setGroupOpen] = useState(true)

const setSidebarOpen = useCallback((open: boolean) => {
setSidebarOpenState(open)
document.cookie = `grace-sidebar-open=${open}; path=/; max-age=31536000`
document.cookie = `grace-sidebar-open=${open}; path=/; max-age=31536000; SameSite=Lax`
}, [])

const activeHref = pathname.startsWith('/medication/drugs')
Expand Down Expand Up @@ -314,18 +311,7 @@ export default function AppShell({
</Link>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="テーマ切替"
onClick={toggleMode}
>
{mode === 'dark' ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</Button>
<ThemeToggle />
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand Down
7 changes: 5 additions & 2 deletions components/page-component/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface SettingsPageProps {
}

export default function SettingsPage({ userName }: SettingsPageProps) {
const { mode, toggleMode } = useTheme()
const { preference, setPreference } = useTheme()

const apiClient = useApiClient()
const [discordUserId, setDiscordUserId] = useState<string | null>(null)
Expand Down Expand Up @@ -52,7 +52,10 @@ export default function SettingsPage({ userName }: SettingsPageProps) {
<div className="flex flex-col gap-5">
<h1 className="text-2xl font-bold">設定</h1>

<DisplaySettings mode={mode} onToggle={toggleMode} />
<DisplaySettings
preference={preference}
onChangePreference={setPreference}
/>

<ExternalServiceSettings
discordUserId={discordUserId}
Expand Down
Loading
Loading