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
61 changes: 59 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
version: "2.1"
version: "2.3"
name: "ExamServer Open World — Calm Playable Map"
description: "A beginner cyber range that reveals complexity only after the learner asks for it."
colors:
Expand Down Expand Up @@ -59,6 +59,48 @@ components:
textColor: "{colors.text}"
rounded: "{rounded.sm}"
height: "44px"
missionStage:
breakpoint: "900px"
typography:
state: "0.4375rem"
micro: "0.5rem"
detail: "0.5625rem"
action: "0.625rem"
objective-mobile: "0.6875rem"
compact: "0.75rem"
objective-desktop: "0.8125rem"
node-mobile: "0.875rem"
node-desktop: "0.9375rem"
display-desktop: "clamp(30px, 3.25vw, 46px)"
display-tablet: "clamp(23px, 6.5vw, 30px)"
display-mobile: "clamp(22px, 6.4vw, 27px)"
geometry:
control: "44px"
action: "46px"
node-desktop: "220px × 90px"
unknown-desktop: "152px × 68px"
node-mobile: "66px"
selected-mobile: "78px"
route-grid-play: "42px"
route-grid-ops: "40px"
route-grid-focus: "44px"
palettes:
play:
routeInk: "#171611"
selectedBackground: "#11110E"
unknown: "#777166"
possibleEdge: "#716C62"
ops:
nodeBorder: "#31545B"
unknown: "#49636A"
routeEdge: "#71DDC4"
possibleEdge: "#879893"
focus:
unknown: "#817E75"
routeEdge: "#4B4944"
alphaLayers:
texture: [0.022, 0.028, 0.03, 0.04, 0.05, 0.07, 0.08]
shadow: [0.12, 0.18, 0.26, 0.28, 0.34, 0.35, 0.42]
---

# ExamServer Open World Design Contract
Expand All @@ -71,6 +113,18 @@ components:

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

### Mission stage refinement

簡素化後の初期画面を、見出し、目標、地図、選択地点が別々の帯に分かれた管理画面にしない。これらは一枚の `mission stage` として読み、上から `mission / objective / route / action` の一方向へ因果をつなぐ。

- mission: 画面種別と世界名を短く示す。長い説明文や独立したheroを置かない。
- objective: mission直下の一文として置き、カード化しない。
- route: 最大面積を渡し、ノード群が横幅の65%以上を使う。作業面全体を囲うだけの矩形を使わない。
- action: 選択地点と `次の一手` を一つの操作単位にする。地図と無関係な全幅footerに見せない。
- tools: 一本の引き手をmission stageの縁へ接続し、独立したボタンが空中に浮いたように見せない。

最初の10秒は「風切モータースの準備経路が見える → Debian標的が選択中だと分かる → 次の一手を押せる」の順に進む。説明を探す、凡例を読む、メニューを開く行為は含めない。

## Visual targets

- `docs/design/themes/play-mission-deck.png` — PLAY / Mission Deck
Expand Down Expand Up @@ -169,14 +223,17 @@ components:
- 主基準: 1366×768。検証: 1672×941、1280×720、360×800。
- 900px未満でも初期画面へ補助情報を縦積みしない。一本の `ツール` だけを残し、選んだ情報をbottom sheetとして表示する。
- 狭いheaderもブランドと `メニュー` の一段だけにし、公開状態、画面移動、テーマ、演習終了は開いた後に表示する。
- モバイルでは選択地点と `次の一手` を地図より前へ置き、地図一覧は残りの画面内でスクロールできる
- モバイルでは地図を一本の縦経路として始め、選択地点と `次の一手` を同じ行へ統合する。初期画面内に先頭二地点と選択地点を入れ、残りの経路だけを作業面内でスクロールさせる
- 横スクロール、sticky headerによるfocus隠れ、44px未満の主要操作を許さない。

## Rejected directions

