diff --git a/AGENTS.md b/AGENTS.md index e974640..b49706d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,9 +71,8 @@ boundaries. The current Editor still depends on egui and is not a public kernel Keep N3 as one application crate. Its documentation adapter consumes the portable [lgtmd SDK](https://github.com/lgtmd/lgtmd), maintained in its own repository. -N3 pins an exact Git revision until a registry release is ready. The tested -boundary separates application replay from document composition and publication; -it does not make the editor a public kernel. Keep N3's replay, renderer, scenarios, +N3 pins the exact reviewed crates.io release `lgtmd = "=0.3.0"`. The tested boundary separates application replay from document composition +and publication; it does not make the editor a public kernel. Keep N3's replay, renderer, scenarios, fixtures, and guide outputs here. SDK authoring, compiler/store changes, portable Rust/Node examples, and SDK verification belong in lgtmd. Prefer focused modules over further speculative crates or APIs. See diff --git a/Cargo.lock b/Cargo.lock index d295f8f..0ca18ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,8 +1355,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lgtmd" -version = "0.0.0" -source = "git+https://github.com/lgtmd/lgtmd?rev=8828c41eaf7fbb002044aea9f8bfd4a06ba76168#8828c41eaf7fbb002044aea9f8bfd4a06ba76168" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5b340735ed1ab1c7f689894b9a2b452169f4110598db5b0952f2972203d806" dependencies = [ "pulldown-cmark", "same-file", diff --git a/Cargo.toml b/Cargo.toml index 093f62e..a7c704b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -lgtmd = { git = "https://github.com/lgtmd/lgtmd", rev = "8828c41eaf7fbb002044aea9f8bfd4a06ba76168" } +lgtmd = "=0.3.0" alacritty_terminal = "=0.26.0" egui-winit = { version = "=0.36.2", default-features = false, features = [ "clipboard", diff --git a/TODO.md b/TODO.md index 7bf4c25..d616b1c 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ and document the implemented behavior in the executable user guide. | Paired selection in 2D | **Deferred:** consider verified front/back vertex pairing in aligned views; exact geometry does not prove selection intent. See [RFC 0001](rfcs/0001-paired-selection-in-2d.md). | | Grouping and hierarchy entities | Add an explicit document entity for organizing objects; evaluate groups, collections, or folders, including nesting, selection, and shared transform semantics. | | Multiple authored scenes | **Draft; implementation deferred.** Independent N3 scenes in one document/editor. Revisit [RFC 0002](rfcs/0002-authored-scenes.md), starting with a small native-scene proof of switching, history/recovery, and persistence before broader import work. | -| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/lgtmd/lgtmd); N3 consumes an exact Git revision. Migrate the remaining 26 N3 templates through the existing adapter when scheduled, and switch to crates.io after an explicitly authorized SDK release. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | +| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/lgtmd/lgtmd); N3 pins lgtmd 0.3.0 from crates.io. Migrate the remaining 26 N3 templates through the existing adapter when scheduled. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | | 3D format I/O | Build on read-only glTF/GLB viewing: scene export/authoring, OBJ export, compression and advanced extensions; evaluate OpenUSD composition/schema support. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Materials and PBR | Extend imported PBR viewing into editable material assignment; HDR environment import, shadows, advanced materials and color management. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Node Canvas UI Kit | Establish reusable UI primitives for node blocks, ports, connections, selection, dragging, pan, and zoom. Validate interactions in isolated workbench examples so future editors can adopt them confidently when a concrete consumer exists. Scope this as UI foundation work; shader/modifier semantics and evaluation remain separate. | diff --git a/docs/architecture/documentation-pipeline.md b/docs/architecture/documentation-pipeline.md index 2c8986d..745a45a 100644 --- a/docs/architecture/documentation-pipeline.md +++ b/docs/architecture/documentation-pipeline.md @@ -67,7 +67,7 @@ must not link back to them. Link validation rejects those outbound references. browser. It uses Python 3's standard library and a vendored Docsify 5 runtime; all scripts and styles load locally. The generated guide index supplies the sidebar, and pages use their existing Markdown links and WebP assets, including -animations. There is no second content tree or separate site build. +animations. This preview needs no separate content tree or site build. This presentation layer does not execute scenarios, regenerate artifacts, or approve a baseline. After `just docs update`, refresh the browser to see the @@ -75,11 +75,53 @@ changed output. `just docs check` still performs the strict replay described below. `just docs-preview-test`, included in `just verify`, checks the server routes and local browser assets independently of the GPU pipeline. -Keep this preview disposable. The pipeline continues to produce ordinary -Markdown and media that a future website can consume; the local viewer does not -commit N3 to a production documentation framework. See +Keep this preview disposable. The canonical pipeline continues to produce +ordinary Markdown and media independently of the local lgtmd site export. See [development](../development.md#documentation-is-executable) for server options. +## Local lgtmd site + +The native lgtmd CLI provides a second local reading and review surface. At the +current SDK pin, full N3 media requires the unreleased SDK CLI large-media host +fix described in [setup](../development.md#local-lgtmd-site); the unmodified +pinned CLI cannot carry the complete collection. +From the repository root, `lgtmd dev` reads +[lgtmd.config.json](../../lgtmd.config.json), starts a loopback server, and invokes +`just docs flow`. A repository-local installation can be invoked as +`.cache/tools/lgtmd/bin/lgtmd dev`; see [setup](../development.md#local-lgtmd-site). +The flow requires the CLI's unique `LGTMD_OUTPUT` and `LGTMD_RUN_ID` environment +before starting capture. + +The [artifact pipeline](../../src/documentation/artifacts.rs) uses the same +`generate_outputs` replay to build the canonical Reader tree and retain completed +SDK documents. The [site bridge](../../src/documentation/site.rs) reuses +Rust-authored documents and adapts retained templates through typed `Guide` +bindings and captured-resource handles. Before exact Reader fragment comparison, +`site::legacy_fragment` translates the SDK's known additional HTML entity +spellings for Markdown punctuation inside generated `code` and `kbd` labels +back to N3's existing label spelling. Text outside those labels stays untouched; +narrative text, whitespace, resource paths, and media still compare exactly. +Canonical guide baseline checks remain unchanged. All media comes from those +same sessions with unchanged bytes; there is no site-specific capture or second +source of prose. + +The site uses full SDK export titles and explicit section anchors, with the +canonical `README.md` index represented by `overview.md`. Site preparation makes +those presentation changes on copied documents, leaving the canonical fragments +and guide manifest untouched. After replay resources are released, +`handoff::complete_run` writes the completed documents for the CLI. The CLI +requires a matching run receipt and successful process exit, compiles both +audiences, and admits the completed candidate. Contributor notes remain outside +the Reader projection. + +Configured candidates, local site snapshots, and static output live in ignored +`.cache/lgtmd`, `.cache/lgtmd-snapshots`, and `build/docs-site`. A first dev session +reports the absence of a recorded Git site baseline. Dev does not accept a +snapshot, regenerate `docs/guide`, or publish. `--watch` reruns the configured +flow on input changes; `--reuse` serves its last completed candidate without +replay. N3's `just docs`, `just docs check`, and `just docs update` keep their +existing preview, strict verification, and intentional baseline-update roles. + ## One replay and rendering path `Session` delivers pointer and keyboard events to the real `WorkspaceUi` and its diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index c4e1d99..714e938 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -2,16 +2,15 @@ The [lgtmd SDK](https://github.com/lgtmd/lgtmd) separates document composition and artifact publication from the application being documented. -N3 remains one application crate and consumes lgtmd from an exact Git revision. -The SDK, its Node bridge, and portable Rust/React examples are maintained in the +N3 remains one application crate and pins lgtmd 0.3.0 from crates.io. +The SDK, its native CLI and Node bridge, and portable Rust/React examples are maintained in the lgtmd repository. N3 owns its application adapter, replay, renderer, scenarios, and guide artifacts here. Neither SDK use nor its examples requires N3's renderer, UI stack, website, or agent skill. -The SDK remains experimental and unpublished. Moving its source establishes -independent repository ownership; it does not establish a public release or a -stable API. A future registry dependency can replace the Git pin after the -package passes its release gates. +The SDK remains experimental. N3 pins the exact crates.io release `0.3.0`. +Publication does not establish a stable API or N3 compatibility; dependency +updates still require native verification and exact guide checks. ## The shared contract @@ -36,12 +35,13 @@ reference. N3's [documentation pipeline](documentation-pipeline.md) owns product replay, timing, captures, and renderer-specific evidence. This document records the boundary between them. -The current public exporter targets Markdown. Artifact producers are generic, -but there is no public structured document-view API for a different publisher -yet. Prove that interface with an actual alternate exporter before exposing it; -it must preserve audience filtering, typed references, observations, and resource -ownership together. The private document model is the extension boundary, without -a plugin registry or a promise of arbitrary presentation support today. +N3's canonical guide and paired Markdown review trees use the SDK's fragment +exporter and retain their existing format. Its local docs-site flow also uses +`handoff::complete_run` to pass completed documents to the native lgtmd CLI. That +CLI owns `site::compile` for structured, audience-filtered site content with exact +resource sidecars, and `review::compare` for page and resource pairing between +retained site bundles. Both presentations use the same N3 replay, narrative +sources, and captured media. ## Internal Rust ownership @@ -57,8 +57,9 @@ Markdown exporter. inventory. It preserves unknown-field compatibility and distinguishes explicit JSON null observations from omitted private payloads. `store` applies that receipt policy to `lifecycle` filesystem operations; `runner` coordinates project-owned -generation and publication. These boundaries add no public serialized document -model or host executor. +generation and publication. The authored document model remains private. Public +`site` projections serialize audience-filtered content; application execution +remains consumer-owned. ## Project-owned configuration @@ -96,13 +97,15 @@ does not add a configuration file format, plugin loader, or repository abstracti ## N3 ownership -| Layer | Responsibility | -| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `Session` and the production application | Real input routing, fixture setup, witnessed controls, behavioral assertions, explicit time, rendering, and capture. | -| [Guide adapter](../../src/documentation/guide.rs) | Convert witnessed controls, canonical key labels, and captured N3 media into typed SDK bindings and resources. | -| SDK `Doc` and `Document` | Compose narrative, retain immutable evidence and contributor notes, validate handles, and render the selected audience. | -| [N3 artifact pipeline](../../src/documentation/artifacts.rs) | Select the registered narrative, assemble the existing guide tree and text manifest, validate guide-only links, and enforce renderer policy. | -| SDK `lifecycle` | Validate output paths and inventories, compare complete trees, stage updates, publish, and retain recovery material on interruption. | +| Layer | Responsibility | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Session` and the production application | Real input routing, fixture setup, witnessed controls, behavioral assertions, explicit time, rendering, and capture. | +| [Guide adapter](../../src/documentation/guide.rs) | Convert witnessed controls, canonical key labels, and captured N3 media into typed SDK bindings and resources. | +| SDK `Doc` and `Document` | Compose narrative, retain immutable evidence and contributor notes, validate handles, and render the selected audience. | +| [N3 artifact pipeline](../../src/documentation/artifacts.rs) | Select the registered narrative, assemble the existing guide tree and text manifest, validate guide-only links, and enforce renderer policy. | +| [N3 site bridge](../../src/documentation/site.rs) | Adapt the same completed narratives to full SDK documents, check fragment parity, and prepare site page titles, index links, and section anchors. | +| Native lgtmd CLI | Execute the configured N3 flow, validate its completed handoff and successful exit, compile site projections, retain candidates, and serve local review. | +| SDK `lifecycle` | Validate output paths and inventories, compare complete trees, stage updates, publish, and retain recovery material on interruption. | `Guide::control` requires that the same replay witnessed the control. Shortcut labels come from the production binding catalog; requesting a label does not @@ -155,6 +158,53 @@ validates links across the complete guide. Normal SDK `render_many` exports keep their own complete-bundle validation. The existing N3 guide baseline, media, and `manifest.txt` format are retained rather than converted to SDK manifest JSON. +## Local site handoff + +`lgtmd dev` uses the root [configuration](../../lgtmd.config.json) to invoke +`just docs flow`. The guarded flow requires the per-run `LGTMD_OUTPUT` and +`LGTMD_RUN_ID` environment supplied by the CLI before it starts capture. The +ordinary `generate_outputs` path replays every feature once, validates the +canonical Reader tree, and retains completed documents from those same sessions. +After generation releases its renderer and session resources, N3 prepares the +site documents and calls `handoff::complete_run`. The CLI also requires successful +process exit before admitting a candidate; writing a receipt cannot conceal a +later failure. + +Rust-authored guides reuse their completed SDK documents. Retained templates +adapt their registered source through `Guide`'s typed control, shortcut, key, +modifier, and captured-resource handles. The bridge compares each document's +Reader fragment, including N3's generated preamble, with the ordinary guide +page before site preparation. The SDK additionally HTML-escapes Markdown +punctuation in typed labels, including backslashes. `site::legacy_fragment` +translates only those known stronger entity spellings inside generated +`code` and `kbd` labels back to N3's existing spelling, then compares the result +exactly. Narrative text, whitespace, resource paths, and media retain exact +comparison, and canonical guide baseline checks remain unchanged. The bridge +does not ingest generated pages as a new narrative source or recapture media. + +Full SDK export supplies the site page title and explicit block anchors. N3's +site preparation removes the duplicate authored title, creates explicit section +anchors, and maps canonical `README.md` index links to the `overview.md` site +page. These are site presentation changes to copies of the completed documents; +the canonical fragments, media paths, media bytes, and text manifest remain +unchanged. Site Markdown and structured bundles therefore have their own export +format, while the explanation and evidence come from the same registered source. + +The native CLI is installed separately from N3's crates.io SDK dependency, under +ignored `.cache/tools/lgtmd`. The unmodified 0.3.0 CLI's encoded JSON limits +reject N3's complete media collection. Local integration therefore uses an isolated SDK build with an +unreleased large-media handoff and retained-candidate host fix, verified +separately from N3. That fix needs to land upstream; N3's pinned application +dependency, canonical captures, and media resolution remain unchanged, and the +SDK host fix stays outside N3's tracked source. The root +configuration keeps retained candidates in `.cache/lgtmd`, local site snapshots +in `.cache/lgtmd-snapshots`, and static site output in `build/docs-site`. These +outputs remain ignored local review material. Starting dev does not accept a +site snapshot or update the canonical guide; an unavailable recorded Git site +baseline is reported explicitly. `just docs` retains its existing Docsify +preview, and `just docs check/update` retain N3's verification and intentional +guide-update roles. See [local setup](../development.md#local-lgtmd-site). + ## Shared publication with explicit ownership N3 and the portable consumers share the SDK's `lifecycle` implementation while @@ -226,7 +276,7 @@ still need human review. ## Node and web integration [packages/lgtmd](https://github.com/lgtmd/lgtmd/blob/main/packages/lgtmd/README.md) authors documents in Node 24 ESM; -[crates/lgtmd-node](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd-node/Cargo.toml) is its coarse native bridge. +[crates/lgtmd-cli](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd-cli/Cargo.toml) owns the shared native command and its internal coarse bridge. One serialized request per compile/publication carries schema-v1 frozen input to the same Rust validation, Markdown exporters, runner and owned store. The wire contains complete ordered content and resource snapshots, never process-local @@ -254,7 +304,8 @@ filesystem store returned an unsupported-platform error. The selected native route retains the existing strict filesystem contract. It requires platform binaries in npm tarballs; installation performs no Rust build. Node 24 ESM on macOS arm64 is the executed local scope. Linux x64 has CI wiring, not a completed -runtime claim. A full viewer and browser-hosted SDK are deferred. +runtime claim. A browser-hosted SDK remains deferred; the native CLI embeds its +local docs-site viewer. The SDK repository owns explicit browser setup and its Node/Chromium verification workflow. It checks the native bridge, authoring and failure semantics, real diff --git a/docs/development.md b/docs/development.md index f240a50..ee31c9d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ The application is a single Rust package named `n3`, with private implementation modules. Its documentation adapter consumes the separately maintained -[lgtmd SDK](https://github.com/lgtmd/lgtmd) at an exact Git revision. +[lgtmd SDK](https://github.com/lgtmd/lgtmd) at the exact crates.io version `0.3.0`. SDK sources and portable examples live in that repository. Read [AGENTS.md](../AGENTS.md) for development principles and iteration guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, and benchmark resources. Start at [the architecture map](architecture/architecture.md) and @@ -445,8 +445,57 @@ in another terminal after intentionally changing the scenario's narrative or its retained template, then refresh the browser. `just docs update` regenerates through the native renderer; `just docs check` verifies there without writing. Neither requires Docker. The small preview uses a vendored Docsify runtime, so it needs no network access, -Node installation, or package-manager setup. It is a local reading surface, not -a choice of framework for the eventual documentation website. +Node installation, or package-manager setup. It remains useful for reading the +existing guide baseline without replay. + +### Local lgtmd site + +The root `lgtmd.config.json` runs the same native documentation replay through +`just docs flow`, then hands the completed documents and captured media to the +lgtmd site compiler. It includes the overview and all 28 feature guides in the +same order as the guide index. Rust-authored pages retain their contributor notes; +retained templates use typed bindings and captured resources from their replay. + +The SDK dependency is pinned to crates.io version `0.3.0`. The native CLI is a +separate installation; npm distributes it in `lgtmd@0.3.0`. Preparing the CLI +from source requires the SDK's Node.js 24 frontend build; using its native +executable with N3's Rust flow does not require Node. See [the native CLI setup](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd-cli/README.md) +for the supported source preparation and installation commands. Keep a local +installation under `.cache/tools/lgtmd` to avoid changing a global tool. + +The unmodified 0.3.0 CLI's encoded JSON size limits cannot +carry N3's complete media collection. Full-site use requires the SDK CLI's +large-media handoff and retained-candidate host fix. This local integration uses +an isolated SDK build with that unreleased fix, verified separately from N3; +the fix still needs to land upstream. N3 keeps its pinned application dependency +and original media without vendoring the SDK host fix or downsampling captures. + +From the N3 repository root, run the installed executable: + +```sh +.cache/tools/lgtmd/bin/lgtmd dev +``` + +If that executable is on `PATH`, the same command is `lgtmd dev`. It starts a +loopback site on port 4177 and runs a fresh complete replay. The terminal reports +the URL and progress; Ctrl-C stops the server and its owned flow. Add `--watch` +to rerun when the configured source, template, asset, fixture, or Cargo input +changes. Restart the server after editing configuration. Use +`lgtmd dev --reuse` to read the last completed candidate without running replay, +or `lgtmd dev --audience contributor` to include contributor notes. + +The site retains candidates in ignored `.cache/lgtmd`, uses ignored +`.cache/lgtmd-snapshots` for local site snapshots, and writes static output to +ignored `build/docs-site` when explicitly built. A first run has no recorded Git +site baseline, so review reports that absence. Starting dev does not accept a +snapshot, update `docs/guide`, commit, or publish. `just docs flow` requires the +per-run environment supplied by lgtmd; use the site CLI to invoke it. + +`just docs` continues to preview the existing guide. `just docs check` and +`just docs update` remain N3's strict native verification and intentional +baseline-update commands; lgtmd's site snapshot commands do not replace them. + +### Guide candidates and verification To inspect both audiences before updating the canonical baseline, build a fresh candidate in ignored local storage: @@ -508,13 +557,12 @@ instructions for SDK setup, independent package checks, and Node/Chromium gates. N3 does not install or run the SDK's web example during application verification. The N3 app's `web/` host remains a separate consumer boundary. -N3 pins the SDK's full Git revision in `Cargo.toml` and retains its resolved -source in `Cargo.lock`. Updating that revision requires `just verify`, including +N3 pins `lgtmd = "=0.3.0"` in `Cargo.toml` and retains its registry source and +checksum in `Cargo.lock`. Updating that version requires `just verify`, including native replay and exact guide checks; portable SDK checks alone cannot establish -N3 compatibility. Cargo must fetch the pinned source and dependencies before an +N3 compatibility. Cargo must fetch the pinned release and dependencies before an offline run. Keep the dependency reproducible without sibling checkout discovery -or a developer-specific path. A registry dependency is deferred until an -explicitly authorized SDK release is ready. +or a developer-specific path. ## User settings diff --git a/justfile b/justfile index ee71417..40f0c36 100644 --- a/justfile +++ b/justfile @@ -186,14 +186,14 @@ docs mode="serve" *args: set -euo pipefail case "$1" in serve) shift; exec python3 "$N3_ROOT/tools/docs_preview.py" "$@" ;; - check|update) + check|update|flow) if [[ $# -ne 1 ]]; then - echo 'check/update do not accept extra arguments' >&2 + echo 'check/update/flow do not accept extra arguments' >&2 exit 2 fi ;; build) ;; - *) echo 'Usage: just docs [serve [--no-open] [--port PORT] | check | update | build --out PATH]' >&2; exit 2 ;; + *) echo 'Usage: just docs [serve [--no-open] [--port PORT] | check | update | flow | build --out PATH]' >&2; exit 2 ;; esac exec cargo run --locked -- --docs "$@" diff --git a/lgtmd.config.json b/lgtmd.config.json new file mode 100644 index 0000000..c7a931b --- /dev/null +++ b/lgtmd.config.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "title": "N3 guide", + "flow": { + "command": ["just", "docs", "flow"], + "watch": [ + "src", + "docs/templates", + "assets", + "fixtures", + "Cargo.toml", + "Cargo.lock" + ] + }, + "snapshots": ".cache/lgtmd-snapshots", + "cacheDir": ".cache/lgtmd", + "outDir": "build/docs-site", + "navigation": [ + "overview", + "settings", + "edit-selection", + "numeric-transforms", + "snapping", + "workspace", + "terminal", + "fps-meter", + "toasts", + "length-units", + "2d", + "2d-ruler", + "axis-locks", + "editing", + "make-face", + "object-feedback", + "selection-keys", + "opening-models", + "scene-viewer", + "animation", + "navigation", + "local-view", + "view-keys", + "view-pie", + "xray", + "shading", + "hand-tool", + "orbit-tool", + "gizmo" + ] +} diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md index defab87..033e0b4 100644 --- a/rfcs/0003-executable-documentation-framework.md +++ b/rfcs/0003-executable-documentation-framework.md @@ -1,17 +1,17 @@ # RFC 0003: A reusable executable documentation framework -Status: **Implemented and extracted — experimental SDK and N3 bridge; independent adoption and release remain open** +Status: **Implemented and extracted — experimental SDK and N3 bridge** Date: 2026-10-04 The framework was incubated inside N3 and is now maintained in the dedicated -[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact -Git revision and retains its application adapter, replay, renderer, and guide +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 pins the crates.io +release 0.3.0 and retains its application adapter, replay, renderer, and guide artifacts. [The architecture contract](../docs/architecture/executable-documents.md) records the current boundary. The findings and development commands below are a historical record of incubation; broader sketches remain proposals where they -exceed the implemented contract. lgtmd remains unpublished, and its current -repository owns SDK development and verification. +exceed the implemented contract. The lgtmd repository owns SDK development and +verification; N3 verifies compatibility when updating its dependency. Authoring requirements: concise, honest authoring and scenarios that write the document's text, resources, and explicit contributor notes themselves. The primary diff --git a/rfcs/0004-lgtmd-web-incubation.md b/rfcs/0004-lgtmd-web-incubation.md index 42322b5..4ce2491 100644 --- a/rfcs/0004-lgtmd-web-incubation.md +++ b/rfcs/0004-lgtmd-web-incubation.md @@ -1,20 +1,19 @@ # RFC 0004: Incubate lgtmd's Rust and web contracts together -Status: **Implemented and extracted; release deferred** +Status: **Implemented and extracted; N3 pins a crates.io release** Date: 2026-10-13 The portable Rust SDK and TypeScript integration were incubated directly in N3, without a submodule, while N3 remained a real consumer. The independently verified SDK, Node bridge, Rust/React examples, and development harness now live in the -[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact -Git revision and retains its own adapter, replay, renderer, and guide artifacts. - -The preferred eventual N3 integration remains a crates.io dependency once the -package passes its release gates. Extraction and dependency pinning do not -publish a package or authorize a release. The incubation findings and original -sequence below preserve the reasoning and historical verification evidence; -SDK-owned development commands now run in the lgtmd repository. +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 pins the crates.io +release 0.3.0 and retains its own adapter, replay, renderer, and guide artifacts. + +N3 now uses the exact crates.io release 0.3.0. Dependency pinning does not +authorize a release. The incubation findings and original sequence below +preserve the reasoning and historical verification evidence; SDK-owned development +commands now run in the lgtmd repository. ## Implementation record diff --git a/rfcs/README.md b/rfcs/README.md index f8db9f2..ab066ef 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -34,9 +34,9 @@ that contain no useful reasoning do not need an RFC. ## Index -| RFC | Status | Topic | -| -------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- | -| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | -| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | -| [0003](0003-executable-documentation-framework.md) | Implemented and extracted | A reusable Rust-first documentation SDK and N3 adapter, maintained in separate repositories. | -| [0004](0004-lgtmd-web-incubation.md) | Implemented and extracted | Rust/web incubation completed; N3 consumes an exact lgtmd Git revision until registry release. | +| RFC | Status | Topic | +| -------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------- | +| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | +| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | +| [0003](0003-executable-documentation-framework.md) | Implemented and extracted | A reusable Rust-first documentation SDK and N3 adapter, maintained in separate repositories. | +| [0004](0004-lgtmd-web-incubation.md) | Implemented and extracted | Rust/web incubation completed; N3 pins lgtmd 0.3.0 from crates.io. | diff --git a/src/documentation/artifacts.rs b/src/documentation/artifacts.rs index b77c511..1de2686 100644 --- a/src/documentation/artifacts.rs +++ b/src/documentation/artifacts.rs @@ -109,19 +109,25 @@ pub(super) fn generate() -> Result { Ok(generate_views(false)?.reader) } -struct Views { - reader: Artifacts, +pub(super) struct Views { + pub(super) reader: Artifacts, contributor: Option, + pub(super) documents: Vec, } /// Both views are rendered from the same completed sessions and captured bytes. /// Ordinary checks only render the published reader view. fn generate_views(include_contributor: bool) -> Result { + generate_outputs(include_contributor, false) +} + +pub(super) fn generate_outputs(include_contributor: bool, include_site: bool) -> Result { validate_feature_inventory()?; let mut capture = pollster::block_on(Capture::new(WIDTH, HEIGHT))?; let (media_width, media_height) = capture.framed_dimensions(); let mut result = Artifacts::new(); let mut contributor = include_contributor.then(Artifacts::new); + let mut documents = Vec::new(); let mut manifest = format!( "n3 executable documentation v6\nrenderer: {}\nui-platform: macOS\napp-canvas: {WIDTH}x{HEIGHT}, 1 pixel/point, RGBA8Unorm\nmedia-canvas: {media_width}x{media_height}, lossless WebP\nframe-template: {}\nbaseline: Light theme; 240-point side panels\nclock: explicit scenario time; no wall clock\n", capture.renderer_profile, @@ -175,6 +181,18 @@ fn generate_views(include_contributor: bool) -> Result { page(Audience::Contributor)?.into_bytes(), )?; } + if include_site { + let document = site::document(feature, &mut session)?; + let expected = std::str::from_utf8(&result[&format!("{}.md", feature.slug)]) + .map_err(|error| error.to_string())?; + if format!("{GENERATED}{}", site::legacy_fragment(&document)?) != expected { + return Err(format!( + "{}: site narrative differs from the guide", + feature.slug + )); + } + documents.push(document); + } index.push_str(&format!("- [{}]({}.md)\n", feature.title, feature.slug)); manifest.push_str(&format!("\nfeature: {}\n", feature.slug)); for (id, path) in session.bindings { @@ -215,12 +233,35 @@ fn generate_views(include_contributor: bool) -> Result { insert(&mut result, "README.md".into(), index.into_bytes())?; insert(&mut result, "manifest.txt".into(), manifest.into_bytes())?; validate_links(&result)?; + if include_site { + documents.insert(0, site::overview(&result["README.md"])?); + } Ok(Views { reader: result, contributor, + documents, }) } +/// Replay once, release host resources, then hand frozen documents to lgtmd. +pub fn flow() -> Result<()> { + for name in ["LGTMD_OUTPUT", "LGTMD_RUN_ID"] { + if std::env::var_os(name).is_none_or(|value| value.is_empty()) { + return Err(format!( + "{name} is required; run this flow through lgtmd dev" + )); + } + } + let views = generate_outputs(false, true)?; + let documents = site::prepare(views.documents)?; + lgtmd::handoff::complete_run(&documents)?; + println!( + "docs flow: {} verified features handed to lgtmd", + FEATURES.len() + ); + Ok(()) +} + pub(super) fn validate_feature_inventory() -> Result<()> { let templates: BTreeSet<_> = FEATURES .iter() diff --git a/src/documentation/guide.rs b/src/documentation/guide.rs index f4f3806..0ffed61 100644 --- a/src/documentation/guide.rs +++ b/src/documentation/guide.rs @@ -71,6 +71,15 @@ impl Guide { ) } + pub(super) fn key(&mut self, name: &str) -> Result { + let key = egui::Key::from_name(name) + .ok_or_else(|| format!("Unknown literal documentation key: {name}"))?; + self.binding( + format!("key-{}", name.to_ascii_lowercase()), + BindingValue::Keys(vec![key.name().to_owned()]), + ) + } + pub(super) fn capture_tutorial( &mut self, session: &mut Session<'_>, diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index b890bad..2c111f8 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -16,7 +16,8 @@ mod guide; pub(crate) mod input; mod renderer_baseline; mod shortcut_replay; -pub use artifacts::{build, run}; +mod site; +pub use artifacts::{build, flow, run}; use artifacts::{insert, validate_path}; use features::FEATURES; diff --git a/src/documentation/site.rs b/src/documentation/site.rs new file mode 100644 index 0000000..bb75575 --- /dev/null +++ b/src/documentation/site.rs @@ -0,0 +1,331 @@ +//! Project-owned bridge from the same replay/narratives to lgtmd's site host. +use super::{ + GENERATED, Result, Session, + features::Feature, + guide::{Guide, Media}, +}; +use crate::controls::Control; +use lgtmd::{ + Doc, Document, IntoProse, + transport::{Input, Node, Part}, +}; + +pub(super) fn document(feature: &Feature, session: &mut Session<'_>) -> Result { + if let Some(template) = feature.template { + let mut guide = Guide::new(feature.slug, feature.title)?; + let mut parts = Vec::new(); + let mut tail = template; + while let Some(start) = tail.find("{{") { + parts.push(tail[..start].into_prose()); + let rest = &tail[start + 2..]; + let end = rest.find("}}").ok_or("Unclosed documentation binding")?; + let (kind, key) = rest[..end] + .split_once(':') + .ok_or("Expected {{kind:key}} binding")?; + parts.push(match kind { + "control" => guide.control(session, Control::parse(key)?)?.into_prose(), + "shortcut" => guide.shortcut(key)?.into_prose(), + "key" => guide.key(key)?.into_prose(), + "modifier" => guide.modifier(key)?.into_prose(), + "value" => session + .values + .get(key) + .ok_or_else(|| format!("Unverified documentation value: {key}"))? + .into_prose(), + "image" | "animation" => guide + .media( + session, + key, + if kind == "image" { + Media::Still + } else { + Media::Animation + }, + )? + .image(&key.replace('-', " ")), + _ => return Err(format!("Unknown documentation binding kind: {kind}")), + }); + tail = &rest[end + 2..]; + } + parts.push(tail.into_prose()); + guide.doc.markdown_parts(parts)?; + // Import only assertions that the real Session already completed, and + // require exactly the same public captures in both audience projections. + guide.finish(session)?; + } + session + .authored + .clone() + .ok_or_else(|| format!("{} has no completed site document", feature.slug)) +} + +pub(super) fn overview(index: &[u8]) -> Result { + let text = std::str::from_utf8(index).map_err(|error| error.to_string())?; + let mut doc = Doc::new("overview", "N3 guide")?; + doc.markdown( + text.strip_prefix(GENERATED) + .ok_or("Missing guide provenance")?, + )?; + doc.finish() +} + +/// The SDK additionally escapes Markdown punctuation inside its generated HTML +/// labels. Compare the same label spelling using N3's existing HTML convention; +/// narrative text, whitespace, resource paths, and media still compare exactly. +pub(super) fn legacy_fragment(document: &Document) -> Result { + let mut fragment = document.render_fragment(lgtmd::Audience::Reader)?; + for (entity, literal) in [ + ("`", "`"), + ("*", "*"), + ("_", "_"), + ("~", "~"), + ("\", "\\"), + ("[", "["), + ("]", "]"), + ("!", "!"), + ] { + for tag in ["code", "kbd"] { + let opening = format!("<{tag}>"); + let closing = format!(""); + let mut offset = 0; + while let Some(start) = fragment[offset..].find(&opening) { + let start = offset + start + opening.len(); + let end = start + + fragment[start..] + .find(&closing) + .ok_or("Unclosed SDK label")?; + let label = fragment[start..end].replace(entity, literal); + fragment.replace_range(start..end, &label); + offset = start + label.len() + closing.len(); + } + } + } + Ok(fragment) +} + +/// The full SDK exporter supplies the page title and explicit block anchors. +/// Adapt those presentation details without changing the canonical fragments. +pub(super) fn prepare(documents: Vec) -> Result> { + let mut input = Input::from_documents(&documents)?; + for document in &mut input.documents { + let mut nodes = Vec::new(); + for (index, node) in std::mem::take(&mut document.nodes).into_iter().enumerate() { + let (id, mut parts) = match node { + Node::Markdown { id, parts } => (id, parts), + Node::RawMarkdown { id, text } => (id, vec![Part::Markdown { text }]), + other => { + nodes.push(other); + continue; + } + }; + if index == 0 + && let Some(Part::Markdown { text }) = parts.first_mut() + && let Some(rest) = text.strip_prefix("# ") + && let Some((title, body)) = rest.split_once('\n') + { + document.title = title.to_owned(); + *text = body.strip_prefix('\n').unwrap_or(body).to_owned(); + } + split_sections(&id, parts, &mut nodes); + } + document.nodes = nodes; + } + input.thaw() +} + +fn heading(line: &str) -> Option<(u8, &str)> { + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + if !(1..=6).contains(&level) { + return None; + } + let text = line[level..].strip_prefix(' ')?.trim_end(); + (!text.is_empty()).then_some((level as u8, text)) +} + +fn heading_id(text: &str) -> String { + text.to_ascii_lowercase() + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("-") +} + +fn split_sections(id: &str, parts: Vec, nodes: &mut Vec) { + let mut current = Vec::new(); + let mut segment = 0; + let mut fence: Option<&str> = None; + let mut line_start = true; + let flush = |current: &mut Vec, segment: &mut usize, nodes: &mut Vec| { + if current.is_empty() { + return; + } + nodes.push(Node::Markdown { + id: if *segment == 0 { + id.to_owned() + } else { + format!("{id}-site-{segment}") + }, + parts: std::mem::take(current), + }); + *segment += 1; + }; + for part in parts { + let Part::Markdown { text } = part else { + current.push(part); + line_start = false; + continue; + }; + for line in text.split_inclusive('\n') { + let marker = if line.starts_with("```") { + Some("```") + } else if line.starts_with("~~~") { + Some("~~~") + } else { + None + }; + if line_start && let Some(marker) = marker { + fence = if fence == Some(marker) { + None + } else { + fence.or(Some(marker)) + }; + } + if line_start + && fence.is_none() + && let Some((level, text)) = heading(line) + { + flush(&mut current, &mut segment, nodes); + nodes.push(Node::Heading { + id: heading_id(text), + level, + text: text.to_owned(), + }); + } else { + // README.md is N3's canonical index. Preserve literal fenced + // examples while routing the existing guide backlink to the site. + current.push(Part::Markdown { + text: if fence.is_none() { + line.replace("[Back to guide](README.md)", "[Back to guide](overview.md)") + } else { + line.to_owned() + }, + }); + } + line_start = line.ends_with('\n'); + } + } + flush(&mut current, &mut segment, nodes); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::documentation::{artifacts, features::FEATURES}; + use lgtmd::{Audience, BindingValue, prose, site}; + + #[test] + fn fragment_parity_accounts_only_for_sdk_label_entity_spelling() { + let mut doc = Doc::new("labels", "Labels").unwrap(); + let key = doc + .binding("key", BindingValue::Keys(vec!["\\".into()])) + .unwrap(); + doc.markdown_parts(("\ outside ", key)).unwrap(); + let document = doc.finish().unwrap(); + assert_eq!( + legacy_fragment(&document).unwrap(), + "\ outside \\" + ); + } + + #[test] + fn site_routes_index_and_sections_without_changing_literal_code_or_typed_keys() { + let mut doc = Doc::new("gizmo", "Axis gizmo").unwrap(); + let key = doc + .binding("projection-key", BindingValue::Keys(vec!["0".into()])) + .unwrap(); + doc.markdown_parts(prose!("# Axis gizmo\n\n[Back to guide](README.md)\n\n## Projection\n\nPress {key}.\n\n```text\n## Literal code\n[Back to guide](README.md)\n```\n", key = key).unwrap()).unwrap(); + let original = doc.finish().unwrap(); + let original_fragment = original.render_fragment(Audience::Reader).unwrap(); + let mut index = Doc::new("overview", "N3 guide").unwrap(); + index.markdown("[Projection](gizmo.md#projection)").unwrap(); + let documents = prepare(vec![index.finish().unwrap(), original.clone()]).unwrap(); + let site = site::compile( + &Input::from_documents(&documents).unwrap(), + "N3 guide", + None, + ) + .unwrap(); + let gizmo = site + .reader + .bundle + .pages + .iter() + .find(|page| page.id == "gizmo") + .unwrap(); + assert_eq!(gizmo.title, "Axis gizmo"); + assert!(gizmo.anchors.iter().any(|anchor| anchor == "projection")); + let markdown = std::str::from_utf8(&site.markdown.reader["gizmo.md"]).unwrap(); + assert_eq!(markdown.matches("# Axis gizmo").count(), 1); + assert!(markdown.contains("## Literal code")); + assert!(markdown.contains("0")); + assert!(markdown.contains("](overview.md)")); + assert!(markdown.contains("[Back to guide](README.md)\n```")); + assert_eq!( + original.render_fragment(Audience::Reader).unwrap(), + original_fragment + ); + } + + #[test] + fn complete_site_replay_preserves_guide_and_filters_contributor_notes() { + let views = artifacts::generate_outputs(false, true).unwrap(); + artifacts::compare( + &views.reader, + &artifacts::read_tree(&super::super::root().join("docs/guide")).unwrap(), + ) + .unwrap(); + let documents = prepare(views.documents).unwrap(); + let config: serde_json::Value = + serde_json::from_str(include_str!("../../lgtmd.config.json")).unwrap(); + let navigation: Vec = serde_json::from_value(config["navigation"].clone()).unwrap(); + assert_eq!( + navigation, + std::iter::once("overview".to_owned()) + .chain(FEATURES.iter().map(|feature| feature.slug.to_owned())) + .collect::>() + ); + let site = site::compile( + &Input::from_documents(&documents).unwrap(), + "N3 guide", + Some(&navigation), + ) + .unwrap(); + for (path, bytes) in &views.reader { + if path.starts_with("assets/") { + assert_eq!(&site.reader.files[path], bytes, "{path}"); + } + } + assert_eq!(site.reader.bundle.pages.len(), FEATURES.len() + 1); + assert!( + site.reader + .bundle + .pages + .iter() + .all(|page| page.notes.is_empty()) + ); + assert!( + site.contributor + .bundle + .pages + .iter() + .any(|page| !page.notes.is_empty()) + ); + assert!( + site.reader + .bundle + .pages + .iter() + .any(|page| page.id == "overview") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 68c9541..efdc7ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,7 +67,7 @@ pub fn run_native() -> Result<(), Box> { editor::EditorAccess::ReadWrite }; if initial.as_deref() == Some(std::ffi::OsStr::new("--docs")) { - const USAGE: &str = "Usage: n3 --docs update|check | --docs build --out PATH"; + const USAGE: &str = "Usage: n3 --docs update|check|flow | --docs build --out PATH"; let mode = args .next() .and_then(|mode| mode.into_string().ok()) @@ -83,6 +83,10 @@ pub fn run_native() -> Result<(), Box> { doc_harness::build(&output).map_err(std::io::Error::other)?; return Ok(()); } + if mode == "flow" && args.next().is_none() { + doc_harness::flow().map_err(std::io::Error::other)?; + return Ok(()); + } if !matches!(mode.as_str(), "update" | "check") || args.next().is_some() { return Err(USAGE.into()); }