diff --git a/DESIGN.md b/DESIGN.md index c96aabf..0bccd43 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,7 +1,7 @@ --- -version: "2.0" -name: "ExamServer Open World — Playable Operations Board" -description: "Three authored visual modes for a beginner cyber range, bound by one learning hierarchy." +version: "2.1" +name: "ExamServer Open World — Calm Playable Map" +description: "A beginner cyber range that reveals complexity only after the learner asks for it." colors: primary: "#007F90" primary-hover: "#006470" @@ -67,7 +67,7 @@ components: このサイトは「説明を読む教材」ではなく、攻撃経路を自分で切り開くプレイアブルな作戦盤である。良い見た目の条件は装飾量ではない。2秒で現在地が分かり、10秒で次の仮説を比較でき、選んだ行動と発見の因果が記憶に残ることを品質基準とする。 -認知負荷はユーザーが求めた時だけ上げる。初期画面には現在の状況、現在の目標、次の主要操作だけを残し、補助情報は名称と件数が分かる「ラベル付きの引き手」として待機させる。引き手を操作した時だけ一枚のdrawerを開き、別の引き手を操作したら中身を入れ替える。 +認知負荷はユーザーが求めた時だけ上げる。初期画面には現在の状況、現在の目標、地図/仮説、次の主要操作だけを残す。補助情報への入口は、各画面に一本だけある「ツール」というラベル付きの引き手である。引いた後のdrawer内で初めて接続、事実、次の調査/ヒント、履歴の名称と件数を見せ、選んだ一種類だけ本文を表示する。 三つの見た目は配色プリセットではない。情報の意味、操作、状態を共有しながら、異なる構図・書体・境界・動きで同じ世界を演出する。 @@ -89,8 +89,9 @@ components: ### 2. Hierarchy before decoration - 常時表示: 画面名、現在の目標、世界/仮説、次の主要操作 -- 引き手: ライブ接続、確定した事実、次の調査/ヒント、最近の発見、見た目 -- 開いた時: 選択した一種類だけをdrawerへ表示する +- 引き手: 各画面に補助情報をまとめた一本の `ツール` +- Header: ブランドと一本の `メニュー`。画面移動、見た目、演習終了、公開状態は開いた後に見せる +- 開いた時: 接続、事実、次の調査/ヒント、履歴から選択した一種類だけをdrawerへ表示する - 緊急表示: 接続断や更新失敗だけは現在の作業を失わせず主画面へ通知する 見出し、選択地点、主ボタンだけが最強のコントラストを持つ。補助文、時刻、接続状態は一段静かにする。件数は「中に何があるか」を予告するために使い、進捗カードやKPI列にはしない。 @@ -133,14 +134,14 @@ components: ## Shared interaction contract -- Headerの `見た目 PLAY / OPS / FOCUS` という一つの引き手からテーマを即時切替できる。三択を初期表示しない。 +- Headerの `メニュー` を開いた後にPLAY / OPS / FOCUSを即時切替できる。画面移動、三択、演習終了を初期表示しない。 - 選択は `aria-pressed` で伝え、44px相当の操作面と明確なfocus ringを持つ。 - 選択テーマはversion付きlocalStorageへ保存する。保存不能でもPLAYで継続する。 - テーマ切替で画面、地図選択、仮説、ヒント、セッション状態を初期化しない。 - 色テーマと公開ガイドはローカルbundleだけで動く。ライブ教材イベントは同一originのクラウドAPIから受け、外部fontや第三者UI SDKは要求しない。 - 補助情報のdrawerは同時に一枚だけ開く。`Escape` と背景操作で閉じ、閉じた後は元の引き手へfocusを戻す。 - drawer内へkeyboard focusを閉じ込め、見えていない補助情報をtab順へ残さない。 -- 新しい教材イベントを受けてもdrawerを勝手に開かない。引き手の件数と主画面の目標だけを更新する。 +- 新しい教材イベントを受けてもdrawerを勝手に開かない。閉じている間は主画面の目標と進捗だけを更新し、件数はdrawerを開いた後に見せる。 ## State language @@ -166,8 +167,9 @@ components: ## Responsive contract - 主基準: 1366×768。検証: 1672×941、1280×720、360×800。 -- 900px未満でも初期画面へ補助情報を縦積みしない。引き手は横方向に短く並べ、選んだ情報だけをbottom sheetとして表示する。 -- 狭いheaderでは現在テーマだけを残し、三択は `見た目` の引き手を開いた後に表示する。 +- 900px未満でも初期画面へ補助情報を縦積みしない。一本の `ツール` だけを残し、選んだ情報をbottom sheetとして表示する。 +- 狭いheaderもブランドと `メニュー` の一段だけにし、公開状態、画面移動、テーマ、演習終了は開いた後に表示する。 +- モバイルでは選択地点と `次の一手` を地図より前へ置き、地図一覧は残りの画面内でスクロールできる。 - 横スクロール、sticky headerによるfocus隠れ、44px未満の主要操作を許さない。 ## Rejected directions diff --git a/PROJECT_CONSTITUTION.md b/PROJECT_CONSTITUTION.md index f8522e4..fd7e4a4 100644 --- a/PROJECT_CONSTITUTION.md +++ b/PROJECT_CONSTITUTION.md @@ -19,7 +19,7 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal 3. Kali BridgeはDebianの公開投影を読み、外向きHTTPSだけでクラウドへ中継する。Debianへインターネット経路を与えない。 4. 学習サイトは未接続でも公開ブリーフィングを表示し、ペアリング後は発見済み事実だけをライブ投影する。未発見の正解や完全な世界定義をブラウザへ渡さない。 5. 状況相談は事実から仮説を選ばせ、観察点、道具、操作例の順でヒントを開く。 -6. 初期画面は現在地、現在目標、次の主要操作だけを見せ、接続、事実、調査、履歴、見た目はラベル付きの引き手から一種類ずつ開く。 +6. 初期画面は現在地、現在目標、地図/仮説、次の主要操作だけを見せる。補助情報は各画面に一本だけあるラベル付きの `ツール` を引いてから、接続、事実、調査/ヒント、履歴の一種類を選んで開く。画面移動、見た目、演習終了はHeaderの一本の `メニュー` に収納する。 7. 3 footholdすべてから3 root経路へ到達でき、別セッションで別ルートを遊べる。 8. root取得後はDebianを信頼せず、外した信頼済み復旧USBからgolden imageへ戻す。 @@ -55,7 +55,7 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal 5. デュアルブート作業がWindows、EFI、実データを損なう。 6. KaliのWi-Fiと有線LANがルータ化し、隔離したDebianへ外部経路を与える。 7. ライブ接続がない通常閲覧で、故障画面または偽の進行データを表示する。 -8. 接続、事実、調査、履歴、設定を初期画面へ並べ、初心者が地図よりUIの解読に時間を使う。 +8. 接続、事実、調査、履歴、設定の入口を初期画面へ並べ、初心者が地図よりUIの解読に時間を使う。 ## Observable acceptance @@ -69,4 +69,4 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal - Kali Bridge停止時は最後の確定状態と再接続表示を残し、復帰後に欠落なく追いつく。 - 復旧USBからDebian、ラボ状態、Debian EFIをgolden stateへ戻し、Windowsが再起動できる。 - Firefox ESRの1366×768、1280×720、360pxで主要フローをキーボードだけでも完了できる。 -- 初回表示では補助パネルの本文が見えず、各ラベルと件数から必要な情報を選び、一度に一枚だけ開閉できる。 +- 初回表示では補助パネルの本文もカテゴリ一覧も見えず、一本の `ツール` を開いた後にラベルと件数から必要な情報を選び、一度に一種類だけ表示できる。 diff --git a/apps/lab-guide/src/App.test.tsx b/apps/lab-guide/src/App.test.tsx index 3a0764f..65462ed 100644 --- a/apps/lab-guide/src/App.test.tsx +++ b/apps/lab-guide/src/App.test.tsx @@ -102,16 +102,21 @@ describe("Lab guide", () => { name: "風切モータースの世界を先に歩く", }), ).toBeVisible(); - expect(screen.getByText("公開ガイド")).toBeVisible(); expect(screen.queryByLabelText(/接続コード/)).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const pairingPull = screen.getByRole("button", { - name: /^ライブ接続、6文字を開く$/, + name: "探索ツールを開く", }); expect(pairingPull).toHaveAttribute("aria-expanded", "false"); + expect(screen.getAllByRole("button", { name: /ツールを開く/ })).toHaveLength( + 1, + ); await user.click(pairingPull); - const pairingDialog = screen.getByRole("dialog", { name: "ライブ接続" }); + const pairingDialog = screen.getByRole("dialog", { name: "探索ツール" }); + expect( + within(pairingDialog).getByRole("button", { name: /^接続6文字$/ }), + ).toHaveAttribute("aria-pressed", "true"); expect(within(pairingDialog).getByLabelText(/接続コード/)).toBeVisible(); expect( within(pairingDialog).getByRole("button", { name: "ライブ接続" }), @@ -132,14 +137,14 @@ describe("Lab guide", () => { ); const pairingPull = await screen.findByRole("button", { - name: /^ライブ接続、6文字を開く$/, + name: "探索ツールを開く", }); pairingPull.focus(); await user.click(pairingPull); - const dialog = screen.getByRole("dialog", { name: "ライブ接続" }); + const dialog = screen.getByRole("dialog", { name: "探索ツール" }); const close = within(dialog).getByRole("button", { - name: "ライブ接続を閉じる", + name: "探索ツールを閉じる", }); const input = within(dialog).getByLabelText(/接続コード/); expect(screen.getAllByRole("dialog")).toHaveLength(1); @@ -155,7 +160,8 @@ describe("Lab guide", () => { expect(pairingPull).toHaveFocus(); }); - it("updates pull counts without opening a drawer on a live discovery", async () => { + it("keeps live counts stored until the user asks for the tool shelf", async () => { + const user = userEvent.setup(); render(); expect( @@ -164,7 +170,7 @@ describe("Lab guide", () => { }), ).toBeVisible(); expect( - screen.getByRole("button", { name: /^事実、0件を開く$/ }), + screen.getByRole("button", { name: "探索ツールを開く" }), ).toBeVisible(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); @@ -175,13 +181,18 @@ describe("Lab guide", () => { { timeout: 2_000 }, ), ).toBeVisible(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "探索ツールを開く" }), + ); + const toolDialog = screen.getByRole("dialog", { name: "探索ツール" }); expect( - screen.getByRole("button", { name: /^事実、1件を開く$/ }), - ).toBeVisible(); + within(toolDialog).getByRole("button", { name: /^事実1件$/ }), + ).toHaveAttribute("aria-pressed", "true"); expect( - screen.getByRole("button", { name: /^最近の発見、1件を開く$/ }), + within(toolDialog).getByRole("button", { name: /^履歴1件$/ }), ).toBeVisible(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("defaults to PLAY and changes art direction without losing the active screen", async () => { @@ -190,7 +201,7 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByLabelText("見た目 PLAY")); + await user.click(screen.getByLabelText("メニュー")); const themeGroup = screen.getByRole("group", { name: "表示テーマ" }); expect( within(themeGroup).getByRole("button", { @@ -202,8 +213,12 @@ describe("Lab guide", () => { await user.click( screen.getByRole("button", { name: "状況相談" }), ); + await user.click(screen.getByLabelText("メニュー")); + const reopenedThemeGroup = screen.getByRole("group", { + name: "表示テーマ", + }); await user.click( - within(themeGroup).getByRole("button", { + within(reopenedThemeGroup).getByRole("button", { name: "OPS ハッカー", }), ); @@ -213,11 +228,12 @@ describe("Lab guide", () => { expect( screen.getByRole("heading", { name: "次に確かめることを選ぶ" }), ).toBeVisible(); - await user.click(screen.getByLabelText("見た目 OPS")); + await user.click(screen.getByLabelText("メニュー")); expect( - within(themeGroup).getByRole("button", { - name: "OPS ハッカー", - }), + within(screen.getByRole("group", { name: "表示テーマ" })).getByRole( + "button", + { name: "OPS ハッカー" }, + ), ).toHaveAttribute("aria-pressed", "true"); window.localStorage.removeItem(THEME_STORAGE_KEY); @@ -228,8 +244,7 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); - expect(await screen.findByLabelText("見た目 FOCUS")).toBeVisible(); - await user.click(screen.getByLabelText("見た目 FOCUS")); + await user.click(await screen.findByLabelText("メニュー")); expect( screen.getByRole("button", { name: "FOCUS シンプル" }), ).toHaveAttribute("aria-pressed", "true"); @@ -325,7 +340,7 @@ describe("Lab guide", () => { ).toBeVisible(); expect(screen.queryByLabelText("見つけたflag")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "状況相談" })); + await user.click(screen.getByRole("button", { name: "次の一手" })); expect( screen.getByRole("heading", { name: "次に確かめることを選ぶ" }), @@ -346,7 +361,7 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole("button", { name: "状況相談" })); + await user.click(await screen.findByRole("button", { name: "次の一手" })); await user.click( screen.getByRole("button", { name: /公開ファイルに別の手掛かりがないか探す/, @@ -370,12 +385,12 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole("button", { name: "状況相談" })); + await user.click(await screen.findByRole("button", { name: "次の一手" })); await user.click( - screen.getByRole("button", { name: /^ヒント、3段階を開く$/ }), + screen.getByRole("button", { name: "相談ツールを開く" }), ); const hintDialog = screen.getByRole("dialog", { - name: "必要ならヒント", + name: "相談ツール", }); expect( within(hintDialog).getByText( @@ -413,11 +428,16 @@ describe("Lab guide", () => { render(); await user.click( - await screen.findByRole("button", { name: /^次の調査、/ }), + await screen.findByRole("button", { name: "探索ツールを開く" }), ); const investigationDialog = screen.getByRole("dialog", { - name: "次の調査", + name: "探索ツール", }); + await user.click( + within(investigationDialog).getByRole("button", { + name: /^次の調査\d+件$/, + }), + ); const input = within(investigationDialog).getByLabelText("見つけたflag"); await user.type(input, "manual-proof"); await user.click( @@ -442,11 +462,16 @@ describe("Lab guide", () => { render(); await user.click( - await screen.findByRole("button", { name: /^次の調査、/ }), + await screen.findByRole("button", { name: "探索ツールを開く" }), ); const investigationDialog = screen.getByRole("dialog", { - name: "次の調査", + name: "探索ツール", }); + await user.click( + within(investigationDialog).getByRole("button", { + name: /^次の調査\d+件$/, + }), + ); expect( within(investigationDialog).getByLabelText("Windowsで見つけたflag"), ).toBeVisible(); @@ -464,6 +489,7 @@ describe("Lab guide", () => { name: "中古バイク店の業務サーバを調べる", }); + await user.click(screen.getByLabelText("メニュー")); const endSession = screen.getByRole("button", { name: "演習を終了" }); await user.click(endSession); expect( @@ -482,8 +508,9 @@ describe("Lab guide", () => { await user.click(screen.getByRole("button", { name: "探索を続ける" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(endSession).toHaveFocus(); + expect(screen.getByLabelText("メニュー")).toHaveFocus(); + await user.click(screen.getByLabelText("メニュー")); await user.click(screen.getByRole("button", { name: "演習を終了" })); await user.click(screen.getByRole("button", { name: "終了する" })); expect( @@ -495,10 +522,10 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); await user.click( - await screen.findByRole("button", { name: /^事実、0件を開く$/ }), + await screen.findByRole("button", { name: "探索ツールを開く" }), ); const factsDialog = screen.getByRole("dialog", { - name: "分かっていること", + name: "探索ツール", }); expect( within(factsDialog).getByText("まだ発見はありません。"), @@ -536,10 +563,10 @@ describe("Lab guide", () => { }); await waitFor(() => expect(publish).toBeTypeOf("function")); await user.click( - screen.getByRole("button", { name: /^事実、3件を開く$/ }), + screen.getByRole("button", { name: "探索ツールを開く" }), ); const factsDialog = screen.getByRole("dialog", { - name: "分かっていること", + name: "探索ツール", }); expect(within(factsDialog).getByText("Webサイトが見える")).toBeVisible(); diff --git a/apps/lab-guide/src/components/AppHeader.tsx b/apps/lab-guide/src/components/AppHeader.tsx index 5c480a2..fdf1883 100644 --- a/apps/lab-guide/src/components/AppHeader.tsx +++ b/apps/lab-guide/src/components/AppHeader.tsx @@ -1,11 +1,13 @@ import { + IconChevronRight, IconLogout2, IconMap2, + IconMenu2, IconMessageCircleQuestion, IconPalette, IconShield, } from "@tabler/icons-react"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { THEME_OPTIONS, type ThemeMode } from "../theme"; import type { ExperienceMode, ScreenId } from "../types"; @@ -28,13 +30,30 @@ export function AppHeader({ theme, onThemeChange, }: AppHeaderProps) { - const themeMenuRef = useRef(null); + const appMenuRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); const activeTheme = THEME_OPTIONS.find((option) => option.id === theme) ?? THEME_OPTIONS[0]; + const closeMenu = () => { + appMenuRef.current?.removeAttribute("open"); + setMenuOpen(false); + }; + + const selectScreen = (screen: ScreenId) => { + onScreenChange(screen); + closeMenu(); + }; + const selectTheme = (nextTheme: ThemeMode) => { onThemeChange(nextTheme); - themeMenuRef.current?.removeAttribute("open"); + closeMenu(); + }; + + const endSession = () => { + closeMenu(); + appMenuRef.current?.querySelector("summary")?.focus(); + onEnd(); }; return ( @@ -43,40 +62,14 @@ export function AppHeader({