diff --git a/apps/admin/src/app/(authed)/error.tsx b/apps/admin/src/app/(authed)/error.tsx
new file mode 100644
index 0000000..ceb9289
--- /dev/null
+++ b/apps/admin/src/app/(authed)/error.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { Button } from '@tecnova/ui/components/button';
+import { DataError } from '@tecnova/ui/components/data-error';
+
+// 認証必須セクションの描画時クラッシュを拾う Error Boundary。
+// 各ページの try/catch では拾えない描画中の throw をここで受ける。
+// error.tsx は Client Component 必須。
+export default function AuthedError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(authed)/layout.tsx b/apps/admin/src/app/(authed)/layout.tsx
index 522f955..459e4bb 100644
--- a/apps/admin/src/app/(authed)/layout.tsx
+++ b/apps/admin/src/app/(authed)/layout.tsx
@@ -1,15 +1,17 @@
-import { MeProvider } from '@tecnova/ui/components/me-provider';
+import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider';
import { Toaster } from '@tecnova/ui/components/sonner';
import { AppShell } from '@/components/app-shell';
// 認証必須セクション全体のレイアウト。MeProvider が /api/me を取得し、
-// AppShell が共通ヘッダーとナビを描画する。/login は別ルートグループなので
-// このレイアウトは適用されない。
+// AppShell(サイドバー等のクローム)は認証解決を待たずに即描画する。
+// ページ本文だけ MeGate でゲートする(即時シェル)。/login は別ルートグループ。
// CRUD のフィードバックはここに置いた Toaster でまとめて受ける。
export default function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
- {children}
+
+ {children}
+
);
diff --git a/apps/admin/src/app/(authed)/loading.tsx b/apps/admin/src/app/(authed)/loading.tsx
new file mode 100644
index 0000000..43fa101
--- /dev/null
+++ b/apps/admin/src/app/(authed)/loading.tsx
@@ -0,0 +1,13 @@
+import { Skeleton } from '@tecnova/ui/components/skeleton';
+
+// ソフトナビ時のコンテンツスケルトン。即時シェル化により AppShell(サイドバー等)は
+// 保たれるため、本文スロットだけがこのフォールバックに置き換わる。
+export default function AuthedLoading() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/error.tsx b/apps/admin/src/app/error.tsx
new file mode 100644
index 0000000..fd2b7ab
--- /dev/null
+++ b/apps/admin/src/app/error.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { Button } from '@tecnova/ui/components/button';
+import { DataError } from '@tecnova/ui/components/data-error';
+
+// ルート段の Error Boundary((authed) の境界で拾えなかった描画クラッシュの受け皿)。
+export default function RootError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/bottom-nav.tsx b/apps/admin/src/components/bottom-nav.tsx
index 769637e..32250d0 100644
--- a/apps/admin/src/components/bottom-nav.tsx
+++ b/apps/admin/src/components/bottom-nav.tsx
@@ -1,6 +1,7 @@
'use client';
-import { useMe } from '@tecnova/ui/components/me-provider';
+import { useMeState } from '@tecnova/ui/components/me-provider';
+import { Skeleton } from '@tecnova/ui/components/skeleton';
import { cn } from '@tecnova/ui/lib/utils';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
@@ -10,11 +11,13 @@ import { isNavItemActive, visibleNavItems } from './nav-items';
// モバイル用のボトムタブバー。画面下に固定し、iPhone のホームインジケータを
// 避けるため safe-area ぶんの余白を足す。ロールに応じて 3〜5 タブを出す。
+// 認証解決前はバーの枠だけ即描画し、ロール依存のタブはスケルトンにする(即時シェル)。
export function BottomNav({ className }: { className?: string }) {
- const me = useMe();
+ const meState = useMeState();
const pathname = usePathname();
const prefersReduced = useReducedMotion();
- const items = visibleNavItems(me.mentor.role);
+ const me = meState.status === 'ok' ? meState.me : null;
+ const items = me ? visibleNavItems(me.mentor.role) : [];
return (
);
diff --git a/apps/admin/src/components/mobile-top-bar.tsx b/apps/admin/src/components/mobile-top-bar.tsx
index a8aa2c9..f8c1288 100644
--- a/apps/admin/src/components/mobile-top-bar.tsx
+++ b/apps/admin/src/components/mobile-top-bar.tsx
@@ -1,6 +1,7 @@
'use client';
-import { useMe } from '@tecnova/ui/components/me-provider';
+import { useMeState } from '@tecnova/ui/components/me-provider';
+import { Skeleton } from '@tecnova/ui/components/skeleton';
import { ThemeToggle } from '@tecnova/ui/components/theme-toggle';
import { cn } from '@tecnova/ui/lib/utils';
import { AccountMenu } from './account-menu';
@@ -8,8 +9,10 @@ import { BrandLogo } from './brand-logo';
// モバイル用のトップバー。左にブランド、右にテーマ切替とアカウント。
// ページタイトルは各ページの PageHeader が担うのでここでは出さない。
+// 認証解決前はブランド/テーマ切替を即描画し、アカウントだけスケルトンにする。
export function MobileTopBar({ className }: { className?: string }) {
- const me = useMe();
+ const meState = useMeState();
+ const me = meState.status === 'ok' ? meState.me : null;
return (
{/* モバイルはタッチ確保のため 40px のヒットエリアにする。 */}
-
- {me.mentor.name.charAt(0)}
-
- }
- />
+ {me ? (
+
+ {me.mentor.name.charAt(0)}
+
+ }
+ />
+ ) : (
+
+ )}
);
diff --git a/apps/admin/src/components/sidebar.tsx b/apps/admin/src/components/sidebar.tsx
index 23dbf17..0b77867 100644
--- a/apps/admin/src/components/sidebar.tsx
+++ b/apps/admin/src/components/sidebar.tsx
@@ -2,7 +2,8 @@
import { IconSelector } from '@tabler/icons-react';
import { Button } from '@tecnova/ui/components/button';
-import { useMe } from '@tecnova/ui/components/me-provider';
+import { useMeState } from '@tecnova/ui/components/me-provider';
+import { Skeleton } from '@tecnova/ui/components/skeleton';
import { ThemeToggle } from '@tecnova/ui/components/theme-toggle';
import { cn } from '@tecnova/ui/lib/utils';
import { motion, useReducedMotion } from 'motion/react';
@@ -15,11 +16,14 @@ import { isNavItemActive, visibleNavItems } from './nav-items';
// デスクトップ用の固定左サイドバー。ブランド → ナビ → フッター(テーマ切替 +
// アカウント)の3段構成。モバイルでは AppShell 側で hidden にする。
+// 認証解決前(me ロード中)はクロームを即描画し、ロール依存のナビとアカウントだけ
+// スケルトンにする(即時シェル)。
export function Sidebar({ className }: { className?: string }) {
- const me = useMe();
+ const meState = useMeState();
const pathname = usePathname();
const prefersReduced = useReducedMotion();
- const items = visibleNavItems(me.mentor.role);
+ const me = meState.status === 'ok' ? meState.me : null;
+ const items = me ? visibleNavItems(me.mentor.role) : [];
return (
diff --git a/apps/checkin/src/components/app-shell.tsx b/apps/checkin/src/components/app-shell.tsx
index 5e728be..a4515f4 100644
--- a/apps/checkin/src/components/app-shell.tsx
+++ b/apps/checkin/src/components/app-shell.tsx
@@ -2,7 +2,7 @@
import { IconClipboardCheck, IconHome, IconSettings } from '@tabler/icons-react';
import { Button } from '@tecnova/ui/components/button';
-import { MeProvider } from '@tecnova/ui/components/me-provider';
+import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -20,13 +20,15 @@ export function AppShell({ children }: Props) {
}
return (
-
- }>{children}
+
+
+ }>{children}
+
);
}
diff --git a/apps/signage/src/components/app-shell.tsx b/apps/signage/src/components/app-shell.tsx
index 5400399..a9e79f2 100644
--- a/apps/signage/src/components/app-shell.tsx
+++ b/apps/signage/src/components/app-shell.tsx
@@ -1,6 +1,6 @@
'use client';
-import { MeProvider } from '@tecnova/ui/components/me-provider';
+import { MeGate, MeProvider } from '@tecnova/ui/components/me-provider';
import { usePathname } from 'next/navigation';
// サイネージは全画面表示なので checkin のようなヘッダ chrome は持たず、MeProvider だけで包む。
@@ -11,13 +11,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
return (
-
- {children}
+
+
+ {children}
+
);
}
diff --git a/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md b/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md
new file mode 100644
index 0000000..23816a2
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-02-admin-instant-shell-design.md
@@ -0,0 +1,84 @@
+# Sub-project 2: 耐障害性+即時インタラクティブシェル 設計
+
+作成日: 2026-06-02 / 親spec: [`2026-06-02-admin-data-layer-modernization-design.md`](./2026-06-02-admin-data-layer-modernization-design.md)
+ブランチ: `feat/admin-resilience-shell`(`refactor/admin-data-layer` から分岐 → PR は develop)
+
+## 0. ゴールと選択
+
+ユーザー選択 = **フル即時インタラクティブシェル**。admin のコールドロードで、サイドバー等のクロームを**即描画**し、ユーザー依存部(ロール別ナビ・アカウント名)だけスケルトンにして `/api/me` 解決後に埋める。加えて `error.tsx`(描画クラッシュの安全網)と `loading.tsx`(ナビ時のコンテンツスケルトン)を追加する。
+
+**コスト/リスクの正直な評価:** これは内部向け管理画面で「クロームが ~100–200ms 早く出る」ための変更で、`MeProvider`(**3アプリ共有**)の契約に手を入れる。便益は限定的・リスクは中。ユーザーは最小案(推奨)よりこちらを明示選択済み。後方互換を保ち、3アプリすべてを検証して安全に着地させる。
+
+## 1. 中核設計: `MeProvider`(状態のみ)+ `MeGate`(ゲート)に分離
+
+現状の `MeProvider` は「/api/me 取得 + 解決まで全体をスケルトンでゲート + ok のとき context 提供」を一手に担う。これを分離する(`packages/ui/src/components/me-provider.tsx`)。
+
+```ts
+export type MeState =
+ | { status: 'loading' }
+ | { status: 'ok'; me: Me }
+ | { status: 'forbidden'; message: string }
+ | { status: 'error'; message: string };
+```
+
+- **`MeProvider`**: /api/me を取得し、401 は `window.location.replace(loginPath)`。**常に** children を `` で包む(ゲートしない・フォールバックを描画しない)。props: `loginPath?`(既定 `/login`)。
+- **`useMeState(): MeState`**: クローム用(me が null のときも扱える)。
+- **`useMe(): Me`**: 従来どおり non-null の `Me` を返す。status が ok でなければ throw(= `MeGate` の内側でのみ使う前提。既存の content 消費者はすべてゲート内なので安全)。
+- **`MeGate`**: status==='ok' のときだけ children を描画。loading/forbidden/error は従来 `MeProvider` が持っていたフォールバックを描画。props: `forbiddenMessage?`, `loadingClassName?`, `forbiddenClassName?`, `errorClassName?`, `loadingFallback?`(任意の ReactNode。指定時は loadingClassName より優先=admin はシェル型スケルトンも渡せる)。
+
+**後方互換:** 旧 `{app}` は `{app}` に置換するだけで**完全に同じ挙動**になる。`useMe()` のシグネチャは不変。
+
+## 2. checkin / signage 移行(挙動を完全維持)
+
+両アプリは `MeProvider` を「全体ゲート」として使用(signage は `useMe` 0、checkin は settings で 1)。フォールバック系 props を `MeGate` へ移すだけ。
+
+- checkin `apps/checkin/src/components/app-shell.tsx`: `{children}`(Chrome は従来どおりゲート内=挙動不変)。
+- signage `apps/signage/src/components/app-shell.tsx`: 同様に `{children}`。
+- checkin settings の `useMe()` はゲート内なので不変。
+
+## 3. admin 即時シェル
+
+`apps/admin/src/app/(authed)/layout.tsx`:
+
+```tsx
+
+
+ {children}
+
+
+
+```
+
+- `AppShell` は**常に**描画(コールドロード中もクローム可視)。ページ本文だけ `MeGate` でゲート。
+- 以下を `useMe()` → `useMeState()` に変更し、status!=='ok' の間はスケルトン表示:
+ - `sidebar.tsx`: ナビ一覧=プレースホルダ行(ロール未確定のため実項目は出さない)、フッターのアカウント=スケルトン。ブランドロゴ(`BrandLogo`)は me 不要なので即表示。アクティブピル/layoutId は ok 後。
+ - `bottom-nav.tsx`: タブ=スケルトン(モバイル)。
+ - `mobile-top-bar.tsx`: アカウントボタン=スケルトン。ロゴは即表示。
+ - `account-menu.tsx`: me が無い間はトリガをスケルトンにし、メニュー自体は ok 後のみ。
+- ナビは `visibleNavItems(role)` がロール必須なので、ok までスケルトン → ok で実ナビに差し替え。
+- content(mentors/pre-registrations 含む)の `useMe()` は `MeGate` 内なので不変。
+
+## 4. error.tsx(描画クラッシュの安全網)
+
+- `apps/admin/src/app/(authed)/error.tsx`(`'use client'` 必須): `DataError`(SP1)を再利用+「再試行」(`reset()`)。
+- `apps/admin/src/app/error.tsx`(root, `'use client'`): 同様の最小フォールバック。
+- これは現状の per-page try/catch では拾えない**描画時 throw** を拾う。
+
+## 5. loading.tsx(ナビ時のコンテンツスケルトン)
+
+即時シェル化により layout(AppShell)はナビ間で永続するため、`(authed)/loading.tsx` はコンテンツスロットのスケルトンとして意味を持つ(ソフトナビ時に AppShell を保ったまま本文だけスケルトン)。汎用のコンテンツスケルトン(リスト/サマリ風)を 1 つ用意して充てる。
+
+## 6. 非ゴール / 注意
+
+- `cacheComponents`/PPR は無効のまま。
+- `useOptimistic` は今回見送り(別途・任意)。
+- セッション 401 の遷移は `MeProvider` に残す(副作用は 1 箇所)。
+- 検証は **3 アプリすべて**: admin(Playwright で即時シェル=ローディング中にサイドバー骨格が見える/ok で実ナビ・アカウント/forbidden・error・コンテンツ)、checkin・signage(少なくとも描画+ゲート挙動が不変なこと)。type-check は admin / @tecnova/ui / checkin / signage、biome は変更ファイル全部。
+
+## 7. ファイル構成
+
+- 変更(shared): `packages/ui/src/components/me-provider.tsx`(分離。`MeGate`/`useMeState` を追加、`useMe` は維持)
+- 変更(checkin): `apps/checkin/src/components/app-shell.tsx`
+- 変更(signage): `apps/signage/src/components/app-shell.tsx`
+- 変更(admin): `(authed)/layout.tsx`, `components/{sidebar,bottom-nav,mobile-top-bar,account-menu,app-shell}.tsx`
+- 新規(admin): `app/(authed)/error.tsx`, `app/error.tsx`, `app/(authed)/loading.tsx`(+必要ならコンテンツスケルトン)
diff --git a/packages/ui/src/components/me-provider.tsx b/packages/ui/src/components/me-provider.tsx
index 336ae17..1fba61c 100644
--- a/packages/ui/src/components/me-provider.tsx
+++ b/packages/ui/src/components/me-provider.tsx
@@ -10,50 +10,46 @@ export interface Me {
mentor: { id: string; email: string; name: string; role: 'admin' | 'mentor' };
}
-type State =
- | { kind: 'loading' }
- | { kind: 'ok'; data: Me }
- | { kind: 'forbidden'; message: string }
- | { kind: 'error'; message: string };
+export type MeState =
+ | { status: 'loading' }
+ | { status: 'ok'; me: Me }
+ | { status: 'forbidden'; message: string }
+ | { status: 'error'; message: string };
-const MeContext = createContext(null);
+const MeStateContext = createContext(null);
+// 認証状態(loading/ok/forbidden/error)を返す。me が無い間も扱えるので
+// シェル(サイドバー等)の即時描画に使う。
+export const useMeState = (): MeState => {
+ const state = useContext(MeStateContext);
+ if (!state) {
+ throw new Error('useMeState must be used inside MeProvider');
+ }
+ return state;
+};
+
+// 認証済みの Me を返す。status が ok 以外では throw するため、MeGate の内側
+// (=認証済みが保証される領域)でのみ使う。シグネチャは従来どおり。
export const useMe = (): Me => {
- const me = useContext(MeContext);
- if (!me) {
- throw new Error('useMe must be used inside MeProvider');
+ const state = useMeState();
+ if (state.status !== 'ok') {
+ throw new Error('useMe must be used inside (me is not loaded)');
}
- return me;
+ return state.me;
};
export interface MeProviderProps {
children: React.ReactNode;
// 401 を受けたときの遷移先。next/router を packages/ui に持ち込まない
- // ため、window.location.replace で素朴に遷移する。/login は別ルート
- // グループなので、フル再読み込みでも UX 差はほぼない。
+ // ため、window.location.replace で素朴に遷移する。
loginPath?: string;
- // 403 時のフォールバック文言。アプリごとに「管理画面の…」「受付アプリの…」
- // など差し替えたいので props で受け取る。
- forbiddenMessage?: string;
- // ステータス別ラッパー の className。背景色などをアプリ側で指定する。
- loadingClassName?: string;
- forbiddenClassName?: string;
- errorClassName?: string;
}
-const DEFAULT_LOADING_CLASS = 'flex flex-1 items-center justify-center p-8';
-const DEFAULT_FAILURE_CLASS =
- 'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center';
-
-export function MeProvider({
- children,
- loginPath = '/login',
- forbiddenMessage = 'アクセス権限がありません',
- loadingClassName = DEFAULT_LOADING_CLASS,
- forbiddenClassName = DEFAULT_FAILURE_CLASS,
- errorClassName = DEFAULT_FAILURE_CLASS,
-}: MeProviderProps) {
- const [state, setState] = useState({ kind: 'loading' });
+// /api/me を取得して状態を context に流すだけのプロバイダ。**ゲートはしない**
+// (常に children を描画する)。ゲートやフォールバックは MeGate が担う。
+// これにより、認証解決前でもシェルのクロームを即描画できる。
+export function MeProvider({ children, loginPath = '/login' }: MeProviderProps) {
+ const [state, setState] = useState({ status: 'loading' });
useEffect(() => {
void (async () => {
@@ -65,24 +61,56 @@ export function MeProvider({
}
if (r.status === 403) {
const body = (await r.json().catch(() => ({}))) as { message?: string };
- setState({ kind: 'forbidden', message: body.message ?? forbiddenMessage });
+ setState({ status: 'forbidden', message: body.message ?? '' });
return;
}
if (!r.ok) {
throw new Error(`HTTP ${r.status}`);
}
const data = (await r.json()) as Me;
- setState({ kind: 'ok', data });
+ setState({ status: 'ok', me: data });
} catch (e) {
- setState({
- kind: 'error',
- message: e instanceof Error ? e.message : String(e),
- });
+ setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
}
})();
- }, [loginPath, forbiddenMessage]);
+ }, [loginPath]);
+
+ return {children};
+}
- if (state.kind === 'loading') {
+export interface MeGateProps {
+ children: React.ReactNode;
+ // 403 時のフォールバック文言。アプリごとに差し替える。
+ forbiddenMessage?: string;
+ // ステータス別ラッパー の className。背景色などをアプリ側で指定する。
+ loadingClassName?: string;
+ forbiddenClassName?: string;
+ errorClassName?: string;
+ // 指定するとローディング表示を差し替える(アプリ固有のスケルトン等)。
+ loadingFallback?: React.ReactNode;
+}
+
+const DEFAULT_LOADING_CLASS = 'flex flex-1 items-center justify-center p-8';
+const DEFAULT_FAILURE_CLASS =
+ 'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center';
+
+// 認証状態でゲートする。status==='ok' のときだけ children を描画し、
+// それ以外は loading/forbidden/error のフォールバックを出す。
+// (従来 MeProvider が一手に担っていたゲート部分をここへ分離した。)
+export function MeGate({
+ children,
+ forbiddenMessage = 'アクセス権限がありません',
+ loadingClassName = DEFAULT_LOADING_CLASS,
+ forbiddenClassName = DEFAULT_FAILURE_CLASS,
+ errorClassName = DEFAULT_FAILURE_CLASS,
+ loadingFallback,
+}: MeGateProps) {
+ const state = useMeState();
+
+ if (state.status === 'loading') {
+ if (loadingFallback !== undefined) {
+ return <>{loadingFallback}>;
+ }
return (
@@ -90,18 +118,18 @@ export function MeProvider({
);
}
- if (state.kind === 'forbidden') {
+ if (state.status === 'forbidden') {
return (
アクセス権限がありません
- {state.message}
+ {state.message || forbiddenMessage}
);
}
- if (state.kind === 'error') {
+ if (state.status === 'error') {
return (
@@ -112,5 +140,5 @@ export function MeProvider({
);
}
- return {children};
+ return <>{children}>;
}