diff --git a/.changeset/registry-compile-fixes.md b/.changeset/registry-compile-fixes.md
new file mode 100644
index 00000000..aaa18c44
--- /dev/null
+++ b/.changeset/registry-compile-fixes.md
@@ -0,0 +1,5 @@
+---
+"@sanring/cli": patch
+---
+
+Fix two registry items that failed to compile after install: `context-menu` imported `../../shared/menu-navigation` instead of `../shared/menu-navigation`, and `block/table-page` left out the required `value` on its row "Delete" menu item.
diff --git a/.claude/adrs/0002-org-chart-layout-engine.md b/.claude/adrs/0002-org-chart-layout-engine.md
new file mode 100644
index 00000000..378bf776
--- /dev/null
+++ b/.claude/adrs/0002-org-chart-layout-engine.md
@@ -0,0 +1,95 @@
+---
+schema_version: 1
+adr_id: "0002"
+title: org-chart-layout-engine
+status: Accepted
+date: 2026-10-01
+deciders: [jack755051]
+related: [.claude/charters/p33-org-chart.md]
+---
+
+# ADR-0002: Org Chart 排版引擎與交付形態
+
+## Context
+
+- **觸發事件**:需要「經典組織圖」——主管在上、同儕橫排,且必須支援**雙主管 / 矩陣匯報**、**跨層虛線**、**直角連線避開卡片**。
+- **既有狀況**:`tree` 元件是 ARIA treeview(側欄導航),不是 2D 圖。repo 沒有任何圖排版能力。
+- **既有約束**:
+ - TODOLIST P32「明確不做」:不新增 primitive,官方方向在 blocks 與 registry 生態。
+ - block 與 component 共用 `RegistryComponent` 型別(`packages/cli/src/registry.ts:148`),已支援 `peerDependencies`;`sanring add` 會自動安裝 peer(`collectPeerDeps`)。
+ - 前例:`carousel` 以 `embla-carousel` 為 peerDependency。
+
+## 候選引擎(2026-10-01 以 `npm pack` 實測)
+
+| 引擎 | 授權 | 主檔 gzip | 多父節點 | 直角繞線 | Worker |
+|---|---|---|---|---|---|
+| d3-hierarchy 3.1.2 | ISC | ~6 KB(`d3-hierarchy.min.js`) | ❌ 嚴格樹 | ❌ 只給端點 | 不需要 |
+| @dagrejs/dagre 3.1.1 | MIT | ~17 KB(`dagre.min.js`) | ✅ | ❌ polyline | ❌ |
+| elkjs 0.12.0 | EPL-2.0 OR GPL-3.0-or-later | ~460 KB(`elk-worker.min.js` / `elk.bundled.js`) | ✅ `layered` | ✅ `elk.edgeRouting: ORTHOGONAL` | ✅ `elk-api.js` + worker |
+
+## Decision
+
+1. **引擎:elkjs,演算法固定 `org.eclipse.elk.layered`。** 唯一同時滿足多父節點 + 直角繞線 + Worker 的選項。`mrtree` 是純樹演算法,禁用。
+2. **交付形態:單一 block `block/org-chart`**,`peerDependencies: { "elkjs": "^0.12.0" }`。不新增 `diagram-*` / `org-chart` primitive。
+3. **引擎隔離:`layout.ts` 是唯一 import elkjs 的檔案**,輸出引擎無關的 `{ nodes, edges }` 座標結構;換引擎只改此檔。
+4. **渲染:節點 HTML、連線 SVG**,同一座標系;path 由 elk 轉折點直接拼 `M…L…`。
+5. **不引入 d3**:`d3-shape` 對直角線無用;`d3-zoom` 帶入 selection / drag / transition / interpolate 且直接操作 DOM,與 signal 狀態雙軌。平移縮放自寫(pointer + wheel + CSS `transform`)。
+6. **授權**:消費端選 EPL-2.0 分支;elkjs 不進 `@sanring/cli` tarball(由 `sanring add` 安裝到消費專案);docs 頁明示授權與體積。
+
+## Consequences
+
+- ✅ 矩陣匯報、跨層虛線、直角連線一次到位;CLI 零改動。
+- ✅ 排版在 Worker,不跟 Angular change detection 搶主線程。
+- ⚠️ worker chunk 1.45 MB raw / ~336 KB transfer:只在該頁載入,不進主 bundle(spike 已驗證)。
+- ⚠️ EPL-2.0:法務若否決 → 退路是 dagre(MIT),代價是放棄直角繞線與 Worker。
+- ⚠️ mergeEdges 讓虛線與實線共用匯流排段(批次 B 處理)。
+
+## Spike 結果(批次 A,2026-10-01)
+
+docs app 暫時頁 `/spike/org-chart`(已移除),17 人 demo(含雙主管、CEO → Junior PM 跨層虛線、跨部門虛線)+ 合成 300 / 1000 人。
+
+### A1 虛線處理 → 採「虛線一起參與 layered」
+
+| 方案 | 結果 |
+|---|---|
+| 虛線一起參與(all-equal) | ✅ 分層未被扯歪;elk 為虛線繞線並避開卡片 |
+| 虛線降 priority(solid-priority) | 與 all-equal 視覺上無差,不值得多一組選項 |
+| 只排實線、虛線事後畫直角折線(solid-only) | ❌ 虛線直接穿過卡片(CEO → Junior PM 穿過 CTO 卡片) |
+
+**「經典組織圖」外觀必須加這組選項**(不加則父節點不置中、每條線各自出埠):
+
+```ts
+'elk.algorithm': 'layered',
+'elk.direction': 'DOWN',
+'elk.edgeRouting': 'ORTHOGONAL',
+'elk.layered.mergeEdges': 'true', // 同一主管的子線共用匯流排
+'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
+'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // 父節點置中
+'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', // 尊重輸入順序
+```
+
+**已知瑕疵(批次 B 處理)**:`mergeEdges` 會讓虛線與同源實線共用匯流排段,起點附近虛線被實線蓋住、看起來像實線。
+
+### A2 效能(Worker 往返,dev server,Apple Silicon)
+
+| 節點 | 耗時 |
+|---|---|
+| 17(首次,含 worker 啟動) | ~135–215 ms |
+| 17(暖機後) | ~10–16 ms |
+| 300 | ~51 ms |
+| 1000 | ~166–173 ms |
+
+遠低於 2 秒 gate。
+
+### A3 打包 → 通過
+
+- 正確接法:主線程 `import ELK from 'elkjs/lib/elk-api'` + `workerFactory: () => new Worker(new URL('./x.worker', import.meta.url), { type: 'module' })`;worker 檔只寫 `import 'elkjs/lib/elk-worker.min.js'`(它在 worker scope 自己掛 `self.onmessage`)。
+- ❌ 不要在自訂 worker 裡用 `elk.bundled.js`:它會再開內層 worker,報 `_Worker is not a constructor`。
+- `ng build` 結果:elk 只出現在 `worker-*.js`(1.45 MB raw / ~336 KB transfer);主 bundle 只多 `elk-api`(~1 KB)。docs app 無需 `webWorkerTsConfig`。
+- 會出現 `not ESM` 警告(`elk-api`、`elk-worker.min.js`)→ docs 要教消費端加 `allowedCommonJsDependencies: ["elkjs"]`。
+- dev server 首次載入時 Vite 重新最佳化依賴,可能先報一次 `_Worker` 錯,重整後正常;只影響 dev。
+
+## 推翻條件
+
+- spike 任一 gate 不過(見 charter §6)→ 回到本 ADR 重評 dagre 或縮減需求。
+- 出現第二個「節點 + 連線」block(如流程圖)→ 另開 ADR 評估抽 `diagram-*` 原語。
diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md
new file mode 100644
index 00000000..cde08b00
--- /dev/null
+++ b/.claude/charters/p33-org-chart.md
@@ -0,0 +1,136 @@
+---
+schema_version: 1
+charter_id: p33-org-chart
+charter_name: P33 — Block org-chart(elkjs 排版 + 薄殼渲染)
+status: draft
+charter_type: feature
+parent_charter:
+date: 2026-10-01
+owner: jack755051
+branch: feat/p33-org-chart
+related_prd:
+related_constitution:
+related_adr: .claude/adrs/0002-org-chart-layout-engine.md
+---
+
+# Task Charter: P33 — Block org-chart
+
+選型理由見 [ADR-0002](../adrs/0002-org-chart-layout-engine.md)。本檔只管**範圍、順序、驗證**。
+
+## 1. 目的 (Purpose)
+
+交付 `sanring add block/org-chart`:經典組織圖頁面模板,支援雙主管 / 矩陣匯報、跨層虛線、直角連線避障、平移縮放,並以 `tree` 側欄提供鍵盤可及性。
+
+## 2. 核心重點(每批都要守)
+
+1. **elkjs 只在 `layout.ts` 與 `org-chart.worker.ts`**——其他檔案 grep 不到 `elkjs`。
+2. **演算法只用 `elk.layered`**,`elk.edgeRouting: ORTHOGONAL`。
+3. **節點 HTML、連線 SVG**,同一座標系;不做純 SVG 圖。
+4. **Worker 預設開**;elk 不得進主 bundle。
+5. **狀態在 block 的 signal**;不建 `providedIn: 'root'` service。
+6. **圖不是 ARIA treeview**;鍵盤導航交給既有 `tree`,兩者共用同一份資料。
+7. **不引入 d3**。
+
+## 3. 可做範圍 vs 不可做範圍
+
+### ✅ 可做
+
+- `registry/blocks/org-chart/`(`org-chart.component.ts`、`layout.ts`、`org-chart.worker.ts`、`index.ts`)
+- `registry/registry.json` `blocks[]` 新增一筆,`peerDependencies: { "elkjs": "^0.12.0" }`
+- 根 `package.json` 加 `elkjs`(docs app / spike 用)
+- docs app:`/blocks` 頁加 org-chart 示範;spike 期間可加暫時頁面(批次 A 結束移除)
+- 卡片組合既有元件:`card`、`avatar`、`badge`、`dropdown-menu`、`tree`、`sheet`、`skeleton`、`button`
+- 固定卡片尺寸 input(`nodeW` / `nodeH`)
+- 平移、縮放、fit-to-view、捲動到指定節點(自寫)
+- 循環匯報:偵測後 `console.warn`,不畫
+
+### ❌ 不可做
+
+- `diagram-*` / `org-chart` primitive(`packages/ui` 不動)
+- 改 `packages/cli`(含授權提示機制)
+- elkjs 進 `@sanring/cli` tarball
+- d3 任何套件
+- 可變卡片尺寸 / 隱藏量測、部門分組框(compound node)、節點拖曳
+- CRUD、權限、搜尋後端
+- `mrtree` 或其他 elk 演算法切換 API
+
+## 4. 分批策略 (Phased Plan)
+
+### 批次 A:Spike(Gate,不進 registry)
+
+docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基層 PM 跨層虛線。
+
+- [x] A1 虛線處理:比較「虛線一起參與 layered」vs「只用實線排版、虛線另算繞線」,截圖存證,選一種
+- [x] A2 效能:300 / 1000 節點在 Worker 中 `layout()` 耗時(記錄數字)
+- [x] A3 打包:`ng build` 後確認 elk 位於獨立 worker chunk,主 bundle 無 elk
+- [x] A4 結論寫回 ADR-0002(Status → Accepted 或 Rejected)
+
+### 批次 B:`layout.ts`
+
+結果與選項定案見 ADR-0002「Spike 結果」。
+
+- [x] 修正虛線被實線匯流排蓋住:改用每節點 4 個固定 port(實線 in/out 置中、虛線 in/out 右移 24px),不開 `mergeEdges`;同主管實線仍共用單一出線點
+- [x] worker 檔隨 block 出貨(`org-chart.worker.ts`)
+- [x] 型別:`OrgNode { id }`、`OrgEdge { source, target, kind?: 'solid' | 'dotted' }`;引擎以 `OrgLayoutEngine` 注入(app 用 `createOrgLayoutEngine()` 走 worker,測試用 bundled)
+- [x] 輸出 `OrgLayout { width, height, nodes, edges: { id, source, target, kind, points }[], dropped }` + `orgEdgePath()`
+- [x] 邊清理:未知節點、自環、重複邊、實線匯報循環(drop 閉環那條,`console.warn`);虛線可反向指上
+- [x] unit test:`apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts`(放 docs 是因為 `registry/` 整包進 CLI tarball)——驗證雙主管、虛線不改層級、同主管單一匯流排、虛線不疊實線、線不穿卡片、循環 / 清理
+
+### 批次 C:渲染殼
+
+- [x] 卡片層(`@for` + 絕對定位,卡片是 `
+
+
+
+ {{ i18n.t('blocks.org.body') }}
+
+
+
+
+
+
+
+
`,
})
@@ -190,14 +209,54 @@ export class BlocksPageComponent {
{ id: 'dashboard-shell', titleKey: 'blocks.dashboard.title' },
{ id: 'login', titleKey: 'blocks.login.title' },
{ id: 'table-page', titleKey: 'blocks.table.title' },
+ { id: 'org-chart', titleKey: 'blocks.org.title' },
];
protected readonly installAll = `npx @sanring/cli add block/dashboard-shell
npx @sanring/cli add block/login
-npx @sanring/cli add block/table-page`;
+npx @sanring/cli add block/table-page
+npx @sanring/cli add block/org-chart`;
protected readonly installDashboard = `npx @sanring/cli add block/dashboard-shell`;
protected readonly installLogin = `npx @sanring/cli add block/login`;
protected readonly installTable = `npx @sanring/cli add block/table-page`;
+ protected readonly installOrg = `npx @sanring/cli add block/org-chart`;
+ protected readonly orgSnippet = ``;
+ protected readonly orgSelected = signal(null);
+ protected readonly orgPeople: OrgPerson[] = [
+ { id: 'ada', name: 'Ada Lin', title: 'CEO' },
+ { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' },
+ { id: 'cora', name: 'Cora Wu', title: 'CFO', department: 'Finance' },
+ { id: 'dan', name: 'Dan Chen', title: 'VP Sales', department: 'Sales' },
+ { id: 'eve', name: 'Eve Tsai', title: 'Eng Manager', department: 'Eng' },
+ { id: 'finn', name: 'Finn Kao', title: 'Eng Manager', department: 'Eng' },
+ { id: 'gus', name: 'Gus Lee', title: 'Controller', department: 'Finance' },
+ { id: 'hana', name: 'Hana Su', title: 'Sales Lead', department: 'Sales' },
+ { id: 'jo', name: 'Jo Yang', title: 'Engineer', department: 'Eng' },
+ { id: 'kai', name: 'Kai Lu', title: 'Engineer', department: 'Eng' },
+ { id: 'mia', name: 'Mia Hsu', title: 'Solutions Eng', department: 'Eng' },
+ { id: 'nia', name: 'Nia Lo', title: 'Accountant', department: 'Finance' },
+ { id: 'oto', name: 'Oto Pan', title: 'Account Exec', department: 'Sales' },
+ { id: 'quin', name: 'Quin Fang', title: 'Junior PM', department: 'Eng' },
+ ];
+ protected readonly orgLinks: OrgLink[] = [
+ { source: 'ada', target: 'ben' },
+ { source: 'ada', target: 'cora' },
+ { source: 'ada', target: 'dan' },
+ { source: 'ben', target: 'eve' },
+ { source: 'ben', target: 'finn' },
+ { source: 'cora', target: 'gus' },
+ { source: 'dan', target: 'hana' },
+ { source: 'eve', target: 'jo' },
+ { source: 'eve', target: 'quin' },
+ { source: 'finn', target: 'kai' },
+ // Mia reports to both Finn and Hana.
+ { source: 'finn', target: 'mia' },
+ { source: 'hana', target: 'mia' },
+ { source: 'gus', target: 'nia' },
+ { source: 'hana', target: 'oto' },
+ // The CEO sponsors Quin's project directly.
+ { source: 'ada', target: 'quin', kind: 'dotted' },
+ ];
protected readonly dashboardSnippet = `
`;
diff --git a/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts b/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts
new file mode 100644
index 00000000..0d0fe09a
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts
@@ -0,0 +1,147 @@
+// Lives in the docs app (not next to the block) because registry/ ships inside
+// the CLI tarball. Uses elk's bundled build: same engine, no worker.
+import ELK from 'elkjs/lib/elk.bundled.js';
+import {
+ layoutOrg,
+ orgEdgePath,
+ type OrgEdge,
+ type OrgLayout,
+ type OrgPoint,
+ type PositionedOrgNode,
+} from '../../../../../../registry/blocks/org-chart/layout';
+
+const engine = new ELK();
+const size = { nodeWidth: 180, nodeHeight: 64 };
+
+const s = (source: string, target: string): OrgEdge => ({ source, target, kind: 'solid' });
+const d = (source: string, target: string): OrgEdge => ({ source, target, kind: 'dotted' });
+
+// mia reports to eng2 and sal1; ceo has a dotted line to a junior pm two levels down.
+const people = [
+ 'ceo', 'cto', 'cfo', 'cso', 'eng1', 'eng2', 'fin1', 'sal1', 'sal2',
+ 'dev1', 'dev2', 'dev3', 'mia', 'acc1', 'rep1', 'rep2', 'pm',
+].map((id) => ({ id }));
+const links = [
+ s('ceo', 'cto'), s('ceo', 'cfo'), s('ceo', 'cso'),
+ s('cto', 'eng1'), s('cto', 'eng2'), s('cfo', 'fin1'), s('cso', 'sal1'), s('cso', 'sal2'),
+ s('eng1', 'dev1'), s('eng1', 'dev2'), s('eng2', 'dev3'), s('eng2', 'mia'), s('sal1', 'mia'),
+ s('fin1', 'acc1'), s('sal2', 'rep1'), s('sal2', 'rep2'), s('eng1', 'pm'),
+ d('ceo', 'pm'), d('fin1', 'rep2'),
+];
+
+type Segment = [OrgPoint, OrgPoint];
+const segments = (points: OrgPoint[]): Segment[] =>
+ points.slice(1).map((p, i) => [points[i], p] as Segment);
+
+function overlaps([a1, a2]: Segment, [b1, b2]: Segment): boolean {
+ const span = (p: number, q: number, r: number, t: number) =>
+ Math.min(Math.max(p, q), Math.max(r, t)) - Math.max(Math.min(p, q), Math.min(r, t));
+ if (a1.x === a2.x && b1.x === b2.x && a1.x === b1.x) return span(a1.y, a2.y, b1.y, b2.y) > 0.5;
+ if (a1.y === a2.y && b1.y === b2.y && a1.y === b1.y) return span(a1.x, a2.x, b1.x, b2.x) > 0.5;
+ return false;
+}
+
+function crossesCard([a, b]: Segment, n: PositionedOrgNode): boolean {
+ return (
+ Math.max(a.x, b.x) > n.x + 1 &&
+ Math.min(a.x, b.x) < n.x + n.width - 1 &&
+ Math.max(a.y, b.y) > n.y + 1 &&
+ Math.min(a.y, b.y) < n.y + n.height - 1
+ );
+}
+
+describe('layoutOrg', () => {
+ let layout: OrgLayout;
+ const node = (id: string) => layout.nodes.find((n) => n.id === id)!;
+
+ beforeAll(async () => {
+ layout = await layoutOrg(engine, people, links, size);
+ });
+
+ it('places every person and routes every edge', () => {
+ expect(layout.nodes).toHaveLength(people.length);
+ expect(layout.edges).toHaveLength(links.length);
+ expect(layout.dropped).toEqual([]);
+ expect(layout.width).toBeGreaterThan(0);
+ });
+
+ it('puts managers above reports, including both managers of a dual report', () => {
+ expect(node('cto').y).toBeGreaterThan(node('ceo').y);
+ expect(node('mia').y).toBeGreaterThan(node('eng2').y);
+ expect(node('mia').y).toBeGreaterThan(node('sal1').y);
+ });
+
+ it('keeps a dotted line from pulling its target out of its solid level', () => {
+ expect(node('pm').y).toBe(node('dev1').y);
+ });
+
+ it('draws one shared bus per manager', () => {
+ const starts = layout.edges
+ .filter((e) => e.kind === 'solid' && e.source === 'ceo')
+ .map((e) => `${e.points[0].x},${e.points[0].y}`);
+ expect(new Set(starts).size).toBe(1);
+ });
+
+ it('never lays a dotted line on top of a solid one', () => {
+ const solid = layout.edges.filter((e) => e.kind === 'solid').flatMap((e) => segments(e.points));
+ const dotted = layout.edges.filter((e) => e.kind === 'dotted').flatMap((e) => segments(e.points));
+ const hits = dotted.filter((a) => solid.some((b) => overlaps(a, b)));
+ expect(hits).toEqual([]);
+ });
+
+ it('routes no edge through a card', () => {
+ const hits = layout.edges.flatMap((e) =>
+ segments(e.points)
+ .filter((seg) => layout.nodes.some((n) => crossesCard(seg, n)))
+ .map(() => `${e.source}->${e.target}`),
+ );
+ expect(hits).toEqual([]);
+ });
+});
+
+describe('layoutOrg edge sanitising', () => {
+ beforeEach(() => vi.spyOn(console, 'warn').mockImplementation(() => undefined));
+ afterEach(() => vi.restoreAllMocks());
+
+ it('drops the solid edge that closes a reporting cycle and warns', async () => {
+ const result = await layoutOrg(
+ engine,
+ [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
+ [s('a', 'b'), s('b', 'c'), s('c', 'a')],
+ size,
+ );
+ expect(result.dropped).toEqual([s('c', 'a')]);
+ expect(result.edges).toHaveLength(2);
+ expect(console.warn).toHaveBeenCalledOnce();
+ });
+
+ it('allows a dotted line that points back up the hierarchy', async () => {
+ const result = await layoutOrg(engine, [{ id: 'a' }, { id: 'b' }], [s('a', 'b'), d('b', 'a')], size);
+ expect(result.dropped).toEqual([]);
+ expect(result.edges).toHaveLength(2);
+ });
+
+ it('drops unknown endpoints and self-loops, and dedupes repeats', async () => {
+ const result = await layoutOrg(
+ engine,
+ [{ id: 'a' }, { id: 'b' }],
+ [{ source: 'a', target: 'b' }, s('a', 'b'), s('a', 'ghost'), s('b', 'b')],
+ size,
+ );
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].kind).toBe('solid');
+ expect(result.dropped).toEqual([s('a', 'ghost'), s('b', 'b')]);
+ });
+
+ it('lays out an empty org', async () => {
+ const result = await layoutOrg(engine, [], [], size);
+ expect(result.nodes).toEqual([]);
+ expect(result.edges).toEqual([]);
+ });
+});
+
+describe('orgEdgePath', () => {
+ it('builds an orthogonal SVG path', () => {
+ expect(orgEdgePath([{ x: 0, y: 0 }, { x: 0, y: 10 }, { x: 20, y: 10 }])).toBe('M0 0 L0 10 L20 10');
+ });
+});
diff --git a/apps/docs/src/app/shell/header/feature-list.component.ts b/apps/docs/src/app/shell/header/feature-list.component.ts
index efa02e7e..9508fb4b 100644
--- a/apps/docs/src/app/shell/header/feature-list.component.ts
+++ b/apps/docs/src/app/shell/header/feature-list.component.ts
@@ -4,11 +4,13 @@ import { LucideMenu, LucideMonitor, LucideMoon, LucideSearch, LucideSun } from '
import { CommandDialogComponent, SANRING_COMMAND_IMPORTS, SANRING_SHEET_IMPORTS } from '@sanring/ui';
import { I18nService } from '../../i18n/i18n.service';
import {
+ docsBlockItems,
docsComponentItems,
docsSectionItems,
visibleDocsComponentItems,
} from '../../navigation/docs-navigation';
import { isRecentlyUpdatedComponentId } from '../../pages/changelog/component-changelog';
+import { DocsBlocksListComponent } from '../sidebar/docs-blocks-list.component';
import { DocsComponentsListComponent } from '../sidebar/docs-components-list.component';
import { DocsSectionComponent } from '../sidebar/docs-section.component';
import { DocsNavStateService } from '../docs-nav-state.service';
@@ -33,6 +35,7 @@ const MAX_SEARCH_RESULTS = 8;
imports: [
HeaderActionButtonComponent,
RouterLink,
+ DocsBlocksListComponent,
DocsComponentsListComponent,
DocsSectionComponent,
LucideMenu,
@@ -88,6 +91,8 @@ const MAX_SEARCH_RESULTS = 8;
[items]="mobileNavigationItems"
/>
+
+
@if (navState.hasSidebar()) {
} @else {
@@ -242,6 +247,13 @@ export class FeatureListComponent {
const sectionItems = docsSectionItems
.filter((item): item is typeof item & { path: string } => !!item.path && !item.disabled)
.map((item) => ({ label: this.i18n.t(item.labelKey), path: item.path }));
+ const blockItems = docsBlockItems
+ .filter((item) => !item.disabled)
+ .map((item) => ({
+ label: this.i18n.t(item.labelKey),
+ description: this.i18n.t(item.descriptionKey),
+ path: `${item.path}#${item.fragment}`,
+ }));
const componentItems = docsComponentItems
.filter((item) => !item.disabled)
.map((item) => ({
@@ -250,7 +262,7 @@ export class FeatureListComponent {
path: item.path,
}));
- return [...sectionItems, ...componentItems];
+ return [...sectionItems, ...blockItems, ...componentItems];
});
// 空字串時直接瀏覽全部項目(Command Dialog 是完整 modal,讓使用者不用打字也能瀏覽比較合理);
diff --git a/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts b/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts
new file mode 100644
index 00000000..0ed1537f
--- /dev/null
+++ b/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts
@@ -0,0 +1,18 @@
+import { Component, inject, Input } from '@angular/core';
+import { I18nService } from '../../i18n/i18n.service';
+import { docsBlockItems } from '../../navigation/docs-navigation';
+import { DocsSectionComponent } from './docs-section.component';
+
+@Component({
+ selector: 'app-docs-blocks-list',
+ imports: [DocsSectionComponent],
+ template: `
+
+ `,
+})
+export class DocsBlocksListComponent {
+ @Input() sectionClass = 'mt-11';
+
+ protected readonly items = docsBlockItems;
+ protected readonly i18n = inject(I18nService);
+}
diff --git a/apps/docs/src/app/shell/sidebar/docs-section.component.ts b/apps/docs/src/app/shell/sidebar/docs-section.component.ts
index d8926014..acbf7c4c 100644
--- a/apps/docs/src/app/shell/sidebar/docs-section.component.ts
+++ b/apps/docs/src/app/shell/sidebar/docs-section.component.ts
@@ -1,5 +1,7 @@
import { Component, inject, Input } from '@angular/core';
-import { RouterLink, RouterLinkActive } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { NavigationEnd, Router, RouterLink } from '@angular/router';
+import { filter, map, startWith } from 'rxjs';
import {
docsComponentStatusBadgeKeys,
docsComponentStatusDotClass,
@@ -9,7 +11,7 @@ import { I18nService } from '../../i18n/i18n.service';
@Component({
selector: 'app-docs-section',
- imports: [RouterLink, RouterLinkActive],
+ imports: [RouterLink],
template: `
{{ title }}
@@ -22,9 +24,9 @@ import { I18nService } from '../../i18n/i18n.service';
} @else {
{{ i18n.t(item.labelKey) }}
@if (item.badge) {
@@ -56,4 +58,26 @@ export class DocsSectionComponent {
protected readonly i18n = inject(I18nService);
protected readonly statusBadgeKeys = docsComponentStatusBadgeKeys;
protected readonly statusDotClass = docsComponentStatusDotClass;
+
+ private readonly router = inject(Router);
+ private readonly url = toSignal(
+ this.router.events.pipe(
+ filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+ map(() => this.router.url),
+ startWith(this.router.url),
+ ),
+ { initialValue: this.router.url },
+ );
+
+ protected isActive(item: DocsSidebarItem): boolean {
+ if (!item.path || !item.active) return false;
+ const tree = this.router.parseUrl(this.url());
+ const currentPath = '/' + (tree.root.children['primary']?.segments.map((segment) => segment.path).join('/') ?? '');
+ const pathMatches = item.exact
+ ? currentPath === item.path
+ : currentPath === item.path || currentPath.startsWith(`${item.path}/`);
+ if (!pathMatches) return false;
+ if (item.fragment) return tree.fragment === item.fragment;
+ return true;
+ }
}
diff --git a/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts b/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
index fbdc8604..12fd5710 100644
--- a/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
+++ b/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
@@ -1,16 +1,18 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { DocsNavStateService } from '../docs-nav-state.service';
+import { DocsBlocksListComponent } from './docs-blocks-list.component';
import { DocsComponentsListComponent } from './docs-components-list.component';
import { DocsSectionsListComponent } from './docs-sections-list.component';
@Component({
selector: 'app-docs-sidebar',
- imports: [DocsSectionsListComponent, DocsComponentsListComponent],
+ imports: [DocsSectionsListComponent, DocsBlocksListComponent, DocsComponentsListComponent],
template: `
`,
diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css
index 504a2874..249f68dc 100644
--- a/apps/docs/src/styles.css
+++ b/apps/docs/src/styles.css
@@ -23,6 +23,7 @@
@source "./app";
@source "../../../packages/ui/src";
+@source "../../../registry";
@theme inline {
/* 主色綁定 docs-accent,讓 light/dark 切換時 bg-primary 等 utility 跟著變動 */
diff --git a/apps/docs/tsconfig.spec.json b/apps/docs/tsconfig.spec.json
index 48fcc2fd..e6d05f76 100644
--- a/apps/docs/tsconfig.spec.json
+++ b/apps/docs/tsconfig.spec.json
@@ -4,6 +4,7 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
+ "rootDir": "../../",
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
diff --git a/eslint.config.js b/eslint.config.js
index ac1ec338..0e358405 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -12,6 +12,8 @@ module.exports = tseslint.config(
'node_modules/**',
// build 產物:registry/** 同步過去的複本,見 CONTRIBUTING.md
'packages/cli/registry/**',
+ // registry/** 的消費端佈局複本,見 apps/docs/scripts/stage-registry.mjs
+ 'apps/docs/src/registry-stage/**',
],
},
{
diff --git a/package.json b/package.json
index 368c36b6..2fb99e6d 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,16 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
+ "stage:registry": "node apps/docs/scripts/stage-registry.mjs",
+ "postinstall": "node apps/docs/scripts/stage-registry.mjs",
+ "prestart": "node apps/docs/scripts/stage-registry.mjs",
"start": "ng serve",
+ "prebuild": "node apps/docs/scripts/stage-registry.mjs",
"build": "ng build",
"watch": "ng build --watch --configuration development",
+ "pretest": "node apps/docs/scripts/stage-registry.mjs",
"test": "ng test",
+ "pretest:e2e:docs": "node apps/docs/scripts/stage-registry.mjs",
"test:e2e:docs": "playwright test --config=apps/docs/playwright.config.ts",
"test:e2e:cli": "pnpm --filter @sanring/cli test:e2e",
"lint": "eslint .",
@@ -30,6 +36,7 @@
"@sanring/date-picker-core": "^0.5.0",
"@stackblitz/sdk": "^1.11.1",
"clsx": "^2.1.1",
+ "elkjs": "^0.12.0",
"embla-carousel": "^8.6.0",
"rxjs": "~7.8.0",
"shiki": "^4.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e0d0a8f..96baa228 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ elkjs:
+ specifier: ^0.12.0
+ version: 0.12.0
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
@@ -2692,6 +2695,9 @@ packages:
electron-to-chromium@1.5.372:
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
+ elkjs@0.12.0:
+ resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==}
+
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
@@ -6693,6 +6699,8 @@ snapshots:
electron-to-chromium@1.5.372: {}
+ elkjs@0.12.0: {}
+
embla-carousel@8.6.0: {}
emoji-regex@10.6.0: {}
diff --git a/registry/blocks/org-chart/index.ts b/registry/blocks/org-chart/index.ts
new file mode 100644
index 00000000..8c6b00eb
--- /dev/null
+++ b/registry/blocks/org-chart/index.ts
@@ -0,0 +1,2 @@
+export * from './layout';
+export * from './org-chart.component';
diff --git a/registry/blocks/org-chart/layout.ts b/registry/blocks/org-chart/layout.ts
new file mode 100644
index 00000000..cc57c38d
--- /dev/null
+++ b/registry/blocks/org-chart/layout.ts
@@ -0,0 +1,219 @@
+// The only module (with org-chart.worker.ts) that knows about elkjs. Everything
+// it returns is plain coordinates, so swapping the engine only touches this file.
+import ELK, { type ElkExtendedEdge, type ElkNode, type ElkPort } from 'elkjs/lib/elk-api';
+
+export type OrgEdgeKind = 'solid' | 'dotted';
+
+export interface OrgNode {
+ id: string;
+}
+
+/** `solid` = line manager; `dotted` = matrix / dotted-line reporting. */
+export interface OrgEdge {
+ source: string;
+ target: string;
+ kind?: OrgEdgeKind;
+}
+
+export interface OrgLayoutOptions {
+ nodeWidth: number;
+ nodeHeight: number;
+ /** Horizontal gap between siblings. */
+ nodeGap?: number;
+ /** Vertical gap between levels. */
+ levelGap?: number;
+}
+
+export interface OrgPoint {
+ x: number;
+ y: number;
+}
+
+export interface PositionedOrgNode {
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface RoutedOrgEdge {
+ id: string;
+ source: string;
+ target: string;
+ kind: OrgEdgeKind;
+ points: OrgPoint[];
+}
+
+export interface OrgLayout {
+ width: number;
+ height: number;
+ nodes: PositionedOrgNode[];
+ edges: RoutedOrgEdge[];
+ /** Edges left out of the layout: unknown endpoints, self-loops, or solid reporting cycles. */
+ dropped: OrgEdge[];
+}
+
+/** Anything with elk's `layout()` — the worker-backed engine in the app, the bundled one in tests. */
+export interface OrgLayoutEngine {
+ layout(graph: ElkNode): Promise;
+}
+
+/** Runs elk in a Web Worker so large charts never block change detection. */
+export function createOrgLayoutEngine(): OrgLayoutEngine {
+ return new ELK({
+ workerFactory: () =>
+ new Worker(new URL('./org-chart.worker', import.meta.url), { type: 'module' }),
+ });
+}
+
+// Solid and dotted edges leave / enter through separate ports. With one shared
+// port (or `mergeEdges`), elk routes dotted lines along the solid bus, so the
+// first stretch of a dotted line is drawn on top of a solid one.
+const DOTTED_PORT_OFFSET = 24;
+
+const LAYOUT_OPTIONS: Record = {
+ 'elk.algorithm': 'layered',
+ 'elk.direction': 'DOWN',
+ 'elk.edgeRouting': 'ORTHOGONAL',
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
+};
+
+export async function layoutOrg(
+ engine: OrgLayoutEngine,
+ nodes: readonly OrgNode[],
+ edges: readonly OrgEdge[],
+ options: OrgLayoutOptions,
+): Promise {
+ const { nodeWidth: w, nodeHeight: h, nodeGap = 24, levelGap = 48 } = options;
+ const { kept, dropped } = sanitizeEdges(nodes, edges);
+ if (dropped.length > 0) {
+ console.warn('[org-chart] dropped edges (unknown node, self-loop, or reporting cycle):', dropped);
+ }
+
+ const port = (node: string, name: string, side: 'NORTH' | 'SOUTH', x: number): ElkPort => ({
+ id: `${node}:${name}`,
+ width: 1,
+ height: 1,
+ x,
+ y: side === 'NORTH' ? -1 : h,
+ layoutOptions: { 'elk.port.side': side },
+ });
+
+ const graph: ElkNode = {
+ id: 'root',
+ layoutOptions: {
+ ...LAYOUT_OPTIONS,
+ 'elk.spacing.nodeNode': String(nodeGap),
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(levelGap),
+ },
+ children: nodes.map((node) => ({
+ id: node.id,
+ width: w,
+ height: h,
+ layoutOptions: { 'elk.portConstraints': 'FIXED_POS' },
+ ports: [
+ port(node.id, 'in', 'NORTH', w / 2),
+ port(node.id, 'out', 'SOUTH', w / 2),
+ port(node.id, 'dotted-in', 'NORTH', w / 2 + DOTTED_PORT_OFFSET),
+ port(node.id, 'dotted-out', 'SOUTH', w / 2 + DOTTED_PORT_OFFSET),
+ ],
+ })),
+ edges: kept.map((edge) => {
+ const dotted = edge.kind === 'dotted';
+ return {
+ id: edge.id,
+ sources: [`${edge.source}:${dotted ? 'dotted-out' : 'out'}`],
+ targets: [`${edge.target}:${dotted ? 'dotted-in' : 'in'}`],
+ };
+ }),
+ };
+
+ const result = await engine.layout(graph);
+ const byId = new Map(kept.map((edge) => [edge.id, edge]));
+
+ return {
+ width: result.width ?? 0,
+ height: result.height ?? 0,
+ nodes: (result.children ?? []).map((child) => ({
+ id: child.id,
+ x: child.x ?? 0,
+ y: child.y ?? 0,
+ width: child.width ?? w,
+ height: child.height ?? h,
+ })),
+ edges: (result.edges ?? []).flatMap((edge) => {
+ const source = byId.get(edge.id);
+ const section = edge.sections?.[0];
+ if (!source || !section) return [];
+ return [
+ {
+ id: edge.id,
+ source: source.source,
+ target: source.target,
+ kind: source.kind,
+ points: [section.startPoint, ...(section.bendPoints ?? []), section.endPoint],
+ },
+ ];
+ }),
+ dropped,
+ };
+}
+
+/** SVG `d` for an orthogonal polyline. */
+export function orgEdgePath(points: readonly OrgPoint[]): string {
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`).join(' ');
+}
+
+type KeptEdge = Required & { id: string };
+
+function sanitizeEdges(
+ nodes: readonly OrgNode[],
+ edges: readonly OrgEdge[],
+): { kept: KeptEdge[]; dropped: OrgEdge[] } {
+ const ids = new Set(nodes.map((node) => node.id));
+ const dropped: OrgEdge[] = [];
+ const seen = new Set();
+ const candidates: KeptEdge[] = [];
+
+ for (const edge of edges) {
+ const kind = edge.kind ?? 'solid';
+ const id = `${kind}:${edge.source}->${edge.target}`;
+ if (seen.has(id)) continue;
+ if (!ids.has(edge.source) || !ids.has(edge.target) || edge.source === edge.target) {
+ dropped.push(edge);
+ continue;
+ }
+ seen.add(id);
+ candidates.push({ ...edge, kind, id });
+ }
+
+ // A cycle in line management is bad data (A manages B manages A). Drop the
+ // solid edge that closes each cycle; dotted lines may point anywhere.
+ const children = new Map();
+ for (const edge of candidates) {
+ if (edge.kind !== 'solid') continue;
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge]);
+ }
+ const state = new Map();
+ const backEdges = new Set();
+ const visit = (id: string) => {
+ state.set(id, 'visiting');
+ for (const edge of children.get(id) ?? []) {
+ const next = state.get(edge.target);
+ if (next === 'visiting') backEdges.add(edge.id);
+ else if (next === undefined) visit(edge.target);
+ }
+ state.set(id, 'done');
+ };
+ for (const node of nodes) if (!state.has(node.id)) visit(node.id);
+
+ const kept: KeptEdge[] = [];
+ for (const edge of candidates) {
+ if (backEdges.has(edge.id)) dropped.push({ source: edge.source, target: edge.target, kind: edge.kind });
+ else kept.push(edge);
+ }
+ return { kept, dropped };
+}
diff --git a/registry/blocks/org-chart/org-chart.component.ts b/registry/blocks/org-chart/org-chart.component.ts
new file mode 100644
index 00000000..a2c7ac9d
--- /dev/null
+++ b/registry/blocks/org-chart/org-chart.component.ts
@@ -0,0 +1,166 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ computed,
+ inject,
+ input,
+ linkedSignal,
+ model,
+ resource,
+} from '@angular/core';
+import { SANRING_AVATAR_IMPORTS } from '../avatar';
+import { BadgeDirective } from '../badge';
+import { SkeletonDirective } from '../skeleton';
+import {
+ createOrgLayoutEngine,
+ layoutOrg,
+ orgEdgePath,
+ type OrgEdge,
+ type OrgLayout,
+ type OrgLayoutEngine,
+} from './layout';
+
+export interface OrgPerson {
+ id: string;
+ name: string;
+ title?: string;
+ department?: string;
+ avatarUrl?: string;
+}
+
+/** `solid` = line manager, `dotted` = matrix / dotted-line reporting. */
+export type OrgLink = OrgEdge;
+
+@Component({
+ selector: 'sanring-org-chart',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SANRING_AVATAR_IMPORTS, BadgeDirective, SkeletonDirective],
+ host: {
+ class:
+ 'block overflow-auto rounded-[var(--sanring-radius-lg)] border border-[var(--sanring-border)] bg-[var(--sanring-background)]',
+ },
+ template: `
+ @if (layout.error()) {
+ Could not lay out the org chart.
+ } @else if (people().length === 0) {
+ No people to show.
+ } @else if (chart(); as c) {
+
+
+ @for (node of c.nodes; track node.id) {
+
+ }
+
+ } @else {
+
+ }
+ `,
+})
+export class OrgChartComponent {
+ readonly people = input.required();
+ readonly links = input.required();
+ readonly nodeWidth = input(240);
+ readonly nodeHeight = input(64);
+ /** Id of the highlighted person. */
+ readonly selected = model(null);
+
+ private engine?: OrgLayoutEngine & { terminateWorker?: () => void };
+
+ protected readonly layout = resource({
+ params: () => ({
+ people: this.people(),
+ links: this.links(),
+ nodeWidth: this.nodeWidth(),
+ nodeHeight: this.nodeHeight(),
+ }),
+ loader: ({ params }) => {
+ this.engine ??= createOrgLayoutEngine();
+ return layoutOrg(this.engine, params.people, params.links, params);
+ },
+ });
+
+ // Keep showing the previous layout while a new one is computed, so editing
+ // the data does not flash the loading skeleton.
+ private readonly lastLayout = linkedSignal({
+ source: () => this.layout.value(),
+ computation: (next, previous) => next ?? previous?.value,
+ });
+
+ protected readonly chart = computed(() => {
+ const layout = this.lastLayout();
+ if (!layout) return undefined;
+ const byId = new Map(this.people().map((person) => [person.id, person]));
+ return {
+ width: layout.width,
+ height: layout.height,
+ nodes: layout.nodes.flatMap((node) => {
+ const person = byId.get(node.id);
+ return person ? [{ ...node, person }] : [];
+ }),
+ edges: layout.edges.map((edge) => ({ id: edge.id, kind: edge.kind, d: orgEdgePath(edge.points) })),
+ };
+ });
+
+ constructor() {
+ inject(DestroyRef).onDestroy(() => this.engine?.terminateWorker?.());
+ }
+
+ protected initials(name: string): string {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]!.toUpperCase())
+ .join('');
+ }
+}
diff --git a/registry/blocks/org-chart/org-chart.worker.ts b/registry/blocks/org-chart/org-chart.worker.ts
new file mode 100644
index 00000000..e4a1e562
--- /dev/null
+++ b/registry/blocks/org-chart/org-chart.worker.ts
@@ -0,0 +1,5 @@
+///
+// Inside a worker scope elk-worker installs `self.onmessage` and speaks the
+// elk-api protocol used by createOrgLayoutEngine(). Do not swap this for
+// elk.bundled.js — that build spawns its own nested worker and fails here.
+import 'elkjs/lib/elk-worker.min.js';
diff --git a/registry/blocks/table-page/table-page.component.ts b/registry/blocks/table-page/table-page.component.ts
index d218eab3..5855ca1f 100644
--- a/registry/blocks/table-page/table-page.component.ts
+++ b/registry/blocks/table-page/table-page.component.ts
@@ -180,7 +180,7 @@ const SEED: TablePageRow[] = [
-