- generic dashboard、KPI、ranking、streak、trophy、card soup
- 事実、行動、履歴、接続、設定をすべて常時表示するcontrol-room layout
- glassmorphism、巨大な角丸container、無意味なgradient
- 見出し、目標、地図、選択地点をそれぞれ独立した帯やcardへ分割すること
- 巨大な矩形の中央へ小さなgraphをfitさせ、空白を作業面に見せること
- mission/mapのcomponent規則へ色やfont-sizeを直書きすること。`missionStage` のsemantic token宣言を唯一の入口にする
- fake terminal、code rain、stock hacker photography
- chat人格、自由文AI prompt、仮説より先に完全commandを見せること
- runtimeで生成画像を背景として読み込み、offline bundleを重くすること
33 changes: 33 additions & 0 deletions TASK_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,36 @@
- Automatic detection is event-based and privacy-bounded, not full command monitoring.
- Live mode requires Kali internet access while Debian remains isolated. Fully offline sessions use the local telemetry API without Vercel.
- 旧三列レイアウトとモバイルでの全パネル縦積みは廃止し、desktopはside drawer、narrow画面はbottom sheetへ統一する。

## UI refinement contract — 2026-07-29

### Goal

- User request: 整理後も微妙に見えるUIを、情報量を戻さず完成品らしい作戦盤へ仕上げる。
- Real problem being solved: 見出し、目標、地図、選択地点が別々の帯として並び、中央の広い枠に小さなノードが浮くため、簡素だが弱い画面に見える。
- Core mechanism affected: 発見した経路が一本につながり、次の一手を自分で選ぶ瞬間。

### Approach

- Chosen approach: `mission / route / action` を一枚のステージへ再構成し、地図ノードと経路を拡大する。補助情報は既存の一本のツールに残す。
- Rejected alternative 1: 画像heroや装飾カードを追加する。地図より装飾が主役になり、認知負荷を戻すため。
- Rejected alternative 2: 旧Mission Deckの三列・下段イベント構成へ戻す。情報は豊かでも、最初の判断が散るため。
- Why this is coherent: 新しい機能や状態を増やさず、既存の因果関係だけを視覚的に強くする。

### Change shape

- Add: 地図上の段階ラベル、選択地点とCTAを一体化する視覚構造、控えめな座標/紙面テクスチャ。
- Remove: 地図全体を囲う重い矩形、別帯として重複する見出し・目標・選択地点の境界、過剰な空白。
- Merge: 見出しと目標をmission headerへ、選択地点と主CTAをroute actionへ統合する。
- Rename: UI上の主要操作名は変更しない。
- Leave unchanged: データモデル、公開/ライブ状態、drawer、テーマ切替、focus管理、API、安全境界。

### Observable acceptance

