From db4c8ba97f7bfc51499b82079025581932c49158 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 26 Oct 2026 10:12:22 +0900 Subject: [PATCH] feat: add highlighted protocol views and inline operation forms Expose Rust Text View tokens and safely highlight signatures and JSON results. Add animated Interface and inline Run disclosures with a shared 10ms line cadence, typed argument inputs, and explicit execution. Update documentation and browser regressions for the new controls and explicit counter-value responses. --- README.md | 16 +- site/astro.config.mjs | 2 + site/browser/src/manual.rs | 40 ++- site/browser/src/manual/tests.rs | 106 +++++++ site/package-lock.json | 7 + site/package.json | 1 + site/playwright.dev.config.ts | 3 +- site/src/components/PlaygroundPage.astro | 10 +- site/src/components/ProtocolPanel.astro | 11 +- site/src/scripts/json-view.ts | 25 ++ site/src/scripts/operation-form.ts | 136 ++++++++ site/src/scripts/protocol.ts | 75 ++--- site/src/scripts/text-view.ts | 99 ++++++ site/src/styles/json-view.css | 24 ++ site/src/styles/protocol.css | 19 +- site/src/styles/text-view.css | 71 +++++ site/tests/browser/json-view.spec.ts | 115 +++++++ site/tests/browser/operation-form.spec.ts | 259 +++++++++++++++ site/tests/browser/playground.spec.ts | 122 ++++--- site/tests/browser/protocol.spec.ts | 74 ++++- site/tests/browser/text-view.spec.ts | 367 ++++++++++++++++++++++ 21 files changed, 1453 insertions(+), 129 deletions(-) create mode 100644 site/src/scripts/json-view.ts create mode 100644 site/src/scripts/operation-form.ts create mode 100644 site/src/scripts/text-view.ts create mode 100644 site/src/styles/json-view.css create mode 100644 site/src/styles/text-view.css create mode 100644 site/tests/browser/json-view.spec.ts create mode 100644 site/tests/browser/operation-form.spec.ts create mode 100644 site/tests/browser/text-view.spec.ts diff --git a/README.md b/README.md index 931ab2b..12544d7 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,15 @@ Worldspace path、表示用の既存識別子、Web URLは別です。たとえ 1. Protocolを開くと、Rust `wip-text-view`が生成した選択済みObjectのText Viewを表示します。ツリーで別Objectを選ぶと、そのText Viewに切り替わります。同じObjectの再選択でもObserveします。 2. 未取得のInterfaceは`? /::document`の形式で表示します。クリックするとDescriptorとInterface Text Viewを取得して`v /::document`となり、再クリックで`> /::document`に折りたたみます。取得済みの再展開はClientのfresh cacheを利用します。 -3. 展開したInterfaceのDescriptorにあるOperationを選び、引数を入力して`Execute Operation`を押します。文書の`document.read()`は引数なしなので、Argumentsは`{}`です。Playgroundの操作ではDescriptorに応じて引数を入力します。 +3. 各Operationのコメントと宣言の間にある`▶ Run`を押すと、その場に`create(name: "…")`のような引数入力フォームが展開されます。`Execute`または単一行入力でのEnterで実行します。引数なしの`document.read()`もフォームを開くだけでは呼び出さず、明示実行が必要です。フォームは再クリックまたはEscapeで閉じられます。 -Argumentsはパラメーター名からtagged ValueへのJSON recordです。たとえば`{"name":{"kind":"string","value":"example"}}`。`integer`は精度を失わない10進文字列(Protocol Integerの範囲は±9,007,199,254,740,991)、`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`を使います。入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。ClientはDescriptorに基づいて引数と操作の前提を検証します。 +文字列は引用符内の欄にそのまま入力し、整数は10進表記で入力します。UIがパラメーターごとにtagged Valueへ変換し、WASM APIには従来と同じJSON record(例:`{"name":{"kind":"string","value":"example"}}`)を渡します。`integer`はJSのNumberを経由せず文字列として渡し、Protocol Integerの範囲(±9,007,199,254,740,991)などはClientで検証します。省略可能な引数はincludeで送信を選択でき、複合型・名前付き型には個別のtagged Value JSON欄を使います。`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`です。API入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。 + +Object/Interfaceのハイライトには`wip-text-view`の`tokenize_object` / `tokenize_interface`を使います。WASMは同じtoken列から`textView`と`textViewTokens`(kind/text)を返し、UIは文字列を再解析せず、安全なテキストノードで種別を着色します。ライト/ダークのパレットは`site/src/styles/text-view.css`に定義しています。空白・改行・引用は保持し、未知の種別は無装飾、不整合やtokenなしの応答は元のplain textで表示します。 + +InterfaceとRunフォームの開閉は同じ行アニメーションを使います。展開時は上から、折りたたみ時は下から1行10msずつフェードし、高さも行数×10msで変化します。reduced-motion設定と印刷では省略します。 + +Operation結果のハイライトには[Kirari](https://github.com/vortexkit/kirari)(`@vortexlabs/kirari`)のcoreとJSON grammarだけを使い、token配列を安全なspan/text nodeへ変換します。キー・文字列・数値・真偽値・null・記号をライト/ダーク対応で色分けし、整形済みJSONの文字列は変更しません。ハイライトに失敗した場合もplain textで結果を保持します。 Object/InterfaceのText ViewとOperation結果は別領域です。結果はpath・Interface・Operation付きのtagged JSONで、本文HTMLも文字列として表示し、markupとして実行しません。対象・Interface・Operationの変更で以前の結果表示を消します。ObserveとInterface取得はClientのfresh cacheを使えますが、Operationは明示実行ごとに1回呼び出し、自動再試行しません。ページ遷移・モード往復ではsessionを保持し、再読込で新sessionになります。 @@ -68,12 +74,12 @@ Object/InterfaceのText ViewとOperation結果は別領域です。結果はpath - `collection.create(name)` / `remove(name)`:子Objectを作成・削除。名前は`[A-Za-z0-9_-]{1,32}`、最大32個。新規値は0です。 - `counter.get()` / `increment(amount)` / `reset()`:現在値の取得・増減・0へのリセット。Protocol Integerの範囲外への変更は失敗し、状態を変更しません。 -- 初期状態は`apple = 3`、`orange = 0`。値はObject名ではなく、Observeされたdescriptionをツリー横に表示します。 -- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、Call結果から子一覧や値を捏造しません。再観測に失敗しても成功済みOperationを再実行しません。 +- 初期状態は`apple = 3`、`orange = 0`。ツリーはObject名だけを表示します。`description`は`An in-memory counter.`という説明文で、現在値や機械可読データとして解釈しません。値は明示的な`counter.get()` / `increment()` / `reset()`のOperationレスポンスに表示します。 +- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、作成・削除による子一覧の変更をツリーへ反映します。Observeからカウンター値を読み取ったり、値の取得Operationを自動実行したりしません。再観測に失敗しても成功済みOperationを再実行しません。 - 子の選択は`/playground/?mode=protocol&object=%2Fplayground%2Fapple`のようなURLで保持します。ページ・履歴・Read/Protocol往復ではProtocol sessionが残り、ブラウザの再読み込みで初期化します。削除済みObjectへの履歴移動はエラーを表示し、復活させません。 - 通常Readは静的な操作説明を表示します。状態の永続化・外部送信・文書変更はありません。 -例:createのArgumentsは`{"name":{"kind":"string","value":"banana"}}`、incrementは`{"amount":{"kind":"integer","value":"2"}}`です。 +例:createの`name`欄に`banana`、incrementの`amount`欄に`2`を入力すると、それぞれ`create(name: "banana")`、`increment(amount: 2)`の呼び出しになります。 ### 実行上の境界 diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 0fc04e0..f492634 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -17,6 +17,8 @@ export default defineConfig({ site, base, trailingSlash: 'always', + // Browser tests exercise the site, not the development toolbar overlay. + devToolbar: { enabled: process.env.SITE_BROWSER_TEST !== '1' }, markdown: { processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }), shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, langAlias: { wip: 'text' } }, diff --git a/site/browser/src/manual.rs b/site/browser/src/manual.rs index d3e5bd2..f0fec67 100644 --- a/site/browser/src/manual.rs +++ b/site/browser/src/manual.rs @@ -161,10 +161,11 @@ impl BrowserSession { // Reconstruct requested coverage from fresh Client state, never from Host data. let observation = self.manual_observation(path, depth)?; let object = self.fresh_object(path)?; - let text = - wip_text_view::render_object(&object).map_err(|e| ManualError::new("render", e))?; + let tokens = + wip_text_view::tokenize_object(&object).map_err(|e| ManualError::new("render", e))?; + let text = wip_text_view::to_plain_text(&tokens); Ok( - json!({"path": path, "observation": observation, "textView": text, "cached": cached, "stats": self.manual_stats()}), + json!({"path": path, "observation": observation, "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}), ) } @@ -221,13 +222,14 @@ impl BrowserSession { .descriptor .as_ref() .ok_or_else(missing_observation)?; - let text = wip_text_view::render_interface(&wip_text_view::Interface { + let tokens = wip_text_view::tokenize_interface(&wip_text_view::Interface { reference: &reference, descriptor, }) .map_err(|e| ManualError::new("render", e))?; + let text = wip_text_view::to_plain_text(&tokens); Ok( - json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "cached": cached, "stats": self.manual_stats()}), + json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}), ) } @@ -277,6 +279,34 @@ impl BrowserSession { } } +// Display-only DTOs: preserve WIP text verbatim, not HTML or protocol identity. +fn text_view_tokens(tokens: &[wip_text_view::Token]) -> Vec { + tokens + .iter() + .map(|token| json!({"kind": text_view_token_kind(token.kind()), "text": token.text()})) + .collect() +} + +fn text_view_token_kind(kind: wip_text_view::TokenKind) -> &'static str { + use wip_text_view::TokenKind; + match kind { + TokenKind::Keyword => "keyword", + TokenKind::TypeName => "type-name", + TokenKind::OperationName => "operation-name", + TokenKind::ParameterName => "parameter-name", + TokenKind::FieldName => "field-name", + TokenKind::CaseName => "case-name", + TokenKind::String => "string", + TokenKind::Reference => "reference", + TokenKind::Documentation => "documentation", + TokenKind::Symbol => "symbol", + TokenKind::Whitespace => "whitespace", + TokenKind::Newline => "newline", + TokenKind::Indentation => "indentation", + _ => "plain", + } +} + fn missing_observation() -> ManualError { ManualError { kind: "client", diff --git a/site/browser/src/manual/tests.rs b/site/browser/src/manual/tests.rs index a519abe..1d74615 100644 --- a/site/browser/src/manual/tests.rs +++ b/site/browser/src/manual/tests.rs @@ -166,6 +166,9 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() { observed["textView"], wip_text_view::render_object(&object).unwrap() ); + assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap()); + let cached = s.manual_observe("/quoted", 0.0).unwrap(); + assert_eq!(cached["textViewTokens"], observed["textViewTokens"]); let text = observed["textView"].as_str().unwrap(); assert!(text.contains("引用 \\\"quote\\\" \\\\ space\\nnext\\tline ")); assert!(text.ends_with("}\n")); @@ -187,6 +190,17 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() { }) .unwrap() ); + assert_display_tokens( + &fetched, + &wip_text_view::tokenize_interface(&wip_text_view::Interface { + reference: &reference, + descriptor, + }) + .unwrap(), + ); + let cached = s.manual_interface("/", "document").unwrap(); + assert_eq!(cached["textViewTokens"], fetched["textViewTokens"]); + assert_eq!(fetched["reference"], reference_json(&reference)); assert_eq!(fetched["descriptor"]["operations"][0]["name"], "read"); assert_eq!( fetched["descriptor"]["operations"][0]["parameters"], @@ -194,6 +208,98 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() { ); } +fn assert_display_tokens(response: &Json, tokens: &[wip_text_view::Token]) { + let dto = response["textViewTokens"].as_array().unwrap(); + assert_eq!(dto.len(), tokens.len()); + for (entry, token) in dto.iter().zip(tokens) { + assert_eq!(entry.as_object().unwrap().len(), 2); + assert_eq!(entry["kind"], text_view_token_kind(token.kind())); + assert_eq!(entry["text"], token.text()); + } + let concatenated: String = dto + .iter() + .map(|entry| entry["text"].as_str().unwrap()) + .collect(); + assert_eq!(concatenated, response["textView"].as_str().unwrap()); +} + +#[test] +fn display_token_kinds_use_the_explicit_kebab_case_whitelist() { + use wip_text_view::TokenKind; + for (kind, expected) in [ + (TokenKind::Keyword, "keyword"), + (TokenKind::TypeName, "type-name"), + (TokenKind::OperationName, "operation-name"), + (TokenKind::ParameterName, "parameter-name"), + (TokenKind::FieldName, "field-name"), + (TokenKind::CaseName, "case-name"), + (TokenKind::String, "string"), + (TokenKind::Reference, "reference"), + (TokenKind::Documentation, "documentation"), + (TokenKind::Symbol, "symbol"), + (TokenKind::Whitespace, "whitespace"), + (TokenKind::Newline, "newline"), + (TokenKind::Indentation, "indentation"), + ] { + assert_eq!(text_view_token_kind(kind), expected); + } +} + +#[test] +fn display_tokens_preserve_quoted_markup_and_unicode_as_raw_text() { + let raw = r#""#; + let quoted = r#""""#; + let mut data: Json = serde_json::from_str(&manifest()).unwrap(); + data["nodes"][1]["title"] = json!(raw); + let mut s = BrowserSession::new(&data.to_string()).unwrap(); + let observed = s.manual_observe("/quoted", 0.0).unwrap(); + let object = s.fresh_object("/quoted").unwrap(); + assert_eq!( + observed["textView"], + wip_text_view::render_object(&object).unwrap() + ); + assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap()); + assert!( + observed["textViewTokens"] + .as_array() + .unwrap() + .contains(&json!({"kind": "string", "text": quoted})) + ); + + s.manual_interface("/", "document").unwrap(); + let reference = Worldspace::document_interface(); + let mut descriptor = s + .client + .interface(&s.session, &reference) + .unwrap() + .descriptor + .clone() + .unwrap(); + descriptor.documentation = Some(Documentation { + summary: raw.into(), + details: None, + }); + descriptor.operations[0].name = raw.into(); + let interface = wip_text_view::Interface { + reference: &reference, + descriptor: &descriptor, + }; + let tokens = wip_text_view::tokenize_interface(&interface).unwrap(); + let dto = json!({ + "textView": wip_text_view::render_interface(&interface).unwrap(), + "textViewTokens": text_view_tokens(&tokens), + }); + // JSON transport must retain literal text, never HTML entities or generated markup. + let decoded: Json = serde_json::from_str(&dto.to_string()).unwrap(); + assert_display_tokens(&decoded, &tokens); + let entries = decoded["textViewTokens"].as_array().unwrap(); + let quoted_identifier = r#"``"#; + assert!(entries.contains(&json!({"kind": "operation-name", "text": quoted_identifier}))); + assert!(entries.contains(&json!({"kind": "documentation", "text": format!("/// {quoted}")}))); + assert_eq!(descriptor.operations[0].name, raw); + assert_eq!(reference, Worldspace::document_interface()); +} + #[test] fn all_tagged_values_round_trip_without_integer_or_bytes_coercion() { let values = vec![ diff --git a/site/package-lock.json b/site/package-lock.json index 6fafd98..4dd9665 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@astrojs/markdown-remark": "7.3.2", + "@vortexlabs/kirari": "1.1.0", "animejs": "4.5.0", "astro": "7.3.8" }, @@ -1932,6 +1933,12 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vortexlabs/kirari": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vortexlabs/kirari/-/kirari-1.1.0.tgz", + "integrity": "sha512-HKM06r3Y3hrmPTOeX4r9Q1UqZMoFFwC2MU2BsXDrblPPi5Q1Ht5eDgF6b2mcGO7GKbJpoWkxXWsho7W/uCIbEA==", + "license": "MIT" + }, "node_modules/@vscode/emmet-helper": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", diff --git a/site/package.json b/site/package.json index f03b2c4..fb8932f 100644 --- a/site/package.json +++ b/site/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@astrojs/markdown-remark": "7.3.2", + "@vortexlabs/kirari": "1.1.0", "animejs": "4.5.0", "astro": "7.3.8" }, diff --git a/site/playwright.dev.config.ts b/site/playwright.dev.config.ts index deaf94b..f6c88ef 100644 --- a/site/playwright.dev.config.ts +++ b/site/playwright.dev.config.ts @@ -8,10 +8,11 @@ const baseURL = `http://127.0.0.1:${port}${base}/`; export default defineConfig({ ...preview, // Override rather than merge webServer: dev checks must not require dist/. - testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts'], + testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts', 'text-view.spec.ts', 'operation-form.spec.ts', 'json-view.spec.ts'], use: { ...preview.use, baseURL }, webServer: { command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`, + env: { SITE_BROWSER_TEST: '1' }, url: baseURL, reuseExistingServer: false, }, diff --git a/site/src/components/PlaygroundPage.astro b/site/src/components/PlaygroundPage.astro index b5ea229..8f52cb8 100644 --- a/site/src/components/PlaygroundPage.astro +++ b/site/src/components/PlaygroundPage.astro @@ -2,13 +2,13 @@ import { href } from '../lib/book'; ---

Playground

-

ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。Protocolで開くと、実際のObserveでカウンターを取得します。

+

ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。Protocolで開くと、ObserveでカウンターObjectの一覧を取得します。

-

初期状態はappleが3、orangeが0です。操作後に再Observeし、ツリーの子Objectと値を更新します。名前は1〜32文字の英数字・_・-、カウンターは最大32個です。

-

Argumentsの入力例:

-
{'{"name":{"kind":"string","value":"banana"}}'}
-
{'{"amount":{"kind":"integer","value":"2"}}'}
+

初期状態はappleが3、orangeが0です。作成・削除は再Observeでツリーに反映します。ツリーにはObject名だけを表示し、値はget()などのOperationレスポンスで確認します。名前は1〜32文字の英数字・_・-、カウンターは最大32個です。

+

Interfaceを開き、各Operationのコメントと宣言の間にある▶ Runを押すと、関数呼び出し風の引数入力欄が展開されます。文字列は引用符の内側、整数は数値の欄に入力し、ExecuteまたはEnterで実行します。

+
{'create(name: "banana")'}
+
{'increment(amount: 2)'}

状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。

diff --git a/site/src/components/ProtocolPanel.astro b/site/src/components/ProtocolPanel.astro index 33debb0..8487975 100644 --- a/site/src/components/ProtocolPanel.astro +++ b/site/src/components/ProtocolPanel.astro @@ -1,5 +1,7 @@ --- import '../styles/protocol.css'; +import '../styles/text-view.css'; +import '../styles/json-view.css'; ---