Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -67,7 +67,7 @@ components:

このサイトは「説明を読む教材」ではなく、攻撃経路を自分で切り開くプレイアブルな作戦盤である。良い見た目の条件は装飾量ではない。2秒で現在地が分かり、10秒で次の仮説を比較でき、選んだ行動と発見の因果が記憶に残ることを品質基準とする。

認知負荷はユーザーが求めた時だけ上げる。初期画面には現在の状況、現在の目標、次の主要操作だけを残し、補助情報は名称と件数が分かる「ラベル付きの引き手」として待機させる。引き手を操作した時だけ一枚のdrawerを開き、別の引き手を操作したら中身を入れ替える
認知負荷はユーザーが求めた時だけ上げる。初期画面には現在の状況、現在の目標、地図/仮説、次の主要操作だけを残す。補助情報への入口は、各画面に一本だけある「ツール」というラベル付きの引き手である。引いた後のdrawer内で初めて接続、事実、次の調査/ヒント、履歴の名称と件数を見せ、選んだ一種類だけ本文を表示する

三つの見た目は配色プリセットではない。情報の意味、操作、状態を共有しながら、異なる構図・書体・境界・動きで同じ世界を演出する。

Expand All @@ -89,8 +89,9 @@ components:
### 2. Hierarchy before decoration

- 常時表示: 画面名、現在の目標、世界/仮説、次の主要操作
- 引き手: ライブ接続、確定した事実、次の調査/ヒント、最近の発見、見た目
- 開いた時: 選択した一種類だけをdrawerへ表示する
- 引き手: 各画面に補助情報をまとめた一本の `ツール`
- Header: ブランドと一本の `メニュー`。画面移動、見た目、演習終了、公開状態は開いた後に見せる
- 開いた時: 接続、事実、次の調査/ヒント、履歴から選択した一種類だけをdrawerへ表示する
- 緊急表示: 接続断や更新失敗だけは現在の作業を失わせず主画面へ通知する

見出し、選択地点、主ボタンだけが最強のコントラストを持つ。補助文、時刻、接続状態は一段静かにする。件数は「中に何があるか」を予告するために使い、進捗カードやKPI列にはしない。
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions PROJECT_CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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へ戻す。

Expand Down Expand Up @@ -55,7 +55,7 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal
5. デュアルブート作業がWindows、EFI、実データを損なう。
6. KaliのWi-Fiと有線LANがルータ化し、隔離したDebianへ外部経路を与える。
7. ライブ接続がない通常閲覧で、故障画面または偽の進行データを表示する。
8. 接続、事実、調査、履歴、設定を初期画面へ並べ、初心者が地図よりUIの解読に時間を使う。
8. 接続、事実、調査、履歴、設定の入口を初期画面へ並べ、初心者が地図よりUIの解読に時間を使う。

## Observable acceptance

Expand All @@ -69,4 +69,4 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal
- Kali Bridge停止時は最後の確定状態と再接続表示を残し、復帰後に欠落なく追いつく。
- 復旧USBからDebian、ラボ状態、Debian EFIをgolden stateへ戻し、Windowsが再起動できる。
- Firefox ESRの1366×768、1280×720、360pxで主要フローをキーボードだけでも完了できる。
- 初回表示では補助パネルの本文が見えず、各ラベルと件数から必要な情報を選び、一度に一枚だけ開閉できる
- 初回表示では補助パネルの本文もカテゴリ一覧も見えず、一本の `ツール` を開いた後にラベルと件数から必要な情報を選び、一度に一種類だけ表示できる
95 changes: 61 additions & 34 deletions apps/lab-guide/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "ライブ接続" }),
Expand All @@ -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);
Expand All @@ -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(<App client={createFixtureClient("transition")} />);

expect(
Expand All @@ -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();

Expand All @@ -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 () => {
Expand All @@ -190,7 +201,7 @@ describe("Lab guide", () => {
const user = userEvent.setup();
render(<App client={createFixtureClient("live")} />);

await user.click(screen.getByLabelText("見た目 PLAY"));
await user.click(screen.getByLabelText("メニュー"));
const themeGroup = screen.getByRole("group", { name: "表示テーマ" });
expect(
within(themeGroup).getByRole("button", {
Expand All @@ -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 ハッカー",
}),
);
Expand All @@ -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);
Expand All @@ -228,8 +244,7 @@ describe("Lab guide", () => {
const user = userEvent.setup();
render(<App client={createFixtureClient("live")} />);

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");
Expand Down Expand Up @@ -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: "次に確かめることを選ぶ" }),
Expand All @@ -346,7 +361,7 @@ describe("Lab guide", () => {
const user = userEvent.setup();
render(<App client={createFixtureClient("live")} />);

await user.click(await screen.findByRole("button", { name: "状況相談" }));
await user.click(await screen.findByRole("button", { name: "次の一手" }));
await user.click(
screen.getByRole("button", {
name: /公開ファイルに別の手掛かりがないか探す/,
Expand All @@ -370,12 +385,12 @@ describe("Lab guide", () => {
const user = userEvent.setup();
render(<App client={createFixtureClient("live")} />);

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(
Expand Down Expand Up @@ -413,11 +428,16 @@ describe("Lab guide", () => {
render(<App client={createFixtureClient("unavailable")} />);

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(
Expand All @@ -442,11 +462,16 @@ describe("Lab guide", () => {
render(<App client={createFixtureClient("success")} />);

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();
Expand All @@ -464,6 +489,7 @@ describe("Lab guide", () => {
name: "中古バイク店の業務サーバを調べる",
});

await user.click(screen.getByLabelText("メニュー"));
const endSession = screen.getByRole("button", { name: "演習を終了" });
await user.click(endSession);
expect(
Expand All @@ -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(
Expand All @@ -495,10 +522,10 @@ describe("Lab guide", () => {
const user = userEvent.setup();
render(<App client={createFixtureClient("empty")} />);
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("まだ発見はありません。"),
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading