From 8bf8e22448e48e1d15f2ab5170341bbe8e04fccb Mon Sep 17 00:00:00 2001 From: "M. Pabiszczak" <2+mpabi@noreply.zsl-gitea.mpabi.pl> Date: Sun, 19 Jul 2026 16:43:35 +0200 Subject: [PATCH] feat: publish A1-A8 card viewer and FreeRTOS C generator --- docs/CARD.md | 60 +- docs/OFFLINE-FIRST-DEPLOYMENT.md | 164 ++ docs/UML-VIEWPOINTS.md | 725 +++++++++ react/src/main.tsx | 2558 ++++++++++++++++++++++++++++-- react/src/react.css | 1614 +++++++++++++++++-- schemas/card-source.schema.json | 195 ++- tools/build_freertos_c_card.py | 419 +++++ tools/render_card.py | 915 ++++++++++- web/app.css | 1 + web/app.js | 61 + 10 files changed, 6394 insertions(+), 318 deletions(-) create mode 100644 docs/OFFLINE-FIRST-DEPLOYMENT.md create mode 100644 docs/UML-VIEWPOINTS.md create mode 100755 tools/build_freertos_c_card.py create mode 100644 web/app.css create mode 100644 web/app.js diff --git a/docs/CARD.md b/docs/CARD.md index 5cb3723..d8377c3 100644 --- a/docs/CARD.md +++ b/docs/CARD.md @@ -45,6 +45,17 @@ której powstaje `https:///`. `show_repository_qr: true` wymaga `repository_url`. Adres podglądu `localhost` nie powinien być publikowany na karcie. +### Publikacja lokalna i globalna + +UUID hosta/workspace oraz UUID karty są stabilną tożsamością zasobu, a nie +tożsamością konkretnego serwera. Viewer podczas zajęć otwiera kartę przez +względny adres lokalnego ingressu K3s; kanoniczny adres globalny pozostaje w +metadanych, QR i udostępnianych odsyłaczach. Utrata Internetu nie może blokować +katalogu, przełączania kart, API sterowania, symulatora, dowodów ani raportu. + +Pełny kontrakt topologii, routingu, magazynowania i przyszłej synchronizacji +opisuje [OFFLINE-FIRST-DEPLOYMENT.md](OFFLINE-FIRST-DEPLOYMENT.md). + ### Status i wersja tasków w zakresie karty Wiersze `front_page_scope.scope_table.rows[]` mogą opisywać stan opracowania @@ -180,10 +191,51 @@ rozmiaru: Jeśli każda figura ma odpowiadać osobnej stronie wydruku, sekcja deklaruje `"asset_page_mode": "one-per-page"`. Generator wtedy wstawia jawne podziały -stron w TeX i buduje osobne płótna A4 w React/HTML. Pierwsza strona zachowuje -nagłówek sekcji, a kolejne są stronami kontynuacji z powtórzoną tabliczką -zasobu. Widok ekranowy rozdziela płótna odstępem `5cm`; reguła nie wpływa na -wydruk. +stron w TeX i buduje osobne płótna A4 w React/HTML. Diagram interaktywny sam +renderuje zwarty, jednakowy nagłówek na każdej części; zewnętrzny nagłówek +sekcji nie przesuwa pierwszego kafla względem pozostałych. + +Orientację wybiera sekcja przez `"page_orientation": "portrait"` albo +`"landscape"`. Jest to własność fizycznej strony, a nie zamiennik dla +`page_grid`: pojedynczy szeroki diagram może dostać landscape, pionowa +hierarchia portrait, a dopiero model nieczytelny po właściwym dobraniu +orientacji otrzymuje jawne złożenie kilku stron. + +Diagram większy od bezpiecznego pola A4 deklaruje `page_grid` przy assetcie: + +```json +"page_grid": { + "columns": 2, + "rows": 2, + "cuts_x": [660], + "cuts_y": [440], + "page_width": 660, + "page_height": 760, + "step_tiles": {"class-base": 1, "runtime-proof": 4} +} +``` + +`cuts_x` i `cuts_y` są współrzędnymi SVG, a `step_tiles` przypisuje każdy krok +interaktywny dokładnie do jednego fizycznego kafla (numeracja wierszowa od 1). +Generator odrzuca kafel większy od pola A4 oraz szew przecinający zamknięty +blok UML. React może złożyć strony w `spread_group`, zachowując jeden kursor i +jedną numerację rysunku; wydruk nadal zawiera niezależne kartki A4. + +Włączony spread jest automatycznie dopasowywany do szerokości widoku, tak aby +wszystkie strony jednego wiersza i ich łączenia były widoczne równocześnie. +Nagłówki stron pokazują pulsujące kierunki kontynuacji `← ↑ ↓ →`; środkowa +strona szeregu może wskazywać obie strony, a złożenie wielowierszowe także górę +i dół. Wskaźniki są wyłącznie nawigacją ekranową i znikają w druku. + +### Boczne szyny paneli + +Narzędzia pomocnicze HTML używają wspólnego systemu paneli bocznych zamiast +modalnej zasłony. Cienka kolorowa szyna otwiera panel w trybie `Auto` po +najechaniu; kliknięcie przełącza go w `Fixed`. Kolejny klik, przycisk zamknięcia +albo `Esc` odpina panel. Renderer może wystawić kilka niezależnych szyn po obu +stronach (obecnie: katalog i spis treści po lewej, przebieg i raport po prawej). +Panele nakładają się na widok tylko podczas pracy i są zawsze usuwane z +wydruku. Nie zmieniają geometrii stron A4 ani kafelków diagramu. Końcowy słownik jest domyślnie obecny w React/HTML dla zgodności. Karta, która ma zawierać wyłącznie jawnie wymienione strony, ustawia na poziomie głównym diff --git a/docs/OFFLINE-FIRST-DEPLOYMENT.md b/docs/OFFLINE-FIRST-DEPLOYMENT.md new file mode 100644 index 0000000..8cb995c --- /dev/null +++ b/docs/OFFLINE-FIRST-DEPLOYMENT.md @@ -0,0 +1,164 @@ +# Offline-first local and global deployment + +## Architecture decision + +The teaching application must remain fully operational when the Internet +connection is unavailable. The local K3s cluster is therefore the primary +runtime during a lesson. The global installation is a replica, publication +endpoint and later synchronization peer; it is not a runtime dependency of a +lesson. + +The development server on `http://localhost:8080` represents the local ingress +contract. Browser code must use relative, same-origin application and API URLs +so that the same generated bundle can run behind the development server, local +K3s ingress or the global ingress without rebuilding the card. + +## Stable identity is not a transport address + +A resource is identified by stable values: + +```text +workspace/site UUID + card UUID + version + content digest +``` + +These values must not change when the resource moves between local and global +deployments. A URL is only one endpoint resolving that identity. + +The target catalogue contract is: + +```json +{ + "resource": { + "site_uuid": "", + "card_uuid": "", + "version": "v00.04", + "digest": "sha256:..." + }, + "endpoints": { + "local": "/cards///", + "canonical": "https://///" + } +} +``` + +The internal card tree navigates to `endpoints.local`. The canonical endpoint +is retained for publication metadata, QR codes, external sharing and global +synchronization. It must not be the only way to open another card from a local +viewer. + +An installation may additionally use split-horizon DNS so the canonical host +resolves to the local ingress on the school network and to the global ingress +outside it. Correctness must not depend on split DNS, however: the relative +local route remains the explicit offline path. + +## Local K3s runtime + +The local cluster owns every service required during a lesson: + +```text +browser + -> local DNS / K3s ingress + -> card catalogue and resource resolver + -> generated HTML, SVG, TeX and downloadable artefacts + -> card control API and navigation state + -> Neovim / simulator bridge + -> progress, event outbox and evidence storage + -> local Gitea and container registry where the lesson needs them +``` + +No lesson-critical asset may be loaded at runtime from a CDN, public font +service, GitHub, a public package registry or the global STEM instance. +Dependencies, container images and generated card releases are synchronized to +the local cluster before the lesson. + +Recommended K3s responsibilities are: + +- Traefik or an equivalent ingress as the single browser entry point; +- a catalogue/resolver mapping resource UUIDs to local immutable releases; +- local object storage for generated artefacts, screenshots and reports; +- a durable database for catalogue, progress and synchronization metadata; +- persistent volumes for card state, evidence and the event outbox; +- local DNS and pre-provisioned TLS material; +- a local OCI registry and Gitea mirror for reproducible laboratory work. + +A browser service worker may cache the application shell and the last opened +cards, but it is an additional resilience layer. It does not replace the local +server, control API or durable K3s storage. + +## Global runtime + +The global deployment exposes the same resource identity and compatible API +schemas. It provides: + +- externally reachable published cards and QR targets; +- long-term storage of accepted reports and evidence; +- upstream card releases and catalogue metadata; +- cross-site administration and backup; +- the synchronization peer for one or more local teaching sites. + +Local and global installations may have different origins. Generated cards +must therefore contain no environment-specific API origin in JavaScript. + +## Navigation contract + +Clicking a card in the tree follows this policy: + +1. resolve its stable resource identity in the local catalogue; +2. open the relative local route on the current origin; +3. use a locally cached immutable release when the active release is already + present but the catalogue backend is temporarily restarting; +4. offer the canonical global endpoint only as an explicit fallback when a + network is available; +5. show a clear `PLAN / no release` state when no local or global release + exists—never silently open an unrelated card. + +Changing cards must not require starting another development HTTP server or +changing ports. One ingress serves the catalogue and routes all card releases. + +## Synchronization boundary + +Synchronization is deliberately separate from lesson execution. Its data model +must be designed now even if the transport is implemented later. + +Card releases are immutable and content-addressed. They normally synchronize +from global to local. Classroom work synchronizes from local to global through +an append-only outbox containing globally unique, idempotent events: + +```text +event_id, site_id, actor_id, resource identity, local sequence, +recorded_at, payload schema, payload digest +``` + +Progress changes, navigation checkpoints, screenshots, report annotations and +final report submissions are separate event types. Retrying a transfer cannot +duplicate an event. Binary evidence is uploaded by digest and referenced from +events. + +Conflict rules are explicit: + +- immutable card releases never merge; a new digest is a new release; +- append-only evidence never overwrites another site event; +- mutable progress uses its event history and actor/site identity rather than + blind last-write-wins; +- catalogue activation is an administrative decision with a recorded base + revision; +- rejected or incompatible events remain in a visible quarantine queue. + +The local installation displays synchronization health but never blocks a +lesson because the global peer is offline. + +## Offline acceptance test + +A release is ready for classroom use only when the following test passes: + +1. preload the selected card series, images and containers into local K3s; +2. disconnect the Internet uplink while preserving the classroom LAN; +3. open the catalogue through the local ingress; +4. switch between all released cards without leaving the local origin; +5. run Neovim, simulator checkpoints and diagram navigation; +6. record progress, timestamps, screenshots and a report; +7. restart the browser and one application pod and recover the saved state; +8. reconnect the uplink and verify idempotent synchronization without data + loss or duplicated evidence. + +Passing only an online browser test is not sufficient. diff --git a/docs/UML-VIEWPOINTS.md b/docs/UML-VIEWPOINTS.md new file mode 100644 index 0000000..48cdd87 --- /dev/null +++ b/docs/UML-VIEWPOINTS.md @@ -0,0 +1,725 @@ +# A1–A8 Interactive Architecture Viewpoints + +Status: design contract, version 0.2, 2026-07-18. + +This document defines how STEM cards describe architecture, code and runtime +evidence. It is a house convention, not a new UML standard. The convention is +organized in the spirit of ISO/IEC/IEEE 42010:2022, uses UML 2.5.1 notation, +adds selected MARTE 1.3 concepts for real-time systems, and uses PlantUML as +the textual source of rendered diagrams. + +## Scope and rollout + +A1–A8 is runtime-independent. The fixed viewpoints are intended to support +embedded RTOS software, ordinary C++ object models, POSIX or standard threads, +and asynchronous Boost.Asio applications without changing the navigation +grammar. + +The rollout is deliberately staged: + +1. **current application:** FreeRTOS C++ cards on RV32I/Hazard3; +2. **later application:** `std::thread`/`std::jthread` and Boost.Asio; +3. **not yet scheduled:** concrete Asio/thread card repositories and lesson + numbering. + +The later profile is specified below so current diagrams do not bake in RTOS- +only assumptions. It is a compatibility target, not a claim that the current +FreeRTOS card already implements or teaches Asio. + +## Standards boundary + +- **ISO/IEC/IEEE 42010:2022** supplies the concepts of architecture + descriptions, viewpoints, views and model kinds. A1–A8 is our concrete set + of viewpoints. We do not claim full ISO conformance merely because a card + uses these labels. +- **UML 2.5.1** supplies the notation for classes, objects, components, + interactions, activities and state machines. It does not prescribe A1–A8. +- **MARTE 1.3** may annotate schedulable resources, time, hardware and + allocation in RTOS cards. A MARTE property must come from configuration, + code or measurement; authors must not invent period, deadline or execution + time merely to fill a box. +- **arc42** is a useful wider documentation template. A1–A8 is the smaller, + interactive teaching slice used inside a card, not a replacement for an + architecture description of the whole product. +- **PlantUML** is the text-first renderer. The `.puml` source, not a manually + edited SVG, is authoritative. + +Primary references: + +- +- +- +- +- +- +- + +## The fixed navigation model + +The tab names remain stable across cards. A subtitle identifies the actual +mechanism being taught. + +```text +A1 CONTEXT +A2 STRUCTURE +A3 DISPATCH +A4 APPLICATION +A5 FLOW +A6 STATE +A7 RUNTIME +A8 PATTERNS +``` + +These eight tabs ask eight different questions: + +| ID | Question | Typical model kind | +|---|---|---| +| A1 CONTEXT | Where is the subject and what lies outside its boundary? | component, package or deployment view | +| A2 STRUCTURE | What static elements and relationships construct it? | class, component or object structure | +| A3 DISPATCH | How is an abstract call bound to the actual implementation? | class plus sequence/call-path view | +| A4 APPLICATION | How does application code specialize or compose it? | application class/object view | +| A5 FLOW | What happens in what order? | sequence or activity view | +| A6 STATE | Which lifecycles and state transitions exist? | state machine, never an unlabeled flowchart | +| A7 RUNTIME | What actually exists in memory and in the debugger now? | object diagram plus evidence overlay | +| A8 PATTERNS | Which design roles and trade-offs explain the construction? | participant map plus decision record | + +Not every card needs all eight views. The top-level navigation keeps all eight +positions stable; an omitted view is visibly unavailable and states why. A +small card should not create decorative diagrams to fill empty tabs. + +## Viewpoint contracts + +### A1 CONTEXT + +**Purpose:** establish system boundary and dependency direction before class +details appear. + +Required content: + +- subject under study; +- upstream application actor or component; +- wrapped platform/API; +- runtime platform, hardware or simulator when relevant; +- one verb on every boundary-crossing relation. + +For an RTOS wrapper this normally reads: + +```text +Application task + │ specializes / uses + ▼ +C++ wrapper + │ calls + ▼ +FreeRTOS C API + │ schedules on + ▼ +Hazard3 simulator +``` + +A1 must not list every method. It is a map, not a class reference. + +### A2 STRUCTURE + +**Purpose:** show the static construction that the compiler sees. + +Required content: + +- class/interface/component names; +- relevant fields and operations only; +- visibility where it is part of the lesson; +- precise relationship type: generalization, realization, composition, + aggregation or dependency; +- storage/ownership role when the structure owns a resource. + +Class rows receive semantic anchors only when a card can connect them to a +source line or a compile-time proof. Inheritance, `friend`, templates and +visibility are CODE facts; they are never presented as runtime events. + +### A3 DISPATCH + +**Purpose:** explain the binding mechanism between the generic boundary and +the concrete implementation. + +The title is fixed; the subtitle is mechanism-specific. Examples: + +```text +A3 DISPATCH · CRTP static dispatch +A3 DISPATCH · virtual dispatch through vtable +A3 DISPATCH · C callback and function pointer +A3 DISPATCH · event to subscriber +A3 DISPATCH · executor and completion handler +``` + +The diagram must identify: + +1. the call origin; +2. the erased or abstract boundary; +3. the cast, lookup or binding operation; +4. the concrete target; +5. whether the decision is compile-time or runtime; +6. the ABI proof that distinguishes the mechanism. + +This viewpoint prevents a wrapper from being mislabeled. A CRTP wrapper must +show `static_cast` and the absence of virtual dispatch. A dynamic +wrapper must show object address, `vptr`, vtable entry and indirect call. + +### A4 APPLICATION + +**Purpose:** show one concrete use without repeating the complete library. + +The generic mechanism is collapsed to its public contract. The view maps every +pattern or framework role to the names used in the exercise, for example +`Strategy -> PaymentStrategy` and `Context -> ShoppingCart`. + +At least one application invariant is printed next to the model. Example: +"`ShoppingCart` depends on the strategy interface, not a payment variant." + +### A5 FLOW + +**Purpose:** show causal order for one named scenario. + +Rules: + +- state the precondition and observable postcondition; +- use sequence diagrams for collaborating participants and activity diagrams + for algorithmic branching; +- number messages in the same order as interactive steps; +- show returns only when they carry teaching information; +- split construction, scheduling and callback dispatch into phases when one + page would hide the boundary. + +A5 is the primary home of RUN steps and deterministic checkpoints. + +### A6 STATE + +**Purpose:** separate lifecycle from execution flow. + +When two state domains exist, draw two explicit lanes rather than merging them. +For a FreeRTOS C++ task these are commonly: + +```text +C++ object lifetime: absent -> constructed -> alive -> destroyed +Kernel task state: Ready <-> Running <-> Blocked; Suspended; Deleted +``` + +Every transition has a trigger, guard or event. Values returned by platform +APIs are platform states, not invented wrapper states. A wrapper-specific state +machine is allowed only when the code really stores and enforces it. + +### A7 RUNTIME + +**Purpose:** connect the model to a reproducible observation. + +A7 is deliberately a hybrid, not pure UML. It combines an object diagram with +selected debugger facts: + +- object and subobject addresses; +- `this`, callback context and handles; +- fields, ownership pointers, TCB and stack interval; +- PC, SP and relevant argument registers; +- vptr/vtable target for dynamic dispatch; +- symbol/absence-of-symbol proof for static dispatch; +- timestamp, target, ELF revision and checkpoint identity. + +Every value is either captured from a deterministic run or marked as a +symbolic example. A screenshot without target, source revision and timestamp is +illustration, not evidence. + +### A8 PATTERNS + +**Purpose:** explain why the design has its shape and what it costs. + +Each claimed pattern records: + +- intent and problem; +- forces/constraints; +- canonical participant roles; +- exact mapping from role to project class/function; +- collaboration that makes the pattern real; +- invariant or test; +- benefit and cost; +- rejected alternative; +- conditions in which the pattern should not be used. + +Pattern names are not decorative stereotypes. `Facade` is valid only when a +simpler boundary to a subsystem can be identified. `RAII` is valid only when +acquisition and release are safely paired for every legal lifetime. A callback +bridge may be an Adapter without making the whole wrapper an Adapter. + +## Authored viewpoint record + +The future machine-readable form should preserve this conceptual record. This +is a design target, not yet a required `card_source.json` schema extension: + +```json +{ + "id": "a3-dispatch", + "number": "A3", + "label": "DISPATCH", + "subtitle": "Dynamic polymorphism through vtable", + "purpose": "Trace the C callback to the concrete C++ override.", + "stakeholders": ["student", "teacher", "embedded developer"], + "concerns": ["C-to-C++ bridge", "void* context", "vtable", "ABI"], + "model_kind": "uml-sequence", + "source": "doc/assets/a3-dispatch.puml", + "fallback_text": "doc/assets/a3-dispatch.txt", + "spread_group": "task-dispatch", + "steps": [ + { + "id": "dispatch-vptr", + "number": 4, + "mode": "RUN", + "svg_target": "dispatch-vptr", + "code_ref": "include/FreeRTOS/Task.hpp:110", + "snapshot_ref": "task.dispatch.vptr", + "evidence": "a0, object address, vptr, vtable entry and jalr target" + } + ] +} +``` + +Required semantics: + +- `id`, `number` and `label` are stable teaching identity; +- `subtitle` names the actual mechanism and may differ between cards; +- `source` is the text source of the rendered asset; +- `fallback_text` is a compact readable map, not generated alt text; +- `code_ref` is repository-relative and validated; +- CODE steps may have no snapshot and never reset the program; +- RUN steps require a deterministic replay target; +- `evidence` says what proves the claim, not merely what should be visible; +- `spread_group` joins logical continuations without changing A4 print pages. + +## Plain technical rendering profile + +"Plain text UML" means two related things in this project: + +1. diagrams are authored as reviewable text; +2. their rendered form resembles a restrained technical drawing rather than a + presentation slide. + +The default profile therefore uses: + +- white background, black/charcoal strokes and no shadows; +- square or nearly square corners; +- one monospaced family for identifiers, fields, methods and evidence; +- one sans-serif family only for explanatory prose; +- orthogonal relations where the renderer can keep labels legible; +- left-aligned members and short, explicit verbs on relations; +- no gradients, icons, ornamental colors or oversized titles; +- line weight and spacing, not color, as the primary print hierarchy; +- visible step anchors such as `01`–`08` that remain useful in SVG and print; +- two muted selectable tones in HTML and one subtle active outline; +- non-selectable content in neutral gray, never confused with a selectable + inactive element. + +PlantUML can generate ASCII/Unicode text output for sequence diagrams. We may +ship that output as the fallback for sequences. Other diagram kinds receive a +small hand-maintained text map because PlantUML's ASCII renderer does not cover +all UML kinds. The fallback communicates topology and reading order; SVG and +TeX remain the visual outputs. + +## Orientation, type scale and page visibility + +The default reading direction is vertical. A horizontal layout is reserved for +a genuine sequence or timeline whose primary fact is execution order over +time. A binding chain is vertical unless time or participant lifelines are +being modelled explicitly. + +| Viewpoint | Default orientation | Reason | +|---|---|---| +| A1 CONTEXT | vertical | responsibility descends from application to platform | +| A2 STRUCTURE | vertical | base types, specializations and dependencies form tiers | +| A3 DISPATCH | vertical | binding stages form a top-to-bottom chain; use horizontal only for a genuine sequence | +| A4 APPLICATION | vertical | application objects lead to observers and invariants | +| A5 FLOW | horizontal | messages and time advance in reading order | +| A6 STATE | vertical | lifecycle transitions form explicit top-to-bottom paths | +| A7 RUNTIME | geometry-dependent | use vertical for an ownership descent, horizontal for a memory/CPU evidence pipeline | +| A8 PATTERNS | geometry-dependent | use vertical for tiers and horizontal for a short chain of roles and consequences | + +Use `top to bottom direction` unless the diagram is a genuine sequence or +timeline (normally A5). A diagram must not be made horizontal merely to fill +page width. + +Physical page orientation follows the rendered model rather than the viewpoint +name: use one portrait page, one landscape page, or an explicit semantic page +composition. Prefer a single page after a deliberate layout change; introduce +tiles only when readable labels and complete semantic blocks still cannot fit. + +The default PlantUML type scale is `14` for ordinary labels, `15` for class +names and `13` for class attributes. A smaller value is allowed only when the +diagram remains readable at the normal A4 viewer scale. Prefer fewer words, +shorter relation labels and a second logical page over shrinking text. + +All diagrams belonging to one card use one shared PlantUML style include. The +style is print-first and keeps the following invariants: + +- one monospaced family for nodes, relations and sequence messages; +- a `190 px` minimum class width, while content may make a node wider; +- `1.5 px` node borders and `1.3 px` arrows; +- a near-white node fill, blue-grey borders and dark text with sufficient + monochrome-print contrast; +- the same base styling for class, state and sequence diagrams; +- no shadows, decorative gradients or per-diagram colour inventions. + +Interactive selection is an overlay owned by the viewer. The source diagram +must remain fully legible with no selection, while selectable nodes receive a +clearer idle treatment than non-interactive context nodes. Selection changes +emphasis, not geometry: it must not resize a node, move a label or alter the +diagram layout. + +At `content scale = 100%`, the HTML viewer preserves the intrinsic PlantUML +scale. A diagram that exceeds the declared A4-safe viewport is split into +semantic tiles; it is not silently compressed to `width: 100%`. The joined +HTML spread reconstructs the logical page in a CSS grid, while ordinary view +and print retain separate physical A4 sheets. Canvas zoom remains an explicit +viewer action and may be used to inspect the joined overview. + +Every diagram starts on a new A4 page in HTML and TeX. Its SVG must fit within +the page-safe margins with all labels, arrowheads and numbered anchors visible. +Long diagrams continue on a new page with stable global step numbers and may be +joined by the HTML spread view. Every physical tile remains legible on its own, +but its `part`, row and column identify its exact position in the logical model. + +## Large diagrams and page continuity + +A viewpoint is one logical model even when it needs several A4 pages. + +- `page_grid` declares columns, rows and explicit semantic `cuts_x`/`cuts_y`; +- a cut may cross whitespace or a connector, but never a class, object, state, + participant, note, numbered anchor or other closed semantic block; +- the generator validates the rendered SVG geometry and rejects a colliding + seam instead of shrinking the diagram or accepting a broken print; +- every interactive step belongs to exactly one tile through `step_tiles`; +- all tiles use one logical figure number and one global navigation cursor; +- each page carries `A`, the same viewpoint title, `part x/y`, and a shared + `spread_group`; +- duplicated participants at a page boundary are marked `continued`, not + treated as new objects; +- step numbers remain global across parts; +- connectors leaving a page use a named continuation token, for example + `D3 -> A5.2`; +- HTML can join the parts side by side; print keeps independent A4 pages; +- the joined HTML view fits the complete logical row into the viewport, keeps + page tops aligned and centers the semantic join rather than either sheet; +- a screen-only continuation cue in every part header indicates available + neighbours to the left, right, above and below; +- selecting a step scrolls to the correct part and preserves one cursor; +- no information is encoded only in the physical proximity of two pages. + +The source diagram may use a modest, explicit PlantUML `scale` when that is +necessary to place a semantic seam inside the A4-safe viewport. This is a +design decision, not automatic fit-to-width. The resulting normal print must +still meet the shared type-scale readability gate; HTML hover/canvas zoom is a +convenience and never a substitute for readable print. + +## Interaction and correspondence rules + +The diagram, source tree and debugger share one navigation identity: + +```text +SERIES -> CARD -> TASK -> BLOCK -> VIEWPOINT/PHASE -> STEP -> SNAPSHOT +``` + +For every selectable anchor: + +- its visible step number occurs exactly once inside that diagram; +- click and keyboard navigation select the same step; +- CODE opens the exact code or binary proof and does not mutate runtime; +- RUN opens code and may replay only after explicit activation; +- compile-time relations use code, `nm`, `readelf` or disassembly evidence; +- runtime messages use checkpoint evidence; +- focus is a subtle overlay; unselected selectable targets stay visible; +- a missing `snapshot_ref` is valid for CODE and invalid for RUN; +- the authored SVG target is validated after PlantUML rendering. + +## Diagram-first teaching contract + +The diagram is the primary entry point into an example. A printed card must +teach the algorithm, responsibility boundary, state transition or dispatch +mechanism before it exposes implementation syntax. Complete source listings +do not belong on the card. The card carries the repository URL/QR code, while +an interactive CODE anchor opens only the relevant symbol or source range in +Neovim. + +The intended learning path is: + +```text +goal and scope + -> relevant A1-A8 viewpoint + -> numbered diagram element + -> CODE source anchor or RUN checkpoint + -> Neovim / Termdebug / Hazard3 evidence + -> timestamped snapshot +``` + +Consequently: + +- a card uses only the A1-A8 viewpoints which materially explain the subject; +- the diagram explains the mechanism, source confirms its implementation and + the debugger supplies evidence; +- CODE navigation does not restart or mutate the program; +- RUN navigation may replay only to a declared deterministic checkpoint; +- source excerpts may appear temporarily in the interactive Neovim view, but + are not duplicated as full static listings in HTML, TeX or print; +- every source jump is attached to a concrete diagram element, relation, + message or state rather than to an unrelated code catalogue; +- the repository link remains the canonical route to the complete source. + +This order is deliberate: students first reason about algorithms, states and +responsibilities, then inspect the C or C++ syntax that implements them. + +## Editable UML layout and JSON persistence + +Interactive UML views may expose an explicit `EDIT` mode. It applies only to +UML/model assets; it must not turn the whole card page into a free-form editor +or interfere with normal CODE/RUN navigation. + +PlantUML remains the authoritative semantic source for participants, +relationships and diagram kind. Manual visual decisions are stored in a +sidecar JSON document named after the source, for example: + +```text +doc/assets/a2-structure.puml +doc/assets/a2-structure.layout.json +``` + +Saving a layout persists the complete authored presentation state, not only a +temporary browser transform. At minimum the sidecar records: + +- diagram identity, source revision and layout schema version; +- canvas size, page orientation, scale and optional page-grid metadata; +- stable element identity (`id`/`svg_target`), position and stacking order; +- width, height, local scale and rotation when the model kind allows it; +- displayed name, stereotype, short description and member text overrides; +- font family, font size, weight, style, line height and text alignment; +- fill, stroke, border width, corner treatment and semantic style role; +- group membership, collapsed state and explicit grouping/ungrouping; +- relation endpoints, labels and manually placed routing points; +- numbered CODE/RUN anchor placement without changing its teaching identity; +- editor metadata such as update timestamp and optional author. + +A representative record is: + +```json +{ + "schema": "stem.uml-layout/v1", + "diagram_id": "task01.a2-structure", + "source": "a2-structure.puml", + "source_digest": "sha256:...", + "canvas": { + "width": 1120, + "height": 760, + "orientation": "landscape", + "scale": 1 + }, + "elements": { + "task-base": { + "x": 310, + "y": 72, + "width": 360, + "height": 290, + "z": 2, + "text": { + "title": "Task", + "description": "Static task wrapper", + "font_family": "IBM Plex Mono", + "font_size": 14, + "font_weight": 500, + "align": "left" + }, + "style": { + "role": "wrapper", + "fill": "#eef5f8", + "stroke": "#397c99", + "stroke_width": 1.5 + } + } + }, + "relations": { + "task-to-api": { + "from": "task-base", + "to": "freertos-api", + "label": "calls", + "points": [[490, 362], [490, 430], [690, 430]] + } + }, + "updated_at": "2026-07-19T12:00:00+02:00" +} +``` + +Editor requirements: + +- dragging, resizing, text editing, grouping and relation routing update an + in-memory draft first; +- `Save layout` validates stable IDs and writes the sidecar atomically; +- `Cancel` restores the last saved sidecar without changing PlantUML; +- `Reset layout` removes visual overrides and returns to the generated + PlantUML geometry only after explicit confirmation; +- rerendering PlantUML reapplies compatible overrides by stable ID and reports + orphaned overrides instead of silently discarding them; +- a source digest mismatch is visible to the editor but does not destroy the + saved layout; +- normal view and print consume the same saved JSON, so the authored geometry + is reproducible outside the editing session; +- CODE/RUN step IDs, source references and snapshot identities cannot be + renamed accidentally by a visual edit. + +Exact manual coordinates cannot be represented reliably through PlantUML +layout hints alone. The sidecar is therefore the canonical manual presentation +layer, while `.puml` remains the canonical semantic layer. + +## Two valid A3 profiles for FreeRTOS wrappers + +The viewpoint set intentionally supports both of these designs. + +### Current teaching wrapper in K02 + +```text +A2 STRUCTURE Task, SumTask, SupervisorTask +A3 DISPATCH CRTP static dispatch + void* -> Task* -> Derived* -> run() +A7 RUNTIME same object address; direct/inlined target; no vtable symbol +``` + +This matches the current K02 acceptance gate: no virtual dispatch, vtable, +RTTI or hosted runtime. + +### `jonenz/FreeRTOS-Cpp` reference design + +```text +A2 STRUCTURE TaskBase <- Task / StaticTask <- application task +A3 DISPATCH dynamic dispatch + void* -> TaskBase* -> taskEntry() -> virtual taskFunction() +A7 RUNTIME object address, vptr, vtable entry and indirect target +``` + +The upstream `Task.hpp` creates dynamic/static kernel tasks in constructors, +passes `this` to `callTaskFunction(void*)`, and uses a virtual +`taskFunction()`. Its `TaskBase` destructor may call `vTaskDelete(handle)` when +configured. These are reference facts, not automatic design choices for our +teaching wrapper. Adopting that design would require changing K02's lifecycle, +ABI and no-vtable acceptance contracts explicitly. + +Reference: + + +## Target profile: Boost.Asio and standard threads + +This is the required future compatibility profile. It does not rename the +fixed tabs. Mechanism-specific wording belongs in subtitles: + +```text +A1 CONTEXT · application, Asio, io_context, worker threads, OS +A2 STRUCTURE · Server, Session, socket, strand and ThreadPool +A3 DISPATCH · executor and completion-handler dispatch +A4 APPLICATION · one concrete Session or Client +A5 FLOW · asynchronous initiation and completion +A6 STATE · operation, session and thread lifecycles +A7 RUNTIME · threads, executors, handlers and owned buffers +A8 PATTERNS · Proactor and verified collaboration roles +``` + +The fixed label remains `A5 FLOW`, not `A5 ASYNC FLOW`; the UI subtitle may be +`Async completion flow`. Likewise the fixed label remains `A7 RUNTIME`, with +`Threads / executors` as a subtitle. This keeps cards comparable while still +naming the actual mechanism. + +### A1–A8 mapping + +**A1 CONTEXT** shows this dependency/execution stack: + +```text +Application Session / Server + │ initiates + ▼ +Boost.Asio async operations, executors and strands + │ queue completions in + ▼ +io_context + │ run() is called by + ▼ +std::thread / std::jthread workers + │ use + ▼ +operating-system sockets and scheduling +``` + +**A2 STRUCTURE** shows the static ownership and dependency graph: `Server`, +`Session`, `tcp::socket`, executor/strand, buffers, `io_context` and the worker +collection. It must make lifetime ownership explicit, especially when a +session uses `shared_from_this()` to outlive an initiating call. + +**A3 DISPATCH** traces an initiating function through the outstanding +operation, associated executor, completion queue and selected handler. It must +not draw `async_read` as if it created a user thread. Completion handlers are +invoked only by threads currently running `io_context::run()`; several such +threads form a pool from which `io_context` may choose. A strand adds strictly +sequential handler invocation, not physical affinity to one thread. + +**A4 APPLICATION** collapses Asio infrastructure and shows one concrete +protocol/session contract, for example `EchoSession::start/read/onRead/write`. + +**A5 FLOW** uses the explicit sequence: + +```text +initiate -> outstanding -> operation completes -> handler queued + -> one run() thread invokes handler -> next operation or completion +``` + +**A6 STATE** separates at least three domains when present: + +```text +jthread object: absent -> joinable -> stop requested -> joined +async operation: initiated -> outstanding -> queued -> running -> done +application Session: Created -> Reading -> Processing -> Writing -> Closed +``` + +The `std::jthread` destructor requests stop and joins when the object is still +joinable. A session state is application data; it must not be inferred merely +from a handler being present in a queue. + +**A7 RUNTIME** records `io_context`, worker IDs, current handler and thread, +executor/strand identity, socket descriptor, operation kind, session address, +buffer lifetime and relevant smart-pointer ownership. The evidence view uses +`info threads`, backtraces and object inspection rather than RTOS TCB fields. + +**A8 PATTERNS** may identify Proactor, callback/continuation, serialized +execution through a strand, thread pool, RAII thread ownership and lifetime +management. These are classified carefully: `std::jthread` ownership is a C++ +idiom, a thread pool is a concurrency architecture, and not every completion +handler constitutes a GoF Command. Boost.Asio documents its asynchronous model +as Proactor; the card must still map each claimed role to project code. + +The central invariant for every future Asio card is: + +```text +asynchronous operation != thread + +operation completion -> associated executor -> io_context + -> one thread currently executing run() +``` + +Primary references for this profile: + +- +- +- +- +- + +## Definition of done for a card view + +A viewpoint is ready when: + +1. its purpose and question are stated; +2. the chosen UML/model kind fits that question; +3. every relation has one unambiguous meaning; +4. every selectable item has a stable anchor and code reference; +5. runtime claims have reproducible evidence; +6. compile-time claims use compiler/binary evidence rather than fake runtime; +7. the text fallback remains understandable without SVG; +8. the view prints in monochrome and works as an interactive HTML asset; +9. continuation across pages preserves identity and step order; +10. A8 names costs and rejected alternatives, not only benefits. diff --git a/react/src/main.tsx b/react/src/main.tsx index 875b516..01908c8 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -20,7 +20,17 @@ type ApiNavigation = { task: { id: string; label: string; index: number }; block: { id: string; label: string; index: number }; phase: { id: string; label: string; index: number }; - step: { id: string; label: string; number: number; index: number; global_index: number }; + step: { + id: string; + label: string; + number: number; + index: number; + global_index: number; + mode?: "CODE" | "RUN"; + code_ref?: string; + event_id?: string; + strategy_ref?: string; + }; snapshot_ref: string | null; position: NavigationPosition; sync_requested: boolean; @@ -136,13 +146,17 @@ type UmlSequenceStep = { id: string; number: number; label: string; + mode?: "CODE" | "RUN"; + event_id?: string; + strategy_ref?: string; description: string; code_ref?: string; progress_id?: string; svg_label?: string; + svg_target?: string; snapshot_ref?: string; view?: NvimStepView; - snapshot: UmlSnapshot; + snapshot?: UmlSnapshot; }; type UmlSequencePhase = { id: string; @@ -152,7 +166,7 @@ type UmlSequencePhase = { steps: UmlSequenceStep[]; }; type InteractiveAsset = { - kind: "allocator-memory-flow" | "uml-sequence"; + kind: "allocator-memory-flow" | "uml-sequence" | "uml-class"; storage_key?: string; title?: string; task?: { id: string; label: string }; @@ -177,8 +191,83 @@ type InteractiveAsset = { stages?: UmlStage[]; phases?: UmlSequencePhase[]; }; +type DiagramTile = { + index: number; + count: number; + row: number; + column: number; + rows: number; + columns: number; + x: number; + y: number; + width: number; + height: number; + full_x: number; + full_y: number; + full_width: number; + full_height: number; + page_width: number; + page_height: number; + overview: boolean; + step_ids: string[]; +}; +type UmlLayoutText = { + title?: string; + description?: string; + lines?: string[]; + font_family?: string; + font_size?: number; + font_weight?: number; + font_style?: string; + line_height?: number; + align?: "left" | "center" | "right"; +}; +type UmlLayoutStyle = { + role?: string; + fill?: string; + stroke?: string; + stroke_width?: number; +}; +type UmlLayoutElement = { + x: number; + y: number; + width: number; + height: number; + z?: number; + scale?: number; + rotation?: number; + group?: string | null; + collapsed?: boolean; + text?: UmlLayoutText; + style?: UmlLayoutStyle; +}; +type UmlLayoutDocument = { + schema: "stem.uml-layout/v1"; + diagram_id: string; + source: string; + source_digest?: string; + canvas: { + width: number; + height: number; + orientation?: "portrait" | "landscape"; + scale?: number; + }; + elements: Record; + relations: Record; + updated_at?: string; + updated_by?: string; +}; +type UmlEditableNode = { + id: string; + box: { x: number; y: number; width: number; height: number }; + rect: SVGRectElement; + members: SVGElement[]; + texts: SVGTextElement[]; +}; type Asset = { label: string; + logical_label?: string; + figure_number?: number | null; caption: string; alt: string; href: string; @@ -186,6 +275,11 @@ type Asset = { kind: string; source_href?: string; interactive?: InteractiveAsset | null; + tile?: DiagramTile | null; + diagram_title?: string; + diagram_block_label?: string; + diagram_description?: string; + diagram_phase_labels?: string[]; }; type FlowStep = { id: string; title: string; content_html: string }; type FlowBlock = { @@ -216,20 +310,89 @@ type Section = { tasks: Task[]; assets: Asset[]; steps: { id: string; title: string }[]; + spread_group?: string; + spread_label?: string; + spread_index?: number; + spread_count?: number; + spread_columns?: number; + page_orientation?: "portrait" | "landscape"; +}; +type PageContinuation = { + left: boolean; + right: boolean; + up: boolean; + down: boolean; + index: number; + count: number; }; type Progress = { items: Record; summary: { total: number; counts: Record }; }; type SetProgressStatus = (id: string, status: Status) => Promise; +type CardLibraryTask = { + id: string; + title: string; +}; +type CardLibraryCard = { + id: string; + title: string; + repo: string; + href: string; + available: boolean; + current: boolean; + number?: string; + version?: string; + status?: string; + tasks?: CardLibraryTask[]; +}; +type CardLibrarySeries = { + id: string; + title: string; + current: boolean; + catalog_only?: boolean; + role?: string; + status?: string; + cards: CardLibraryCard[]; +}; +type CardLibraryLevel = { + id: string; + title: string; + current: boolean; + series: CardLibrarySeries[]; +}; +type CardLibrarySubject = { + id: string; + title: string; + current: boolean; + levels: CardLibraryLevel[]; +}; +type CardLibrary = { + schema: string; + workspace: string; + subjects: CardLibrarySubject[]; +}; type CardModel = { schema: string; card: Record; + library?: CardLibrary | null; total_pages: number; front: any; sections: Section[]; + viewpoints?: Viewpoint[]; dictionary: { header_html: string; content_html: string } | null; toc: { id: string; label: string }[]; + has_spread?: boolean; + has_allocator_model?: boolean; +}; +type Viewpoint = { + id: "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "A7" | "A8"; + label: string; + subtitle?: string; + status: "enabled" | "unavailable"; + target_label?: string; + target_id?: string; + reason?: string; }; type NvimPanelSummary = { state: @@ -420,10 +583,332 @@ const Html = ({ html, className }: { html: string; className?: string }) => (
); +function applyDiagramTile(svg: SVGSVGElement, tile?: DiagramTile | null) { + svg.setAttribute("preserveAspectRatio", "xMinYMin meet"); + if (!tile) return; + svg.setAttribute("viewBox", `${tile.x} ${tile.y} ${tile.width} ${tile.height}`); + svg.setAttribute("width", `${tile.width}px`); + svg.setAttribute("height", `${tile.height}px`); + svg.dataset.tileIndex = String(tile.index); + svg.dataset.tileRow = String(tile.row); + svg.dataset.tileColumn = String(tile.column); +} + +function umlLayoutSlug(value: string) { + const slug = value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120); + return slug || "uml-element"; +} + +function rawSvgBox(element: SVGElement) { + const graphics = element as SVGGraphicsElement; + if (typeof graphics.getBBox !== "function") return null; + try { + const box = graphics.getBBox(); + if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return null; + return { x: box.x, y: box.y, width: box.width, height: box.height }; + } catch { + return null; + } +} + +function visualSvgBox(element: SVGElement) { + const box = rawSvgBox(element); + if (!box) return null; + const originX = Number(element.dataset.umlLayoutOriginX); + const originY = Number(element.dataset.umlLayoutOriginY); + const dx = Number(element.dataset.umlLayoutDx); + const dy = Number(element.dataset.umlLayoutDy); + const sx = Number(element.dataset.umlLayoutSx); + const sy = Number(element.dataset.umlLayoutSy); + if (![originX, originY, dx, dy, sx, sy].every(Number.isFinite)) return box; + return { + x: originX + (box.x - originX) * sx + dx, + y: originY + (box.y - originY) * sy + dy, + width: box.width * sx, + height: box.height * sy, + }; +} + +function rememberUmlOriginal(element: SVGElement) { + if (element.dataset.umlLayoutRemembered === "true") return; + element.dataset.umlLayoutRemembered = "true"; + element.dataset.umlLayoutOriginalTransform = element.getAttribute("transform") ?? ""; + element.dataset.umlLayoutOriginalVisibility = element.getAttribute("visibility") ?? ""; + element.dataset.umlLayoutOriginalStyle = element.getAttribute("style") ?? ""; + if (element instanceof SVGTextElement) { + element.dataset.umlLayoutOriginalText = element.textContent ?? ""; + element.dataset.umlLayoutOriginalX = element.getAttribute("x") ?? ""; + element.dataset.umlLayoutOriginalTextAnchor = element.getAttribute("text-anchor") ?? ""; + element.dataset.umlLayoutOriginalFontFamily = element.getAttribute("font-family") ?? ""; + element.dataset.umlLayoutOriginalFontSize = element.getAttribute("font-size") ?? ""; + element.dataset.umlLayoutOriginalFontWeight = element.getAttribute("font-weight") ?? ""; + } + if (element instanceof SVGRectElement) { + element.dataset.umlLayoutOriginalFill = element.getAttribute("fill") ?? ""; + element.dataset.umlLayoutOriginalStroke = element.getAttribute("stroke") ?? ""; + element.dataset.umlLayoutOriginalStrokeWidth = element.getAttribute("stroke-width") ?? ""; + } +} + +function restoreUmlLayoutSvg(svg: SVGSVGElement) { + svg.querySelectorAll(".uml-layout-hit,.uml-layout-resize,.uml-layout-generated") + .forEach(node => node.remove()); + svg.querySelectorAll("[data-uml-layout-remembered='true']") + .forEach(element => { + const restore = (name: string, value: string | undefined) => { + if (value) element.setAttribute(name, value); + else element.removeAttribute(name); + }; + restore("transform", element.dataset.umlLayoutOriginalTransform); + restore("visibility", element.dataset.umlLayoutOriginalVisibility); + restore("style", element.dataset.umlLayoutOriginalStyle); + if (element instanceof SVGTextElement) { + element.textContent = element.dataset.umlLayoutOriginalText ?? ""; + restore("x", element.dataset.umlLayoutOriginalX); + restore("text-anchor", element.dataset.umlLayoutOriginalTextAnchor); + restore("font-family", element.dataset.umlLayoutOriginalFontFamily); + restore("font-size", element.dataset.umlLayoutOriginalFontSize); + restore("font-weight", element.dataset.umlLayoutOriginalFontWeight); + } + if (element instanceof SVGRectElement) { + restore("fill", element.dataset.umlLayoutOriginalFill); + restore("stroke", element.dataset.umlLayoutOriginalStroke); + restore("stroke-width", element.dataset.umlLayoutOriginalStrokeWidth); + } + delete element.dataset.umlLayoutOriginX; + delete element.dataset.umlLayoutOriginY; + delete element.dataset.umlLayoutDx; + delete element.dataset.umlLayoutDy; + delete element.dataset.umlLayoutSx; + delete element.dataset.umlLayoutSy; + delete element.dataset.umlLayoutId; + }); +} + +function discoverUmlEditableNodes( + svg: SVGSVGElement, + stepIdsByNumber: Map, +): UmlEditableNode[] { + const root = svg.querySelector(":scope > g") ?? svg; + const children = Array.from(root.children).filter( + (child): child is SVGElement => child instanceof SVGElement, + ); + const rectangles = children + .filter((child): child is SVGRectElement => child instanceof SVGRectElement) + .map(rect => ({ rect, box: rawSvgBox(rect) })) + .filter((item): item is { rect: SVGRectElement; box: UmlEditableNode["box"] } => + Boolean(item.box && item.box.width >= 30 && item.box.height >= 14), + ) + .sort((left, right) => left.box.width * left.box.height - right.box.width * right.box.height); + + const assigned = new Map(); + for (const element of children) { + const box = rawSvgBox(element); + if (!box) continue; + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + const owner = rectangles.find(({ box: candidate }) => + centerX >= candidate.x - 0.5 + && centerX <= candidate.x + candidate.width + 0.5 + && centerY >= candidate.y - 0.5 + && centerY <= candidate.y + candidate.height + 0.5, + ); + if (owner) assigned.set(element, owner.rect); + } + + const used = new Set(); + return rectangles.map(({ rect, box }, index) => { + const members = children.filter(element => assigned.get(element) === rect); + if (!members.includes(rect)) members.unshift(rect); + const texts = members.filter( + (element): element is SVGTextElement => element instanceof SVGTextElement, + ).sort((left, right) => Number(left.getAttribute("y")) - Number(right.getAttribute("y"))); + const firstLine = (texts[0]?.textContent ?? rect.id ?? `element-${index + 1}`).trim(); + const number = /^(\d{1,3})\b/.exec(firstLine)?.[1]?.padStart(2, "0"); + const preferred = rect.id || (number ? stepIdsByNumber.get(number) : "") || firstLine; + const base = umlLayoutSlug(preferred); + let id = base; + let suffix = 2; + while (used.has(id)) id = `${base}-${suffix++}`; + used.add(id); + members.forEach(rememberUmlOriginal); + return { id, box, rect, members, texts }; + }); +} + +function inferredUmlLayoutElement(node: UmlEditableNode): UmlLayoutElement { + const firstText = node.texts[0]; + const style = node.rect.getAttribute("style") ?? ""; + const styleValue = (name: string) => new RegExp(`${name}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1]?.trim(); + const fontSize = Number(firstText?.getAttribute("font-size") ?? 12); + return { + ...node.box, + z: 0, + scale: 1, + rotation: 0, + text: { + title: (node.texts[0]?.textContent ?? "").trim(), + description: node.texts.slice(1).map(text => (text.textContent ?? "").trim()).join("\n"), + lines: node.texts.map(text => (text.textContent ?? "").trim()), + font_family: firstText?.getAttribute("font-family") ?? "Monospace", + font_size: Number.isFinite(fontSize) ? fontSize : 12, + font_weight: Number(firstText?.getAttribute("font-weight") ?? 400) || 400, + align: "left", + }, + style: { + fill: node.rect.getAttribute("fill") ?? styleValue("fill") ?? "#edf6fa", + stroke: node.rect.getAttribute("stroke") ?? styleValue("stroke") ?? "#2c7794", + stroke_width: Number(node.rect.getAttribute("stroke-width") ?? styleValue("stroke-width") ?? 1.5), + }, + }; +} + +function applyUmlLayoutElement(node: UmlEditableNode, authored: UmlLayoutElement) { + const width = Math.max(12, Number(authored.width) || node.box.width); + const height = Math.max(12, Number(authored.height) || node.box.height); + const x = Number.isFinite(Number(authored.x)) ? Number(authored.x) : node.box.x; + const y = Number.isFinite(Number(authored.y)) ? Number(authored.y) : node.box.y; + const sx = width / Math.max(1, node.box.width); + const sy = height / Math.max(1, node.box.height); + const dx = x - node.box.x; + const dy = y - node.box.y; + const transform = `translate(${dx} ${dy}) translate(${node.box.x} ${node.box.y}) scale(${sx} ${sy}) translate(${-node.box.x} ${-node.box.y})`; + + node.members.forEach(member => { + rememberUmlOriginal(member); + const original = member.dataset.umlLayoutOriginalTransform ?? ""; + member.setAttribute("transform", `${transform}${original ? ` ${original}` : ""}`); + member.dataset.umlLayoutOriginX = String(node.box.x); + member.dataset.umlLayoutOriginY = String(node.box.y); + member.dataset.umlLayoutDx = String(dx); + member.dataset.umlLayoutDy = String(dy); + member.dataset.umlLayoutSx = String(sx); + member.dataset.umlLayoutSy = String(sy); + member.dataset.umlLayoutId = node.id; + }); + + const text = authored.text ?? {}; + const lines = text.lines ?? [text.title ?? "", ...(text.description ? text.description.split("\n") : [])]; + node.texts.forEach((item, index) => { + if (index < lines.length) { + item.textContent = lines[index]; + item.removeAttribute("visibility"); + } else if (text.lines) { + item.setAttribute("visibility", "hidden"); + } + if (text.font_family) item.setAttribute("font-family", text.font_family); + if (Number.isFinite(Number(text.font_size))) item.setAttribute("font-size", String(text.font_size)); + if (Number.isFinite(Number(text.font_weight))) item.setAttribute("font-weight", String(text.font_weight)); + if (text.font_style) item.setAttribute("font-style", text.font_style); + const align = text.align ?? "left"; + const localX = align === "center" + ? node.box.x + node.box.width / 2 + : align === "right" + ? node.box.x + node.box.width - 8 + : node.box.x + 8; + item.setAttribute("x", String(localX)); + item.setAttribute("text-anchor", align === "center" ? "middle" : align === "right" ? "end" : "start"); + }); + if (authored.style?.fill) node.rect.setAttribute("fill", authored.style.fill); + if (authored.style?.stroke) node.rect.setAttribute("stroke", authored.style.stroke); + if (Number.isFinite(Number(authored.style?.stroke_width))) { + node.rect.setAttribute("stroke-width", String(authored.style?.stroke_width)); + } +} + +function emptyUmlLayout( + asset: Asset, + config: InteractiveAsset, + sourceDigest = "", +): UmlLayoutDocument { + const source = asset.source_href?.split("/").pop() ?? "diagram.puml"; + return { + schema: "stem.uml-layout/v1", + diagram_id: `${config.task?.id ?? "task"}.${config.block?.id ?? asset.label}`, + source, + source_digest: sourceDigest, + canvas: { + width: asset.tile?.full_width ?? 0, + height: asset.tile?.full_height ?? 0, + orientation: (asset.tile?.page_width ?? 0) > (asset.tile?.page_height ?? 0) + ? "landscape" + : "portrait", + scale: 1, + }, + elements: {}, + relations: {}, + }; +} + +function diagramTileHostStyle(tile?: DiagramTile | null): React.CSSProperties { + if (!tile) return {}; + const horizontal = tile.column === 1 && tile.columns > 1 + ? tile.page_width - tile.width + : tile.column === tile.columns + ? 0 + : Math.max(0, (tile.page_width - tile.width) / 2); + const vertical = tile.row === 1 && tile.rows > 1 + ? tile.page_height - tile.height + : tile.row === tile.rows + ? 0 + : Math.max(0, (tile.page_height - tile.height) / 2); + return { + "--uml-tile-page-width": `${tile.page_width}px`, + "--uml-tile-page-height": `${tile.page_height}px`, + "--uml-tile-left": `${horizontal}px`, + "--uml-tile-top": `${vertical}px`, + } as React.CSSProperties; +} + function Header({ html }: { html: string }) { return ; } +function continuationForSection(section: Section): PageContinuation | null { + const count = section.spread_count ?? 1; + if (count <= 1) return null; + const index = Math.max(1, Math.min(count, section.spread_index ?? 1)); + const columns = Math.max(1, Math.min(count, section.spread_columns ?? 1)); + const offset = index - 1; + const row = Math.floor(offset / columns); + const column = offset % columns; + return { + left: column > 0, + right: column + 1 < columns && index < count, + up: row > 0, + down: index + columns <= count, + index, + count, + }; +} + +function PageContinuationCue({ continuation }: { continuation?: PageContinuation | null }) { + if (!continuation) return null; + const directions = [ + continuation.left && ["left", "←", "kontynuacja po lewej"], + continuation.up && ["up", "↑", "kontynuacja powyżej"], + continuation.down && ["down", "↓", "kontynuacja poniżej"], + continuation.right && ["right", "→", "kontynuacja po prawej"], + ].filter(Boolean) as string[][]; + return ( + + {directions.map(([direction, glyph, label]) => ( + {glyph} + ))} + + ); +} + function Margin({ side, html }: { side: "left" | "right"; html: string }) { return (