-
-
今日の受付
-
- {summary.totalCheckedIn}
- 人
-
-
-
-
滞在中
-
- {summary.currentlyPresent}
- 人
-
-
-
-
退室済み
-
- {summary.checkedOut}
- 人
-
+
+
+
+ 人
+ >
+ }
+ />
+
+ 0} />
+ 滞在中
+
+ }
+ value={
+ <>
+
+ 人
+ >
+ }
+ />
+
+
+ 人
+ >
+ }
+ />
-
- {lastResult && (
-
-
- チェックアウトしました
-
- {lastResult.checkedOutCount}人を
- {formatJapaneseDateTime(lastResult.checkedOutAt)}にチェックアウトしました。
-
-
- )}
+ {lastResult && (
+
+
+ チェックアウトしました
+
+ {lastResult.checkedOutCount}人を
+ {formatJapaneseDateTime(lastResult.checkedOutAt)}にチェックアウトしました。
+
+
+ )}
- {error && (
-
-
- 処理できませんでした
- {error}
-
- )}
+ {error && (
+
+
+ 処理できませんでした
+ {error}
+
+ )}
-
-
setQuery(e.target.value)}
- placeholder="ID・ニックネーム・氏名・学年で検索"
- className="h-14 rounded-lg bg-white px-5 text-lg"
- />
-
-
-
void checkoutParticipants(selectedPresentIds)}
+
+
setQuery(e.target.value)}
+ placeholder="ID・ニックネーム・氏名・学年で検索"
+ className="h-14 rounded-lg bg-white px-5 text-lg"
/>
+
+
+ void checkoutParticipants(selectedPresentIds)}
+ />
+
-
-
-
+
+
+
-
-
- {filteredSessions.length === 0 ? (
-
-
-
+
+
+
+ {filteredSessions.length === 0 ? (
+
+
+
+
+
+ 表示できる参加者はいません
+
-
- 表示できる参加者はいません
-
-
- ) : (
-
-
-
-
-
- toggleFilteredPresent(checked === true)}
- className="size-6 rounded-md"
- />
-
- 参加者
- 状態
- 受付時刻
- 滞在時間
- 操作
-
-
-
- {filteredSessions.map((session) => {
- const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs);
- return (
-
-
- {session.isPresent ? (
-
- toggleParticipant(session.participantId, checked === true)
- }
- className="size-6 rounded-md"
- />
- ) : null}
-
-
-
-
- {session.nickname}
-
-
- {session.fullName}
-
-
- ID {session.participantId}
- /
- {session.grade}
-
-
-
-
-
-
- {session.isPresent ? '滞在中' : '退室済み'}
-
- {session.term ? (
-
+ ) : (
+
+
+
+
+
+ toggleFilteredPresent(checked === true)}
+ className="size-6 rounded-md"
+ />
+
+ 参加者
+ 状態
+ 受付時刻
+ 滞在時間
+ 操作
+
+
+
+ {filteredSessions.map((session, index) => {
+ const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs);
+ return (
+
+
+ {session.isPresent ? (
+
+ toggleParticipant(session.participantId, checked === true)
+ }
+ className="size-6 rounded-md"
+ />
) : null}
- {!session.counted && }
-
-
-
- {formatJapaneseDateTimeWithYear(session.checkedInAt)}
-
-
- {session.isPresent
- ? `${formatDuration(stayDurationMinutes)} 経過`
- : formatDuration(stayDurationMinutes)}
-
-
-
-
-
- );
- })}
-
-
-
- )}
-
-
+
+
+
+
+ {session.nickname}
+
+
+ {session.fullName}
+
+
+ ID {session.participantId}
+ /
+ {session.grade}
+
+
+
+
+
+
+ {session.isPresent ? '滞在中' : '退室済み'}
+
+ {session.term ? (
+
+ ) : null}
+ {!session.counted && }
+
+
+
+ {formatJapaneseDateTimeWithYear(session.checkedInAt)}
+
+
+ {session.isPresent
+ ? `${formatDuration(stayDurationMinutes)} 経過`
+ : formatDuration(stayDurationMinutes)}
+
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+
-
+
);
}
diff --git a/apps/checkin/src/app/layout.tsx b/apps/checkin/src/app/layout.tsx
index 4e729d0..678a33f 100644
--- a/apps/checkin/src/app/layout.tsx
+++ b/apps/checkin/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from 'next';
import { LINE_Seed_JP } from 'next/font/google';
import '@tecnova/ui/globals.css';
+import './view-transitions.css';
import { cn } from '@tecnova/ui/lib/utils';
import { AppShell } from '@/components/app-shell';
diff --git a/apps/checkin/src/app/login/page.tsx b/apps/checkin/src/app/login/page.tsx
index 9365ce5..829577a 100644
--- a/apps/checkin/src/app/login/page.tsx
+++ b/apps/checkin/src/app/login/page.tsx
@@ -3,20 +3,19 @@
import { IconBrandGoogleFilled } from '@tabler/icons-react';
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Button } from '@tecnova/ui/components/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@tecnova/ui/components/card';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card';
+import { motion, useReducedMotion } from 'motion/react';
+import Image from 'next/image';
import { useState } from 'react';
+import { PageShell } from '@/components/page-shell';
+import { Reveal } from '@/components/reveal';
import { authClient } from '@/lib/auth-client';
+import { tapScale } from '@/lib/motion';
export default function LoginPage() {
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
+ const prefersReduced = useReducedMotion();
const signIn = async () => {
setBusy(true);
@@ -39,27 +38,54 @@ export default function LoginPage() {
};
return (
-
-
-
- うけつけシステム
- 許可リストに登録されたメンターのみ利用できます
-
- {error && (
-
-
- ログインエラー
- {error}
-
-
- )}
-
-
-
-
-
+
+
+
+
+
+
+
+
ようこそ
+
+ うけつけシステムにサインインしてください
+
+
+
+
+ {error && (
+
+
+ ログインエラー
+ {error}
+
+
+ )}
+
+
+
+
+
+ 許可リストに登録されたメンターのみ利用できます
+
+
+
+
+
);
}
diff --git a/apps/checkin/src/app/manual/page.tsx b/apps/checkin/src/app/manual/page.tsx
index 06781a9..b78c74e 100644
--- a/apps/checkin/src/app/manual/page.tsx
+++ b/apps/checkin/src/app/manual/page.tsx
@@ -3,7 +3,6 @@
import {
IconAlertCircle,
IconArrowRight,
- IconBug,
IconChevronRight,
IconKeyboard,
IconSearch,
@@ -18,73 +17,45 @@ import { Input } from '@tecnova/ui/components/input';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
import { cn } from '@tecnova/ui/lib/utils';
+import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { useRouter } from 'next/navigation';
-import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
+import { type FormEvent, useEffect, useState } from 'react';
+import { PageShell } from '@/components/page-shell';
import { PanelHeader } from '@/components/panel-header';
+import { SegmentedControl } from '@/components/segmented-control';
+import { listItemTransition } from '@/lib/motion';
import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id';
type Mode = 'id' | 'name';
export default function ManualPage() {
const [mode, setMode] = useState('id');
-
+ const prefersReduced = useReducedMotion();
return (
-
+
-
- {mode === 'id' ?
:
}
+
},
+ { value: 'name', label: '名前で探す', icon:
},
+ ]}
+ />
+
+
+ {mode === 'id' ? : }
+
+
-
- );
-}
-
-function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (mode: Mode) => void }) {
- return (
-
- onChange('id')}
- icon={}
- label="IDで入力"
- />
- onChange('name')}
- icon={}
- label="名前で探す"
- />
-
- );
-}
-
-function ToggleButton({
- active,
- onClick,
- icon,
- label,
-}: {
- active: boolean;
- onClick: () => void;
- icon: ReactNode;
- label: string;
-}) {
- return (
-
+
);
}
@@ -103,7 +74,11 @@ function IdEntryPanel() {
return (
diff --git a/apps/checkin/src/app/page.tsx b/apps/checkin/src/app/page.tsx
index f080874..0407721 100644
--- a/apps/checkin/src/app/page.tsx
+++ b/apps/checkin/src/app/page.tsx
@@ -2,10 +2,11 @@
import {
IconArrowRight,
- IconBug,
IconCamera,
IconCameraRotate,
+ IconCircleCheck,
IconClipboardCheck,
+ IconKeyboard,
IconQrcode,
IconRefresh,
IconUserPlus,
@@ -13,13 +14,18 @@ import {
import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent, CardDescription } from '@tecnova/ui/components/card';
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
+import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
+import { type ReactNode, useCallback, useEffect, useRef, useState, ViewTransition } from 'react';
+import { PageShell } from '@/components/page-shell';
import { PanelHeader, type PanelTone } from '@/components/panel-header';
+import { Reveal } from '@/components/reveal';
+import { ScanReticle } from '@/components/scan-reticle';
import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id';
function ActionPanel({
+ index,
title,
description,
icon,
@@ -28,6 +34,7 @@ function ActionPanel({
action,
buttonVariant = 'default',
}: {
+ index: number;
title: string;
description: string;
icon: ReactNode;
@@ -37,26 +44,29 @@ function ActionPanel({
buttonVariant?: 'default' | 'outline';
}) {
return (
-
-
-
-
- {description}
-
-
-
-
-
+
+
+
+
+
+ {description}
+
+
+
+
+
+
);
}
export default function Home() {
const router = useRouter();
+ const prefersReduced = useReducedMotion();
const [cameraError, setCameraError] = useState
(null);
const [scannerAttempt, setScannerAttempt] = useState(0);
const [videoDevices, setVideoDevices] = useState([]);
@@ -65,6 +75,7 @@ export default function Home() {
const videoRef = useRef(null);
const controlsRef = useRef(null);
const navigatingRef = useRef(false);
+ const navTimerRef = useRef(null);
const refreshVideoDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return [];
@@ -98,7 +109,9 @@ export default function Home() {
navigatingRef.current = true;
controlsRef.current?.stop();
setNavigatingId(value);
- router.push(participantProfilePath(value));
+ navTimerRef.current = window.setTimeout(() => {
+ router.push(participantProfilePath(value));
+ }, 450);
})
.then((controls) => {
if (cancelled) {
@@ -125,6 +138,16 @@ export default function Home() {
};
}, [navigatingId, refreshVideoDevices, router, scannerAttempt, selectedDeviceId]);
+ // ナビゲーション遅延タイマーはアンマウント時のみ掃除する。
+ // スキャナ effect の cleanup(navigatingId 変更で毎回走る)で消すと遷移がキャンセルされるため分離する。
+ useEffect(() => {
+ return () => {
+ if (navTimerRef.current) {
+ window.clearTimeout(navTimerRef.current);
+ }
+ };
+ }, []);
+
const switchCamera = async () => {
const devices = videoDevices.length > 0 ? videoDevices : await refreshVideoDevices();
if (devices.length <= 1) {
@@ -140,63 +163,88 @@ export default function Home() {
};
return (
-
+
-
- }
- title="QRコードをかざしてね"
- tone="sky"
- />
-
-
-
- {navigatingId && (
-
-
-
プロフィールを開いています
-
- {navigatingId}
-
-
- )}
- {cameraError && (
-
-
- カメラを起動できませんでした
-
- )}
-
-
-
-
-
-
-
+
+
+ }
+ title="QRコードをかざしてね"
+ tone="sky"
+ />
+
+
+
+ {!navigatingId && !cameraError &&
}
+ {navigatingId && (
+
+
+
+
+
+
+ プロフィールを開いています
+
+ {navigatingId}
+
+
+
+
+ )}
+ {cameraError && (
+
+
+ カメラを起動できませんでした
+
+ )}
+
+
+
+
+
+
+
+
}
@@ -204,8 +252,8 @@ export default function Home() {
href="/first-time"
action="初回登録へ"
/>
-
}
@@ -213,11 +261,11 @@ export default function Home() {
href="/history"
action="履歴を見る"
/>
-
}
+ icon={
}
tone="slate"
href="/manual"
action="入力へ"
@@ -226,6 +274,6 @@ export default function Home() {
-
+
);
}
diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx
index 74457b7..9682c81 100644
--- a/apps/checkin/src/app/reception/participants/[id]/page.tsx
+++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx
@@ -38,7 +38,7 @@ import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState, ViewTransition } from 'react';
import { AnimatedNumber } from '@/components/animated-number';
import { PanelHeader } from '@/components/panel-header';
import { ResultSummaryCard } from '@/components/result-summary-card';
@@ -350,27 +350,20 @@ export default function ReceptionParticipantPage() {
{ label: '滞在時間', value: formatDuration(data.stayDurationMinutes) },
];
return (
-
- : }
- rows={resultRows}
- footer={
-
- }
- />
-
+ : }
+ rows={resultRows}
+ footer={
+
+ }
+ />
);
}
@@ -380,122 +373,126 @@ export default function ReceptionParticipantPage() {
-
-
-
-
-
-
-
-
-
-
- {profile.participant.nickname}
-
-
- {profile.participant.fullName}
-
-
-
- ID {profile.participant.id}
-
-
- {profile.participant.grade}
-
+
+
+
+
+
+
+
+
+
+
+
+ {profile.participant.nickname}
+
+
+ {profile.participant.fullName}
+
+
+
+ ID {profile.participant.id}
+
+
+ {profile.participant.grade}
+
+
+
+ {profile.current.isPresent ? (
+
+ ) : (
+
+ )}
+ {profile.current.isPresent ? 'チェックイン中' : '未チェックイン'}
+
-
- {profile.current.isPresent ? (
-
- ) : (
-
- )}
- {profile.current.isPresent ? 'チェックイン中' : '未チェックイン'}
-
-
-
-
-
- 今日の入室
-
-
- {profile.current.checkedInAt
- ? formatJapaneseDateTime(profile.current.checkedInAt)
- : 'まだありません'}
-
-
-
-
-
-
-
- 参加回数
+
+
+
+ 今日の入室
-
-
- 回
+
+ {profile.current.checkedInAt
+ ? formatJapaneseDateTime(profile.current.checkedInAt)
+ : 'まだありません'}
-
- {participationBreakdown.map((item) => (
-
-
-
-
- {item.label}
-
- -
- {item.value}
-
-
- ))}
-
- {stats.map((item) => (
-
-
-
- {item.label}
+
+
+
+
+
+ 参加回数
-
- {item.value}
+
+
+ 回
+
+ {participationBreakdown.map((item) => (
+
+
-
+
+ {item.label}
+
+ -
+ {item.value}
+
+
+ ))}
+
- ))}
-
-
-
-
+ {stats.map((item) => (
+
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+ {me.mentor.name.slice(0, 1)}
+
+
+
{me.mentor.name}
+
+ {me.mentor.role}
+
+ {me.user.email}
+
+
+
- 設定
-
-
-
- {error && (
-
- ログアウトエラー
- {error}
-
- )}
-
-
-
-
- 項目
- 内容
-
-
-
- {rows.map((row) => (
-
-
- {row.label}
-
-
- {row.value}
-
+
+
+ {error && (
+
+ ログアウトエラー
+ {error}
+
+ )}
+
-
-
-
-
-
-
-
-
-
-
- ログアウトしますか
-
- 受付システムを使うには、もう一度 Google ログインが必要です。
-
-
-
-
- キャンセル
-
-
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+
+ {row.value}
+
+
+ ))}
+
+
+
+
+
+
+
-
-
-
+
+ ログアウト
+
+
+
+
+
+
+
+ ログアウトしますか
+
+ 受付システムを使うには、もう一度 Google ログインが必要です。
+
+
+
+
+ キャンセル
+
+ void signOut()}
+ >
+ {isSigningOut ? 'ログアウト中' : 'ログアウトする'}
+
+
+
+
+
+
+
+
-
+
);
}
diff --git a/apps/checkin/src/app/view-transitions.css b/apps/checkin/src/app/view-transitions.css
new file mode 100644
index 0000000..63bebd3
--- /dev/null
+++ b/apps/checkin/src/app/view-transitions.css
@@ -0,0 +1,22 @@
+/* View Transitions(experimental.viewTransition)の調整。
+ ルート間の既定クロスフェードと、スキャン→プロフィールの共有要素モーフの時間を整える。 */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation-duration: 0.28s;
+}
+
+/* スキャン→プロフィールの共有要素モーフ(name="participant-portal") */
+::view-transition-group(participant-portal) {
+ animation-duration: 0.4s;
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+/* アクセシビリティ: モーション低減時はビュー遷移アニメーションを無効化する */
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-group(*),
+ ::view-transition-old(*),
+ ::view-transition-new(*) {
+ /* biome-ignore lint/complexity/noImportantStyles: モーション低減時のアクセシビリティ上書き。上の named morph 規則(participant-portal)に確実に勝つため !important が必要。 */
+ animation-duration: 0s !important;
+ }
+}
diff --git a/apps/checkin/src/components/live-dot.tsx b/apps/checkin/src/components/live-dot.tsx
new file mode 100644
index 0000000..ff10452
--- /dev/null
+++ b/apps/checkin/src/components/live-dot.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { cn } from '@tecnova/ui/lib/utils';
+import { motion, useReducedMotion } from 'motion/react';
+
+// 滞在中=emerald の脈動、未滞在=slate の静止。プロフィール画面の在席ドットと同じ演出。
+export function LiveDot({ active, className }: { active: boolean; className?: string }) {
+ const prefersReduced = useReducedMotion();
+
+ if (!active) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/checkin/src/components/page-shell.tsx b/apps/checkin/src/components/page-shell.tsx
new file mode 100644
index 0000000..f40503c
--- /dev/null
+++ b/apps/checkin/src/components/page-shell.tsx
@@ -0,0 +1,16 @@
+import { cn } from '@tecnova/ui/lib/utils';
+import type { ReactNode } from 'react';
+
+// 全画面共通のグラデーション地。中央寄せ等は className で上書きする。
+export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/checkin/src/components/result-summary-card.tsx b/apps/checkin/src/components/result-summary-card.tsx
index 4f393ac..2488d1e 100644
--- a/apps/checkin/src/components/result-summary-card.tsx
+++ b/apps/checkin/src/components/result-summary-card.tsx
@@ -1,10 +1,12 @@
'use client';
import { Card, CardContent, CardFooter } from '@tecnova/ui/components/card';
-import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table';
+import { Table, TableBody, TableCell } from '@tecnova/ui/components/table';
import { cn } from '@tecnova/ui/lib/utils';
+import { motion, useReducedMotion } from 'motion/react';
import type { ReactNode } from 'react';
import { PanelHeader, type PanelTone } from '@/components/panel-header';
+import { SuccessIcon } from '@/components/success-icon';
export type SummaryRow = {
label: string;
@@ -27,32 +29,87 @@ export function ResultSummaryCard({
note?: ReactNode;
footer: ReactNode;
}) {
+ const prefersReduced = useReducedMotion();
+ const footerDelay = 0.15 + rows.length * 0.05 + 0.05;
+
return (
-
+
-
-
-
-
-
-
- {rows.map((row) => (
-
-
- {row.label}
-
-
- {row.value}
-
-
- ))}
-
-
-
- {note ? {note}
: null}
-
- {footer}
-
+
+
+
+ {icon}
+
+ }
+ title={title}
+ tone={tone}
+ />
+
+
+
+
+ {rows.map((row, index) => (
+
+
+ {row.label}
+
+
+ {row.value}
+
+
+ ))}
+
+
+
+ {note ? (
+
+ {note}
+
+ ) : null}
+
+
+
+ {footer}
+
+
+
+
);
diff --git a/apps/checkin/src/components/reveal.tsx b/apps/checkin/src/components/reveal.tsx
new file mode 100644
index 0000000..6057cf3
--- /dev/null
+++ b/apps/checkin/src/components/reveal.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { motion, useReducedMotion } from 'motion/react';
+import type { ReactNode } from 'react';
+import { revealAnimate, revealInitial, revealTransition } from '@/lib/motion';
+
+// 子をフェードアップで入場させる薄いラッパ。index でスタッガーをずらす。
+// prefers-reduced-motion 時は initial を無効化して即表示する。
+export function Reveal({
+ index = 0,
+ className,
+ children,
+}: {
+ index?: number;
+ className?: string;
+ children: ReactNode;
+}) {
+ const prefersReduced = useReducedMotion();
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/checkin/src/components/scan-reticle.tsx b/apps/checkin/src/components/scan-reticle.tsx
new file mode 100644
index 0000000..0cc004c
--- /dev/null
+++ b/apps/checkin/src/components/scan-reticle.tsx
@@ -0,0 +1,24 @@
+// 映像枠に重ねる QR スキャン演出。スキャン対象の四隅を示す角ブラケットを静的に表示する。
+// pointer-events-none で下のビデオ操作を妨げない。アニメーションは持たない。
+const CORNER_CLASSES = [
+ 'left-0 top-0 rounded-tl-xl border-l-4 border-t-4',
+ 'right-0 top-0 rounded-tr-xl border-r-4 border-t-4',
+ 'left-0 bottom-0 rounded-bl-xl border-b-4 border-l-4',
+ 'right-0 bottom-0 rounded-br-xl border-b-4 border-r-4',
+] as const;
+
+export function ScanReticle() {
+ return (
+
+
+ {CORNER_CLASSES.map((pos) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/checkin/src/components/segmented-control.tsx b/apps/checkin/src/components/segmented-control.tsx
new file mode 100644
index 0000000..643f22d
--- /dev/null
+++ b/apps/checkin/src/components/segmented-control.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { cn } from '@tecnova/ui/lib/utils';
+import { motion, useReducedMotion } from 'motion/react';
+import { type ReactNode, useId } from 'react';
+
+type SegmentedOption = { value: T; label: string; icon?: ReactNode };
+
+// 2 値のモード切替。選択中インジケータが layoutId でスライドする。reduced 時は即切替。
+export function SegmentedControl({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+}: {
+ options: SegmentedOption[];
+ value: T;
+ onChange: (value: T) => void;
+ ariaLabel: string;
+}) {
+ const prefersReduced = useReducedMotion();
+ const layoutId = useId();
+ return (
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/checkin/src/components/stat-tile.tsx b/apps/checkin/src/components/stat-tile.tsx
new file mode 100644
index 0000000..aa8319e
--- /dev/null
+++ b/apps/checkin/src/components/stat-tile.tsx
@@ -0,0 +1,48 @@
+import { cn } from '@tecnova/ui/lib/utils';
+import type { ReactNode } from 'react';
+
+export type StatTone = 'neutral' | 'emerald';
+
+// 集計値タイル。value は ReactNode(AnimatedNumber + 単位サフィックス等)を想定。
+export function StatTile({
+ label,
+ value,
+ icon,
+ tone = 'neutral',
+ className,
+}: {
+ label: ReactNode;
+ value: ReactNode;
+ icon?: ReactNode;
+ tone?: StatTone;
+ className?: string;
+}) {
+ const isEmerald = tone === 'emerald';
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/apps/checkin/src/components/success-icon.tsx b/apps/checkin/src/components/success-icon.tsx
new file mode 100644
index 0000000..619acd2
--- /dev/null
+++ b/apps/checkin/src/components/success-icon.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { cn } from '@tecnova/ui/lib/utils';
+import { motion } from 'motion/react';
+import type { ReactNode } from 'react';
+
+export const ringToneClasses: Record<'emerald' | 'amber', string> = {
+ emerald: 'border-emerald-400',
+ amber: 'border-amber-400',
+};
+
+export function SuccessIcon({
+ tone,
+ prefersReduced,
+ children,
+}: {
+ tone: 'emerald' | 'amber';
+ prefersReduced: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {!prefersReduced && (
+
+ )}
+
+ {children}
+
+
+ );
+}
diff --git a/apps/checkin/src/lib/motion.ts b/apps/checkin/src/lib/motion.ts
new file mode 100644
index 0000000..4dbea4e
--- /dev/null
+++ b/apps/checkin/src/lib/motion.ts
@@ -0,0 +1,37 @@
+import type { Transition } from 'motion/react';
+
+// 入場・小要素演出のイージングは全画面でこの値に統一する(プロフィール画面と同値)。
+const EASE_OUT = 'easeOut' as const;
+
+// セクション/カードのフェードアップ入場。
+export const revealInitial = { opacity: 0, y: 12 } as const;
+export const revealAnimate = { opacity: 1, y: 0 } as const;
+export const REVEAL_STAGGER_STEP = 0.06;
+export const revealTransition = (index = 0): Transition => ({
+ duration: 0.4,
+ ease: EASE_OUT,
+ delay: index * REVEAL_STAGGER_STEP,
+});
+
+// 主要ボタンの押下フィードバック。
+export const tapScale = { scale: 0.97 } as const;
+
+// タイル/小要素のポップ(来場ヒートマップと同値)。スタッガーは間延びしないよう頭打ち。
+export const popInitial = { opacity: 0, scale: 0.6 } as const;
+export const popAnimate = { opacity: 1, scale: 1 } as const;
+export const POP_STAGGER_STEP = 0.012;
+export const POP_STAGGER_MAX = 0.5;
+export const popTransition = (index = 0): Transition => ({
+ duration: 0.25,
+ ease: EASE_OUT,
+ delay: Math.min(index * POP_STAGGER_STEP, POP_STAGGER_MAX),
+});
+
+// 検索結果・候補・履歴行など、行数が多いリスト向けの大きめスタッガー。
+export const LIST_STAGGER_STEP = 0.04;
+export const LIST_STAGGER_MAX = 0.4;
+export const listItemTransition = (index = 0): Transition => ({
+ duration: 0.3,
+ ease: EASE_OUT,
+ delay: Math.min(index * LIST_STAGGER_STEP, LIST_STAGGER_MAX),
+});
diff --git a/docs/superpowers/plans/2026-05-29-checkin-redesign.md b/docs/superpowers/plans/2026-05-29-checkin-redesign.md
new file mode 100644
index 0000000..cfd0256
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-29-checkin-redesign.md
@@ -0,0 +1,1109 @@
+# checkin リデザイン Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** プロフィール画面の motion + Cohesive Elevation 語彙を `apps/checkin` の残り 7 画面へ統一展開し、各高インパクト画面に 1 つのシグネチャーモーメントを与える。
+
+**Architecture:** 先に再利用プリミティブ(motion 定数 / `Reveal` / `StatTile` / `LiveDot` / `PageShell`)を `apps/checkin/src/components` と `src/lib` に用意し、Home を実機ディレクションスライスとして組んで承認を取る。承認後、確定した語彙を残り 6 画面へ展開し、デザイン整合性の敵対的レビューを通す。視覚回帰防止のためプロフィール画面と `AppShell` は触らない。
+
+**Tech Stack:** Next.js 16 (App Router) / React 19 / `motion`(`motion/react`)/ Tailwind v4 / shadcn(`@tecnova/ui`) / `@tabler/icons-react` / Biome / TypeScript strict。
+
+**設計書:** `docs/superpowers/specs/2026-05-29-checkin-redesign-design.md`
+
+**検証パターン(全タスク共通):**
+- 型: `pnpm type-check`
+- Lint/format: `pnpm biome check --write apps/checkin`
+- 目視: `pnpm --filter checkin dev` → 該当画面 + reduced-motion(OS 設定 ON)を確認
+
+**重要な制約:**
+- App Router API を触る箇所(`useSearchParams` / `Suspense` / metadata 等)は実装前に `node_modules/next/dist/docs/` を確認(`apps/checkin/AGENTS.md`)。
+- モーションは必ず `useReducedMotion()` でゲート。transform/opacity のみ。
+- プロフィール画面 `src/app/reception/participants/[id]/page.tsx` と `src/components/app-shell.tsx` は変更しない。
+
+---
+
+## ファイル構成(新規 / 変更)
+
+**新規(プリミティブ)**
+- `apps/checkin/src/lib/motion.ts` — モーション定数・transition ファクトリ
+- `apps/checkin/src/components/reveal.tsx` — フェードアップ入場ラッパ
+- `apps/checkin/src/components/stat-tile.tsx` — 数値タイル
+- `apps/checkin/src/components/live-dot.tsx` — 脈動プレゼンスドット
+- `apps/checkin/src/components/page-shell.tsx` — グラデーション地 ``
+
+**新規(画面固有シグネチャー)**
+- `apps/checkin/src/components/scan-reticle.tsx` — Home の QR スキャン演出
+- `apps/checkin/src/components/segmented-control.tsx` — Manual のモード切替
+
+**変更(7 画面)**
+- `src/app/page.tsx`(Home)/ `login/page.tsx` / `manual/page.tsx` / `first-time/page.tsx` / `guideline/page.tsx` / `history/page.tsx` / `settings/page.tsx`
+
+---
+
+## Phase 0 — 準備
+
+### Task 0: フィーチャーブランチ作成
+
+- [ ] **Step 1: develop から作業ブランチを切る**
+
+```bash
+git checkout develop
+git pull --ff-only
+git checkout -b feat/checkin-redesign
+```
+
+- [ ] **Step 2: 起点を確認**
+
+Run: `git status && git log --oneline -1`
+Expected: クリーン、`develop` 先端から分岐。
+
+---
+
+## Phase 1 — 共有プリミティブ
+
+### Task 1: モーション定数モジュール
+
+**Files:**
+- Create: `apps/checkin/src/lib/motion.ts`
+
+- [ ] **Step 1: 定数とファクトリを実装**
+
+```ts
+import type { Transition } from 'motion/react';
+
+// 入場・小要素演出のイージングは全画面でこの値に統一する(プロフィール画面と同値)。
+const EASE_OUT = 'easeOut' as const;
+
+// セクション/カードのフェードアップ入場。
+export const revealInitial = { opacity: 0, y: 12 } as const;
+export const revealAnimate = { opacity: 1, y: 0 } as const;
+export const REVEAL_STAGGER_STEP = 0.06;
+export const revealTransition = (index = 0): Transition => ({
+ duration: 0.4,
+ ease: EASE_OUT,
+ delay: index * REVEAL_STAGGER_STEP,
+});
+
+// 主要ボタンの押下フィードバック。
+export const tapScale = { scale: 0.97 } as const;
+
+// タイル/小要素のポップ(来場ヒートマップと同値)。スタッガーは間延びしないよう頭打ち。
+export const popInitial = { opacity: 0, scale: 0.6 } as const;
+export const popAnimate = { opacity: 1, scale: 1 } as const;
+export const POP_STAGGER_STEP = 0.012;
+export const POP_STAGGER_MAX = 0.5;
+export const popTransition = (index = 0): Transition => ({
+ duration: 0.25,
+ ease: EASE_OUT,
+ delay: Math.min(index * POP_STAGGER_STEP, POP_STAGGER_MAX),
+});
+
+// 検索結果・候補・履歴行など、行数が多いリスト向けの大きめスタッガー。
+export const LIST_STAGGER_STEP = 0.04;
+export const LIST_STAGGER_MAX = 0.4;
+export const listItemTransition = (index = 0): Transition => ({
+ duration: 0.3,
+ ease: EASE_OUT,
+ delay: Math.min(index * LIST_STAGGER_STEP, LIST_STAGGER_MAX),
+});
+```
+
+- [ ] **Step 2: 型チェック**
+
+Run: `pnpm type-check`
+Expected: PASS(エラーなし)。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/lib/motion.ts
+git commit -m "feat(checkin): add shared motion constants"
+```
+
+### Task 2: Reveal コンポーネント
+
+**Files:**
+- Create: `apps/checkin/src/components/reveal.tsx`
+
+- [ ] **Step 1: 実装**
+
+```tsx
+'use client';
+
+import { motion, useReducedMotion } from 'motion/react';
+import type { ReactNode } from 'react';
+import { revealAnimate, revealInitial, revealTransition } from '@/lib/motion';
+
+// 子をフェードアップで入場させる薄いラッパ。index でスタッガーをずらす。
+// prefers-reduced-motion 時は initial を無効化して即表示する。
+export function Reveal({
+ index = 0,
+ className,
+ children,
+}: {
+ index?: number;
+ className?: string;
+ children: ReactNode;
+}) {
+ const prefersReduced = useReducedMotion();
+ return (
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/components/reveal.tsx`
+Expected: PASS。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/components/reveal.tsx
+git commit -m "feat(checkin): add Reveal entrance wrapper"
+```
+
+### Task 3: StatTile コンポーネント
+
+**Files:**
+- Create: `apps/checkin/src/components/stat-tile.tsx`
+
+- [ ] **Step 1: 実装**
+
+```tsx
+import { cn } from '@tecnova/ui/lib/utils';
+import type { ReactNode } from 'react';
+
+type StatTone = 'neutral' | 'emerald';
+
+// 集計値タイル。value は ReactNode(AnimatedNumber + 単位サフィックス等)を想定。
+export function StatTile({
+ label,
+ value,
+ icon,
+ tone = 'neutral',
+ className,
+}: {
+ label: ReactNode;
+ value: ReactNode;
+ icon?: ReactNode;
+ tone?: StatTone;
+ className?: string;
+}) {
+ const isEmerald = tone === 'emerald';
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/components/stat-tile.tsx`
+Expected: PASS。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/components/stat-tile.tsx
+git commit -m "feat(checkin): add StatTile component"
+```
+
+### Task 4: LiveDot コンポーネント
+
+**Files:**
+- Create: `apps/checkin/src/components/live-dot.tsx`
+
+- [ ] **Step 1: 実装**
+
+```tsx
+'use client';
+
+import { cn } from '@tecnova/ui/lib/utils';
+import { motion, useReducedMotion } from 'motion/react';
+
+// 滞在中=emerald の脈動、未滞在=slate の静止。プロフィール画面の在席ドットと同じ演出。
+export function LiveDot({ active, className }: { active: boolean; className?: string }) {
+ const prefersReduced = useReducedMotion();
+
+ if (!active) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/components/live-dot.tsx`
+Expected: PASS。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/components/live-dot.tsx
+git commit -m "feat(checkin): add LiveDot presence indicator"
+```
+
+### Task 5: PageShell コンポーネント
+
+**Files:**
+- Create: `apps/checkin/src/components/page-shell.tsx`
+
+- [ ] **Step 1: 実装**
+
+```tsx
+import { cn } from '@tecnova/ui/lib/utils';
+import type { ReactNode } from 'react';
+
+// 全画面共通のグラデーション地。中央寄せ等は className で上書きする。
+export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/components/page-shell.tsx`
+Expected: PASS。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/components/page-shell.tsx
+git commit -m "feat(checkin): add PageShell gradient ground"
+```
+
+---
+
+## Phase 2 — Home ディレクションスライス(承認ゲート)
+
+### Task 6: ScanReticle コンポーネント
+
+**Files:**
+- Create: `apps/checkin/src/components/scan-reticle.tsx`
+
+- [ ] **Step 1: 実装**
+
+```tsx
+'use client';
+
+import { motion, useReducedMotion } from 'motion/react';
+
+// 映像枠に重ねる QR スキャン演出。角ブラケット + 上下に走る走査線。
+// pointer-events-none で下のビデオ操作を妨げない。reduced 時は走査線を止めブラケットのみ。
+const CORNER_CLASSES = [
+ 'left-0 top-0 rounded-tl-xl border-l-4 border-t-4',
+ 'right-0 top-0 rounded-tr-xl border-r-4 border-t-4',
+ 'left-0 bottom-0 rounded-bl-xl border-b-4 border-l-4',
+ 'right-0 bottom-0 rounded-br-xl border-b-4 border-r-4',
+] as const;
+
+export function ScanReticle() {
+ const prefersReduced = useReducedMotion();
+ return (
+
+
+ {CORNER_CLASSES.map((pos) => (
+
+ ))}
+ {!prefersReduced && (
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/components/scan-reticle.tsx`
+Expected: PASS。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/components/scan-reticle.tsx
+git commit -m "feat(checkin): add ScanReticle QR overlay"
+```
+
+### Task 7: Home 画面の再構成
+
+**Files:**
+- Modify: `apps/checkin/src/app/page.tsx`
+
+- [ ] **Step 1: import を更新**
+
+`@tabler/icons-react` の import から `IconBug` を削除し `IconKeyboard` を追加。あわせて新規コンポーネントを import。
+
+変更後の import 群(先頭付近):
+
+```tsx
+import {
+ IconArrowRight,
+ IconCamera,
+ IconCameraRotate,
+ IconClipboardCheck,
+ IconKeyboard,
+ IconQrcode,
+ IconRefresh,
+ IconUserPlus,
+} from '@tabler/icons-react';
+import { Button } from '@tecnova/ui/components/button';
+import { Card, CardContent, CardDescription } from '@tecnova/ui/components/card';
+import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
+import { PageShell } from '@/components/page-shell';
+import { PanelHeader, type PanelTone } from '@/components/panel-header';
+import { Reveal } from '@/components/reveal';
+import { ScanReticle } from '@/components/scan-reticle';
+import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id';
+```
+
+- [ ] **Step 2: `` を PageShell に置換し、レイアウトを Reveal で包む**
+
+`return (` 内の最上位 `` を次へ置換(グラデーション地化)。`` 内の QR カードと右側アクション列をそれぞれ `Reveal` でラップしてスタッガー入場させる。
+
+QR カード(左): `Reveal index={0}` で包む。アクション列(右の ``)の **各 `ActionPanel` を個別に `Reveal` で包む**(index=1,2,3)と段階的に出る。`ActionPanel` 自身を `Reveal` でラップする形にする:
+
+```tsx
+return (
+
+
+
+
+
+ }
+ title="QRコードをかざしてね"
+ tone="sky"
+ />
+
+
+
+ {!navigatingId && !cameraError &&
}
+ {navigatingId && (
+
+
+
プロフィールを開いています
+
+ {navigatingId}
+
+
+ )}
+ {cameraError && (
+
+
+ カメラを起動できませんでした
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
}
+ tone="emerald"
+ href="/first-time"
+ action="初回登録へ"
+ />
+
}
+ tone="sky"
+ href="/history"
+ action="履歴を見る"
+ />
+
}
+ tone="slate"
+ href="/manual"
+ action="入力へ"
+ buttonVariant="outline"
+ />
+
+
+
+
+);
+```
+
+- [ ] **Step 3: `ActionPanel` に `index` を追加し Reveal で包む**
+
+`ActionPanel` の props 型に `index: number;` を追加し、返り値を `Reveal` でラップ:
+
+```tsx
+function ActionPanel({
+ index,
+ title,
+ description,
+ icon,
+ tone,
+ href,
+ action,
+ buttonVariant = 'default',
+}: {
+ index: number;
+ title: string;
+ description: string;
+ icon: ReactNode;
+ tone: PanelTone;
+ href: string;
+ action: string;
+ buttonVariant?: 'default' | 'outline';
+}) {
+ return (
+
+
+
+
+
+ {description}
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: 型チェック + lint**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/page.tsx`
+Expected: PASS。`IconBug` の未使用警告が消えていること。
+
+- [ ] **Step 5: 目視確認**
+
+Run: `pnpm --filter checkin dev` → `http://localhost:3000/`
+確認: グラデーション地、QR カード→アクションカードのスタッガー入場、走査線アニメ、`IconKeyboard` 表示。OS の reduced-motion ON で走査線停止・即表示。
+
+- [ ] **Step 6: commit**
+
+```bash
+git add apps/checkin/src/app/page.tsx
+git commit -m "feat(checkin): elevate home screen with motion and scan reticle"
+```
+
+### 🚦 承認ゲート
+
+> **STOP.** Home をユーザーが実機で確認し、見た目・モーション感・走査線の強さを承認するまで Phase 3 に進まない。
+> フィードバックがあれば該当プリミティブ/Home を調整し、確定した語彙を Phase 3 の基準とする。
+
+---
+
+## Phase 3 — 残り 6 画面への展開
+
+> 各画面共通の「基本変換」: ①`
` を `PageShell`(中央寄せ画面は `className` で `items-center justify-center` を付与)に置換 ②ルート直下のカード/セクションを `Reveal`(index でスタッガー)で包む ③主要送信ボタンを `motion.div`+`whileTap={tapScale}` でラップ(プロフィール画面と同じ手法)。Loading/Error 画面も地をグラデーションへ統一(Error は `bg-rose-50` 維持)。
+> 各タスクは基本変換 + そのシグネチャー実装 + 検証 + commit。
+
+### Task 8: Guideline(ショーケース)
+
+**Files:**
+- Modify: `apps/checkin/src/app/guideline/page.tsx`
+
+- [ ] **Step 1: 基本変換**
+
+`GuidelineSlideView` / `LoadingScreen` / `ActivatingScreen` の `` を `PageShell`(`p-3 sm:p-4` を保つため `className="p-3 sm:p-4"` を渡す)に置換。`ErrorScreen` は `bg-rose-50` のまま。
+
+- [ ] **Step 2: シグネチャー① 方向付きスライド遷移**
+
+`slideIndex` の増減方向を保持する state を追加し、スライド本体(`` のビジュアル+本文)を `AnimatePresence` + `motion.div` で包む。`useReducedMotion` 時は x 移動を 0 にしてクロスフェードのみ。
+
+`GuidelinePageContent` に方向 state を追加:
+
+```tsx
+const [direction, setDirection] = useState(1);
+const goPrev = () => {
+ setDirection(-1);
+ setSlideIndex((index) => Math.max(0, index - 1));
+};
+const goNext = () => {
+ setDirection(1);
+ setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1));
+};
+```
+
+`GuidelineSlideView` の props を `onPrev: goPrev` / `onNext: goNext` に差し替え、`current`(=`slideIndex+1`)と `direction` を渡す。スライド本体を次でラップ(`import { AnimatePresence, motion, useReducedMotion } from 'motion/react';`):
+
+```tsx
+const prefersReduced = useReducedMotion();
+const offset = prefersReduced ? 0 : 40;
+// ...
+
+
+
+ {/* 既存のビジュアルパネル + 本文パネルをそのまま内側へ */}
+
+
+
+```
+
+- [ ] **Step 3: シグネチャー② 進捗バーの幅アニメーション**
+
+進捗バーの内側 `` を `motion.div` 化し幅をアニメート:
+
+```tsx
+
+
+
+```
+
+- [ ] **Step 4: シグネチャー③ アイコンのポップ入場 + キーボード操作**
+
+ビジュアルパネルのアイコン円を `motion.div` にし、`popInitial`/`popAnimate`/`popTransition(0)` を適用(`@/lib/motion` から import、reduced 時は `initial={false}`)。
+`GuidelineSlideView` に矢印キー操作を追加:
+
+```tsx
+useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowRight' && !isLast) onNext();
+ if (e.key === 'ArrowLeft' && current > 1) onPrev();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+}, [current, isLast, onNext, onPrev]);
+```
+
+- [ ] **Step 5: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/guideline/page.tsx`
+目視: `/guideline?preRegistrationId=
` で 前/次 のスライド方向、進捗バーの伸び、アイコンのポップ、←/→ キー、reduced-motion 即時化。
+
+- [ ] **Step 6: commit**
+
+```bash
+git add apps/checkin/src/app/guideline/page.tsx
+git commit -m "feat(checkin): elevate guideline carousel with slide transitions"
+```
+
+### Task 9: Login(ウェルカムヒーロー)
+
+**Files:**
+- Modify: `apps/checkin/src/app/login/page.tsx`
+
+- [ ] **Step 1: 基本変換 + ヒーロー化**
+
+`` を `PageShell`(`className="items-center justify-center"`)へ。カードを `Reveal` で包む。カード上部に TECNOVA ロゴ + やさしい見出しのヒーローを追加(装飾背景なし、フラット)。Google ボタンを `motion.div`+`whileTap={tapScale}` で包む。
+
+```tsx
+import { IconBrandGoogleFilled } from '@tabler/icons-react';
+import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
+import { Button } from '@tecnova/ui/components/button';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card';
+import { motion, useReducedMotion } from 'motion/react';
+import Image from 'next/image';
+import { useState } from 'react';
+import { PageShell } from '@/components/page-shell';
+import { Reveal } from '@/components/reveal';
+import { tapScale } from '@/lib/motion';
+import { authClient } from '@/lib/auth-client';
+// signIn は既存実装のまま
+
+const prefersReduced = useReducedMotion();
+return (
+
+
+
+
+
+
+
ようこそ
+
+ うけつけシステムにサインインしてください
+
+
+
+ {error && (
+
+
+ ログインエラー
+ {error}
+
+
+ )}
+
+
+
+
+
+ 許可リストに登録されたメンターのみ利用できます
+
+
+
+
+
+);
+```
+
+> 注: `useReducedMotion`/`prefersReduced` は `LoginPage` 関数本体の先頭で取得する。
+
+- [ ] **Step 2: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/login/page.tsx`
+目視: `/login` でロゴ + 見出し + カード入場、ボタン押下感。reduced-motion 即時化。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/app/login/page.tsx
+git commit -m "feat(checkin): add welcoming hero to login screen"
+```
+
+### Task 10: Manual(モード切替の作り込み)
+
+**Files:**
+- Create: `apps/checkin/src/components/segmented-control.tsx`
+- Modify: `apps/checkin/src/app/manual/page.tsx`
+
+- [ ] **Step 1: SegmentedControl を作成**
+
+```tsx
+'use client';
+
+import { cn } from '@tecnova/ui/lib/utils';
+import { motion, useReducedMotion } from 'motion/react';
+import type { ReactNode } from 'react';
+
+type SegmentedOption = { value: T; label: string; icon?: ReactNode };
+
+// 2 値以上のモード切替。選択中インジケータが layoutId でスライドする。reduced 時は即切替。
+export function SegmentedControl({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+}: {
+ options: SegmentedOption[];
+ value: T;
+ onChange: (value: T) => void;
+ ariaLabel: string;
+}) {
+ const prefersReduced = useReducedMotion();
+ return (
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+
+ );
+ })}
+
+ );
+}
+```
+
+- [ ] **Step 2: Manual の基本変換 + ModeToggle 置換**
+
+`` を `PageShell` に。`ModeToggle`/`ToggleButton` を削除し `SegmentedControl` を使用。`IdEntryPanel`/`NameSearchPanel` の切替を `AnimatePresence`+`motion.div`(`key={mode}`、フェード+わずかな y)で包む。`IdEntryPanel` の `IconBug` を `IconKeyboard` に変更。
+
+`ManualPage`:
+
+```tsx
+import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
+import { IconKeyboard, IconSearch /* ほか既存 */ } from '@tabler/icons-react';
+import { PageShell } from '@/components/page-shell';
+import { SegmentedControl } from '@/components/segmented-control';
+
+export default function ManualPage() {
+ const [mode, setMode] = useState('id');
+ const prefersReduced = useReducedMotion();
+ return (
+
+
+
},
+ { value: 'name', label: '名前で探す', icon:
},
+ ]}
+ />
+
+
+ {mode === 'id' ? : }
+
+
+
+
+ );
+}
+```
+
+`IdEntryPanel` 内: `PanelHeader icon={}` を `icon={}` に変更。
+
+- [ ] **Step 3: シグネチャー 検索結果のスタッガー**
+
+`SearchResults` の結果 `` 内 `- ` を `motion.li`(`listItemTransition(index)`、reduced 時 `initial={false}`)に変更。`import { listItemTransition } from '@/lib/motion';`。
+
+```tsx
+{state.results.map((participant, index) => (
+
+ {/* 既存 ResultRow */}
+
+))}
+```
+
+> `SearchResults` 内で `const prefersReduced = useReducedMotion();` を先頭に追加。
+
+- [ ] **Step 4: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/manual/page.tsx apps/checkin/src/components/segmented-control.tsx`
+目視: `/manual` でトグルのスライド、パネルのクロスフェード、検索結果のスタッガー、`IconKeyboard` 表示。reduced-motion 即時化。
+
+- [ ] **Step 5: commit**
+
+```bash
+git add apps/checkin/src/app/manual/page.tsx apps/checkin/src/components/segmented-control.tsx
+git commit -m "feat(checkin): add sliding segmented control and result stagger to manual"
+```
+
+### Task 11: History(生きているダッシュボード)
+
+**Files:**
+- Modify: `apps/checkin/src/app/history/page.tsx`
+
+- [ ] **Step 1: 基本変換**
+
+``(本体・LoadingScreen)を `PageShell` に。`ErrorScreen` は `bg-rose-50` 維持。本体の 2 枚のカードを `Reveal`(index 0,1)で包む。
+
+- [ ] **Step 2: シグネチャー サマリ + LiveDot + StatTile**
+
+サマリ 3 枚を `StatTile` に置換し数値を `AnimatedNumber` 化、「滞在中」は emerald トーン + `LiveDot active`。
+
+```tsx
+import { AnimatedNumber } from '@/components/animated-number';
+import { LiveDot } from '@/components/live-dot';
+import { StatTile } from '@/components/stat-tile';
+
+
+
人>}
+ />
+ 0} />滞在中}
+ value={<>人>}
+ />
+ 人>}
+ />
+
+```
+
+- [ ] **Step 3: シグネチャー 行のスタッガー入場**
+
+テーブルの `` 内 `filteredSessions.map` の各 `` を `motion.tr` 化(`@tecnova/ui` の `TableRow` は `` レンダリングのため `asChild` 不可。`motion.tr` を直接使い、`TableCell` は維持)。reduced 時 `initial={false}`、`listItemTransition(index)`。
+
+```tsx
+{filteredSessions.map((session, index) => {
+ const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs);
+ return (
+
+ {/* 既存の TableCell 群をそのまま */}
+
+ );
+})}
+```
+
+> `HistoryPage` 先頭で `const prefersReduced = useReducedMotion();` を取得。`motion`/`useReducedMotion`/`listItemTransition` を import。`motion.tr` の className はテーブル行のスタイルを `@tecnova/ui` の `TableRow` 実装に合わせる(`node_modules/@tecnova/ui` または生成元の table.tsx を確認して border/hover クラスを移植)。
+
+- [ ] **Step 4: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/history/page.tsx`
+目視: `/history` でサマリのカウントアップ、滞在中の脈動ドット、行のスタッガー、空状態。reduced-motion 即時化。
+
+- [ ] **Step 5: commit**
+
+```bash
+git add apps/checkin/src/app/history/page.tsx
+git commit -m "feat(checkin): animate history summary and rows"
+```
+
+### Task 12: First-time(ステップ + 候補のスタッガー)
+
+**Files:**
+- Modify: `apps/checkin/src/app/first-time/page.tsx`
+
+- [ ] **Step 1: 基本変換**
+
+本体・LoadingScreen の `` を `PageShell` に。`ErrorScreen` は `bg-rose-50` 維持。メインカードを `Reveal` で包む。
+
+- [ ] **Step 2: シグネチャー 件数バッジ + ステップ + 候補グリッド**
+
+未登録件数を `AnimatedNumber` 化:
+
+```tsx
+
+ 未登録 人
+
+```
+
+`RegistrationSteps` の各 `- ` と、候補 `
` の各 `- ` を `motion.li`(`listItemTransition(index)`、reduced 時 `initial={false}`)化。`FirstTimePage`/`RegistrationSteps` 先頭で `useReducedMotion()` 取得、`@/lib/motion` と `@/components/animated-number` を import。
+
+- [ ] **Step 3: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/first-time/page.tsx`
+目視: `/first-time` で件数カウントアップ、ステップ/候補のスタッガー、ダイアログ動作維持。reduced-motion 即時化。
+
+- [ ] **Step 4: commit**
+
+```bash
+git add apps/checkin/src/app/first-time/page.tsx
+git commit -m "feat(checkin): stagger first-time steps and candidate grid"
+```
+
+### Task 13: Settings(メンター ID カード)
+
+**Files:**
+- Modify: `apps/checkin/src/app/settings/page.tsx`
+
+- [ ] **Step 1: 基本変換 + ヘッダのアイデンティティカード化**
+
+`` を `PageShell` に。カードを `Reveal` で包む。`CardHeader` の素の歯車を、メンターのアバター(頭文字 or `IconUser`)+ 名前 + ロールバッジのヘッダに差し替え:
+
+```tsx
+
+
+
+ {me.mentor.name.slice(0, 1)}
+
+
+
{me.mentor.name}
+
+ {me.mentor.role}
+ {me.user.email}
+
+
+
+
+```
+
+> 既存の情報テーブル(`rows`)とログアウトダイアログはそのまま残す。`IconSettings` import が未使用になれば削除。
+
+- [ ] **Step 2: 検証**
+
+Run: `pnpm type-check && pnpm biome check --write apps/checkin/src/app/settings/page.tsx`
+目視: `/settings` でアイデンティティカード、入場モーション、ログアウト動作維持。
+
+- [ ] **Step 3: commit**
+
+```bash
+git add apps/checkin/src/app/settings/page.tsx
+git commit -m "feat(checkin): add mentor identity header to settings"
+```
+
+---
+
+## Phase 4 — 整合性レビューと最終検証
+
+### Task 14: デザイン整合性の敵対的レビュー
+
+- [ ] **Step 1: 並列レビューをディスパッチ**
+
+ultracode 方針: Workflow で画面横断のレビュアーを並列起動し、各画面が「Home で確定した語彙」に一致しているかを照合(地のグラデーション、`border-sky-200 shadow-sm`、Reveal スタッガーの index 連番、reduced-motion ゲートの抜け、トーン整合、タッチサイズ)。各指摘を別エージェントで真偽判定し、確定分のみ修正。
+
+- [ ] **Step 2: 指摘を反映してコミット**
+
+```bash
+git add -A && git commit -m "fix(checkin): resolve design-consistency review findings"
+```
+
+### Task 15: 最終検証 + PR
+
+- [ ] **Step 1: 全体検証**
+
+```bash
+pnpm type-check
+pnpm biome check apps/checkin
+pnpm --filter checkin build
+```
+Expected: すべて PASS。
+
+- [ ] **Step 2: 回帰確認**
+
+`pnpm --filter checkin dev` で プロフィール画面 `/reception/participants/` と全画面共通ヘッダに視覚回帰が無いことを確認(地・カード・モーションが従来どおり)。
+
+- [ ] **Step 3: PR 作成(ユーザー承認後)**
+
+```bash
+git push -u origin feat/checkin-redesign
+gh pr create --base develop --title "feat(checkin): redesign all screens except profile" --body "<本プラン要約 + スクショ>"
+```
+
+---
+
+## Self-Review(記入済み)
+
+- **Spec coverage:** §4 プリミティブ → Task1-5/6/10。§5 モーション契約 → Task1。§6 各画面 → Home Task7 / Guideline Task8 / Login Task9 / Manual Task10 / History Task11 / First-time Task12 / Settings Task13。§7 回帰防止 → Task15 Step2 + 全タスクでプロフィール/シェル不変更。§10 検証 → 各タスク検証 + Task15。網羅。
+- **Placeholder scan:** TBD/TODO 無し。各コード手順に実コードを記載。Task11 の `motion.tr` のみ `@tecnova/ui` table.tsx の border/hover クラス移植を要確認と明記(プレースホルダではなく確認指示)。
+- **Type consistency:** `Reveal({index,className,children})`、`StatTile({label,value,icon,tone,className})`、`LiveDot({active,className})`、`PageShell({children,className})`、`SegmentedControl({options,value,onChange,ariaLabel})`、`motion.ts` の `revealTransition/popTransition/listItemTransition/tapScale` — 利用箇所と一致。
diff --git a/docs/superpowers/plans/2026-05-30-signage-chime-app.md b/docs/superpowers/plans/2026-05-30-signage-chime-app.md
new file mode 100644
index 0000000..7e0861a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-30-signage-chime-app.md
@@ -0,0 +1,1567 @@
+# サイネージ+チャイム アプリ 実装計画
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 大型モニター常時表示用の認証付きサイネージアプリ `apps/signage` を新設し、50分活動/10分休憩サイクルに連動した動画表示・カウントダウン・チャイムを自動制御する。
+
+**Architecture:** 端末ローカル時計から純粋ロジック(`@tecnova/shared/activity-cycle`)で活動/休憩フェーズとチャイム時刻を算出し、稼働判定は認証付き既存 `GET /api/sessions/today` のターム別チェックイン数(初回チェックインで稼働開始・ターム終了まで sticky)で行う。画面は L2(動画フルスクリーン+情報バー)で、フェーズ境界にクロスフェードし Web Audio 合成チャイムを鳴らす。認証は checkin/admin と同じメンター・ホワイトリスト(共有管理アカウントで1回ログイン)。
+
+**Tech Stack:** Next.js 16.2.4 / React 19.2.4 / TypeScript / Tailwind v4(`@tecnova/ui`)/ Better Auth(`better-auth/react`)/ motion / Web Audio API / Screen Wake Lock API / vitest(`packages/shared` のみ新規導入)。
+
+設計の根拠は `docs/superpowers/specs/2026-05-29-signage-chime-design.md` を参照。
+
+---
+
+## File Structure
+
+**packages/shared(共有純粋ロジック)**
+- `src/activity-cycle.ts`(新規)— 50/10サイクル分類・チャイムイベント列・カウントダウン
+- `src/activity-cycle.test.ts`(新規)— 上記の単体テスト
+- `package.json`(変更)— `"./activity-cycle"` export + vitest devDep + `test` スクリプト
+
+**apps/api(設定のみ・コード変更なし)**
+- `.dev.vars` / `.env.example`(変更)— `TRUSTED_ORIGINS` にサイネージ origin を追加
+
+**apps/signage(新規アプリ)**
+- 設定: `package.json` / `next.config.ts` / `tsconfig.json` / `postcss.config.mjs` / `components.json` / `.gitignore`
+- 認証: `src/lib/auth-client.ts` / `src/components/app-shell.tsx` / `src/app/login/page.tsx`
+- ルート: `src/app/layout.tsx` / `src/app/manifest.ts` / `src/app/page.tsx`(状態機械)
+- ロジック: `src/lib/now.ts` / `src/lib/use-now.ts` / `src/lib/time.ts` / `src/lib/chimes.ts` / `src/lib/use-chime-scheduler.ts` / `src/lib/use-wake-lock.ts` / `src/lib/use-signage-data.ts`
+- 設定値: `src/config/playlist.ts`
+- 表示: `src/components/{stage,info-bar,break-screen,idle-screen,tap-to-start}.tsx`
+- ドキュメント: `CLAUDE.md` / `AGENTS.md`
+
+**docs**
+- `docs/architecture.md`(変更)— クライアント一覧に `apps/signage` を追記
+
+各ファイルは単一責務。`now.ts`(時刻ソース)・`chimes.ts`(音)・`use-chime-scheduler.ts`(発火)・`use-signage-data.ts`(データ)・各表示コンポーネントは独立して理解・差し替え可能。
+
+---
+
+## Task 1: 共有ロジック `activity-cycle.ts`(TDD・vitest 最小導入)
+
+**Files:**
+- Modify: `packages/shared/package.json`
+- Create: `packages/shared/src/activity-cycle.test.ts`
+- Create: `packages/shared/src/activity-cycle.ts`
+
+- [ ] **Step 1: vitest を devDep と test スクリプトに追加**
+
+`packages/shared/package.json` の `scripts` と `devDependencies` を以下に変更(他キーは既存のまま):
+
+```json
+ "scripts": {
+ "type-check": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20260502.1",
+ "typescript": "^6.0.3",
+ "vitest": "^3.2.4"
+ },
+```
+
+そして `exports` に `"./activity-cycle"` を追加(`"./venue-schedule"` の隣):
+
+```json
+ "exports": {
+ ".": "./src/index.ts",
+ "./google-sheets": "./src/google-sheets.ts",
+ "./schemas": "./src/schemas/index.ts",
+ "./venue-schedule": "./src/venue-schedule.ts",
+ "./activity-cycle": "./src/activity-cycle.ts"
+ },
+```
+
+- [ ] **Step 2: 依存をインストール**
+
+Run: `pnpm install`
+Expected: vitest が `packages/shared` に追加され lockfile 更新。
+
+- [ ] **Step 3: 失敗するテストを書く**
+
+Create `packages/shared/src/activity-cycle.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import {
+ classifyCycleMoment,
+ cycleChimeEventsForDay,
+ msUntilNextBoundary,
+} from './activity-cycle';
+
+// JST 指定の instant を作るヘルパ(Asia/Tokyo は固定 UTC+9)。
+const jst = (iso: string): Date => new Date(`${iso}+09:00`);
+
+describe('classifyCycleMoment', () => {
+ it('活動中(朝・サイクル1)', () => {
+ const m = classifyCycleMoment(jst('2026-05-30T09:30:00'));
+ expect(m.phase).toBe('activity');
+ expect(m.term).toBe('morning');
+ expect(m.cycleIndex).toBe(1);
+ expect(m.phaseEndsAt?.toISOString()).toBe(jst('2026-05-30T09:50:00').toISOString());
+ });
+
+ it(':50 ちょうどは休憩', () => {
+ const m = classifyCycleMoment(jst('2026-05-30T09:50:00'));
+ expect(m.phase).toBe('break');
+ expect(m.phaseEndsAt?.toISOString()).toBe(jst('2026-05-30T10:00:00').toISOString());
+ });
+
+ it('サイクル3の活動(朝 11:30)', () => {
+ const m = classifyCycleMoment(jst('2026-05-30T11:30:00'));
+ expect(m.phase).toBe('activity');
+ expect(m.cycleIndex).toBe(3);
+ expect(m.phaseEndsAt?.toISOString()).toBe(jst('2026-05-30T11:50:00').toISOString());
+ });
+
+ it('昼休みは idle', () => {
+ const m = classifyCycleMoment(jst('2026-05-30T12:30:00'));
+ expect(m.phase).toBe('idle');
+ expect(m.term).toBeNull();
+ expect(m.phaseEndsAt).toBeNull();
+ });
+
+ it('開始前は idle', () => {
+ expect(classifyCycleMoment(jst('2026-05-30T08:00:00')).phase).toBe('idle');
+ });
+
+ it('夕方ターム内', () => {
+ expect(classifyCycleMoment(jst('2026-05-30T16:30:00')).term).toBe('evening');
+ });
+});
+
+describe('cycleChimeEventsForDay', () => {
+ it('1日分は 3ターム×7 = 21 イベント', () => {
+ expect(cycleChimeEventsForDay(jst('2026-05-30T09:00:00'))).toHaveLength(21);
+ });
+
+ it('時系列順に並ぶ', () => {
+ const ev = cycleChimeEventsForDay(jst('2026-05-30T09:00:00'));
+ const ts = ev.map((e) => e.at.getTime());
+ expect([...ts].sort((a, b) => a - b)).toEqual(ts);
+ });
+
+ it('朝タームの最初の3イベントは resume@9:00 / break@9:50 / resume@10:00', () => {
+ const ev = cycleChimeEventsForDay(jst('2026-05-30T09:00:00')).filter((e) => e.term === 'morning');
+ expect(ev[0]).toMatchObject({ kind: 'resume', at: jst('2026-05-30T09:00:00') });
+ expect(ev[1]).toMatchObject({ kind: 'break', at: jst('2026-05-30T09:50:00') });
+ expect(ev[2]).toMatchObject({ kind: 'resume', at: jst('2026-05-30T10:00:00') });
+ expect(ev.at(-1)).toMatchObject({ kind: 'term-end', at: jst('2026-05-30T12:00:00') });
+ });
+
+ it('key は安定(日付#term#kind#HH:mm)', () => {
+ const ev = cycleChimeEventsForDay(jst('2026-05-30T09:00:00')).find((e) => e.term === 'morning' && e.kind === 'break');
+ expect(ev?.key).toBe('2026-05-30#morning#break#09:50');
+ });
+});
+
+describe('msUntilNextBoundary', () => {
+ it('09:30 の次境界は 09:50(20分後)', () => {
+ expect(msUntilNextBoundary(jst('2026-05-30T09:30:00'))).toBe(20 * 60 * 1000);
+ });
+
+ it('営業終了後は null', () => {
+ expect(msUntilNextBoundary(jst('2026-05-30T20:00:00'))).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 4: テストが失敗することを確認**
+
+Run: `pnpm --filter @tecnova/shared test`
+Expected: FAIL(`activity-cycle.ts` 不在で import 解決エラー)。
+
+- [ ] **Step 5: 実装を書く**
+
+Create `packages/shared/src/activity-cycle.ts`:
+
+```ts
+// 活動50分・休憩10分のリズムを壁時計の「時」に合わせて刻む純粋ロジック。
+// 各タームを 50+10=60分 × 3サイクルに割る(3時間タームをちょうど割り切る)。
+// venue-schedule と同じく Workers 安全(Intl のみ・Node API なし)、JST 固定 UTC+9。
+import { TERMS, type TermId, toJstDateString, toJstWallClock } from './venue-schedule';
+
+export const ACTIVITY_MINUTES = 50;
+export const BREAK_MINUTES = 10;
+const CYCLE_MINUTES = ACTIVITY_MINUTES + BREAK_MINUTES; // 60
+
+// Asia/Tokyo は DST が無く固定 UTC+9(venue-schedule と同前提)。
+const JST_OFFSET_HOURS = 9;
+
+// 'HH:mm' を 0:00 からの通算分に。venue-schedule の同名ヘルパは非公開のため再宣言。
+const toMinutesOfDay = (hhmm: string): number =>
+ Number.parseInt(hhmm.slice(0, 2), 10) * 60 + Number.parseInt(hhmm.slice(3, 5), 10);
+
+const jstMinuteOfDay = (instant: Date): number => {
+ const { hour, minute } = toJstWallClock(instant);
+ return hour * 60 + minute;
+};
+
+// ref の JST 暦日における JST 通算分 m を UTC instant に変換(termEndInstant と同手法)。
+const jstInstantOnDayOf = (ref: Date, minuteOfDay: number): Date => {
+ const { year, month, day } = toJstWallClock(ref);
+ const hh = Math.floor(minuteOfDay / 60);
+ const mm = minuteOfDay % 60;
+ return new Date(Date.UTC(year, month - 1, day, hh - JST_OFFSET_HOURS, mm, 0, 0));
+};
+
+export type CyclePhase = 'activity' | 'break' | 'idle';
+
+export interface CycleMoment {
+ phase: CyclePhase;
+ term: TermId | null;
+ cycleIndex: number | null; // 1..3、idle のとき null
+ phaseEndsAt: Date | null; // 現フェーズ終端(次の境界)、idle のとき null
+}
+
+export type ChimeKind = 'resume' | 'break' | 'term-end';
+
+export interface ChimeEvent {
+ kind: ChimeKind;
+ term: TermId;
+ at: Date;
+ key: string; // dedup 用安定キー `${date}#${term}#${kind}#${HH:mm}`
+}
+
+// 瞬間を活動/休憩/idle に分類し、現フェーズの終端 instant も返す。
+export const classifyCycleMoment = (instant: Date): CycleMoment => {
+ const current = jstMinuteOfDay(instant);
+ for (const term of TERMS) {
+ const start = toMinutesOfDay(term.start);
+ const end = toMinutesOfDay(term.end);
+ if (current >= start && current < end) {
+ const offset = current - start; // 0..179
+ const cycleIndex = Math.floor(offset / CYCLE_MINUTES) + 1;
+ const withinCycle = offset % CYCLE_MINUTES;
+ const phase: CyclePhase = withinCycle < ACTIVITY_MINUTES ? 'activity' : 'break';
+ const cycleStart = start + (cycleIndex - 1) * CYCLE_MINUTES;
+ const boundaryMinute =
+ phase === 'activity' ? cycleStart + ACTIVITY_MINUTES : cycleStart + CYCLE_MINUTES;
+ return {
+ phase,
+ term: term.id,
+ cycleIndex,
+ phaseEndsAt: jstInstantOnDayOf(instant, boundaryMinute),
+ };
+ }
+ }
+ return { phase: 'idle', term: null, cycleIndex: null, phaseEndsAt: null };
+};
+
+// instant の JST 暦日における全タームのチャイムイベントを時系列順で返す。
+// クライアントは tick ごとに「前回 < at <= 今」で境界跨ぎを検出し key で dedup する。
+export const cycleChimeEventsForDay = (instant: Date): ChimeEvent[] => {
+ const date = toJstDateString(instant);
+ const events: ChimeEvent[] = [];
+ const push = (kind: ChimeKind, term: TermId, minuteOfDay: number): void => {
+ const hh = String(Math.floor(minuteOfDay / 60)).padStart(2, '0');
+ const mm = String(minuteOfDay % 60).padStart(2, '0');
+ events.push({
+ kind,
+ term,
+ at: jstInstantOnDayOf(instant, minuteOfDay),
+ key: `${date}#${term}#${kind}#${hh}:${mm}`,
+ });
+ };
+ for (const term of TERMS) {
+ const start = toMinutesOfDay(term.start);
+ const end = toMinutesOfDay(term.end);
+ const cycles = Math.round((end - start) / CYCLE_MINUTES);
+ for (let n = 0; n < cycles; n += 1) {
+ push('resume', term.id, start + n * CYCLE_MINUTES);
+ push('break', term.id, start + n * CYCLE_MINUTES + ACTIVITY_MINUTES);
+ }
+ push('term-end', term.id, end);
+ }
+ return events;
+};
+
+// 次の境界までのミリ秒。その日もう境界が無ければ null。
+export const msUntilNextBoundary = (instant: Date): number | null => {
+ const now = instant.getTime();
+ const future = cycleChimeEventsForDay(instant)
+ .map((e) => e.at.getTime())
+ .filter((t) => t > now)
+ .sort((a, b) => a - b);
+ const next = future[0];
+ return next === undefined ? null : next - now;
+};
+
+// 秒丸め(ceil で 0 秒の一瞬を避ける)。
+export const secondsUntilNextBoundary = (instant: Date): number | null => {
+ const ms = msUntilNextBoundary(instant);
+ return ms === null ? null : Math.ceil(ms / 1000);
+};
+```
+
+- [ ] **Step 6: テストが通ることを確認**
+
+Run: `pnpm --filter @tecnova/shared test`
+Expected: PASS(全ケース green)。
+
+- [ ] **Step 7: 型チェック**
+
+Run: `pnpm --filter @tecnova/shared type-check`
+Expected: エラーなし。
+
+- [ ] **Step 8: コミット**
+
+```bash
+git add packages/shared/package.json packages/shared/src/activity-cycle.ts packages/shared/src/activity-cycle.test.ts pnpm-lock.yaml
+git commit -m "feat(shared): add activity-cycle (50/10 schedule + chime events)"
+```
+
+---
+
+## Task 2: API の `TRUSTED_ORIGINS` にサイネージ origin を追加(設定のみ)
+
+**Files:**
+- Modify: `apps/api/.dev.vars`(ローカル開発用・git管理外)
+- Modify: `.env.example`(コメント例)
+
+- [ ] **Step 1: `.dev.vars` に dev origin を追加**
+
+`apps/api/.dev.vars` の `TRUSTED_ORIGINS` 行に `http://localhost:3002` を追記(カンマ区切り):
+
+```
+TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002
+```
+
+- [ ] **Step 2: `.env.example` のコメント例を更新**
+
+`.env.example` の `TRUSTED_ORIGINS` 付近のコメントに signage(3002) を含める(値は空のまま、例コメントのみ)。
+
+- [ ] **Step 3: API を再起動して疎通確認(手動)**
+
+Run: `pnpm --filter @tecnova/api dev`(別ターミナル)
+確認: 既存メンター(または共有)アカウントでログイン済みのブラウザから `http://localhost:8787/api/sessions/today` が 200 を返し、`sessions[].term` と `summary.currentlyPresent` を含む。未ログインだと 401。
+Expected: 認証付きで取得可能。`TRUSTED_ORIGINS` はコード変更なしで CORS と Better Auth 双方に反映。
+
+- [ ] **Step 4: コミット**
+
+```bash
+git add .env.example
+git commit -m "chore(api): document signage origin (3002) in TRUSTED_ORIGINS"
+```
+(`.dev.vars` は git 管理外のためコミットしない。)
+
+---
+
+## Task 3: `apps/signage` スキャフォルド(:3002 で起動するところまで)
+
+**Files(すべて新規・`apps/signage/` 配下):**
+- `package.json` / `next.config.ts` / `tsconfig.json` / `postcss.config.mjs` / `components.json` / `.gitignore`
+- `src/app/layout.tsx`(暫定)/ `src/app/page.tsx`(暫定)/ `src/app/manifest.ts`
+
+- [ ] **Step 1: `package.json`**
+
+Create `apps/signage/package.json`:
+
+```json
+{
+ "name": "signage",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --port 3002",
+ "build": "next build",
+ "start": "next start",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tabler/icons-react": "^3.42.0",
+ "@tecnova/shared": "workspace:*",
+ "@tecnova/ui": "workspace:*",
+ "better-auth": "^1.6.9",
+ "motion": "^12.40.0",
+ "next": "16.2.4",
+ "react": "19.2.4",
+ "react-dom": "19.2.4"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
+```
+
+- [ ] **Step 2: ビルド設定ファイル**
+
+Create `apps/signage/next.config.ts`:
+
+```ts
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ // モノレポ内 workspace パッケージを Next の transpile 対象にする
+ transpilePackages: ['@tecnova/shared', '@tecnova/ui'],
+};
+
+export default nextConfig;
+```
+
+Create `apps/signage/postcss.config.mjs`:
+
+```ts
+export { default } from '@tecnova/ui/postcss.config';
+```
+
+Create `apps/signage/tsconfig.json`(checkin と同一):
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./src/*"],
+ "@tecnova/ui/*": ["../../packages/ui/src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
+ "exclude": ["node_modules"]
+}
+```
+
+Create `apps/signage/components.json`(checkin と同一):
+
+```json
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "radix-maia",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "../../packages/ui/src/styles/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true
+ },
+ "iconLibrary": "tabler",
+ "aliases": {
+ "components": "@/components",
+ "hooks": "@/hooks",
+ "lib": "@/lib",
+ "utils": "@tecnova/ui/lib/utils",
+ "ui": "@tecnova/ui/components"
+ },
+ "rtl": false,
+ "menuColor": "default-translucent",
+ "menuAccent": "subtle"
+}
+```
+
+Create `apps/signage/.gitignore`(checkin の `.gitignore` を verbatim でコピー: `/node_modules`・`/.next/`・`.env*`・`.vercel`・`next-env.d.ts` 等を含むもの)。
+
+- [ ] **Step 3: 暫定 layout / manifest / page**
+
+Create `apps/signage/src/app/manifest.ts`:
+
+```ts
+import type { MetadataRoute } from 'next';
+
+// 壁掛けモニター向け。display:'fullscreen' + orientation:'landscape' が最も強いキオスク表示。
+// アイコンは省略(--kiosk 起動では PWA インストール不要)。
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: 'テクノバながさき サイネージ',
+ short_name: 'サイネージ',
+ description: 'テクノバながさきの会場サイネージ表示',
+ start_url: '/',
+ display: 'fullscreen',
+ orientation: 'landscape',
+ background_color: '#020617',
+ theme_color: '#2563eb',
+ lang: 'ja',
+ };
+}
+```
+
+Create `apps/signage/src/app/layout.tsx`(暫定:この時点では AppShell 未作成なので children 直描画。Task 4 で AppShell に差し替える):
+
+```tsx
+import type { Metadata, Viewport } from 'next';
+import { LINE_Seed_JP } from 'next/font/google';
+import '@tecnova/ui/globals.css';
+import { cn } from '@tecnova/ui/lib/utils';
+
+const fontSans = LINE_Seed_JP({
+ variable: '--font-sans',
+ weight: ['100', '400', '700', '800'],
+ subsets: ['latin'],
+});
+
+export const metadata: Metadata = {
+ title: 'テクノバ サイネージ',
+ appleWebApp: { capable: true, title: 'サイネージ', statusBarStyle: 'black-translucent' },
+};
+
+export const viewport: Viewport = {
+ themeColor: '#020617',
+ width: 'device-width',
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+};
+
+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+Create `apps/signage/src/app/page.tsx`(暫定プレースホルダ。Task 10 で状態機械に差し替える):
+
+```tsx
+export default function SignagePage() {
+ return signage;
+}
+```
+
+- [ ] **Step 4: インストールして起動確認**
+
+Run: `pnpm install`
+Run: `pnpm --filter signage dev`
+確認: `http://localhost:3002` が「signage」を表示。
+Expected: :3002 で起動。
+
+- [ ] **Step 5: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 6: コミット**
+
+```bash
+git add apps/signage pnpm-lock.yaml
+git commit -m "feat(signage): scaffold Next.js app on port 3002"
+```
+
+---
+
+## Task 4: 認証ゲート(auth-client / AppShell / login)
+
+**Files:**
+- Create: `apps/signage/src/lib/auth-client.ts`
+- Create: `apps/signage/src/components/app-shell.tsx`
+- Create: `apps/signage/src/app/login/page.tsx`
+- Modify: `apps/signage/src/app/layout.tsx`
+
+- [ ] **Step 1: Better Auth クライアント**
+
+Create `apps/signage/src/lib/auth-client.ts`:
+
+```ts
+import { createAuthClient } from 'better-auth/react';
+
+// Worker(API)の URL。本番では NEXT_PUBLIC_API_URL を Vercel 側で設定する。
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787';
+
+// クッキーは Worker 側オリジンに発行される。サイネージは別オリジンから fetch するため
+// credentials:'include' でクッキーを同送させる(API の TRUSTED_ORIGINS に 3002 を登録済み)。
+export const authClient = createAuthClient({
+ baseURL: API_URL,
+ fetchOptions: { credentials: 'include' },
+});
+```
+
+- [ ] **Step 2: AppShell(最小・ヘッダなし)**
+
+Create `apps/signage/src/components/app-shell.tsx`(checkin と違い全画面表示なのでヘッダ chrome は持たず、`MeProvider` だけで包む):
+
+```tsx
+'use client';
+
+import { MeProvider } from '@tecnova/ui/components/me-provider';
+import { usePathname } from 'next/navigation';
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ // /login は認証ゲートの外(401 時の遷移先)。
+ if (pathname.startsWith('/login')) {
+ return <>{children}>;
+ }
+ return (
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 3: ログインページ(自己完結・`@tecnova/ui` のみ依存)**
+
+Create `apps/signage/src/app/login/page.tsx`:
+
+```tsx
+'use client';
+
+import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
+import { Button } from '@tecnova/ui/components/button';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card';
+import { useState } from 'react';
+import { authClient } from '@/lib/auth-client';
+
+export default function LoginPage() {
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const signIn = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ // callbackURL は絶対URL(フロントのオリジンに戻す)。相対だと API オリジンに着地する。
+ const redirect = `${window.location.origin}/`;
+ await authClient.signIn.social({
+ provider: 'google',
+ callbackURL: redirect,
+ errorCallbackURL: redirect,
+ });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ テクノバ サイネージ
+
+ {error && (
+
+
+ ログインエラー
+ {error}
+
+
+ )}
+
+
+
+ 許可リストに登録されたアカウントのみ利用できます
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: layout を AppShell でラップ**
+
+Modify `apps/signage/src/app/layout.tsx` — import を追加し body を差し替え:
+
+```tsx
+import { AppShell } from '@/components/app-shell';
+```
+
+```tsx
+
+ {children}
+
+```
+
+- [ ] **Step 5: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 6: 認証ゲートを手動確認**
+
+Run: `pnpm --filter signage dev`(api も起動しておく)
+確認: 未ログインで `http://localhost:3002` → `/login` へ遷移。共有(or 任意の許可リスト)アカウントでログイン → `/` に戻り「signage」プレースホルダが表示。
+Expected: 401→/login、ログイン後に本体表示。
+
+- [ ] **Step 7: コミット**
+
+```bash
+git add apps/signage/src
+git commit -m "feat(signage): add mentor-whitelist auth (auth-client, MeProvider shell, login)"
+```
+
+---
+
+## Task 5: 時刻ソースとユーティリティ(`now` / `use-now` / `time`)
+
+**Files:**
+- Create: `apps/signage/src/lib/now.ts`
+- Create: `apps/signage/src/lib/use-now.ts`
+- Create: `apps/signage/src/lib/time.ts`
+
+- [ ] **Step 1: `now.ts`(`?now=` 上書き対応の時刻ソース)**
+
+Create `apps/signage/src/lib/now.ts`:
+
+```ts
+// 端末のローカル時計を返す。?now=ISO クエリがある場合のみ、その時刻を起点に
+// 実時間の経過分だけ進めた擬似時刻を返す(タイムベース挙動の手動検証用)。
+let anchor: { base: number; mountedAt: number } | null | undefined;
+
+const readAnchor = (): { base: number; mountedAt: number } | null => {
+ if (typeof window === 'undefined') return null;
+ const raw = new URLSearchParams(window.location.search).get('now');
+ if (!raw) return null;
+ const base = new Date(raw).getTime();
+ if (Number.isNaN(base)) return null;
+ return { base, mountedAt: Date.now() };
+};
+
+export const getNow = (): Date => {
+ if (anchor === undefined) anchor = readAnchor();
+ if (anchor === null) return new Date();
+ return new Date(anchor.base + (Date.now() - anchor.mountedAt));
+};
+```
+
+- [ ] **Step 2: `use-now.ts`(毎秒 re-render 用フック)**
+
+Create `apps/signage/src/lib/use-now.ts`:
+
+```ts
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getNow } from './now';
+
+// 表示更新用に一定間隔で現在時刻を返す(チャイム発火は use-chime-scheduler が別途精密に行う)。
+export const useNow = (intervalMs = 1000): Date => {
+ const [now, setNow] = useState(() => getNow());
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(getNow()), intervalMs);
+ return () => window.clearInterval(id);
+ }, [intervalMs]);
+ return now;
+};
+```
+
+- [ ] **Step 3: `time.ts`(JST 時刻・カウントダウン整形)**
+
+Create `apps/signage/src/lib/time.ts`:
+
+```ts
+const jstHmFormatter = new Intl.DateTimeFormat('en-GB', {
+ timeZone: 'Asia/Tokyo',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+});
+
+// JST の 'HH:mm'。
+export const jstHm = (date: Date): string => jstHmFormatter.format(date);
+
+// 秒数を 'M:SS' に(休憩カウントダウン用)。負値は 0 扱い。
+export const mmss = (totalSeconds: number): string => {
+ const s = Math.max(0, Math.floor(totalSeconds));
+ const m = Math.floor(s / 60);
+ const sec = s % 60;
+ return `${m}:${String(sec).padStart(2, '0')}`;
+};
+```
+
+- [ ] **Step 4: `use-wake-lock.ts`(スリープ防止・visibility で再取得)**
+
+Create `apps/signage/src/lib/use-wake-lock.ts`:
+
+```ts
+'use client';
+
+import { useEffect } from 'react';
+
+// 画面スリープ防止。document が hidden になると OS が自動解放するため
+// visibilitychange で再取得する。HTTPS(secure context)必須。
+export const useWakeLock = (enabled: boolean): void => {
+ useEffect(() => {
+ if (!enabled || !('wakeLock' in navigator)) return;
+ let lock: WakeLockSentinel | null = null;
+
+ const request = async (): Promise => {
+ try {
+ lock = await navigator.wakeLock.request('screen');
+ } catch {
+ // low battery / hidden などで拒否されうる(ベストエフォート)。
+ }
+ };
+ const onVisible = (): void => {
+ if (document.visibilityState === 'visible') void request();
+ };
+
+ void request();
+ document.addEventListener('visibilitychange', onVisible);
+ return () => {
+ document.removeEventListener('visibilitychange', onVisible);
+ void lock?.release().catch(() => {});
+ };
+ }, [enabled]);
+};
+```
+
+- [ ] **Step 5: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし(`WakeLockSentinel`/`navigator.wakeLock` は lib.dom 由来)。
+
+- [ ] **Step 6: コミット**
+
+```bash
+git add apps/signage/src/lib
+git commit -m "feat(signage): add time utils (now override, useNow, jst/mmss) and wake lock hook"
+```
+
+---
+
+## Task 6: チャイム音(Web Audio 合成)`chimes.ts`
+
+**Files:**
+- Create: `apps/signage/src/lib/chimes.ts`
+
+- [ ] **Step 1: 実装**
+
+Create `apps/signage/src/lib/chimes.ts`:
+
+```ts
+import type { ChimeKind } from '@tecnova/shared/activity-cycle';
+
+// 単一の AudioContext を使い回す(ブラウザは同時 context 数を制限するため)。
+let ctx: AudioContext | null = null;
+
+const getCtx = (): AudioContext => {
+ if (!ctx) ctx = new AudioContext();
+ return ctx;
+};
+
+// 起動タップ内で呼ぶ。自動再生制約を解放する。
+export const resumeAudio = async (): Promise => {
+ const c = getCtx();
+ if (c.state !== 'running') await c.resume();
+};
+
+// suspended に戻っていれば再開(OS スリープ後・タブ復帰時)。
+export const ensureAudioRunning = async (): Promise => {
+ if (ctx && ctx.state !== 'running') await ctx.resume();
+};
+
+const tone = (
+ c: AudioContext,
+ freq: number,
+ startAt: number,
+ dur: number,
+ type: OscillatorType,
+): void => {
+ const osc = c.createOscillator();
+ const gain = c.createGain();
+ osc.type = type;
+ osc.frequency.value = freq;
+ // 指数エンベロープでベル風の余韻(0 には到達できないので 0.0001 へ)。
+ gain.gain.setValueAtTime(0.0001, startAt);
+ gain.gain.exponentialRampToValueAtTime(0.5, startAt + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, startAt + dur);
+ osc.connect(gain).connect(c.destination);
+ osc.start(startAt);
+ osc.stop(startAt + dur + 0.05);
+};
+
+// 種別ごとに音色・音程を変える。
+const PATTERNS: Record = {
+ resume: { freqs: [784, 988], type: 'sine', dur: 0.7 }, // 上行=再開
+ break: { freqs: [988, 784], type: 'sine', dur: 0.8 }, // 下行=休憩(キンコン)
+ 'term-end': { freqs: [880, 587], type: 'triangle', dur: 1.2 }, // 長め=ターム終了
+};
+
+export const playChime = (kind: ChimeKind): void => {
+ const c = getCtx();
+ if (c.state !== 'running') return; // 解放前は鳴らさない
+ const { freqs, type, dur } = PATTERNS[kind];
+ const t = c.currentTime + 0.02;
+ tone(c, freqs[0], t, dur, type);
+ tone(c, freqs[1], t + 0.45, dur, type);
+};
+```
+
+- [ ] **Step 2: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 3: コミット**
+
+```bash
+git add apps/signage/src/lib/chimes.ts
+git commit -m "feat(signage): synthesize chime tones via Web Audio"
+```
+
+---
+
+## Task 7: チャイムスケジューラ `use-chime-scheduler.ts`
+
+**Files:**
+- Create: `apps/signage/src/lib/use-chime-scheduler.ts`
+
+- [ ] **Step 1: 実装(自己補正 setTimeout + key dedup)**
+
+Create `apps/signage/src/lib/use-chime-scheduler.ts`:
+
+```ts
+'use client';
+
+import { type ChimeEvent, cycleChimeEventsForDay } from '@tecnova/shared/activity-cycle';
+import { useEffect, useRef } from 'react';
+
+interface Args {
+ enabled: boolean; // 音声解放済みか(起動タップ後)
+ isTermActive: (term: ChimeEvent['term']) => boolean; // 稼働判定
+ onChime: (event: ChimeEvent) => void; // 発火時の副作用(playChime 等)
+ getNow: () => Date;
+}
+
+// 壁時計の :00/:50 等の境界でちょうど発火させる。setInterval は使わず、毎 tick
+// Date から次境界までの遅延を再計算する(ドリフトしない)。key で二重発火を防ぐ。
+export const useChimeScheduler = ({ enabled, isTermActive, onChime, getNow }: Args): void => {
+ const isActiveRef = useRef(isTermActive);
+ const onChimeRef = useRef(onChime);
+ const getNowRef = useRef(getNow);
+ isActiveRef.current = isTermActive;
+ onChimeRef.current = onChime;
+ getNowRef.current = getNow;
+
+ useEffect(() => {
+ if (!enabled) return;
+ const fired = new Set();
+ let last = getNowRef.current().getTime();
+ let timer = 0;
+
+ const tick = (): void => {
+ const now = getNowRef.current().getTime();
+ const events = cycleChimeEventsForDay(new Date(now));
+ for (const e of events) {
+ const at = e.at.getTime();
+ if (at > last && at <= now && !fired.has(e.key)) {
+ fired.add(e.key);
+ if (isActiveRef.current(e.term)) onChimeRef.current(e);
+ }
+ }
+ last = now;
+ const nextAt = events
+ .map((e) => e.at.getTime())
+ .filter((t) => t > now)
+ .sort((a, b) => a - b)[0];
+ const delay = nextAt === undefined ? 1000 : Math.min(1000, Math.max(50, nextAt - now));
+ timer = window.setTimeout(tick, delay);
+ };
+
+ const onVisible = (): void => {
+ if (document.visibilityState === 'visible') {
+ window.clearTimeout(timer);
+ tick();
+ }
+ };
+
+ timer = window.setTimeout(tick, 0);
+ document.addEventListener('visibilitychange', onVisible);
+ return () => {
+ window.clearTimeout(timer);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ }, [enabled]);
+};
+```
+
+- [ ] **Step 2: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 3: コミット**
+
+```bash
+git add apps/signage/src/lib/use-chime-scheduler.ts
+git commit -m "feat(signage): drift-free chime scheduler with per-boundary dedupe"
+```
+
+---
+
+## Task 8: ライブデータ取得 `use-signage-data.ts`
+
+**Files:**
+- Create: `apps/signage/src/lib/use-signage-data.ts`
+
+- [ ] **Step 1: 実装(`/api/sessions/today` をポーリングしターム別に集計)**
+
+Create `apps/signage/src/lib/use-signage-data.ts`:
+
+```ts
+'use client';
+
+import type { TermId } from '@tecnova/shared/venue-schedule';
+import type { TodaySessionsResponse } from '@tecnova/shared/schemas';
+import { apiJson } from '@tecnova/ui/lib/api-client';
+import { useEffect, useState } from 'react';
+
+export interface SignageData {
+ currentlyPresent: number;
+ totalCheckedIn: number;
+ termCounts: Record;
+}
+
+const EMPTY: SignageData = {
+ currentlyPresent: 0,
+ totalCheckedIn: 0,
+ termCounts: { morning: 0, afternoon: 0, evening: 0 },
+};
+
+const POLL_MS = 20_000;
+
+// 認証付き /api/sessions/today を ~20秒間隔で取得。ターム別 checkedIn は
+// sessions[].term の件数(累計=ターム終了まで sticky)。失敗時は直近値を保持。
+export const useSignageData = (): SignageData => {
+ const [data, setData] = useState(EMPTY);
+
+ useEffect(() => {
+ let active = true;
+ const load = async (): Promise => {
+ try {
+ const res = await apiJson('/api/sessions/today');
+ if (!active) return;
+ const termCounts: Record = { morning: 0, afternoon: 0, evening: 0 };
+ for (const s of res.sessions) {
+ if (s.term) termCounts[s.term] += 1;
+ }
+ setData({
+ currentlyPresent: res.summary.currentlyPresent,
+ totalCheckedIn: res.summary.totalCheckedIn,
+ termCounts,
+ });
+ } catch {
+ // ネットワーク不達時は直近値を維持(degrade)。
+ }
+ };
+ void load();
+ const id = window.setInterval(() => void load(), POLL_MS);
+ return () => {
+ active = false;
+ window.clearInterval(id);
+ };
+ }, []);
+
+ return data;
+};
+```
+
+- [ ] **Step 2: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし(`TodaySessionsResponse` は `@tecnova/shared/schemas` からエクスポート済み)。
+
+- [ ] **Step 3: コミット**
+
+```bash
+git add apps/signage/src/lib/use-signage-data.ts
+git commit -m "feat(signage): poll /api/sessions/today and derive per-term counts"
+```
+
+---
+
+## Task 9: 動画プレイリスト + Stage(動画レイヤ)
+
+**Files:**
+- Create: `apps/signage/src/config/playlist.ts`
+- Create: `apps/signage/src/components/stage.tsx`
+
+- [ ] **Step 1: プレイリスト設定**
+
+Create `apps/signage/src/config/playlist.ts`:
+
+```ts
+export interface VideoItem {
+ src: string;
+ type?: string;
+}
+
+// 動画はリポジトリに置かず、self-host/CDN の URL を列挙する。差し替えは本ファイル+再デプロイ。
+// 例(実URLに差し替える):
+// { src: 'https://cdn.example.com/signage/intro.mp4', type: 'video/mp4' },
+// 空配列のままだと Stage は背景色のみを表示する。
+export const PLAYLIST: VideoItem[] = [];
+```
+
+- [ ] **Step 2: Stage(動画は常時マウント・active で再生/停止、unmuted で音声)**
+
+Create `apps/signage/src/components/stage.tsx`:
+
+```tsx
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { PLAYLIST } from '@/config/playlist';
+
+interface Props {
+ active: boolean; // 活動フェーズ中のみ再生
+ unmuted: boolean; // 起動タップ後に音声ON
+}
+
+export function Stage({ active, unmuted }: Props) {
+ const ref = useRef(null);
+ const indexRef = useRef(0);
+
+ // React は muted を DOM に確実に反映しないため ref 経由で設定する。
+ useEffect(() => {
+ const v = ref.current;
+ if (v) v.muted = !unmuted;
+ }, [unmuted]);
+
+ useEffect(() => {
+ const v = ref.current;
+ if (!v) return;
+ if (active) void v.play().catch(() => {});
+ else v.pause();
+ }, [active]);
+
+ const handleEnded = (): void => {
+ const v = ref.current;
+ if (!v || PLAYLIST.length <= 1) return;
+ indexRef.current = (indexRef.current + 1) % PLAYLIST.length;
+ const next = PLAYLIST[indexRef.current];
+ if (!next) return;
+ v.src = next.src;
+ void v.play().catch(() => {});
+ };
+
+ const first = PLAYLIST[0];
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 3: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 4: コミット**
+
+```bash
+git add apps/signage/src/config/playlist.ts apps/signage/src/components/stage.tsx
+git commit -m "feat(signage): playlist config and video stage layer"
+```
+
+---
+
+## Task 10: 表示コンポーネント(info-bar / break-screen / idle-screen / tap-to-start)
+
+**Files:**
+- Create: `apps/signage/src/components/info-bar.tsx`
+- Create: `apps/signage/src/components/break-screen.tsx`
+- Create: `apps/signage/src/components/idle-screen.tsx`
+- Create: `apps/signage/src/components/tap-to-start.tsx`
+
+- [ ] **Step 1: InfoBar(活動中の上部バー)**
+
+Create `apps/signage/src/components/info-bar.tsx`:
+
+```tsx
+'use client';
+
+import { TERM_LABELS, type TermId } from '@tecnova/shared/venue-schedule';
+import { jstHm, mmss } from '@/lib/time';
+
+interface Props {
+ term: TermId;
+ now: Date;
+ present: number;
+ secondsToBreak: number | null;
+}
+
+export function InfoBar({ term, now, present, secondsToBreak }: Props) {
+ return (
+
+
+ {TERM_LABELS[term]}の部
+
+ {jstHm(now)}
+ {secondsToBreak !== null && (
+ 休憩まで {mmss(secondsToBreak)}
+ )}
+
+ 在館 {present} 人
+
+
+ );
+}
+```
+
+- [ ] **Step 2: BreakScreen(休憩中・カウントダウン主役、クロスフェード)**
+
+Create `apps/signage/src/components/break-screen.tsx`:
+
+```tsx
+'use client';
+
+import { mmss } from '@/lib/time';
+
+interface Props {
+ show: boolean;
+ secondsToResume: number | null;
+ present: number;
+}
+
+// 動画レイヤの上に重ね、opacity でクロスフェード。動画は裏で再生継続(再読込フラッシュ防止)。
+// prefers-reduced-motion 時は globals 側のトランジション無効化に委ねつつ、ここでも duration を短縮。
+export function BreakScreen({ show, secondsToResume, present }: Props) {
+ return (
+
+
休憩中
+
+ {secondsToResume !== null ? mmss(secondsToResume) : '--:--'}
+
+
再開までの時間
+
+ 在館 {present} 人
+
+
+ );
+}
+```
+
+- [ ] **Step 3: IdleScreen(待機・営業時間外/稼働前)**
+
+Create `apps/signage/src/components/idle-screen.tsx`:
+
+```tsx
+'use client';
+
+import { jstHm } from '@/lib/time';
+
+interface Props {
+ show: boolean;
+ soon: boolean; // ターム内だが未稼働(初回チェックイン前)=「まもなく開始」
+ now: Date;
+ nextStartAt: Date | null; // 次の活動開始(境界)。ターム外のときのみ算出。
+ present: number;
+}
+
+export function IdleScreen({ show, soon, now, nextStartAt, present }: Props) {
+ return (
+
+
tec-nova Nagasaki
+
{jstHm(now)}
+
+ {soon
+ ? 'まもなく開始'
+ : nextStartAt
+ ? `次は ${jstHm(nextStartAt)} から`
+ : '本日は終了しました'}
+
+ {present > 0 &&
在館 {present} 人
}
+
+ );
+}
+```
+
+- [ ] **Step 4: TapToStart(音声解放ゲート)**
+
+Create `apps/signage/src/components/tap-to-start.tsx`:
+
+```tsx
+'use client';
+
+interface Props {
+ onStart: () => void;
+}
+
+export function TapToStart({ onStart }: Props) {
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 5: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 6: コミット**
+
+```bash
+git add apps/signage/src/components
+git commit -m "feat(signage): info-bar, break/idle screens, tap-to-start overlay"
+```
+
+---
+
+## Task 11: 状態機械の結線 `page.tsx`
+
+**Files:**
+- Modify: `apps/signage/src/app/page.tsx`
+
+- [ ] **Step 1: 本体を実装(暫定プレースホルダを置き換え)**
+
+Replace `apps/signage/src/app/page.tsx` の全内容:
+
+```tsx
+'use client';
+
+import {
+ type ChimeEvent,
+ classifyCycleMoment,
+ msUntilNextBoundary,
+} from '@tecnova/shared/activity-cycle';
+import { useCallback, useEffect, useState } from 'react';
+import { BreakScreen } from '@/components/break-screen';
+import { IdleScreen } from '@/components/idle-screen';
+import { InfoBar } from '@/components/info-bar';
+import { Stage } from '@/components/stage';
+import { TapToStart } from '@/components/tap-to-start';
+import { ensureAudioRunning, playChime, resumeAudio } from '@/lib/chimes';
+import { getNow } from '@/lib/now';
+import { useChimeScheduler } from '@/lib/use-chime-scheduler';
+import { useNow } from '@/lib/use-now';
+import { useSignageData } from '@/lib/use-signage-data';
+import { useWakeLock } from '@/lib/use-wake-lock';
+
+export default function SignagePage() {
+ const [started, setStarted] = useState(false);
+ const now = useNow(1000);
+ const data = useSignageData();
+ const moment = classifyCycleMoment(now);
+
+ const isTermActive = useCallback(
+ (term: 'morning' | 'afternoon' | 'evening') => data.termCounts[term] > 0,
+ [data.termCounts],
+ );
+
+ // 現タームが稼働中(初回チェックイン済み)なら moment.phase、未稼働/ターム外は idle。
+ const active = moment.term !== null && isTermActive(moment.term);
+ const phase = active ? moment.phase : 'idle';
+
+ useWakeLock(started);
+
+ const onChime = useCallback((e: ChimeEvent) => {
+ playChime(e.kind);
+ }, []);
+ useChimeScheduler({ enabled: started, isTermActive, onChime, getNow });
+
+ // タブ復帰時に AudioContext が suspended に戻っていれば再開する(spec §6)。
+ useEffect(() => {
+ if (!started) return;
+ const onVisible = (): void => {
+ if (document.visibilityState === 'visible') void ensureAudioRunning();
+ };
+ document.addEventListener('visibilitychange', onVisible);
+ return () => document.removeEventListener('visibilitychange', onVisible);
+ }, [started]);
+
+ const handleStart = async (): Promise => {
+ await resumeAudio();
+ try {
+ await document.documentElement.requestFullscreen?.();
+ } catch {
+ // 全画面はベストエフォート(dev では拒否されうる)。
+ }
+ setStarted(true);
+ };
+
+ // フェーズ終端までの秒(活動→休憩 / 休憩→再開)。
+ const phaseSecondsLeft =
+ moment.phaseEndsAt === null
+ ? null
+ : Math.ceil((moment.phaseEndsAt.getTime() - now.getTime()) / 1000);
+
+ // idle の理由を区別:ターム内・未稼働なら「まもなく開始」、ターム外なら次タームの開始時刻。
+ const inUnstartedTerm = moment.term !== null && !active;
+ // 次の活動開始(次境界=次タームの resume)はターム外のときだけ算出する
+ // (ターム内・未稼働で算出すると次境界が break になり「次は HH:MM から」が誤表示になるため)。
+ const msNext = moment.term === null ? msUntilNextBoundary(now) : null;
+ const nextStartAt = msNext === null ? null : new Date(now.getTime() + msNext);
+
+ return (
+
+
+
+ {phase === 'activity' && moment.term && (
+
+ )}
+
+
+
+
+
+ {!started && }
+
+ );
+}
+```
+
+- [ ] **Step 2: 型チェック**
+
+Run: `pnpm --filter signage type-check`
+Expected: エラーなし。
+
+- [ ] **Step 3: 手動 E2E(時刻上書きで全状態を確認)**
+
+Run: `pnpm --filter signage dev` + api 起動 + ログイン済み。
+確認(`?now=` で擬似時刻、ローカル D1 に当日セッションを1件入れて稼働させる):
+- `http://localhost:3002/?now=2026-05-30T09:30:00` → 「タップして開始」→ 当該タームに当日チェックインがあれば activity(動画+情報バー「休憩まで …」)。チェックインが無ければ idle で「**まもなく開始**」(「次は …から」ではない)になることを確認。
+- `?now=2026-05-30T09:49:50` → 数秒後に :50 へ → break 画面にクロスフェード+休憩チャイム。
+- `?now=2026-05-30T09:59:50` → :00 で activity に戻り再開チャイム。
+- `?now=2026-05-30T12:30:00` → idle(「次は 13:00 から」)。
+- OS/ブラウザの reduce-motion をON → クロスフェードが瞬時切替になることを確認。
+Expected: 各状態遷移とチャイムが設計通り。
+
+- [ ] **Step 4: 全体型チェック&Lint**
+
+Run: `pnpm type-check`
+Run: `pnpm biome check .`
+Expected: いずれもエラーなし(必要なら `pnpm biome check --write .` で整形)。
+
+- [ ] **Step 5: コミット**
+
+```bash
+git add apps/signage/src/app/page.tsx
+git commit -m "feat(signage): wire phase state machine (video/break/idle + chimes)"
+```
+
+---
+
+## Task 12: ドキュメント(アプリ docs + architecture 追記)
+
+**Files:**
+- Create: `apps/signage/AGENTS.md`
+- Create: `apps/signage/CLAUDE.md`
+- Modify: `docs/architecture.md`
+
+- [ ] **Step 1: `AGENTS.md`(checkin と同一)**
+
+Create `apps/signage/AGENTS.md`:
+
+```markdown
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
+```
+
+- [ ] **Step 2: `CLAUDE.md`**
+
+Create `apps/signage/CLAUDE.md`:
+
+```markdown
+@AGENTS.md
+
+# signage(会場サイネージ / 大型モニター・キオスク)
+
+- **Next.js 16 / React 19**。App Router の API がトレーニングデータと乖離しているため、実装前に `node_modules/next/dist/docs/` を確認すること。
+- **dev ポート**: `3002`(`next dev --port 3002`)。api は `8787`、checkin は `3000`、admin は `3001`。
+- **認証あり**: checkin/admin と同じメンター・ホワイトリスト(`MeProvider`/`auth-client`)。運用は**テクノバ共有の管理用 Google アカウント**で1回ログイン(セッション既定7日)。`useMe` はツリー内に `MeProvider` 必須。
+- **データ**: 認証付き `GET /api/sessions/today` を再利用(新規エンドポイントなし)。ターム別チェックイン数は `sessions[].term` から算出し、**現タームに当日チェックインが入った時点で稼働開始**(ターム終了まで sticky)。
+- **時刻ロジック**: `@tecnova/shared/activity-cycle`(50分活動/10分休憩・チャイム時刻)。表示の時刻上書きは `?now=ISO`(手動検証用)。
+- **動画**: `src/config/playlist.ts` に URL を列挙(リポジトリにバイナリを置かない)。
+- **キオスク**: 横向き・フルスクリーン。起動時「タップして開始」で音声/全画面/wake lock を解放(ブラウザの自動再生制約)。本番は Chromium を `--kiosk` 等で起動。
+- **必須 env**: `NEXT_PUBLIC_API_URL`(未設定時 `http://localhost:8787`)。API 側 `TRUSTED_ORIGINS` にサイネージ origin(dev: `http://localhost:3002`、本番ドメイン)を登録すること。
+- 新しい `@tecnova/*` パッケージを使うときは `next.config.ts` の `transpilePackages` に追加。
+```
+
+- [ ] **Step 3: `docs/architecture.md` にサイネージを追記**
+
+`docs/architecture.md` のフロントエンドアプリ一覧(`apps checkin` / `apps admin` などが並ぶ箇所)に、認証ありの新規アプリとして `apps signage`(会場サイネージ・大型モニター・メンター認証・`/api/sessions/today` 再利用・`activity-cycle` 連動)を、既存エントリと同じ記法で1行追加する。拡張ロードマップ側にも一文(サイネージ=Phase 拡張で追加、機微コンテンツは同じ認証配下で拡張予定)を加える。
+
+- [ ] **Step 4: Lint**
+
+Run: `pnpm biome check .`
+Expected: エラーなし。
+
+- [ ] **Step 5: コミット**
+
+```bash
+git add apps/signage/AGENTS.md apps/signage/CLAUDE.md docs/architecture.md
+git commit -m "docs(signage): add app CLAUDE.md/AGENTS.md and architecture entry"
+```
+
+---
+
+## 動作確認サマリ(全タスク後)
+
+1. `pnpm --filter @tecnova/shared test` … activity-cycle の単体テスト green。
+2. `pnpm type-check` … 全 workspace 型エラーなし。
+3. `pnpm biome check .` … lint/format クリーン。
+4. `pnpm dev` 後、`http://localhost:3002` で:未ログイン→/login→共有アカウントでログイン→「タップして開始」→ `?now=` で activity/break/idle 遷移とチャイムを確認。
+5. 実機キオスク:音付き動画・wake lock・全画面、タブ復帰で音声復帰、セッション維持(再読込でログイン不要)を確認。
+
+## 留意点(spec §9 由来)
+
+- 端末ローカル時計依存(NTP 同期推奨)。
+- セッション既定7日(長期運用は `apps/api/src/lib/auth.ts` に `session.expiresIn` 追加で延長可)。
+- 稼働判定はポーリング間隔(~20秒)分のラグあり。:50 直前の初チェックインは当該休憩チャイムを逃しうる(許容)。
+- v1 は `/api/sessions/today` の PII を画面に出さない。将来の機微コンテンツは同じ認証配下の新エンドポイントで。
+- 起動タップ後の動画 unmute/再生は React effect 経由(タップと同一ジェスチャ内の同期呼び出しではない)。ページの sticky activation + キオスクの autoplay policy で実用上は問題なし。非キオスクの素のブラウザで初回 unmuted 再生がブロックされた場合は Stage の muted フォールバック(`play()` 失敗を握る)に依存する。より厳密にするなら handleStart 内で video へ imperative に `muted=false; play()` してから `setStarted(true)` する。
+```
+
diff --git a/docs/superpowers/specs/2026-05-29-checkin-redesign-design.md b/docs/superpowers/specs/2026-05-29-checkin-redesign-design.md
new file mode 100644
index 0000000..9385f49
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-29-checkin-redesign-design.md
@@ -0,0 +1,181 @@
+# checkin リデザイン設計書
+
+- 日付: 2026-05-29
+- 対象アプリ: `apps/checkin`(受付端末 / iPad PWA)
+- ステータス: 設計(実装前レビュー待ち)
+
+---
+
+## 1. 背景と目的
+
+`apps/checkin` の参加者プロフィール画面(`/reception/participants/[id]`)は直近のコミット
+(`efd0aed` / `790aaf5` / `49ea36f`)で **motion + Cohesive Elevation** の演出が施され、アプリ内の
+「仕上がりの基準線」になっている。一方で **それ以外の 7 画面は構造・配色は揃っているが演出が無い**:
+
+- すべてフラットな `bg-sky-50`(プロフィールの `from-sky-50 to-white` グラデーション地ではない)
+- 入場モーションがゼロ(カードもリストも静的に出る)
+- 数値が静的(カウントアップ無し)、リストにスタッガー無し
+- ガイドラインの進捗バーは固定幅、スライドはハード切り替え
+- 「マニュアル入力」アイコンが `IconBug`(`page.tsx:217` / `manual/page.tsx:106`)=置き去りのプレースホルダ
+
+**目的**: プロフィール画面の演出語彙を残り 7 画面へ展開し、アプリ全体を一つの製品として統一する。
+さらに高インパクトな画面には 1 つずつ「シグネチャーモーメント」を与え、統一感に加えて体験の質を引き上げる。
+
+方針はブレインストーミングで合意済み:**「統一の土台 + シグネチャーモーメント」**。
+
+---
+
+## 2. スコープ
+
+### 対象(7 画面)
+
+`/`(Home / QR スキャナ)、`/login`、`/manual`、`/first-time`、`/guideline`、`/history`、`/settings`
+
+### 対象外(回帰させない)
+
+- **プロフィール画面** `/reception/participants/[id]` — 基準線。視覚は変更しない。
+ - ただし新設の共有プリミティブ(`Reveal` 等)への **無風リファクタ**(見た目を一切変えない差し替え)は任意。
+ 本リデザインでは原則手を入れず、安全に確認できる範囲で別途検討する。
+- **`AppShell`(共通ヘッダ)** `src/components/app-shell.tsx` — 全画面(プロフィール含む)を包むため、
+ 視覚回帰を避ける。変更するとしても加算的・中立的なものに限る。
+
+### 非対象(YAGNI)
+
+- 装飾的な背景テクスチャ・モチーフ(ブループリント/ドット/グロー/メッシュ等)は **入れない**。
+ 土台はグラデーション地とカードのみ。演出とレイアウトで格上げする(ユーザー判断: "Keep it flat")。
+- 新しい API・データモデルの追加は無し(既存エンドポイント・スキーマのまま)。
+
+---
+
+## 3. デザイン原則(基準線 = プロフィール画面)
+
+1. **地** は `bg-gradient-to-b from-sky-50 to-white`(全対象画面に統一)。エラー画面は従来どおり `bg-rose-50`。
+2. **カード** は `border-sky-200 shadow-sm`、見出しは既存 `PanelHeader`(tone 付き丸アイコン + `text-3xl` タイトル)。
+3. **トーン体系**(既存を踏襲): emerald=成功/滞在中、sky=情報/主要、amber=警告/チェックアウト、rose=危険、slate=中立。
+4. **モーションは必ず `useReducedMotion()` でゲート**。reduced 時は `initial={false}` で即表示し、ループ演出は止める。
+5. **タッチ前提のサイズ**(`h-14`/`h-16`/`h-20`、`text-xl`〜`text-2xl`)と `tabular-nums` を維持。
+6. 演出は **transform/opacity のみ**(GPU 合成、レイアウトスラッシュ回避)。
+
+---
+
+## 4. 共有プリミティブ(新規追加)
+
+すべて `apps/checkin/src/components/`(既存 `AnimatedNumber`/`PanelHeader`/`ResultSummaryCard` と同居の checkin ローカル)。
+複数アプリで必要になったら `packages/ui` へ昇格する(現時点ではミニマム優先で昇格しない)。
+
+| ファイル | 役割 | 備考 |
+|---|---|---|
+| `src/lib/motion.ts` | モーション定数・variant ファクトリを 1 箇所に集約 | 値は §5。reduced-motion 判定は各コンポーネント側 |
+| `src/components/reveal.tsx` | `` フェードアップ入場ラッパ | reduced 時は素の要素。`index` でスタッガー。`as`/`className` 可 |
+| `src/components/stat-tile.tsx` | ラベル + 数値タイル(`rounded-lg border bg-white p-4`) | `tone`/`icon` 任意。数値は `AnimatedNumber` 連携可 |
+| `src/components/live-dot.tsx` | 滞在中を示す脈動ドット | `active` で emerald 脈動 / slate 静止。reduced 対応 |
+| `src/components/page-shell.tsx` | グラデーション地の `` 既定 | `className` で上書き可。中央寄せ等はオプション |
+| `src/components/scan-reticle.tsx` | Home の QR スキャン演出(静的な角ブラケット) | アニメーションなし(走査線はユーザー判断で不採用) |
+| `src/components/segmented-control.tsx` | Manual のモード切替(スライドする選択インジケータ) | `motion` の `layoutId` でスライド。reduced 時は即切替 |
+
+> Next.js 16 / React 19 / `motion`(`motion/react`)は既に利用中。`AGENTS.md` のとおり、App Router 系 API を触る箇所は
+> 実装前に `node_modules/next/dist/docs/` を確認する。
+
+---
+
+## 5. モーション契約(`src/lib/motion.ts`)
+
+| 名前 | 値 | 用途 |
+|---|---|---|
+| `reveal(index)` | `initial {opacity:0, y:12}` → `animate {opacity:1, y:0}`、`{duration:0.4, ease:'easeOut', delay: index*0.06}` | カード/セクションの入場(プロフィールと同値) |
+| `tap` | `whileTap {scale:0.97}` | 主要ボタンの押下フィードバック |
+| `pop(index)` | `initial {opacity:0, scale:0.6}` → `{opacity:1, scale:1}`、`{duration:0.25, ease:'easeOut', delay: min(index*0.012, 0.5)}` | タイル/小要素のポップ(プロフィール来場ヒートマップと同値) |
+| `listItem(index)` | フェードアップ + 大きめスタッガー(`delay: min(index*0.04, 0.4)`) | 検索結果・候補・履歴行 |
+| `slide(direction)` | `AnimatePresence` で `x: ±40, opacity:0` の方向付き出入り | ガイドラインのスライド遷移。reduced 時はクロスフェード/即時 |
+
+すべて reduced-motion 時は無効化(即表示・ループ停止)。
+
+---
+
+## 6. 画面ごとの設計
+
+各画面共通: 地をグラデーションへ、ルート直下を `Reveal` でスタッガー入場、主要ボタンに `tap`、カードは `border-sky-200 shadow-sm`。
+以下は「土台 + シグネチャー + 個別修正」。
+
+### 6.1 Home `/`(ディレクションスライス)
+
+- 土台: グラデーション地。右の 3 アクションカードを `Reveal` で順次入場。
+- **シグネチャー: QR スキャン演出**。映像枠にスキャン対象を示す静的な角ブラケット(`ScanReticle`)。
+ 検出→遷移中のオーバーレイ(ID 表示)も合わせて磨く。走査線アニメーションはユーザー判断で不採用(アニメーションを持たない)。
+- 個別修正: 「マニュアル入力」カードの `IconBug` → `IconKeyboard`(`page.tsx:217`)。
+
+### 6.2 Guideline `/guideline`(ショーケース)
+
+- 土台: グラデーション地、既存のスライド別トーン体系を維持。
+- **シグネチャー: スライド体験の作り込み**。
+ - `AnimatePresence` による **方向付きスライド遷移**(次へ=左へ送り、前へ=右へ戻す)。
+ - 進捗バーの **幅アニメーション**(現状は固定幅)。
+ - アイコンの軽いポップ入場、最後の同意ステップにささやかな達成感の強調。
+ - **キーボード矢印**(←/→)でのスライド送り対応。
+- reduced 時はスライドをクロスフェード or 即時、進捗は即反映。
+
+### 6.3 Login `/login`
+
+- 土台: グラデーション地、入場モーション。Google ボタンに `tap`。
+- **シグネチャー: 温かいウェルカムヒーロー**。TECNOVA ロゴ + やさしい見出し + 余白設計で「最初に見る画面」を歓迎的に。
+ 装飾背景は無し(フラット指定)。レイアウトとロゴ、モーションで魅せる。
+
+### 6.4 Manual `/manual`
+
+- 土台: グラデーション地、入場モーション。
+- **シグネチャー: モード切替の作り込み**。`SegmentedControl` のスライドする選択インジケータ + ID/名前パネルのクロスフェード。
+ ID 入力の押下感、検索結果は `listItem` でスタッガー。
+- 個別修正: ID パネルの `IconBug` → `IconKeyboard`(`manual/page.tsx:106`)。
+
+### 6.5 History `/history`
+
+- 土台: グラデーション地、入場モーション。
+- **シグネチャー: 生きているダッシュボード**。サマリ 3 数値を `AnimatedNumber` 化、「滞在中」に `LiveDot`(脈動)。
+ テーブル行 / 空状態を磨き、`StatTile` で集計カードを統一。
+
+### 6.6 First-time `/first-time`
+
+- 土台: グラデーション地、入場モーション。
+- **シグネチャー**: 登録ステップ(1-2-3)と候補グリッドをスタッガー入場、未登録人数バッジを `AnimatedNumber`、候補カードの押下感。
+
+### 6.7 Settings `/settings`
+
+- 土台: グラデーション地、入場モーション。
+- **シグネチャー: メンターのアイデンティティカード**。素の歯車見出しの代わりに、アバター + 名前 + ロールバッジのヘッダ。控えめだが統一感。
+
+---
+
+## 7. 触らないもの・回帰防止
+
+- プロフィール画面・`AppShell` ヘッダは視覚回帰させない(§2)。
+- 既存の状態機械・データ取得・`apiFetch`・Zod 型アサートは変更しない(演出と見た目のレイヤのみ)。
+- `ResultSummaryCard` は emerald/amber トーンの完了画面で使われ続ける。必要なら入場モーションのみ加算。
+
+---
+
+## 8. 制約
+
+- **Next.js 16 / React 19**: App Router API を触る箇所は実装前に `node_modules/next/dist/docs/` を確認(`AGENTS.md`)。
+- **a11y**: reduced-motion ゲート必須、ガイドラインはキーボード操作、`aria-label`/フォーカス可視を維持、明るい環境向けの十分なコントラスト。
+- **コード規約**: TypeScript strict、`any` 禁止、arrow function、import は先頭集約、Biome(2 スペース / セミコロン / シングルクォート)。
+- **パフォーマンス**: transform/opacity のみで演出、装飾画像を増やさない(フラット)。`motion` の既存利用に倣う。
+
+---
+
+## 9. 作業順序とディレクションスライス
+
+1. 共有プリミティブ(`motion.ts` / `Reveal` / `StatTile` / `LiveDot` / `PageShell`)を先に用意。
+2. **Home を実機で動くディレクションスライスとして実装** → ユーザーが起動して見た目・モーション感を承認。
+3. 承認後、確定した語彙を残り 6 画面へ展開(**Guideline を 2 番目** = ショーケース、続いて Login/Manual/History/First-time/Settings)。
+4. ultracode 方針に従い、展開フェーズは並列ワークフローで実行し、**デザイン整合性の敵対的レビュー**を通す。
+
+---
+
+## 10. 検証方法
+
+フロント視覚タスクのため自動テストは限定的。各段階で以下を満たす:
+
+- `pnpm type-check` と `pnpm biome check`(lint/format)がパス。
+- `pnpm --filter checkin dev` で各画面を起動し、見た目・モーション・reduced-motion(OS 設定)を目視確認。
+- 主要ブレークポイント(iPad 縦/横、`sm`/`lg`)でレイアウト崩れが無いこと。
+- プロフィール画面と `AppShell` に視覚回帰が無いこと。
diff --git a/docs/superpowers/specs/2026-05-29-signage-chime-design.md b/docs/superpowers/specs/2026-05-29-signage-chime-design.md
new file mode 100644
index 0000000..31b4bd5
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-29-signage-chime-design.md
@@ -0,0 +1,293 @@
+# サイネージ+チャイム アプリ(`apps/signage`)設計
+
+- 作成日: 2026-05-29
+- ステータス: 設計合意済み(実装計画はこの後に作成)
+- 関連: `docs/architecture.md`(拡張ロードマップに本アプリを追記予定)
+
+---
+
+## 背景・目的(Context)
+
+会場(tec-nova Nagasaki)の大型モニターに常時表示する**デジタルサイネージ兼チャイム**アプリを新設する。
+現状、サイネージ/チャイム/大型モニターは `requirements.md` / `mvp.md` / `architecture.md` のいずれにも記載がなく、本アプリは**新規スコープ**である。
+
+会場運用は「**活動50分 → 休憩10分**」のリズムで進む(例: 9:00–9:50 活動 / 9:50–10:00 休憩 / 10:00 再開 …)。
+このリズムの区切りを**チャイム**で知らせ、普段は**動画を流し**つつ、時間が近づくと**シームレスに画面を切り替える**ことで、子どもと運営の双方に「いまは活動/休憩」「次の区切りまであと何分」を直感的に伝える。
+
+狙う成果:
+- 区切りの合図(チャイム)と視覚表示を自動化し、運営の手間と「時間を見失う」事故を減らす。
+- 動画で会場の雰囲気をつくる(ウェルカム・空気づくり)。
+- 既存プラットフォームの**チェックインデータ**を稼働シグナルに使い、無人でも「今日・このタームが稼働中か」を正しく判定する。
+
+### 認証方針(決定事項)
+
+サイネージは**他2アプリ(checkin/admin)と同じメンター・ホワイトリスト認証**(Better Auth + Google OAuth + `mentors` 許可リスト)にする。**公開(無認証)にはしない。**
+
+- 理由:将来「チェックイン履歴ベースの情報」「メンター紹介スライド(氏名・写真・経歴)」などの**機微コンテンツ**を流す構想があるため、データ取得経路は最初からアクセス制御下に置く。「壁に映ること」と「APIを誰でも叩けること」は別問題で、後者を塞ぐ。
+- 端末運用:**テクノバ共有の管理用 Google アカウント(`mentors` にメンターロールで登録済み)**でキオスク端末を1回ログインし、セッション(既定7日)で維持する。個人アカウントを使わず最小権限。
+- 効果:v1 では**新規エンドポイントを作らず**、認証付きの既存 `GET /api/sessions/today` をそのまま再利用できる。将来の機微コンテンツも同じ許可リスト配下の `/api/*`(必要なら `/api/signage/*`)に足すだけで、**新しい認証機構は不要**。
+
+---
+
+## スコープ
+
+### v1 に含む
+- 動画フルスクリーン再生(ループ・プレイリスト)+常時表示の情報バー(**レイアウト L2**)。
+- **50/10サイクル**に連動した状態遷移(活動↔休憩↔待機)と**シームレスなクロスフェード**。
+- **チャイム**(Web Audio 合成、種別ごとに音色を変える)。
+- **在館人数のライブ表示**と、**ターム最初のチェックインで稼働開始**するデータ駆動ロジック(認証付き `GET /api/sessions/today` を再利用)。
+- 休憩中画面は**「再開まで M:SS」のカウントダウンを主役**に。
+- 営業時間外・昼休みの**待機(ロゴ)画面**。
+- メンター・ホワイトリスト認証(checkin と同じログイン経路)。
+- キオスク運用(フルスクリーン・横向き・スリープ防止・起動時タップで音声解放)。
+
+### v1 に含まない(将来拡張・認証経路は確定済み)
+- admin での動画CMS(アップロード・並べ替え)。
+- ニックネーム表示・チェックイン履歴ベースの情報・メンター紹介スライド → **認証付き `/api/*` エンドポイントを追加**して対応(公開はしない)。
+- 任意時刻のカスタムアナウンス登録。
+- YouTube/Vimeo 埋め込み(v1は HTML5 `