From bd170ac202a6cd4e5b002ed9c5aa2f89312072fc Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Mon, 26 Oct 2026 00:38:01 +0200 Subject: [PATCH 1/6] feat(docs): add shared StateForge documentation system --- README.md | 78 ++++ pyproject.toml | 59 +++ src/stateforge_docs_theme/__init__.py | 10 + src/stateforge_docs_theme/plugin.py | 112 +++++ .../procedure/__init__.py | 6 + src/stateforge_docs_theme/procedure/model.py | 416 ++++++++++++++++++ .../procedure/renderer.py | 53 +++ .../procedure/schema.json | 131 ++++++ src/stateforge_docs_theme/py.typed | 1 + src/stateforge_docs_theme/theme/__init__.py | 1 + .../assets/images/stateforge-favicon.svg | 8 + .../theme/assets/images/stateforge-mark.svg | 13 + .../assets/javascripts/procedure-player.js | 290 ++++++++++++ .../theme/assets/stylesheets/stateforge.css | 366 +++++++++++++++ src/stateforge_docs_theme/theme/main.html | 7 + .../theme/mkdocs_theme.yml | 12 + tests/fixtures/site/docs/v0.1.0/index.md | 9 + .../docs/v0.1.0/procedures/bootstrap.yaml | 224 ++++++++++ tests/fixtures/site/mkdocs.yml | 29 ++ tests/test_package.py | 41 ++ tests/test_procedure_model.py | 171 +++++++ tests/test_renderer.py | 29 ++ 22 files changed, 2066 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/stateforge_docs_theme/__init__.py create mode 100644 src/stateforge_docs_theme/plugin.py create mode 100644 src/stateforge_docs_theme/procedure/__init__.py create mode 100644 src/stateforge_docs_theme/procedure/model.py create mode 100644 src/stateforge_docs_theme/procedure/renderer.py create mode 100644 src/stateforge_docs_theme/procedure/schema.json create mode 100644 src/stateforge_docs_theme/py.typed create mode 100644 src/stateforge_docs_theme/theme/__init__.py create mode 100644 src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg create mode 100644 src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg create mode 100644 src/stateforge_docs_theme/theme/assets/javascripts/procedure-player.js create mode 100644 src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css create mode 100644 src/stateforge_docs_theme/theme/main.html create mode 100644 src/stateforge_docs_theme/theme/mkdocs_theme.yml create mode 100644 tests/fixtures/site/docs/v0.1.0/index.md create mode 100644 tests/fixtures/site/docs/v0.1.0/procedures/bootstrap.yaml create mode 100644 tests/fixtures/site/mkdocs.yml create mode 100644 tests/test_package.py create mode 100644 tests/test_procedure_model.py create mode 100644 tests/test_renderer.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..da3f35d --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# StateForge Docs + +Shared documentation infrastructure for the StateForge project. + +This repository produces the `stateforge-docs-theme` Python distribution. It +extends Material for MkDocs with StateForge branding, shared presentation +components, and build-time validation. Documentation content remains in the +repository that owns it. + +## Interactive procedures + +The theme includes a declarative procedure player for explaining interactions +between any number of ordered entities. Processes, services, storage, +resources, external systems, and users can participate in the same flow. Each +step declares its active entities, concurrent transitions, internal activity, +shared context, success paths, and failure paths. The renderer contains no +Agent, Provider, Bootstrap, or other procedure-specific topology. + +Documentation versions come exclusively from the source directory. The whole +site for a release lives below that version, including its procedure pages and +declarations: + +```text +docs/ +├── v0.1.0/ +│ ├── index.md +│ ├── bootstrap.md +│ └── procedures/ +│ └── bootstrap.yaml +└── v0.2.0/ + └── ... +``` + +The filename stays `bootstrap.yaml`. A declaration must not contain a +`version`, `protocol_version`, or `documentation_version` field. The build +derives `v0.1.0` from the documentation tree and rejects ambiguous or missing +version locations. + +Embed a declaration from Markdown: + +````markdown +```stateforge-procedure +source: procedures/bootstrap.yaml +scenario: success +``` +```` + +The source path is relative to the Markdown page and must remain within the +MkDocs `docs_dir`. Builds reject traversal, oversized declarations, unknown +fields, malformed actors, invalid scenarios, and unsafe inline data. The +generated page contains a static ordered fallback before JavaScript enhances +it into the interactive player. + +## Consumer configuration + +```yaml +theme: + name: stateforge + +plugins: + - search + - stateforge: + component: protocol +``` + +Supported component identities are `stateforge`, `agent`, `provider`, +`protocol`, `control-plane`, and `sf-aop`. + +## Development + +```bash +python -m venv .venv +.venv/bin/pip install -e '.[dev]' +.venv/bin/ruff format --check . +.venv/bin/ruff check . +.venv/bin/mypy src +.venv/bin/pytest +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5791f70 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "stateforge-docs-theme" +version = "0.1.0" +description = "Shared documentation presentation and interactive procedure tooling for StateForge." +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "StateForge" }] +dependencies = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.6,<10", + "PyYAML>=6,<7", +] + +[project.optional-dependencies] +dev = [ + "build>=1.2", + "mypy>=1.17", + "pytest>=8.4", + "ruff>=0.12", + "types-PyYAML>=6", +] + +[project.entry-points."mkdocs.themes"] +stateforge = "stateforge_docs_theme.theme" + +[project.entry-points."mkdocs.plugins"] +stateforge = "stateforge_docs_theme.plugin:StateForgeDocsPlugin" + +[tool.hatch.build.targets.wheel] +packages = ["src/stateforge_docs_theme"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/README.md", + "/LICENSE", + "/pyproject.toml", +] + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +packages = ["stateforge_docs_theme"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/stateforge_docs_theme/__init__.py b/src/stateforge_docs_theme/__init__.py new file mode 100644 index 0000000..427fb6a --- /dev/null +++ b/src/stateforge_docs_theme/__init__.py @@ -0,0 +1,10 @@ +"""Shared StateForge documentation presentation and procedure tooling.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("stateforge-docs-theme") +except PackageNotFoundError: # pragma: no cover - source checkout + __version__ = "0.0.0" + +__all__ = ["__version__"] diff --git a/src/stateforge_docs_theme/plugin.py b/src/stateforge_docs_theme/plugin.py new file mode 100644 index 0000000..351cf4d --- /dev/null +++ b/src/stateforge_docs_theme/plugin.py @@ -0,0 +1,112 @@ +"""MkDocs integration for the shared StateForge documentation system.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml +from mkdocs.config import config_options +from mkdocs.config.base import Config +from mkdocs.exceptions import PluginError +from mkdocs.plugins import BasePlugin +from mkdocs.structure.files import Files +from mkdocs.structure.pages import Page + +from stateforge_docs_theme.procedure.model import ( + ProcedureDeclarationError, + load_procedure, +) +from stateforge_docs_theme.procedure.renderer import render_player + +_PROCEDURE_FENCE = re.compile( + r"^```stateforge-procedure[ \t]*\n(?P.*?)^```[ \t]*$", + re.MULTILINE | re.DOTALL, +) + + +class StateForgeDocsConfig(Config): # type: ignore[no-untyped-call] + """Configuration shared by all StateForge documentation consumers.""" + + component = config_options.Choice( + ( + "stateforge", + "agent", + "provider", + "protocol", + "control-plane", + "sf-aop", + ), + default="stateforge", + ) + procedure_players = config_options.Type(bool, default=True) + + +class StateForgeDocsPlugin( # type: ignore[no-untyped-call] + BasePlugin[StateForgeDocsConfig] +): + """Render declarative procedure players and expose component identity.""" + + def on_config(self, config: Any) -> Any: + extra = dict(config.extra or {}) + stateforge = dict(extra.get("stateforge") or {}) + stateforge["component"] = self.config.component + extra["stateforge"] = stateforge + config.extra = extra + return config + + def on_page_markdown( + self, + markdown: str, + *, + page: Page, + config: Any, + files: Files, + ) -> str: + del files + if not self.config.procedure_players: + return markdown + + if page.file.abs_src_path is None: + raise PluginError(f"page has no source path: {page.file.src_path}") + page_source = Path(page.file.abs_src_path) + docs_dir = Path(config.docs_dir) + + def replace(match: re.Match[str]) -> str: + try: + directive = _load_directive(match.group("body")) + source = (page_source.parent / directive["source"]).resolve() + declaration = load_procedure(source, docs_dir=docs_dir) + return render_player( + declaration, + selected_scenario=directive.get("scenario"), + ) + except (ProcedureDeclarationError, ValueError) as error: + raise PluginError( + f"{page.file.src_path}: invalid stateforge-procedure block: {error}" + ) from error + + return _PROCEDURE_FENCE.sub(replace, markdown) + + +def _load_directive(source: str) -> dict[str, str]: + try: + loaded = yaml.safe_load(source) + except yaml.YAMLError as error: + raise ProcedureDeclarationError("directive must be valid YAML") from error + if not isinstance(loaded, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in loaded.items() + ): + raise ProcedureDeclarationError( + "directive must be a string-keyed mapping of text values" + ) + missing = {"source"}.difference(loaded) + unknown = set(loaded).difference({"source", "scenario"}) + if missing: + raise ProcedureDeclarationError("directive requires source") + if unknown: + raise ProcedureDeclarationError( + f"directive contains unknown fields: {', '.join(sorted(unknown))}" + ) + return loaded diff --git a/src/stateforge_docs_theme/procedure/__init__.py b/src/stateforge_docs_theme/procedure/__init__.py new file mode 100644 index 0000000..f43ba78 --- /dev/null +++ b/src/stateforge_docs_theme/procedure/__init__.py @@ -0,0 +1,6 @@ +"""Declarative interactive procedure rendering.""" + +from stateforge_docs_theme.procedure.model import load_procedure +from stateforge_docs_theme.procedure.renderer import render_player + +__all__ = ["load_procedure", "render_player"] diff --git a/src/stateforge_docs_theme/procedure/model.py b/src/stateforge_docs_theme/procedure/model.py new file mode 100644 index 0000000..9b604cd --- /dev/null +++ b/src/stateforge_docs_theme/procedure/model.py @@ -0,0 +1,416 @@ +"""Load and validate versioned procedure declarations.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +SCHEMA_ID = "stateforge.docs/procedure/v1" +MAX_DECLARATION_BYTES = 512 * 1024 + +_VERSION_DIRECTORY = re.compile(r"^v\d+(?:\.\d+)*$") +_VERSIONED_FILENAME = re.compile(r"(?:^|[-_.])v\d+(?:\.\d+)*(?:$|[-_.])") +_FORBIDDEN_VERSION_KEYS = frozenset( + {"version", "protocol_version", "documentation_version"} +) +_ENTITY_KINDS = frozenset( + {"process", "service", "resource", "storage", "external", "user"} +) +_TONES = frozenset({"normal", "success", "warning", "failure"}) + + +class ProcedureDeclarationError(ValueError): + """Raised when a procedure declaration violates the shared contract.""" + + +def load_procedure(path: Path, *, docs_dir: Path) -> dict[str, Any]: + """Load one declaration and derive its documentation version from its path.""" + resolved_docs = docs_dir.resolve() + resolved_path = path.resolve() + if not resolved_path.is_relative_to(resolved_docs): + raise ProcedureDeclarationError("procedure source must remain inside docs_dir") + if resolved_path.suffix not in {".yaml", ".yml"}: + raise ProcedureDeclarationError("procedure source must be YAML") + if _VERSIONED_FILENAME.search(resolved_path.stem): + raise ProcedureDeclarationError( + "procedure filenames must not contain versions; use a version directory" + ) + try: + size = resolved_path.stat().st_size + except OSError as error: + raise ProcedureDeclarationError( + f"cannot read procedure source: {resolved_path}" + ) from error + if size > MAX_DECLARATION_BYTES: + raise ProcedureDeclarationError( + f"procedure source exceeds {MAX_DECLARATION_BYTES} bytes" + ) + try: + loaded = yaml.safe_load(resolved_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as error: + raise ProcedureDeclarationError( + f"invalid procedure source: {resolved_path}" + ) from error + + declaration = _mapping(loaded, "procedure declaration") + forbidden = _FORBIDDEN_VERSION_KEYS.intersection(declaration) + if forbidden: + names = ", ".join(sorted(forbidden)) + raise ProcedureDeclarationError( + f"procedure versions come from directories, not fields: {names}" + ) + + relative = resolved_path.relative_to(resolved_docs) + documentation_version = _version_from_path(relative) + _validate_declaration(declaration) + expanded = _expand_scenario_prefixes(declaration) + return {**expanded, "documentation_version": documentation_version} + + +def _version_from_path(relative_path: Path) -> str: + for part in reversed(relative_path.parts[:-1]): + if _VERSION_DIRECTORY.fullmatch(part): + return part + raise ProcedureDeclarationError( + "procedure source must be located below a version directory such as " + "v0.1.0/procedures/bootstrap.yaml" + ) + + +def _validate_declaration(declaration: dict[str, Any]) -> None: + _exact_keys( + declaration, + required={"schema", "procedure", "title", "summary", "entities", "scenarios"}, + optional={"default_scenario"}, + context="procedure declaration", + ) + if _text(declaration["schema"], "schema") != SCHEMA_ID: + raise ProcedureDeclarationError(f"schema must be {SCHEMA_ID!r}") + _identifier(declaration["procedure"], "procedure") + _text(declaration["title"], "title") + _text(declaration["summary"], "summary") + + entities = _list(declaration["entities"], "entities") + if not entities: + raise ProcedureDeclarationError("entities must not be empty") + entity_ids: set[str] = set() + for index, value in enumerate(entities): + entity_id = _validate_entity(value, f"entities[{index}]") + if entity_id in entity_ids: + raise ProcedureDeclarationError(f"duplicate entity ID: {entity_id}") + entity_ids.add(entity_id) + + scenarios = _mapping(declaration["scenarios"], "scenarios") + if not scenarios: + raise ProcedureDeclarationError("scenarios must not be empty") + scenario_ids = set(scenarios) + for scenario_id, scenario_value in scenarios.items(): + _identifier(scenario_id, "scenario identifier") + _validate_scenario( + scenario_id, + scenario_value, + entity_ids, + scenario_ids, + ) + + default_scenario = declaration.get("default_scenario", next(iter(scenarios))) + if default_scenario not in scenarios: + raise ProcedureDeclarationError( + "default_scenario must identify one declared scenario" + ) + + +def _validate_entity(value: Any, context: str) -> str: + entity = _mapping(value, context) + _exact_keys( + entity, + required={"id", "label", "kind"}, + optional={"role"}, + context=context, + ) + entity_id = _identifier(entity["id"], f"{context}.id") + _text(entity["label"], f"{context}.label") + kind = _text(entity["kind"], f"{context}.kind") + if kind not in _ENTITY_KINDS: + raise ProcedureDeclarationError( + f"{context}.kind must be one of {sorted(_ENTITY_KINDS)}" + ) + if "role" in entity: + _text(entity["role"], f"{context}.role") + return entity_id + + +def _validate_scenario( + scenario_id: str, + value: Any, + entity_ids: set[str], + scenario_ids: set[str], +) -> None: + context = f"scenarios.{scenario_id}" + scenario = _mapping(value, context) + _exact_keys( + scenario, + required={"label", "steps"}, + optional={"prefix"}, + context=context, + ) + _text(scenario["label"], f"{context}.label") + if "prefix" in scenario: + prefix = _mapping(scenario["prefix"], f"{context}.prefix") + _exact_keys( + prefix, + required={"scenario", "through"}, + optional=set(), + context=f"{context}.prefix", + ) + parent = _identifier( + prefix["scenario"], + f"{context}.prefix.scenario", + ) + if parent not in scenario_ids: + raise ProcedureDeclarationError( + f"{context}.prefix references unknown scenario: {parent}" + ) + _identifier(prefix["through"], f"{context}.prefix.through") + steps = _list(scenario["steps"], f"{context}.steps") + if not steps: + raise ProcedureDeclarationError(f"{context}.steps must not be empty") + step_ids: set[str] = set() + for index, value in enumerate(steps): + step_id = _validate_step( + value, + f"{context}.steps[{index}]", + entity_ids, + ) + if step_id is None: + continue + if step_id in step_ids: + raise ProcedureDeclarationError( + f"{context}.steps contains duplicate step ID: {step_id}" + ) + step_ids.add(step_id) + + +def _validate_step( + value: Any, + context: str, + entity_ids: set[str], +) -> str | None: + step = _mapping(value, context) + _exact_keys( + step, + required={ + "title", + "detail", + "phase", + "active", + "transitions", + "activities", + }, + optional={"id", "fields", "context"}, + context=context, + ) + step_id = _identifier(step["id"], f"{context}.id") if "id" in step else None + _text(step["title"], f"{context}.title") + _text(step["detail"], f"{context}.detail") + _text(step["phase"], f"{context}.phase") + _text_list(step.get("fields", []), f"{context}.fields") + + active = _identifier_list(step["active"], f"{context}.active") + _require_known_entities(active, entity_ids, f"{context}.active") + + transitions = _list(step["transitions"], f"{context}.transitions") + for index, transition_value in enumerate(transitions): + _validate_transition( + transition_value, + f"{context}.transitions[{index}]", + entity_ids, + ) + + activities = _mapping(step["activities"], f"{context}.activities") + _require_known_entities(set(activities), entity_ids, f"{context}.activities") + for entity_id, activity_value in activities.items(): + activity = _mapping(activity_value, f"{context}.activities.{entity_id}") + _exact_keys( + activity, + required={"title", "items"}, + optional=set(), + context=f"{context}.activities.{entity_id}", + ) + _text(activity["title"], f"{context}.activities.{entity_id}.title") + _text_list(activity["items"], f"{context}.activities.{entity_id}.items") + + if "context" in step: + shared_context = _mapping(step["context"], f"{context}.context") + _exact_keys( + shared_context, + required={"kind", "value"}, + optional=set(), + context=f"{context}.context", + ) + _text(shared_context["kind"], f"{context}.context.kind") + _text(shared_context["value"], f"{context}.context.value") + return step_id + + +def _expand_scenario_prefixes( + declaration: dict[str, Any], +) -> dict[str, Any]: + scenarios = _mapping(declaration["scenarios"], "scenarios") + expanded_steps: dict[str, list[Any]] = {} + + def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: + if scenario_id in expanded_steps: + return expanded_steps[scenario_id] + if scenario_id in stack: + cycle = " -> ".join((*stack, scenario_id)) + raise ProcedureDeclarationError(f"scenario prefix cycle detected: {cycle}") + + scenario = _mapping( + scenarios[scenario_id], + f"scenarios.{scenario_id}", + ) + inherited: list[Any] = [] + if "prefix" in scenario: + prefix = _mapping( + scenario["prefix"], + f"scenarios.{scenario_id}.prefix", + ) + parent_id = prefix["scenario"] + through = prefix["through"] + parent_steps = expand(parent_id, (*stack, scenario_id)) + for index, step_value in enumerate(parent_steps): + step = _mapping( + step_value, + f"scenarios.{parent_id}.steps[{index}]", + ) + inherited.append(step_value) + if step.get("id") == through: + break + else: + raise ProcedureDeclarationError( + f"scenarios.{scenario_id}.prefix references unknown step " + f"in {parent_id}: {through}" + ) + + combined = [*inherited, *_list(scenario["steps"], "steps")] + identifiers = [ + step["id"] for step in combined if isinstance(step, dict) and "id" in step + ] + if len(identifiers) != len(set(identifiers)): + raise ProcedureDeclarationError( + f"scenarios.{scenario_id} expands to duplicate step IDs" + ) + expanded_steps[scenario_id] = combined + return combined + + expanded_scenarios = { + scenario_id: { + key: item + for key, item in _mapping( + scenario_value, + f"scenarios.{scenario_id}", + ).items() + if key != "prefix" + } + for scenario_id, scenario_value in scenarios.items() + } + for scenario_id in scenarios: + expanded_scenarios[scenario_id]["steps"] = expand(scenario_id, ()) + + return {**declaration, "scenarios": expanded_scenarios} + + +def _validate_transition(value: Any, context: str, entity_ids: set[str]) -> None: + transition = _mapping(value, context) + _exact_keys( + transition, + required={"source", "target", "label"}, + optional={"tone"}, + context=context, + ) + source = _identifier(transition["source"], f"{context}.source") + target = _identifier(transition["target"], f"{context}.target") + _require_known_entities({source, target}, entity_ids, context) + _text(transition["label"], f"{context}.label") + tone = _text(transition.get("tone", "normal"), f"{context}.tone") + if tone not in _TONES: + raise ProcedureDeclarationError( + f"{context}.tone must be one of {sorted(_TONES)}" + ) + + +def _require_known_entities( + referenced: set[str], entity_ids: set[str], context: str +) -> None: + unknown = referenced.difference(entity_ids) + if unknown: + raise ProcedureDeclarationError( + f"{context} references unknown entities: {', '.join(sorted(unknown))}" + ) + + +def _mapping(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise ProcedureDeclarationError(f"{context} must be a string-keyed mapping") + return value + + +def _list(value: Any, context: str) -> list[Any]: + if not isinstance(value, list): + raise ProcedureDeclarationError(f"{context} must be a list") + return value + + +def _text(value: Any, context: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ProcedureDeclarationError(f"{context} must be non-empty text") + return value + + +def _identifier(value: Any, context: str) -> str: + identifier = _text(value, context) + if not re.fullmatch(r"[a-z][a-z0-9-]*", identifier): + raise ProcedureDeclarationError( + f"{context} must use lowercase letters, digits, and hyphens" + ) + return identifier + + +def _text_list(value: Any, context: str) -> list[str]: + values = _list(value, context) + for index, item in enumerate(values): + _text(item, f"{context}[{index}]") + return values + + +def _identifier_list(value: Any, context: str) -> set[str]: + values = _list(value, context) + identifiers = { + _identifier(item, f"{context}[{index}]") for index, item in enumerate(values) + } + if len(identifiers) != len(values): + raise ProcedureDeclarationError(f"{context} must not contain duplicates") + return identifiers + + +def _exact_keys( + value: dict[str, Any], + *, + required: set[str], + optional: set[str], + context: str, +) -> None: + missing = required.difference(value) + unknown = set(value).difference(required | optional) + if missing: + raise ProcedureDeclarationError( + f"{context} is missing: {', '.join(sorted(missing))}" + ) + if unknown: + raise ProcedureDeclarationError( + f"{context} contains unknown fields: {', '.join(sorted(unknown))}" + ) diff --git a/src/stateforge_docs_theme/procedure/renderer.py b/src/stateforge_docs_theme/procedure/renderer.py new file mode 100644 index 0000000..a5da10b --- /dev/null +++ b/src/stateforge_docs_theme/procedure/renderer.py @@ -0,0 +1,53 @@ +"""Render validated procedure declarations for the shared browser player.""" + +from __future__ import annotations + +import html +import json +from typing import Any + + +def render_player( + declaration: dict[str, Any], *, selected_scenario: str | None = None +) -> str: + """Render one interactive player plus a complete static fallback.""" + scenarios = declaration["scenarios"] + scenario_id = selected_scenario or declaration.get("default_scenario") + if scenario_id is None: + scenario_id = next(iter(scenarios)) + if scenario_id not in scenarios: + raise ValueError(f"unknown procedure scenario: {scenario_id}") + + payload = {**declaration, "default_scenario": scenario_id} + encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + encoded = ( + encoded.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e") + ) + fallback = _render_fallback(declaration, scenario_id) + return ( + '\n
\n' + "' + f'' + f"{fallback}" + "\n" + "
\n" + ) + + +def _render_fallback(declaration: dict[str, Any], scenario_id: str) -> str: + scenario = declaration["scenarios"][scenario_id] + steps = "".join( + "
  • " + f"{html.escape(step['title'])} " + f"{html.escape(step['detail'])}" + "
  • " + for step in scenario["steps"] + ) + return ( + '
    ' + f"{html.escape(declaration['title'])}: " + f"{html.escape(scenario['label'])}" + f"
      {steps}
    " + "
    " + ) diff --git a/src/stateforge_docs_theme/procedure/schema.json b/src/stateforge_docs_theme/procedure/schema.json new file mode 100644 index 0000000..fd348bb --- /dev/null +++ b/src/stateforge_docs_theme/procedure/schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stateforge.net/schemas/docs/procedure/v1", + "title": "StateForge interactive procedure declaration", + "type": "object", + "additionalProperties": false, + "required": ["schema", "procedure", "title", "summary", "entities", "scenarios"], + "properties": { + "schema": { "const": "stateforge.docs/procedure/v1" }, + "procedure": { "$ref": "#/$defs/identifier" }, + "title": { "$ref": "#/$defs/text" }, + "summary": { "$ref": "#/$defs/text" }, + "default_scenario": { "$ref": "#/$defs/identifier" }, + "entities": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/entity" } + }, + "scenarios": { + "type": "object", + "minProperties": 1, + "propertyNames": { "$ref": "#/$defs/identifier" }, + "additionalProperties": { "$ref": "#/$defs/scenario" } + } + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "entity": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "kind"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "label": { "$ref": "#/$defs/text" }, + "kind": { + "enum": ["process", "service", "resource", "storage", "external", "user"] + }, + "role": { "$ref": "#/$defs/text" } + } + }, + "activity": { + "type": "object", + "additionalProperties": false, + "required": ["title", "items"], + "properties": { + "title": { "$ref": "#/$defs/text" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/text" } + } + } + }, + "transition": { + "type": "object", + "additionalProperties": false, + "required": ["source", "target", "label"], + "properties": { + "source": { "$ref": "#/$defs/identifier" }, + "target": { "$ref": "#/$defs/identifier" }, + "label": { "$ref": "#/$defs/text" }, + "tone": { "enum": ["normal", "success", "warning", "failure"] } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": ["title", "detail", "phase", "active", "transitions", "activities"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "title": { "$ref": "#/$defs/text" }, + "detail": { "$ref": "#/$defs/text" }, + "phase": { "$ref": "#/$defs/text" }, + "active": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/identifier" } + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/text" } + }, + "transitions": { + "type": "array", + "items": { "$ref": "#/$defs/transition" } + }, + "activities": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/activity" } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value"], + "properties": { + "kind": { "$ref": "#/$defs/text" }, + "value": { "$ref": "#/$defs/text" } + } + } + } + }, + "scenario": { + "type": "object", + "additionalProperties": false, + "required": ["label", "steps"], + "properties": { + "label": { "$ref": "#/$defs/text" }, + "prefix": { + "type": "object", + "additionalProperties": false, + "required": ["scenario", "through"], + "properties": { + "scenario": { "$ref": "#/$defs/identifier" }, + "through": { "$ref": "#/$defs/identifier" } + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/step" } + } + } + } + } +} diff --git a/src/stateforge_docs_theme/py.typed b/src/stateforge_docs_theme/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/stateforge_docs_theme/py.typed @@ -0,0 +1 @@ + diff --git a/src/stateforge_docs_theme/theme/__init__.py b/src/stateforge_docs_theme/theme/__init__.py new file mode 100644 index 0000000..d41f699 --- /dev/null +++ b/src/stateforge_docs_theme/theme/__init__.py @@ -0,0 +1 @@ +"""MkDocs theme resources for StateForge documentation.""" diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg new file mode 100644 index 0000000..46dc5ad --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg @@ -0,0 +1,8 @@ + + StateForge + + + + + + diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg new file mode 100644 index 0000000..a2698eb --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg @@ -0,0 +1,13 @@ + + StateForge + A cube containing a layered state checkpoint. + + + + + + + + + + diff --git a/src/stateforge_docs_theme/theme/assets/javascripts/procedure-player.js b/src/stateforge_docs_theme/theme/assets/javascripts/procedure-player.js new file mode 100644 index 0000000..86c587e --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/javascripts/procedure-player.js @@ -0,0 +1,290 @@ +(() => { + "use strict"; + + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function button(label, action, primary = false) { + const node = element("button", primary ? "is-primary" : "", label); + node.type = "button"; + node.dataset.action = action; + return node; + } + + class StateForgeProcedurePlayer extends HTMLElement { + connectedCallback() { + if (this.dataset.initialized === "true") return; + const source = this.querySelector(".sf-procedure-data"); + if (!source) return; + try { + this.declaration = JSON.parse(source.textContent || ""); + } catch (error) { + console.error("StateForge procedure declaration is invalid", error); + return; + } + this.scenario = this.declaration.default_scenario; + this.stepIndex = 0; + this.timer = null; + this.dataset.initialized = "true"; + this.build(source); + this.render(); + } + + disconnectedCallback() { + this.stop(); + } + + build(source) { + const fallback = this.querySelector(".sf-procedure-fallback"); + if (fallback) fallback.remove(); + + this.player = element("section", "sf-procedure"); + this.player.setAttribute( + "aria-label", + `${this.declaration.title} interactive procedure`, + ); + this.player.style.setProperty( + "--sf-entity-count", + String(this.declaration.entities.length), + ); + + const scenarioLabel = element( + "label", + "sf-procedure__scenario-label", + "Scenario", + ); + this.scenarioSelect = element("select"); + this.scenarioSelect.setAttribute("aria-label", "Procedure scenario"); + for (const [id, scenario] of Object.entries(this.declaration.scenarios)) { + const option = element("option", "", scenario.label); + option.value = id; + this.scenarioSelect.append(option); + } + this.scenarioSelect.addEventListener("change", () => { + this.stop(); + this.scenario = this.scenarioSelect.value; + this.stepIndex = 0; + this.render(); + }); + scenarioLabel.append(this.scenarioSelect); + scenarioLabel.hidden = Object.keys(this.declaration.scenarios).length < 2; + + this.topologyScroll = element("div", "sf-procedure__topology-scroll"); + this.topology = element("div", "sf-procedure__topology"); + this.laneGrid = element("div", "sf-procedure__lane-grid"); + this.lanes = new Map(); + this.entityCards = new Map(); + this.activityPanels = new Map(); + for (const entity of this.declaration.entities) { + const lane = element("section", "sf-procedure__lane"); + lane.dataset.entity = entity.id; + const card = element("div", "sf-procedure__entity"); + card.dataset.entity = entity.id; + if (entity.role) card.title = entity.role; + card.append( + element("span", "sf-procedure__entity-kind", entity.kind), + element("strong", "", entity.label), + ); + const panel = element("div", "sf-procedure__activity"); + panel.dataset.entity = entity.id; + panel.append(element("strong"), element("ul")); + lane.append(card, panel); + this.lanes.set(entity.id, lane); + this.entityCards.set(entity.id, card); + this.activityPanels.set(entity.id, panel); + this.laneGrid.append(lane); + } + + this.phase = element("div", "sf-procedure__phase"); + this.phase.append( + element("span", "sf-procedure__phase-label", "Phase"), + element("strong"), + ); + + this.flowGrid = element("div", "sf-procedure__flow-grid"); + this.flowGrid.setAttribute("aria-label", "Current procedure transitions"); + + this.context = element("div", "sf-procedure__context"); + this.contextKind = element("span", "sf-procedure__context-kind"); + this.contextValue = element("code"); + this.context.append(this.contextKind, this.contextValue); + + this.topology.append(this.laneGrid, this.flowGrid); + this.topologyScroll.append(this.topology); + + this.detail = element("div", "sf-procedure__detail"); + this.detail.setAttribute("aria-live", "polite"); + const detailCopy = element("div"); + this.stepCount = element("span", "sf-procedure__step-count"); + this.stepTitle = element("h3"); + this.stepDetail = element("p"); + detailCopy.append(this.stepCount, this.stepTitle, this.stepDetail); + this.fields = element("div", "sf-procedure__fields"); + this.detail.append(detailCopy, this.fields); + + this.progress = element("div", "sf-procedure__progress"); + this.progress.setAttribute("role", "progressbar"); + this.progress.setAttribute("aria-label", "Procedure progress"); + this.progress.setAttribute("aria-valuemin", "1"); + this.progressBar = element("div", "sf-procedure__progress-bar"); + this.progress.append(this.progressBar); + + const controls = element("div", "sf-procedure__controls"); + this.previous = button("Previous", "previous"); + this.play = button("Play", "play", true); + this.play.setAttribute("aria-pressed", "false"); + this.next = button("Next", "next"); + controls.append(scenarioLabel, this.previous, this.play, this.next); + controls.addEventListener("click", (event) => { + const target = event.target.closest("button[data-action]"); + if (!target) return; + if (target.dataset.action === "previous") this.move(-1); + if (target.dataset.action === "next") this.move(1); + if (target.dataset.action === "play") this.togglePlay(); + }); + + this.player.append( + this.phase, + this.topologyScroll, + this.context, + this.detail, + this.progress, + controls, + ); + source.after(this.player); + } + + get steps() { + return this.declaration.scenarios[this.scenario].steps; + } + + move(offset) { + this.stop(); + this.stepIndex = Math.max( + 0, + Math.min(this.stepIndex + offset, this.steps.length - 1), + ); + this.render(); + } + + togglePlay() { + if (this.timer) { + this.stop(); + return; + } + if (this.stepIndex === this.steps.length - 1) this.stepIndex = 0; + this.play.textContent = "Pause"; + this.play.setAttribute("aria-pressed", "true"); + this.render(); + this.timer = window.setInterval(() => { + if (this.stepIndex >= this.steps.length - 1) { + this.stop(); + return; + } + this.stepIndex += 1; + this.render(); + }, 2300); + } + + stop() { + if (this.timer) window.clearInterval(this.timer); + this.timer = null; + if (this.play) { + this.play.textContent = "Play"; + this.play.setAttribute("aria-pressed", "false"); + } + } + + renderActivity(entity, activity, active) { + const panel = this.activityPanels.get(entity.id); + panel.hidden = !activity; + panel.classList.toggle("is-idle", !active); + panel.querySelector("strong").textContent = activity?.title || ""; + const items = activity ? activity.items : []; + panel.querySelector("ul").replaceChildren( + ...items.map((value) => element("li", "", value)), + ); + } + + renderTransitions(transitions) { + this.flowGrid.replaceChildren(); + const entityIndex = new Map( + this.declaration.entities.map((entity, index) => [entity.id, index]), + ); + transitions.forEach((transition, rowIndex) => { + const source = entityIndex.get(transition.source); + const target = entityIndex.get(transition.target); + const start = Math.min(source, target) + 1; + const end = Math.max(source, target) + 2; + const flow = element("div", "sf-procedure__transition"); + flow.style.gridColumn = `${start} / ${end}`; + flow.style.gridRow = String(rowIndex + 1); + flow.classList.toggle("is-reverse", source > target); + flow.classList.toggle("is-self", source === target); + flow.dataset.tone = transition.tone || "normal"; + flow.append(element("span", "sf-procedure__transition-line")); + const message = element( + "span", + "sf-procedure__transition-message", + transition.label, + ); + flow.append(message); + this.flowGrid.append(flow); + }); + this.flowGrid.classList.toggle("is-empty", transitions.length === 0); + } + + render() { + const current = this.steps[this.stepIndex]; + const active = new Set(current.active); + const completion = ((this.stepIndex + 1) / this.steps.length) * 100; + + this.scenarioSelect.value = this.scenario; + this.phase.querySelector("strong").textContent = current.phase; + + for (const entity of this.declaration.entities) { + this.lanes + .get(entity.id) + .classList.toggle("is-active", active.has(entity.id)); + this.entityCards + .get(entity.id) + .classList.toggle("is-active", active.has(entity.id)); + this.renderActivity( + entity, + current.activities[entity.id], + active.has(entity.id), + ); + } + this.renderTransitions(current.transitions); + + this.context.hidden = !current.context; + if (current.context) { + this.contextKind.textContent = current.context.kind; + this.contextValue.textContent = current.context.value; + } + + this.stepCount.textContent = `Step ${this.stepIndex + 1} of ${this.steps.length}`; + this.stepTitle.textContent = current.title; + this.stepDetail.textContent = current.detail; + this.fields.replaceChildren( + ...(current.fields || []).map((value) => element("code", "", value)), + ); + this.progress.setAttribute("aria-valuemax", String(this.steps.length)); + this.progress.setAttribute("aria-valuenow", String(this.stepIndex + 1)); + this.progressBar.style.width = `${completion}%`; + this.previous.disabled = this.stepIndex === 0; + this.next.disabled = this.stepIndex === this.steps.length - 1; + } + } + + if (!customElements.get("stateforge-procedure-player")) { + customElements.define( + "stateforge-procedure-player", + StateForgeProcedurePlayer, + ); + } +})(); diff --git a/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css new file mode 100644 index 0000000..aefbbac --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css @@ -0,0 +1,366 @@ +:root { + --sf-accent: #2878ef; + --sf-series-1: #2878ef; + --sf-series-2: #0b8a71; + --sf-series-3: #b76509; + --sf-series-4: #7652c7; + --sf-series-5: #b53e62; + --sf-series-6: #397a95; + --sf-success: #16865b; + --sf-warning: #c66b0a; + --sf-failure: #c43c4a; + --sf-surface: color-mix(in srgb, var(--md-default-bg-color) 94%, var(--sf-accent)); + --sf-surface-strong: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--sf-accent)); + --sf-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent); + --sf-muted: color-mix(in srgb, var(--md-default-fg-color) 66%, transparent); +} + +[data-md-color-scheme="slate"] { + --sf-surface: color-mix(in srgb, var(--md-default-bg-color) 90%, var(--sf-accent)); + --sf-surface-strong: color-mix(in srgb, var(--md-default-bg-color) 82%, var(--sf-accent)); + --sf-border: color-mix(in srgb, var(--md-default-fg-color) 24%, transparent); + --sf-muted: color-mix(in srgb, var(--md-default-fg-color) 72%, transparent); +} + +.md-header { + background: color-mix(in srgb, var(--md-primary-fg-color) 94%, #07111f); +} + +.md-grid { + max-width: 90rem; +} + +stateforge-procedure-player { + display: block; + margin: 2rem 0; + max-width: 100%; + min-width: 0; +} + +.sf-procedure { + color: var(--md-default-fg-color); + font-size: .76rem; + max-width: 100%; + min-width: 0; +} + +.sf-procedure__scenario-label { + color: var(--sf-muted); + display: grid; + font-size: .64rem; + gap: .25rem; + min-width: 9rem; +} + +.sf-procedure select, +.sf-procedure button { + background: var(--md-default-bg-color); + border: 1px solid var(--sf-border); + border-radius: .45rem; + color: var(--md-default-fg-color); + font: inherit; +} + +.sf-procedure select { + min-height: 2rem; + padding: .35rem .6rem; +} + +.sf-procedure button { + min-height: 2rem; + padding: .35rem .8rem; +} + +.sf-procedure button:not(:disabled):hover, +.sf-procedure button:not(:disabled):focus-visible, +.sf-procedure select:focus-visible { + border-color: var(--sf-accent); + outline: 2px solid color-mix(in srgb, var(--sf-accent) 35%, transparent); + outline-offset: 2px; +} + +.sf-procedure button:disabled { + opacity: .45; +} + +.sf-procedure__topology-scroll { + overflow-x: auto; + padding-bottom: .4rem; + scrollbar-color: var(--sf-border) transparent; +} + +.sf-procedure__topology { + min-width: max(100%, calc(var(--sf-entity-count) * 8.5rem)); +} + +.sf-procedure__lane-grid, +.sf-procedure__flow-grid { + display: grid; + gap: .65rem; + grid-template-columns: repeat(var(--sf-entity-count), minmax(7.25rem, 1fr)); +} + +.sf-procedure__lane { + --sf-entity-accent: var(--sf-series-1); +} + +.sf-procedure__lane:nth-child(6n + 2) { --sf-entity-accent: var(--sf-series-2); } +.sf-procedure__lane:nth-child(6n + 3) { --sf-entity-accent: var(--sf-series-3); } +.sf-procedure__lane:nth-child(6n + 4) { --sf-entity-accent: var(--sf-series-4); } +.sf-procedure__lane:nth-child(6n + 5) { --sf-entity-accent: var(--sf-series-5); } +.sf-procedure__lane:nth-child(6n + 6) { --sf-entity-accent: var(--sf-series-6); } + +.sf-procedure__lane { + background: color-mix(in srgb, var(--sf-entity-accent) 9%, var(--md-default-bg-color)); + border-top: 3px solid color-mix(in srgb, var(--sf-entity-accent) 55%, var(--sf-border)); + min-height: 7rem; + padding: .65rem .75rem .75rem; + transition: background-color .2s ease, border-color .2s ease, opacity .2s ease; +} + +.sf-procedure__lane.is-active { + background: color-mix(in srgb, var(--sf-entity-accent) 20%, var(--md-default-bg-color)); + border-color: var(--sf-entity-accent); +} + +.sf-procedure__lane:not(.is-active) { + opacity: .58; +} + +.sf-procedure__entity-kind, +.sf-procedure__phase-label, +.sf-procedure__context-kind, +.sf-procedure__step-count { + color: var(--sf-muted); + display: block; + font-size: .61rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.sf-procedure__entity strong, +.sf-procedure__activity strong { + display: block; + margin: .2rem 0; +} + +.sf-procedure__phase { + align-items: center; + display: flex; + gap: .4rem; + justify-content: center; + margin: .75rem 0 .25rem; +} + +.sf-procedure__phase strong { + background: var(--sf-surface-strong); + border-radius: 999px; + font-size: .62rem; + padding: .18rem .5rem; +} + +.sf-procedure__flow-grid { + grid-auto-rows: minmax(3.2rem, auto); + margin-top: .55rem; + min-height: 3.2rem; + position: relative; +} + +.sf-procedure__flow-grid.is-empty::before { + background: var(--sf-border); + content: ""; + height: 2px; + left: 3%; + position: absolute; + right: 3%; + top: 1.6rem; +} + +.sf-procedure__transition { + --sf-transition-color: var(--sf-accent); + min-width: 0; + position: relative; +} + +.sf-procedure__transition[data-tone="success"] { --sf-transition-color: var(--sf-success); } +.sf-procedure__transition[data-tone="warning"] { --sf-transition-color: var(--sf-warning); } +.sf-procedure__transition[data-tone="failure"] { --sf-transition-color: var(--sf-failure); } + +.sf-procedure__transition-line { + background: var(--sf-transition-color); + height: 2px; + left: 5%; + position: absolute; + right: 5%; + top: 1.6rem; +} + +.sf-procedure__transition-line::after { + border-bottom: 5px solid transparent; + border-left: 8px solid var(--sf-transition-color); + border-top: 5px solid transparent; + content: ""; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); +} + +.sf-procedure__transition.is-reverse .sf-procedure__transition-line::after { + border-left: 0; + border-right: 8px solid var(--sf-transition-color); + left: -1px; + right: auto; +} + +.sf-procedure__transition.is-self .sf-procedure__transition-line { + left: 35%; + right: 35%; +} + +.sf-procedure__transition-message { + background: var(--md-default-bg-color); + border: 1px solid var(--sf-transition-color); + border-radius: .28rem; + color: var(--md-default-fg-color); + font-size: .67rem; + font-weight: 600; + left: 50%; + max-width: calc(100% - 1rem); + overflow-wrap: anywhere; + padding: .24rem .42rem; + position: absolute; + text-align: center; + top: .75rem; + transform: translateX(-50%); + white-space: normal; + z-index: 1; +} + +.sf-procedure__activity { + border-top: 1px solid color-mix(in srgb, var(--sf-entity-accent) 24%, transparent); + margin-top: .55rem; + padding-top: .45rem; + transition: opacity .2s ease; +} + +.sf-procedure__activity.is-idle { + opacity: .46; +} + +.sf-procedure__activity ul { + color: var(--sf-muted); + font-size: .65rem; + margin: .35rem 0 0; + padding-left: 1rem; +} + +.sf-procedure__activity li + li { + margin-top: .15rem; +} + +.sf-procedure__context { + align-items: center; + display: flex; + gap: .45rem; + justify-content: center; + margin: .5rem 0 .8rem; +} + +.sf-procedure__context code { + background: var(--sf-surface-strong); + border-radius: .25rem; + overflow-wrap: anywhere; + padding: .25rem .4rem; + white-space: normal; +} + +.sf-procedure__detail { + background: var(--sf-surface); + border-radius: .6rem; + display: grid; + gap: .8rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: .8rem; +} + +.sf-procedure__detail h3 { + font-size: .88rem; + margin: .15rem 0 .2rem; +} + +.sf-procedure__detail p { + margin: 0; +} + +.sf-procedure__fields { + align-content: center; + display: flex; + flex-wrap: wrap; + gap: .3rem; + justify-content: flex-end; + max-width: 17rem; +} + +.sf-procedure__fields code { + background: var(--sf-surface-strong); + border-radius: .25rem; + padding: .16rem .35rem; + white-space: nowrap; +} + +.sf-procedure__progress { + background: var(--sf-surface-strong); + border-radius: 999px; + height: .3rem; + margin: .8rem 0; + overflow: hidden; +} + +.sf-procedure__progress-bar { + background: var(--sf-accent); + height: 100%; + transition: width .2s ease; +} + +.sf-procedure__controls { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: .5rem; + justify-content: center; +} + +.sf-procedure__controls .is-primary { + background: var(--md-default-fg-color); + color: var(--md-default-bg-color); +} + +.sf-procedure-fallback { + border-left: 3px solid var(--sf-accent); + padding-left: .8rem; +} + +@media (max-width: 46rem) { + .sf-procedure__scenario-label { + width: 100%; + } + + .sf-procedure__detail { + grid-template-columns: 1fr; + } + + .sf-procedure__fields { + justify-content: flex-start; + max-width: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .sf-procedure__lane, + .sf-procedure__activity, + .sf-procedure__progress-bar { + transition: none; + } +} diff --git a/src/stateforge_docs_theme/theme/main.html b/src/stateforge_docs_theme/theme/main.html new file mode 100644 index 0000000..8ddb6fc --- /dev/null +++ b/src/stateforge_docs_theme/theme/main.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block extrahead %} + {{ super() }} + + +{% endblock %} diff --git a/src/stateforge_docs_theme/theme/mkdocs_theme.yml b/src/stateforge_docs_theme/theme/mkdocs_theme.yml new file mode 100644 index 0000000..e36d1fe --- /dev/null +++ b/src/stateforge_docs_theme/theme/mkdocs_theme.yml @@ -0,0 +1,12 @@ +extends: material +locale: en +logo: assets/images/stateforge-mark.svg +favicon: assets/images/stateforge-favicon.svg + +features: + - navigation.sections + - navigation.indexes + - navigation.top + - content.code.copy + - search.suggest + - search.highlight diff --git a/tests/fixtures/site/docs/v0.1.0/index.md b/tests/fixtures/site/docs/v0.1.0/index.md new file mode 100644 index 0000000..5dd3ffd --- /dev/null +++ b/tests/fixtures/site/docs/v0.1.0/index.md @@ -0,0 +1,9 @@ +# Procedure player + +The declaration version is derived from the enclosing `v0.1.0/` documentation +directory. + +```stateforge-procedure +source: procedures/bootstrap.yaml +scenario: success +``` diff --git a/tests/fixtures/site/docs/v0.1.0/procedures/bootstrap.yaml b/tests/fixtures/site/docs/v0.1.0/procedures/bootstrap.yaml new file mode 100644 index 0000000..d9a2bdf --- /dev/null +++ b/tests/fixtures/site/docs/v0.1.0/procedures/bootstrap.yaml @@ -0,0 +1,224 @@ +schema: stateforge.docs/procedure/v1 +procedure: bootstrap +title: Discovery and Bootstrap +summary: From endpoint publication to a fenced local protocol session. +default_scenario: success + +entities: + - id: provider + label: Provider Process + kind: process + role: Publishes its endpoint and serves the local protocol + - id: discovery-directory + label: Discovery Directory + kind: storage + role: Carries atomic endpoint descriptors + - id: unix-socket + label: Unix Socket + kind: resource + role: Carries the local HTTP/2 connection + - id: agent + label: StateForge Agent + kind: process + role: Discovers, validates, and establishes sessions + +scenarios: + success: + label: Successful session + steps: + - id: publish-endpoint + title: Publish the endpoint descriptor + detail: The Provider binds its socket and atomically publishes a descriptor for the live endpoint. + phase: Endpoint publication + active: [provider, discovery-directory, unix-socket, agent] + fields: [provider_instance_id, endpoint_generation, socket_path] + transitions: + - source: provider + target: unix-socket + label: bind + - source: provider + target: discovery-directory + label: descriptor.json + activities: + provider: + title: Create a discoverable endpoint + items: + - Bind the Unix socket + - Serialize the canonical descriptor + - Atomically rename into place + discovery-directory: + title: Publish one complete observation + items: + - Replace the temporary file atomically + unix-socket: + title: Listen for local connections + items: + - Own the published socket path + agent: + title: Watch the discovery directory + items: + - Observe create and replace events + - Ignore incomplete temporary files + context: + kind: Published file + value: /run/stateforge-agent/providers/.1.json + + - id: discover-endpoint + title: Discover and validate the descriptor + detail: A filesystem event is only a hint; the Agent validates the complete final observation. + phase: Endpoint discovery + active: [discovery-directory, agent] + fields: [bounded read, canonical JSON, identity correlation] + transitions: + - source: discovery-directory + target: agent + label: descriptor event + activities: + discovery-directory: + title: Expose the final descriptor + items: + - Preserve filename and content correlation + agent: + title: Validate one correlated observation + items: + - Read the bounded final file + - Validate schema and filename identity + - Confirm socket and generation + context: + kind: Trust boundary + value: instance + generation + socket must agree + + - id: open-channel + title: Open the provisional local channel + detail: The Agent connects to the validated Unix socket without trusting the peer yet. + phase: Channel establishment + active: [agent, unix-socket, provider] + fields: [HTTP/2, Unix socket, transport ceilings] + transitions: + - source: agent + target: unix-socket + label: connect + - source: unix-socket + target: provider + label: local channel + activities: + provider: + title: Accept local gRPC transport + items: + - Serve Bootstrap on the shared channel + unix-socket: + title: Carry the provisional channel + items: + - Remain bound to this endpoint generation + agent: + title: Configure the client channel + items: + - Use the validated socket path + - Apply protocol transport ceilings + context: + kind: Transport + value: unix:///run/stateforge-agent/providers/.1.sock + + - id: validate-request + title: Validate the Bootstrap request + detail: The Provider compares the requested instance and generation with the endpoint it serves now. + phase: Handshake + active: [agent, provider] + fields: [expected_provider_instance_id, expected_endpoint_generation, supported_versions] + transitions: + - source: agent + target: provider + label: HandshakeRequest + activities: + provider: + title: Enforce live endpoint fences + items: + - Validate the protocol-owned contract + - Compare Provider instance and generation + agent: + title: Await a fenced response + items: + - Keep the discovery observation unchanged + - Apply the Bootstrap deadline + context: + kind: RPC + value: BootstrapService.Handshake + + - id: negotiate-session + title: Issue a fresh fenced session + detail: The Provider returns a never-reused session bound to its runtime and endpoint generation. + phase: Session negotiation + active: [provider, agent] + fields: [provider_runtime_id, session_id, selected_version] + transitions: + - source: provider + target: agent + label: HandshakeResponse + tone: success + activities: + provider: + title: Create session state + items: + - Select the highest mutual version + - Generate a fresh session ID + agent: + title: Validate before retaining state + items: + - Compare identity and generation fences + - Confirm the selected version was offered + context: + kind: Session fence + value: instance + generation + runtime + session + version + + - id: bind-session + title: Bind the negotiated session + detail: Every later procedure is fenced to the accepted Provider runtime, generation, and session. + phase: Session bound + active: [agent, provider] + fields: [SESSION_BOUND, "next: GetManifest"] + transitions: + - source: agent + target: provider + label: session active + tone: success + activities: + provider: + title: Serve the active session + items: + - Reject stale or foreign session IDs + agent: + title: Store the Provider session + items: + - Fence every later protocol procedure + context: + kind: Result + value: The provisional channel is now trusted for this session + + incompatible: + label: No common wire version + prefix: + scenario: success + through: validate-request + steps: + - title: Reject incompatible versions + detail: No common wire version exists, so the Provider creates no session. + phase: Handshake rejected + active: [agent, provider] + fields: [UNIMPLEMENTED, no session created] + transitions: + - source: provider + target: agent + label: UNIMPLEMENTED + tone: warning + activities: + provider: + title: Return the canonical status + items: + - Retain no session state + agent: + title: Mark this generation incompatible + items: + - Do not retry the unchanged generation indefinitely + context: + kind: Negotiation + value: offered versions ∩ supported versions = ∅ diff --git a/tests/fixtures/site/mkdocs.yml b/tests/fixtures/site/mkdocs.yml new file mode 100644 index 0000000..9e08bb4 --- /dev/null +++ b/tests/fixtures/site/mkdocs.yml @@ -0,0 +1,29 @@ +site_name: StateForge documentation fixture +site_url: https://docs.stateforge.net/fixture/ + +theme: + name: stateforge + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Use system preference + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Use dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Use light mode + +plugins: + - search + - stateforge: + component: protocol + +nav: + - v0.1.0: + - Procedure player: v0.1.0/index.md diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..fea8444 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path + +from mkdocs.commands.build import build +from mkdocs.config import load_config + +import stateforge_docs_theme + + +def test_theme_contains_bundled_brand_and_player_assets() -> None: + package = Path(stateforge_docs_theme.__file__).parent + theme = package / "theme" + + assert (theme / "mkdocs_theme.yml").is_file() + assert (theme / "assets/images/stateforge-mark.svg").is_file() + assert (theme / "assets/images/stateforge-favicon.svg").is_file() + assert (theme / "assets/stylesheets/stateforge.css").is_file() + player = theme / "assets/javascripts/procedure-player.js" + assert player.is_file() + source = player.read_text() + assert "Bootstrap" not in source + assert "StateForge Agent" not in source + assert "Provider Process" not in source + + +def test_fixture_site_builds_with_interactive_player(tmp_path: Path) -> None: + fixture = Path(__file__).parent / "fixtures/site" + config = load_config( + config_file=str(fixture / "mkdocs.yml"), + site_dir=str(tmp_path / "site"), + strict=True, + ) + + build(config) + + rendered = (tmp_path / "site/v0.1.0/index.html").read_text() + assert "stateforge-procedure-player" in rendered + assert 'data-documentation-version="v0.1.0"' in rendered + assert "assets/javascripts/procedure-player.js" in rendered + assert "assets/stylesheets/stateforge.css" in rendered diff --git a/tests/test_procedure_model.py b/tests/test_procedure_model.py new file mode 100644 index 0000000..07eb035 --- /dev/null +++ b/tests/test_procedure_model.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from stateforge_docs_theme.procedure.model import ( + ProcedureDeclarationError, + load_procedure, +) + +FIXTURE_DOCS = Path(__file__).parent / "fixtures/site/docs" +DECLARATION = FIXTURE_DOCS / "v0.1.0/procedures/bootstrap.yaml" + + +def test_version_is_derived_from_directory() -> None: + loaded = load_procedure(DECLARATION, docs_dir=FIXTURE_DOCS) + + assert loaded["documentation_version"] == "v0.1.0" + assert loaded["procedure"] == "bootstrap" + assert [entity["id"] for entity in loaded["entities"]] == [ + "provider", + "discovery-directory", + "unix-socket", + "agent", + ] + + +def test_scenario_prefix_expands_shared_steps() -> None: + loaded = load_procedure(DECLARATION, docs_dir=FIXTURE_DOCS) + + incompatible = loaded["scenarios"]["incompatible"] + assert "prefix" not in incompatible + assert [step["id"] for step in incompatible["steps"][:-1]] == [ + "publish-endpoint", + "discover-endpoint", + "open-channel", + "validate-request", + ] + assert incompatible["steps"][-1]["title"] == ("Reject incompatible versions") + + +@pytest.mark.parametrize( + "field", + ["version", "protocol_version", "documentation_version"], +) +def test_declaration_rejects_embedded_versions(tmp_path: Path, field: str) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration[field] = "1.0" + destination.write_text(yaml.safe_dump(declaration)) + + with pytest.raises( + ProcedureDeclarationError, + match="versions come from directories", + ): + load_procedure(destination, docs_dir=docs) + + +def test_declaration_rejects_versioned_filename(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap-v1.yaml" + destination.parent.mkdir(parents=True) + destination.write_text(DECLARATION.read_text()) + + with pytest.raises( + ProcedureDeclarationError, + match="filenames must not contain versions", + ): + load_procedure(destination, docs_dir=docs) + + +def test_declaration_requires_version_directory(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + destination.write_text(DECLARATION.read_text()) + + with pytest.raises( + ProcedureDeclarationError, + match="below a version directory", + ): + load_procedure(destination, docs_dir=docs) + + +def test_declaration_cannot_escape_docs_dir(tmp_path: Path) -> None: + docs = tmp_path / "docs" + docs.mkdir() + outside = tmp_path / "v0.1.0/procedures/bootstrap.yaml" + outside.parent.mkdir(parents=True) + outside.write_text(DECLARATION.read_text()) + + with pytest.raises( + ProcedureDeclarationError, + match="inside docs_dir", + ): + load_procedure(outside, docs_dir=docs) + + +def test_declaration_does_not_limit_entity_count(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["entities"].extend( + { + "id": f"external-{index}", + "label": f"External system {index}", + "kind": "external", + "role": "Participates in the documented procedure", + } + for index in range(12) + ) + destination.write_text(yaml.safe_dump(declaration)) + + loaded = load_procedure(destination, docs_dir=docs) + + assert len(loaded["entities"]) == 16 + + +def test_transition_rejects_unknown_entity(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["scenarios"]["success"]["steps"][0]["transitions"][0]["target"] = ( + "missing-entity" + ) + destination.write_text(yaml.safe_dump(declaration)) + + with pytest.raises( + ProcedureDeclarationError, + match="references unknown entities", + ): + load_procedure(destination, docs_dir=docs) + + +def test_scenario_prefix_rejects_unknown_step(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["scenarios"]["incompatible"]["prefix"]["through"] = "missing-step" + destination.write_text(yaml.safe_dump(declaration)) + + with pytest.raises( + ProcedureDeclarationError, + match="references unknown step", + ): + load_procedure(destination, docs_dir=docs) + + +def test_scenario_prefix_rejects_cycles(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/procedures/bootstrap.yaml" + destination.parent.mkdir(parents=True) + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["scenarios"]["success"]["prefix"] = { + "scenario": "incompatible", + "through": "validate-request", + } + destination.write_text(yaml.safe_dump(declaration)) + + with pytest.raises( + ProcedureDeclarationError, + match="scenario prefix cycle detected", + ): + load_procedure(destination, docs_dir=docs) diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..98d1a30 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path + +from stateforge_docs_theme.procedure.model import load_procedure +from stateforge_docs_theme.procedure.renderer import render_player + +FIXTURE_DOCS = Path(__file__).parent / "fixtures/site/docs" +DECLARATION = FIXTURE_DOCS / "v0.1.0/procedures/bootstrap.yaml" + + +def test_renderer_embeds_derived_version_and_static_fallback() -> None: + declaration = load_procedure(DECLARATION, docs_dir=FIXTURE_DOCS) + + rendered = render_player(declaration) + + assert 'data-documentation-version="v0.1.0"' in rendered + assert '"documentation_version":"v0.1.0"' in rendered + assert '
    ' in rendered + assert "Publish the endpoint descriptor" in rendered + + +def test_renderer_selects_requested_scenario() -> None: + declaration = load_procedure(DECLARATION, docs_dir=FIXTURE_DOCS) + + rendered = render_player(declaration, selected_scenario="incompatible") + + assert '"default_scenario":"incompatible"' in rendered + assert "Reject incompatible versions" in rendered From 4df20ab934b756cf2417de542ae3793e56901203 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Mon, 26 Oct 2026 00:59:33 +0200 Subject: [PATCH 2/6] feat(docs): complete shared theme system --- .github/workflows/theme-ci.yml | 179 ++++++++++++++++++ README.md | 57 +++++- pyproject.toml | 4 + src/stateforge_docs_theme/branding.py | 61 ++++++ src/stateforge_docs_theme/plugin.py | 98 ++++++++-- .../assets/images/stateforge-favicon.svg | 21 +- .../theme/assets/images/stateforge-mark.svg | 40 ++-- .../theme/assets/stylesheets/stateforge.css | 132 ++++++++++++- src/stateforge_docs_theme/theme/main.html | 42 ++++ .../theme/mkdocs_theme.yml | 20 ++ tests/fixtures/site/docs/v0.1.0/index.md | 4 + tests/fixtures/site/mkdocs.yml | 24 +-- tests/test_branding.py | 49 +++++ tests/test_package.py | 34 ++++ tests/test_plugin_config.py | 94 +++++++++ tests/test_visual.py | 140 ++++++++++++++ 16 files changed, 951 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/theme-ci.yml create mode 100644 src/stateforge_docs_theme/branding.py create mode 100644 tests/test_branding.py create mode 100644 tests/test_plugin_config.py create mode 100644 tests/test_visual.py diff --git a/.github/workflows/theme-ci.yml b/.github/workflows/theme-ci.yml new file mode 100644 index 0000000..885e305 --- /dev/null +++ b/.github/workflows/theme-ci.yml @@ -0,0 +1,179 @@ +name: Theme CI + +on: + pull_request: + push: + +permissions: + contents: read + +concurrency: + group: theme-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + source: + name: Source and browser verification + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + + - name: Install source and development tools + run: python -m pip install --disable-pip-version-check '.[dev]' + + - name: Install workflow linter + env: + ACTIONLINT_VERSION: v1.7.12 + run: | + actionlint_directory="${RUNNER_TEMP}/actionlint" + mkdir -p "${actionlint_directory}" + GOBIN="${actionlint_directory}" go install \ + "github.com/rhysd/actionlint/cmd/actionlint@${ACTIONLINT_VERSION}" + echo "${actionlint_directory}" >> "${GITHUB_PATH}" + + - name: Verify source and unit behavior + run: | + python -m ruff format --check . + python -m ruff check . + python -m mypy src + python -m pytest -m 'not visual' + actionlint -color + + - name: Audit workflow security + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + inputs: . + collect: all + online-audits: false + persona: auditor + min-severity: medium + version: v1.30.1 + token: unused-offline + advanced-security: false + annotations: true + + - name: Install browser + run: python -m playwright install --with-deps chromium + + - name: Verify responsive and accessible presentation + env: + STATEFORGE_VISUAL_EVIDENCE: evidence/visual + STATEFORGE_VISUAL_TESTS: "1" + run: python -m pytest tests/test_visual.py + + - name: Preserve visual evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: stateforge-docs-theme-visual-evidence + path: evidence/visual + if-no-files-found: warn + + build: + name: Build reproducible distributions + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + + - name: Install build frontend + run: python -m pip install --disable-pip-version-check 'build>=1.2' + + - name: Build and compare distributions + run: | + set -euo pipefail + export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + mkdir -p dist-first dist-second dist evidence/distribution + python -m build --outdir dist-first + python -m build --outdir dist-second + cmp dist-first/*.whl dist-second/*.whl + cmp dist-first/*.tar.gz dist-second/*.tar.gz + cp dist-first/* dist/ + sha256sum dist/* | tee evidence/distribution/SHA256SUMS + python -m zipfile -l dist/*.whl | tee evidence/distribution/wheel-contents.txt + tar -tzf dist/*.tar.gz | tee evidence/distribution/sdist-contents.txt + grep -F 'stateforge-mark.svg' evidence/distribution/wheel-contents.txt + grep -F 'stateforge-favicon.svg' evidence/distribution/wheel-contents.txt + grep -F 'stateforge.css' evidence/distribution/wheel-contents.txt + grep -F 'main.html' evidence/distribution/wheel-contents.txt + grep -F 'stateforge-mark.svg' evidence/distribution/sdist-contents.txt + grep -F 'stateforge-favicon.svg' evidence/distribution/sdist-contents.txt + grep -F 'stateforge.css' evidence/distribution/sdist-contents.txt + grep -F 'main.html' evidence/distribution/sdist-contents.txt + + - name: Upload exact distributions + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: stateforge-docs-theme-distributions + path: | + dist + evidence/distribution + if-no-files-found: error + + installed: + name: Installed ${{ matrix.artifact }} + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - artifact: wheel + pattern: "*.whl" + - artifact: sdist + pattern: "*.tar.gz" + steps: + - name: Check out fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Download exact distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: stateforge-docs-theme-distributions + path: artifacts + + - name: Install and build outside the source tree + env: + ARTIFACT_KIND: ${{ matrix.artifact }} + PACKAGE_PATTERN: ${{ matrix.pattern }} + run: | + set -euo pipefail + package="$(find artifacts/dist -type f -name "${PACKAGE_PATTERN}" -print -quit)" + test -n "${package}" + python -m venv "${RUNNER_TEMP}/consumer" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --disable-pip-version-check "${package}" + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/consumer/bin/python" -m mkdocs build \ + --strict \ + --config-file "${GITHUB_WORKSPACE}/tests/fixtures/site/mkdocs.yml" \ + --site-dir "${RUNNER_TEMP}/site-${ARTIFACT_KIND}" + test -f "${RUNNER_TEMP}/site-${ARTIFACT_KIND}/v0.1.0/index.html" + test -f "${RUNNER_TEMP}/site-${ARTIFACT_KIND}/assets/images/stateforge-mark.svg" + test -f "${RUNNER_TEMP}/site-${ARTIFACT_KIND}/assets/images/stateforge-favicon.svg" diff --git a/README.md b/README.md index da3f35d..d01a657 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,63 @@ plugins: - search - stateforge: component: protocol + documentation_version: v0.1.0 + latest_version: v0.2.0 + versions: + - label: v0.2.0 + url: ../v0.2.0/ + - label: v0.1.0 + url: ./ ``` -Supported component identities are `stateforge`, `agent`, `provider`, -`protocol`, `control-plane`, and `sf-aop`. +Built-in component accents are provided for `stateforge`, `agent`, `provider`, +`protocol`, `control-plane`, and `sf-aop`. A future repository may use any +lowercase hyphenated component identity. It inherits the StateForge accent or +supplies an explicit six-digit `accent`; the theme chooses a WCAG-compliant +light or dark foreground automatically. + +The optional version configuration renders one keyboard-accessible selector. +When `documentation_version` differs from `latest_version`, the theme also +renders a historical-version warning with a link to the latest documentation. +Publishing and redirect behavior remain owned by later documentation +milestones. + +## Theme ownership and Material boundary + +The package owns only shared presentation: the StateForge mark and favicon, +brand tokens, system typography, light and dark palettes, focus treatment, +component accents, repository-link styling, code presentation, the version +selector, and reusable interactive components. Consumers continue to own +their Markdown, navigation, repository metadata, generated references, and +repository-specific plugins. + +The Material integration is deliberately limited to: + +- one theme inheritance declaration in `mkdocs_theme.yml`; +- one `main.html` override that calls `super()` and adds shared assets, + version navigation, and the historical warning; and +- CSS layered over Material's public class surface. + +No documentation content depends on a Material template. A future centrally +managed Zensical migration can replace this package's template and CSS layer +without rewriting consumer Markdown. + +The theme disables remote web fonts and uses a local system-font stack. All +StateForge branding required at runtime is packaged inside the distribution. + +## Distribution verification + +Build the wheel and source distribution from the same source revision: + +```bash +python -m build +``` + +The repository test and CI suites inspect both archives, install each artifact +in a clean environment, select `theme.name: stateforge`, and build the fixture +site strictly. Browser checks cover desktop and mobile layouts, light and dark +schemes, focus visibility, component contrast, version navigation, and bundled +logo and favicon resolution. ## Development diff --git a/pyproject.toml b/pyproject.toml index 5791f70..e2b7570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ dev = [ "build>=1.2", "mypy>=1.17", + "playwright>=1.56,<2", "pytest>=8.4", "ruff>=0.12", "types-PyYAML>=6", @@ -57,3 +58,6 @@ packages = ["stateforge_docs_theme"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "visual: browser-backed responsive and accessibility verification", +] diff --git a/src/stateforge_docs_theme/branding.py b/src/stateforge_docs_theme/branding.py new file mode 100644 index 0000000..f8b225a --- /dev/null +++ b/src/stateforge_docs_theme/branding.py @@ -0,0 +1,61 @@ +"""Stable StateForge brand tokens shared by the theme and its tests.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from types import MappingProxyType + +DEFAULT_ACCENT = "#245fc7" +LIGHT_FOREGROUND = "#ffffff" +DARK_FOREGROUND = "#111827" + +COMPONENT_ACCENTS: Mapping[str, str] = MappingProxyType( + { + "stateforge": DEFAULT_ACCENT, + "agent": "#1d5fbf", + "provider": "#087a61", + "protocol": "#a95000", + "control-plane": "#6542b8", + "sf-aop": "#98365d", + } +) + +_COMPONENT = re.compile(r"^[a-z][a-z0-9-]*$") +_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def component_accent(component: str, custom_accent: str | None = None) -> str: + """Resolve one validated component accent, allowing future repositories.""" + if not _COMPONENT.fullmatch(component): + raise ValueError("component must use lowercase letters, digits, and hyphens") + accent = custom_accent or COMPONENT_ACCENTS.get(component, DEFAULT_ACCENT) + if not _HEX_COLOR.fullmatch(accent): + raise ValueError("accent must be a six-digit hexadecimal color") + return accent.lower() + + +def contrast_foreground(background: str) -> str: + """Choose the higher-contrast brand-safe foreground for a background.""" + candidates = (LIGHT_FOREGROUND, DARK_FOREGROUND) + return max(candidates, key=lambda color: contrast_ratio(background, color)) + + +def contrast_ratio(first: str, second: str) -> float: + """Return the WCAG contrast ratio for two hexadecimal colors.""" + lighter, darker = sorted( + (_relative_luminance(first), _relative_luminance(second)), + reverse=True, + ) + return (lighter + 0.05) / (darker + 0.05) + + +def _relative_luminance(color: str) -> float: + if not _HEX_COLOR.fullmatch(color): + raise ValueError("color must be a six-digit hexadecimal color") + channels = [int(color[index : index + 2], 16) / 255 for index in (1, 3, 5)] + linear = [ + channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 + for channel in channels + ] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] diff --git a/src/stateforge_docs_theme/plugin.py b/src/stateforge_docs_theme/plugin.py index 351cf4d..b80d131 100644 --- a/src/stateforge_docs_theme/plugin.py +++ b/src/stateforge_docs_theme/plugin.py @@ -4,7 +4,7 @@ import re from pathlib import Path -from typing import Any +from typing import Any, TypedDict import yaml from mkdocs.config import config_options @@ -14,6 +14,12 @@ from mkdocs.structure.files import Files from mkdocs.structure.pages import Page +from stateforge_docs_theme.branding import ( + COMPONENT_ACCENTS, + LIGHT_FOREGROUND, + component_accent, + contrast_foreground, +) from stateforge_docs_theme.procedure.model import ( ProcedureDeclarationError, load_procedure, @@ -26,20 +32,33 @@ ) +class DocumentationVersionConfig(Config): # type: ignore[no-untyped-call] + """One link rendered by the shared documentation version selector.""" + + label = config_options.Type(str) + url = config_options.Type(str) + + +class DocumentationVersion(TypedDict): + """One normalized documentation-version link exposed to the theme.""" + + label: str + url: str + current: bool + + class StateForgeDocsConfig(Config): # type: ignore[no-untyped-call] """Configuration shared by all StateForge documentation consumers.""" - component = config_options.Choice( - ( - "stateforge", - "agent", - "provider", - "protocol", - "control-plane", - "sf-aop", - ), - default="stateforge", + component = config_options.Type(str, default="stateforge") + accent = config_options.Optional(config_options.Type(str)) + documentation_version = config_options.Optional(config_options.Type(str)) + latest_version = config_options.Optional(config_options.Type(str)) + versions = config_options.ListOfItems( + config_options.SubConfig(DocumentationVersionConfig), + default=[], ) + historical_warning = config_options.Type(bool, default=True) procedure_players = config_options.Type(bool, default=True) @@ -49,9 +68,64 @@ class StateForgeDocsPlugin( # type: ignore[no-untyped-call] """Render declarative procedure players and expose component identity.""" def on_config(self, config: Any) -> Any: + try: + accent = component_accent(self.config.component, self.config.accent) + except ValueError as error: + raise PluginError( + f"invalid StateForge theme configuration: {error}" + ) from error + + versions: list[DocumentationVersion] = [ + {"label": entry.label, "url": entry.url, "current": False} + for entry in self.config.versions + ] + labels = [entry["label"] for entry in versions] + if len(labels) != len(set(labels)): + raise PluginError("documentation version labels must be unique") + current_version = self.config.documentation_version + latest_version = self.config.latest_version + if current_version and versions and current_version not in labels: + raise PluginError( + "documentation_version must identify one configured version" + ) + if latest_version and versions and latest_version not in labels: + raise PluginError("latest_version must identify one configured version") + + for entry in versions: + entry["current"] = entry["label"] == current_version + latest_url = next( + (entry["url"] for entry in versions if entry["label"] == latest_version), + None, + ) + historical = bool( + self.config.historical_warning + and current_version + and latest_version + and current_version not in {latest_version, "dev"} + ) + + foreground = contrast_foreground(accent) + logo_filter = ( + "brightness(0) invert(1)" + if foreground == LIGHT_FOREGROUND + else "brightness(0)" + ) extra = dict(config.extra or {}) stateforge = dict(extra.get("stateforge") or {}) - stateforge["component"] = self.config.component + stateforge.update( + { + "accent": accent, + "accent_foreground": foreground, + "component": self.config.component, + "documentation_version": current_version, + "historical": historical, + "known_components": sorted(COMPONENT_ACCENTS), + "latest_url": latest_url, + "latest_version": latest_version, + "logo_filter": logo_filter, + "versions": versions, + } + ) extra["stateforge"] = stateforge config.extra = extra return config diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg index 46dc5ad..0245d39 100644 --- a/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-favicon.svg @@ -1,8 +1,19 @@ - + StateForge - - - - + + + + + + + + + + + + + + + diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg index a2698eb..ffd2a3e 100644 --- a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg @@ -1,13 +1,31 @@ - - StateForge - A cube containing a layered state checkpoint. - - - - - - - - + + StateForge + A cube containing a layered state checkpoint. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css index aefbbac..032cbb5 100644 --- a/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css +++ b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css @@ -1,5 +1,17 @@ :root { - --sf-accent: #2878ef; + --sf-accent: #245fc7; + --sf-accent-contrast: #ffffff; + --sf-canvas: #ffffff; + --sf-ink: #111827; + --sf-logo-filter: brightness(0) invert(1); + --sf-font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --sf-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --sf-radius-small: .3rem; + --sf-radius-medium: .6rem; + --sf-space-1: .25rem; + --sf-space-2: .5rem; + --sf-space-3: .75rem; + --sf-space-4: 1rem; --sf-series-1: #2878ef; --sf-series-2: #0b8a71; --sf-series-3: #b76509; @@ -16,20 +28,133 @@ } [data-md-color-scheme="slate"] { + --sf-canvas: #0f172a; + --sf-ink: #f8fafc; --sf-surface: color-mix(in srgb, var(--md-default-bg-color) 90%, var(--sf-accent)); --sf-surface-strong: color-mix(in srgb, var(--md-default-bg-color) 82%, var(--sf-accent)); --sf-border: color-mix(in srgb, var(--md-default-fg-color) 24%, transparent); --sf-muted: color-mix(in srgb, var(--md-default-fg-color) 72%, transparent); } +body, +button, +input, +select { + font-family: var(--sf-font-sans); +} + +code, +kbd, +pre { + font-family: var(--sf-font-mono); +} + +:where(a, button, input, select, summary):focus-visible { + border-radius: var(--sf-radius-small); + box-shadow: 0 0 0 5px var(--sf-accent); + outline: 3px solid var(--sf-accent-contrast); + outline-offset: 2px; +} + +.md-typeset a, +.md-nav__link--active, +.md-nav__link:focus, +.md-nav__link:hover { + color: var(--sf-accent); +} + +.md-typeset code { + border-radius: var(--sf-radius-small); +} + +.md-typeset pre > code { + border-left: 3px solid var(--sf-accent); +} + .md-header { - background: color-mix(in srgb, var(--md-primary-fg-color) 94%, #07111f); + background: var(--sf-accent); + color: var(--sf-accent-contrast); +} + +.md-header__button, +.md-header__source, +.md-header__title { + color: inherit; +} + +.md-header__button.md-logo img { + filter: var(--sf-logo-filter); +} + +.md-source:focus-visible, +.md-source:hover { + background: color-mix(in srgb, var(--sf-accent-contrast) 14%, transparent); } .md-grid { max-width: 90rem; } +.sf-historical-warning { + background: #fff4d6; + border-bottom: 1px solid #d99a20; + color: #402b00; + padding: var(--sf-space-2) var(--sf-space-4); +} + +.sf-historical-warning a { + color: #754600; + font-weight: 700; + margin-left: var(--sf-space-2); + text-decoration: underline; +} + +.sf-version-selector { + background: color-mix(in srgb, var(--sf-accent) 8%, var(--md-default-bg-color)); + border-bottom: 1px solid var(--sf-border); + color: var(--md-default-fg-color); + font-size: .68rem; +} + +.sf-version-selector__inner { + align-items: center; + display: flex; + gap: var(--sf-space-3); + min-height: 2.1rem; + overflow-x: auto; + padding: var(--sf-space-1) var(--sf-space-4); +} + +.sf-version-selector__label { + color: var(--sf-muted); + flex: 0 0 auto; + font-weight: 700; +} + +.sf-version-selector ul { + align-items: center; + display: flex; + gap: var(--sf-space-1); + list-style: none; + margin: 0; + padding: 0; +} + +.sf-version-selector a { + border-radius: 999px; + color: inherit; + display: block; + padding: .18rem .55rem; + text-decoration: none; + white-space: nowrap; +} + +.sf-version-selector a[aria-current="page"] { + background: var(--sf-accent); + color: var(--sf-accent-contrast); + font-weight: 700; +} + stateforge-procedure-player { display: block; margin: 2rem 0; @@ -75,7 +200,8 @@ stateforge-procedure-player { .sf-procedure button:not(:disabled):focus-visible, .sf-procedure select:focus-visible { border-color: var(--sf-accent); - outline: 2px solid color-mix(in srgb, var(--sf-accent) 35%, transparent); + box-shadow: 0 0 0 5px var(--sf-accent); + outline: 3px solid var(--sf-accent-contrast); outline-offset: 2px; } diff --git a/src/stateforge_docs_theme/theme/main.html b/src/stateforge_docs_theme/theme/main.html index 8ddb6fc..d4661bd 100644 --- a/src/stateforge_docs_theme/theme/main.html +++ b/src/stateforge_docs_theme/theme/main.html @@ -3,5 +3,47 @@ {% block extrahead %} {{ super() }} + {% set stateforge = config.extra.stateforge %} + {% endblock %} + +{% block header %} + {{ super() }} + {% set stateforge = config.extra.stateforge %} + {% if stateforge.historical %} +
    + You are reading StateForge documentation for + {{ stateforge.documentation_version }}. + {% if stateforge.latest_url %} + + Read the latest version ({{ stateforge.latest_version }}) + + {% endif %} +
    + + {% endif %} + {% if stateforge.versions %} + + {% endif %} +{% endblock %} diff --git a/src/stateforge_docs_theme/theme/mkdocs_theme.yml b/src/stateforge_docs_theme/theme/mkdocs_theme.yml index e36d1fe..45d27f4 100644 --- a/src/stateforge_docs_theme/theme/mkdocs_theme.yml +++ b/src/stateforge_docs_theme/theme/mkdocs_theme.yml @@ -2,6 +2,26 @@ extends: material locale: en logo: assets/images/stateforge-mark.svg favicon: assets/images/stateforge-favicon.svg +font: false + +palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Use system preference + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Use dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Use light mode + +icon: + repo: fontawesome/brands/github features: - navigation.sections diff --git a/tests/fixtures/site/docs/v0.1.0/index.md b/tests/fixtures/site/docs/v0.1.0/index.md index 5dd3ffd..350bf43 100644 --- a/tests/fixtures/site/docs/v0.1.0/index.md +++ b/tests/fixtures/site/docs/v0.1.0/index.md @@ -7,3 +7,7 @@ directory. source: procedures/bootstrap.yaml scenario: success ``` + +```python +from stateforge_docs_theme import __version__ +``` diff --git a/tests/fixtures/site/mkdocs.yml b/tests/fixtures/site/mkdocs.yml index 9e08bb4..7a09b3d 100644 --- a/tests/fixtures/site/mkdocs.yml +++ b/tests/fixtures/site/mkdocs.yml @@ -1,28 +1,22 @@ site_name: StateForge documentation fixture site_url: https://docs.stateforge.net/fixture/ +repo_name: stateforge-org/stateforge-docs +repo_url: https://github.com/stateforge-org/stateforge-docs theme: name: stateforge - palette: - - media: "(prefers-color-scheme)" - toggle: - icon: material/brightness-auto - name: Use system preference - - media: "(prefers-color-scheme: light)" - scheme: default - toggle: - icon: material/brightness-7 - name: Use dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - toggle: - icon: material/brightness-4 - name: Use light mode plugins: - search - stateforge: component: protocol + documentation_version: v0.1.0 + latest_version: v0.2.0 + versions: + - label: v0.2.0 + url: ../v0.2.0/ + - label: v0.1.0 + url: ./ nav: - v0.1.0: diff --git a/tests/test_branding.py b/tests/test_branding.py new file mode 100644 index 0000000..1eee70b --- /dev/null +++ b/tests/test_branding.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from stateforge_docs_theme.branding import ( + COMPONENT_ACCENTS, + component_accent, + contrast_foreground, + contrast_ratio, +) + + +def test_named_component_accents_are_distinct_and_accessible() -> None: + assert len(set(COMPONENT_ACCENTS.values())) == len(COMPONENT_ACCENTS) + assert set(COMPONENT_ACCENTS) == { + "stateforge", + "agent", + "provider", + "protocol", + "control-plane", + "sf-aop", + } + for accent in COMPONENT_ACCENTS.values(): + foreground = contrast_foreground(accent) + assert contrast_ratio(accent, foreground) >= 4.5 + + +def test_future_component_can_supply_an_accessible_custom_accent() -> None: + accent = component_accent("new-component", "#e8c547") + + assert accent == "#e8c547" + assert contrast_foreground(accent) == "#111827" + assert contrast_ratio(accent, contrast_foreground(accent)) >= 4.5 + + +@pytest.mark.parametrize( + ("component", "accent"), + [ + ("Not Valid", None), + ("valid", "blue"), + ("valid", "#123"), + ], +) +def test_invalid_brand_inputs_are_rejected( + component: str, + accent: str | None, +) -> None: + with pytest.raises(ValueError): + component_accent(component, accent) diff --git a/tests/test_package.py b/tests/test_package.py index fea8444..a713f43 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib.metadata import entry_points from pathlib import Path from mkdocs.commands.build import build @@ -23,6 +24,25 @@ def test_theme_contains_bundled_brand_and_player_assets() -> None: assert "StateForge Agent" not in source assert "Provider Process" not in source + for asset in theme.rglob("*"): + if asset.suffix in {".css", ".html", ".js"}: + contents = asset.read_text() + assert "http://" not in contents + assert "https://" not in contents + + +def test_theme_is_discoverable_and_fixture_has_no_branding_copies() -> None: + themes = {entry.name: entry.value for entry in entry_points(group="mkdocs.themes")} + assert themes["stateforge"] == "stateforge_docs_theme.theme" + + fixture_docs = Path(__file__).parent / "fixtures/site/docs" + local_branding = [ + path + for path in fixture_docs.rglob("*") + if path.suffix.lower() in {".css", ".svg", ".png", ".ico"} + ] + assert local_branding == [] + def test_fixture_site_builds_with_interactive_player(tmp_path: Path) -> None: fixture = Path(__file__).parent / "fixtures/site" @@ -39,3 +59,17 @@ def test_fixture_site_builds_with_interactive_player(tmp_path: Path) -> None: assert 'data-documentation-version="v0.1.0"' in rendered assert "assets/javascripts/procedure-player.js" in rendered assert "assets/stylesheets/stateforge.css" in rendered + assert "stateforge-brand-tokens" in rendered + assert "#a95000" in rendered + assert "--sf-logo-filter: brightness(0) invert(1)" in rendered + assert "Documentation version" in rendered + assert "v0.1.0" in rendered + assert "v0.2.0" in rendered + assert "You are reading StateForge documentation" in rendered + assert "stateforge-org/stateforge-docs" in rendered + assert "fonts.googleapis.com" not in rendered + assert 'href="../assets/images/stateforge-favicon.svg"' in rendered + + site = tmp_path / "site" + assert (site / "assets/images/stateforge-mark.svg").is_file() + assert (site / "assets/images/stateforge-favicon.svg").is_file() diff --git a/tests/test_plugin_config.py b/tests/test_plugin_config.py new file mode 100644 index 0000000..0082bd5 --- /dev/null +++ b/tests/test_plugin_config.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest +from mkdocs.commands.build import build +from mkdocs.config import load_config +from mkdocs.exceptions import Abort + + +def _write_site(tmp_path: Path, plugin_config: str) -> Path: + docs = tmp_path / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Fixture\n") + config = tmp_path / "mkdocs.yml" + config.write_text( + dedent( + f""" + site_name: StateForge configuration fixture + theme: + name: stateforge + plugins: + - stateforge: + {plugin_config} + """ + ) + ) + return config + + +@pytest.mark.parametrize( + "plugin_config", + [ + """ + documentation_version: v0.1.0 + latest_version: v0.1.0 + versions: + - label: v0.1.0 + url: ./ + - label: v0.1.0 + url: ../duplicate/ + """, + """ + documentation_version: v0.1.0 + latest_version: v0.2.0 + versions: + - label: v0.2.0 + url: ../v0.2.0/ + """, + """ + documentation_version: v0.1.0 + latest_version: v0.2.0 + versions: + - label: v0.1.0 + url: ./ + """, + ], +) +def test_invalid_version_configuration_is_rejected( + tmp_path: Path, + plugin_config: str, +) -> None: + config_file = _write_site(tmp_path, plugin_config) + config = load_config( + config_file=str(config_file), + site_dir=str(tmp_path / "site"), + strict=True, + ) + + with pytest.raises(Abort): + build(config) + + +def test_future_component_configuration_is_rendered(tmp_path: Path) -> None: + config_file = _write_site( + tmp_path, + """ + component: stateforge-storage + accent: "#e8c547" + """, + ) + config = load_config( + config_file=str(config_file), + site_dir=str(tmp_path / "site"), + strict=True, + ) + + build(config) + + rendered = (tmp_path / "site/index.html").read_text() + assert "#e8c547" in rendered + assert "--sf-logo-filter: brightness(0)" in rendered + assert "stateforge-mark.svg" in rendered diff --git a/tests/test_visual.py b/tests/test_visual.py new file mode 100644 index 0000000..99edb4e --- /dev/null +++ b/tests/test_visual.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import os +import threading +from collections.abc import Iterator +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +from mkdocs.commands.build import build +from mkdocs.config import load_config + +if os.environ.get("STATEFORGE_VISUAL_TESTS") != "1": + pytest.skip( + "set STATEFORGE_VISUAL_TESTS=1 to run browser verification", + allow_module_level=True, + ) + +from playwright.sync_api import Browser, Page, sync_playwright # noqa: E402 + + +class _QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + del format, args + + +@pytest.fixture(scope="module") +def site_directory(tmp_path_factory: pytest.TempPathFactory) -> Path: + fixture = Path(__file__).parent / "fixtures/site" + site = tmp_path_factory.mktemp("rendered-site") + config = load_config( + config_file=str(fixture / "mkdocs.yml"), + site_dir=str(site), + strict=True, + ) + build(config) + return site + + +@pytest.fixture(scope="module") +def site_url(site_directory: Path) -> Iterator[str]: + handler = partial(_QuietHandler, directory=str(site_directory)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + try: + yield f"http://{host}:{port}" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +@pytest.fixture(scope="module") +def browser() -> Iterator[Browser]: + with sync_playwright() as playwright: + instance = playwright.chromium.launch() + yield instance + instance.close() + + +@pytest.mark.visual +@pytest.mark.parametrize( + ("width", "height", "scheme", "expected_palette"), + [ + (1440, 1000, "light", "default"), + (390, 844, "dark", "slate"), + ], +) +def test_responsive_light_and_dark_rendering( + browser: Browser, + site_url: str, + tmp_path: Path, + width: int, + height: int, + scheme: str, + expected_palette: str, +) -> None: + page = browser.new_page(viewport={"width": width, "height": height}) + page.emulate_media(color_scheme=scheme) + page.goto(f"{site_url}/v0.1.0/", wait_until="networkidle") + + assert page.locator("body").get_attribute("data-md-color-scheme") == ( + expected_palette + ) + assert page.locator(".sf-version-selector").is_visible() + assert page.locator(".sf-historical-warning").is_visible() + assert page.locator(".md-header__source").is_visible() is (width >= 1220) + assert page.evaluate( + "document.documentElement.scrollWidth <= document.documentElement.clientWidth" + ) + assert page.locator("stateforge-procedure-player").is_visible() + + evidence = Path(os.environ.get("STATEFORGE_VISUAL_EVIDENCE", str(tmp_path))) + evidence.mkdir(parents=True, exist_ok=True) + page.screenshot( + path=evidence / f"fixture-{width}-{scheme}.png", + full_page=True, + ) + page.close() + + +@pytest.mark.visual +def test_focus_brand_assets_and_component_accent( + browser: Browser, + site_url: str, +) -> None: + page: Page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(f"{site_url}/v0.1.0/", wait_until="networkidle") + + page.keyboard.press("Tab") + focused = page.locator(":focus") + assert focused.count() == 1 + outline = focused.evaluate("element => getComputedStyle(element).outlineStyle") + assert outline != "none" + box_shadow = focused.evaluate("element => getComputedStyle(element).boxShadow") + assert box_shadow != "none" + + accent = page.evaluate( + "getComputedStyle(document.documentElement)" + ".getPropertyValue('--sf-accent').trim()" + ) + assert accent == "#a95000" + logo_filter = page.locator(".md-header__button.md-logo img").evaluate( + "element => getComputedStyle(element).filter" + ) + assert logo_filter != "none" + assert page.locator('[aria-label="Documentation versions"]').count() == 1 + assert page.locator('[aria-current="page"]').get_attribute("href") == "./" + + for asset in ( + "/assets/images/stateforge-mark.svg", + "/assets/images/stateforge-favicon.svg", + ): + response = page.request.get(f"{site_url}{asset}") + assert response.ok + assert response.headers["content-type"].startswith("image/svg+xml") + page.close() From 0a1a0048acdc3a6683c6bb2a9bbbe1923b7e99c5 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Mon, 26 Oct 2026 14:24:00 +0200 Subject: [PATCH 3/6] feat(docs): add declarative entity flow visualizations --- README.md | 81 +- src/stateforge_docs_theme/declaration.py | 144 ++ .../entity_flow/__init__.py | 13 + .../entity_flow/model.py | 208 +++ .../entity_flow/renderer.py | 43 + src/stateforge_docs_theme/plugin.py | 44 +- src/stateforge_docs_theme/procedure/model.py | 248 +-- .../assets/images/stateforge-mark-black.svg | 27 + .../theme/assets/images/stateforge-mark.svg | 32 +- .../theme/assets/javascripts/entity-flow.js | 340 ++++ .../assets/javascripts/lifecycle-flow.js | 130 ++ .../assets/javascripts/version-selector.js | 10 + .../theme/assets/stylesheets/stateforge.css | 1566 ++++++++++++++--- src/stateforge_docs_theme/theme/main.html | 26 +- tests/fixtures/site/docs/v0.1.0/index.md | 59 + .../site/docs/v0.1.0/trigger-flow.yaml | 128 ++ tests/fixtures/site/mkdocs.yml | 6 +- tests/test_entity_flow_model.py | 72 + tests/test_entity_flow_renderer.py | 19 + tests/test_package.py | 39 +- tests/test_plugin_config.py | 3 +- tests/test_visual.py | 98 +- 22 files changed, 2892 insertions(+), 444 deletions(-) create mode 100644 src/stateforge_docs_theme/declaration.py create mode 100644 src/stateforge_docs_theme/entity_flow/__init__.py create mode 100644 src/stateforge_docs_theme/entity_flow/model.py create mode 100644 src/stateforge_docs_theme/entity_flow/renderer.py create mode 100644 src/stateforge_docs_theme/theme/assets/images/stateforge-mark-black.svg create mode 100644 src/stateforge_docs_theme/theme/assets/javascripts/entity-flow.js create mode 100644 src/stateforge_docs_theme/theme/assets/javascripts/lifecycle-flow.js create mode 100644 src/stateforge_docs_theme/theme/assets/javascripts/version-selector.js create mode 100644 tests/fixtures/site/docs/v0.1.0/trigger-flow.yaml create mode 100644 tests/test_entity_flow_model.py create mode 100644 tests/test_entity_flow_renderer.py diff --git a/README.md b/README.md index d01a657..ba6cd9a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,78 @@ fields, malformed actors, invalid scenarios, and unsafe inline data. The generated page contains a static ordered fallback before JavaScript enhances it into the interactive player. +## Interactive lifecycle summaries + +Landing pages may use the compact lifecycle component when two endpoints +exchange a short, ordered series of messages. The visible stage buttons remain +the complete static explanation. With JavaScript enabled, selecting a stage +updates the endpoint states and plays its declared messages once. + +Each `[data-sf-flow-step]` button declares: + +- `data-source-state` and `data-target-state` for the endpoint summaries; +- `data-result` for the stable state reached after playback; and +- `data-events` as a JSON array of `direction`, `label`, and `detail` values. + +`forward` moves from source to target and `reverse` moves from target to source. +The component uses native buttons, reports the selected stage with +`aria-pressed`, exposes one polite live status, and skips playback when the +visitor requests reduced motion. The shared script contains no +repository-specific actors or messages. + +## Declarative entity flows + +Use an entity flow for a compact, animated explanation of one responsibility +transfer across several real components. The renderer, interaction controls, +accessibility behavior, StateForge branding, and bundled third-party marks live +in this package. The owning repository supplies only version-local YAML. + +````markdown +```stateforge-entity-flow +source: trigger-publication-overview.yaml +``` +```` + +An entity declaration describes identity, visual role, and initial state. Each +step declares the active entities, state changes, directed transitions, and +the exact owner of responsibility at that point: + +```yaml +schema: stateforge.docs/entity-flow/v1 +flow: trigger-publication +title: Trigger publication responsibility flow +entities: + - id: provider + label: Example Provider + kind: StateForge Provider + brand: stateforge + accent: provider + state: {label: Pending queue, value: Empty} + - id: agent + label: StateForge Agent + kind: StateForge Agent + brand: stateforge + accent: agent + state: {label: Trigger record, value: Not accepted} +steps: + - id: publish + phase: Trigger publication + title: Publish the trigger + detail: The Provider retains the publication while sending it. + active: [provider, agent] + states: + provider: {label: Pending queue, value: 1 retained publication} + transitions: + - {source: provider, target: agent, label: TriggerPublication} + responsibility: + entities: [provider] + label: The Provider remains responsible until durable acceptance. +``` + +Supported bundled brands are `stateforge`, `service`, and `none`. `service` is +a neutral service glyph for factual product references; `none` uses a compact +text mark derived from the entity label. + ## Consumer configuration ```yaml @@ -58,18 +130,21 @@ theme: name: stateforge plugins: - - search + - material/search - stateforge: component: protocol documentation_version: v0.1.0 latest_version: v0.2.0 versions: - label: v0.2.0 - url: ../v0.2.0/ + url: v0.2.0/ - label: v0.1.0 - url: ./ + url: v0.1.0/ ``` +Version URLs are relative to the documentation root. The theme resolves them +correctly from root pages and nested procedure or reference pages. + Built-in component accents are provided for `stateforge`, `agent`, `provider`, `protocol`, `control-plane`, and `sf-aop`. A future repository may use any lowercase hyphenated component identity. It inherits the StateForge accent or diff --git a/src/stateforge_docs_theme/declaration.py b/src/stateforge_docs_theme/declaration.py new file mode 100644 index 0000000..d7327f6 --- /dev/null +++ b/src/stateforge_docs_theme/declaration.py @@ -0,0 +1,144 @@ +"""Shared validation mechanics for versioned documentation declarations.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +_VERSION_DIRECTORY = re.compile(r"^v\d+(?:\.\d+)*$") +_VERSIONED_FILENAME = re.compile(r"(?:^|[-_.])v\d+(?:\.\d+)*(?:$|[-_.])") + + +class DeclarationError(ValueError): + """Raised when a declarative documentation source is invalid.""" + + +def load_versioned_yaml( + path: Path, + *, + docs_dir: Path, + maximum_bytes: int, + source_name: str, +) -> tuple[dict[str, Any], str]: + """Load bounded YAML and derive its documentation version from its path.""" + resolved_docs = docs_dir.resolve() + resolved_path = path.resolve() + if not resolved_path.is_relative_to(resolved_docs): + raise DeclarationError(f"{source_name} source must remain inside docs_dir") + if resolved_path.suffix not in {".yaml", ".yml"}: + raise DeclarationError(f"{source_name} source must be YAML") + if _VERSIONED_FILENAME.search(resolved_path.stem): + raise DeclarationError( + f"{source_name} filenames must not contain versions; " + "use a version directory" + ) + try: + size = resolved_path.stat().st_size + except OSError as error: + raise DeclarationError( + f"cannot read {source_name} source: {resolved_path}" + ) from error + if size > maximum_bytes: + raise DeclarationError(f"{source_name} source exceeds {maximum_bytes} bytes") + try: + loaded = yaml.safe_load(resolved_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as error: + raise DeclarationError( + f"invalid {source_name} source: {resolved_path}" + ) from error + + relative = resolved_path.relative_to(resolved_docs) + return mapping(loaded, f"{source_name} declaration"), version_from_path(relative) + + +def version_from_path(relative_path: Path) -> str: + """Return the nearest version directory containing a declaration.""" + for part in reversed(relative_path.parts[:-1]): + if _VERSION_DIRECTORY.fullmatch(part): + return part + raise DeclarationError( + "declaration source must be located below a version directory such as " + "v0.1.0/procedures/bootstrap.yaml" + ) + + +def mapping(value: Any, context: str) -> dict[str, Any]: + """Require a string-keyed mapping.""" + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise DeclarationError(f"{context} must be a string-keyed mapping") + return value + + +def list_value(value: Any, context: str) -> list[Any]: + """Require a list.""" + if not isinstance(value, list): + raise DeclarationError(f"{context} must be a list") + return value + + +def text(value: Any, context: str) -> str: + """Require non-empty text.""" + if not isinstance(value, str) or not value.strip(): + raise DeclarationError(f"{context} must be non-empty text") + return value + + +def identifier(value: Any, context: str) -> str: + """Require a lowercase kebab-case declaration identifier.""" + result = text(value, context) + if not re.fullmatch(r"[a-z][a-z0-9-]*", result): + raise DeclarationError( + f"{context} must use lowercase letters, digits, and hyphens" + ) + return result + + +def text_list(value: Any, context: str) -> list[str]: + """Require a list of non-empty strings.""" + values = list_value(value, context) + for index, item in enumerate(values): + text(item, f"{context}[{index}]") + return values + + +def identifier_list(value: Any, context: str) -> set[str]: + """Require a duplicate-free list of declaration identifiers.""" + values = list_value(value, context) + identifiers = { + identifier(item, f"{context}[{index}]") for index, item in enumerate(values) + } + if len(identifiers) != len(values): + raise DeclarationError(f"{context} must not contain duplicates") + return identifiers + + +def exact_keys( + value: dict[str, Any], + *, + required: set[str], + optional: set[str], + context: str, +) -> None: + """Require an exact set of mandatory and optional mapping keys.""" + missing = required.difference(value) + unknown = set(value).difference(required | optional) + if missing: + raise DeclarationError(f"{context} is missing: {', '.join(sorted(missing))}") + if unknown: + raise DeclarationError( + f"{context} contains unknown fields: {', '.join(sorted(unknown))}" + ) + + +def require_known_entities( + referenced: set[str], entity_ids: set[str], context: str +) -> None: + """Require all referenced entity identifiers to be declared.""" + unknown = referenced.difference(entity_ids) + if unknown: + raise DeclarationError( + f"{context} references unknown entities: {', '.join(sorted(unknown))}" + ) diff --git a/src/stateforge_docs_theme/entity_flow/__init__.py b/src/stateforge_docs_theme/entity_flow/__init__.py new file mode 100644 index 0000000..a58401c --- /dev/null +++ b/src/stateforge_docs_theme/entity_flow/__init__.py @@ -0,0 +1,13 @@ +"""Declarative entity-flow visualization support.""" + +from stateforge_docs_theme.entity_flow.model import ( + EntityFlowDeclarationError, + load_entity_flow, +) +from stateforge_docs_theme.entity_flow.renderer import render_entity_flow + +__all__ = [ + "EntityFlowDeclarationError", + "load_entity_flow", + "render_entity_flow", +] diff --git a/src/stateforge_docs_theme/entity_flow/model.py b/src/stateforge_docs_theme/entity_flow/model.py new file mode 100644 index 0000000..df79d90 --- /dev/null +++ b/src/stateforge_docs_theme/entity_flow/model.py @@ -0,0 +1,208 @@ +"""Load and validate versioned entity-flow declarations.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from stateforge_docs_theme.declaration import ( + DeclarationError, + exact_keys, + identifier, + identifier_list, + list_value, + load_versioned_yaml, + mapping, + require_known_entities, + text, +) + +SCHEMA_ID = "stateforge.docs/entity-flow/v1" +MAX_DECLARATION_BYTES = 128 * 1024 + +EntityFlowDeclarationError = DeclarationError + +_BRANDS = frozenset({"none", "service", "stateforge"}) +_ACCENTS = frozenset({"neutral", "service", "systemd", "provider", "agent"}) +_TONES = frozenset({"normal", "success", "warning", "failure"}) + + +def load_entity_flow(path: Path, *, docs_dir: Path) -> dict[str, Any]: + """Load one entity flow and derive its documentation version from its path.""" + declaration, documentation_version = load_versioned_yaml( + path, + docs_dir=docs_dir, + maximum_bytes=MAX_DECLARATION_BYTES, + source_name="entity-flow", + ) + _validate_declaration(declaration) + return {**declaration, "documentation_version": documentation_version} + + +def _validate_declaration(declaration: dict[str, Any]) -> None: + exact_keys( + declaration, + required={"schema", "flow", "title", "entities", "steps"}, + optional={"summary", "autoplay"}, + context="entity-flow declaration", + ) + if text(declaration["schema"], "schema") != SCHEMA_ID: + raise EntityFlowDeclarationError(f"schema must be {SCHEMA_ID!r}") + identifier(declaration["flow"], "flow") + text(declaration["title"], "title") + if "summary" in declaration: + text(declaration["summary"], "summary") + if "autoplay" in declaration and not isinstance(declaration["autoplay"], bool): + raise EntityFlowDeclarationError("autoplay must be a boolean") + + entities = list_value(declaration["entities"], "entities") + if len(entities) < 2: + raise EntityFlowDeclarationError("entities must contain at least two entries") + entity_ids: set[str] = set() + for index, value in enumerate(entities): + entity_id = _validate_entity(value, f"entities[{index}]") + if entity_id in entity_ids: + raise EntityFlowDeclarationError(f"duplicate entity ID: {entity_id}") + entity_ids.add(entity_id) + + steps = list_value(declaration["steps"], "steps") + if not steps: + raise EntityFlowDeclarationError("steps must not be empty") + step_ids: set[str] = set() + for index, value in enumerate(steps): + step_id = _validate_step(value, f"steps[{index}]", entity_ids) + if step_id in step_ids: + raise EntityFlowDeclarationError(f"duplicate step ID: {step_id}") + step_ids.add(step_id) + + +def _validate_entity(value: Any, context: str) -> str: + entity = mapping(value, context) + exact_keys( + entity, + required={"id", "label", "kind", "accent", "state"}, + optional={"brand", "description"}, + context=context, + ) + entity_id = identifier(entity["id"], f"{context}.id") + text(entity["label"], f"{context}.label") + text(entity["kind"], f"{context}.kind") + if "description" in entity: + text(entity["description"], f"{context}.description") + brand = identifier(entity.get("brand", "none"), f"{context}.brand") + if brand not in _BRANDS: + raise EntityFlowDeclarationError( + f"{context}.brand must be one of {sorted(_BRANDS)}" + ) + accent = identifier(entity["accent"], f"{context}.accent") + if accent not in _ACCENTS: + raise EntityFlowDeclarationError( + f"{context}.accent must be one of {sorted(_ACCENTS)}" + ) + _validate_state(entity["state"], f"{context}.state") + return entity_id + + +def _validate_step(value: Any, context: str, entity_ids: set[str]) -> str: + step = mapping(value, context) + exact_keys( + step, + required={ + "id", + "phase", + "title", + "detail", + "active", + "states", + "transitions", + "responsibility", + }, + optional=set(), + context=context, + ) + step_id = identifier(step["id"], f"{context}.id") + text(step["phase"], f"{context}.phase") + text(step["title"], f"{context}.title") + text(step["detail"], f"{context}.detail") + active = identifier_list(step["active"], f"{context}.active") + require_known_entities(active, entity_ids, f"{context}.active") + + states = mapping(step["states"], f"{context}.states") + require_known_entities(set(states), entity_ids, f"{context}.states") + for entity_id, state_value in states.items(): + _validate_state(state_value, f"{context}.states.{entity_id}") + + transitions = list_value(step["transitions"], f"{context}.transitions") + for index, transition_value in enumerate(transitions): + _validate_transition( + transition_value, + f"{context}.transitions[{index}]", + entity_ids, + ) + + responsibility = mapping( + step["responsibility"], + f"{context}.responsibility", + ) + exact_keys( + responsibility, + required={"entities", "label"}, + optional=set(), + context=f"{context}.responsibility", + ) + owners = identifier_list( + responsibility["entities"], + f"{context}.responsibility.entities", + ) + if not owners: + raise EntityFlowDeclarationError( + f"{context}.responsibility.entities must not be empty" + ) + require_known_entities(owners, entity_ids, f"{context}.responsibility.entities") + text(responsibility["label"], f"{context}.responsibility.label") + + if not states and not transitions: + raise EntityFlowDeclarationError( + f"{context} must change entity state or carry a transition" + ) + return step_id + + +def _validate_state(value: Any, context: str) -> None: + state = mapping(value, context) + exact_keys( + state, + required={"label", "value"}, + optional={"tone"}, + context=context, + ) + text(state["label"], f"{context}.label") + text(state["value"], f"{context}.value") + tone = identifier(state.get("tone", "normal"), f"{context}.tone") + if tone not in _TONES: + raise EntityFlowDeclarationError( + f"{context}.tone must be one of {sorted(_TONES)}" + ) + + +def _validate_transition( + value: Any, + context: str, + entity_ids: set[str], +) -> None: + transition = mapping(value, context) + exact_keys( + transition, + required={"source", "target", "label"}, + optional={"tone"}, + context=context, + ) + source = identifier(transition["source"], f"{context}.source") + target = identifier(transition["target"], f"{context}.target") + require_known_entities({source, target}, entity_ids, context) + text(transition["label"], f"{context}.label") + tone = identifier(transition.get("tone", "normal"), f"{context}.tone") + if tone not in _TONES: + raise EntityFlowDeclarationError( + f"{context}.tone must be one of {sorted(_TONES)}" + ) diff --git a/src/stateforge_docs_theme/entity_flow/renderer.py b/src/stateforge_docs_theme/entity_flow/renderer.py new file mode 100644 index 0000000..788bcad --- /dev/null +++ b/src/stateforge_docs_theme/entity_flow/renderer.py @@ -0,0 +1,43 @@ +"""Render validated entity-flow declarations for the shared browser component.""" + +from __future__ import annotations + +import html +import json +from typing import Any + + +def render_entity_flow(declaration: dict[str, Any]) -> str: + """Render one interactive entity flow plus an accessible static fallback.""" + encoded = json.dumps(declaration, ensure_ascii=False, separators=(",", ":")) + encoded = ( + encoded.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e") + ) + return ( + '\n
    \n' + "' + '" + f"{_render_fallback(declaration)}" + "\n" + "
    \n" + ) + + +def _render_fallback(declaration: dict[str, Any]) -> str: + entities = ", ".join(entity["label"] for entity in declaration["entities"]) + steps = "".join( + "
  • " + f"{html.escape(step['title'])} " + f"{html.escape(step['detail'])}" + "
  • " + for step in declaration["steps"] + ) + return ( + '
    ' + f"{html.escape(declaration['title'])}" + f"

    {html.escape(entities)}

    " + f"
      {steps}
    " + "
    " + ) diff --git a/src/stateforge_docs_theme/plugin.py b/src/stateforge_docs_theme/plugin.py index b80d131..120e6cc 100644 --- a/src/stateforge_docs_theme/plugin.py +++ b/src/stateforge_docs_theme/plugin.py @@ -16,10 +16,14 @@ from stateforge_docs_theme.branding import ( COMPONENT_ACCENTS, - LIGHT_FOREGROUND, component_accent, contrast_foreground, ) +from stateforge_docs_theme.entity_flow import ( + EntityFlowDeclarationError, + load_entity_flow, + render_entity_flow, +) from stateforge_docs_theme.procedure.model import ( ProcedureDeclarationError, load_procedure, @@ -30,6 +34,10 @@ r"^```stateforge-procedure[ \t]*\n(?P.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL, ) +_ENTITY_FLOW_FENCE = re.compile( + r"^```stateforge-entity-flow[ \t]*\n(?P.*?)^```[ \t]*$", + re.MULTILINE | re.DOTALL, +) class DocumentationVersionConfig(Config): # type: ignore[no-untyped-call] @@ -60,6 +68,7 @@ class StateForgeDocsConfig(Config): # type: ignore[no-untyped-call] ) historical_warning = config_options.Type(bool, default=True) procedure_players = config_options.Type(bool, default=True) + entity_flows = config_options.Type(bool, default=True) class StateForgeDocsPlugin( # type: ignore[no-untyped-call] @@ -105,12 +114,8 @@ def on_config(self, config: Any) -> Any: ) foreground = contrast_foreground(accent) - logo_filter = ( - "brightness(0) invert(1)" - if foreground == LIGHT_FOREGROUND - else "brightness(0)" - ) extra = dict(config.extra or {}) + extra.setdefault("generator", False) stateforge = dict(extra.get("stateforge") or {}) stateforge.update( { @@ -122,7 +127,6 @@ def on_config(self, config: Any) -> Any: "known_components": sorted(COMPONENT_ACCENTS), "latest_url": latest_url, "latest_version": latest_version, - "logo_filter": logo_filter, "versions": versions, } ) @@ -139,7 +143,7 @@ def on_page_markdown( files: Files, ) -> str: del files - if not self.config.procedure_players: + if not self.config.procedure_players and not self.config.entity_flows: return markdown if page.file.abs_src_path is None: @@ -147,7 +151,7 @@ def on_page_markdown( page_source = Path(page.file.abs_src_path) docs_dir = Path(config.docs_dir) - def replace(match: re.Match[str]) -> str: + def replace_procedure(match: re.Match[str]) -> str: try: directive = _load_directive(match.group("body")) source = (page_source.parent / directive["source"]).resolve() @@ -161,7 +165,27 @@ def replace(match: re.Match[str]) -> str: f"{page.file.src_path}: invalid stateforge-procedure block: {error}" ) from error - return _PROCEDURE_FENCE.sub(replace, markdown) + def replace_entity_flow(match: re.Match[str]) -> str: + try: + directive = _load_directive(match.group("body")) + if "scenario" in directive: + raise EntityFlowDeclarationError( + "entity-flow directives do not select scenarios" + ) + source = (page_source.parent / directive["source"]).resolve() + declaration = load_entity_flow(source, docs_dir=docs_dir) + return render_entity_flow(declaration) + except (EntityFlowDeclarationError, ValueError) as error: + raise PluginError( + f"{page.file.src_path}: invalid stateforge-entity-flow block: " + f"{error}" + ) from error + + if self.config.entity_flows: + markdown = _ENTITY_FLOW_FENCE.sub(replace_entity_flow, markdown) + if self.config.procedure_players: + markdown = _PROCEDURE_FENCE.sub(replace_procedure, markdown) + return markdown def _load_directive(source: str) -> dict[str, str]: diff --git a/src/stateforge_docs_theme/procedure/model.py b/src/stateforge_docs_theme/procedure/model.py index 9b604cd..420d039 100644 --- a/src/stateforge_docs_theme/procedure/model.py +++ b/src/stateforge_docs_theme/procedure/model.py @@ -2,17 +2,25 @@ from __future__ import annotations -import re from pathlib import Path from typing import Any -import yaml +from stateforge_docs_theme.declaration import ( + DeclarationError, + exact_keys, + identifier, + identifier_list, + list_value, + load_versioned_yaml, + mapping, + require_known_entities, + text, + text_list, +) SCHEMA_ID = "stateforge.docs/procedure/v1" MAX_DECLARATION_BYTES = 512 * 1024 -_VERSION_DIRECTORY = re.compile(r"^v\d+(?:\.\d+)*$") -_VERSIONED_FILENAME = re.compile(r"(?:^|[-_.])v\d+(?:\.\d+)*(?:$|[-_.])") _FORBIDDEN_VERSION_KEYS = frozenset( {"version", "protocol_version", "documentation_version"} ) @@ -22,40 +30,17 @@ _TONES = frozenset({"normal", "success", "warning", "failure"}) -class ProcedureDeclarationError(ValueError): - """Raised when a procedure declaration violates the shared contract.""" +ProcedureDeclarationError = DeclarationError def load_procedure(path: Path, *, docs_dir: Path) -> dict[str, Any]: """Load one declaration and derive its documentation version from its path.""" - resolved_docs = docs_dir.resolve() - resolved_path = path.resolve() - if not resolved_path.is_relative_to(resolved_docs): - raise ProcedureDeclarationError("procedure source must remain inside docs_dir") - if resolved_path.suffix not in {".yaml", ".yml"}: - raise ProcedureDeclarationError("procedure source must be YAML") - if _VERSIONED_FILENAME.search(resolved_path.stem): - raise ProcedureDeclarationError( - "procedure filenames must not contain versions; use a version directory" - ) - try: - size = resolved_path.stat().st_size - except OSError as error: - raise ProcedureDeclarationError( - f"cannot read procedure source: {resolved_path}" - ) from error - if size > MAX_DECLARATION_BYTES: - raise ProcedureDeclarationError( - f"procedure source exceeds {MAX_DECLARATION_BYTES} bytes" - ) - try: - loaded = yaml.safe_load(resolved_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, yaml.YAMLError) as error: - raise ProcedureDeclarationError( - f"invalid procedure source: {resolved_path}" - ) from error - - declaration = _mapping(loaded, "procedure declaration") + declaration, documentation_version = load_versioned_yaml( + path, + docs_dir=docs_dir, + maximum_bytes=MAX_DECLARATION_BYTES, + source_name="procedure", + ) forbidden = _FORBIDDEN_VERSION_KEYS.intersection(declaration) if forbidden: names = ", ".join(sorted(forbidden)) @@ -63,37 +48,25 @@ def load_procedure(path: Path, *, docs_dir: Path) -> dict[str, Any]: f"procedure versions come from directories, not fields: {names}" ) - relative = resolved_path.relative_to(resolved_docs) - documentation_version = _version_from_path(relative) _validate_declaration(declaration) expanded = _expand_scenario_prefixes(declaration) return {**expanded, "documentation_version": documentation_version} -def _version_from_path(relative_path: Path) -> str: - for part in reversed(relative_path.parts[:-1]): - if _VERSION_DIRECTORY.fullmatch(part): - return part - raise ProcedureDeclarationError( - "procedure source must be located below a version directory such as " - "v0.1.0/procedures/bootstrap.yaml" - ) - - def _validate_declaration(declaration: dict[str, Any]) -> None: - _exact_keys( + exact_keys( declaration, required={"schema", "procedure", "title", "summary", "entities", "scenarios"}, optional={"default_scenario"}, context="procedure declaration", ) - if _text(declaration["schema"], "schema") != SCHEMA_ID: + if text(declaration["schema"], "schema") != SCHEMA_ID: raise ProcedureDeclarationError(f"schema must be {SCHEMA_ID!r}") - _identifier(declaration["procedure"], "procedure") - _text(declaration["title"], "title") - _text(declaration["summary"], "summary") + identifier(declaration["procedure"], "procedure") + text(declaration["title"], "title") + text(declaration["summary"], "summary") - entities = _list(declaration["entities"], "entities") + entities = list_value(declaration["entities"], "entities") if not entities: raise ProcedureDeclarationError("entities must not be empty") entity_ids: set[str] = set() @@ -103,12 +76,12 @@ def _validate_declaration(declaration: dict[str, Any]) -> None: raise ProcedureDeclarationError(f"duplicate entity ID: {entity_id}") entity_ids.add(entity_id) - scenarios = _mapping(declaration["scenarios"], "scenarios") + scenarios = mapping(declaration["scenarios"], "scenarios") if not scenarios: raise ProcedureDeclarationError("scenarios must not be empty") scenario_ids = set(scenarios) for scenario_id, scenario_value in scenarios.items(): - _identifier(scenario_id, "scenario identifier") + identifier(scenario_id, "scenario identifier") _validate_scenario( scenario_id, scenario_value, @@ -124,22 +97,22 @@ def _validate_declaration(declaration: dict[str, Any]) -> None: def _validate_entity(value: Any, context: str) -> str: - entity = _mapping(value, context) - _exact_keys( + entity = mapping(value, context) + exact_keys( entity, required={"id", "label", "kind"}, optional={"role"}, context=context, ) - entity_id = _identifier(entity["id"], f"{context}.id") - _text(entity["label"], f"{context}.label") - kind = _text(entity["kind"], f"{context}.kind") + entity_id = identifier(entity["id"], f"{context}.id") + text(entity["label"], f"{context}.label") + kind = text(entity["kind"], f"{context}.kind") if kind not in _ENTITY_KINDS: raise ProcedureDeclarationError( f"{context}.kind must be one of {sorted(_ENTITY_KINDS)}" ) if "role" in entity: - _text(entity["role"], f"{context}.role") + text(entity["role"], f"{context}.role") return entity_id @@ -150,23 +123,23 @@ def _validate_scenario( scenario_ids: set[str], ) -> None: context = f"scenarios.{scenario_id}" - scenario = _mapping(value, context) - _exact_keys( + scenario = mapping(value, context) + exact_keys( scenario, required={"label", "steps"}, optional={"prefix"}, context=context, ) - _text(scenario["label"], f"{context}.label") + text(scenario["label"], f"{context}.label") if "prefix" in scenario: - prefix = _mapping(scenario["prefix"], f"{context}.prefix") - _exact_keys( + prefix = mapping(scenario["prefix"], f"{context}.prefix") + exact_keys( prefix, required={"scenario", "through"}, optional=set(), context=f"{context}.prefix", ) - parent = _identifier( + parent = identifier( prefix["scenario"], f"{context}.prefix.scenario", ) @@ -174,8 +147,8 @@ def _validate_scenario( raise ProcedureDeclarationError( f"{context}.prefix references unknown scenario: {parent}" ) - _identifier(prefix["through"], f"{context}.prefix.through") - steps = _list(scenario["steps"], f"{context}.steps") + identifier(prefix["through"], f"{context}.prefix.through") + steps = list_value(scenario["steps"], f"{context}.steps") if not steps: raise ProcedureDeclarationError(f"{context}.steps must not be empty") step_ids: set[str] = set() @@ -199,8 +172,8 @@ def _validate_step( context: str, entity_ids: set[str], ) -> str | None: - step = _mapping(value, context) - _exact_keys( + step = mapping(value, context) + exact_keys( step, required={ "title", @@ -213,16 +186,16 @@ def _validate_step( optional={"id", "fields", "context"}, context=context, ) - step_id = _identifier(step["id"], f"{context}.id") if "id" in step else None - _text(step["title"], f"{context}.title") - _text(step["detail"], f"{context}.detail") - _text(step["phase"], f"{context}.phase") - _text_list(step.get("fields", []), f"{context}.fields") + step_id = identifier(step["id"], f"{context}.id") if "id" in step else None + text(step["title"], f"{context}.title") + text(step["detail"], f"{context}.detail") + text(step["phase"], f"{context}.phase") + text_list(step.get("fields", []), f"{context}.fields") - active = _identifier_list(step["active"], f"{context}.active") - _require_known_entities(active, entity_ids, f"{context}.active") + active = identifier_list(step["active"], f"{context}.active") + require_known_entities(active, entity_ids, f"{context}.active") - transitions = _list(step["transitions"], f"{context}.transitions") + transitions = list_value(step["transitions"], f"{context}.transitions") for index, transition_value in enumerate(transitions): _validate_transition( transition_value, @@ -230,36 +203,36 @@ def _validate_step( entity_ids, ) - activities = _mapping(step["activities"], f"{context}.activities") - _require_known_entities(set(activities), entity_ids, f"{context}.activities") + activities = mapping(step["activities"], f"{context}.activities") + require_known_entities(set(activities), entity_ids, f"{context}.activities") for entity_id, activity_value in activities.items(): - activity = _mapping(activity_value, f"{context}.activities.{entity_id}") - _exact_keys( + activity = mapping(activity_value, f"{context}.activities.{entity_id}") + exact_keys( activity, required={"title", "items"}, optional=set(), context=f"{context}.activities.{entity_id}", ) - _text(activity["title"], f"{context}.activities.{entity_id}.title") - _text_list(activity["items"], f"{context}.activities.{entity_id}.items") + text(activity["title"], f"{context}.activities.{entity_id}.title") + text_list(activity["items"], f"{context}.activities.{entity_id}.items") if "context" in step: - shared_context = _mapping(step["context"], f"{context}.context") - _exact_keys( + shared_context = mapping(step["context"], f"{context}.context") + exact_keys( shared_context, required={"kind", "value"}, optional=set(), context=f"{context}.context", ) - _text(shared_context["kind"], f"{context}.context.kind") - _text(shared_context["value"], f"{context}.context.value") + text(shared_context["kind"], f"{context}.context.kind") + text(shared_context["value"], f"{context}.context.value") return step_id def _expand_scenario_prefixes( declaration: dict[str, Any], ) -> dict[str, Any]: - scenarios = _mapping(declaration["scenarios"], "scenarios") + scenarios = mapping(declaration["scenarios"], "scenarios") expanded_steps: dict[str, list[Any]] = {} def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: @@ -269,13 +242,13 @@ def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: cycle = " -> ".join((*stack, scenario_id)) raise ProcedureDeclarationError(f"scenario prefix cycle detected: {cycle}") - scenario = _mapping( + scenario = mapping( scenarios[scenario_id], f"scenarios.{scenario_id}", ) inherited: list[Any] = [] if "prefix" in scenario: - prefix = _mapping( + prefix = mapping( scenario["prefix"], f"scenarios.{scenario_id}.prefix", ) @@ -283,7 +256,7 @@ def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: through = prefix["through"] parent_steps = expand(parent_id, (*stack, scenario_id)) for index, step_value in enumerate(parent_steps): - step = _mapping( + step = mapping( step_value, f"scenarios.{parent_id}.steps[{index}]", ) @@ -296,7 +269,7 @@ def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: f"in {parent_id}: {through}" ) - combined = [*inherited, *_list(scenario["steps"], "steps")] + combined = [*inherited, *list_value(scenario["steps"], "steps")] identifiers = [ step["id"] for step in combined if isinstance(step, dict) and "id" in step ] @@ -310,7 +283,7 @@ def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: expanded_scenarios = { scenario_id: { key: item - for key, item in _mapping( + for key, item in mapping( scenario_value, f"scenarios.{scenario_id}", ).items() @@ -325,92 +298,19 @@ def expand(scenario_id: str, stack: tuple[str, ...]) -> list[Any]: def _validate_transition(value: Any, context: str, entity_ids: set[str]) -> None: - transition = _mapping(value, context) - _exact_keys( + transition = mapping(value, context) + exact_keys( transition, required={"source", "target", "label"}, optional={"tone"}, context=context, ) - source = _identifier(transition["source"], f"{context}.source") - target = _identifier(transition["target"], f"{context}.target") - _require_known_entities({source, target}, entity_ids, context) - _text(transition["label"], f"{context}.label") - tone = _text(transition.get("tone", "normal"), f"{context}.tone") + source = identifier(transition["source"], f"{context}.source") + target = identifier(transition["target"], f"{context}.target") + require_known_entities({source, target}, entity_ids, context) + text(transition["label"], f"{context}.label") + tone = text(transition.get("tone", "normal"), f"{context}.tone") if tone not in _TONES: raise ProcedureDeclarationError( f"{context}.tone must be one of {sorted(_TONES)}" ) - - -def _require_known_entities( - referenced: set[str], entity_ids: set[str], context: str -) -> None: - unknown = referenced.difference(entity_ids) - if unknown: - raise ProcedureDeclarationError( - f"{context} references unknown entities: {', '.join(sorted(unknown))}" - ) - - -def _mapping(value: Any, context: str) -> dict[str, Any]: - if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): - raise ProcedureDeclarationError(f"{context} must be a string-keyed mapping") - return value - - -def _list(value: Any, context: str) -> list[Any]: - if not isinstance(value, list): - raise ProcedureDeclarationError(f"{context} must be a list") - return value - - -def _text(value: Any, context: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ProcedureDeclarationError(f"{context} must be non-empty text") - return value - - -def _identifier(value: Any, context: str) -> str: - identifier = _text(value, context) - if not re.fullmatch(r"[a-z][a-z0-9-]*", identifier): - raise ProcedureDeclarationError( - f"{context} must use lowercase letters, digits, and hyphens" - ) - return identifier - - -def _text_list(value: Any, context: str) -> list[str]: - values = _list(value, context) - for index, item in enumerate(values): - _text(item, f"{context}[{index}]") - return values - - -def _identifier_list(value: Any, context: str) -> set[str]: - values = _list(value, context) - identifiers = { - _identifier(item, f"{context}[{index}]") for index, item in enumerate(values) - } - if len(identifiers) != len(values): - raise ProcedureDeclarationError(f"{context} must not contain duplicates") - return identifiers - - -def _exact_keys( - value: dict[str, Any], - *, - required: set[str], - optional: set[str], - context: str, -) -> None: - missing = required.difference(value) - unknown = set(value).difference(required | optional) - if missing: - raise ProcedureDeclarationError( - f"{context} is missing: {', '.join(sorted(missing))}" - ) - if unknown: - raise ProcedureDeclarationError( - f"{context} contains unknown fields: {', '.join(sorted(unknown))}" - ) diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark-black.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark-black.svg new file mode 100644 index 0000000..62177be --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark-black.svg @@ -0,0 +1,27 @@ + + StateForge + A cube containing a layered state checkpoint. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg index ffd2a3e..1d3a75e 100644 --- a/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg +++ b/src/stateforge_docs_theme/theme/assets/images/stateforge-mark.svg @@ -1,28 +1,18 @@ - - StateForge - A cube containing a layered state checkpoint. + + StateForge + A glowing blue cube containing a layered state checkpoint on a dark background. + - - - - - - - - - - - - - - + + + - + - - - + + + diff --git a/src/stateforge_docs_theme/theme/assets/javascripts/entity-flow.js b/src/stateforge_docs_theme/theme/assets/javascripts/entity-flow.js new file mode 100644 index 0000000..4eba09d --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/javascripts/entity-flow.js @@ -0,0 +1,340 @@ +(() => { + "use strict"; + + const script = document.currentScript; + const assetRoot = new URL("../images/", script ? script.src : document.baseURI); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function control(label, action, primary = false) { + const node = element( + "button", + primary ? "sf-entity-flow__control is-primary" : "sf-entity-flow__control", + label, + ); + node.type = "button"; + node.dataset.action = action; + return node; + } + + function brand(entity) { + const wrapper = element("span", "sf-entity-flow__brand"); + wrapper.dataset.brand = entity.brand || "none"; + if (entity.brand === "stateforge") { + const picture = document.createElement("picture"); + const light = document.createElement("img"); + const dark = document.createElement("img"); + light.src = new URL("stateforge-mark-black.svg", assetRoot).href; + dark.src = new URL("stateforge-mark.svg", assetRoot).href; + light.alt = "StateForge"; + dark.alt = "StateForge"; + light.className = "sf-entity-flow__brand-light"; + dark.className = "sf-entity-flow__brand-dark"; + picture.append(light, dark); + wrapper.append(picture); + } else if (entity.brand === "service") { + wrapper.setAttribute("aria-hidden", "true"); + const glyph = element("span", "sf-entity-flow__service-glyph"); + glyph.append(element("i"), element("i"), element("i")); + wrapper.append(glyph); + } else { + wrapper.setAttribute("aria-hidden", "true"); + wrapper.append(element("span", "", entity.label.slice(0, 3))); + } + return wrapper; + } + + class StateForgeEntityFlow extends HTMLElement { + connectedCallback() { + if (this.dataset.initialized === "true") return; + const source = this.querySelector(".sf-entity-flow-data"); + if (!source) return; + try { + this.declaration = JSON.parse(source.textContent || ""); + } catch (error) { + console.error("StateForge entity-flow declaration is invalid", error); + return; + } + this.stepIndex = 0; + this.timer = null; + this.hasAutoplayed = false; + this.dataset.initialized = "true"; + this.build(source); + this.render(false); + this.observeAutoplay(); + } + + disconnectedCallback() { + this.stop(); + if (this.observer) this.observer.disconnect(); + } + + build(source) { + const fallback = this.querySelector(".sf-entity-flow-fallback"); + if (fallback) fallback.remove(); + + this.flow = element("section", "sf-entity-flow"); + this.flow.setAttribute("aria-label", this.declaration.title); + this.flow.style.setProperty( + "--sf-entity-flow-count", + String(this.declaration.entities.length), + ); + + this.phase = element("div", "sf-entity-flow__phase"); + this.phase.append( + element("span", "", "Current event"), + element("strong"), + ); + + this.viewport = element("div", "sf-entity-flow__viewport"); + this.stage = element("div", "sf-entity-flow__stage"); + this.entityGrid = element("div", "sf-entity-flow__entities"); + this.cards = new Map(); + this.stateLabels = new Map(); + this.stateValues = new Map(); + + for (const entity of this.declaration.entities) { + const card = element("article", "sf-entity-flow__entity"); + card.dataset.entity = entity.id; + card.dataset.accent = entity.accent; + const header = element("header", "sf-entity-flow__entity-header"); + const identity = element("div", "sf-entity-flow__identity"); + identity.append( + element("span", "sf-entity-flow__kind", entity.kind), + element("strong", "", entity.label), + ); + header.append(brand(entity), identity); + const description = entity.description + ? element("p", "sf-entity-flow__description", entity.description) + : null; + const state = element("div", "sf-entity-flow__state"); + const stateLabel = element("span"); + const stateValue = element("strong"); + state.append(stateLabel, stateValue); + card.append(header); + if (description) card.append(description); + card.append(state); + this.cards.set(entity.id, card); + this.stateLabels.set(entity.id, stateLabel); + this.stateValues.set(entity.id, stateValue); + this.entityGrid.append(card); + } + + this.transitionGrid = element("div", "sf-entity-flow__transitions"); + this.transitionGrid.setAttribute("aria-label", "Current entity transitions"); + this.stage.append(this.entityGrid, this.transitionGrid); + this.viewport.append(this.stage); + + this.responsibility = element("div", "sf-entity-flow__responsibility"); + this.responsibility.append( + element("span", "sf-entity-flow__responsibility-label", "Responsibility"), + element("strong"), + ); + + this.detail = element("div", "sf-entity-flow__detail"); + this.detail.setAttribute("aria-live", "polite"); + const copy = element("div"); + this.stepCount = element("span", "sf-entity-flow__step-count"); + this.stepTitle = element("h3"); + this.stepDetail = element("p"); + copy.append(this.stepCount, this.stepTitle, this.stepDetail); + this.detail.append(copy); + + this.progress = element("div", "sf-entity-flow__progress"); + this.progress.setAttribute("role", "progressbar"); + this.progress.setAttribute("aria-label", "Entity flow progress"); + this.progress.setAttribute("aria-valuemin", "1"); + this.progressBar = element("span", "sf-entity-flow__progress-bar"); + this.progress.append(this.progressBar); + + const footer = element("div", "sf-entity-flow__footer"); + this.stepButtons = element("div", "sf-entity-flow__step-buttons"); + this.declaration.steps.forEach((step, index) => { + const node = element("button", "", String(index + 1)); + node.type = "button"; + node.dataset.step = String(index); + node.setAttribute("aria-label", `Show ${step.title}`); + node.addEventListener("click", () => { + this.stop(); + this.stepIndex = index; + this.render(true); + }); + this.stepButtons.append(node); + }); + const controls = element("div", "sf-entity-flow__controls"); + this.previous = control("Previous", "previous"); + this.play = control("Play", "play", true); + this.play.setAttribute("aria-pressed", "false"); + this.next = control("Next", "next"); + controls.append(this.previous, this.play, this.next); + controls.addEventListener("click", (event) => { + const target = event.target.closest("button[data-action]"); + if (!target) return; + if (target.dataset.action === "previous") this.move(-1); + if (target.dataset.action === "next") this.move(1); + if (target.dataset.action === "play") this.togglePlay(); + }); + footer.append(this.stepButtons, controls); + + this.flow.append( + this.phase, + this.viewport, + this.responsibility, + this.detail, + this.progress, + footer, + ); + source.after(this.flow); + } + + observeAutoplay() { + if (!this.declaration.autoplay || reducedMotion.matches) return; + this.observer = new IntersectionObserver( + (entries) => { + if ( + this.hasAutoplayed || + !entries.some((entry) => entry.isIntersecting) + ) { + return; + } + this.hasAutoplayed = true; + this.start(); + this.observer.disconnect(); + }, + { threshold: 0.55 }, + ); + this.observer.observe(this); + } + + stateAt(entityId) { + const entity = this.declaration.entities.find((item) => item.id === entityId); + let state = entity.state; + for (let index = 0; index <= this.stepIndex; index += 1) { + const update = this.declaration.steps[index].states[entityId]; + if (update) state = update; + } + return state; + } + + move(offset) { + this.stop(); + this.stepIndex = Math.max( + 0, + Math.min( + this.stepIndex + offset, + this.declaration.steps.length - 1, + ), + ); + this.render(true); + } + + togglePlay() { + if (this.timer) { + this.stop(); + return; + } + this.start(); + } + + start() { + this.stop(); + if (this.stepIndex === this.declaration.steps.length - 1) { + this.stepIndex = 0; + } + this.play.textContent = "Pause"; + this.play.setAttribute("aria-pressed", "true"); + this.render(true); + this.timer = window.setInterval(() => { + if (this.stepIndex >= this.declaration.steps.length - 1) { + this.stop(); + return; + } + this.stepIndex += 1; + this.render(true); + }, 2200); + } + + stop() { + if (this.timer) window.clearInterval(this.timer); + this.timer = null; + if (this.play) { + this.play.textContent = "Play"; + this.play.setAttribute("aria-pressed", "false"); + } + } + + renderTransitions(transitions, animate) { + this.transitionGrid.replaceChildren(); + const entityIndex = new Map( + this.declaration.entities.map((entity, index) => [entity.id, index]), + ); + transitions.forEach((transition, rowIndex) => { + const source = entityIndex.get(transition.source); + const target = entityIndex.get(transition.target); + const start = Math.min(source, target) + 1; + const end = Math.max(source, target) + 2; + const node = element("div", "sf-entity-flow__transition"); + node.style.gridColumn = `${start} / ${end}`; + node.style.gridRow = String(rowIndex + 1); + node.classList.toggle("is-reverse", source > target); + node.dataset.tone = transition.tone || "normal"; + node.append( + element("span", "sf-entity-flow__transition-line"), + element("span", "sf-entity-flow__packet"), + element("code", "sf-entity-flow__message", transition.label), + ); + this.transitionGrid.append(node); + if (animate && !reducedMotion.matches) { + window.requestAnimationFrame(() => node.classList.add("is-running")); + } + }); + this.transitionGrid.classList.toggle("is-empty", transitions.length === 0); + } + + render(animate) { + const current = this.declaration.steps[this.stepIndex]; + const active = new Set(current.active); + const responsible = new Set(current.responsibility.entities); + const completion = ((this.stepIndex + 1) / this.declaration.steps.length) * 100; + + this.phase.querySelector("strong").textContent = current.phase; + for (const entity of this.declaration.entities) { + const card = this.cards.get(entity.id); + const state = this.stateAt(entity.id); + card.classList.toggle("is-active", active.has(entity.id)); + card.classList.toggle("is-responsible", responsible.has(entity.id)); + card.dataset.tone = state.tone || "normal"; + this.stateLabels.get(entity.id).textContent = state.label; + this.stateValues.get(entity.id).textContent = state.value; + } + this.renderTransitions(current.transitions, animate); + + this.responsibility.querySelector("strong").textContent = + current.responsibility.label; + this.stepCount.textContent = `Step ${this.stepIndex + 1} of ${this.declaration.steps.length}`; + this.stepTitle.textContent = current.title; + this.stepDetail.textContent = current.detail; + this.progress.setAttribute( + "aria-valuemax", + String(this.declaration.steps.length), + ); + this.progress.setAttribute("aria-valuenow", String(this.stepIndex + 1)); + this.progressBar.style.width = `${completion}%`; + this.previous.disabled = this.stepIndex === 0; + this.next.disabled = this.stepIndex === this.declaration.steps.length - 1; + [...this.stepButtons.children].forEach((node, index) => { + node.setAttribute("aria-current", index === this.stepIndex ? "step" : "false"); + }); + } + } + + if (!customElements.get("stateforge-entity-flow")) { + customElements.define("stateforge-entity-flow", StateForgeEntityFlow); + } +})(); diff --git a/src/stateforge_docs_theme/theme/assets/javascripts/lifecycle-flow.js b/src/stateforge_docs_theme/theme/assets/javascripts/lifecycle-flow.js new file mode 100644 index 0000000..3ba64cd --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/javascripts/lifecycle-flow.js @@ -0,0 +1,130 @@ +(() => { + "use strict"; + + const EVENT_DURATION_MS = 620; + + function parseEvents(step) { + try { + const events = JSON.parse(step.dataset.events || "[]"); + return Array.isArray(events) ? events : []; + } catch (error) { + console.error("StateForge lifecycle events are invalid", error); + return []; + } + } + + function initialize(root) { + if (root.dataset.initialized === "true") return; + + const steps = Array.from(root.querySelectorAll("[data-sf-flow-step]")); + const sourceState = root.querySelector("[data-sf-flow-source-state]"); + const targetState = root.querySelector("[data-sf-flow-target-state]"); + const packet = root.querySelector("[data-sf-flow-packet]"); + const message = root.querySelector("[data-sf-flow-message]"); + const status = root.querySelector("[data-sf-flow-status]"); + const source = root.querySelector('[data-sf-flow-actor="source"]'); + const target = root.querySelector('[data-sf-flow-actor="target"]'); + + if ( + steps.length === 0 || + !sourceState || + !targetState || + !packet || + !message || + !status || + !source || + !target + ) { + return; + } + + let run = 0; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + + function clearAnimation() { + packet.classList.remove("is-forward", "is-reverse"); + source.classList.remove("is-sending", "is-receiving"); + target.classList.remove("is-sending", "is-receiving"); + } + + function showEvent(event) { + const direction = event.direction === "reverse" ? "reverse" : "forward"; + message.textContent = event.label || "Protocol message"; + clearAnimation(); + + if (reducedMotion.matches) return; + + const sender = direction === "forward" ? source : target; + const receiver = direction === "forward" ? target : source; + sender.classList.add("is-sending"); + receiver.classList.add("is-receiving"); + void packet.offsetWidth; + packet.classList.add(`is-${direction}`); + } + + async function select(step, play) { + const selectedRun = ++run; + const events = parseEvents(step); + + for (const candidate of steps) { + const selected = candidate === step; + candidate.setAttribute("aria-pressed", String(selected)); + const action = candidate.querySelector("[data-sf-flow-action]"); + if (action) action.textContent = selected ? "Replay" : "Play"; + } + + sourceState.textContent = step.dataset.sourceState || "Source"; + targetState.textContent = step.dataset.targetState || "Target"; + root.dataset.stage = step.dataset.stage || ""; + clearAnimation(); + + if (!play) { + const firstEvent = events[0]; + message.textContent = firstEvent?.label || "Protocol exchange"; + status.textContent = + root.dataset.prompt || "Select a stage to play its exchange"; + root.setAttribute("aria-busy", "false"); + return; + } + + if (reducedMotion.matches || events.length === 0) { + const finalEvent = events.at(-1); + message.textContent = finalEvent?.label || "Protocol exchange"; + status.textContent = step.dataset.result || "Ready"; + root.setAttribute("aria-busy", "false"); + return; + } + + root.setAttribute("aria-busy", "true"); + for (const event of events) { + if (run !== selectedRun) return; + status.textContent = event.detail || event.label || "Protocol exchange"; + showEvent(event); + await new Promise((resolve) => { + window.setTimeout(resolve, EVENT_DURATION_MS); + }); + } + + if (run !== selectedRun) return; + clearAnimation(); + status.textContent = step.dataset.result || "Stage complete"; + root.setAttribute("aria-busy", "false"); + } + + for (const step of steps) { + step.addEventListener("click", () => select(step, true)); + } + + root.dataset.initialized = "true"; + const initial = + steps.find((step) => step.getAttribute("aria-pressed") === "true") || + steps[0]; + select(initial, false); + } + + for (const root of document.querySelectorAll("[data-sf-lifecycle]")) { + initialize(root); + } +})(); diff --git a/src/stateforge_docs_theme/theme/assets/javascripts/version-selector.js b/src/stateforge_docs_theme/theme/assets/javascripts/version-selector.js new file mode 100644 index 0000000..ad4d25c --- /dev/null +++ b/src/stateforge_docs_theme/theme/assets/javascripts/version-selector.js @@ -0,0 +1,10 @@ +"use strict"; + +for (const selector of document.querySelectorAll("[data-sf-version-selector]")) { + selector.addEventListener("change", () => { + const destination = selector.value; + if (destination) { + window.location.assign(destination); + } + }); +} diff --git a/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css index 032cbb5..01650b4 100644 --- a/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css +++ b/src/stateforge_docs_theme/theme/assets/stylesheets/stateforge.css @@ -1,11 +1,23 @@ :root { - --sf-accent: #245fc7; + --sf-brand: #7fbcfb; + --sf-night: #101317; + --sf-steel: #273548; + --sf-text: #f4f7fb; + --sf-muted-dark: #9ba7b4; + --sf-accent: #216aa6; --sf-accent-contrast: #ffffff; --sf-canvas: #ffffff; - --sf-ink: #111827; - --sf-logo-filter: brightness(0) invert(1); + --sf-ink: #101317; + --sf-hero-bg: #f4f7fb; + --sf-hero-border: color-mix(in srgb, var(--sf-steel) 24%, transparent); + --sf-hero-text: #101317; + --sf-hero-muted: color-mix(in srgb, var(--sf-steel) 78%, transparent); + --sf-release-bg: color-mix(in srgb, var(--sf-brand) 8%, #ffffff); + --sf-release-border: color-mix(in srgb, var(--sf-steel) 24%, transparent); + --sf-release-text: #101317; + --sf-release-muted: color-mix(in srgb, var(--sf-steel) 78%, transparent); --sf-font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --sf-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --sf-font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; --sf-radius-small: .3rem; --sf-radius-medium: .6rem; --sf-space-1: .25rem; @@ -18,9 +30,9 @@ --sf-series-4: #7652c7; --sf-series-5: #b53e62; --sf-series-6: #397a95; - --sf-success: #16865b; - --sf-warning: #c66b0a; - --sf-failure: #c43c4a; + --sf-success: #27845f; + --sf-warning: #9d6c00; + --sf-failure: #b43c46; --sf-surface: color-mix(in srgb, var(--md-default-bg-color) 94%, var(--sf-accent)); --sf-surface-strong: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--sf-accent)); --sf-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent); @@ -28,12 +40,28 @@ } [data-md-color-scheme="slate"] { - --sf-canvas: #0f172a; - --sf-ink: #f8fafc; + --sf-accent: #7fbcfb; + --sf-accent-contrast: #101317; + --sf-canvas: #101317; + --sf-ink: #f4f7fb; + --sf-hero-bg: #101317; + --sf-hero-border: color-mix(in srgb, var(--sf-steel) 84%, var(--sf-brand)); + --sf-hero-text: #f4f7fb; + --sf-hero-muted: #9ba7b4; + --sf-release-bg: #101317; + --sf-release-border: #273548; + --sf-release-text: #f4f7fb; + --sf-release-muted: #9ba7b4; + --sf-success: #6ed6a6; + --sf-warning: #edc96c; + --sf-failure: #ed8585; --sf-surface: color-mix(in srgb, var(--md-default-bg-color) 90%, var(--sf-accent)); --sf-surface-strong: color-mix(in srgb, var(--md-default-bg-color) 82%, var(--sf-accent)); --sf-border: color-mix(in srgb, var(--md-default-fg-color) 24%, transparent); --sf-muted: color-mix(in srgb, var(--md-default-fg-color) 72%, transparent); + --md-default-bg-color: #101317; + --md-default-fg-color: #f4f7fb; + --md-code-bg-color: #20262e; } body, @@ -72,8 +100,9 @@ pre { } .md-header { - background: var(--sf-accent); - color: var(--sf-accent-contrast); + background: var(--sf-night); + border-bottom: 1px solid color-mix(in srgb, var(--sf-brand) 28%, transparent); + color: var(--sf-text); } .md-header__button, @@ -82,13 +111,9 @@ pre { color: inherit; } -.md-header__button.md-logo img { - filter: var(--sf-logo-filter); -} - .md-source:focus-visible, .md-source:hover { - background: color-mix(in srgb, var(--sf-accent-contrast) 14%, transparent); + background: color-mix(in srgb, var(--sf-brand) 12%, transparent); } .md-grid { @@ -110,9 +135,9 @@ pre { } .sf-version-selector { - background: color-mix(in srgb, var(--sf-accent) 8%, var(--md-default-bg-color)); + background: var(--sf-night); border-bottom: 1px solid var(--sf-border); - color: var(--md-default-fg-color); + color: var(--sf-text); font-size: .68rem; } @@ -120,211 +145,1192 @@ pre { align-items: center; display: flex; gap: var(--sf-space-3); - min-height: 2.1rem; - overflow-x: auto; - padding: var(--sf-space-1) var(--sf-space-4); + min-height: 2.4rem; + padding: .35rem var(--sf-space-4); } .sf-version-selector__label { - color: var(--sf-muted); + color: var(--sf-muted-dark); flex: 0 0 auto; font-weight: 700; } -.sf-version-selector ul { +.sf-version-selector__control { + position: relative; +} + +.sf-version-selector__control::after { + border-bottom: 1px solid var(--sf-brand); + border-right: 1px solid var(--sf-brand); + content: ""; + height: .4rem; + pointer-events: none; + position: absolute; + right: .7rem; + top: 50%; + transform: translateY(-65%) rotate(45deg); + width: .4rem; +} + +.sf-version-selector select { + appearance: none; + background: var(--sf-steel); + border: 1px solid color-mix(in srgb, var(--sf-brand) 38%, var(--sf-steel)); + border-radius: var(--sf-radius-small); + color: var(--sf-text); + cursor: pointer; + font-family: var(--sf-font-mono); + font-size: .66rem; + font-weight: 600; + min-height: 1.65rem; + min-width: 9.5rem; + padding: .25rem 2rem .25rem .65rem; +} + +.sf-docs-home { + display: grid; + gap: 2.4rem; + margin: .5rem 0 2rem; +} + +.sf-docs-hero { + background: var(--sf-hero-bg); + border: 1px solid var(--sf-hero-border); + border-radius: var(--sf-radius-medium); + color: var(--sf-hero-text); + overflow: hidden; + padding: clamp(1.5rem, 4vw, 3.5rem); + position: relative; +} + +.sf-docs-hero::before { + background: var(--sf-component-accent); + content: ""; + height: 2px; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.sf-docs-hero__grid { align-items: center; - display: flex; - gap: var(--sf-space-1); - list-style: none; + display: grid; + gap: 2rem; + grid-template-columns: minmax(0, 1fr) minmax(9rem, 14rem); +} + +.sf-docs-eyebrow, +.sf-docs-section__eyebrow { + color: var(--sf-brand); + font-size: .64rem; + font-weight: 700; + letter-spacing: .11em; + text-transform: uppercase; +} + +.sf-docs-eyebrow::before { + background: var(--sf-component-accent); + content: ""; + display: inline-block; + height: .45rem; + margin-right: .5rem; + width: .45rem; +} + +.sf-docs-hero h1 { + color: var(--sf-hero-text); + font-size: clamp(2rem, 5vw, 3.7rem); + font-weight: 500; + letter-spacing: -.035em; + line-height: 1.05; + margin: .8rem 0 1rem; + max-width: 16ch; +} + +.sf-docs-hero__lead { + color: var(--sf-hero-muted); + font-size: clamp(.9rem, 1.7vw, 1.08rem); + line-height: 1.65; margin: 0; - padding: 0; + max-width: 44rem; } -.sf-version-selector a { - border-radius: 999px; - color: inherit; +.sf-docs-hero__mark { + animation: sf-docs-mark-idle 5.6s cubic-bezier(.45, 0, .2, 1) infinite; + display: grid; + justify-items: center; + place-self: center; + transform-origin: 50% 72%; + will-change: transform; +} + +.sf-docs-hero__mark img { + animation: sf-docs-mark-glow 5.6s ease-in-out infinite; + border-radius: var(--sf-radius-medium); + filter: drop-shadow(0 0 1.1rem color-mix(in srgb, var(--sf-brand) 30%, transparent)); + height: auto; + max-width: 11rem; + width: 100%; +} + +.sf-docs-hero__mark:hover, +.sf-docs-hero__mark:focus-within, +.sf-docs-hero__mark:hover img, +.sf-docs-hero__mark:focus-within img { + animation-play-state: paused; +} + +@keyframes sf-docs-mark-idle { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + + 50% { + transform: translate3d(0, -.28rem, 0); + } +} + +@keyframes sf-docs-mark-glow { + 0%, + 100% { + filter: drop-shadow(0 0 .95rem color-mix(in srgb, var(--sf-brand) 24%, transparent)); + } + + 50% { + filter: drop-shadow(0 .12rem 1.45rem color-mix(in srgb, var(--sf-brand) 42%, transparent)); + } +} + +.sf-docs-hero__logo--dark { + display: none; +} + +[data-md-color-scheme="slate"] .sf-docs-hero__logo--light { + display: none; +} + +[data-md-color-scheme="slate"] .sf-docs-hero__logo--dark { display: block; - padding: .18rem .55rem; - text-decoration: none; - white-space: nowrap; } -.sf-version-selector a[aria-current="page"] { - background: var(--sf-accent); - color: var(--sf-accent-contrast); +.sf-docs-actions { + display: flex; + flex-wrap: wrap; + gap: .65rem; + margin-top: 1.5rem; +} + +.sf-docs-button { + align-items: center; + border: 1px solid color-mix(in srgb, var(--sf-brand) 45%, var(--sf-steel)); + border-radius: var(--sf-radius-small); + display: inline-flex; font-weight: 700; + gap: .4rem; + min-height: 2.25rem; + padding: .45rem .85rem; + text-decoration: none; } -stateforge-procedure-player { +.md-typeset .sf-docs-button--primary { + background: var(--sf-brand); + border-color: var(--sf-brand); + color: var(--sf-night); +} + +.md-typeset .sf-docs-button--secondary { + background: transparent; + color: var(--sf-hero-text); +} + +.sf-docs-section { + display: grid; + gap: 1rem; +} + +.sf-docs-section h2 { + font-size: clamp(1.45rem, 3vw, 2.1rem); + font-weight: 500; + letter-spacing: -.025em; + margin: .2rem 0 0; +} + +.sf-docs-section__intro { + color: var(--sf-muted); + margin: 0; + max-width: 48rem; +} + +.sf-docs-flow { + display: grid; + gap: .75rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.sf-docs-lifecycle { + display: grid; + gap: 1rem; +} + +.sf-docs-lifecycle__exchange { + align-items: center; + display: grid; + gap: 1rem; + grid-template-columns: minmax(7rem, 1fr) minmax(10rem, 2fr) minmax(7rem, 1fr); + min-height: 6.5rem; +} + +.sf-docs-lifecycle__actor { + background: color-mix(in srgb, var(--md-default-bg-color) 92%, var(--sf-steel)); + border-left: 3px solid var(--sf-component-accent); + min-height: 4.4rem; + padding: .7rem .75rem; + transition: background-color .24s ease, box-shadow .24s ease, transform .24s ease; +} + +.sf-docs-lifecycle__actor[data-sf-flow-actor="target"] { + border-left-color: var(--sf-brand); +} + +.sf-docs-lifecycle__actor.is-sending { + background: color-mix(in srgb, var(--sf-brand) 13%, var(--md-default-bg-color)); + transform: translateY(-2px); +} + +.sf-docs-lifecycle__actor.is-receiving { + box-shadow: 0 0 1.1rem color-mix(in srgb, var(--sf-brand) 24%, transparent); +} + +.sf-docs-lifecycle__actor span { + color: var(--sf-muted); display: block; - margin: 2rem 0; - max-width: 100%; - min-width: 0; + font-family: var(--sf-font-mono); + font-size: .58rem; + letter-spacing: .06em; + margin-bottom: .35rem; + text-transform: uppercase; } -.sf-procedure { - color: var(--md-default-fg-color); +.sf-docs-lifecycle__actor strong { + display: block; font-size: .76rem; - max-width: 100%; - min-width: 0; + line-height: 1.45; } -.sf-procedure__scenario-label { +.sf-docs-lifecycle__rail { + height: 5rem; + position: relative; +} + +.sf-docs-lifecycle__line { + background: color-mix(in srgb, var(--sf-brand) 38%, var(--sf-border)); + height: 1px; + left: 0; + position: absolute; + right: 0; + top: 50%; +} + +.sf-docs-lifecycle__line::before, +.sf-docs-lifecycle__line::after { + border-bottom: 4px solid transparent; + border-top: 4px solid transparent; + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +.sf-docs-lifecycle__line::before { + border-right: 6px solid var(--sf-brand); + left: 0; +} + +.sf-docs-lifecycle__line::after { + border-left: 6px solid var(--sf-brand); + right: 0; +} + +.sf-docs-lifecycle__packet { + background: var(--sf-brand); + border-radius: 50%; + box-shadow: 0 0 1rem color-mix(in srgb, var(--sf-brand) 70%, transparent); + height: .65rem; + left: 0; + opacity: 0; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: .65rem; + z-index: 2; +} + +.sf-docs-lifecycle__packet.is-forward { + animation: sf-lifecycle-forward .62s ease-in-out; +} + +.sf-docs-lifecycle__packet.is-reverse { + animation: sf-lifecycle-reverse .62s ease-in-out; +} + +.sf-docs-lifecycle__message { + background: var(--md-default-bg-color); + color: var(--sf-accent); + font-size: .61rem; + left: 50%; + max-width: 90%; + overflow-wrap: anywhere; + padding: .15rem .35rem; + position: absolute; + text-align: center; + top: .5rem; + transform: translateX(-50%); + white-space: normal; + z-index: 1; +} + +.sf-docs-lifecycle__status { color: var(--sf-muted); - display: grid; + font-family: var(--sf-font-mono); font-size: .64rem; - gap: .25rem; - min-width: 9rem; + margin: -.4rem 0 0; + min-height: 1.25rem; + text-align: center; } -.sf-procedure select, -.sf-procedure button { - background: var(--md-default-bg-color); +@keyframes sf-lifecycle-forward { + 0% { left: 0; opacity: 0; } + 16% { opacity: 1; } + 84% { opacity: 1; } + 100% { left: 100%; opacity: 0; } +} + +@keyframes sf-lifecycle-reverse { + 0% { left: 100%; opacity: 0; } + 16% { opacity: 1; } + 84% { opacity: 1; } + 100% { left: 0; opacity: 0; } +} + +.sf-docs-flow__step { + appearance: none; + background: color-mix(in srgb, var(--md-default-bg-color) 94%, var(--sf-steel)); border: 1px solid var(--sf-border); - border-radius: .45rem; - color: var(--md-default-fg-color); + border-radius: var(--sf-radius-medium); + color: inherit; font: inherit; + min-height: 9rem; + padding: 1rem; + position: relative; + text-align: left; + transition: background-color .18s ease, border-color .18s ease, transform .18s ease; + width: 100%; } -.sf-procedure select { - min-height: 2rem; - padding: .35rem .6rem; +.sf-docs-flow__step:hover, +.sf-docs-flow__step[aria-pressed="true"] { + background: color-mix(in srgb, var(--sf-brand) 9%, var(--md-default-bg-color)); + border-color: color-mix(in srgb, var(--sf-brand) 75%, var(--sf-border)); + transform: translateY(-2px); +} + +.sf-docs-flow__step[aria-pressed="true"]::before { + background: var(--sf-brand); + content: ""; + height: 3px; + left: .8rem; + position: absolute; + right: .8rem; + top: 0; +} + +.sf-docs-flow__action { + color: var(--sf-muted); + font-family: var(--sf-font-mono); + font-size: .54rem; + letter-spacing: .06em; + position: absolute; + right: 1rem; + top: 1rem; + text-transform: uppercase; +} + +.sf-docs-flow__step[aria-pressed="true"] .sf-docs-flow__action { + color: var(--sf-brand); +} + +.sf-docs-flow__step:not(:last-child)::after { + background: var(--sf-brand); + content: ""; + height: 1px; + position: absolute; + right: -.76rem; + top: 50%; + width: .76rem; +} + +.sf-docs-flow__number { + color: var(--sf-brand); + font-family: var(--sf-font-mono); + font-size: .62rem; +} + +.sf-docs-flow__step h3 { + font-size: .92rem; + margin: .65rem 0 .35rem; +} + +.sf-docs-flow__step p { + color: var(--sf-muted); + font-size: .72rem; + line-height: 1.6; + margin: 0; +} + +.sf-docs-card-grid { + display: grid; + gap: .8rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.sf-docs-card-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.sf-docs-card-grid--one { + grid-template-columns: minmax(0, 1fr); +} + +.md-typeset .sf-docs-card { + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-medium); + color: inherit; + display: block; + min-height: 10rem; + padding: 1rem; + text-decoration: none; + transition: border-color .18s ease, transform .18s ease; +} + +.md-typeset .sf-docs-card:hover { + border-color: var(--sf-brand); + transform: translateY(-2px); +} + +.sf-docs-card__label { + color: var(--sf-brand); + font-family: var(--sf-font-mono); + font-size: .6rem; + letter-spacing: .06em; +} + +.sf-docs-card h3 { + font-size: .9rem; + margin: .8rem 0 .45rem; +} + +.sf-docs-card p { + color: var(--sf-muted); + font-size: .72rem; + line-height: 1.55; + margin: 0; +} + +.sf-procedure-map__intro { + color: var(--sf-muted); + max-width: 52rem; +} + +.sf-procedure-map__phase-summary { + color: var(--sf-muted); + margin: -.35rem 0 1rem; + max-width: 48rem; +} + +.md-typeset .sf-procedure-map__card { + min-height: 11rem; + position: relative; +} + +.sf-procedure-map__meta { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: .45rem; + justify-content: space-between; +} + +.sf-procedure-map__direction { + color: var(--sf-muted); + font-family: var(--sf-font-mono); + font-size: .55rem; + letter-spacing: .04em; + text-transform: uppercase; +} + +.sf-procedure-map__lanes { + display: grid; + gap: .8rem; + grid-template-columns: minmax(0, 2fr) minmax(13rem, 1fr); +} + +.sf-procedure-map__lane { + background: var(--sf-surface); + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-medium); + display: grid; + gap: .8rem; + padding: .8rem; +} + +.sf-procedure-map__lane-header { + align-items: baseline; + display: flex; + gap: .65rem; + justify-content: space-between; +} + +.sf-procedure-map__lane-header strong { + font-size: .76rem; +} + +.sf-procedure-map__lane-header span { + color: var(--sf-muted); + font-family: var(--sf-font-mono); + font-size: .55rem; + text-transform: uppercase; +} + +.sf-procedure-map__lane .sf-docs-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.sf-procedure-map__lane--single .sf-docs-card-grid { + grid-template-columns: 1fr; +} + +.sf-docs-release { + align-items: center; + background: var(--sf-release-bg); + border: 1px solid var(--sf-release-border); + border-left: 3px solid var(--sf-brand); + border-radius: var(--sf-radius-small); + color: var(--sf-release-text); + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: 1rem 1.2rem; +} + +.sf-docs-release p { + color: var(--sf-release-muted); + margin: .25rem 0 0; +} + +.sf-docs-release code { + color: var(--sf-brand); +} + +stateforge-procedure-player { + display: block; + margin: 2rem 0; + max-width: 100%; + min-width: 0; +} + +.sf-procedure { + color: var(--md-default-fg-color); + font-size: .76rem; + max-width: 100%; + min-width: 0; +} + +.sf-procedure__scenario-label { + color: var(--sf-muted); + display: grid; + font-size: .64rem; + gap: .25rem; + min-width: 9rem; +} + +.sf-procedure select, +.sf-procedure button { + background: var(--md-default-bg-color); + border: 1px solid var(--sf-border); + border-radius: .45rem; + color: var(--md-default-fg-color); + font: inherit; +} + +.sf-procedure select { + min-height: 2rem; + padding: .35rem .6rem; +} + +.sf-procedure button { + min-height: 2rem; + padding: .35rem .8rem; +} + +.sf-procedure button:not(:disabled):hover, +.sf-procedure button:not(:disabled):focus-visible, +.sf-procedure select:focus-visible { + border-color: var(--sf-accent); + box-shadow: 0 0 0 5px var(--sf-accent); + outline: 3px solid var(--sf-accent-contrast); + outline-offset: 2px; +} + +.sf-procedure button:disabled { + opacity: .45; +} + +.sf-procedure__topology-scroll { + overflow-x: auto; + padding-bottom: .4rem; + scrollbar-color: var(--sf-border) transparent; +} + +.sf-procedure__topology { + min-width: max(100%, calc(var(--sf-entity-count) * 8.5rem)); +} + +.sf-procedure__lane-grid, +.sf-procedure__flow-grid { + display: grid; + gap: .65rem; + grid-template-columns: repeat(var(--sf-entity-count), minmax(7.25rem, 1fr)); +} + +.sf-procedure__lane { + --sf-entity-accent: var(--sf-series-1); +} + +.sf-procedure__lane:nth-child(6n + 2) { --sf-entity-accent: var(--sf-series-2); } +.sf-procedure__lane:nth-child(6n + 3) { --sf-entity-accent: var(--sf-series-3); } +.sf-procedure__lane:nth-child(6n + 4) { --sf-entity-accent: var(--sf-series-4); } +.sf-procedure__lane:nth-child(6n + 5) { --sf-entity-accent: var(--sf-series-5); } +.sf-procedure__lane:nth-child(6n + 6) { --sf-entity-accent: var(--sf-series-6); } + +.sf-procedure__lane { + background: color-mix(in srgb, var(--sf-entity-accent) 9%, var(--md-default-bg-color)); + border-top: 3px solid color-mix(in srgb, var(--sf-entity-accent) 55%, var(--sf-border)); + min-height: 7rem; + padding: .65rem .75rem .75rem; + transition: background-color .2s ease, border-color .2s ease, opacity .2s ease; +} + +.sf-procedure__lane.is-active { + background: color-mix(in srgb, var(--sf-entity-accent) 20%, var(--md-default-bg-color)); + border-color: var(--sf-entity-accent); +} + +.sf-procedure__lane:not(.is-active) { + opacity: .58; +} + +.sf-procedure__entity-kind, +.sf-procedure__phase-label, +.sf-procedure__context-kind, +.sf-procedure__step-count { + color: var(--sf-muted); + display: block; + font-size: .61rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.sf-procedure__entity strong, +.sf-procedure__activity strong { + display: block; + margin: .2rem 0; +} + +.sf-procedure__phase { + align-items: center; + display: flex; + gap: .4rem; + justify-content: center; + margin: .75rem 0 .25rem; +} + +.sf-procedure__phase strong { + background: var(--sf-surface-strong); + border-radius: 999px; + font-size: .62rem; + padding: .18rem .5rem; +} + +.sf-procedure__flow-grid { + grid-auto-rows: minmax(3.2rem, auto); + margin-top: .55rem; + min-height: 3.2rem; + position: relative; +} + +.sf-procedure__flow-grid.is-empty::before { + background: var(--sf-border); + content: ""; + height: 2px; + left: 3%; + position: absolute; + right: 3%; + top: 1.6rem; +} + +.sf-procedure__transition { + --sf-transition-color: var(--sf-accent); + min-width: 0; + position: relative; +} + +.sf-procedure__transition[data-tone="success"] { --sf-transition-color: var(--sf-success); } +.sf-procedure__transition[data-tone="warning"] { --sf-transition-color: var(--sf-warning); } +.sf-procedure__transition[data-tone="failure"] { --sf-transition-color: var(--sf-failure); } + +.sf-procedure__transition-line { + background: var(--sf-transition-color); + height: 2px; + left: 5%; + position: absolute; + right: 5%; + top: 1.6rem; +} + +.sf-procedure__transition-line::after { + border-bottom: 5px solid transparent; + border-left: 8px solid var(--sf-transition-color); + border-top: 5px solid transparent; + content: ""; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); +} + +.sf-procedure__transition.is-reverse .sf-procedure__transition-line::after { + border-left: 0; + border-right: 8px solid var(--sf-transition-color); + left: -1px; + right: auto; +} + +.sf-procedure__transition.is-self .sf-procedure__transition-line { + left: 35%; + right: 35%; +} + +.sf-procedure__transition-message { + background: var(--md-default-bg-color); + border: 1px solid var(--sf-transition-color); + border-radius: .28rem; + color: var(--md-default-fg-color); + font-size: .67rem; + font-weight: 600; + left: 50%; + max-width: calc(100% - 1rem); + overflow-wrap: anywhere; + padding: .24rem .42rem; + position: absolute; + text-align: center; + top: .75rem; + transform: translateX(-50%); + white-space: normal; + z-index: 1; +} + +.sf-procedure__activity { + border-top: 1px solid color-mix(in srgb, var(--sf-entity-accent) 24%, transparent); + margin-top: .55rem; + padding-top: .45rem; + transition: opacity .2s ease; +} + +.sf-procedure__activity.is-idle { + opacity: .46; +} + +.sf-procedure__activity ul { + color: var(--sf-muted); + font-size: .65rem; + margin: .35rem 0 0; + padding-left: 1rem; +} + +.sf-procedure__activity li + li { + margin-top: .15rem; +} + +.sf-procedure__context { + align-items: center; + display: flex; + gap: .45rem; + justify-content: center; + margin: .5rem 0 .8rem; +} + +.sf-procedure__context code { + background: var(--sf-surface-strong); + border-radius: .25rem; + overflow-wrap: anywhere; + padding: .25rem .4rem; + white-space: normal; +} + +.sf-procedure__detail { + background: var(--sf-surface); + border-radius: .6rem; + display: grid; + gap: .8rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: .8rem; +} + +.sf-procedure__detail h3 { + font-size: .88rem; + margin: .15rem 0 .2rem; +} + +.sf-procedure__detail p { + margin: 0; +} + +.sf-procedure__fields { + align-content: center; + display: flex; + flex-wrap: wrap; + gap: .3rem; + justify-content: flex-end; + max-width: 17rem; +} + +.sf-procedure__fields code { + background: var(--sf-surface-strong); + border-radius: .25rem; + padding: .16rem .35rem; + white-space: nowrap; +} + +.sf-procedure__progress { + background: var(--sf-surface-strong); + border-radius: 999px; + height: .3rem; + margin: .8rem 0; + overflow: hidden; +} + +.sf-procedure__progress-bar { + background: var(--sf-accent); + height: 100%; + transition: width .2s ease; +} + +.sf-procedure__controls { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: .5rem; + justify-content: center; +} + +.sf-procedure__controls .is-primary { + background: var(--md-default-fg-color); + color: var(--md-default-bg-color); +} + +.sf-procedure-fallback { + border-left: 3px solid var(--sf-accent); + padding-left: .8rem; +} + +stateforge-entity-flow { + display: block; + margin: 2rem 0; + max-width: 100%; + min-width: 0; +} + +.sf-entity-flow { + background: + radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--sf-brand) 9%, transparent), transparent 28%), + var(--md-default-bg-color); + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-medium); + color: var(--md-default-fg-color); + overflow: hidden; + padding: clamp(.8rem, 2vw, 1.25rem); +} + +.sf-entity-flow__phase { + align-items: center; + display: flex; + gap: .55rem; + justify-content: center; + margin-bottom: .9rem; +} + +.sf-entity-flow__phase span { + color: var(--sf-muted); + font-family: var(--sf-font-mono); + font-size: .58rem; + letter-spacing: .08em; + text-transform: uppercase; +} + +.sf-entity-flow__phase strong { + background: var(--sf-surface-strong); + border: 1px solid var(--sf-border); + border-radius: 999px; + font-size: .68rem; + padding: .25rem .55rem; +} + +.sf-entity-flow__viewport { + max-width: 100%; + overflow-x: auto; + padding: .25rem 0 .5rem; +} + +.sf-entity-flow__stage { + min-width: max(100%, calc(var(--sf-entity-flow-count) * 11rem)); +} + +.sf-entity-flow__entities, +.sf-entity-flow__transitions { + display: grid; + gap: .75rem; + grid-template-columns: repeat(var(--sf-entity-flow-count), minmax(0, 1fr)); +} + +.sf-entity-flow__entity { + --sf-entity-flow-accent: var(--sf-muted); + background: color-mix(in srgb, var(--md-default-bg-color) 95%, var(--sf-entity-flow-accent)); + border: 1px solid color-mix(in srgb, var(--sf-entity-flow-accent) 32%, var(--sf-border)); + border-radius: var(--sf-radius-medium); + display: flex; + flex-direction: column; + min-height: 9.2rem; + overflow: hidden; + padding: .8rem; + position: relative; + transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; +} + +.sf-entity-flow__entity::before { + background: var(--sf-entity-flow-accent); + content: ""; + height: 3px; + left: 0; + opacity: .72; + position: absolute; + right: 0; + top: 0; +} + +.sf-entity-flow__entity[data-accent="service"] { --sf-entity-flow-accent: #55a86a; } +.sf-entity-flow__entity[data-accent="systemd"] { --sf-entity-flow-accent: #30d475; } +.sf-entity-flow__entity[data-accent="provider"] { --sf-entity-flow-accent: var(--sf-series-2); } +.sf-entity-flow__entity[data-accent="agent"] { --sf-entity-flow-accent: var(--sf-series-1); } + +.sf-entity-flow__entity.is-active { + border-color: var(--sf-entity-flow-accent); + box-shadow: 0 .4rem 1.4rem color-mix(in srgb, var(--sf-entity-flow-accent) 18%, transparent); + transform: translateY(-2px); +} + +.sf-entity-flow__entity.is-responsible::after { + background: var(--sf-entity-flow-accent); + border: 2px solid var(--md-default-bg-color); + border-radius: 50%; + box-shadow: 0 0 0 2px var(--sf-entity-flow-accent); + content: ""; + height: .42rem; + position: absolute; + right: .75rem; + top: .75rem; + width: .42rem; +} + +.sf-entity-flow__entity-header { + align-items: center; + display: flex; + gap: .65rem; + min-width: 0; +} + +.sf-entity-flow__brand { + align-items: center; + background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--sf-entity-flow-accent)); + border: 1px solid color-mix(in srgb, var(--sf-entity-flow-accent) 35%, var(--sf-border)); + border-radius: .45rem; + display: flex; + flex: 0 0 2.65rem; + height: 2.65rem; + justify-content: center; + overflow: hidden; +} + +.sf-entity-flow__brand picture, +.sf-entity-flow__brand img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.sf-entity-flow__service-glyph { + display: grid; + gap: .22rem; + width: 1.25rem; +} + +.sf-entity-flow__service-glyph i { + background: currentColor; + border-radius: 999px; + display: block; + height: 2px; + opacity: .78; + position: relative; } -.sf-procedure button { - min-height: 2rem; - padding: .35rem .8rem; +.sf-entity-flow__service-glyph i::after { + background: var(--sf-entity-flow-accent); + border-radius: 50%; + content: ""; + height: .22rem; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: .22rem; } -.sf-procedure button:not(:disabled):hover, -.sf-procedure button:not(:disabled):focus-visible, -.sf-procedure select:focus-visible { - border-color: var(--sf-accent); - box-shadow: 0 0 0 5px var(--sf-accent); - outline: 3px solid var(--sf-accent-contrast); - outline-offset: 2px; +.sf-entity-flow__brand[data-brand="none"] span { + color: var(--sf-entity-flow-accent); + font-family: var(--sf-font-mono); + font-size: .58rem; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; } -.sf-procedure button:disabled { - opacity: .45; +.sf-entity-flow__brand-dark { + display: none !important; } -.sf-procedure__topology-scroll { - overflow-x: auto; - padding-bottom: .4rem; - scrollbar-color: var(--sf-border) transparent; +[data-md-color-scheme="slate"] .sf-entity-flow__brand-light { + display: none !important; } -.sf-procedure__topology { - min-width: max(100%, calc(var(--sf-entity-count) * 8.5rem)); +[data-md-color-scheme="slate"] .sf-entity-flow__brand-dark { + display: block !important; } -.sf-procedure__lane-grid, -.sf-procedure__flow-grid { - display: grid; - gap: .65rem; - grid-template-columns: repeat(var(--sf-entity-count), minmax(7.25rem, 1fr)); +.sf-entity-flow__identity { + min-width: 0; } -.sf-procedure__lane { - --sf-entity-accent: var(--sf-series-1); +.sf-entity-flow__kind { + color: var(--sf-muted); + display: block; + font-family: var(--sf-font-mono); + font-size: .52rem; + letter-spacing: .06em; + line-height: 1.2; + margin-bottom: .25rem; + text-transform: uppercase; } -.sf-procedure__lane:nth-child(6n + 2) { --sf-entity-accent: var(--sf-series-2); } -.sf-procedure__lane:nth-child(6n + 3) { --sf-entity-accent: var(--sf-series-3); } -.sf-procedure__lane:nth-child(6n + 4) { --sf-entity-accent: var(--sf-series-4); } -.sf-procedure__lane:nth-child(6n + 5) { --sf-entity-accent: var(--sf-series-5); } -.sf-procedure__lane:nth-child(6n + 6) { --sf-entity-accent: var(--sf-series-6); } - -.sf-procedure__lane { - background: color-mix(in srgb, var(--sf-entity-accent) 9%, var(--md-default-bg-color)); - border-top: 3px solid color-mix(in srgb, var(--sf-entity-accent) 55%, var(--sf-border)); - min-height: 7rem; - padding: .65rem .75rem .75rem; - transition: background-color .2s ease, border-color .2s ease, opacity .2s ease; +.sf-entity-flow__identity strong { + display: block; + font-size: .74rem; + line-height: 1.35; + overflow-wrap: anywhere; } -.sf-procedure__lane.is-active { - background: color-mix(in srgb, var(--sf-entity-accent) 20%, var(--md-default-bg-color)); - border-color: var(--sf-entity-accent); +.sf-entity-flow__description { + color: var(--sf-muted); + font-size: .62rem; + line-height: 1.45; + margin: .65rem 0; } -.sf-procedure__lane:not(.is-active) { - opacity: .58; +.sf-entity-flow__state { + background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--sf-entity-flow-accent)); + border-left: 2px solid var(--sf-entity-flow-accent); + display: grid; + gap: .18rem; + margin-top: auto; + padding: .42rem .5rem; } -.sf-procedure__entity-kind, -.sf-procedure__phase-label, -.sf-procedure__context-kind, -.sf-procedure__step-count { +.sf-entity-flow__state span { color: var(--sf-muted); - display: block; - font-size: .61rem; - font-weight: 700; - letter-spacing: .08em; + font-family: var(--sf-font-mono); + font-size: .48rem; + letter-spacing: .05em; text-transform: uppercase; } -.sf-procedure__entity strong, -.sf-procedure__activity strong { - display: block; - margin: .2rem 0; +.sf-entity-flow__state strong { + font-size: .66rem; + line-height: 1.35; + overflow-wrap: anywhere; } -.sf-procedure__phase { - align-items: center; - display: flex; - gap: .4rem; - justify-content: center; - margin: .75rem 0 .25rem; +.sf-entity-flow__entity[data-tone="success"] .sf-entity-flow__state { + border-left-color: var(--sf-success); } -.sf-procedure__phase strong { - background: var(--sf-surface-strong); - border-radius: 999px; - font-size: .62rem; - padding: .18rem .5rem; +.sf-entity-flow__entity[data-tone="warning"] .sf-entity-flow__state { + border-left-color: var(--sf-warning); } -.sf-procedure__flow-grid { - grid-auto-rows: minmax(3.2rem, auto); - margin-top: .55rem; - min-height: 3.2rem; +.sf-entity-flow__entity[data-tone="failure"] .sf-entity-flow__state { + border-left-color: var(--sf-failure); +} + +.sf-entity-flow__transitions { + grid-auto-rows: 3.25rem; + min-height: 3.25rem; position: relative; } -.sf-procedure__flow-grid.is-empty::before { +.sf-entity-flow__transitions.is-empty::before { background: var(--sf-border); content: ""; - height: 2px; + height: 1px; left: 3%; position: absolute; right: 3%; - top: 1.6rem; + top: 50%; } -.sf-procedure__transition { - --sf-transition-color: var(--sf-accent); +.sf-entity-flow__transition { + --sf-entity-flow-transition: var(--sf-accent); min-width: 0; position: relative; } -.sf-procedure__transition[data-tone="success"] { --sf-transition-color: var(--sf-success); } -.sf-procedure__transition[data-tone="warning"] { --sf-transition-color: var(--sf-warning); } -.sf-procedure__transition[data-tone="failure"] { --sf-transition-color: var(--sf-failure); } +.sf-entity-flow__transition[data-tone="success"] { --sf-entity-flow-transition: var(--sf-success); } +.sf-entity-flow__transition[data-tone="warning"] { --sf-entity-flow-transition: var(--sf-warning); } +.sf-entity-flow__transition[data-tone="failure"] { --sf-entity-flow-transition: var(--sf-failure); } -.sf-procedure__transition-line { - background: var(--sf-transition-color); +.sf-entity-flow__transition-line { + background: color-mix(in srgb, var(--sf-entity-flow-transition) 76%, var(--sf-border)); height: 2px; - left: 5%; + left: 6%; position: absolute; - right: 5%; + right: 6%; top: 1.6rem; } -.sf-procedure__transition-line::after { +.sf-entity-flow__transition-line::after { border-bottom: 5px solid transparent; - border-left: 8px solid var(--sf-transition-color); + border-left: 8px solid var(--sf-entity-flow-transition); border-top: 5px solid transparent; content: ""; position: absolute; @@ -333,142 +1339,247 @@ stateforge-procedure-player { transform: translateY(-50%); } -.sf-procedure__transition.is-reverse .sf-procedure__transition-line::after { +.sf-entity-flow__transition.is-reverse .sf-entity-flow__transition-line::after { border-left: 0; - border-right: 8px solid var(--sf-transition-color); + border-right: 8px solid var(--sf-entity-flow-transition); left: -1px; right: auto; } -.sf-procedure__transition.is-self .sf-procedure__transition-line { - left: 35%; - right: 35%; -} - -.sf-procedure__transition-message { +.sf-entity-flow__message { background: var(--md-default-bg-color); - border: 1px solid var(--sf-transition-color); - border-radius: .28rem; + border: 1px solid color-mix(in srgb, var(--sf-entity-flow-transition) 65%, var(--sf-border)); + border-radius: .3rem; color: var(--md-default-fg-color); - font-size: .67rem; - font-weight: 600; + font-size: .55rem; left: 50%; max-width: calc(100% - 1rem); overflow-wrap: anywhere; - padding: .24rem .42rem; + padding: .2rem .38rem; position: absolute; text-align: center; - top: .75rem; + top: .88rem; transform: translateX(-50%); white-space: normal; - z-index: 1; + z-index: 2; } -.sf-procedure__activity { - border-top: 1px solid color-mix(in srgb, var(--sf-entity-accent) 24%, transparent); - margin-top: .55rem; - padding-top: .45rem; - transition: opacity .2s ease; +.sf-entity-flow__packet { + background: var(--sf-entity-flow-transition); + border-radius: 50%; + box-shadow: 0 0 .85rem color-mix(in srgb, var(--sf-entity-flow-transition) 70%, transparent); + height: .55rem; + left: 6%; + opacity: 0; + position: absolute; + top: 1.6rem; + transform: translate(-50%, -50%); + width: .55rem; + z-index: 3; } -.sf-procedure__activity.is-idle { - opacity: .46; +.sf-entity-flow__transition.is-running .sf-entity-flow__packet { + animation: sf-entity-flow-forward .9s cubic-bezier(.42, 0, .25, 1); } -.sf-procedure__activity ul { - color: var(--sf-muted); - font-size: .65rem; - margin: .35rem 0 0; - padding-left: 1rem; +.sf-entity-flow__transition.is-running.is-reverse .sf-entity-flow__packet { + animation-name: sf-entity-flow-reverse; } -.sf-procedure__activity li + li { - margin-top: .15rem; +@keyframes sf-entity-flow-forward { + 0% { left: 6%; opacity: 0; } + 14% { opacity: 1; } + 86% { opacity: 1; } + 100% { left: 94%; opacity: 0; } } -.sf-procedure__context { +@keyframes sf-entity-flow-reverse { + 0% { left: 94%; opacity: 0; } + 14% { opacity: 1; } + 86% { opacity: 1; } + 100% { left: 6%; opacity: 0; } +} + +.sf-entity-flow__responsibility { align-items: center; + background: var(--sf-surface); + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-small); display: flex; - gap: .45rem; - justify-content: center; - margin: .5rem 0 .8rem; + gap: .65rem; + margin: .25rem 0 .75rem; + padding: .55rem .7rem; } -.sf-procedure__context code { - background: var(--sf-surface-strong); - border-radius: .25rem; - overflow-wrap: anywhere; - padding: .25rem .4rem; - white-space: normal; +.sf-entity-flow__responsibility-label { + color: var(--sf-muted); + flex: 0 0 auto; + font-family: var(--sf-font-mono); + font-size: .52rem; + letter-spacing: .06em; + text-transform: uppercase; } -.sf-procedure__detail { +.sf-entity-flow__responsibility strong { + font-size: .68rem; + line-height: 1.4; +} + +.sf-entity-flow__detail { background: var(--sf-surface); - border-radius: .6rem; - display: grid; - gap: .8rem; - grid-template-columns: minmax(0, 1fr) auto; - padding: .8rem; + border-radius: var(--sf-radius-medium); + padding: .75rem .85rem; } -.sf-procedure__detail h3 { +.sf-entity-flow__step-count { + color: var(--sf-muted); + font-family: var(--sf-font-mono); + font-size: .52rem; + letter-spacing: .06em; + text-transform: uppercase; +} + +.sf-entity-flow__detail h3 { font-size: .88rem; - margin: .15rem 0 .2rem; + margin: .18rem 0 .22rem; } -.sf-procedure__detail p { +.sf-entity-flow__detail p { + color: var(--sf-muted); + font-size: .7rem; + line-height: 1.55; margin: 0; } -.sf-procedure__fields { - align-content: center; +.sf-entity-flow__progress { + background: var(--sf-surface-strong); + border-radius: 999px; + height: .28rem; + margin: .75rem 0; + overflow: hidden; +} + +.sf-entity-flow__progress-bar { + background: linear-gradient(90deg, var(--sf-series-2), var(--sf-series-1)); + display: block; + height: 100%; + transition: width .25s ease; +} + +.sf-entity-flow__footer { + align-items: center; display: flex; flex-wrap: wrap; - gap: .3rem; - justify-content: flex-end; - max-width: 17rem; + gap: .75rem; + justify-content: space-between; } -.sf-procedure__fields code { - background: var(--sf-surface-strong); - border-radius: .25rem; - padding: .16rem .35rem; - white-space: nowrap; +.sf-entity-flow__step-buttons { + display: flex; + gap: .35rem; } -.sf-procedure__progress { +.sf-entity-flow__step-buttons button { background: var(--sf-surface-strong); - border-radius: 999px; - height: .3rem; - margin: .8rem 0; - overflow: hidden; + border: 1px solid var(--sf-border); + border-radius: 50%; + color: var(--sf-muted); + cursor: pointer; + font-family: var(--sf-font-mono); + font-size: .48rem; + height: 1.4rem; + padding: 0; + width: 1.4rem; } -.sf-procedure__progress-bar { - background: var(--sf-accent); - height: 100%; - transition: width .2s ease; +.sf-entity-flow__step-buttons button[aria-current="step"] { + background: var(--sf-brand); + border-color: var(--sf-brand); + color: var(--sf-night); } -.sf-procedure__controls { - align-items: end; +.sf-entity-flow__controls { display: flex; - flex-wrap: wrap; - gap: .5rem; - justify-content: center; + gap: .45rem; } -.sf-procedure__controls .is-primary { +.sf-entity-flow__control { + background: var(--md-default-bg-color); + border: 1px solid var(--sf-border); + border-radius: .42rem; + color: var(--md-default-fg-color); + cursor: pointer; + font: inherit; + font-size: .65rem; + min-height: 1.95rem; + padding: .32rem .65rem; +} + +.sf-entity-flow__control.is-primary { background: var(--md-default-fg-color); color: var(--md-default-bg-color); } -.sf-procedure-fallback { +.sf-entity-flow__control:disabled { + cursor: default; + opacity: .42; +} + +.sf-entity-flow-fallback { border-left: 3px solid var(--sf-accent); padding-left: .8rem; } @media (max-width: 46rem) { + .sf-version-selector__inner { + align-items: stretch; + flex-direction: column; + gap: .25rem; + } + + .sf-version-selector select { + width: 100%; + } + + .sf-docs-hero__grid, + .sf-docs-flow, + .sf-docs-card-grid, + .sf-procedure-map__lanes, + .sf-docs-release { + grid-template-columns: 1fr; + } + + .sf-procedure-map__lane .sf-docs-card-grid { + grid-template-columns: 1fr; + } + + .sf-docs-lifecycle__exchange { + gap: .5rem; + grid-template-columns: minmax(4.6rem, 1fr) minmax(6.5rem, 1.5fr) minmax(4.6rem, 1fr); + } + + .sf-docs-lifecycle__actor { + min-height: 5.2rem; + padding: .55rem; + } + + .sf-docs-lifecycle__actor strong { + font-size: .66rem; + } + + .sf-docs-hero__mark { + display: none; + } + + .sf-docs-flow__step:not(:last-child)::after { + bottom: -.76rem; + height: .76rem; + left: 1.2rem; + right: auto; + top: auto; + width: 1px; + } + .sf-procedure__scenario-label { width: 100%; } @@ -481,12 +1592,41 @@ stateforge-procedure-player { justify-content: flex-start; max-width: none; } + + .sf-entity-flow__footer, + .sf-entity-flow__responsibility { + align-items: stretch; + flex-direction: column; + } + + .sf-entity-flow__controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + } } @media (prefers-reduced-motion: reduce) { + .sf-docs-hero__mark, + .sf-docs-hero__mark img, + .sf-docs-card, + .sf-docs-flow__step, + .sf-docs-lifecycle__actor, + .sf-entity-flow__entity, + .sf-entity-flow__progress-bar, .sf-procedure__lane, .sf-procedure__activity, .sf-procedure__progress-bar { transition: none; } + + .sf-docs-hero__mark, + .sf-docs-hero__mark img, + .sf-entity-flow__packet, + .sf-docs-lifecycle__packet { + animation: none; + } + + .sf-docs-hero__mark { + transform: none; + } } diff --git a/src/stateforge_docs_theme/theme/main.html b/src/stateforge_docs_theme/theme/main.html index d4661bd..9918d25 100644 --- a/src/stateforge_docs_theme/theme/main.html +++ b/src/stateforge_docs_theme/theme/main.html @@ -6,12 +6,14 @@ {% set stateforge = config.extra.stateforge %} + + + {% endblock %} {% block header %} @@ -33,16 +35,18 @@ {% if stateforge.versions %}
    - Documentation version -
      + +
      + +
    {% endif %} diff --git a/tests/fixtures/site/docs/v0.1.0/index.md b/tests/fixtures/site/docs/v0.1.0/index.md index 350bf43..76c602c 100644 --- a/tests/fixtures/site/docs/v0.1.0/index.md +++ b/tests/fixtures/site/docs/v0.1.0/index.md @@ -8,6 +8,65 @@ source: procedures/bootstrap.yaml scenario: success ``` +## Entity flow + +```stateforge-entity-flow +source: trigger-flow.yaml +``` + ```python from stateforge_docs_theme import __version__ ``` + +
    +
    +
    + Source + Publishes state +
    + +
    + Target + Accepts state +
    +
    +

    Select a stage to play its exchange

    +
    + + +
    +
    diff --git a/tests/fixtures/site/docs/v0.1.0/trigger-flow.yaml b/tests/fixtures/site/docs/v0.1.0/trigger-flow.yaml new file mode 100644 index 0000000..2851883 --- /dev/null +++ b/tests/fixtures/site/docs/v0.1.0/trigger-flow.yaml @@ -0,0 +1,128 @@ +schema: stateforge.docs/entity-flow/v1 +flow: trigger-publication +title: Trigger publication responsibility flow +summary: A service-state observation becomes an acknowledged trigger. +autoplay: false +entities: + - id: service + label: example.service + kind: Managed service + brand: service + accent: service + description: The local workload whose state is observed. + state: + label: Unit state + value: active + tone: success + - id: manager + label: systemd + kind: Service manager + brand: none + accent: systemd + description: Owns the authoritative unit state. + state: + label: Observation + value: Unit is active + tone: success + - id: provider + label: Example Provider + kind: StateForge Provider + brand: stateforge + accent: provider + description: Converts the observation into a declared trigger. + state: + label: Pending queue + value: Empty + - id: agent + label: StateForge Agent + kind: StateForge Agent + brand: stateforge + accent: agent + description: Validates the publication and records its disposition. + state: + label: Trigger record + value: Not accepted +steps: + - id: service-fails + phase: Unit state changed + title: The service stops + detail: The service manager records the unit transition from active to failed. + active: [service, manager] + states: + service: + label: Unit state + value: failed + tone: failure + manager: + label: Observation + value: Unit failed + tone: failure + transitions: + - source: service + target: manager + label: active → failed + tone: failure + responsibility: + entities: [provider] + label: The Provider remains responsible for any publication it creates. + - id: provider-observes + phase: Provider observation + title: The Provider observes the failure + detail: The Provider normalizes the unit-state change into a declared event. + active: [manager, provider] + states: + provider: + label: Observation + value: Failure normalized + tone: warning + transitions: + - source: manager + target: provider + label: unit failed + tone: warning + responsibility: + entities: [provider] + label: The Provider owns the event until the Agent accepts it durably. + - id: publish-trigger + phase: Trigger publication + title: Publish the trigger + detail: The Provider retains the publication while sending its immutable identity and payload. + active: [provider, agent] + states: + provider: + label: Pending queue + value: 1 retained publication + tone: warning + agent: + label: Validation + value: Checking publication + tone: warning + transitions: + - source: provider + target: agent + label: TriggerPublication + responsibility: + entities: [provider] + label: Sending does not transfer responsibility; the Provider still retains the publication. + - id: acknowledge + phase: Durable acknowledgement + title: Acknowledge the accepted trigger + detail: The Agent commits the trigger before returning its terminal disposition. + active: [agent, provider] + states: + provider: + label: Pending queue + value: Empty + tone: success + agent: + label: Trigger record + value: Accepted durably + tone: success + transitions: + - source: agent + target: provider + label: ACCEPTED_NEW + tone: success + responsibility: + entities: [agent] + label: The Agent now owns subsequent delivery of the accepted trigger. diff --git a/tests/fixtures/site/mkdocs.yml b/tests/fixtures/site/mkdocs.yml index 7a09b3d..a2aadfa 100644 --- a/tests/fixtures/site/mkdocs.yml +++ b/tests/fixtures/site/mkdocs.yml @@ -7,16 +7,16 @@ theme: name: stateforge plugins: - - search + - material/search - stateforge: component: protocol documentation_version: v0.1.0 latest_version: v0.2.0 versions: - label: v0.2.0 - url: ../v0.2.0/ + url: v0.2.0/ - label: v0.1.0 - url: ./ + url: v0.1.0/ nav: - v0.1.0: diff --git a/tests/test_entity_flow_model.py b/tests/test_entity_flow_model.py new file mode 100644 index 0000000..c1fd067 --- /dev/null +++ b/tests/test_entity_flow_model.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from stateforge_docs_theme.entity_flow import ( + EntityFlowDeclarationError, + load_entity_flow, +) + +FIXTURE_DOCS = Path(__file__).parent / "fixtures/site/docs" +DECLARATION = FIXTURE_DOCS / "v0.1.0/trigger-flow.yaml" + + +def test_entity_flow_derives_version_and_preserves_declared_entities() -> None: + loaded = load_entity_flow(DECLARATION, docs_dir=FIXTURE_DOCS) + + assert loaded["documentation_version"] == "v0.1.0" + assert loaded["flow"] == "trigger-publication" + assert [entity["id"] for entity in loaded["entities"]] == [ + "service", + "manager", + "provider", + "agent", + ] + assert loaded["steps"][-1]["responsibility"]["entities"] == ["agent"] + + +def _write_declaration( + tmp_path: Path, declaration: dict[str, object] +) -> tuple[Path, Path]: + docs = tmp_path / "docs" + destination = docs / "v0.1.0/flow.yaml" + destination.parent.mkdir(parents=True) + destination.write_text(yaml.safe_dump(declaration)) + return destination, docs + + +def test_entity_flow_rejects_unknown_entity_references(tmp_path: Path) -> None: + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["steps"][0]["active"].append("missing") + destination, docs = _write_declaration(tmp_path, declaration) + + with pytest.raises( + EntityFlowDeclarationError, + match="references unknown entities: missing", + ): + load_entity_flow(destination, docs_dir=docs) + + +def test_entity_flow_rejects_unbundled_brand(tmp_path: Path) -> None: + declaration = yaml.safe_load(DECLARATION.read_text()) + declaration["entities"][0]["brand"] = "vendor-logo" + destination, docs = _write_declaration(tmp_path, declaration) + + with pytest.raises(EntityFlowDeclarationError, match="brand must be one of"): + load_entity_flow(destination, docs_dir=docs) + + +def test_entity_flow_requires_version_directory(tmp_path: Path) -> None: + docs = tmp_path / "docs" + destination = docs / "flow.yaml" + docs.mkdir() + destination.write_text(DECLARATION.read_text()) + + with pytest.raises( + EntityFlowDeclarationError, + match="below a version directory", + ): + load_entity_flow(destination, docs_dir=docs) diff --git a/tests/test_entity_flow_renderer.py b/tests/test_entity_flow_renderer.py new file mode 100644 index 0000000..e315c95 --- /dev/null +++ b/tests/test_entity_flow_renderer.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + +from stateforge_docs_theme.entity_flow import load_entity_flow, render_entity_flow + +FIXTURE_DOCS = Path(__file__).parent / "fixtures/site/docs" +DECLARATION = FIXTURE_DOCS / "v0.1.0/trigger-flow.yaml" + + +def test_entity_flow_renderer_embeds_data_and_static_fallback() -> None: + declaration = load_entity_flow(DECLARATION, docs_dir=FIXTURE_DOCS) + + rendered = render_entity_flow(declaration) + + assert '' in rendered + assert '"documentation_version":"v0.1.0"' in rendered + assert '
    ' in rendered + assert "Acknowledge the accepted trigger" in rendered diff --git a/tests/test_package.py b/tests/test_package.py index a713f43..4ea6529 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -15,14 +15,39 @@ def test_theme_contains_bundled_brand_and_player_assets() -> None: assert (theme / "mkdocs_theme.yml").is_file() assert (theme / "assets/images/stateforge-mark.svg").is_file() + assert (theme / "assets/images/stateforge-mark-black.svg").is_file() assert (theme / "assets/images/stateforge-favicon.svg").is_file() assert (theme / "assets/stylesheets/stateforge.css").is_file() player = theme / "assets/javascripts/procedure-player.js" + entity_flow = theme / "assets/javascripts/entity-flow.js" + lifecycle = theme / "assets/javascripts/lifecycle-flow.js" + selector = theme / "assets/javascripts/version-selector.js" assert player.is_file() + assert entity_flow.is_file() + assert lifecycle.is_file() + assert selector.is_file() source = player.read_text() assert "Bootstrap" not in source assert "StateForge Agent" not in source assert "Provider Process" not in source + entity_flow_source = entity_flow.read_text() + assert "nginx" not in entity_flow_source + assert "TriggerPublication" not in entity_flow_source + + styles = (theme / "assets/stylesheets/stateforge.css").read_text() + assert "@keyframes sf-docs-mark-idle" in styles + assert "@keyframes sf-docs-mark-glow" in styles + assert ".sf-docs-hero__mark:hover" in styles + assert ".sf-procedure-map__lanes" in styles + assert ".sf-procedure-map__lane--single" in styles + assert "@media (prefers-reduced-motion: reduce)" in styles + + mark = (theme / "assets/images/stateforge-mark.svg").read_text() + assert 'color="#7FBCFB"' in mark + assert '' in mark + black_mark = (theme / "assets/images/stateforge-mark-black.svg").read_text() + assert 'color="#000000"' in black_mark + assert " None: rendered = (tmp_path / "site/v0.1.0/index.html").read_text() assert "stateforge-procedure-player" in rendered + assert "stateforge-entity-flow" in rendered assert 'data-documentation-version="v0.1.0"' in rendered assert "assets/javascripts/procedure-player.js" in rendered + assert "assets/javascripts/entity-flow.js" in rendered + assert "assets/javascripts/lifecycle-flow.js" in rendered + assert "assets/javascripts/version-selector.js" in rendered assert "assets/stylesheets/stateforge.css" in rendered assert "stateforge-brand-tokens" in rendered - assert "#a95000" in rendered - assert "--sf-logo-filter: brightness(0) invert(1)" in rendered + assert "--sf-component-accent: #a95000" in rendered assert "Documentation version" in rendered + assert "data-sf-version-selector" in rendered + assert 'class="md-search"' in rendered + assert 'data-md-component="search-query"' in rendered + assert '