- 1280×720の初見5秒で「準備経路を見て、選択地点から次の一手へ進む画面」と説明できる。
- 1280×720で地図ノード群が作業面の横幅の65%以上を使い、巨大な空の枠に見えない。
- 初期表示の強い面は地図ステージと主CTAだけで、補助カテゴリ名や本文を表示しない。
- 390×844でheader、mission、現在目標、選択地点、主CTA、地図の最初の2地点が初期画面に入る。
- PLAY、OPS、FOCUSは同じ情報順と操作を保ち、色を外しても選択中/発見済み/未発見を区別できる。
- 主要操作は44px以上、横スクロールなし、keyboard、Escape、focus return、reduced motionを維持する。
- 新規runtime依存、外部font、背景写真、偽データを追加しない。
5 changes: 5 additions & 0 deletions apps/lab-guide/.openai/hosting.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a6802b720d48191864459449afa456a",
"d1": "DB",
"r2": null
}
75 changes: 71 additions & 4 deletions apps/lab-guide/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ManualFlagForm } from "./components/ManualFlagForm";
import {
fallbackPositionFor,
WORLD_POSITIONS,
worldEdgeFor,
} from "./components/MapCanvas";
import { SituationConsultation } from "./components/SituationConsultation";
import { createFixtureClient } from "./fixtures";
Expand Down Expand Up @@ -99,7 +100,7 @@ describe("Lab guide", () => {

expect(
await screen.findByRole("heading", {
name: "風切モータースの世界を先に歩く",
name: "風切モータースへ接続する",
}),
).toBeVisible();
expect(screen.queryByLabelText(/接続コード/)).not.toBeInTheDocument();
Expand Down Expand Up @@ -287,6 +288,72 @@ describe("Lab guide", () => {
expect(subset[3]).not.toEqual(fallbackPositionFor("map-04"));
});

it("keeps desktop map selection keyboard-operable without focusable graph chrome", async () => {
const user = userEvent.setup();
render(
<App
client={createBrowseClient()}
initialExperience="browse"
/>,
);

const desktopNode = (id: string) => {
const node = screen
.getByTestId(`rf__node-${id}`)
.querySelector("button.world-node");
if (!(node instanceof HTMLButtonElement)) {
throw new Error(`desktop world node ${id} is not a button`);
}
return node;
};

const nodeWrapper = await screen.findByTestId("rf__node-browse-kali");
const kaliNode = desktopNode("browse-kali");
const targetNode = desktopNode("browse-target");
const nextAction = screen.getByTestId("next-action-map");

expect(kaliNode).toHaveAttribute(
"aria-label",
"Kali、観察と攻撃を行う側",
);
expect(kaliNode).toHaveAttribute("aria-pressed", "false");
expect(targetNode).toHaveAttribute("aria-pressed", "true");
expect(nodeWrapper).not.toHaveAttribute("tabindex");
expect(nodeWrapper).not.toHaveAttribute("role");
expect(kaliNode.querySelector("button")).toBeNull();
expect(
worldEdgeFor({
id: "keyboard-route",
from: "browse-kali",
to: "browse-link",
state: "known",
}),
).toMatchObject({
selectable: false,
focusable: false,
deletable: false,
});
expect(nextAction.closest("button")).toBe(nextAction);
expect(nextAction.closest(".world-node")).toBeNull();

kaliNode.focus();
await user.keyboard("{Enter}");

await waitFor(() =>
expect(desktopNode("browse-kali")).toHaveAttribute(
"aria-pressed",
"true",
),
);
expect(desktopNode("browse-target")).toHaveAttribute(
"aria-pressed",
"false",
);
expect(screen.getByTestId("next-action-map").closest("button")).toBe(
screen.getByTestId("next-action-map"),
);
});

it("exercises all fourteen telemetry map IDs in the success fixture", async () => {
const projection = await createFixtureClient("success").getState();
expect(projection.graph.nodes.map((node) => node.id)).toEqual(
Expand Down Expand Up @@ -340,7 +407,7 @@ describe("Lab guide", () => {
).toBeVisible();
expect(screen.queryByLabelText("見つけたflag")).not.toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "次の一手" }));
await user.click(screen.getByTestId("next-action-map"));

expect(
screen.getByRole("heading", { name: "次に確かめることを選ぶ" }),
Expand All @@ -361,7 +428,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.findByTestId("next-action-map"));
await user.click(
screen.getByRole("button", {
name: /公開ファイルに別の手掛かりがないか探す/,
Expand All @@ -385,7 +452,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.findByTestId("next-action-map"));
await user.click(
screen.getByRole("button", { name: "相談ツールを開く" }),
);
Expand Down
14 changes: 7 additions & 7 deletions apps/lab-guide/src/browse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const browseProjection: LabProjection = {
sessionId: "public-guide",
revision: 0,
status: "active",
heading: "風切モータースの世界を先に歩く",
heading: "風切モータースへ接続する",
lede:
"標的がなくても遊び方を確認できます。演習では発見に合わせて、この地図が変化します。",
objective: "必要な機材と安全な接続範囲を理解する",
Expand Down Expand Up @@ -89,44 +89,44 @@ const browseProjection: LabProjection = {
detail: "観察と攻撃を行う側",
icon: "terminal",
state: "discovered",
position: { x: 40, y: 260 },
position: { x: 30, y: 205 },
},
{
id: "browse-link",
label: "直結Ethernet",
detail: "演習専用の有線経路",
icon: "network",
state: "discovered",
position: { x: 285, y: 260 },
position: { x: 300, y: 205 },
},
{
id: "browse-target",
label: "Debian標的",
detail: "風切モータースの業務環境",
icon: "server",
state: "selected",
position: { x: 535, y: 260 },
position: { x: 590, y: 205 },
},
{
id: "browse-unknown-a",
label: "未発見",
icon: "file",
state: "undiscovered",
position: { x: 190, y: 70 },
position: { x: 980, y: 35 },
},
{
id: "browse-unknown-b",
label: "未発見",
icon: "file",
state: "undiscovered",
position: { x: 430, y: 70 },
position: { x: 980, y: 205 },
},
{
id: "browse-unknown-c",
label: "未発見",
icon: "file",
state: "undiscovered",
position: { x: 430, y: 455 },
position: { x: 980, y: 375 },
},
],
edges: [
Expand Down
Loading
Loading