Skip to content

Commit 42e94db

Browse files
montfortclaude
andauthored
feat(loom): loom-0.3.0 — Loom M3: rich UI (deltas, cycles, centrality, search/pin/open, i18n) (T3.1–T3.5) (#243)
Completes CHARTER-01-loom-server M3 (Spec 001 §3.3, NFR2, NFR5), keeping Loom loopback-only and read-only: - T3.1 Incremental rebuild: mtime-keyed parse cache + WS `delta` events; the SPA patches the graph in place, preserving layout for unchanged documents. Initial WS sync stays a full `rebuild`. - T3.2 Cycle/SCC reporting: core `cycles_in` (Tarjan) over resolved semantic edges (SUPERSEDES, ORIGINATES_FROM); surfaced in /api/stats + stats panel. - T3.3 Centrality sizing: header selector — betweenness (default), pagerank, degree. - T3.4 Search (camera focus), pin subgraph, and VS Code/Cursor deep-links + copy-path (client-side; server gains no capability). - T3.5 UI i18n (en/es/zh-CN) driven by the project's configured language; resolution moved into straymark-core (CLI delegates), served at GET /api/meta. Bumps straymark-loom + web to 0.3.0 and straymark-core to 0.3.0 (additive API; CLI core dep updated to match). T3.6 (release tag) stays open until the post-merge loom-0.3.0 tag. See AILOG-2026-06-13-001. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf11e48 commit 42e94db

28 files changed

Lines changed: 1559 additions & 246 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## Loom 0.3.0 — M3 rich UI
11+
12+
Completes Loom M3 (`CHARTER-01-loom-server`): the analytical dashboard becomes a rich
13+
exploration tool while remaining loopback-only and read-only.
14+
15+
### Added (Loom)
16+
17+
- Incremental rebuild with WebSocket `delta` events: a parse cache re-parses only changed
18+
files and the SPA patches the graph in place, preserving layout for unchanged documents.
19+
- Dependency-cycle (SCC) reporting over the resolved semantic edges (`SUPERSEDES`,
20+
`ORIGINATES_FROM`), surfaced in `/api/stats` and the stats panel.
21+
- Centrality-based node sizing with a selector (Betweenness — default, PageRank, Degree).
22+
- Search with camera focus, "Pin subgraph" to isolate a thread, and VS Code / Cursor
23+
deep-links plus copy-path in the node panel (client-side; the server stays read-only).
24+
- UI internationalization (`en` / `es` / `zh-CN`) driven by the project's configured
25+
language, served at the new `GET /api/meta` endpoint.
26+
27+
### Changed (Loom)
28+
29+
- Project language resolution moved into `straymark-core` (`core::config`); the CLI now
30+
delegates to it, so the CLI and Loom share one source of truth (`straymark-core` → 0.3.0).
31+
32+
---
33+
1034
## Loom 0.2.0 — M2 analytics and panels
1135

1236
Completes Loom M2 (`CHARTER-01-loom-server`): the walking skeleton becomes an analytical

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ StrayMark uses independent version tags for each component:
279279
| --- | --- | --- | --- |
280280
| Framework | `fw-` | `fw-4.26.0` | Templates (12 types), governance, directives, Charter template + schema |
281281
| CLI | `cli-` | `cli-3.24.0` | The `straymark` binary |
282-
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.2.0` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` |
282+
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.3.0` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` |
283283

284284
Check installed versions with `straymark status` or `straymark about`.
285285

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ name = "straymark"
1717
path = "src/main.rs"
1818

1919
[dependencies]
20-
straymark-core = { version = "0.2.0", path = "../core" }
20+
straymark-core = { version = "0.3.0", path = "../core" }
2121
clap = { version = "4", features = ["derive"] }
2222
reqwest = { version = "0.12", features = ["blocking", "rustls-tls", "json"] }
2323
serde = { version = "1", features = ["derive"] }

cli/src/config.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,9 @@ impl StrayMarkConfig {
122122
/// `straymark new`, and `straymark status` so they all agree on which
123123
/// language to use.
124124
pub fn resolve_language(project_root: &Path) -> String {
125-
let config_path = project_root.join(".straymark/config.yml");
126-
if config_path.exists() {
127-
return Self::load(project_root)
128-
.map(|c| c.language)
129-
.unwrap_or_else(|_| default_language());
130-
}
131-
crate::utils::detect_os_locale().unwrap_or_else(default_language)
125+
// Single source of truth shared with Loom: `straymark-core` owns the
126+
// resolution logic (config `language` key → OS locale → "en").
127+
straymark_core::config::resolve_language(project_root)
132128
}
133129
}
134130

cli/src/utils.rs

Lines changed: 0 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -104,40 +104,6 @@ pub fn resolve_project_root(path: &str) -> Option<ResolvedPath> {
104104
None
105105
}
106106

107-
/// Read `$LC_ALL` (preferred when set) or `$LANG` and map a POSIX locale
108-
/// string like `zh_CN.UTF-8` or `es_MX` to one of the languages StrayMark
109-
/// supports (`en`, `es`, `zh-CN`). Returns `None` when no env var is set
110-
/// or when the territory points at an unsupported variant (e.g.,
111-
/// Traditional Chinese in `zh_TW` / `zh_HK`). Callers fall back to `"en"`.
112-
pub fn detect_os_locale() -> Option<String> {
113-
let raw = std::env::var("LC_ALL")
114-
.ok()
115-
.filter(|v| !v.is_empty())
116-
.or_else(|| std::env::var("LANG").ok().filter(|v| !v.is_empty()))?;
117-
parse_posix_locale(&raw)
118-
}
119-
120-
/// Parse a POSIX locale string (e.g. `zh_CN.UTF-8`, `es`, `C`) and map it
121-
/// to a StrayMark-supported language code. Public for unit testing.
122-
pub fn parse_posix_locale(raw: &str) -> Option<String> {
123-
// Strip charset (`.UTF-8`) and modifier (`@euro`) suffixes first.
124-
let trimmed = raw.split('.').next()?.split('@').next()?;
125-
if trimmed.is_empty() {
126-
return None;
127-
}
128-
let mut parts = trimmed.splitn(2, '_');
129-
let lang = parts.next()?;
130-
let territory = parts.next();
131-
match (lang, territory) {
132-
("zh", Some("CN")) | ("zh", Some("SG")) | ("zh", None) => Some("zh-CN".to_string()),
133-
// Traditional Chinese (TW / HK / MO) — StrayMark only ships zh-CN.
134-
("zh", _) => None,
135-
("es", _) => Some("es".to_string()),
136-
("en", _) | ("C", _) | ("POSIX", _) => Some("en".to_string()),
137-
_ => None,
138-
}
139-
}
140-
141107
/// Resolve `<dir>/<filename>` honoring an optional translation under
142108
/// `<dir>/i18n/<lang>/<filename>`. When `lang` is `"en"` (or any value where
143109
/// the localized variant is absent), returns the root path unchanged. This is
@@ -325,54 +291,6 @@ mod tests {
325291
assert_eq!(resolved, dir.join("FOO.md"));
326292
}
327293

328-
#[test]
329-
fn parse_posix_locale_zh_cn() {
330-
assert_eq!(parse_posix_locale("zh_CN.UTF-8"), Some("zh-CN".into()));
331-
assert_eq!(parse_posix_locale("zh_CN"), Some("zh-CN".into()));
332-
assert_eq!(parse_posix_locale("zh_SG.UTF-8"), Some("zh-CN".into()));
333-
// Bare "zh" with no territory: assume Simplified.
334-
assert_eq!(parse_posix_locale("zh"), Some("zh-CN".into()));
335-
}
336-
337-
#[test]
338-
fn parse_posix_locale_traditional_chinese_unsupported() {
339-
// We don't ship Traditional translations — those should fall back
340-
// through to "en" via the caller's None handling, not silently
341-
// claim Simplified.
342-
assert_eq!(parse_posix_locale("zh_TW.UTF-8"), None);
343-
assert_eq!(parse_posix_locale("zh_HK.UTF-8"), None);
344-
}
345-
346-
#[test]
347-
fn parse_posix_locale_spanish_any_territory() {
348-
assert_eq!(parse_posix_locale("es_MX.UTF-8"), Some("es".into()));
349-
assert_eq!(parse_posix_locale("es_ES"), Some("es".into()));
350-
assert_eq!(parse_posix_locale("es_AR.UTF-8"), Some("es".into()));
351-
}
352-
353-
#[test]
354-
fn parse_posix_locale_english_and_pseudo() {
355-
assert_eq!(parse_posix_locale("en_US.UTF-8"), Some("en".into()));
356-
assert_eq!(parse_posix_locale("en"), Some("en".into()));
357-
assert_eq!(parse_posix_locale("C"), Some("en".into()));
358-
assert_eq!(parse_posix_locale("POSIX"), Some("en".into()));
359-
}
360-
361-
#[test]
362-
fn parse_posix_locale_unsupported_returns_none() {
363-
// French isn't translated yet; caller must fall back to "en".
364-
assert_eq!(parse_posix_locale("fr_FR.UTF-8"), None);
365-
assert_eq!(parse_posix_locale("ja_JP.UTF-8"), None);
366-
assert_eq!(parse_posix_locale(""), None);
367-
}
368-
369-
#[test]
370-
fn parse_posix_locale_strips_charset_and_modifier() {
371-
// `@modifier` after the charset (or before it) appears in some
372-
// locales; we strip whatever follows the first `@` or `.`.
373-
assert_eq!(parse_posix_locale("es_ES@euro"), Some("es".into()));
374-
}
375-
376294
#[test]
377295
fn resolve_localized_path_for_english_skips_lookup() {
378296
let tmp = tempfile::TempDir::new().unwrap();

core/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "straymark-core"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
description = "Shared document model and knowledge graph for StrayMark — parses governance documents and builds their typed traceability graph"
66
license = "MIT"
@@ -15,3 +15,6 @@ authors = ["Strange Days Tech, S.A.S."]
1515
anyhow = "1"
1616
serde = { version = "1", features = ["derive"] }
1717
serde_yaml = "0.9"
18+
19+
[dev-dependencies]
20+
tempfile = "3"

core/src/config.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Project language resolution, shared by the CLI and Loom so both agree on
2+
//! which of StrayMark's supported locales (`en`, `es`, `zh-CN`) to display.
3+
//!
4+
//! Extracted into `straymark-core` in Loom M3: the CLI's
5+
//! `StrayMarkConfig::resolve_language` delegates here, and Loom calls
6+
//! [`resolve_language`] directly to localize its UI — one source of truth, no
7+
//! drift (the same principle as the M0 parser extraction).
8+
9+
use std::path::Path;
10+
11+
use serde::Deserialize;
12+
13+
/// The default StrayMark UI language when nothing else resolves.
14+
pub const DEFAULT_LANGUAGE: &str = "en";
15+
16+
/// Just the `language` key of `.straymark/config.yml`; the full config struct
17+
/// (complexity, regional scope, …) stays in the CLI.
18+
#[derive(Debug, Deserialize)]
19+
struct LanguageOnly {
20+
#[serde(default)]
21+
language: Option<String>,
22+
}
23+
24+
/// Resolve the effective display language for a project, applying all
25+
/// fallbacks in order:
26+
///
27+
/// 1. If `.straymark/config.yml` exists on disk, the value of its `language`
28+
/// key (defaulting to `"en"` when the field is absent or the file fails to
29+
/// parse). A configured value — even the default `"en"` — is treated as an
30+
/// explicit choice and is never overridden by env vars.
31+
/// 2. If no config file exists, parse `$LC_ALL` / `$LANG` and map it onto a
32+
/// supported locale (`en`, `es`, `zh-CN`).
33+
/// 3. Final fallback: `"en"`.
34+
pub fn resolve_language(project_root: &Path) -> String {
35+
let config_path = project_root.join(".straymark/config.yml");
36+
if config_path.exists() {
37+
return std::fs::read_to_string(&config_path)
38+
.ok()
39+
.and_then(|contents| serde_yaml::from_str::<LanguageOnly>(&contents).ok())
40+
.and_then(|c| c.language)
41+
.unwrap_or_else(|| DEFAULT_LANGUAGE.to_string());
42+
}
43+
detect_os_locale().unwrap_or_else(|| DEFAULT_LANGUAGE.to_string())
44+
}
45+
46+
/// Read `$LC_ALL` (preferred when set) or `$LANG` and map a POSIX locale
47+
/// string like `zh_CN.UTF-8` or `es_MX` to one of the languages StrayMark
48+
/// supports (`en`, `es`, `zh-CN`). Returns `None` when no env var is set
49+
/// or when the territory points at an unsupported variant (e.g.,
50+
/// Traditional Chinese in `zh_TW` / `zh_HK`). Callers fall back to `"en"`.
51+
pub fn detect_os_locale() -> Option<String> {
52+
let raw = std::env::var("LC_ALL")
53+
.ok()
54+
.filter(|v| !v.is_empty())
55+
.or_else(|| std::env::var("LANG").ok().filter(|v| !v.is_empty()))?;
56+
parse_posix_locale(&raw)
57+
}
58+
59+
/// Parse a POSIX locale string (e.g. `zh_CN.UTF-8`, `es`, `C`) and map it
60+
/// to a StrayMark-supported language code. Public for unit testing.
61+
pub fn parse_posix_locale(raw: &str) -> Option<String> {
62+
// Strip charset (`.UTF-8`) and modifier (`@euro`) suffixes first.
63+
let trimmed = raw.split('.').next()?.split('@').next()?;
64+
if trimmed.is_empty() {
65+
return None;
66+
}
67+
let mut parts = trimmed.splitn(2, '_');
68+
let lang = parts.next()?;
69+
let territory = parts.next();
70+
match (lang, territory) {
71+
("zh", Some("CN")) | ("zh", Some("SG")) | ("zh", None) => Some("zh-CN".to_string()),
72+
// Traditional Chinese (TW / HK / MO) — StrayMark only ships zh-CN.
73+
("zh", _) => None,
74+
("es", _) => Some("es".to_string()),
75+
("en", _) | ("C", _) | ("POSIX", _) => Some("en".to_string()),
76+
_ => None,
77+
}
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
84+
#[test]
85+
fn parse_posix_locale_zh_cn() {
86+
assert_eq!(parse_posix_locale("zh_CN.UTF-8"), Some("zh-CN".into()));
87+
assert_eq!(parse_posix_locale("zh_CN"), Some("zh-CN".into()));
88+
assert_eq!(parse_posix_locale("zh_SG.UTF-8"), Some("zh-CN".into()));
89+
// Bare "zh" with no territory: assume Simplified.
90+
assert_eq!(parse_posix_locale("zh"), Some("zh-CN".into()));
91+
}
92+
93+
#[test]
94+
fn parse_posix_locale_traditional_chinese_unsupported() {
95+
assert_eq!(parse_posix_locale("zh_TW.UTF-8"), None);
96+
assert_eq!(parse_posix_locale("zh_HK.UTF-8"), None);
97+
}
98+
99+
#[test]
100+
fn parse_posix_locale_spanish_any_territory() {
101+
assert_eq!(parse_posix_locale("es_MX.UTF-8"), Some("es".into()));
102+
assert_eq!(parse_posix_locale("es_ES"), Some("es".into()));
103+
assert_eq!(parse_posix_locale("es_AR.UTF-8"), Some("es".into()));
104+
}
105+
106+
#[test]
107+
fn parse_posix_locale_english_and_pseudo() {
108+
assert_eq!(parse_posix_locale("en_US.UTF-8"), Some("en".into()));
109+
assert_eq!(parse_posix_locale("en"), Some("en".into()));
110+
assert_eq!(parse_posix_locale("C"), Some("en".into()));
111+
assert_eq!(parse_posix_locale("POSIX"), Some("en".into()));
112+
}
113+
114+
#[test]
115+
fn parse_posix_locale_unsupported_returns_none() {
116+
assert_eq!(parse_posix_locale("fr_FR.UTF-8"), None);
117+
assert_eq!(parse_posix_locale("ja_JP.UTF-8"), None);
118+
assert_eq!(parse_posix_locale(""), None);
119+
}
120+
121+
#[test]
122+
fn parse_posix_locale_strips_charset_and_modifier() {
123+
assert_eq!(parse_posix_locale("es_ES@euro"), Some("es".into()));
124+
}
125+
126+
#[test]
127+
fn resolve_language_reads_config_language() {
128+
let tmp = tempfile::tempdir().unwrap();
129+
std::fs::create_dir_all(tmp.path().join(".straymark")).unwrap();
130+
std::fs::write(
131+
tmp.path().join(".straymark/config.yml"),
132+
"language: zh-CN\n",
133+
)
134+
.unwrap();
135+
assert_eq!(resolve_language(tmp.path()), "zh-CN");
136+
}
137+
138+
#[test]
139+
fn resolve_language_config_without_language_key_defaults_en() {
140+
let tmp = tempfile::tempdir().unwrap();
141+
std::fs::create_dir_all(tmp.path().join(".straymark")).unwrap();
142+
std::fs::write(
143+
tmp.path().join(".straymark/config.yml"),
144+
"complexity:\n threshold: 5\n",
145+
)
146+
.unwrap();
147+
assert_eq!(resolve_language(tmp.path()), "en");
148+
}
149+
}

0 commit comments

Comments
 (0)