diff --git a/README.md b/README.md index c674731..4882d4b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ OpenMBEE View Editor brings source editing, named model views and reviewed graph - **Query the draft model.** Request engine-backed completion, symbol information and project-file definitions against the complete current source set. - **Inspect requirement graphs.** Browse requirement definitions/usages, declared identifiers and documentation, typing, hierarchy and positive/negated satisfaction links. Follow exact source declarations; diagram links do not report executed test results. - **Open source-defined views.** Navigate named SysML views independently of the active source file. Inspect nested and inherited parts, typed inherited ports and resolved connection endpoints. +- **Read generated documents.** Open document definitions (`part def` specializations of `DocumentQueries::Document`) next to named views. The embedded OpenSysML engine plans, evaluates and renders them; headings, table rows and inline references reveal their source declarations, and `Diagram` blocks mount the editor’s native view renderer. - **Navigate source issues.** Open an issue’s reported file and line in the source editor. Missing or stale locations are explained while drafts and the last valid diagram are retained. - **Browse the model hierarchy.** Expand packages, definitions and declared members in the Model tree. Inspect type/kind labels; use arrow keys, type-ahead and Enter/Space to navigate and select. - **Review graphical changes.** Rename parts, create native views, add typed part usages and connect compatible directed ports through an exact source-change preview. @@ -59,7 +60,7 @@ OpenMBEE View Editor brings source editing, named model views and reviewed graph - **Arrange your workspace.** Resize or collapse Explorer, Inspector, source/diagram panes and Issues using dividers, keyboard controls or panel options. - **Choose your appearance.** Use Light, Dark or Follow system, six diagram palettes and custom muted element colors. Theme and panel preferences stay in the browser. -Structural, native requirement and bounded action/state workflows have real-engine browser coverage. Use-case, mixed, sequence and geometry renderers remain unavailable; behavior diagrams do not execute or simulate models. See [capabilities and limits](docs/capabilities.md) and [local verification](docs/verification.md) for the implemented scope. +Structural, native requirement and bounded action/state workflows have real-engine browser coverage. Use-case, mixed, sequence and geometry renderers remain unavailable; behavior diagrams do not execute or simulate models. Generated documents are read-only: there is no inline text editing, publication or Flexo MMS export, and PDF/AsciiDoc output is unavailable in the browser. See [capabilities and limits](docs/capabilities.md) and [local verification](docs/verification.md) for the implemented scope. In the source editor, use **Ctrl+F** / **Cmd+F** for find/replace and **Ctrl+Z** / **Cmd+Z** for draft editing undo; **Ctrl+Y** / **Cmd+Shift+Z** redo draft edits. **Tab** moves focus out of the editor. Toolbar **Undo source** / **Redo source** operate on accepted model revisions. Highlighting is lexical; the pinned engines still validate source when you apply it. Use **Ctrl+Space** for engine-backed completion, **F1** for symbol information and **F12** for project-file definitions; pointing at a symbol also requests hover information. Queries use the complete current draft source set. Unsupported completion edits and stale replies are rejected. Formatting and inline diagnostics remain future work. @@ -82,7 +83,7 @@ Vite excludes generated `sandbox/` contents and prepared `static/vendor/` binari The landing page offers **Open app** and **Try an example**. `/app` opens a project launcher with **New project**, **Open files or project**, **Load example** and **Local recovery**. No model loads implicitly. New projects stay marked modified until downloaded. -Choose **Load example** to open the **Inspection rover** in Diagram mode. Six source files describe four connected assemblies, declared component values, an inspection mission, operating states and quantitative requirements. +Choose **Load example** to open the **Inspection rover** in Diagram mode. Seven source files describe four connected assemblies, declared component values, an inspection mission, operating states, quantitative requirements and a generated inspection report. Explore the named views **architecture → mission → lifecycle → requirements → definitions**. Choose **Guide** in the project header for a tour of the editor controls. The opening diagram includes a 480 Wh battery, two distinct drives, lidar, controller, camera and radio. Declared values, successions and satisfaction links are model content; the app does not execute or simulate them. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 65f813a..ee592f7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -6,19 +6,20 @@ One static browser application contains a framework-independent workspace and a ## Ownership and direction -| Module | Owns | -| --------------------------- | ------------------------------------------------------------------------------------------------- | -| `workspace/project` | Source documents, validation of membership/limits, applied model records, exact source digests | -| `workspace/editing` | Command schemas, engine-facing interfaces and command postconditions | -| `workspace/views` | Evaluated artifact/identity contracts and revision checks | -| `workspace/language` | Read-only source query/result contracts and UTF-16 source positions | -| `workspace/history` | Accepted transaction history | -| `workspace/persistence` | Versioned project schema, serialization and format rules | -| `workspace/createWorkspace` | Session lifetime and prepare/accept orchestration | -| `engines` | Disposable workers, timeout/cancellation, engine-specific calls and data normalization | -| `workbench` | Files/Views/Model navigation, source interaction, diagrams, properties, dialogs and UI state | -| `contracts` | Shared validation mechanics; schemas remain with their owners | -| `ui` | Shared React controls, theme tokens and browser appearance preferences; no workspace transactions | +| Module | Owns | +| --------------------------- | ----------------------------------------------------------------------------------------------------- | +| `workspace/project` | Source documents, validation of membership/limits, applied model records, exact source digests | +| `workspace/editing` | Command schemas, engine-facing interfaces and command postconditions | +| `workspace/documents` | Rendered-document contract, typed document diagnostics, explorer grouping and click-to-source mapping | +| `workspace/views` | Evaluated artifact/identity contracts and revision checks | +| `workspace/language` | Read-only source query/result contracts and UTF-16 source positions | +| `workspace/history` | Accepted transaction history | +| `workspace/persistence` | Versioned project schema, serialization and format rules | +| `workspace/createWorkspace` | Session lifetime and prepare/accept orchestration | +| `engines` | Disposable workers, timeout/cancellation, engine-specific calls and data normalization | +| `workbench` | Files/Views/Model navigation, source interaction, diagrams, properties, dialogs and UI state | +| `contracts` | Shared validation mechanics; schemas remain with their owners | +| `ui` | Shared React controls, theme tokens and browser appearance preferences; no workspace transactions | The workspace imports neither React/DOM nor engine implementations. Semantic and transformation adapters satisfy the two interfaces declared by editing; a separate language adapter satisfies the read-only contract in `workspace/language`. `workbench/shell/session.ts` constructs them and creates a session. UI entry points call the same workspace operations. `npm run check:architecture` checks import direction and platform globals. diff --git a/docs/capabilities.md b/docs/capabilities.md index 56e8efe..c70dc05 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -2,24 +2,25 @@ This catalogue describes the current implementation. Check [verification](verification.md) for checks actually performed; a feature description alone is not test evidence. -| Workflow | Support | Limit | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Entry and shell | OpenMBEE landing, explicit project launcher, example/new/import/recovery actions; live-session resume and browser navigation | Folder write-back and remote checkout remain planned; exploration scope/camera are transient | -| Model exploration | Read-only nested project/package ownership overview and explicit part-definition/usage tree or compact structural projection without view declarations; independent source-file navigation | No inferred system root, automatic typed-instance expansion, exploration persistence or graphical editing | -| Project sources | Multiple imported SysML files resolve together | 20 files / 5 MiB per source set; one active source editor | -| Source editing | CodeMirror lexical highlighting, line numbers, bracket matching/closing, indentation, find/replace, Toolkit draft formatting, draft history, apply/discard and accepted source history | Toolkit formatting remains a draft until apply; no inline diagnostics or syntax-aware folding; unsafe declaration/context matches lose affected positions | -| Source assistance | Toolkit completion, hover, project-file definitions and references bound to the full current draft source set | Explicit plain-text completion only; snippets/additional edits, ambiguous and library-only definition targets are unavailable | -| Named views | Open declarations; create GeneralView/InterconnectionView usages in a package | Custom filters/definitions remain editable in source | -| Diagram rendering | Native requirement graphs with identifiers, documentation, typing, direct/type-based ownership and declared positive/negated satisfaction; flat tree views; nested interconnection parts, declared attribute compartments, ports and resolved endpoints; bounded native action/state diagrams with initial/final nodes and directed relationships | Requirement constraints/subject compartments, generalized typed-instance trace expansion, owned-port boundary notation, effective conjugated directions, modeled layout/routes and case/mixed/sequence/geometry renderers remain future work | -| Semantic editing | Reviewed part definition/usage rename, native view creation, typed part creation and directed same-type port connections | Simple identifiers and qualified names; explicit out/inout to in/inout with identical resolved types; conjugated, ambiguous or undirected endpoints, reconnect, delete, duplicate and behavior editing are unavailable | -| Model navigation | Expandable ownership tree, declared members, type/kind labels, keyboard navigation and diagram-selection reveal | View declarations use the separate Views list; inherited members are not cloned beneath usages; no full-model search or virtualized rendering | -| Source navigation | Recorded origin range from diagram/port selection, snapshot-bound Issues file/line navigation and explicit file-only handling | Explorer-only selection does not yet resolve a source range; stale, unknown and out-of-range diagnostic locations are explained without guessing | -| Diagram presentation | Move nested groups, retain descendant positions, reconcile compatible positions across reviewed edits and bounded source changes, save/reopen and refit panes | Changed bindings, headers, owners or ambiguous correspondence invalidate affected positions; manual overlap avoidance, parent resizing, camera persistence, route edits and independent layout undo remain future work | -| Workbench panels | Resizable Explorer, Inspector, source/diagram split and Issues; collapse/restore, keyboard and button resizing, reset and browser layout preferences | Full docking/floating windows are unavailable; preferences do not recover source work | -| Save/reopen | Version-1 bundle of source, drafts and positions; local recovery with explicit restore/discard and active file/view | One IndexedDB recovery copy per browser origin; storage can be cleared or unavailable; no camera persistence or folder write-back | -| Runtime validation | TypeBox project/command/message/engine schemas | Schema validity is separate from SysML semantic validity | -| Appearance | Shared shadcn/Radix controls; Light/Dark/System, six palettes and custom muted package/part fills | Browser preferences, separate from project files; detailed port color controls remain unavailable | -| Execution | No simulation controls | Rendered behavior or declared verify links would not imply execution results | +| Workflow | Support | Limit | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Entry and shell | OpenMBEE landing, explicit project launcher, example/new/import/recovery actions; live-session resume and browser navigation | Folder write-back and remote checkout remain planned; exploration scope/camera are transient | +| Model exploration | Read-only nested project/package ownership overview and explicit part-definition/usage tree or compact structural projection without view declarations; independent source-file navigation | No inferred system root, automatic typed-instance expansion, exploration persistence or graphical editing | +| Project sources | Multiple imported SysML files resolve together | 20 files / 5 MiB per source set; one active source editor | +| Source editing | CodeMirror lexical highlighting, line numbers, bracket matching/closing, indentation, find/replace, Toolkit draft formatting, draft history, apply/discard and accepted source history | Toolkit formatting remains a draft until apply; no inline diagnostics or syntax-aware folding; unsafe declaration/context matches lose affected positions | +| Source assistance | Toolkit completion, hover, project-file definitions and references bound to the full current draft source set | Explicit plain-text completion only; snippets/additional edits, ambiguous and library-only definition targets are unavailable | +| Named views | Open declarations; create GeneralView/InterconnectionView usages in a package | Custom filters/definitions remain editable in source | +| Generated documents | Explorer **Documents** group lists `part def` specializations of `DocumentQueries::Document` resolved by the engine with their declaring file; the **Document** tab renders the engine's semantic HTML read-only through an allowlist (no scripts, styles or media), keeps its `sysml-` classes and `data-` attributes, reveals headings/blocks/table rows/inline references in source and mounts the native view renderer for `Diagram` blocks that embed a declared view, with a link to open the named view; typed planning/evaluation failures appear in Issues | Read-only: no inline text editing, publication or Flexo export; PDF/AsciiDoc output and Graphviz drawings are unavailable in the browser; blocks the native renderers cannot draw show the engine notice; documents re-render on apply, not while typing | +| Diagram rendering | Native requirement graphs with identifiers, documentation, typing, direct/type-based ownership and declared positive/negated satisfaction; flat tree views; nested interconnection parts, declared attribute compartments, ports and resolved endpoints; bounded native action/state diagrams with initial/final nodes and directed relationships | Requirement constraints/subject compartments, generalized typed-instance trace expansion, owned-port boundary notation, effective conjugated directions, modeled layout/routes and case/mixed/sequence/geometry renderers remain future work | +| Semantic editing | Reviewed part definition/usage rename, native view creation, typed part creation and directed same-type port connections | Simple identifiers and qualified names; explicit out/inout to in/inout with identical resolved types; conjugated, ambiguous or undirected endpoints, reconnect, delete, duplicate and behavior editing are unavailable | +| Model navigation | Expandable ownership tree, declared members, type/kind labels, keyboard navigation and diagram-selection reveal | View declarations use the separate Views list; inherited members are not cloned beneath usages; no full-model search or virtualized rendering | +| Source navigation | Recorded origin range from diagram/port selection, snapshot-bound Issues file/line navigation and explicit file-only handling | Explorer-only selection does not yet resolve a source range; stale, unknown and out-of-range diagnostic locations are explained without guessing | +| Diagram presentation | Move nested groups, retain descendant positions, reconcile compatible positions across reviewed edits and bounded source changes, save/reopen and refit panes | Changed bindings, headers, owners or ambiguous correspondence invalidate affected positions; manual overlap avoidance, parent resizing, camera persistence, route edits and independent layout undo remain future work | +| Workbench panels | Resizable Explorer, Inspector, source/diagram split and Issues; collapse/restore, keyboard and button resizing, reset and browser layout preferences | Full docking/floating windows are unavailable; preferences do not recover source work | +| Save/reopen | Version-1 bundle of source, drafts and positions; local recovery with explicit restore/discard and active file/view | One IndexedDB recovery copy per browser origin; storage can be cleared or unavailable; no camera persistence or folder write-back | +| Runtime validation | TypeBox project/command/message/engine schemas | Schema validity is separate from SysML semantic validity | +| Appearance | Shared shadcn/Radix controls; Light/Dark/System, six palettes and custom muted package/part fills | Browser preferences, separate from project files; detailed port color controls remain unavailable | +| Execution | No simulation controls | Rendered behavior or declared verify links would not imply execution results | This repository does not claim full SysML v2 language, notation, execution or interchange conformance. @@ -29,7 +30,7 @@ Use **New part** and **Connect ports** in supported structural diagrams, or the Layout reconciliation checks every saved view, including inactive views. Verified commands use source-bound UTF-8 splices; free-form edits use conservative replacement ranges and require unchanged engine-recorded declaration headers, complete usage contexts and compatible resolved bindings. Unknown or ambiguous mappings are invalidated explicitly. This is bounded preservation, not a general persistent-identity guarantee. See [the implemented policy](architecture/identity-reconciliation.md) and [authoring contracts](architecture/graphical-authoring.md). -The explicit **Load example** project is [Inspection rover](../examples/inspection-rover/README.md): six source files and five views covering architecture, mission, lifecycle, requirements and component definitions. All five render bounded projections; their catalog entries retain limits. Inspection rover is the only bundled example; the legacy [Smart air workshop](../examples/air-compressor/README.md) remains available for explicit source import and regression coverage. Declared attributes show effective source members, with shorter labels for simple scalar defaults and exact declaration text on hover. Larger named structural diagrams use narrower cards and tighter spacing. Values are not evaluated. Action/state graphs show authored behavior, not execution. +The explicit **Load example** project is [Inspection rover](../examples/inspection-rover/README.md): seven source files, five views covering architecture, mission, lifecycle, requirements and component definitions, and one generated inspection report. All five render bounded projections; their catalog entries retain limits. Inspection rover is the only bundled example; the legacy [Smart air workshop](../examples/air-compressor/README.md) remains available for explicit source import and regression coverage. Declared attributes show effective source members, with shorter labels for simple scalar defaults and exact declaration text on hover. Larger named structural diagrams use narrower cards and tighter spacing. Values are not evaluated. Action/state graphs show authored behavior, not execution. The [visualization example library](../examples/README.md) records representative models, named views, intended visual quality and current gaps. Its checks distinguish successful engine evaluation from application rendering; use-case, package-graph and other unsupported renderer fixtures remain explicit development targets. diff --git a/docs/next-steps.md b/docs/next-steps.md index 1a2f8c2..b4f7a88 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -38,6 +38,15 @@ Follow the [integration research](research/openmbee-flexo-mms.md): prove unchang Publication follows only after one reviewed command preserves repository UUIDs and unedited semantics, with server-enforced stale-head rejection and defined retry/ambiguous-outcome handling. Authentication, conversion and publication failures must retain local work. No deployment/authentication target has been selected here. +## Generated documents: follow-ups + +The read-only document surface renders engine HTML and reveals declarations in source. Out of scope so far, in order of expected value: + +- Editing document text in place (paragraph and span text, titles, captions) through Toolkit source transactions with the same preview/review flow as graphical edits. +- Exporting PDF, which needs the engine's external converters (WeasyPrint, pandoc, mermaid-cli) and is unavailable in the browser; a hosted service or the CLI remains the route. +- Publishing rendered documents to Flexo MMS alongside the planned project checkout/publication flow. +- Replacing the local `studio_render_document_v1` overlay with the upstream `RenderDocument` RPC once the pinned engine serves it; the bridge already mirrors its field names. + ## Follow-on usability and rendering Named structural and bounded action/state diagrams now retain generated orthogonal routes and labels. Follow-on [edge interaction and routing](architecture/structural-views.md#planned-edge-interactions-and-routing) work includes accessible endpoint descriptions, hit targets and obstacle rerouting after arbitrary individual-node movement. Read-only exploration retains its separate routing geometry. diff --git a/engine-bridge/README.md b/engine-bridge/README.md index 985673e..22e8cb6 100644 --- a/engine-bridge/README.md +++ b/engine-bridge/README.md @@ -7,7 +7,7 @@ The browser app requires OpenSysML and SysML Toolkit WASM workers. Preparation s | OpenSysML | 0.12.0 / `760c4d1fc8edfa4e214423099065c0a7c1b24e2d` | Parsing, model inspection, validation and native evaluation | | SysML Toolkit | 0.10.10 / `339ef0e073d06e553e81c079755ddfcd224e4b92` | Source transformations and draft language queries | -`scripts/prepare-opensysml.mjs` verifies the source archive, applies the retained overlays under `engine-bridge/opensysml/`, runs bridge tests and builds with Go 1.27.3. The derived module exposes `studio_evaluate_view_v1`, `studio_relationships_v1` and `studio_exploration_v1`. It records source/archive, overlay, builder, toolchain, binary and runtime identities. Set `SYSML_GO` to a local Go 1.27.3 binary on platforms without the pinned Linux x64 download path. +`scripts/prepare-opensysml.mjs` verifies the source archive, applies the retained overlays under `engine-bridge/opensysml/`, runs bridge tests and builds with Go 1.27.3. The derived module exposes `studio_evaluate_view_v1`, `studio_relationships_v1`, `studio_exploration_v1` and `studio_render_document_v1`. It records source/archive, overlay, builder, toolchain, binary and runtime identities. Set `SYSML_GO` to a local Go 1.27.3 binary on platforms without the pinned Linux x64 download path. `scripts/prepare-sysml-toolkit.mjs` verifies its release archive and every prepared asset. It retains the Apache and EPL licenses, library assets, build information and third-party notices. Neither preparation script installs a toolchain globally. @@ -19,6 +19,8 @@ The structural bridge retains engine hierarchy, inherited-port correspondence an Native requirement graphs retain the engine's selected subjects and related context. The local overlay adds declared short names, complete owned documentation bodies, satisfaction assertion/negation flags and direct-versus-type-context ownership by looking up recorded AST origins. It does not split rendered detail text, infer satisfaction from subjects, or change the native graph's nodes, edges or order. Requirement transport validation checks exact correspondence, endpoint membership, exposure classification, polarity and direct ownership before publication. A declared relationship is not an execution result. +`render_document.go` (engine `studio_document.go`) adds `ListDocuments` and `RenderDocument` to the pinned engine, which does not yet serve the upstream `RenderDocument` RPC. It resolves document definitions through `docplan.IsDocumentDefinition`, compiles them with `docplan.Compile`, evaluates them with `docir.EvaluateLinked` over the cached model's runtime and renders Markdown or fragment HTML with `docrender` (`WithoutGraphviz`; Graphviz is unavailable in wasm). Markdown keeps the CLI's Mermaid fences; HTML requests the DOT diagram form (source text, no Graphviz call) because the Mermaid HTML path resolves inline images against `os.Getwd`, which js/wasm does not implement, and the browser replaces every `sysml-diagram` figure with its native renderer or the view evaluation's notice anyway. The request mirrors the upstream protojson field names (`modelHash`, `document`, `form`) so the browser contract survives the swap. The response adds the declared origins of every `data-element` reference and `data-name` content path, and types `docplan`/`docir`/`docrender` failures by stage and kind with their source line. `validated_document.go` (combined `studio_document.go`) refuses revisions with validation errors before planning, as the view bridge does; its tests cover HTML, Markdown, unknown documents, non-documents, typed planning errors and the validation gate. The overlay does not change upstream planning, evaluation or rendering semantics. + `insertMember` supports typed part and connection creation. Creation may insert within the recorded owner or promote only its terminal semicolon to a body; existing surrounding source is retained. Toolkit accepts some directionally incompatible connections, so the workspace separately checks explicit out/inout-to-in/inout direction, identical resolved type and exact evaluated usage/port endpoints. Conjugated or ambiguous endpoint paths remain unavailable for graphical creation. [Authoring scope and evidence](../docs/architecture/graphical-authoring.md). Toolkit also supplies completion, hover, definition, references and formatting queries through a separate disposable language worker. Queries use the complete draft source set, negotiate UTF-16 positions and normalize only supported results. The workbench rejects stale replies; completion and formatting edits remain unapplied draft changes until the normal source transaction validates them. Engine pins, prepared assets and provenance are unchanged. See [source assistance](../docs/architecture/source-assistance-recovery.md). diff --git a/engine-bridge/opensysml/render_document.go b/engine-bridge/opensysml/render_document.go new file mode 100644 index 0000000..d3f6c9f --- /dev/null +++ b/engine-bridge/opensysml/render_document.go @@ -0,0 +1,476 @@ +package engine + +import ( + "context" + "errors" + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/doc/docir" + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/ir/docplan" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// JListDocumentsRequest names the cached model whose document definitions are +// listed. +type JListDocumentsRequest struct { + ModelHash string `json:"modelHash,omitempty"` +} + +// JRenderDocumentRequest mirrors the protojson field names of the upstream +// RenderDocument RPC (model hash, document name, form) so the local bridge can +// be replaced by that RPC without changing the browser contract. +type JRenderDocumentRequest struct { + ModelHash string `json:"modelHash,omitempty"` + Document string `json:"document,omitempty"` + Form string `json:"form,omitempty"` +} + +// DocumentDefinition is one part def specializing DocumentQueries::Document, +// with the source declaration it is read from. +type DocumentDefinition struct { + QualifiedName string `json:"qualifiedName"` + Name string `json:"name"` + Origin *ViewOrigin `json:"origin,omitempty"` +} + +// DocumentList is the studio_render_document_v1 listing envelope. +type DocumentList struct { + SchemaVersion int `json:"schemaVersion"` + ModelHash string `json:"modelHash"` + Documents []DocumentDefinition `json:"documents"` +} + +// DocumentDiagnostic is one typed planning, evaluation or rendering failure +// with the source location the engine recorded for it. +type DocumentDiagnostic struct { + Stage string `json:"stage"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Message string `json:"message"` + Document string `json:"document,omitempty"` + Content string `json:"content,omitempty"` + Query string `json:"query,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` +} + +// DocumentReference maps a data-element qualified name in the rendered output +// to the declaration it denotes. +type DocumentReference struct { + QualifiedName string `json:"qualifiedName"` + Kind string `json:"kind"` + Origin *ViewOrigin `json:"origin,omitempty"` +} + +// DocumentContent maps a rendered content block (section, paragraph, table, +// list, diagram) to the part declaring it inside the document definition. Path +// joins the data-name attributes from the document root with "/". +type DocumentContent struct { + Path string `json:"path"` + Name string `json:"name"` + Kind string `json:"kind"` + View string `json:"view,omitempty"` + Origin *ViewOrigin `json:"origin,omitempty"` +} + +// RenderedDocument is the studio_render_document_v1 rendering envelope. +type RenderedDocument struct { + SchemaVersion int `json:"schemaVersion"` + ModelHash string `json:"modelHash"` + Document string `json:"document"` + Form string `json:"form"` + Status string `json:"status"` + Title string `json:"title,omitempty"` + Content string `json:"content"` + References []DocumentReference `json:"references"` + Contents []DocumentContent `json:"contents"` + Diagnostics []DocumentDiagnostic `json:"diagnostics"` +} + +const ( + documentFormHTML = "html" + documentFormMarkdown = "markdown" +) + +// listDocuments resolves document definitions through the semantic model, in +// qualified-name order, with their declaring source ranges. +func (e *Engine) listDocuments(req *JListDocumentsRequest) ([]byte, error) { + cached, ok := e.get(req.ModelHash) + if !ok { + return nil, statusErrorf(codeNotFound, msgModelNotFound, req.ModelHash) + } + typed := cached.typedModel() + out := DocumentList{SchemaVersion: 1, ModelHash: req.ModelHash, Documents: []DocumentDefinition{}} + for _, doc := range cached.Index.WorkspaceDocuments() { + for _, sym := range model.DeclaredDocumentDefinitions(cached.Index, typed, cached.Index.DocumentRoot(doc)) { + out.Documents = append(out.Documents, DocumentDefinition{ + QualifiedName: cached.Index.GetFQN(sym), + Name: sym.Name, + Origin: documentOrigin(sym.Origin()), + }) + } + } + sort.SliceStable(out.Documents, func(i, j int) bool { + return out.Documents[i].QualifiedName < out.Documents[j].QualifiedName + }) + return marshal(out) +} + +// renderDocument compiles a document definition to its plan, evaluates the +// plan over the cached model's runtime and renders it as semantic HTML or +// Markdown. Typed planning, evaluation and rendering failures are reported in +// the envelope rather than as transport errors so the browser can place them +// in its diagnostics surface. +func (e *Engine) renderDocument(req *JRenderDocumentRequest) ([]byte, error) { + cached, ok := e.get(req.ModelHash) + if !ok { + return nil, statusErrorf(codeNotFound, msgModelNotFound, req.ModelHash) + } + if req.Document == "" { + return nil, statusError(codeInvalidArgument, "document is required") + } + form := req.Form + if form == "" { + form = documentFormHTML + } + if form != documentFormHTML && form != documentFormMarkdown { + return nil, statusErrorf(codeInvalidArgument, "unknown document form %q: one of html, markdown", req.Form) + } + typed := cached.typedModel() + matches := lookupNamed(cached.Index, req.Document) + if len(matches) > 1 { + documents := matches[:0:0] + for _, candidate := range matches { + if docplan.IsDocumentDefinition(cached.Index, typed, candidate) { + documents = append(documents, candidate) + } + } + matches = documents + } + if len(matches) != 1 { + return nil, statusErrorf(codeNotFound, "document not found: %s", req.Document) + } + sym := matches[0] + out := RenderedDocument{ + SchemaVersion: 1, + ModelHash: req.ModelHash, + Document: req.Document, + Form: form, + Status: "invalid", + References: []DocumentReference{}, + Contents: []DocumentContent{}, + Diagnostics: []DocumentDiagnostic{}, + } + if !docplan.IsDocumentDefinition(cached.Index, typed, sym) { + out.Diagnostics = append(out.Diagnostics, DocumentDiagnostic{ + Stage: "planning", + Kind: docplanKinds[docplan.ErrorNotDocumentDefinition], + Severity: "error", + Message: req.Document + " is not a document: one is a part def specializing DocumentQueries::Document", + Document: cached.Index.GetFQN(sym), + File: sym.Origin().Doc, + Line: cached.lineOf(sym.Origin()), + }) + return marshal(out) + } + plan, err := docplan.Compile(cached.Index, typed, cached.resolver, sym) + if err != nil { + out.Diagnostics = append(out.Diagnostics, cached.documentDiagnostic("planning", err)) + return marshal(out) + } + rt := e.newRuntime(context.Background(), cached) + queries := queryexec.Context{ + Index: cached.Index, + Resolver: cached.resolver, + Model: typed, + Runtime: rt, + LineIndex: func(name string) *source.LineIndex { + if file := cached.document(name); file != nil { + return file.Lines() + } + return nil + }, + } + document, err := docir.EvaluateLinked( + plan, + model.SiblingDocumentPlans(cached.Index, typed, cached.resolver, sym), + queries, + queryexec.Options{}, + cached.sourceText(), + ) + if err != nil { + out.Diagnostics = append(out.Diagnostics, cached.documentDiagnostic("evaluation", err)) + return marshal(out) + } + // Graphviz is unavailable in wasm. The browser replaces every diagram + // figure with its native renderer, so HTML carries each diagram's DOT + // source as text: the Mermaid HTML path inlines images relative to + // os.Getwd, which js/wasm does not implement. Markdown keeps the CLI's + // Mermaid fences. + var content string + if form == documentFormHTML { + content, err = docrender.HTML(document, docrender.HTMLOptions{ + Fragment: true, + NoDefaultStylesheet: true, + DiagramForm: view.FormDot, + WithoutGraphviz: true, + }) + } else { + content, err = docrender.Markdown(document, docrender.MarkdownOptions{ + DiagramForm: view.FormMermaid, + WithoutGraphviz: true, + }) + } + if err != nil { + out.Diagnostics = append(out.Diagnostics, cached.documentDiagnostic("rendering", err)) + return marshal(out) + } + out.Status = "complete" + out.Title = document.Title() + out.Content = content + out.References = cached.documentReferences(document) + out.Contents = cached.documentContents("", document.Content()) + return marshal(out) +} + +// documentContents records each content block's declaring part by its +// data-name path, so a rendered heading or block can reveal its declaration. +func (cached *cachedModel) documentContents(prefix string, nodes []docir.Content) []DocumentContent { + out := []DocumentContent{} + for _, node := range nodes { + path := node.Name() + if prefix != "" { + path = prefix + "/" + node.Name() + } + item := DocumentContent{Path: path, Name: node.Name(), Kind: string(node.Kind()), Origin: documentOrigin(node.Origin())} + if rendering := node.Rendering(); rendering != nil { + item.View = rendering.View + } + out = append(out, item) + out = append(out, cached.documentContents(path, node.Children())...) + } + return out +} + +// documentReferences resolves every element the rendered output names in a +// data-element attribute: table rows, list items, definition entries and +// inline references. +func (cached *cachedModel) documentReferences(document *docir.Document) []DocumentReference { + seen := map[string]DocumentReference{} + record := func(sym *symbols.Symbol) { + if sym == nil { + return + } + name := cached.Index.GetFQN(sym) + if _, done := seen[name]; done || name == "" { + return + } + seen[name] = DocumentReference{QualifiedName: name, Kind: sym.Kind.String(), Origin: documentOrigin(sym.Origin())} + } + value := func(v queryexec.Value) { + if sym, ok := v.Element(); ok { + record(sym) + } + } + named := func(qualified string) { + if qualified == "" { + return + } + if matches := lookupNamed(cached.Index, qualified); len(matches) == 1 { + record(matches[0]) + } + } + runs := func(items []docir.TextRun) { + for _, run := range items { + named(run.TargetElement()) + } + } + var walk func(nodes []docir.Content) + walk = func(nodes []docir.Content) { + for _, node := range nodes { + runs(node.Runs()) + for _, row := range node.Rows() { + value(row.Element()) + } + for _, group := range node.Groups() { + for _, row := range group.Rows() { + value(row.Element()) + } + } + for _, item := range node.Items() { + value(item.Element()) + runs(item.Runs()) + } + for _, definition := range node.Definitions() { + value(definition.Element()) + } + walk(node.Children()) + } + } + walk(document.Content()) + out := make([]DocumentReference, 0, len(seen)) + for _, ref := range seen { + out = append(out, ref) + } + sort.Slice(out, func(i, j int) bool { return out[i].QualifiedName < out[j].QualifiedName }) + return out +} + +// documentOrigin is a located declaration range, nil for library or synthetic +// declarations. +func documentOrigin(origin symbols.Origin) *ViewOrigin { + if !origin.Located() { + return nil + } + return &ViewOrigin{File: origin.Doc, Offset: origin.Span.Offset, Length: origin.Span.Len, NameOffset: origin.Name.Offset, NameLength: origin.Name.Len} +} + +// lineOf is the 1-based line an origin starts on in the model's own sources, +// zero when the origin is unlocated or belongs to the library. +func (cached *cachedModel) lineOf(origin symbols.Origin) int { + if !origin.Located() { + return 0 + } + file := cached.document(origin.Doc) + if file == nil { + return 0 + } + return file.Lines().PosAt(origin.Span.Offset).Line +} + +// documentDiagnostic classifies a typed docplan, docir or docrender failure. +// Untyped failures keep their message under a generic kind. +func (cached *cachedModel) documentDiagnostic(stage string, err error) DocumentDiagnostic { + out := DocumentDiagnostic{Stage: stage, Kind: stage, Severity: "error", Message: err.Error()} + var planning *docplan.Error + var evaluation *docir.Error + var rendering *docrender.Error + switch { + case errors.As(err, &planning): + out.Kind = docplanKinds[planning.Kind] + out.Severity = planning.Kind.Severity().String() + out.Document, out.Content, out.Query = planning.Document, planning.Content, planning.Query + out.File, out.Line = cached.locate(planning.Origin) + case errors.As(err, &evaluation): + out.Kind = docirKinds[evaluation.Kind] + out.Document, out.Content, out.Query = evaluation.Document, evaluation.Content, evaluation.Query + out.File, out.Line = cached.locate(evaluation.Origin) + case errors.As(err, &rendering): + out.Kind = docrenderKinds[rendering.Kind] + out.Content = rendering.Content + } + if out.Kind == "" { + out.Kind = stage + } + return out +} + +func (cached *cachedModel) locate(origin symbols.Origin) (string, int) { + if !origin.Located() || cached.document(origin.Doc) == nil { + return "", 0 + } + return origin.Doc, cached.lineOf(origin) +} + +var docplanKinds = map[docplan.ErrorKind]string{ + docplan.ErrorInvalidContext: "invalidContext", + docplan.ErrorLibraryUnavailable: "libraryUnavailable", + docplan.ErrorNotDocumentDefinition: "notDocumentDefinition", + docplan.ErrorMissingTitle: "missingTitle", + docplan.ErrorInvalidAttribute: "invalidAttribute", + docplan.ErrorInvalidContent: "invalidContent", + docplan.ErrorNestedDocument: "nestedDocument", + docplan.ErrorMissingText: "missingText", + docplan.ErrorConflictingText: "conflictingText", + docplan.ErrorMissingQuery: "missingQuery", + docplan.ErrorConflictingQuery: "conflictingQuery", + docplan.ErrorUnknownQuery: "unknownQuery", + docplan.ErrorQueryPlanning: "queryPlanning", + docplan.ErrorInvalidStyle: "invalidStyle", + docplan.ErrorUnknownParameter: "unknownParameter", + docplan.ErrorDuplicateBinding: "duplicateBinding", + docplan.ErrorMissingBinding: "missingBinding", + docplan.ErrorUnsupportedBinding: "unsupportedBinding", + docplan.ErrorBindingType: "bindingType", + docplan.ErrorBindingMultiplicity: "bindingMultiplicity", + docplan.ErrorMissingViewSource: "missingViewSource", + docplan.ErrorInvalidViewSource: "invalidViewSource", + docplan.ErrorUnknownViewSource: "unknownViewSource", + docplan.ErrorMissingDiagramKind: "missingDiagramKind", + docplan.ErrorConflictingKind: "conflictingKind", + docplan.ErrorUnsupportedKind: "unsupportedKind", + docplan.ErrorInvalidDirection: "invalidDirection", + docplan.ErrorUnsupportedDirection: "unsupportedDirection", + docplan.ErrorInvalidPalette: "invalidPalette", + docplan.ErrorUnsupportedPalette: "unsupportedPalette", + docplan.ErrorInvalidPorts: "invalidPorts", + docplan.ErrorUnsupportedPorts: "unsupportedPorts", + docplan.ErrorInvalidShow: "invalidShow", + docplan.ErrorInvalidOverlay: "invalidOverlay", + docplan.ErrorUnsupportedOverlay: "unsupportedOverlay", + docplan.ErrorConflictingRuns: "conflictingRuns", + docplan.ErrorAmbiguousRun: "ambiguousRun", + docplan.ErrorMissingRunText: "missingRunText", + docplan.ErrorInvalidRunStyle: "invalidRunStyle", + docplan.ErrorMissingLinkTarget: "missingLinkTarget", + docplan.ErrorMissingRefTarget: "missingRefTarget", + docplan.ErrorUnknownRefTarget: "unknownRefTarget", + docplan.ErrorInvalidRefTarget: "invalidRefTarget", + docplan.ErrorAmbiguousRefTarget: "ambiguousRefTarget", + docplan.ErrorUnknownValueTarget: "unknownValueTarget", + docplan.ErrorInvalidValueTarget: "invalidValueTarget", + docplan.ErrorUnknownGroupColumn: "unknownGroupColumn", + docplan.ErrorInvalidColumnWidths: "invalidColumnWidths", + docplan.ErrorInvalidColumnLabels: "invalidColumnLabels", + docplan.ErrorColumnRunWithoutQuery: "columnRunWithoutQuery", + docplan.ErrorConflictingColumnRuns: "conflictingColumnRuns", + docplan.ErrorMissingRunColumn: "missingRunColumn", + docplan.ErrorConflictingRunStyle: "conflictingRunStyle", + docplan.ErrorUnknownRunColumn: "unknownRunColumn", + docplan.ErrorMissingDefinitionColumn: "missingDefinitionColumn", + docplan.ErrorUnknownDefinitionColumn: "unknownDefinitionColumn", + docplan.ErrorMissingFormulaSource: "missingFormulaSource", + docplan.ErrorMissingImageLocation: "missingImageLocation", + docplan.ErrorUnsupportedMarkupLanguage: "unsupportedMarkupLanguage", + docplan.ErrorUnsupportedMarkupConstruct: "unsupportedMarkupConstruct", + docplan.ErrorMisplacedMarkupBody: "misplacedMarkupBody", + docplan.ErrorMarkupBodyNotParagraph: "markupBodyNotParagraph", + docplan.ErrorMarkupToolMissing: "markupToolMissing", + docplan.ErrorMarkupToolFailed: "markupToolFailed", + docplan.ErrorMarkupHeadingLevel: "markupHeadingLevel", +} +var docirKinds = map[docir.ErrorKind]string{ + docir.ErrorInvalidContext: "invalidContext", + docir.ErrorInvalidPlan: "invalidPlan", + docir.ErrorQueryExecution: "queryExecution", + docir.ErrorViewRendering: "viewRendering", + docir.ErrorUnknownGroup: "unknownGroup", + docir.ErrorUnknownRunColumn: "unknownRunColumn", + docir.ErrorInvalidRunStyle: "invalidRunStyle", + docir.ErrorInvalidRunTarget: "invalidRunTarget", + docir.ErrorBlankMath: "blankMath", + docir.ErrorUnknownDefinitionColumn: "unknownDefinitionColumn", + docir.ErrorMissingImageLocation: "missingImageLocation", + docir.ErrorValueEvaluation: "valueEvaluation", + docir.ErrorEmptyValue: "emptyValue", +} +var docrenderKinds = map[docrender.ErrorKind]string{ + docrender.ErrorNilDocument: "nilDocument", + docrender.ErrorUnknownContent: "unknownContent", + docrender.ErrorMissingRendering: "missingRendering", + docrender.ErrorUnrenderableDiagram: "unrenderableDiagram", + docrender.ErrorUnrenderableForm: "unrenderableForm", + docrender.ErrorUnknownForm: "unknownForm", + docrender.ErrorOversizedDiagram: "oversizedDiagram", + docrender.ErrorEmptyStylesheet: "emptyStylesheet", + docrender.ErrorAmbiguousStylesheet: "ambiguousStylesheet", + docrender.ErrorUnsafeStylesheet: "unsafeStylesheet", + docrender.ErrorUnknownTheme: "unknownTheme", + docrender.ErrorSurplusDiagramImages: "surplusDiagramImages", + docrender.ErrorTableColumns: "tableColumns", +} diff --git a/engine-bridge/opensysml/validated_document.go b/engine-bridge/opensysml/validated_document.go new file mode 100644 index 0000000..8b5f564 --- /dev/null +++ b/engine-bridge/opensysml/validated_document.go @@ -0,0 +1,67 @@ +package combined + +import ( + "context" + "encoding/json" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/engine" +) + +// listDocuments resolves document definitions in the cached model. +func (s *Server) listDocuments(ctx context.Context, params []byte) ([]byte, error) { + body, err := s.engine.Call(ctx, "ListDocuments", params) + if err == nil { + s.touchRequest(params) + } + return body, err +} + +// renderDocument validates the same cached revision before rendering a +// document from it, as evaluateView does for named views. +func (s *Server) renderDocument(ctx context.Context, params []byte) ([]byte, error) { + var req engine.JRenderDocumentRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, err + } + body, err := s.core.Call(ctx, "GetDiagnostics", params) + if err != nil { + return nil, err + } + var validation struct { + Diagnostics []struct { + Severity string `json:"severity"` + } `json:"diagnostics"` + } + if err := json.Unmarshal(body, &validation); err != nil { + return nil, err + } + for _, diagnostic := range validation.Diagnostics { + if diagnostic.Severity == "error" { + form := req.Form + if form == "" { + form = "html" + } + return json.Marshal(engine.RenderedDocument{ + SchemaVersion: 1, + ModelHash: req.ModelHash, + Document: req.Document, + Form: form, + Status: "invalid", + References: []engine.DocumentReference{}, + Contents: []engine.DocumentContent{}, + Diagnostics: []engine.DocumentDiagnostic{{ + Stage: "request", + Kind: "modelInvalid", + Severity: "error", + Message: "Model validation failed; correct source diagnostics before rendering documents.", + Document: req.Document, + }}, + }) + } + } + body, err = s.engine.Call(ctx, "RenderDocument", params) + if err == nil { + s.touchRequest(params) + } + return body, err +} diff --git a/engine-bridge/opensysml/validated_document_test.go b/engine-bridge/opensysml/validated_document_test.go new file mode 100644 index 0000000..534f46c --- /dev/null +++ b/engine-bridge/opensysml/validated_document_test.go @@ -0,0 +1,303 @@ +package combined + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +const studioDocumentModel = `package Demo { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import StandardViewDefinitions::*; + + part def Subsystem { + attribute mass : Real; + } + part vehicle { + part motor : Subsystem { + attribute redefines mass = 2.0; + } + part chassis : Subsystem { + attribute redefines mass = 5.0; + } + } + requirement massLimit { + doc /* Keep the vehicle light. */ + } + view structureView : InterconnectionView { + expose vehicle; + } + + calc def Parts :> Query { + in root : Element; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 2), + type = "PartUsage" + ), + properties = ("name", "mass") + ) + } + + part def Report :> Document { + attribute redefines title = "Demo Report"; + part intro : Paragraph { + part lead : Span { + :>> text = "The vehicle is bounded by"; + } + part limit : Ref { + ref redefines target = massLimit; + } + } + part structure : Section { + attribute redefines title = "Structure"; + part parts : Table { + attribute redefines caption = "Parts by mass"; + calc rows : Parts { + in root = vehicle; + } + } + } + part diagrams : Section { + attribute redefines title = "Diagrams"; + part layout : Diagram { + attribute redefines caption = "Vehicle interconnection"; + ref redefines source = structureView; + } + } + } +}` + +const studioBrokenDocumentModel = `package Demo { + private import DocumentQueries::*; + + part def Broken :> Document { + attribute redefines title = "Broken Report"; + part orphan : Table { + attribute redefines caption = "No query"; + } + } +}` + +type studioCall = func(method string, params any) (map[string]any, error) + +// studioDocumentServer parses one model and returns callers for the validated +// combined surface and for the engine behind it. +func studioDocumentServer(t *testing.T, content string) (call, direct studioCall, hash string) { + t.Helper() + server, err := New("v0.12.0") + if err != nil { + t.Fatal(err) + } + caller := func(invoke func(context.Context, string, []byte) ([]byte, error)) studioCall { + return func(method string, params any) (map[string]any, error) { + p, _ := json.Marshal(params) + body, err := invoke(context.Background(), method, p) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + t.Fatal(err) + } + return out, nil + } + } + call, direct = caller(server.Call), caller(server.engine.Call) + parsed, err := call("ParseSources", map[string]any{ + "documents": []any{map[string]any{"name": "model.sysml", "content": content}}, + "strictConformance": true, + }) + if err != nil { + t.Fatal(err) + } + return call, direct, parsed["modelHash"].(string) +} + +func TestStudioListDocuments(t *testing.T) { + call, _, hash := studioDocumentServer(t, studioDocumentModel) + result, err := call("ListDocuments", map[string]any{"modelHash": hash}) + if err != nil { + t.Fatal(err) + } + if result["schemaVersion"] != float64(1) || result["modelHash"] != hash { + t.Fatalf("Envelope mismatch: %v", result) + } + documents := result["documents"].([]any) + if len(documents) != 1 { + t.Fatalf("Expected the one document definition, got %v", documents) + } + names := []string{} + for _, raw := range documents { + item := raw.(map[string]any) + names = append(names, item["qualifiedName"].(string)) + origin, ok := item["origin"].(map[string]any) + if !ok || origin["file"] != "model.sysml" || origin["length"].(float64) <= 0 || origin["nameLength"].(float64) <= 0 { + t.Fatalf("Document lost its declaring location: %v", item) + } + } + if strings.Join(names, ",") != "Demo::Report" { + t.Fatalf("Unexpected document order: %v", names) + } +} + +func TestStudioRenderDocumentHTML(t *testing.T) { + call, _, hash := studioDocumentServer(t, studioDocumentModel) + result, err := call("RenderDocument", map[string]any{"modelHash": hash, "document": "Demo::Report", "form": "html"}) + if err != nil { + t.Fatal(err) + } + if result["status"] != "complete" || result["form"] != "html" || result["document"] != "Demo::Report" || result["title"] != "Demo Report" { + t.Fatalf("%v", result) + } + content := result["content"].(string) + for _, fragment := range []string{ + `class="sysml-document"`, + `class="sysml-section"`, + `data-name="structure"`, + `Demo Report`, + `data-element="Demo::vehicle::motor"`, + `data-element="Demo::massLimit"`, + `class="sysml-diagram"`, + `data-view="Demo::structureView"`, + } { + if !strings.Contains(content, fragment) { + t.Fatalf("HTML lacks %s:\n%s", fragment, content) + } + } + if strings.Contains(content, " Document { + attribute redefines title = "Report"; + } + part p : Missing; +}`) + result, err := call("RenderDocument", map[string]any{"modelHash": hash, "document": "Broken::Report"}) + if err != nil { + t.Fatal(err) + } + diagnostics := result["diagnostics"].([]any) + if result["status"] != "invalid" || result["form"] != "html" || len(diagnostics) != 1 || diagnostics[0].(map[string]any)["stage"] != "request" { + t.Fatalf("An invalid model must be refused before planning: %v", result) + } +} diff --git a/examples/README.md b/examples/README.md index 311faf2..adbec2e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -323,7 +323,7 @@ Expected engine notices include: “Dimensions and coordinate-frame poses evalua ### Inspection rover -Files: [system.sysml](inspection-rover/system.sysml), [components.sysml](inspection-rover/components.sysml), [types.sysml](inspection-rover/types.sysml), [behavior.sysml](inspection-rover/behavior.sysml), [requirements.sysml](inspection-rover/requirements.sysml), [views.sysml](inspection-rover/views.sysml). +Files: [system.sysml](inspection-rover/system.sysml), [components.sysml](inspection-rover/components.sysml), [types.sysml](inspection-rover/types.sysml), [behavior.sysml](inspection-rover/behavior.sysml), [requirements.sysml](inspection-rover/requirements.sysml), [views.sysml](inspection-rover/views.sysml), [report.sysml](inspection-rover/report.sysml). **RoverViews::architecture** — engine artifact: `interconnection`; app: **partial**. diff --git a/examples/catalog.json b/examples/catalog.json index 8c3c695..a7017b0 100644 --- a/examples/catalog.json +++ b/examples/catalog.json @@ -1114,7 +1114,8 @@ "types.sysml", "behavior.sysml", "requirements.sysml", - "views.sysml" + "views.sysml", + "report.sysml" ], "views": [ { diff --git a/examples/inspection-rover/README.md b/examples/inspection-rover/README.md index efead98..76c57ec 100644 --- a/examples/inspection-rover/README.md +++ b/examples/inspection-rover/README.md @@ -1,6 +1,6 @@ # Inspection rover -The default **Load example** and **Try an example** project. Six source files and five named views describe one indoor inspection rover, rather than unrelated syntax demonstrations. This is the only bundled example in the project launcher. +The default **Load example** and **Try an example** project. Seven source files, five named views and one generated report describe one indoor inspection rover, rather than unrelated syntax demonstrations. This is the only bundled example in the project launcher. ## Guided tour @@ -9,6 +9,7 @@ The default **Load example** and **Try an example** project. Six source files an 3. **lifecycle** connects docked, patrolling, inspecting, returning, charging and safeStop. The low-reserve and obstacle paths make the mission's recovery concerns explicit. Transition names are labels, not evaluated guards or live events. 4. **requirements** traces four quantitative declarations to their exact usage targets: 20% energy reserve, 1.5 m/s maximum drive speed, 0.8 m obstacle clearance and 12 MP image resolution. Satisfaction assertions are modeled claims, not verification results. Only the left drive is explicitly linked to the speed requirement; no right-drive link is inferred. 5. **definitions** shows reusable component types and actuator/sensor specialization. The two drives in architecture share the same Drive definition but remain distinct usages. +6. **InspectionReport** (Documents group) is generated by the engine from the applied model: one section per subsystem with a query-backed parts table, the declared requirement usages with their documentation, and a `Diagram` block that mounts the architecture view. Click a heading, table row or the inline rover reference to reveal its declaration; the report is read-only. Select any ordinary diagram node and choose **Open source** to inspect its declaration. Download the project to preserve sources and independently arranged view geometry. Model edits remain drafts until **Apply source** succeeds. @@ -20,5 +21,6 @@ Select any ordinary diagram node and choose **Open source** to inspect its decla - `behavior.sysml`: mission successions and operating-state transitions. - `requirements.sysml`: quantitative bounds and four explicit satisfaction assertions. - `views.sysml`: the five authored browsing selections. +- `report.sysml`: `DocumentQueries` queries and the `InspectionReport` document definition. The example is authored here under Apache-2.0. Numeric attributes use scalar names with explicit units in their names/documentation; this is not a dimensional-analysis or simulation model. No physical test results, live state, automatic routing behavior or solver results are claimed. Declared behavior rendering is bounded to the native action/state graphs; sequence, case and mixed views still need independent renderers. diff --git a/examples/inspection-rover/report.sysml b/examples/inspection-rover/report.sysml new file mode 100644 index 0000000..2265adf --- /dev/null +++ b/examples/inspection-rover/report.sysml @@ -0,0 +1,128 @@ +package RoverReport { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import RoverSystem::*; + + calc def Subparts :> Query { + in root : Element; + Project( + source = OrderBy( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "PartUsage", + exact = true + ), + property = "name", + direction = "ascending", + missing = "last", + multiple = "error" + ), + properties = ("name", "type") + ) + } + + calc def DeclaredRequirements :> Query { + in scope : String; + Project( + source = WhereType( + source = Descendants( + source = Named(qualifiedName = scope), + maxDepth = 1 + ), + type = "RequirementUsage", + exact = true + ), + properties = ("name", "documentation") + ) + } + + part def InspectionReport :> Document { + attribute redefines title = "Inspection Rover Report"; + + part intro : Paragraph { + part lead : Span { + :>> text = "This report is generated from the applied model of"; + } + part roverRef : Ref { + ref redefines target = RoverSystem::rover; + } + part tail : Span { + :>> text = "and lists each subsystem's parts, the declared requirements and the architecture view."; + } + } + + part energy : Section { + attribute redefines title = "Energy"; + part summary : Paragraph { + :>> text = "A 480 Wh battery feeds a distribution unit with drive, compute and payload outlets."; + } + part parts : Table { + attribute redefines caption = "Energy parts"; + calc rows : Subparts { + in root = rover.energy; + } + } + } + + part mobility : Section { + attribute redefines title = "Mobility"; + part summary : Paragraph { + :>> text = "Two drive usages share one Drive definition and remain distinct parts."; + } + part parts : Table { + attribute redefines caption = "Mobility parts"; + calc rows : Subparts { + in root = rover.mobility; + } + } + } + + part autonomy : Section { + attribute redefines title = "Autonomy"; + part summary : Paragraph { + :>> text = "Lidar feeds obstacle perception; the controller commands both drives."; + } + part parts : Table { + attribute redefines caption = "Autonomy parts"; + calc rows : Subparts { + in root = rover.autonomy; + } + } + } + + part inspection : Section { + attribute redefines title = "Inspection"; + part summary : Paragraph { + :>> text = "The camera returns images to the controller and the radio carries mission telemetry."; + } + part parts : Table { + attribute redefines caption = "Inspection parts"; + calc rows : Subparts { + in root = rover.inspection; + } + } + } + + part requirements : Section { + attribute redefines title = "Requirements"; + part note : Paragraph { + :>> text = "Declared quantitative bounds. Satisfaction assertions are modeled claims, not verification results."; + } + part table : Table { + attribute redefines caption = "Declared requirement usages"; + calc rows : DeclaredRequirements { + in scope = "RoverRequirements"; + } + } + } + + part architecture : Section { + attribute redefines title = "Architecture"; + part diagram : Diagram { + attribute redefines caption = "Rover interconnection, from the architecture view"; + ref redefines source = RoverViews::architecture; + } + } + } +} diff --git a/scripts/prepare-opensysml.mjs b/scripts/prepare-opensysml.mjs index ca4dc05..0f85fb5 100644 --- a/scripts/prepare-opensysml.mjs +++ b/scripts/prepare-opensysml.mjs @@ -21,6 +21,9 @@ const overlays = [ 'requirements_view_test.go', 'relationships.go', 'relationships_test.go', + 'render_document.go', + 'validated_document.go', + 'validated_document_test.go', ]; const structuralFixtures = ['model.sysml', 'views.sysml']; const patchHash = digest( @@ -126,6 +129,18 @@ await cp( resolve(root, 'engine-bridge/opensysml/relationships_test.go'), resolve(source, 'internal/frontend/combined/studio_relationships_test.go'), ); +await cp( + resolve(root, 'engine-bridge/opensysml/render_document.go'), + resolve(source, 'internal/frontend/engine/studio_document.go'), +); +await cp( + resolve(root, 'engine-bridge/opensysml/validated_document.go'), + resolve(source, 'internal/frontend/combined/studio_document.go'), +); +await cp( + resolve(root, 'engine-bridge/opensysml/validated_document_test.go'), + resolve(source, 'internal/frontend/combined/studio_document_test.go'), +); await cp( resolve(root, 'engine-bridge/opensysml/geometry_view.go'), resolve(source, 'internal/frontend/engine/studio_geometry.go'), @@ -156,15 +171,20 @@ await replace( 'case "RenderView":', 'case "EvaluateView":\n var req JRenderViewRequest\n if err := decode(params, &req); err != nil { return nil, err }\n return e.evaluateView(&req)\n case "RenderView":', ); +await replace( + 'internal/frontend/engine/engine.go', + 'case "RenderView":', + 'case "ListDocuments":\n var req JListDocumentsRequest\n if err := decode(params, &req); err != nil { return nil, err }\n return e.listDocuments(&req)\n case "RenderDocument":\n var req JRenderDocumentRequest\n if err := decode(params, &req); err != nil { return nil, err }\n return e.renderDocument(&req)\n case "RenderView":', +); await replace( 'internal/frontend/combined/combined.go', 'case "GetServerInfo":', - 'case "Explore":\n return s.explore(ctx,params)\n case "EvaluateRelationships":\n return s.evaluateRelationships(ctx,params)\n case "EvaluateView":\n return s.evaluateView(ctx,params)\n case "GetServerInfo":', + 'case "Explore":\n return s.explore(ctx,params)\n case "EvaluateRelationships":\n return s.evaluateRelationships(ctx,params)\n case "EvaluateView":\n return s.evaluateView(ctx,params)\n case "ListDocuments":\n return s.listDocuments(ctx,params)\n case "RenderDocument":\n return s.renderDocument(ctx,params)\n case "GetServerInfo":', ); await replace( 'internal/frontend/combined/combined.go', 'var capabilities = []string{', - 'var capabilities = []string{\n "studio_exploration_v1",\n "studio_evaluate_view_v1",\n "studio_relationships_v1",', + 'var capabilities = []string{\n "studio_exploration_v1",\n "studio_evaluate_view_v1",\n "studio_relationships_v1",\n "studio_render_document_v1",', ); let go = process.env.SYSML_GO; if (!go) { diff --git a/src/lib/engines/opensysml/index.ts b/src/lib/engines/opensysml/index.ts index 65d3fc4..472bf21 100644 --- a/src/lib/engines/opensysml/index.ts +++ b/src/lib/engines/opensysml/index.ts @@ -7,13 +7,36 @@ import { type ExplorationEngine, } from '$lib/workspace/exploration'; import type { SemanticEngine } from '$lib/workspace/editing'; +import { + validateRenderedDocument, + type DocumentEngine, +} from '$lib/workspace/documents'; import { readModelSnapshot } from '$lib/workspace/project'; import { validateEvaluation } from '$lib/workspace/views'; import { requestWorker } from '../workers/request'; export function createSemanticEngine( base: string, -): SemanticEngine & ExplorationEngine & RequirementsEngine { +): SemanticEngine & ExplorationEngine & RequirementsEngine & DocumentEngine { return { + renderDocument(documents, modelHash, name, form, signal) { + return requestWorker( + new Worker(new URL('./semantic.worker.ts', import.meta.url), { + type: 'module', + }), + { + protocolVersion: 1, + operation: 'document', + documents, + modelHash, + document: name, + form, + base, + }, + (value) => + validateRenderedDocument(value, modelHash, name, form, documents), + signal, + ); + }, requirements(documents, modelHash, signal) { return requestWorker( new Worker(new URL('./semantic.worker.ts', import.meta.url), { diff --git a/src/lib/engines/opensysml/semantic.worker.ts b/src/lib/engines/opensysml/semantic.worker.ts index 1d97ef8..7cbbc73 100644 --- a/src/lib/engines/opensysml/semantic.worker.ts +++ b/src/lib/engines/opensysml/semantic.worker.ts @@ -1,4 +1,8 @@ -import type { Diagnostic, ModelElement } from '$lib/workspace/project'; +import type { + Diagnostic, + DocumentDefinition, + ModelElement, +} from '$lib/workspace/project'; import type { EvaluatedView } from '$lib/workspace/views'; interface GoRuntime { importObject: WebAssembly.Imports; @@ -72,6 +76,19 @@ self.onmessage = async (event: MessageEvent) => { }); return; } + if (operation === 'document') { + if (parsed.modelHash !== request.modelHash) + throw new Error('Document request no longer matches source.'); + // Mirrors the upstream RenderDocument field names: modelHash, document, form. + self.postMessage({ + value: call('RenderDocument', { + modelHash: parsed.modelHash, + document: request.document, + form: request.form, + }), + }); + return; + } if (operation === 'evaluate' || operation === 'explore') { if (parsed.modelHash !== request.modelHash) throw new Error('View request no longer matches source.'); @@ -89,7 +106,12 @@ self.onmessage = async (event: MessageEvent) => { return; } const elements: ModelElement[] = []; + let documentDefinitions: DocumentDefinition[] = []; if (!diagnostics.some((item) => item.severity === 'error')) { + documentDefinitions = call<{ documents: DocumentDefinition[] }>( + 'ListDocuments', + { modelHash: parsed.modelHash }, + ).documents; const pending = parsed.roots.flatMap((root) => (root.childIds ?? []).map((id) => ({ id, @@ -125,7 +147,12 @@ self.onmessage = async (event: MessageEvent) => { } } self.postMessage({ - value: { modelHash: parsed.modelHash, elements, diagnostics }, + value: { + modelHash: parsed.modelHash, + elements, + diagnostics, + documents: documentDefinitions, + }, }); } catch (error) { self.postMessage({ diff --git a/src/lib/engines/workers/schema.ts b/src/lib/engines/workers/schema.ts index 7b0ac40..cf973a1 100644 --- a/src/lib/engines/workers/schema.ts +++ b/src/lib/engines/workers/schema.ts @@ -3,6 +3,7 @@ import { ExplorationRequestSchema } from '$lib/workspace/exploration'; import { validator } from '$lib/contracts/validation'; import { SourceDocumentsSchema } from '$lib/workspace/project'; import { SemanticCommandSchema } from '$lib/workspace/editing'; +import { DocumentFormSchema } from '$lib/workspace/documents/schema'; const common = { protocolVersion: Type.Literal(1), documents: SourceDocumentsSchema, @@ -28,6 +29,16 @@ export const SemanticRequestSchema = Type.Union([ closed, ), Type.Object({ ...common, operation: Type.Literal('parse') }, closed), + Type.Object( + { + ...common, + operation: Type.Literal('document'), + modelHash: Type.String({ minLength: 1 }), + document: Type.String({ minLength: 1 }), + form: DocumentFormSchema, + }, + closed, + ), Type.Object( { ...common, diff --git a/src/lib/ui/documents.css b/src/lib/ui/documents.css new file mode 100644 index 0000000..f73b75a --- /dev/null +++ b/src/lib/ui/documents.css @@ -0,0 +1,199 @@ +/* Generated document pane: engine HTML keeps its sysml- classes; the editor theme styles them. */ +.document-pane { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} +.document-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--sysml-border); +} +.document-heading > div { + display: flex; + flex-direction: column; + min-width: 0; +} +.document-heading strong { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.document-kind { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sysml-muted); +} +.document-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px 24px 32px; + font-size: 14px; + line-height: 1.5; + color: var(--sysml-text); +} +.document-body .sysml-document { + max-width: 860px; + margin: 0 auto; +} +.document-body .sysml-title, +.document-body h2, +.document-body h3, +.document-body h4 { + font-weight: 600; + line-height: 1.25; + margin: 1.2em 0 0.5em; + color: var(--sysml-text); +} +.document-body .sysml-title { + font-size: 22px; + margin-top: 0.2em; +} +.document-body h2 { + font-size: 18px; +} +.document-body h3 { + font-size: 15px; +} +.document-body .sysml-section-number { + color: var(--sysml-muted); +} +.document-body .sysml-paragraph { + margin: 0 0 0.8em; +} +.document-body [data-element], +.document-body h1, +.document-body h2, +.document-body h3, +.document-body h4, +.document-body .sysml-paragraph { + cursor: pointer; + border-radius: 3px; +} +.document-body [data-element]:not(tr):hover, +.document-body h1:hover, +.document-body h2:hover, +.document-body h3:hover, +.document-body h4:hover, +.document-body .sysml-paragraph:hover, +.document-body tr[data-element]:hover > td { + background: color-mix(in srgb, var(--sysml-accent) 12%, transparent); +} +.document-body [tabindex='0']:focus-visible { + outline: 2px solid var(--sysml-accent); + outline-offset: 1px; +} +.document-body .sysml-ref { + color: var(--sysml-accent); + text-decoration: underline dotted; +} +.document-body a[href] { + color: var(--sysml-accent); +} +.document-body .sysml-code, +.document-body code { + font-family: var(--sysml-mono, ui-monospace, monospace); + font-size: 0.92em; + background: var(--sysml-panel); + border-radius: 3px; + padding: 0 3px; +} +.document-body table { + border-collapse: collapse; + width: 100%; + margin: 0.5em 0 1em; + font-size: 13px; +} +.document-body caption, +.document-body figcaption { + caption-side: bottom; + text-align: left; + color: var(--sysml-muted); + font-size: 12px; + padding: 4px 0; +} +.document-body th, +.document-body td { + border: 1px solid var(--sysml-border); + padding: 4px 8px; + text-align: left; + vertical-align: top; +} +.document-body th { + background: var(--sysml-panel); + font-weight: 600; +} +.document-body .sysml-group-heading td, +.document-body .sysml-group th { + background: color-mix(in srgb, var(--sysml-panel) 60%, transparent); +} +.document-body .sysml-list { + margin: 0 0 0.8em; + padding-left: 1.4em; +} +.document-body .sysml-definitions dt { + font-weight: 600; +} +.document-body .sysml-definitions dd { + margin: 0 0 0.5em 1em; +} +.document-body figure { + margin: 0.8em 0 1.2em; +} +.document-body .sysml-diagram { + border: 1px solid var(--sysml-border); + border-radius: 6px; + background: var(--sysml-surface, transparent); + overflow: hidden; +} +.document-body .sysml-diagram figcaption { + padding: 6px 10px; + border-top: 1px solid var(--sysml-border); +} +.sysml-diagram-mount, +.document-diagram { + display: flex; + flex-direction: column; + min-height: 0; +} +.document-diagram-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 4px 6px 4px 10px; + font-size: 12px; + color: var(--sysml-muted); + border-bottom: 1px solid var(--sysml-border); +} +.document-diagram-canvas { + display: flex; + height: 360px; + min-height: 0; +} +.document-diagram-canvas .diagram-pane { + flex: 1; + min-height: 0; +} +.document-diagram-notice { + margin: 0; + padding: 12px; + font-size: 13px; + color: var(--sysml-muted); +} +.document-diagram-notice::before { + content: 'Engine notice: '; + font-weight: 600; +} +.document-diagram[data-state='loading'] .document-diagram-notice::before, +.document-diagram[data-state='failed'] .document-diagram-notice::before { + content: ''; +} diff --git a/src/lib/ui/theme.css b/src/lib/ui/theme.css index 07223d4..e61b97f 100644 --- a/src/lib/ui/theme.css +++ b/src/lib/ui/theme.css @@ -11,3 +11,4 @@ @import './requirements.css'; @import './requirements-data.css'; +@import './documents.css'; diff --git a/src/lib/workbench/diagrams/Diagram.tsx b/src/lib/workbench/diagrams/Diagram.tsx index 5b9e81c..ee6d500 100644 --- a/src/lib/workbench/diagrams/Diagram.tsx +++ b/src/lib/workbench/diagrams/Diagram.tsx @@ -101,6 +101,8 @@ interface Props { onCreatePart: () => void; onConnectPorts: () => void; layoutMode: 'code' | 'split' | 'diagram'; + /** Read-only rendering inside another surface: no view selector, actions or geometry edits. */ + embedded?: boolean; } export function Diagram({ exploration, @@ -118,6 +120,7 @@ export function Diagram({ onCreatePart, onConnectPorts, layoutMode, + embedded = false, }: Props) { const [nodes, setNodes] = useState([]), [edges, setEdges] = useState([]), @@ -311,187 +314,195 @@ export function Diagram({ viewNames.set(item.name, (viewNames.get(item.name) ?? 0) + 1); return (
-
-
- - event.target.value - ? onOpenView(event.target.value) - : onExplore?.({ - scope: { kind: 'project' }, - representation: 'tree', - }) - } - > - Explore model - {views.map((item) => ( - - {viewNames.get(item.name) === 1 ? item.name : item.id} - - ))} - - {exploration && ( - <> -
- - { - const subject = event.target.value; - const item = elements.find((item) => item.id === subject); - onExplore?.( - !subject - ? { scope: { kind: 'project' }, representation: 'tree' } - : item?.kind === 'package' + {!embedded && ( +
+
+ + event.target.value + ? onOpenView(event.target.value) + : onExplore?.({ + scope: { kind: 'project' }, + representation: 'tree', + }) + } + > + Explore model + {views.map((item) => ( + + {viewNames.get(item.name) === 1 ? item.name : item.id} + + ))} + + {exploration && ( + <> +
+ + { + const subject = event.target.value; + const item = elements.find((item) => item.id === subject); + onExplore?.( + !subject ? { - scope: { kind: 'package', subject }, + scope: { kind: 'project' }, representation: 'tree', } - : { - scope: { kind: 'element', subject }, - representation: 'tree', - }, - 'scope', - ); - }} - > - - Project overview - - {elements - .filter((item) => - ['package', 'partDef', 'partUsage'].includes(item.kind), - ) - .map((item) => ( - - {item.id} · {explorationKindLabel(item.kind)} - - ))} - -
-
- - - onExplore?.( - event.target.value === 'interconnection' && - exploration.scope.kind === 'element' - ? { - scope: exploration.scope, - representation: 'interconnection', - } - : { ...exploration, representation: 'tree' }, - 'representation', - ) - } - > - - Ownership overview - - - Structure - - -
-
- - {exploration.scope.kind === 'project' - ? 'Project' - : explorationKindLabel( - elements.find((item) => item.id === explorationSubject) - ?.kind ?? exploration.scope.kind, - )} + + Project overview + + {elements + .filter((item) => + ['package', 'partDef', 'partUsage'].includes(item.kind), + ) + .map((item) => ( + + {item.id} · {explorationKindLabel(item.kind)} + + ))} + +
+
+ + + onExplore?.( + event.target.value === 'interconnection' && + exploration.scope.kind === 'element' + ? { + scope: exploration.scope, + representation: 'interconnection', + } + : { ...exploration, representation: 'tree' }, + 'representation', + ) + } + > + + Ownership overview + + + Structure + + +
+
+ + {exploration.scope.kind === 'project' + ? 'Project' + : explorationKindLabel( + elements.find( + (item) => item.id === explorationSubject, + )?.kind ?? exploration.scope.kind, + )} + + + {exploration.scope.kind === 'project' + ? 'Project overview' + : exploration.scope.subject} + +
+ + )} +
+ {view?.artifact && ( + + {view.artifact.kind === 'interconnection' + ? 'Structure' + : view.artifact.kind === 'requirement' + ? 'Requirements' + : view.artifact.kind === 'action' + ? 'Mission flow' + : view.artifact.kind === 'state' + ? 'Operating states' + : view.artifact.kind === 'tree' + ? exploration + ? 'Ownership overview' + : 'Overview tree' + : 'Named view'} - - {exploration.scope.kind === 'project' - ? 'Project overview' - : exploration.scope.subject} - -
- - )} -
- {view?.artifact && ( - - {view.artifact.kind === 'interconnection' - ? 'Structure' - : view.artifact.kind === 'requirement' - ? 'Requirements' - : view.artifact.kind === 'action' - ? 'Mission flow' - : view.artifact.kind === 'state' - ? 'Operating states' - : view.artifact.kind === 'tree' - ? exploration - ? 'Ownership overview' - : 'Overview tree' - : 'Named view'} + )} + + {disabled + ? 'Applied revision · draft pending' + : view + ? exploration + ? 'Temporary exploration · applied model' + : 'Applied model' + : 'Choose a named view'} - )} - - {disabled - ? 'Applied revision · draft pending' - : view - ? exploration - ? 'Temporary exploration · applied model' - : 'Applied model' - : 'Choose a named view'} - -
-
- {!exploration && view?.artifact?.kind === 'interconnection' && ( -
- - +
- )} -
+ {!exploration && view?.artifact?.kind === 'interconnection' && ( +
+ + +
+ )} + + )} {!view ? (

One model. Focused views.

diff --git a/src/lib/workbench/documents/DocumentPane.tsx b/src/lib/workbench/documents/DocumentPane.tsx new file mode 100644 index 0000000..00e9fc0 --- /dev/null +++ b/src/lib/workbench/documents/DocumentPane.tsx @@ -0,0 +1,285 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ExternalLink } from 'lucide-react'; +import { Button } from '$lib/ui/primitives/button'; +import { Diagram } from '../diagrams/Diagram'; +import type { Workspace, WorkspaceState } from '$lib/workspace/createWorkspace'; +import type { DocumentClick } from '$lib/workspace/documents'; +import type { EvaluatedView } from '$lib/workspace/views'; +import { contentPath, sanitizeDocumentHtml } from './document-html'; + +interface DiagramMount { + readonly key: string; + readonly view: string; + readonly caption: string; + readonly notice: string; + readonly node: HTMLElement; +} + +/** Read-only rendering of the engine's HTML with native diagrams mounted in place of Mermaid fences. */ +export function DocumentPane({ + workspace, + state, + rerendering = null, + onReveal, + onOpenView, +}: { + workspace: Workspace; + state: WorkspaceState; + /** Qualified name of the document being re-rendered from a new applied revision. */ + rerendering?: string | null; + onReveal: (click: DocumentClick) => void; + onOpenView: (id: string) => void; +}) { + const rendered = state.renderedDocument; + const container = useRef(null); + const [mounts, setMounts] = useState([]); + useLayoutEffect(() => { + const host = container.current; + if (!host) return; + if (!rendered || rendered.status !== 'complete') { + host.replaceChildren(); + setMounts([]); + return; + } + host.replaceChildren(sanitizeDocumentHtml(rendered.content)); + // Native heading/row semantics stay; only href-less reference anchors need a role. + for (const element of host.querySelectorAll( + '[data-element], h1, h2, h3, h4, h5, h6, .sysml-paragraph', + )) { + element.tabIndex = 0; + const qualifiedName = element.getAttribute('data-element'); + if (qualifiedName) { + element.title = `Reveal ${qualifiedName} in source`; + if ( + element instanceof HTMLAnchorElement && + !element.hasAttribute('href') + ) + element.setAttribute('role', 'link'); + } else element.title = 'Reveal this block in source'; + } + const next: DiagramMount[] = []; + host + .querySelectorAll('figure.sysml-diagram') + .forEach((figure, index) => { + const notice = + figure.querySelector('.sysml-diagram-notice')?.textContent?.trim() ?? + ''; + const caption = figure.querySelector('figcaption'); + const node = document.createElement('div'); + node.className = 'sysml-diagram-mount'; + figure.replaceChildren(node, ...(caption ? [caption] : [])); + next.push({ + key: `${rendered.modelHash}:${index}`, + view: figure.getAttribute('data-view') ?? '', + caption: caption?.textContent ?? '', + notice, + node, + }); + }); + setMounts(next); + }, [rendered]); + function activate(target: EventTarget | null) { + const host = container.current; + if (!host || !(target instanceof Element)) return; + const reference = target.closest('[data-element]'); + if (reference && host.contains(reference)) { + onReveal({ + kind: 'element', + qualifiedName: reference.getAttribute('data-element')!, + }); + return; + } + const path = contentPath(target, host); + if (path) onReveal({ kind: 'content', path }); + } + if (!rendered && rerendering) + return ( +
+
+

Re-rendering document

+

+ The engine is planning and evaluating {rerendering} from the applied + model. +

+
+
+ ); + if (!rendered) + return ( +
+
+

Generated documents

+

+ Choose a document definition in the Explorer. The engine plans, + evaluates and renders it from the applied model. +

+
+
+ ); + return ( +
+
+
+ Generated document + + {rendered.title || rendered.document} + +
+ + {state.unapplied + ? 'Applied revision · draft pending' + : 'Read-only · applied model'} + +
+ {rendered.status !== 'complete' && ( +
+

Document could not be rendered

+ {rendered.diagnostics.map((item, index) => ( +

+ {item.message} + {item.file + ? ` (${item.file}${item.line ? ':' + item.line : ''})` + : ''} +

+ ))} +

Open Issues to navigate to the reported declaration.

+
+ )} +
+ ); +} + +/** One Diagram block: the editor's native renderer for the declared view, or the engine's notice. */ +function EmbeddedView({ + mount, + workspace, + state, + onOpenView, +}: { + mount: DiagramMount; + workspace: Workspace; + state: WorkspaceState; + onOpenView: (id: string) => void; +}) { + const [view, setView] = useState(); + const [error, setError] = useState(''); + const declared = state.model?.elements.some( + (item) => item.id === mount.view && item.kind === 'viewUsage', + ); + useEffect(() => { + if (!declared) return; + const controller = new AbortController(); + setView(undefined); + setError(''); + workspace + .previewView(mount.view, controller.signal) + .then((value) => { + if (!controller.signal.aborted) setView(value); + }) + .catch((reason) => { + if (!controller.signal.aborted) + setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => controller.abort(); + }, [workspace, state.model, mount.view, declared]); + const notice = + mount.notice || + (mount.view + ? `${mount.view} is not a declared view of the applied model.` + : 'This diagram block does not embed a declared view.'); + const drawable = + view?.status === 'complete' && view.renderStatus === 'available'; + return ( +
+
+ {mount.view || 'Diagram'} + {declared && ( + + )} +
+ {!declared ? ( +

{notice}

+ ) : error ? ( +

+ {error} +

+ ) : !view ? ( +

+ Evaluating {mount.view}… +

+ ) : drawable ? ( +
+ undefined} + onMove={() => undefined} + onCreatePart={() => undefined} + onConnectPorts={() => undefined} + /> +
+ ) : ( +

+ {view.diagnostics.join(' ') || notice} +

+ )} +
+ ); +} diff --git a/src/lib/workbench/documents/document-html.ts b/src/lib/workbench/documents/document-html.ts new file mode 100644 index 0000000..853798f --- /dev/null +++ b/src/lib/workbench/documents/document-html.ts @@ -0,0 +1,190 @@ +/** Rebuild engine HTML through an allowlist: no scripts, styles, media, forms or event handlers survive. */ +const structural = new Set([ + 'article', + 'header', + 'footer', + 'nav', + 'section', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'span', + 'a', + 'em', + 'strong', + 'b', + 'i', + 'u', + 's', + 'small', + 'sup', + 'sub', + 'code', + 'kbd', + 'samp', + 'var', + 'pre', + 'br', + 'hr', + 'blockquote', + 'cite', + 'abbr', + 'time', + 'table', + 'caption', + 'colgroup', + 'col', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'th', + 'td', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', + 'figure', + 'figcaption', +]); +/** Removed with their content: executable, embedded or interactive content has no place in a read-only document. */ +const removed = new Set([ + 'script', + 'style', + 'link', + 'meta', + 'base', + 'title', + 'head', + 'iframe', + 'frame', + 'object', + 'embed', + 'applet', + 'svg', + 'math', + 'template', + 'noscript', + 'form', + 'input', + 'button', + 'select', + 'textarea', + 'img', + 'picture', + 'source', + 'video', + 'audio', + 'canvas', + 'map', + 'area', + 'slot', + 'dialog', +]); +const attributes = new Set([ + 'id', + 'class', + 'title', + 'lang', + 'dir', + 'colspan', + 'rowspan', + 'scope', + 'start', + 'reversed', + 'datetime', + 'cite', +]); + +function keepClass(value: string) { + return value + .split(/\s+/) + .filter((token) => token.startsWith('sysml-')) + .join(' '); +} + +function keepHref(value: string) { + const trimmed = value.trim(); + if (trimmed.startsWith('#')) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return undefined; +} + +function copyAttributes(from: Element, to: Element) { + for (const { name, value } of Array.from(from.attributes)) { + const lower = name.toLowerCase(); + if (lower === 'class') { + const kept = keepClass(value); + if (kept) to.setAttribute('class', kept); + } else if (lower === 'href') { + const kept = keepHref(value); + if (kept) { + to.setAttribute('href', kept); + if (!kept.startsWith('#')) { + to.setAttribute('rel', 'noopener noreferrer'); + to.setAttribute('target', '_blank'); + } + } + } else if (lower.startsWith('data-') || attributes.has(lower)) + to.setAttribute(lower, value); + } +} + +function rebuild(source: Node, into: Node, target: Document) { + for (const child of Array.from(source.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + into.appendChild(target.createTextNode(child.nodeValue ?? '')); + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const element = child as Element, + tag = element.localName.toLowerCase(); + if (removed.has(tag)) continue; + // Diagram source fences are replaced by native renderers or the engine notice. + if ( + tag === 'pre' && + ['mermaid', 'dot', 'plantuml', 'd2'].some((form) => + element.classList.contains(form), + ) + ) + continue; + if (!structural.has(tag)) { + rebuild(element, into, target); + continue; + } + const copy = target.createElement(tag); + copyAttributes(element, copy); + rebuild(element, copy, target); + into.appendChild(copy); + } +} + +/** Parse untrusted document HTML in an inert document and rebuild only the allowed tree. */ +export function sanitizeDocumentHtml( + html: string, + target: Document = document, +): DocumentFragment { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const fragment = target.createDocumentFragment(); + rebuild(parsed.body, fragment, target); + return fragment; +} + +/** Path of data-name attributes from the document root to the block holding `node`. */ +export function contentPath(node: Element, root: Element): string | undefined { + const names: string[] = []; + let current: Element | null = node.closest('[data-content]'); + while (current && current !== root && root.contains(current)) { + const name = current.getAttribute('data-name'); + if (name) names.unshift(name); + current = current.parentElement?.closest('[data-content]') ?? null; + } + return names.length ? names.join('/') : undefined; +} diff --git a/src/lib/workbench/explorer/Explorer.tsx b/src/lib/workbench/explorer/Explorer.tsx index 8df1333..de982c1 100644 --- a/src/lib/workbench/explorer/Explorer.tsx +++ b/src/lib/workbench/explorer/Explorer.tsx @@ -1,5 +1,6 @@ import { Button } from '$lib/ui/primitives/button'; -import { Eye, FileCode2 } from 'lucide-react'; +import { Eye, FileCode2, FileText } from 'lucide-react'; +import { documentEntries } from '$lib/workspace/documents'; import { ModelTree } from './ModelTree'; import type { WorkspaceState } from '$lib/workspace/createWorkspace'; import type { ModelElement } from '$lib/workspace/project'; @@ -10,6 +11,7 @@ export function Explorer({ selectionRequest, onFile, onView, + onDocument, onSelect, onNewView, }: { @@ -19,11 +21,13 @@ export function Explorer({ selectionRequest: number; onFile: (name: string) => void; onView: (id: string) => void; + onDocument: (id: string) => void; onSelect: (element: ModelElement) => void; onNewView: () => void; }) { const views = state.model?.elements.filter((item) => item.kind === 'viewUsage') ?? []; + const documents = documentEntries(state.model?.documents); return (
- {dataMode !== 'diagram' && readySession && ( - + )} + {dataMode === 'document' && readySession && ( + + void run(async () => { + setDataMode('diagram'); + await workspace().openView(id); + }) + } /> )}
void>(); @@ -165,6 +175,8 @@ export function createWorkspace(dependencies: { validate(model); } validate(model); + if (model.documents) + validateDocumentDefinitions(model.documents, documents); return { ...model, sourceHash: await sourceDigest(documents) }; } function restore( @@ -178,6 +190,7 @@ export function createWorkspace(dependencies: { revision: state.revision + 1, view: null, exploration: null, + renderedDocument: null, diagnostics: value.model.diagnostics, diagnosticDocuments: value.project.documents, layoutNotices, @@ -391,7 +404,10 @@ export function createWorkspace(dependencies: { proposals.clear(); publish({ project: { ...project, drafts: project.documents }, - diagnostics: state.model?.diagnostics ?? [], + diagnostics: [ + ...(state.model?.diagnostics ?? []), + ...documentDiagnostics(state.renderedDocument), + ], diagnosticDocuments: project.documents, }); }, @@ -612,6 +628,71 @@ export function createWorkspace(dependencies: { finish(token); } }, + async openDocument(name: string) { + alive(); + if (state.busy) throw new Error('Finish the current operation first.'); + const { project, model } = accepted(); + if (!dependencies.documents) + throw new Error('Document rendering is unavailable.'); + if (!model.documents?.some((item) => item.qualifiedName === name)) + throw new Error('Choose a declared document.'); + const { token, signal } = begin(); + try { + const result = validateRenderedDocument( + await dependencies.documents.renderDocument( + project.documents, + model.modelHash, + name, + 'html', + signal, + ), + model.modelHash, + name, + 'html', + project.documents, + ); + current(token); + // Planning/evaluation failures join the model diagnostics of the same revision. + publish({ + renderedDocument: result, + diagnostics: [...model.diagnostics, ...documentDiagnostics(result)], + diagnosticDocuments: project.documents, + }); + } finally { + finish(token); + } + }, + closeDocument() { + alive(); + if (!state.renderedDocument) return; + const { project, model } = accepted(); + publish({ + renderedDocument: null, + diagnostics: model.diagnostics, + diagnosticDocuments: project.documents, + }); + }, + /** Evaluate a declared view for an embedded, read-only rendering without replacing the open view. */ + async previewView(id: string, signal?: AbortSignal) { + alive(); + const { project, model } = accepted(); + if ( + !model.elements.some( + (item) => item.id === id && item.kind === 'viewUsage', + ) + ) + throw new Error('Choose a declared view.'); + return validateEvaluation( + await dependencies.semantic.evaluate( + project.documents, + model.modelHash, + id, + signal, + ), + model.modelHash, + id, + ); + }, moveSymbol(symbol: string, position: Point) { moveSymbols({ [symbol]: position }); }, diff --git a/src/lib/workspace/documents/documents.test.ts b/src/lib/workspace/documents/documents.test.ts new file mode 100644 index 0000000..be5875d --- /dev/null +++ b/src/lib/workspace/documents/documents.test.ts @@ -0,0 +1,262 @@ +import { expect, it } from 'vitest'; +import { + documentDiagnostics, + documentEntries, + documentSourceTarget, + validateRenderedDocument, + type RenderedDocument, +} from './index'; +import type { DocumentDefinition, SourceDocument } from '../project'; + +const applied: SourceDocument[] = [ + { name: 'model.sysml', text: 'package Demo { part def Ünit; part motor; }' }, + { + name: 'report.sysml', + text: 'package Report { part def R :> Document {} }', + }, +]; +const modelBytes = new TextEncoder().encode(applied[0]!.text); +const motorOffset = modelBytes.indexOf( + new TextEncoder().encode('part motor')[0]!, + new TextEncoder().encode('package Demo { part def Ünit; ').length, +); +const rendered: RenderedDocument = { + schemaVersion: 1, + modelHash: 'hash', + ['document']: 'Report::R', + form: 'html', + status: 'complete', + title: 'Report', + content: '
', + references: [ + { + qualifiedName: 'Demo::motor', + kind: 'partUsage', + origin: { + file: 'model.sysml', + offset: motorOffset, + length: 'part motor;'.length, + nameOffset: motorOffset + 5, + nameLength: 5, + }, + }, + { qualifiedName: 'ScalarValues::Real', kind: 'dataType' }, + ], + contents: [ + { + path: 'intro', + name: 'intro', + kind: 'paragraph', + origin: { file: 'report.sysml', offset: 17, length: 26 }, + }, + ], + diagnostics: [], +}; + +it('groups declared documents by display name and declaring file, disambiguating repeated names', () => { + const definitions: DocumentDefinition[] = [ + { + qualifiedName: 'B::Report', + name: 'Report', + origin: { file: 'b.sysml', offset: 0, length: 4 }, + }, + { + qualifiedName: 'A::Report', + name: 'Report', + origin: { file: 'a.sysml', offset: 0, length: 4 }, + }, + { qualifiedName: 'Lib::Guide', name: 'Guide' }, + ]; + expect(documentEntries(definitions)).toEqual([ + { id: 'Lib::Guide', name: 'Guide', file: undefined, label: 'Guide' }, + { + id: 'A::Report', + name: 'Report', + file: 'a.sysml', + label: 'A::Report · a.sysml', + }, + { + id: 'B::Report', + name: 'Report', + file: 'b.sysml', + label: 'B::Report · b.sysml', + }, + ]); + expect(documentEntries(undefined)).toEqual([]); +}); + +it('maps clicked elements and content blocks to UTF-16 ranges of the exact applied file', () => { + const element = documentSourceTarget( + { kind: 'element', qualifiedName: 'Demo::motor' }, + rendered, + applied, + applied, + ); + // "Ünit" is two UTF-8 bytes but one UTF-16 unit, so the editor offset is one less. + expect(element).toEqual({ + target: { + file: 'model.sysml', + start: motorOffset - 1, + end: motorOffset - 1 + 'part motor;'.length, + }, + qualifiedName: 'Demo::motor', + }); + expect( + documentSourceTarget( + { kind: 'content', path: 'intro' }, + rendered, + applied, + applied, + ), + ).toEqual({ target: { file: 'report.sysml', start: 17, end: 43 } }); +}); + +it('explains library, unknown, drafted and out-of-range targets instead of guessing', () => { + expect( + documentSourceTarget( + { kind: 'element', qualifiedName: 'ScalarValues::Real' }, + rendered, + applied, + applied, + ), + ).toHaveProperty('reason'); + expect( + documentSourceTarget( + { kind: 'element', qualifiedName: 'Demo::missing' }, + rendered, + applied, + applied, + ), + ).toHaveProperty('reason'); + expect( + documentSourceTarget( + { kind: 'content', path: 'intro/missing' }, + rendered, + applied, + applied, + ), + ).toHaveProperty('reason'); + const drafted = applied.map((file) => + file.name === 'model.sysml' ? { ...file, text: file.text + ' ' } : file, + ); + expect( + documentSourceTarget( + { kind: 'element', qualifiedName: 'Demo::motor' }, + rendered, + applied, + drafted, + ), + ).toEqual({ + reason: + 'Apply or discard this file’s draft before revealing its applied source.', + }); + expect( + documentSourceTarget( + { kind: 'element', qualifiedName: 'Demo::motor' }, + rendered, + applied.filter((file) => file.name !== 'model.sysml'), + applied, + ), + ).toHaveProperty('reason'); +}); + +it('accepts only the requested revision, document and form with in-bounds origins', () => { + expect( + validateRenderedDocument(rendered, 'hash', 'Report::R', 'html', applied), + ).toEqual(rendered); + expect(() => + validateRenderedDocument(rendered, 'other', 'Report::R', 'html', applied), + ).toThrow(/stale/); + expect(() => + validateRenderedDocument( + rendered, + 'hash', + 'Report::Other', + 'html', + applied, + ), + ).toThrow(/stale/); + expect(() => + validateRenderedDocument( + rendered, + 'hash', + 'Report::R', + 'markdown', + applied, + ), + ).toThrow(/stale/); + expect(() => + validateRenderedDocument( + { ...rendered, status: 'invalid' }, + 'hash', + 'Report::R', + 'html', + applied, + ), + ).toThrow(/cannot publish content/); + expect(() => + validateRenderedDocument( + { ...rendered, status: 'invalid', content: '', diagnostics: [] }, + 'hash', + 'Report::R', + 'html', + applied, + ), + ).toThrow(/omitted its diagnostics/); + expect(() => + validateRenderedDocument( + { + ...rendered, + references: [ + { + qualifiedName: 'Demo::motor', + kind: 'partUsage', + origin: { file: 'model.sysml', offset: 40, length: 40 }, + }, + ], + }, + 'hash', + 'Report::R', + 'html', + applied, + ), + ).toThrow(/outside the applied project/); +}); + +it('turns typed planning and evaluation failures into located diagnostics', () => { + const failed: RenderedDocument = { + ...rendered, + status: 'invalid', + content: '', + diagnostics: [ + { + stage: 'planning', + kind: 'unknownQuery', + severity: 'error', + message: 'content parts references unknown query Missing', + file: 'report.sysml', + line: 4, + }, + { + stage: 'evaluation', + kind: 'queryExecution', + severity: 'error', + message: 'query failed', + }, + ], + }; + expect(documentDiagnostics(failed)).toEqual([ + { + severity: 'error', + message: + 'Document Report::R: content parts references unknown query Missing (planning unknownQuery)', + file: 'report.sysml', + line: 4, + }, + { + severity: 'error', + message: 'Document Report::R: query failed (evaluation queryExecution)', + }, + ]); + expect(documentDiagnostics(null)).toEqual([]); +}); diff --git a/src/lib/workspace/documents/index.ts b/src/lib/workspace/documents/index.ts new file mode 100644 index 0000000..7e78a0f --- /dev/null +++ b/src/lib/workspace/documents/index.ts @@ -0,0 +1,219 @@ +import type { Static } from 'typebox'; +import type { ReadonlyData } from '$lib/contracts/validation'; +import type { + Diagnostic, + DocumentDefinition, + SourceDocument, + SourceLocation, +} from '../project'; +import { + DocumentFormSchema, + DocumentReferenceSchema, + DocumentContentSchema, + DocumentDiagnosticSchema, + RenderedDocumentSchema, + readRenderedDocument, +} from './schema'; +export { RenderedDocumentSchema, readRenderedDocument } from './schema'; +export type DocumentForm = Static; +export type DocumentReference = ReadonlyData< + Static +>; +export type DocumentContent = ReadonlyData< + Static +>; +export type DocumentDiagnostic = ReadonlyData< + Static +>; +export type RenderedDocument = ReadonlyData< + Static +>; + +/** Renders one document definition of an already parsed model revision. */ +export interface DocumentEngine { + renderDocument( + documents: readonly SourceDocument[], + modelHash: string, + name: string, + form: DocumentForm, + signal?: AbortSignal, + ): Promise; +} + +function withinSources( + origin: SourceLocation | undefined, + sizes: ReadonlyMap, +) { + if (!origin) return true; + const size = sizes.get(origin.file); + const nameLength = origin.nameLength ?? 0; + return ( + size !== undefined && + origin.offset + origin.length <= size && + (nameLength === 0 || + (origin.nameOffset !== undefined && + origin.nameOffset >= origin.offset && + origin.nameOffset + nameLength <= origin.offset + origin.length)) + ); +} + +function sourceSizes(documents: readonly SourceDocument[]) { + return new Map( + documents.map((file) => [ + file.name, + new TextEncoder().encode(file.text).length, + ]), + ); +} + +/** Accept only the requested document of the requested revision, with origins inside the applied sources. */ +export function validateRenderedDocument( + value: unknown, + modelHash: string, + name: string, + form: DocumentForm, + documents: readonly SourceDocument[], +): RenderedDocument { + const result = readRenderedDocument(value); + if ( + result.modelHash !== modelHash || + result['document'] !== name || + result.form !== form + ) + throw new Error('Invalid or stale document response.'); + if (result.status === 'complete' && !result.content) + throw new Error('Complete document rendering omitted its content.'); + if (result.status !== 'complete' && result.content) + throw new Error('Incomplete document rendering cannot publish content.'); + if (result.status !== 'complete' && !result.diagnostics.length) + throw new Error('Incomplete document rendering omitted its diagnostics.'); + const sizes = sourceSizes(documents); + for (const item of [...result.references, ...result.contents]) + if (!withinSources(item.origin, sizes)) + throw new Error( + 'Document source correspondence is outside the applied project.', + ); + return result; +} + +/** Document definitions are listed by the engine with the file declaring them. */ +export function validateDocumentDefinitions( + definitions: readonly DocumentDefinition[], + documents: readonly SourceDocument[], +) { + const sizes = sourceSizes(documents); + const seen = new Set(); + for (const item of definitions) { + if (seen.has(item.qualifiedName)) + throw new Error('Document definitions have duplicate identities.'); + seen.add(item.qualifiedName); + if (!withinSources(item.origin, sizes)) + throw new Error( + 'Document declaration is outside the applied project sources.', + ); + } + return definitions; +} + +export interface DocumentEntry { + readonly id: string; + readonly name: string; + readonly file?: string; + readonly label: string; +} + +/** Explorer rows: declared documents sorted by name, then qualified name, labelled with their declaring file. */ +export function documentEntries( + definitions: readonly DocumentDefinition[] | undefined, +): DocumentEntry[] { + const counts = new Map(); + for (const item of definitions ?? []) + counts.set(item.name, (counts.get(item.name) ?? 0) + 1); + return (definitions ?? []) + .map((item) => ({ + id: item.qualifiedName, + name: item.name, + file: item.origin?.file, + label: + (counts.get(item.name) === 1 ? item.name : item.qualifiedName) + + (item.origin ? ` · ${item.origin.file}` : ''), + })) + .sort( + (a, b) => + a.name.localeCompare(b.name) || + a.id.localeCompare(b.id) || + (a.file ?? '').localeCompare(b.file ?? ''), + ); +} + +/** Diagnostics for the Issues surface; typed kinds stay visible in the message. */ +export function documentDiagnostics( + rendered: RenderedDocument | null | undefined, +): Diagnostic[] { + return (rendered?.diagnostics ?? []).map((item) => ({ + severity: item.severity, + message: `Document ${rendered!['document']}: ${item.message} (${item.stage} ${item.kind})`, + ...(item.file ? { file: item.file } : {}), + ...(item.line !== undefined ? { line: item.line } : {}), + })); +} + +export type DocumentClick = + | { kind: 'element'; qualifiedName: string } + | { kind: 'content'; path: string }; +export type DocumentSourceTarget = + | { + target: { file: string; start: number; end: number }; + qualifiedName?: string; + } + | { reason: string }; + +/** Convert a recorded UTF-8 origin to UTF-16 editor offsets in the matching applied file. */ +export function documentSourceTarget( + click: DocumentClick, + rendered: RenderedDocument, + applied: readonly SourceDocument[], + drafts: readonly SourceDocument[], +): DocumentSourceTarget { + let origin: SourceLocation | undefined, qualifiedName: string | undefined; + if (click.kind === 'element') { + const reference = rendered.references.find( + (item) => item.qualifiedName === click.qualifiedName, + ); + if (!reference) + return { reason: 'This reference is not part of the rendered document.' }; + origin = reference.origin; + qualifiedName = reference.qualifiedName; + } else { + const content = rendered.contents.find((item) => item.path === click.path); + if (!content) + return { reason: 'This block is not part of the rendered document.' }; + origin = content.origin; + } + if (!origin) + return { + reason: + 'This element is declared outside the project sources (for example in a library).', + }; + const file = applied.find((item) => item.name === origin.file); + const draft = drafts.find((item) => item.name === origin.file); + if (!file || !draft) + return { reason: 'The declaring source file is not in the project.' }; + if (file.text !== draft.text) + return { + reason: + 'Apply or discard this file’s draft before revealing its applied source.', + }; + const bytes = new TextEncoder().encode(file.text); + if (origin.offset + origin.length > bytes.length) + return { reason: 'The recorded source range is outside the file.' }; + const decoder = new TextDecoder('utf-8', { fatal: true }); + const start = decoder.decode(bytes.slice(0, origin.offset)).length; + const end = decoder.decode( + bytes.slice(0, origin.offset + origin.length), + ).length; + return { + target: { file: file.name, start, end }, + ...(qualifiedName ? { qualifiedName } : {}), + }; +} diff --git a/src/lib/workspace/documents/schema.ts b/src/lib/workspace/documents/schema.ts new file mode 100644 index 0000000..5fe1521 --- /dev/null +++ b/src/lib/workspace/documents/schema.ts @@ -0,0 +1,60 @@ +import Type from 'typebox'; +import { validator } from '$lib/contracts/validation'; +import { SourceLocationSchema } from '../project/schema'; +export { DocumentDefinitionSchema } from '../project/schema'; +const closed = { additionalProperties: false }; +export const DocumentFormSchema = Type.Union([ + Type.Literal('html'), + Type.Literal('markdown'), +]); +export const DocumentReferenceSchema = Type.Object( + { + qualifiedName: Type.String({ minLength: 1 }), + kind: Type.String(), + origin: Type.Optional(SourceLocationSchema), + }, + closed, +); +export const DocumentContentSchema = Type.Object( + { + path: Type.String({ minLength: 1 }), + name: Type.String(), + kind: Type.String(), + view: Type.Optional(Type.String()), + origin: Type.Optional(SourceLocationSchema), + }, + closed, +); +export const DocumentDiagnosticSchema = Type.Object( + { + stage: Type.String({ minLength: 1 }), + kind: Type.String({ minLength: 1 }), + severity: Type.String({ minLength: 1 }), + message: Type.String(), + ['document']: Type.Optional(Type.String()), + content: Type.Optional(Type.String()), + query: Type.Optional(Type.String()), + file: Type.Optional(Type.String()), + line: Type.Optional(Type.Integer({ minimum: 1 })), + }, + closed, +); +export const RenderedDocumentSchema = Type.Object( + { + schemaVersion: Type.Literal(1), + modelHash: Type.String({ minLength: 1 }), + ['document']: Type.String({ minLength: 1 }), + form: DocumentFormSchema, + status: Type.Union([Type.Literal('complete'), Type.Literal('invalid')]), + title: Type.Optional(Type.String()), + content: Type.String(), + references: Type.Array(DocumentReferenceSchema), + contents: Type.Array(DocumentContentSchema), + diagnostics: Type.Array(DocumentDiagnosticSchema), + }, + closed, +); +export const readRenderedDocument = validator( + RenderedDocumentSchema, + 'Invalid engine document response', +); diff --git a/src/lib/workspace/project/index.ts b/src/lib/workspace/project/index.ts index 92e01b4..665324f 100644 --- a/src/lib/workspace/project/index.ts +++ b/src/lib/workspace/project/index.ts @@ -6,6 +6,7 @@ import { SourceLocationSchema, ModelElementSchema, ModelSnapshotSchema, + DocumentDefinitionSchema, readSourceDocuments, } from './schema'; export { SourceDocumentsSchema, readModelSnapshot } from './schema'; @@ -14,6 +15,9 @@ export type Diagnostic = ReadonlyData>; export type SourceLocation = ReadonlyData>; export type ModelElement = ReadonlyData>; export type ModelSnapshot = ReadonlyData>; +export type DocumentDefinition = ReadonlyData< + Static +>; export const sourceLimits = { files: 20, bytes: 5 * 1024 * 1024 } as const; export function validateDocuments(input: unknown): SourceDocument[] { const sources = readSourceDocuments(input); diff --git a/src/lib/workspace/project/schema.ts b/src/lib/workspace/project/schema.ts index 4b7b22a..8b8ba27 100644 --- a/src/lib/workspace/project/schema.ts +++ b/src/lib/workspace/project/schema.ts @@ -35,11 +35,22 @@ export const ModelElementSchema = Type.Object( }, closed, ); +export const DocumentDefinitionSchema = Type.Object( + { + qualifiedName: Type.String({ minLength: 1 }), + name: Type.String(), + origin: Type.Optional(SourceLocationSchema), + }, + closed, +); export const ModelSnapshotSchema = Type.Object( { modelHash: Type.String(), elements: Type.Array(ModelElementSchema, { maxItems: 2000 }), diagnostics: Type.Array(DiagnosticSchema), + documents: Type.Optional( + Type.Array(DocumentDefinitionSchema, { maxItems: 200 }), + ), }, closed, ); diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts new file mode 100644 index 0000000..ae557f8 --- /dev/null +++ b/tests/e2e/documents.spec.ts @@ -0,0 +1,176 @@ +import { test, expect, copySourceSelection } from './support'; + +/** Open the bundled rover and its generated report through the real pinned engine. */ +async function openRoverReport(page: import('@playwright/test').Page) { + await page.goto('/app'); + await page.getByRole('button', { name: 'Load example', exact: true }).click(); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('RoverViews::architecture'); + const explorer = page.getByRole('complementary', { + name: 'Project navigation', + exact: true, + }); + const documents = explorer.getByRole('region', { + name: 'Documents', + exact: true, + }); + const report = documents.getByRole('button', { + name: 'InspectionReport', + exact: true, + }); + await expect(report).toHaveAttribute( + 'title', + 'RoverReport::InspectionReport', + ); + await expect(report).toContainText('report.sysml'); + await report.click(); + await expect(report).toHaveAttribute('aria-current', 'page'); + const pane = page.getByRole('region', { name: 'Document', exact: true }); + await expect( + pane.getByRole('heading', { name: 'Inspection Rover Report', exact: true }), + ).toBeVisible(); + return { explorer, pane }; +} + +test('@critical renders the rover report from the engine, reveals clicked declarations in source and mounts the architecture view natively', async ({ + page, +}) => { + const { pane } = await openRoverReport(page); + await expect( + page.getByRole('button', { name: 'Document', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + // Engine HTML keeps its semantic classes and model facts. + const body = pane.locator('.document-body'); + await expect(body.locator('article.sysml-document')).toHaveAttribute( + 'data-document', + 'RoverReport::InspectionReport', + ); + await expect( + body.getByRole('heading', { name: 'Energy', exact: true }), + ).toBeVisible(); + const batteryRow = body.locator( + 'tr.sysml-row[data-element="RoverSystem::InspectionRover::energy::battery"]', + ); + await expect(batteryRow.getByRole('cell').nth(0)).toHaveText('battery'); + await expect(batteryRow.getByRole('cell').nth(1)).toHaveText( + 'RoverComponents::Battery', + ); + await expect( + body + .locator('tr.sysml-row[data-element="RoverRequirements::speed"]') + .getByRole('cell') + .nth(1), + ).toHaveText('Limit the left drive nominal indoor speed to 1.5 m/s.'); + await expect(body.locator('script, style, img, iframe')).toHaveCount(0); + await expect(body.locator('pre.mermaid, pre.dot')).toHaveCount(0); + await expect(body).not.toContainText('digraph'); + + // A Diagram block that embeds a declared view mounts the native renderer. + const embedded = pane.getByRole('group', { + name: 'Embedded diagram RoverViews::architecture', + exact: true, + }); + await expect(embedded).toHaveAttribute('data-state', 'rendered'); + await expect( + embedded.getByRole('group', { + name: 'part battery : Battery', + exact: true, + }), + ).toBeVisible(); + await expect( + embedded.getByRole('button', { name: 'Open as named view', exact: true }), + ).toBeEnabled(); + await expect( + embedded.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveCount(0); + await embedded.scrollIntoViewIfNeeded(); + await page.screenshot({ + path: test.info().outputPath('rover-document.png'), + fullPage: false, + }); + + // The inline reference reveals the referenced declaration in the source editor. + await body.getByRole('link', { name: 'rover', exact: true }).click(); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await expect( + page + .getByRole('complementary', { name: 'Project navigation', exact: true }) + .getByRole('button', { name: 'system.sysml', exact: true }), + ).toHaveAttribute('aria-current', 'page'); + await expect + .poll(() => copySourceSelection(source)) + .toBe('part rover : InspectionRover;'); + + // A table row reveals its element; a heading reveals the section declaration in the report. + await batteryRow.click(); + await expect + .poll(() => copySourceSelection(source)) + .toBe('part battery : Battery;'); + await body.getByRole('heading', { name: 'Mobility', exact: true }).click(); + await expect( + page + .getByRole('complementary', { name: 'Project navigation', exact: true }) + .getByRole('button', { name: 'report.sysml', exact: true }), + ).toHaveAttribute('aria-current', 'page'); + await expect + .poll(() => copySourceSelection(source)) + .toMatch(/^part mobility : Section \{[\s\S]*\}$/); + + // Opening the embedded view as a named view returns to the diagram surface. + await embedded + .getByRole('button', { name: 'Open as named view', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Diagram view', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect( + page.getByRole('combobox', { name: 'Named view', exact: true }), + ).toHaveValue('RoverViews::architecture'); +}); + +test('re-renders the open document from the applied revision and reports typed planning failures in Issues', async ({ + page, +}) => { + const { pane } = await openRoverReport(page); + const source = page.getByRole('textbox', { + name: 'SysML source', + exact: true, + }); + await page + .getByRole('complementary', { name: 'Project navigation', exact: true }) + .getByRole('button', { name: 'report.sysml', exact: true }) + .click(); + await source.press('ControlOrMeta+a'); + const text = await copySourceSelection(source); + await source.press('ControlOrMeta+a'); + await page.keyboard.insertText( + text.replace( + 'attribute redefines title = "Energy";', + 'attribute redefines title = "Power";', + ), + ); + await page.getByRole('button', { name: 'Apply source', exact: true }).click(); + await expect( + pane.getByRole('heading', { name: 'Power', exact: true }), + ).toBeVisible(); + await expect(pane.getByRole('heading', { name: 'Energy' })).toHaveCount(0); + + // A missing query makes the revision invalid: the pane keeps the last document and Issues locate the block. + await source.press('ControlOrMeta+a'); + await page.keyboard.insertText( + text.replace(/calc rows : Subparts \{\s*in root = rover\.energy;\s*\}/, ''), + ); + await page.getByRole('button', { name: 'Apply source', exact: true }).click(); + const issues = page.getByRole('region', { name: 'Issues', exact: true }); + await expect(issues).toContainText('references no query'); + await expect( + issues.getByRole('button', { name: /Open report\.sysml at line \d+/ }), + ).toBeVisible(); + await expect( + pane.getByRole('heading', { name: 'Power', exact: true }), + ).toBeVisible(); +}); diff --git a/tests/e2e/rover.spec.ts b/tests/e2e/rover.spec.ts index 6a6b4d6..5f6af29 100644 --- a/tests/e2e/rover.spec.ts +++ b/tests/e2e/rover.spec.ts @@ -19,7 +19,7 @@ async function rover(page: import('@playwright/test').Page) { await waitForRoverStartup(page); } -test('@critical opens the inspection rover with inherited declared values and exact connections, preserving all six files across its guided tour', async ({ +test('@critical opens the inspection rover with inherited declared values and exact connections, preserving all seven files across its guided tour', async ({ page, }) => { const results = await observeEvaluations(page); @@ -86,7 +86,7 @@ test('@critical opens the inspection rover with inherited declared values and ex ) .toBe(10); const saved = await downloadProject(page); - expect(saved.bundle.documents).toHaveLength(6); + expect(saved.bundle.documents).toHaveLength(7); for (const file of saved.bundle.documents) expect(file.text).toBe( await readFile( diff --git a/tests/e2e/shell-entry.spec.ts b/tests/e2e/shell-entry.spec.ts index a8fc923..4543d2a 100644 --- a/tests/e2e/shell-entry.spec.ts +++ b/tests/e2e/shell-entry.spec.ts @@ -110,7 +110,7 @@ test('@critical landing and direct example links open the real model once and re await expect(page).toHaveURL(/\/app$/); const bundle = (await downloadProject(page)).bundle; expect(bundle.name).toBe('Inspection rover'); - expect(bundle.documents).toHaveLength(6); + expect(bundle.documents).toHaveLength(7); expect(bundle.drafts).toEqual(bundle.documents); await page.reload(); await waitForLauncher(page); @@ -579,7 +579,7 @@ test('a newer example opening supersedes a held real engine request without publ await expect(page.getByRole('alert')).not.toBeVisible(); const bundle = (await downloadProject(page)).bundle; expect(bundle.name).toBe('Inspection rover'); - expect(bundle.documents).toHaveLength(6); + expect(bundle.documents).toHaveLength(7); for (const document of bundle.documents) { expect(document.text).toBe( await readFile( diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png index 1dcf45c..fc4b0eb 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png index 9739ad7..5a19882 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-overview-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png index 88de4c3..048e53c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png index 481a90b..a5f6a25 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/assembly-exploration-structure-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png index 40ebb13..5c105b8 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png index efc5d47..a5c6099 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/default-rover-architecture-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png index 2a95cff..9a55f7e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png index 819b1ce..8977161 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png index 98fe280..5fd2c92 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png index dbf8815..ae58b62 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/diagnostics-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png index 2fcdd91..a7b58ba 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png index 5ddf11b..cec463e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/exploration-overview-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png index f608273..de95336 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png index eaa3890..925e432 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/exploration-structure-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png index 25b31a0..09df9a6 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png index 5c2e27b..a1411f6 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/initial-exploration-retry-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/model-tree-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/model-tree-dark-visual-linux.png index e9ab28c..98babbc 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/model-tree-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/model-tree-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/model-tree-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/model-tree-light-visual-linux.png index 79724e0..9651623 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/model-tree-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/model-tree-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png index 723b1f6..b0057c6 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png index 2014210..b0d4b77 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png index 2b3e7aa..7e02448 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png index 0d23e08..45bfb4b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-overview-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png index d697971..8a88cf4 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png index f192c35..5a6ce87 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png index 8ed2218..9123940 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png index a33355b..98b9d94 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/requirements-table-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png index e51568b..18239e0 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png index aeea1db..f35283c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png index 1af55d0..e78265a 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png index 1c1dda7..ef8a078 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-lifecycle-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png index 666768b..eeda10d 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png index 20de124..17fe72f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png index 97a6e70..83d4596 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png index c1e7203..666b06f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-mission-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png index 9a4add0..892305f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png index 021fbbd..174793c 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png index 56b6409..129cae2 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png index 56b9ef6..a35cfec 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/rover-requirements-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-narrow-visual-linux.png index be3ca46..1d71619 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png index 7ec7c57..90be168 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-narrow-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-narrow-visual-linux.png index 4e259fa..5ab95b4 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-narrow-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-narrow-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png index 103300c..98c0cd6 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/symbol-information-light-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png index facd098..7d229f1 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png index 32c5994..75ae398 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-dark-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png index 7a9c1ca..1f4983b 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-1440-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png index 662bfd4..b546bd5 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/traceability-matrix-light-768-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png index 725bcc1..72b9a61 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/workbench-dark-visual-linux.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png b/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png index 0f3cde7..63bbeea 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png and b/tests/e2e/visual.spec.ts-snapshots/workbench-light-visual-linux.png differ