From bbb50e06b4badcb57d83dd64d9e98f75d33f0568 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 12:29:49 +0300 Subject: [PATCH] feat(ui): add activity indicator governance --- apps/catalog/src/CatalogApp.tsx | 15 ++ docs/COMPONENTS.md | 6 + package.json | 4 +- packages/ui-core/styles.css | 25 ++++ packages/ui-react/src/ActivityIndicator.tsx | 32 +++++ packages/ui-react/src/index.ts | 1 + registry/components.json | 16 +++ scripts/activity-indicator-contract.test.mjs | 41 ++++++ scripts/spark-governance-contract.test.mjs | 137 +++++++++++++++++++ 9 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/ui-react/src/ActivityIndicator.tsx create mode 100644 scripts/activity-indicator-contract.test.mjs create mode 100644 scripts/spark-governance-contract.test.mjs diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 5840264..e25b555 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns"; import { + ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, @@ -1372,6 +1373,20 @@ export function CatalogApp() { + +
+ + + Загружаем данные + + +
+

При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.

+
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 932db24..c099be9 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -25,6 +25,12 @@ Button используется для всех текстовых действ Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом. +## ActivityIndicator + +`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции. + +Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается. + ## Field FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля. diff --git a/package.json b/package.json index df06e45..7b950d5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -31,6 +31,8 @@ "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", + "test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs", + "test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs", "test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", "test:range-control": "node --test scripts/range-control-contract.test.mjs" diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index a21ef7e..49b20b1 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -211,6 +211,30 @@ transform: scale(var(--nodedc-icon-glyph-scale)); } +.nodedc-activity-indicator { + display: inline-block; + width: 1.125rem; + height: 1.125rem; + flex: 0 0 auto; + box-sizing: border-box; + border: 2px solid currentColor; + border-inline-end-color: transparent; + border-radius: var(--nodedc-radius-circle); + color: inherit; + opacity: 0.78; + animation: nodedc-activity-indicator-spin 780ms linear infinite; +} + +.nodedc-activity-indicator[data-size="compact"] { + width: 0.875rem; + height: 0.875rem; + border-width: 1.5px; +} + +@keyframes nodedc-activity-indicator-spin { + to { transform: rotate(360deg); } +} + .nodedc-icon-button { display: inline-grid; width: var(--nodedc-icon-button-size); @@ -3585,6 +3609,7 @@ textarea.nodedc-field__control { } @media (prefers-reduced-motion: reduce) { + .nodedc-activity-indicator, .nodedc-dropdown-surface, .nodedc-overlay, .nodedc-window, diff --git a/packages/ui-react/src/ActivityIndicator.tsx b/packages/ui-react/src/ActivityIndicator.tsx new file mode 100644 index 0000000..fd308c9 --- /dev/null +++ b/packages/ui-react/src/ActivityIndicator.tsx @@ -0,0 +1,32 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "./cn.js"; + +export type ActivityIndicatorSize = "default" | "compact"; + +export interface ActivityIndicatorProps extends Omit< + HTMLAttributes, + "aria-hidden" | "aria-label" | "children" | "role" +> { + size?: ActivityIndicatorSize; + label?: string; +} + +export function ActivityIndicator({ + size = "default", + label, + className, + ...props +}: ActivityIndicatorProps) { + const accessibleLabel = label?.trim() || undefined; + + return ( + + ); +} diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index c9ad7cd..600ec9f 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -1,4 +1,5 @@ export * from "./AppHeader.js"; +export * from "./ActivityIndicator.js"; export * from "./AdminNavigationPanel.js"; export * from "./ApplicationShell.js"; export * from "./ApplicationSidePanel.js"; diff --git a/registry/components.json b/registry/components.json index 5ea68e7..ef329c7 100644 --- a/registry/components.json +++ b/registry/components.json @@ -33,6 +33,22 @@ "Destructive actions remain neutral until confirmation unless danger is the principal message." ] }, + { + "id": "activity-indicator", + "status": "baseline", + "package": "@nodedc/ui-react", + "exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"], + "domContract": ["nodedc-activity-indicator"], + "summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.", + "variants": ["default", "compact"], + "behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"], + "rules": [ + "Use compact inside a Button icon slot and default for standalone inline progress.", + "The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.", + "The component never owns operation state, timing or completion.", + "Reduced-motion preferences stop rotation without hiding the pending-state affordance." + ] + }, { "id": "field", "status": "baseline", diff --git a/scripts/activity-indicator-contract.test.mjs b/scripts/activity-indicator-contract.test.mjs new file mode 100644 index 0000000..d0681f6 --- /dev/null +++ b/scripts/activity-indicator-contract.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ActivityIndicator } from "../packages/ui-react/dist/index.js"; + +test("ActivityIndicator separates decorative and announced progress", () => { + const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" })); + const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" })); + + assert.match(decorative, /class="nodedc-activity-indicator"/); + assert.match(decorative, /data-size="compact"/); + assert.match(decorative, /aria-hidden="true"/); + assert.doesNotMatch(decorative, /role="status"/); + + assert.match(announced, /role="status"/); + assert.match(announced, /aria-label="Подключаем устройство"/); + assert.doesNotMatch(announced, /aria-hidden/); + assert.doesNotMatch(announced, /data-size=/); +}); + +test("ActivityIndicator is registered, cataloged and motion-safe", async () => { + const [styles, registrySource, docs, catalog] = await Promise.all([ + readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), + readFile(new URL("../registry/components.json", import.meta.url), "utf8"), + readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"), + ]); + const registry = JSON.parse(registrySource); + const entry = registry.components.find((component) => component.id === "activity-indicator"); + + assert.deepEqual(entry?.variants, ["default", "compact"]); + assert.ok(entry?.exports.includes("ActivityIndicator")); + assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/); + assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/); + assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/); + assert.match(docs, /## ActivityIndicator/); + assert.match(catalog, /\}/); +}); diff --git a/scripts/spark-governance-contract.test.mjs b/scripts/spark-governance-contract.test.mjs new file mode 100644 index 0000000..0b48051 --- /dev/null +++ b/scripts/spark-governance-contract.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +function lineNumber(lines, value) { + return lines.findIndex((line) => line.includes(value)); +} + +function assertLine(lines, value, message) { + const index = lineNumber(lines, value); + assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`); + return index + 1; +} + +function extractBlock(lines, key) { + const start = lines.findIndex((line) => line.trim() === `${key} = """`); + assert.ok(start >= 0, `missing ${key} block start`); + let end = start + 1; + while (end < lines.length && lines[end] !== "\"\"\"") { + end += 1; + } + assert.ok(end < lines.length, `unterminated ${key} block`); + return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim(); +} + +test("global spark agent configuration matches governance contract", async () => { + const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/); + const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]"); + const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents"); + const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2"); + const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model"); + const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents"); + const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled"); + + assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present"); + assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true'); + assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2"); + assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"'); + assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"'); + assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true"); +}); + +test("spark_explorer contract", async () => { + const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/); + const instructions = extractBlock(explorerLines, "developer_instructions"); + + const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer"); + const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark"); + const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning"); + const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode"); + + assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"'); + assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"'); + assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"'); + assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"'); + assert.ok( + instructions.includes("Never edit, create, move, or delete files."), + "explorer instructions must prohibit edits", + ); + assert.ok( + instructions.includes("Never spawn another agent."), + "explorer instructions must prohibit nested agents", + ); +}); + +test("spark_worker contract", async () => { + const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/); + const instructions = extractBlock(workerLines, "developer_instructions"); + + const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker"); + const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark"); + const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning"); + const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode"); + + assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"'); + assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"'); + assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"'); + assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"'); + assert.ok( + instructions.includes("exact allowed file"), + "worker instructions must require exact allowed path allowlist", + ); + assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install"); + assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents"); + assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries"); +}); + +test("AGENTS and governance contract documents", async () => { + const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/); + const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/); + + assert.ok( + agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")), + "AGENTS.md must point to governance document", + ); + assert.ok( + agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")), + "AGENTS.md should state max two subagents", + ); + assert.ok( + agentsLines.some((line) => line.toLowerCase().includes("one write-capable")), + "AGENTS.md should state max one writer subagent", + ); + + assert.ok( + governanceLines.some((line) => line.includes("## Mandatory task packet")), + "governance should define mandatory task packet", + ); + assert.ok( + governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")), + "governance should include mandatory objective field", + ); + assert.ok( + governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")), + "governance should include allowed scope field", + ); + assert.ok( + governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")), + "governance should include forbidden actions field", + ); + assert.ok( + governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")), + "governance should include acceptance criteria field", + ); + assert.ok( + governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")), + "governance should include verification field", + ); + assert.ok( + governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")), + "governance should include output field", + ); + assert.ok( + governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")), + "governance should retain final primary review and verification ownership", + ); +});