diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dfd4de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npx tsc -noEmit -skipLibCheck + - run: npm test + - run: npm run build diff --git a/.gitignore b/.gitignore index a28ac69..49e069f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ main.js.map *.log deploy.local.mjs deploy.local.ps1 +*.code-workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 4041213..1a1b9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to O-Tie are documented here. +## [1.0.8] — 2026-06-27 + +### Added + +- **Pan from anywhere on touch** — Single-finger drags now pan the canvas even when the finger starts on a node; a tap still selects the node. +- **Keyboard node selection** — Nodes are focusable (`Tab`), selectable with `Enter`/`Space`, and clearable with `Escape`. +- **Settings validation** — Numeric layout settings reject invalid input with clear visual feedback instead of silently ignoring it. +- **Unit tests and CI** — Vitest coverage for the model, layout, and pan/zoom logic, plus a GitHub Actions workflow running lint, type-check, tests, and build. + +### Fixed + +- **Data-loss guard** — A malformed `.bowtie` file no longer gets overwritten by an empty diagram; the original content is preserved and a notice is shown. + +### Changed + +- Internal: split the editor view into focused modules (history, pan/zoom, stack rows, external sync) and refreshed the README, examples, and contributor docs. + +--- + ## [1.0.7] — 2026-06-23 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..163b3dc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to O-Tie + +Thanks for your interest in improving O-Tie. This guide covers local setup, the +checks we run, how to regenerate documentation assets, and the release process. + +## Prerequisites + +- Node.js 20+ and npm +- Obsidian 1.4.0+ (for manual testing) + +## Setup + +```bash +npm install +``` + +## Develop + +```bash +npm run dev # esbuild watch -> main.js +npm run build # type-check (tsc) + production bundle +npm run lint # ESLint with the Obsidian plugin guideline rules +npm test # unit tests (model + layout) +``` + +To test inside a real vault, point the deploy helper at your plugin folder: + +```bash +OBSIDIAN_PLUGIN_DIR="/path/to/vault/.obsidian/plugins/o-tie" npm run deploy +``` + +`npm run lint`, `npm run build`, and `npm test` must all pass before a change is +merged or released — these mirror the automated checks Obsidian runs on the +community directory. + +## Regenerating documentation assets + +The README screenshots and the example `.bowtie` files are generated from the +plugin's real layout engine and stylesheet, so they always match the current +behaviour. + +```bash +npx playwright install chromium # one time, for screenshots +npm run screenshots # -> assets/*.png +npm run examples # -> examples/*.bowtie +``` + +## Release process + +Obsidian requires the GitHub release tag to match `manifest.json` exactly +(e.g. `1.0.8`, not `v1.0.8`). + +1. Run `npm run lint`, `npm run build`, and `npm test`. +2. Complete the QA checklist below. +3. Bump the version: `npm version patch` (updates `manifest.json` and + `versions.json` via `version-bump.mjs`). +4. Update `CHANGELOG.md`. +5. Push the tag: `git push origin `. The release workflow builds and + attaches `main.js`, `manifest.json`, and `styles.css`. + +### Manual QA checklist + +Mobile regressions have driven most past patch releases, so test on a phone or +tablet as well as desktop before every release. + +Desktop: + +- [ ] Create a new bowtie; rename it via the toolbar title. +- [ ] Add a threat, consequence, event, and barrier from the toolbar. +- [ ] Add a prevention/mitigation/between-events barrier via lane `+` buttons. +- [ ] Add a barrier analysis stack row; change a preset value; add a custom row. +- [ ] Add a degradation factor and a safeguard. +- [ ] Edit a label inline (double-click) and notes (inspector). +- [ ] Undo/redo (toolbar and Ctrl+Z / Ctrl+Y). +- [ ] Pan (drag), zoom (wheel), and Fit. +- [ ] Export as image (full diagram and visible area), with/without grid. +- [ ] Delete nodes; confirm autosave writes to the `.bowtie` file. +- [ ] Open a deliberately malformed `.bowtie` file: a notice appears and the + file is not overwritten. + +Mobile (phone and tablet): + +- [ ] One-finger pan and two-finger pinch zoom. +- [ ] Overlay `+` / `×` controls appear at node corners and stay circular after zoom. +- [ ] Add and delete nodes via overlay controls. +- [ ] App swipe gestures do not fire while interacting with the canvas. + +## GitHub repository topics + +For discoverability, the repository should carry these topics: + +``` +obsidian, obsidian-plugin, bowtie, risk-management, process-safety, hse, barrier-management, risk-analysis +``` + +A maintainer can set them with: + +```bash +gh repo edit --add-topic obsidian,obsidian-plugin,bowtie,risk-management,process-safety,hse,barrier-management,risk-analysis +``` diff --git a/README.md b/README.md index 648f11c..14c371d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Build and edit **risk bowtie diagrams** in Obsidian with an interactive visual e Diagrams are stored as `.bowtie` files in your vault and auto-save as you edit. +## Screenshots + +![O-Tie editor showing a bowtie with threats, prevention and mitigation barriers, a central top event, consequences, an escalation chain, and a colour-coded barrier analysis stack](assets/screenshot-diagram.png) + +*A bowtie for a cold-storage ammonia loss-of-primary-containment scenario.* + +![A prevention barrier with its full analysis stack — type, effectiveness, criticality, responsible party, validation method, and status — plus a safeguard and degradation factor below it](assets/screenshot-barrier.png) + +*Per-barrier analysis stacks and escalation factors (safeguards and degradation factors).* + +> Regenerate these images with `npm run screenshots` (requires `npx playwright install chromium` once). + ## Features - Interactive bowtie editor with fan-in/fan-out layout @@ -82,20 +94,49 @@ After updating O-Tie on desktop, let Obsidian Sync finish, then reload Obsidian ## File format -`.bowtie` files are JSON. Example: +`.bowtie` files are JSON. The hazard and top event live inside `events`, and barriers can carry escalation chains and an analysis `stack`: ```json { - "name": "Defective Steamcracker", - "hazard": "High pressure ethylene", - "topEvent": "Loss of containment", - "threats": [{ "label": "Corrosion", "preventionBarriers": [] }], - "consequences": [{ "label": "Fire", "mitigationBarriers": [] }], - "view": { "zoom": 1, "panX": 0, "panY": 0 } + "id": "b1f2…", + "name": "Defective steamcracker", + "events": [ + { + "id": "ev1", + "label": "Loss of containment", + "hazard": "High-pressure ethylene", + "transitionBarriers": [] + } + ], + "threats": [ + { + "id": "t1", + "label": "Tube corrosion / fatigue", + "preventionBarriers": [ + { + "id": "pb1", + "label": "Mechanical integrity inspection", + "degradationChains": [], + "stack": [ + { "id": "s1", "field": "type", "label": "Active Hardware", "color": "#48c9b0" } + ], + "stackCollapsed": false + } + ] + } + ], + "consequences": [ + { "id": "c1", "label": "Fire / explosion", "mitigationBarriers": [] } + ], + "view": { "zoom": 1, "panX": 0, "panY": 0 }, + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z" } ``` -See [examples/steamcracker.bowtie](examples/steamcracker.bowtie) and [examples/cold-storage-ammonia.bowtie](examples/cold-storage-ammonia.bowtie) (LinkedIn promo scenario). +Files written by older versions (with top-level `hazard`/`topEvent` and barrier `escalationFactors`) are migrated automatically on open. + +See [examples/steamcracker.bowtie](examples/steamcracker.bowtie) and [examples/cold-storage-ammonia.bowtie](examples/cold-storage-ammonia.bowtie). ## Changelog @@ -105,11 +146,16 @@ See [CHANGELOG.md](CHANGELOG.md). ```bash npm install -npm run dev # watch mode -npm run build # production build -npm run lint # Obsidian plugin guidelines check +npm run dev # watch mode +npm run build # production build +npm run lint # Obsidian plugin guidelines check +npm test # unit tests (model + layout) +npm run screenshots # regenerate README images (needs: npx playwright install chromium) +npm run examples # regenerate example .bowtie files ``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, asset regeneration, and the release/QA checklist. + ## Third-party licenses This plugin bundles [html-to-image](https://github.com/bubkoo/html-to-image) (MIT) for PNG export. diff --git a/assets/screenshot-barrier.png b/assets/screenshot-barrier.png new file mode 100644 index 0000000..163e1de Binary files /dev/null and b/assets/screenshot-barrier.png differ diff --git a/assets/screenshot-diagram.png b/assets/screenshot-diagram.png new file mode 100644 index 0000000..0200df7 Binary files /dev/null and b/assets/screenshot-diagram.png differ diff --git a/examples/cold-storage-ammonia.bowtie b/examples/cold-storage-ammonia.bowtie index ad86819..bfe5759 100644 --- a/examples/cold-storage-ammonia.bowtie +++ b/examples/cold-storage-ammonia.bowtie @@ -1,779 +1,541 @@ { - "id": "example-cold-storage-ammonia", - "name": "Cold Storage Ammonia Refrigeration", - "hazard": "Pressurized anhydrous ammonia (NH₃) refrigeration inventory", - "topEvent": "Loss of primary containment from ammonia refrigeration circuit", + "id": "bowtie-0", + "name": "Cold storage ammonia refrigeration", + "events": [ + { + "id": "event-1", + "label": "Loss of primary containment from ammonia refrigeration circuit", + "hazard": "Pressurised anhydrous ammonia (NH3) refrigeration inventory", + "transitionBarriers": [] + } + ], "threats": [ { - "id": "threat-corrosion", + "id": "threat-2", "label": "Corrosion or fatigue of piping, vessels, or evaporator coils", - "notes": "Credible in aged plant with cyclic thermal loading, condensate, or external corrosion under insulation.", "preventionBarriers": [ { - "id": "pb-integrity", + "id": "barrier-3", "label": "Mechanical integrity inspection program", - "notes": "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-deferred-inspection", - "label": "Deferred inspection due to production pressure", - "escalationBarriers": [ + "id": "chain-10", + "safeguards": [ { - "id": "eb-integrity-audit", - "label": "QA audit of mechanical integrity backlog", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-11", + "label": "QA audit of mechanical integrity backlog" } - ] + ], + "degradationFactor": { + "id": "degradation-12", + "label": "Deferred inspection due to production pressure" + } } ], "stack": [ { - "id": "stack-pb-integrity-type", + "id": "stack-4", "field": "type", "label": "Active Hardware", "color": "#48c9b0" }, { - "id": "stack-pb-integrity-effectiveness", + "id": "stack-5", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-integrity-criticality", + "id": "stack-6", "field": "criticality", "label": "Very High", "color": "#2c3e50" }, { - "id": "stack-pb-integrity-responsible", + "id": "stack-7", "field": "responsible", "label": "Tech Department", "color": "#ffffff" }, { - "id": "stack-pb-integrity-validation", + "id": "stack-8", "field": "validation", "label": "Inspection", "color": "#ffffff" }, { - "id": "stack-pb-integrity-status", + "id": "stack-9", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": false + "stackCollapsed": false, + "notes": "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards." }, { - "id": "pb-materials", - "label": "Materials of construction and corrosion allowance per design standard", - "notes": "Compatible metallurgy, coatings, and design margin for operating conditions.", - "escalationFactors": [], + "id": "barrier-13", + "label": "Materials of construction and corrosion allowance", + "degradationChains": [], "stack": [ { - "id": "stack-pb-materials-type", + "id": "stack-14", "field": "type", "label": "Passive Hardware", "color": "#7f8c8d" }, { - "id": "stack-pb-materials-effectiveness", + "id": "stack-15", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-materials-criticality", + "id": "stack-16", "field": "criticality", "label": "High", "color": "#566573" }, { - "id": "stack-pb-materials-responsible", - "field": "responsible", - "label": "Tech Department", - "color": "#ffffff" - }, - { - "id": "stack-pb-materials-validation", - "field": "validation", - "label": "Inspection", - "color": "#ffffff" - }, - { - "id": "stack-pb-materials-status", + "id": "stack-17", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Compatible metallurgy, coatings, and design margin for operating conditions." } - ] + ], + "notes": "Credible in aged plant with cyclic thermal loading, condensate, or corrosion under insulation." }, { - "id": "threat-impact", + "id": "threat-18", "label": "Forklift or vehicle impact on exposed ammonia piping", - "notes": "Common in warehouse aisles adjacent to machine rooms or overhead pipe runs.", "preventionBarriers": [ { - "id": "pb-protection", + "id": "barrier-19", "label": "Physical protection and traffic segregation", - "notes": "Bollards, rack guards, marked routes, and restricted access to machine room.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-bollard-bypass", - "label": "Bollards removed or bypassed for convenience", - "escalationBarriers": [ + "id": "chain-24", + "safeguards": [ { - "id": "eb-supervisor-rounds", - "label": "Supervisor housekeeping and barrier inspection rounds", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-25", + "label": "Supervisor housekeeping and barrier inspection rounds" } - ] + ], + "degradationFactor": { + "id": "degradation-26", + "label": "Bollards removed or bypassed for convenience" + } } ], "stack": [ { - "id": "stack-pb-protection-type", + "id": "stack-20", "field": "type", "label": "Passive Hardware", "color": "#7f8c8d" }, { - "id": "stack-pb-protection-effectiveness", + "id": "stack-21", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-protection-criticality", + "id": "stack-22", "field": "criticality", "label": "High", "color": "#566573" }, { - "id": "stack-pb-protection-responsible", - "field": "responsible", - "label": "Operations", - "color": "#ffffff" - }, - { - "id": "stack-pb-protection-validation", - "field": "validation", - "label": "Inspection", - "color": "#ffffff" - }, - { - "id": "stack-pb-protection-status", + "id": "stack-23", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Bollards, rack guards, marked routes, and restricted machine-room access." } - ] + ], + "notes": "Common in warehouse aisles adjacent to machine rooms or overhead pipe runs." }, { - "id": "threat-overpressure", + "id": "threat-27", "label": "Overpressure in low-side or high-side refrigeration circuit", "preventionBarriers": [ { - "id": "pb-relief", + "id": "barrier-28", "label": "Pressure relief devices and high-pressure interlocks", - "notes": "PSVs discharge to safe location; alarm and trip on abnormal pressure.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-psv-blocked", - "label": "Relief device isolated, blocked, or inspection overdue", - "escalationBarriers": [ + "id": "chain-33", + "safeguards": [ { - "id": "eb-psv-program", - "label": "PSV inspection and register management program", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-34", + "label": "PSV inspection and register management program" } - ] + ], + "degradationFactor": { + "id": "degradation-35", + "label": "Relief device isolated, blocked, or inspection overdue" + } } ], "stack": [ { - "id": "stack-pb-relief-type", + "id": "stack-29", "field": "type", "label": "Active Hardware", "color": "#48c9b0" }, { - "id": "stack-pb-relief-effectiveness", + "id": "stack-30", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-relief-criticality", + "id": "stack-31", "field": "criticality", "label": "Very High", "color": "#2c3e50" }, { - "id": "stack-pb-relief-responsible", - "field": "responsible", - "label": "Tech Department", - "color": "#ffffff" - }, - { - "id": "stack-pb-relief-validation", - "field": "validation", - "label": "Inspection", - "color": "#ffffff" - }, - { - "id": "stack-pb-relief-status", + "id": "stack-32", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "PSVs discharge to a safe location; alarm and trip on abnormal pressure." } ] }, { - "id": "threat-maintenance", + "id": "threat-36", "label": "Maintenance isolation or re-commissioning error", "preventionBarriers": [ { - "id": "pb-ptw", + "id": "barrier-37", "label": "Permit-to-work and LOTO procedures", - "notes": "Verified isolation, zero-energy confirmation, and ammonia-specific competency.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-competency-gap", - "label": "Technician competency not verified for ammonia work", - "escalationBarriers": [ + "id": "chain-42", + "safeguards": [ { - "id": "eb-competency", - "label": "Competency assessment and authorization records", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-43", + "label": "Competency assessment and authorization records" } - ] + ], + "degradationFactor": { + "id": "degradation-44", + "label": "Technician competency not verified for ammonia work" + } } ], "stack": [ { - "id": "stack-pb-ptw-type", + "id": "stack-38", "field": "type", "label": "Active Human", "color": "#5dade2" }, { - "id": "stack-pb-ptw-effectiveness", + "id": "stack-39", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-ptw-criticality", + "id": "stack-40", "field": "criticality", "label": "Very High", "color": "#2c3e50" }, { - "id": "stack-pb-ptw-responsible", - "field": "responsible", - "label": "Operations", - "color": "#ffffff" - }, - { - "id": "stack-pb-ptw-validation", - "field": "validation", - "label": "Audit", - "color": "#ffffff" - }, - { - "id": "stack-pb-ptw-status", + "id": "stack-41", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Verified isolation, zero-energy confirmation, and ammonia-specific competency." } ] }, { - "id": "threat-operator", + "id": "threat-45", "label": "Operator error during startup, shutdown, or abnormal operation", "preventionBarriers": [ { - "id": "pb-sop", + "id": "barrier-46", "label": "Operating procedures and abnormal situation guidance", - "notes": "Written procedures for charge management, alarms, and emergency slowdown.", - "escalationFactors": [], + "degradationChains": [], "stack": [ { - "id": "stack-pb-sop-type", + "id": "stack-47", "field": "type", "label": "Active Human", "color": "#5dade2" }, { - "id": "stack-pb-sop-effectiveness", + "id": "stack-48", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-pb-sop-criticality", + "id": "stack-49", "field": "criticality", "label": "High", "color": "#566573" }, { - "id": "stack-pb-sop-responsible", - "field": "responsible", - "label": "Operations", - "color": "#ffffff" - }, - { - "id": "stack-pb-sop-validation", - "field": "validation", - "label": "Audit", - "color": "#ffffff" - }, - { - "id": "stack-pb-sop-status", + "id": "stack-50", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Written procedures for charge management, alarms, and emergency slowdown." }, { - "id": "pb-training", + "id": "barrier-51", "label": "Operator training and competency assurance", - "notes": "Refresher training on ammonia properties, alarms, and first actions.", - "escalationFactors": [], + "degradationChains": [], "stack": [ { - "id": "stack-pb-training-type", + "id": "stack-52", "field": "type", - "label": "Reactive Human", - "color": "#3498db" + "label": "Active Human", + "color": "#5dade2" }, { - "id": "stack-pb-training-effectiveness", + "id": "stack-53", "field": "effectiveness", "label": "Fair", "color": "#f1c40f" }, { - "id": "stack-pb-training-criticality", + "id": "stack-54", "field": "criticality", "label": "Medium", "color": "#7f8c8d" }, { - "id": "stack-pb-training-responsible", - "field": "responsible", - "label": "HSE Department", - "color": "#ffffff" - }, - { - "id": "stack-pb-training-validation", - "field": "validation", - "label": "Drills and Exercise", - "color": "#ffffff" - }, - { - "id": "stack-pb-training-status", + "id": "stack-55", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true - } - ] - }, - { - "id": "threat-external-fire", - "label": "External fire impinging on ammonia equipment or pipework", - "notes": "Warehouse fire, arcing electrical equipment, or hot work in adjacent area.", - "preventionBarriers": [ - { - "id": "pb-fire-prevention", - "label": "Fire prevention and hot-work controls in ammonia areas", - "notes": "Housekeeping, fire detection, hot-work permits, and separation from combustibles.", - "escalationFactors": [], - "stack": [ - { - "id": "stack-pb-fire-prevention-type", - "field": "type", - "label": "Active Human", - "color": "#5dade2" - }, - { - "id": "stack-pb-fire-prevention-effectiveness", - "field": "effectiveness", - "label": "Good", - "color": "#27ae60" - }, - { - "id": "stack-pb-fire-prevention-criticality", - "field": "criticality", - "label": "High", - "color": "#566573" - }, - { - "id": "stack-pb-fire-prevention-responsible", - "field": "responsible", - "label": "HSE Department", - "color": "#ffffff" - }, - { - "id": "stack-pb-fire-prevention-validation", - "field": "validation", - "label": "Audit", - "color": "#ffffff" - }, - { - "id": "stack-pb-fire-prevention-status", - "field": "status", - "label": "Available", - "color": "#1e8449" - } - ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Refresher training on ammonia properties, alarms, and first actions." } ] } ], "consequences": [ { - "id": "consequence-exposure", + "id": "consequence-56", "label": "Personnel harm due to toxic ammonia exposure", "mitigationBarriers": [ { - "id": "mb-detection", + "id": "barrier-57", "label": "Fixed ammonia gas detection and alarm", - "notes": "Audible/visual alarm, potential automatic isolation or ventilation actuation.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-calibration", - "label": "Sensor calibration overdue or drift", - "escalationBarriers": [ + "id": "chain-64", + "safeguards": [ { - "id": "eb-calibration-program", - "label": "Calibration management and functional test program", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-65", + "label": "Calibration management and functional test program" } - ] + ], + "degradationFactor": { + "id": "degradation-66", + "label": "Sensor calibration overdue or drift" + } } ], "stack": [ { - "id": "stack-mb-detection-type", + "id": "stack-58", "field": "type", "label": "Active Hardware", "color": "#48c9b0" }, { - "id": "stack-mb-detection-effectiveness", + "id": "stack-59", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-mb-detection-criticality", + "id": "stack-60", "field": "criticality", "label": "Very High", "color": "#2c3e50" }, { - "id": "stack-mb-detection-responsible", + "id": "stack-61", "field": "responsible", "label": "HSE Department", "color": "#ffffff" }, { - "id": "stack-mb-detection-validation", + "id": "stack-62", "field": "validation", "label": "Barrier Test", "color": "#ffffff" }, { - "id": "stack-mb-detection-status", + "id": "stack-63", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": false + "stackCollapsed": false, + "notes": "Audible/visual alarm, with potential automatic isolation or ventilation actuation." }, { - "id": "mb-ventilation", + "id": "barrier-67", "label": "Emergency ventilation and isolation valves", - "notes": "Reduce airborne concentration; close isolation valves where safe to do so.", - "escalationFactors": [], + "degradationChains": [], "stack": [ { - "id": "stack-mb-ventilation-type", + "id": "stack-68", "field": "type", "label": "Active Hardware", "color": "#48c9b0" }, { - "id": "stack-mb-ventilation-effectiveness", + "id": "stack-69", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-mb-ventilation-criticality", + "id": "stack-70", "field": "criticality", "label": "Very High", "color": "#2c3e50" }, { - "id": "stack-mb-ventilation-responsible", - "field": "responsible", - "label": "Tech Department", - "color": "#ffffff" - }, - { - "id": "stack-mb-ventilation-validation", - "field": "validation", - "label": "Barrier Test", - "color": "#ffffff" - }, - { - "id": "stack-mb-ventilation-status", + "id": "stack-71", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true - }, - { - "id": "mb-evacuation", - "label": "Personnel evacuation and emergency muster", - "notes": "Alarm response, headcount, and upwind assembly per ERP.", - "escalationFactors": [], - "stack": [ - { - "id": "stack-mb-evacuation-type", - "field": "type", - "label": "Reactive Human", - "color": "#3498db" - }, - { - "id": "stack-mb-evacuation-effectiveness", - "field": "effectiveness", - "label": "Good", - "color": "#27ae60" - }, - { - "id": "stack-mb-evacuation-criticality", - "field": "criticality", - "label": "High", - "color": "#566573" - }, - { - "id": "stack-mb-evacuation-responsible", - "field": "responsible", - "label": "HSE Department", - "color": "#ffffff" - }, - { - "id": "stack-mb-evacuation-validation", - "field": "validation", - "label": "Drills and Exercise", - "color": "#ffffff" - }, - { - "id": "stack-mb-evacuation-status", - "field": "status", - "label": "Available", - "color": "#1e8449" - } - ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Reduce airborne concentration; close isolation valves where safe to do so." } ] }, { - "id": "consequence-environment", + "id": "consequence-72", "label": "Environmental damage due to ammonia release", "mitigationBarriers": [ { - "id": "mb-containment", + "id": "barrier-73", "label": "Containment sump and emergency dilution / knock-down", - "notes": "Limit spread to drains, waterways, or off-site receptors.", - "escalationFactors": [], + "degradationChains": [], "stack": [ { - "id": "stack-mb-containment-type", + "id": "stack-74", "field": "type", "label": "Passive Hardware", "color": "#7f8c8d" }, { - "id": "stack-mb-containment-effectiveness", + "id": "stack-75", "field": "effectiveness", "label": "Fair", "color": "#f1c40f" }, { - "id": "stack-mb-containment-criticality", + "id": "stack-76", "field": "criticality", "label": "High", "color": "#566573" }, { - "id": "stack-mb-containment-responsible", - "field": "responsible", - "label": "Tech Department", - "color": "#ffffff" - }, - { - "id": "stack-mb-containment-validation", - "field": "validation", - "label": "Inspection", - "color": "#ffffff" - }, - { - "id": "stack-mb-containment-status", + "id": "stack-77", "field": "status", "label": "Degraded", "color": "#f39c12" } ], - "stackCollapsed": true - }, - { - "id": "mb-notification", - "label": "Regulatory reporting and community notification", - "notes": "RMP/SEVESO-style reporting and liaison with authorities as required.", - "escalationFactors": [], - "stack": [ - { - "id": "stack-mb-notification-type", - "field": "type", - "label": "Reactive Human", - "color": "#3498db" - }, - { - "id": "stack-mb-notification-effectiveness", - "field": "effectiveness", - "label": "Good", - "color": "#27ae60" - }, - { - "id": "stack-mb-notification-criticality", - "field": "criticality", - "label": "Medium", - "color": "#7f8c8d" - }, - { - "id": "stack-mb-notification-responsible", - "field": "responsible", - "label": "HSE Department", - "color": "#ffffff" - }, - { - "id": "stack-mb-notification-validation", - "field": "validation", - "label": "Audit", - "color": "#ffffff" - }, - { - "id": "stack-mb-notification-status", - "field": "status", - "label": "Available", - "color": "#1e8449" - } - ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Limit spread to drains, waterways, or off-site receptors." } ] }, { - "id": "consequence-business", + "id": "consequence-78", "label": "Operational loss due to cold-chain interruption", "mitigationBarriers": [ { - "id": "mb-erp", + "id": "barrier-79", "label": "Emergency response plan and qualified contractor standby", - "notes": "Ammonia-qualified emergency repair and product salvage / transfer plan.", - "escalationFactors": [ + "degradationChains": [ { - "id": "ef-contractor-gap", - "label": "Contractor availability or response time not assured", - "escalationBarriers": [ + "id": "chain-85", + "safeguards": [ { - "id": "eb-contractor-retainer", - "label": "Pre-qualified contractor agreement and annual drill", - "escalationFactors": [], - "stack": [], - "stackCollapsed": true + "id": "safeguard-86", + "label": "Pre-qualified contractor agreement and annual drill" } - ] + ], + "degradationFactor": { + "id": "degradation-87", + "label": "Contractor availability or response time not assured" + } } ], "stack": [ { - "id": "stack-mb-erp-type", + "id": "stack-80", "field": "type", - "label": "Reactive Human", - "color": "#3498db" + "label": "Active Human", + "color": "#5dade2" }, { - "id": "stack-mb-erp-effectiveness", + "id": "stack-81", "field": "effectiveness", "label": "Good", "color": "#27ae60" }, { - "id": "stack-mb-erp-criticality", + "id": "stack-82", "field": "criticality", "label": "High", "color": "#566573" }, { - "id": "stack-mb-erp-responsible", + "id": "stack-83", "field": "responsible", "label": "Management", "color": "#ffffff" }, { - "id": "stack-mb-erp-validation", - "field": "validation", - "label": "Drills and Exercise", - "color": "#ffffff" - }, - { - "id": "stack-mb-erp-status", + "id": "stack-84", "field": "status", "label": "Available", "color": "#1e8449" } ], - "stackCollapsed": true + "stackCollapsed": true, + "notes": "Ammonia-qualified emergency repair and product salvage / transfer plan." } ] } @@ -783,6 +545,6 @@ "panX": 0, "panY": 0 }, - "createdAt": "2026-06-19T00:00:00.000Z", - "updatedAt": "2026-06-19T12:05:10.306Z" + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z" } diff --git a/examples/steamcracker.bowtie b/examples/steamcracker.bowtie index 4865971..0564c7d 100644 --- a/examples/steamcracker.bowtie +++ b/examples/steamcracker.bowtie @@ -1,67 +1,89 @@ { - "id": "example-steamcracker", - "name": "Defective Steamcracker", - "hazard": "High pressure ethylene cracking system", - "topEvent": "Loss of containment in steamcracker furnace", + "id": "bowtie-0", + "name": "Defective steamcracker", + "events": [ + { + "id": "event-1", + "label": "Loss of containment in steamcracker furnace", + "hazard": "High-pressure ethylene cracking system", + "transitionBarriers": [] + } + ], "threats": [ { - "id": "threat-1", + "id": "threat-2", "label": "Tube corrosion / fatigue", "preventionBarriers": [ { - "id": "pb-1", + "id": "barrier-3", "label": "Periodic tube inspection program", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true }, { - "id": "pb-2", + "id": "barrier-4", "label": "Material selection standards", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true } ] }, { - "id": "threat-2", + "id": "threat-5", "label": "Operator error during startup", "preventionBarriers": [ { - "id": "pb-3", + "id": "barrier-6", "label": "Standard operating procedures", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true } ] } ], "consequences": [ { - "id": "consequence-1", + "id": "consequence-7", "label": "Fire / explosion", "mitigationBarriers": [ { - "id": "mb-1", + "id": "barrier-8", "label": "Deluge and fire suppression systems", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true } ] }, { - "id": "consequence-2", + "id": "consequence-9", "label": "Toxic gas release", "mitigationBarriers": [ { - "id": "mb-2", + "id": "barrier-10", "label": "Gas detection and isolation", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true }, { - "id": "mb-3", + "id": "barrier-11", "label": "Emergency response plan", - "escalationFactors": [] + "degradationChains": [], + "stack": [], + "stackCollapsed": true } ] } ], - "view": { "zoom": 1, "panX": 0, "panY": 0 }, - "createdAt": "2026-06-09T00:00:00.000Z", - "updatedAt": "2026-06-09T00:00:00.000Z" + "view": { + "zoom": 1, + "panX": 0, + "panY": 0 + }, + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z" } diff --git a/manifest.json b/manifest.json index b11b4f4..b73b445 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "o-tie", "name": "O-Tie", - "version": "1.0.7", + "version": "1.0.8", "minAppVersion": "1.4.0", - "description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.", + "description": "Build risk bowtie diagrams in your vault: threats, prevention and mitigation barriers, escalation factors, barrier analysis stacks, and PNG export.", "author": "Andre482", "authorUrl": "https://github.com/Andre482", "isDesktopOnly": false diff --git a/package-lock.json b/package-lock.json index 2d222fd..b7713aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "o-tie", - "version": "1.0.2", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "o-tie", - "version": "1.0.2", + "version": "1.0.8", "license": "MIT", "dependencies": { "html-to-image": "^1.11.13" @@ -21,7 +21,8 @@ "playwright": "^1.61.0", "tslib": "^2.6.2", "typescript": "^5.3.3", - "typescript-eslint": "^8.61.1" + "typescript-eslint": "^8.61.1", + "vitest": "^4.1.9" } }, "node_modules/@codemirror/state": { @@ -49,6 +50,40 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -738,6 +773,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -784,6 +826,35 @@ "ret": "~0.1.10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgr/core": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", @@ -797,6 +868,288 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -804,6 +1157,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/codemirror": { "version": "5.60.8", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", @@ -814,6 +1196,13 @@ "@types/tern": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1096,6 +1485,92 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1319,6 +1794,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1428,6 +1913,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1472,6 +1967,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -1610,6 +2112,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1798,6 +2310,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2637,6 +3156,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2647,6 +3176,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3742,6 +4281,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3778,6 +4590,16 @@ "loose-envify": "cli.js" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3838,6 +4660,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4012,6 +4853,20 @@ "@codemirror/view": "6.38.6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4120,6 +4975,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -4175,6 +5044,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4332,6 +5230,40 @@ "node": ">=0.12" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -4579,6 +5511,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -4780,6 +5743,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4797,6 +5777,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toml-eslint-parser": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", @@ -5037,6 +6027,216 @@ "punycode": "^2.1.0" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -5150,6 +6350,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 1215913..bb6d629 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "o-tie", - "version": "1.0.7", + "version": "1.0.8", "description": "Obsidian plugin for building risk bowtie diagrams", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "lint": "eslint src/", + "test": "vitest run", + "screenshots": "node scripts/render-screenshots.mjs", + "examples": "node scripts/generate-examples.mjs", "deploy": "npm run build && node deploy.mjs", "version": "node version-bump.mjs && git add manifest.json versions.json" }, @@ -26,7 +29,8 @@ "playwright": "^1.61.0", "tslib": "^2.6.2", "typescript": "^5.3.3", - "typescript-eslint": "^8.61.1" + "typescript-eslint": "^8.61.1", + "vitest": "^4.1.9" }, "dependencies": { "html-to-image": "^1.11.13" diff --git a/scripts/engine.mjs b/scripts/engine.mjs new file mode 100644 index 0000000..30e033d --- /dev/null +++ b/scripts/engine.mjs @@ -0,0 +1,34 @@ +// Bundles the plugin's pure layout/model TypeScript so Node scripts can use it. +import { build } from "esbuild"; +import { rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const root = join(scriptsDir, ".."); + +export async function loadEngine() { + const result = await build({ + stdin: { + contents: ` + export { layoutBowtie, DEFAULT_LAYOUT, barrierHeaderHeightFor } from "./src/layout"; + export * as model from "./src/model"; + `, + resolveDir: root, + loader: "ts", + sourcefile: "engine-entry.ts", + }, + bundle: true, + format: "esm", + platform: "node", + write: false, + logLevel: "silent", + }); + const tmp = join(scriptsDir, `_engine-${process.pid}.mjs`); + writeFileSync(tmp, result.outputFiles[0].text); + try { + return await import(pathToFileURL(tmp).href); + } finally { + rmSync(tmp, { force: true }); + } +} diff --git a/scripts/generate-examples.mjs b/scripts/generate-examples.mjs new file mode 100644 index 0000000..f3c054d --- /dev/null +++ b/scripts/generate-examples.mjs @@ -0,0 +1,307 @@ +// Regenerates the committed example .bowtie files in the current schema. +// +// Authoring the scenarios through the real model guarantees the files validate +// and stay in canonical form. Run: npm run examples + +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEngine } from "./engine.mjs"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const examplesDir = join(scriptsDir, "..", "examples"); +const FIXED_TS = "2026-06-12T00:00:00.000Z"; + +const engine = await loadEngine(); +const model = engine.model; + +function fieldColor(fieldKey, label) { + const field = model.BARRIER_STACK_FIELDS.find((f) => f.key === fieldKey); + return field?.options.find((o) => o.label === label)?.color; +} + +function barrier(label, opts = {}) { + const b = model.createBarrier(label); + if (opts.notes) b.notes = opts.notes; + if (opts.stack) { + b.stack = opts.stack.map(([field, value]) => { + const color = fieldColor(field, value); + if (color === undefined) throw new Error(`Unknown ${field} option: ${value}`); + return model.createBarrierStackItem(value, field, color); + }); + model.sortBarrierStack(b.stack); + b.stackCollapsed = opts.expanded !== true; + } + if (opts.chains) { + b.degradationChains = opts.chains.map((c) => { + const chain = model.createDegradationChain(c.factor); + chain.safeguards = (c.safeguards ?? []).map((s) => model.createEscalationNode(s)); + return chain; + }); + } + return b; +} + +function threat(label, notes, barriers) { + const t = model.createThreat(label); + if (notes) t.notes = notes; + t.preventionBarriers = barriers; + return t; +} + +function consequence(label, notes, barriers) { + const c = model.createConsequence(label); + if (notes) c.notes = notes; + c.mitigationBarriers = barriers; + return c; +} + +// Stable, readable ids so regeneration produces minimal diffs. +function reassignIds(bowtie) { + let n = 0; + const id = (prefix) => `${prefix}-${n++}`; + bowtie.id = id("bowtie"); + for (const event of bowtie.events) { + event.id = id("event"); + event.transitionBarriers.forEach(reassignBarrier(id)); + } + for (const t of bowtie.threats) { + t.id = id("threat"); + t.preventionBarriers.forEach(reassignBarrier(id)); + } + for (const c of bowtie.consequences) { + c.id = id("consequence"); + c.mitigationBarriers.forEach(reassignBarrier(id)); + } + bowtie.createdAt = FIXED_TS; + bowtie.updatedAt = FIXED_TS; + return bowtie; + + function reassignBarrier(idFn) { + return (b) => { + b.id = idFn("barrier"); + for (const item of b.stack ?? []) item.id = idFn("stack"); + for (const chain of b.degradationChains) { + chain.id = idFn("chain"); + for (const sg of chain.safeguards) sg.id = idFn("safeguard"); + chain.degradationFactor.id = idFn("degradation"); + } + }; + } +} + +function buildAmmonia() { + const bt = model.createBowtie("Cold storage ammonia refrigeration"); + bt.events[0].label = "Loss of primary containment from ammonia refrigeration circuit"; + bt.events[0].hazard = "Pressurised anhydrous ammonia (NH3) refrigeration inventory"; + + bt.threats = [ + threat( + "Corrosion or fatigue of piping, vessels, or evaporator coils", + "Credible in aged plant with cyclic thermal loading, condensate, or corrosion under insulation.", + [ + barrier("Mechanical integrity inspection program", { + notes: "NDT, thickness monitoring, and replacement intervals per IIAR-6 / company standards.", + expanded: true, + stack: [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["responsible", "Tech Department"], + ["validation", "Inspection"], + ["status", "Available"], + ], + chains: [ + { + factor: "Deferred inspection due to production pressure", + safeguards: ["QA audit of mechanical integrity backlog"], + }, + ], + }), + barrier("Materials of construction and corrosion allowance", { + notes: "Compatible metallurgy, coatings, and design margin for operating conditions.", + stack: [ + ["type", "Passive Hardware"], + ["effectiveness", "Good"], + ["criticality", "High"], + ["status", "Available"], + ], + }), + ] + ), + threat( + "Forklift or vehicle impact on exposed ammonia piping", + "Common in warehouse aisles adjacent to machine rooms or overhead pipe runs.", + [ + barrier("Physical protection and traffic segregation", { + notes: "Bollards, rack guards, marked routes, and restricted machine-room access.", + stack: [ + ["type", "Passive Hardware"], + ["effectiveness", "Good"], + ["criticality", "High"], + ["status", "Available"], + ], + chains: [ + { + factor: "Bollards removed or bypassed for convenience", + safeguards: ["Supervisor housekeeping and barrier inspection rounds"], + }, + ], + }), + ] + ), + threat("Overpressure in low-side or high-side refrigeration circuit", undefined, [ + barrier("Pressure relief devices and high-pressure interlocks", { + notes: "PSVs discharge to a safe location; alarm and trip on abnormal pressure.", + stack: [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["status", "Available"], + ], + chains: [ + { + factor: "Relief device isolated, blocked, or inspection overdue", + safeguards: ["PSV inspection and register management program"], + }, + ], + }), + ]), + threat("Maintenance isolation or re-commissioning error", undefined, [ + barrier("Permit-to-work and LOTO procedures", { + notes: "Verified isolation, zero-energy confirmation, and ammonia-specific competency.", + stack: [ + ["type", "Active Human"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["status", "Available"], + ], + chains: [ + { + factor: "Technician competency not verified for ammonia work", + safeguards: ["Competency assessment and authorization records"], + }, + ], + }), + ]), + threat("Operator error during startup, shutdown, or abnormal operation", undefined, [ + barrier("Operating procedures and abnormal situation guidance", { + notes: "Written procedures for charge management, alarms, and emergency slowdown.", + stack: [ + ["type", "Active Human"], + ["effectiveness", "Good"], + ["criticality", "High"], + ["status", "Available"], + ], + }), + barrier("Operator training and competency assurance", { + notes: "Refresher training on ammonia properties, alarms, and first actions.", + stack: [ + ["type", "Active Human"], + ["effectiveness", "Fair"], + ["criticality", "Medium"], + ["status", "Available"], + ], + }), + ]), + ]; + + bt.consequences = [ + consequence("Personnel harm due to toxic ammonia exposure", undefined, [ + barrier("Fixed ammonia gas detection and alarm", { + notes: "Audible/visual alarm, with potential automatic isolation or ventilation actuation.", + expanded: true, + stack: [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["responsible", "HSE Department"], + ["validation", "Barrier Test"], + ["status", "Available"], + ], + chains: [ + { + factor: "Sensor calibration overdue or drift", + safeguards: ["Calibration management and functional test program"], + }, + ], + }), + barrier("Emergency ventilation and isolation valves", { + notes: "Reduce airborne concentration; close isolation valves where safe to do so.", + stack: [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["status", "Available"], + ], + }), + ]), + consequence("Environmental damage due to ammonia release", undefined, [ + barrier("Containment sump and emergency dilution / knock-down", { + notes: "Limit spread to drains, waterways, or off-site receptors.", + stack: [ + ["type", "Passive Hardware"], + ["effectiveness", "Fair"], + ["criticality", "High"], + ["status", "Degraded"], + ], + }), + ]), + consequence("Operational loss due to cold-chain interruption", undefined, [ + barrier("Emergency response plan and qualified contractor standby", { + notes: "Ammonia-qualified emergency repair and product salvage / transfer plan.", + stack: [ + ["type", "Active Human"], + ["effectiveness", "Good"], + ["criticality", "High"], + ["responsible", "Management"], + ["status", "Available"], + ], + chains: [ + { + factor: "Contractor availability or response time not assured", + safeguards: ["Pre-qualified contractor agreement and annual drill"], + }, + ], + }), + ]), + ]; + + return reassignIds(bt); +} + +function buildSteamcracker() { + const bt = model.createBowtie("Defective steamcracker"); + bt.events[0].label = "Loss of containment in steamcracker furnace"; + bt.events[0].hazard = "High-pressure ethylene cracking system"; + + bt.threats = [ + threat("Tube corrosion / fatigue", undefined, [ + barrier("Periodic tube inspection program"), + barrier("Material selection standards"), + ]), + threat("Operator error during startup", undefined, [ + barrier("Standard operating procedures"), + ]), + ]; + bt.consequences = [ + consequence("Fire / explosion", undefined, [ + barrier("Deluge and fire suppression systems"), + ]), + consequence("Toxic gas release", undefined, [ + barrier("Gas detection and isolation"), + barrier("Emergency response plan"), + ]), + ]; + + return reassignIds(bt); +} + +function write(name, bowtie) { + const file = join(examplesDir, name); + writeFileSync(file, `${model.serializeBowtie(bowtie)}\n`); + console.log(`Wrote ${file}`); +} + +write("cold-storage-ammonia.bowtie", buildAmmonia()); +write("steamcracker.bowtie", buildSteamcracker()); diff --git a/scripts/render-screenshots.mjs b/scripts/render-screenshots.mjs new file mode 100644 index 0000000..b7cd399 --- /dev/null +++ b/scripts/render-screenshots.mjs @@ -0,0 +1,279 @@ +// Generates authentic O-Tie diagram screenshots for the README. +// +// It reuses the plugin's real layout engine (src/layout.ts) and stylesheet +// (styles.css), reproduces the same node/edge DOM the plugin renders, and +// captures it with headless Chromium. The output mirrors the plugin's "Export +// as image" appearance (no editing chrome). +// +// Usage: npm run screenshots +// Requires Chromium once: npx playwright install chromium + +import { chromium } from "playwright"; +import { mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEngine } from "./engine.mjs"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const root = join(scriptsDir, ".."); +const assetsDir = join(root, "assets"); +const stylesCss = readFileSync(join(root, "styles.css"), "utf8"); + +// Light-theme stand-ins for the Obsidian CSS variables the canvas relies on. +const THEME_VARS = ` + --background-primary: #ffffff; + --background-secondary: #f4f6f8; + --background-modifier-border: #d6dae0; + --background-modifier-border-hover: #c2c8d0; + --background-modifier-hover: #ecedf1; + --text-normal: #1a1a1a; + --text-muted: #5d6d7e; + --text-error: #c0392b; + --interactive-accent: #7b6cf6; + --radius-s: 4px; + --font-ui-smaller: 11px; + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +`; + +const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]); + +function isLightStackColor(color) { + if (LIGHT_STACK_ROW_COLORS.has(color)) return true; + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return false; + const n = parseInt(match[1], 16); + const r = (n >> 16) & 0xff; + const g = (n >> 8) & 0xff; + const b = n & 0xff; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function fieldColor(model, fieldKey, label) { + const field = model.BARRIER_STACK_FIELDS.find((f) => f.key === fieldKey); + const option = field?.options.find((o) => o.label === label); + return option?.color; +} + +function addStack(model, barrier, rows) { + barrier.stack = rows.map(([field, label]) => + model.createBarrierStackItem(label, field, fieldColor(model, field, label)) + ); + model.sortBarrierStack?.(barrier.stack); + barrier.stackCollapsed = false; +} + +// Representative cold-storage ammonia scenario (matches the docs example). +function buildHeroBowtie(model) { + const bt = model.createBowtie("Cold storage ammonia — LOPC"); + bt.events[0].label = "Loss of primary containment"; + bt.events[0].hazard = "Pressurised anhydrous ammonia"; + + const mi = model.createBarrier("Mechanical integrity inspection"); + addStack(model, mi, [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ]); + const chain = model.createDegradationChain("Deferred inspection"); + chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")]; + mi.degradationChains = [chain]; + + const corrosion = model.createThreat("Corrosion / fatigue of piping"); + corrosion.preventionBarriers = [mi, model.createBarrier("Material selection standards")]; + + const impact = model.createThreat("Forklift impact on pipework"); + impact.preventionBarriers = [model.createBarrier("Physical protection & traffic segregation")]; + + const maint = model.createThreat("Maintenance isolation error"); + maint.preventionBarriers = [model.createBarrier("Permit-to-work and LOTO")]; + + bt.threats = [corrosion, impact, maint]; + + const toxic = model.createConsequence("Toxic exposure to personnel"); + const detect = model.createBarrier("Fixed gas detection & alarm"); + addStack(model, detect, [ + ["type", "Active Hardware+Human"], + ["status", "Available"], + ]); + toxic.mitigationBarriers = [detect, model.createBarrier("Emergency ventilation & isolation")]; + + const env = model.createConsequence("Environmental release"); + env.mitigationBarriers = [model.createBarrier("Containment sump & dilution")]; + + const biz = model.createConsequence("Business interruption"); + biz.mitigationBarriers = [model.createBarrier("Emergency response plan")]; + + bt.consequences = [toxic, env, biz]; + return bt; +} + +// Minimal scenario that showcases a single barrier's analysis stack + escalation. +function buildBarrierBowtie(model) { + const bt = model.createBowtie("Barrier analysis"); + bt.events[0].label = "Loss of containment"; + bt.events[0].hazard = "Pressurised ammonia"; + + const mi = model.createBarrier("Mechanical integrity inspection"); + addStack(model, mi, [ + ["type", "Active Hardware"], + ["effectiveness", "Good"], + ["criticality", "Very High"], + ["responsible", "Tech Department"], + ["validation", "Inspection"], + ["status", "Available"], + ]); + const chain = model.createDegradationChain("Deferred inspection"); + chain.safeguards = [model.createEscalationNode("QA audit of MI backlog")]; + mi.degradationChains = [chain]; + + const threat = model.createThreat("Corrosion / fatigue"); + threat.preventionBarriers = [mi]; + bt.threats = [threat]; + bt.consequences = [model.createConsequence("Toxic release")]; + return bt; +} + +function renderNodeHtml(node, barriersById) { + const wrapStyle = `left:${node.x}px;top:${node.y}px;width:${node.width}px;height:${node.height}px`; + const inner = renderNodeInner(node, barriersById); + return `
${inner}
`; +} + +function renderNodeInner(node, barriersById) { + const subtitle = escapeHtml(node.subtitle); + const label = escapeHtml(node.label); + + if (node.kind === "topEvent") { + return ( + `
` + + `
${label}
` + + `
${subtitle}
` + ); + } + + const isBarrier = + node.kind === "preventionBarrier" || + node.kind === "mitigationBarrier" || + node.kind === "transitionBarrier"; + + if (isBarrier) { + const barrier = barriersById.get(node.ref.barrierId); + const headerH = barrierHeaderHeight(barrier); + let html = `
`; + html += `
`; + html += `
${subtitle}
`; + html += `
${label}
`; + html += `
`; + const stack = barrier?.stack ?? []; + if (stack.length > 0 && barrier?.stackCollapsed === false) { + html += `
`; + for (const item of stack) { + const classes = ["o-tie-stack-row"]; + if (item.field) classes.push("o-tie-stack-row-preset"); + const style = [`height:${ROW_H}px`]; + if (item.color) { + style.push(`background-color:${item.color}`); + if (isLightStackColor(item.color)) classes.push("o-tie-stack-row-light"); + } + html += `
`; + html += `
${escapeHtml(item.label)}
`; + } + html += `
`; + } + html += `
`; + return html; + } + + return ( + `
` + + `
${subtitle}
` + + `
${label}
` + ); +} + +function renderEdgesSvg(layout) { + const size = 8; + let svg = ``; + for (const edge of layout.edges) { + svg += ``; + } + for (const edge of layout.edges) { + const { x, y, angleDeg } = edge.arrow; + svg += ``; + svg += ``; + } + svg += ``; + return svg; +} + +function pageHtml(layout, barriersById) { + const nodes = layout.nodes.map((n) => renderNodeHtml(n, barriersById)).join(""); + return ` +
+ ${renderEdgesSvg(layout)} +
${nodes}
+
+ `; +} + +// Header height calculator captured from the engine at runtime. +let barrierHeaderHeight = () => 52; +let ROW_H = 24; + +function indexBarriers(bowtie) { + const map = new Map(); + const add = (b) => map.set(b.id, b); + for (const e of bowtie.events) e.transitionBarriers.forEach(add); + for (const t of bowtie.threats) t.preventionBarriers.forEach(add); + for (const c of bowtie.consequences) c.mitigationBarriers.forEach(add); + return map; +} + +async function capture(page, engine, bowtie, outFile) { + const layout = engine.layoutBowtie(bowtie, engine.DEFAULT_LAYOUT); + ROW_H = engine.DEFAULT_LAYOUT.barrierStackRowHeight; + barrierHeaderHeight = (barrier) => + barrier ? engine.barrierHeaderHeightFor(barrier, engine.DEFAULT_LAYOUT) : engine.DEFAULT_LAYOUT.barrierHeaderHeight; + const barriersById = indexBarriers(bowtie); + await page.setContent(pageHtml(layout, barriersById), { waitUntil: "networkidle" }); + const frame = page.locator(".shot-frame"); + await frame.screenshot({ path: outFile }); + console.log(`Wrote ${outFile}`); +} + +async function main() { + mkdirSync(assetsDir, { recursive: true }); + const engine = await loadEngine(); + const browser = await chromium.launch(); + const page = await browser.newPage({ deviceScaleFactor: 2 }); + try { + await capture(page, engine, buildHeroBowtie(engine.model), join(assetsDir, "screenshot-diagram.png")); + await capture(page, engine, buildBarrierBowtie(engine.model), join(assetsDir, "screenshot-barrier.png")); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/bowtieView.ts b/src/bowtieView.ts index 4ecd0c5..9064486 100644 --- a/src/bowtieView.ts +++ b/src/bowtieView.ts @@ -1,4 +1,4 @@ -import { Menu, Notice, Platform, TextFileView, TFile, WorkspaceLeaf, setIcon } from "obsidian"; +import { Menu, Notice, Platform, TextFileView, WorkspaceLeaf, setIcon } from "obsidian"; import { ExportImageModal } from "./exportImageModal"; import { HelpModal } from "./helpModal"; import type { BowtieExportViewport } from "./exportImage"; @@ -22,7 +22,6 @@ import { BOWTIE_VIEW_TYPE, createBarrier, createBarrierStackItem, - cloneBowtie, createBowtie, createConsequence, createDegradationChain, @@ -37,52 +36,11 @@ import { serializeBowtie, sortBarrierStack, touchBowtie, - bowtieStructureSignature, } from "./model"; - -const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [ - { color: "#5dade2", label: "Sky blue" }, - { color: "#3498db", label: "Blue" }, - { color: "#48c9b0", label: "Teal" }, - { color: "#1abc9c", label: "Mint" }, - { color: "#7f8c8d", label: "Gray" }, - { color: "#1e8449", label: "Dark green" }, - { color: "#27ae60", label: "Green" }, - { color: "#f1c40f", label: "Yellow" }, - { color: "#e67e22", label: "Orange" }, - { color: "#c0392b", label: "Red" }, - { color: "#2c3e50", label: "Navy" }, - { color: "#566573", label: "Slate" }, - { color: "#ffffff", label: "White" }, - { color: "#f4ecf7", label: "Light purple" }, - { color: "#eafaf1", label: "Light green" }, -]; - -const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]); - -function isLightStackColor(color: string): boolean { - if (LIGHT_STACK_ROW_COLORS.has(color)) return true; - const match = /^#([0-9a-f]{6})$/i.exec(color); - if (!match) return false; - const n = parseInt(match[1], 16); - const r = (n >> 16) & 0xff; - const g = (n >> 8) & 0xff; - const b = n & 0xff; - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.62; -} - -function createColorMenuTitle(color: string, label: string): DocumentFragment { - const frag = activeDocument.createDocumentFragment(); - const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" }); - const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" }); - swatch.setCssStyles({ backgroundColor: color }); - if (LIGHT_STACK_ROW_COLORS.has(color)) { - swatch.addClass("o-tie-color-swatch-light"); - } - wrap.createEl("span", { cls: "o-tie-color-menu-label", text: label }); - return frag; -} +import { ExternalSync } from "./externalSync"; +import { BowtieHistory } from "./history"; +import { computeFit, computeZoomAt, normalizeWheelDelta, wheelFactorFromDelta } from "./panZoom"; +import { STACK_ROW_COLOR_OPTIONS, createColorMenuTitle, isLightStackColor } from "./stackRows"; export { BOWTIE_VIEW_TYPE }; @@ -105,6 +63,9 @@ export class BowtieView extends TextFileView { private panPointerId: number | null = null; private activePointers = new Map(); private pinchLastDistance: number | null = null; + private gestureMoved = false; + private panStartedOnNode = false; + private suppressNextClick = false; private saveTimeout: number | null = null; private viewSaveTimeout: number | null = null; private viewShellReady = false; @@ -125,14 +86,13 @@ export class BowtieView extends TextFileView { private toolbarCollapsed = false; private undoBtn: HTMLButtonElement | null = null; private redoBtn: HTMLButtonElement | null = null; - private undoStack: Bowtie[] = []; - private redoStack: Bowtie[] = []; - private undoSelectionStack: (NodeRef | null)[] = []; - private redoSelectionStack: (NodeRef | null)[] = []; + private history = new BowtieHistory(BowtieView.MAX_UNDO); private isRestoringHistory = false; private static readonly MAX_UNDO = 50; private static readonly GRID_SIZE = 20; private stackCollapseBackup: Map | null = null; + private loadError = false; + private rawData: string | null = null; private get useCssZoom(): boolean { // CSS zoom distorts border-radius on child buttons in mobile WebKit. @@ -166,6 +126,11 @@ export class BowtieView extends TextFileView { } getViewData(): string { + // Preserve the original bytes of a file we could not parse so autosave + // never overwrites a malformed (but potentially recoverable) file. + if (this.loadError && this.rawData !== null) { + return this.rawData; + } return serializeBowtie(this.bowtie); } @@ -173,8 +138,15 @@ export class BowtieView extends TextFileView { if (clear) this.clear(); try { this.bowtie = deserializeBowtie(data); + this.loadError = false; + this.rawData = null; } catch { + this.loadError = true; + this.rawData = data; this.bowtie = createBowtie("Untitled"); + new Notice( + "Could not read this .bowtie file — it may be malformed. The file has not been changed; editing here will overwrite it." + ); } this.resetHistory(); this.render(); @@ -188,6 +160,8 @@ export class BowtieView extends TextFileView { this.toolbarTitleEl = null; this.undoBtn = null; this.redoBtn = null; + this.loadError = false; + this.rawData = null; this.resetHistory(); } @@ -235,29 +209,22 @@ export class BowtieView extends TextFileView { } private resetHistory(): void { - this.undoStack = []; - this.redoStack = []; - this.undoSelectionStack = []; - this.redoSelectionStack = []; + this.history.reset(); this.updateUndoRedoButtons(); } private commitEdit(): void { if (this.isRestoringHistory) return; - this.undoStack.push(cloneBowtie(this.bowtie)); - this.undoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); - if (this.undoStack.length > BowtieView.MAX_UNDO) { - this.undoStack.shift(); - this.undoSelectionStack.shift(); - } - this.redoStack = []; - this.redoSelectionStack = []; + // A deliberate user edit takes ownership of the file; allow saves again. + this.loadError = false; + this.rawData = null; + this.history.record(this.bowtie, this.selectedRef); this.updateUndoRedoButtons(); } private updateUndoRedoButtons(): void { - if (this.undoBtn) this.undoBtn.disabled = this.undoStack.length === 0; - if (this.redoBtn) this.redoBtn.disabled = this.redoStack.length === 0; + if (this.undoBtn) this.undoBtn.disabled = !this.history.canUndo; + if (this.redoBtn) this.redoBtn.disabled = !this.history.canRedo; } private nodeRefExists(ref: NodeRef): boolean { @@ -294,21 +261,15 @@ export class BowtieView extends TextFileView { } private undo(): void { - if (this.undoStack.length === 0) return; - this.redoStack.push(cloneBowtie(this.bowtie)); - this.redoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); - const snapshot = this.undoStack.pop()!; - const selectedRef = this.undoSelectionStack.pop() ?? null; - this.restoreHistorySnapshot(snapshot, selectedRef); + const snapshot = this.history.undo(this.bowtie, this.selectedRef); + if (!snapshot) return; + this.restoreHistorySnapshot(snapshot.bowtie, snapshot.selection); } private redo(): void { - if (this.redoStack.length === 0) return; - this.undoStack.push(cloneBowtie(this.bowtie)); - this.undoSelectionStack.push(this.selectedRef ? { ...this.selectedRef } : null); - const snapshot = this.redoStack.pop()!; - const selectedRef = this.redoSelectionStack.pop() ?? null; - this.restoreHistorySnapshot(snapshot, selectedRef); + const snapshot = this.history.redo(this.bowtie, this.selectedRef); + if (!snapshot) return; + this.restoreHistorySnapshot(snapshot.bowtie, snapshot.selection); } private render(): void { @@ -392,10 +353,10 @@ export class BowtieView extends TextFileView { this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" }); const addGroup = this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-group" }); - this.createToolbarBtn(addGroup, "+ Threat", () => this.addThreat(), { primary: true }); - this.createToolbarBtn(addGroup, "+ Consequence", () => this.addConsequence(), { primary: true }); - this.createToolbarBtn(addGroup, "+ Event", () => this.addTopEvent(), { primary: true }); - this.createToolbarBtn(addGroup, "+ Barrier", () => this.addBarrierToSelection()); + this.createToolbarBtn(addGroup, "+ threat", () => this.addThreat(), { primary: true }); + this.createToolbarBtn(addGroup, "+ consequence", () => this.addConsequence(), { primary: true }); + this.createToolbarBtn(addGroup, "+ event", () => this.addTopEvent(), { primary: true }); + this.createToolbarBtn(addGroup, "+ barrier", () => this.addBarrierToSelection()); this.toolbarActionsEl.createDiv({ cls: "o-tie-toolbar-separator" }); @@ -524,7 +485,7 @@ export class BowtieView extends TextFileView { this.selectedRef.kind === "mitigationBarrier" || this.selectedRef.kind === "transitionBarrier" ) { - const stackBtn = actions.createEl("button", { text: "+ Stack row", cls: "mod-small" }); + const stackBtn = actions.createEl("button", { text: "+ stack row", cls: "mod-small" }); stackBtn.addEventListener("click", (e) => { const rect = stackBtn.getBoundingClientRect(); this.showAddStackRowMenu( @@ -539,7 +500,7 @@ export class BowtieView extends TextFileView { } if (this.selectedRef.kind === "degradationFactor") { - const sgBtn = actions.createEl("button", { text: "+ Safeguard", cls: "mod-small" }); + const sgBtn = actions.createEl("button", { text: "+ safeguard", cls: "mod-small" }); sgBtn.addEventListener("click", () => { this.addSafeguard(this.selectedRef!); }); @@ -549,7 +510,7 @@ export class BowtieView extends TextFileView { const eventIndex = this.bowtie.events.findIndex((e) => e.id === this.selectedRef!.eventId); if (eventIndex >= 0 && eventIndex < this.bowtie.events.length - 1) { const barBtn = actions.createEl("button", { - text: "+ Barrier to next event", + text: "+ barrier to next event", cls: "mod-cta mod-small", }); barBtn.addEventListener("click", () => { @@ -559,14 +520,14 @@ export class BowtieView extends TextFileView { } if (this.selectedRef.kind === "threat") { - const barBtn = actions.createEl("button", { text: "+ Prevention Barrier", cls: "mod-cta mod-small" }); + const barBtn = actions.createEl("button", { text: "+ prevention barrier", cls: "mod-cta mod-small" }); barBtn.addEventListener("click", () => { this.addPreventionBarrier(this.selectedRef!.threatId!); }); } if (this.selectedRef.kind === "consequence") { - const barBtn = actions.createEl("button", { text: "+ Mitigation Barrier", cls: "mod-cta mod-small" }); + const barBtn = actions.createEl("button", { text: "+ mitigation barrier", cls: "mod-cta mod-small" }); barBtn.addEventListener("click", () => { this.addMitigationBarrier(this.selectedRef!.consequenceId!); }); @@ -593,7 +554,7 @@ export class BowtieView extends TextFileView { }); this.svgEl.setAttribute("width", String(layout.bounds.width)); this.svgEl.setAttribute("height", String(layout.bounds.height)); - this.svgEl.innerHTML = ""; + this.svgEl.replaceChildren(); for (const edge of layout.edges) { const path = activeDocument.createElementNS("http://www.w3.org/2000/svg", "path"); @@ -632,6 +593,28 @@ export class BowtieView extends TextFileView { this.svgEl.appendChild(group); } + private selectNodeElement(ref: NodeRef, wrap: HTMLElement): void { + this.selectedRef = ref; + this.renderInspector(); + this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => { + n.removeClass("o-tie-node-selected"); + n.setAttribute("aria-pressed", "false"); + }); + wrap.addClass("o-tie-node-selected"); + wrap.setAttribute("aria-pressed", "true"); + this.updateOverlayVisibility(); + } + + private clearSelection(): void { + this.selectedRef = null; + this.renderInspector(); + this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => { + n.removeClass("o-tie-node-selected"); + n.setAttribute("aria-pressed", "false"); + }); + this.updateOverlayVisibility(); + } + private renderNode(node: PositionedNode): void { const wrap = this.nodesEl.createDiv({ cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`, @@ -812,14 +795,28 @@ export class BowtieView extends TextFileView { } } + wrap.tabIndex = 0; + wrap.setAttribute("role", "button"); + wrap.setAttribute("aria-label", node.label?.trim() ? `${node.subtitle}: ${node.label}` : node.subtitle); + wrap.setAttribute("aria-pressed", this.isNodeSelected(node.ref) ? "true" : "false"); + wrap.addEventListener("click", (e) => { if ((e.target as HTMLElement).closest("button, [role='button']")) return; e.stopPropagation(); - this.selectedRef = node.ref; - this.renderInspector(); - this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected")); - wrap.addClass("o-tie-node-selected"); - this.updateOverlayVisibility(); + this.selectNodeElement(node.ref, wrap); + }); + + wrap.addEventListener("keydown", (e) => { + if (e.target !== wrap) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + this.selectNodeElement(node.ref, wrap); + } else if (e.key === "Escape") { + e.stopPropagation(); + this.clearSelection(); + wrap.blur(); + } }); if (!isBarrier) { @@ -1324,10 +1321,19 @@ export class BowtieView extends TextFileView { this.containerEl_, "pointerdown", (e) => { + // A click only ever follows immediately after the previous pointerup; + // clear any stale suppression flag at the start of a new gesture. + this.suppressNextClick = false; const target = e.target as HTMLElement; - if (!this.isPanZoomTarget(target)) return; + const isTouch = e.pointerType === "touch" || e.pointerType === "pen"; + const onEmptyCanvas = this.isPanZoomTarget(target); + // On touch/pen, let a single-finger drag pan from anywhere on the + // canvas (including on top of a node), as long as the finger isn't on + // an interactive control. A tap still selects the node (see pointerup). + const fromNode = isTouch && !onEmptyCanvas && !this.isInteractiveControl(target); + if (!onEmptyCanvas && !fromNode) return; - if (e.pointerType === "touch" || e.pointerType === "pen") { + if (isTouch && onEmptyCanvas) { e.preventDefault(); e.stopPropagation(); } @@ -1343,6 +1349,8 @@ export class BowtieView extends TextFileView { if (this.activePointers.size === 1) { this.panPointerId = e.pointerId; this.pinchLastDistance = null; + this.gestureMoved = false; + this.panStartedOnNode = fromNode; this.containerEl_.setPointerCapture(e.pointerId); this.startPan(e.clientX, e.clientY); } @@ -1388,6 +1396,12 @@ export class BowtieView extends TextFileView { } if (e.pointerId === this.panPointerId) { + // A drag that began on a node must not also fire the node's tap-select + // click. A pure tap (no movement) falls through and selects normally. + if (this.gestureMoved && this.panStartedOnNode) { + this.suppressNextClick = true; + } + this.panStartedOnNode = false; this.endPan(); } @@ -1458,19 +1472,29 @@ export class BowtieView extends TextFileView { const dy = this.wheelDeltaAccum; this.wheelDeltaAccum = 0; if (Math.abs(dy) < 0.01) return; - const factor = this.wheelFactorFromDelta(dy, this.wheelCtrlKey); + const factor = wheelFactorFromDelta(dy, this.wheelCtrlKey); this.zoomAt(this.wheelClient.x, this.wheelClient.y, factor); }); }, { passive: false } ); + this.registerDomEvent( + this.containerEl_, + "click", + (e) => { + if (this.suppressNextClick) { + this.suppressNextClick = false; + e.stopPropagation(); + e.preventDefault(); + } + }, + { capture: true } + ); + this.registerDomEvent(this.containerEl_, "click", (e) => { if ((e.target as HTMLElement).closest(".o-tie-node-wrap")) return; - this.selectedRef = null; - this.renderInspector(); - this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected")); - this.updateOverlayVisibility(); + this.clearSelection(); }); this.registerDomEvent(activeDocument, "keydown", (e) => { @@ -1503,22 +1527,12 @@ export class BowtieView extends TextFileView { } private normalizeWheelDelta(e: WheelEvent): number { - let dy = e.deltaY; - if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { - dy *= 16; - } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - dy *= this.containerEl_.clientHeight || window.innerHeight; - } - return dy; - } - - private wheelFactorFromDelta(deltaY: number, ctrlKey: boolean): number { - // Gentler zoom: ~5% per mouse-wheel notch, smooth trackpad pinch. - const sensitivity = ctrlKey ? 0.0012 : 0.00045; - const factor = Math.exp(-deltaY * sensitivity); - const maxStep = ctrlKey ? 1.08 : 1.06; - const minStep = 1 / maxStep; - return Math.max(minStep, Math.min(maxStep, factor)); + return normalizeWheelDelta( + e.deltaY, + e.deltaMode, + 16, + this.containerEl_.clientHeight || window.innerHeight + ); } private applyTransform(): void { @@ -1564,35 +1578,17 @@ export class BowtieView extends TextFileView { if (this.externalSyncReady) return; this.externalSyncReady = true; - this.registerEvent( - this.app.vault.on("modify", (file) => { - if (!(file instanceof TFile) || file !== this.file) return; - if (Date.now() - this.lastSelfSaveAt < 3000) return; - void this.handleExternalFileChange(file); - }) - ); - } - - private async handleExternalFileChange(file: TFile): Promise { - const disk = await this.app.vault.read(file); - if (disk === this.data || disk === this.getViewData()) return; - - let diskBowtie: Bowtie; - let localBowtie: Bowtie; - try { - diskBowtie = deserializeBowtie(disk); - localBowtie = this.bowtie; - } catch { - return; - } - - if (bowtieStructureSignature(diskBowtie) === bowtieStructureSignature(localBowtie)) { - return; - } - - if (this.saveTimeout !== null) return; - - this.setViewData(disk, false); + new ExternalSync({ + app: this.app, + getFile: () => this.file, + getLocalBowtie: () => this.bowtie, + getViewData: () => this.getViewData(), + getLocalData: () => this.data, + hasPendingSave: () => this.saveTimeout !== null, + getLastSelfSaveAt: () => this.lastSelfSaveAt, + applyDiskData: (disk) => this.setViewData(disk, false), + registerEvent: (ref) => this.registerEvent(ref), + }).start(); } private worldToScreen(wx: number, wy: number): { x: number; y: number } { @@ -1727,9 +1723,8 @@ export class BowtieView extends TextFileView { e.stopPropagation(); } - private isPanZoomTarget(target: HTMLElement): boolean { - return !( - target.closest(".o-tie-node-wrap") || + private isInteractiveControl(target: HTMLElement): boolean { + return !!( target.closest(".o-tie-controls-overlay") || target.closest(".o-tie-lane-add") || target.closest("button") || @@ -1737,6 +1732,10 @@ export class BowtieView extends TextFileView { ); } + private isPanZoomTarget(target: HTMLElement): boolean { + return !(target.closest(".o-tie-node-wrap") || this.isInteractiveControl(target)); + } + private blockCanvasGesture(e: Event): void { e.stopPropagation(); if (e.cancelable) e.preventDefault(); @@ -1757,6 +1756,7 @@ export class BowtieView extends TextFileView { if (!this.isPanning) return; const dx = clientX - this.panStart.x; const dy = clientY - this.panStart.y; + if (!this.gestureMoved && Math.hypot(dx, dy) > 6) this.gestureMoved = true; if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; this.bowtie.view.panX = this.panOrigin.x + dx; this.bowtie.view.panY = this.panOrigin.y + dy; @@ -1804,17 +1804,9 @@ export class BowtieView extends TextFileView { private zoomAt(clientX: number, clientY: number, factor: number): void { if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; const rect = this.containerEl_.getBoundingClientRect(); - const mx = clientX - rect.left; - const my = clientY - rect.top; - const oldZoom = this.bowtie.view.zoom; - const newZoom = Math.min(3, Math.max(0.2, oldZoom * factor)); - if (newZoom === oldZoom) return; - - const worldX = (mx - this.bowtie.view.panX) / oldZoom; - const worldY = (my - this.bowtie.view.panY) / oldZoom; - this.bowtie.view.zoom = newZoom; - this.bowtie.view.panX = mx - worldX * newZoom; - this.bowtie.view.panY = my - worldY * newZoom; + const next = computeZoomAt(this.bowtie.view, clientX - rect.left, clientY - rect.top, factor); + if (!next) return; + this.bowtie.view = next; this.applyTransform(); this.scheduleViewSave(); } @@ -1904,18 +1896,13 @@ export class BowtieView extends TextFileView { const rect = this.containerEl_.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; - const padding = 40; - const scaleX = (rect.width - padding * 2) / layout.bounds.width; - const scaleY = (rect.height - padding * 2) / layout.bounds.height; - const zoom = Math.min(1.2, Math.max(0.3, Math.min(scaleX, scaleY))); - - const panX = (rect.width - layout.bounds.width * zoom) / 2; - const panY = (rect.height - layout.bounds.height * zoom) / 2; - - if (!this.bowtie.view) this.bowtie.view = { zoom: 1, panX: 0, panY: 0 }; - this.bowtie.view.zoom = zoom; - this.bowtie.view.panX = panX; - this.bowtie.view.panY = panY; + this.bowtie.view = computeFit( + layout.bounds.width, + layout.bounds.height, + rect.width, + rect.height, + 40 + ); this.applyTransform(); if (animate) this.scheduleViewSave(); } @@ -2255,14 +2242,14 @@ export class BowtieView extends TextFileView { private getNodeSubtitle(ref: NodeRef): string { const map: Record = { hazard: "Hazard", - topEvent: "Top Event", + topEvent: "Top event", threat: "Threat", consequence: "Consequence", - preventionBarrier: "Prevention Barrier", - mitigationBarrier: "Mitigation Barrier", + preventionBarrier: "Prevention barrier", + mitigationBarrier: "Mitigation barrier", transitionBarrier: "Barrier", safeguard: "Safeguard", - degradationFactor: "Degradation Factor", + degradationFactor: "Degradation factor", }; return map[ref.kind] ?? ref.kind; } diff --git a/src/editorModal.ts b/src/editorModal.ts index ccb7876..0114ca1 100644 --- a/src/editorModal.ts +++ b/src/editorModal.ts @@ -11,13 +11,13 @@ export class NewBowtieNameModal extends Modal { onOpen(): void { const { contentEl, modalEl } = this; modalEl.addClass("o-tie-modal"); - contentEl.createEl("h2", { text: "Create New Bowtie" }); + contentEl.createEl("h2", { text: "Create new bowtie" }); new Setting(contentEl) .setName("Bowtie name") .setDesc("Used for the file name and diagram title") .addText((text) => { - text.setPlaceholder("e.g. Defective Steamcracker"); + text.setPlaceholder("E.g. Defective steamcracker"); text.inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") this.submit(text.getValue()); }); diff --git a/src/externalSync.ts b/src/externalSync.ts new file mode 100644 index 0000000..01c1f98 --- /dev/null +++ b/src/externalSync.ts @@ -0,0 +1,56 @@ +import { App, type EventRef, TFile } from "obsidian"; +import { type Bowtie, bowtieStructureSignature, deserializeBowtie } from "./model"; + +export interface ExternalSyncHost { + app: App; + getFile: () => TFile | null; + getLocalBowtie: () => Bowtie; + getViewData: () => string; + getLocalData: () => string; + hasPendingSave: () => boolean; + getLastSelfSaveAt: () => number; + applyDiskData: (disk: string) => void; + registerEvent: (ref: EventRef) => void; +} + +/** + * Reloads the view when the underlying .bowtie file changes on disk (e.g. via + * Obsidian Sync), but only when the change is genuinely external and there are + * no unsaved local edits. + */ +export class ExternalSync { + private started = false; + + constructor(private readonly host: ExternalSyncHost) {} + + start(): void { + if (this.started) return; + this.started = true; + this.host.registerEvent( + this.host.app.vault.on("modify", (file) => { + if (!(file instanceof TFile) || file !== this.host.getFile()) return; + if (Date.now() - this.host.getLastSelfSaveAt() < 3000) return; + void this.handle(file); + }) + ); + } + + private async handle(file: TFile): Promise { + const disk = await this.host.app.vault.read(file); + if (disk === this.host.getLocalData() || disk === this.host.getViewData()) return; + + let diskBowtie: Bowtie; + try { + diskBowtie = deserializeBowtie(disk); + } catch { + return; + } + + if (bowtieStructureSignature(diskBowtie) === bowtieStructureSignature(this.host.getLocalBowtie())) { + return; + } + if (this.host.hasPendingSave()) return; + + this.host.applyDiskData(disk); + } +} diff --git a/src/helpModal.ts b/src/helpModal.ts index 554ef84..e16ab48 100644 --- a/src/helpModal.ts +++ b/src/helpModal.ts @@ -11,7 +11,7 @@ export class HelpModal extends Modal { onOpen(): void { const { contentEl, modalEl } = this; modalEl.addClass("o-tie-modal", "o-tie-help-modal"); - contentEl.createEl("h2", { text: "O-Tie Help" }); + contentEl.createEl("h2", { text: "Help" }); contentEl.createEl("p", { cls: "o-tie-help-version", diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..f3b9cd7 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,53 @@ +import { type Bowtie, cloneBowtie, type NodeRef } from "./model"; + +export interface BowtieSnapshot { + bowtie: Bowtie; + selection: NodeRef | null; +} + +function snapshot(bowtie: Bowtie, selection: NodeRef | null): BowtieSnapshot { + return { + bowtie: cloneBowtie(bowtie), + selection: selection ? { ...selection } : null, + }; +} + +/** Undo/redo stack of bowtie snapshots with the selection at edit time. */ +export class BowtieHistory { + private undoStack: BowtieSnapshot[] = []; + private redoStack: BowtieSnapshot[] = []; + + constructor(private readonly maxUndo = 50) {} + + reset(): void { + this.undoStack = []; + this.redoStack = []; + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + /** Record the state immediately before an edit; clears the redo stack. */ + record(bowtie: Bowtie, selection: NodeRef | null): void { + this.undoStack.push(snapshot(bowtie, selection)); + if (this.undoStack.length > this.maxUndo) this.undoStack.shift(); + this.redoStack = []; + } + + undo(currentBowtie: Bowtie, currentSelection: NodeRef | null): BowtieSnapshot | null { + if (this.undoStack.length === 0) return null; + this.redoStack.push(snapshot(currentBowtie, currentSelection)); + return this.undoStack.pop() ?? null; + } + + redo(currentBowtie: Bowtie, currentSelection: NodeRef | null): BowtieSnapshot | null { + if (this.redoStack.length === 0) return null; + this.undoStack.push(snapshot(currentBowtie, currentSelection)); + return this.redoStack.pop() ?? null; + } +} diff --git a/src/panZoom.ts b/src/panZoom.ts new file mode 100644 index 0000000..b63f224 --- /dev/null +++ b/src/panZoom.ts @@ -0,0 +1,72 @@ +import type { BowtieViewState } from "./model"; + +export const MIN_ZOOM = 0.2; +export const MAX_ZOOM = 3; +export const FIT_MIN_ZOOM = 0.3; +export const FIT_MAX_ZOOM = 1.2; + +export function clampZoom(zoom: number, min = MIN_ZOOM, max = MAX_ZOOM): number { + return Math.min(max, Math.max(min, zoom)); +} + +/** Normalize a wheel delta to pixels regardless of the browser's delta mode. */ +export function normalizeWheelDelta( + deltaY: number, + deltaMode: number, + lineHeight: number, + pageHeight: number +): number { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) return deltaY * lineHeight; + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return deltaY * pageHeight; + return deltaY; +} + +/** Gentle zoom factor: ~5% per wheel notch, smoother for trackpad pinch (ctrl). */ +export function wheelFactorFromDelta(deltaY: number, ctrlKey: boolean): number { + const sensitivity = ctrlKey ? 0.0012 : 0.00045; + const factor = Math.exp(-deltaY * sensitivity); + const maxStep = ctrlKey ? 1.08 : 1.06; + const minStep = 1 / maxStep; + return Math.max(minStep, Math.min(maxStep, factor)); +} + +/** + * Zoom toward a point (in container-local coordinates) by a factor, keeping the + * world point under the cursor stable. Returns null when the zoom is unchanged. + */ +export function computeZoomAt( + view: BowtieViewState, + localX: number, + localY: number, + factor: number +): BowtieViewState | null { + const oldZoom = view.zoom; + const newZoom = clampZoom(oldZoom * factor); + if (newZoom === oldZoom) return null; + + const worldX = (localX - view.panX) / oldZoom; + const worldY = (localY - view.panY) / oldZoom; + return { + zoom: newZoom, + panX: localX - worldX * newZoom, + panY: localY - worldY * newZoom, + }; +} + +/** Center and scale a diagram of the given bounds inside a viewport rectangle. */ +export function computeFit( + boundsWidth: number, + boundsHeight: number, + rectWidth: number, + rectHeight: number, + padding: number +): BowtieViewState { + const scaleX = (rectWidth - padding * 2) / boundsWidth; + const scaleY = (rectHeight - padding * 2) / boundsHeight; + const zoom = Math.min(FIT_MAX_ZOOM, Math.max(FIT_MIN_ZOOM, Math.min(scaleX, scaleY))); + return { + zoom, + panX: (rectWidth - boundsWidth * zoom) / 2, + panY: (rectHeight - boundsHeight * zoom) / 2, + }; +} diff --git a/src/settingsTab.ts b/src/settingsTab.ts index eaddf95..5c3413b 100644 --- a/src/settingsTab.ts +++ b/src/settingsTab.ts @@ -42,68 +42,75 @@ export class OTieSettingTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName("Column gap") - .setDesc("Horizontal spacing between bowtie columns (pixels)") - .addText((text) => - text - .setPlaceholder("120") - .setValue(String(this.plugin.settings.columnGap)) - .onChange(async (value) => { - const parsed = parseInt(value, 10); - if (!isNaN(parsed) && parsed > 0) { - this.plugin.settings.columnGap = parsed; - await this.plugin.saveSettings(); - } - }) - ); + this.addPixelSetting( + containerEl, + "Column gap", + "Horizontal spacing between bowtie columns (pixels)", + 120, + () => this.plugin.settings.columnGap, + (v) => (this.plugin.settings.columnGap = v) + ); - new Setting(containerEl) - .setName("Row gap") - .setDesc("Vertical spacing between threats/consequences (pixels)") - .addText((text) => - text - .setPlaceholder("40") - .setValue(String(this.plugin.settings.rowGap)) - .onChange(async (value) => { - const parsed = parseInt(value, 10); - if (!isNaN(parsed) && parsed > 0) { - this.plugin.settings.rowGap = parsed; - await this.plugin.saveSettings(); - } - }) - ); + this.addPixelSetting( + containerEl, + "Row gap", + "Vertical spacing between threats/consequences (pixels)", + 40, + () => this.plugin.settings.rowGap, + (v) => (this.plugin.settings.rowGap = v) + ); - new Setting(containerEl) - .setName("Node width") - .setDesc("Default width of bowtie nodes (pixels)") - .addText((text) => - text - .setPlaceholder("220") - .setValue(String(this.plugin.settings.nodeWidth)) - .onChange(async (value) => { - const parsed = parseInt(value, 10); - if (!isNaN(parsed) && parsed > 0) { - this.plugin.settings.nodeWidth = parsed; - await this.plugin.saveSettings(); - } - }) - ); + this.addPixelSetting( + containerEl, + "Node width", + "Default width of bowtie nodes (pixels)", + 220, + () => this.plugin.settings.nodeWidth, + (v) => (this.plugin.settings.nodeWidth = v) + ); + this.addPixelSetting( + containerEl, + "Node height", + "Default height of bowtie nodes (pixels)", + 80, + () => this.plugin.settings.nodeHeight, + (v) => (this.plugin.settings.nodeHeight = v) + ); + } + + private addPixelSetting( + containerEl: HTMLElement, + name: string, + desc: string, + placeholder: number, + get: () => number, + set: (value: number) => void + ): void { new Setting(containerEl) - .setName("Node height") - .setDesc("Default height of bowtie nodes (pixels)") - .addText((text) => + .setName(name) + .setDesc(desc) + .addText((text) => { text - .setPlaceholder("80") - .setValue(String(this.plugin.settings.nodeHeight)) + .setPlaceholder(String(placeholder)) + .setValue(String(get())) .onChange(async (value) => { - const parsed = parseInt(value, 10); - if (!isNaN(parsed) && parsed > 0) { - this.plugin.settings.nodeHeight = parsed; - await this.plugin.saveSettings(); + const parsed = Number(value.trim()); + const valid = Number.isFinite(parsed) && Number.isInteger(parsed) && parsed > 0 && parsed <= 2000; + text.inputEl.toggleClass("o-tie-setting-invalid", !valid); + text.inputEl.setAttribute("aria-invalid", valid ? "false" : "true"); + if (!valid) { + text.inputEl.title = "Enter a whole number of pixels between 1 and 2000"; + return; } - }) - ); + text.inputEl.title = ""; + set(parsed); + await this.plugin.saveSettings(); + }); + text.inputEl.type = "number"; + text.inputEl.inputMode = "numeric"; + text.inputEl.min = "1"; + text.inputEl.max = "2000"; + }); } } diff --git a/src/stackRows.ts b/src/stackRows.ts new file mode 100644 index 0000000..9572ef1 --- /dev/null +++ b/src/stackRows.ts @@ -0,0 +1,43 @@ +export const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [ + { color: "#5dade2", label: "Sky blue" }, + { color: "#3498db", label: "Blue" }, + { color: "#48c9b0", label: "Teal" }, + { color: "#1abc9c", label: "Mint" }, + { color: "#7f8c8d", label: "Gray" }, + { color: "#1e8449", label: "Dark green" }, + { color: "#27ae60", label: "Green" }, + { color: "#f1c40f", label: "Yellow" }, + { color: "#e67e22", label: "Orange" }, + { color: "#c0392b", label: "Red" }, + { color: "#2c3e50", label: "Navy" }, + { color: "#566573", label: "Slate" }, + { color: "#ffffff", label: "White" }, + { color: "#f4ecf7", label: "Light purple" }, + { color: "#eafaf1", label: "Light green" }, +]; + +const LIGHT_STACK_ROW_COLORS = new Set(["#ffffff", "#f4ecf7", "#eafaf1"]); + +export function isLightStackColor(color: string): boolean { + if (LIGHT_STACK_ROW_COLORS.has(color)) return true; + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return false; + const n = parseInt(match[1], 16); + const r = (n >> 16) & 0xff; + const g = (n >> 8) & 0xff; + const b = n & 0xff; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.62; +} + +export function createColorMenuTitle(color: string, label: string): DocumentFragment { + const frag = activeDocument.createDocumentFragment(); + const wrap = frag.createEl("span", { cls: "o-tie-color-menu-title" }); + const swatch = wrap.createEl("span", { cls: "o-tie-color-swatch" }); + swatch.setCssStyles({ backgroundColor: color }); + if (LIGHT_STACK_ROW_COLORS.has(color)) { + swatch.addClass("o-tie-color-swatch-light"); + } + wrap.createEl("span", { cls: "o-tie-color-menu-label", text: label }); + return frag; +} diff --git a/styles.css b/styles.css index 7d39132..19d08fd 100644 --- a/styles.css +++ b/styles.css @@ -1,3 +1,9 @@ +/* Settings */ +.o-tie-setting-invalid { + border-color: var(--text-error) !important; + box-shadow: 0 0 0 1px var(--text-error); +} + /* Modal (create bowtie) */ .o-tie-modal { max-width: 480px; diff --git a/tests/layout.test.ts b/tests/layout.test.ts new file mode 100644 index 0000000..bf6e36d --- /dev/null +++ b/tests/layout.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + bezierPointAt, + barrierHeaderHeightFor, + DEFAULT_LAYOUT, + layoutBowtie, + nodeBoxHeight, +} from "../src/layout"; +import { + createBarrier, + createBowtie, + createConsequence, + createThreat, + type Bowtie, +} from "../src/model"; + +function sample(): Bowtie { + const bt = createBowtie("Sample"); + bt.events[0].label = "Top event"; + bt.events[0].hazard = "Hazard"; + const threat = createThreat("Threat"); + threat.preventionBarriers = [createBarrier("Prevention")]; + bt.threats = [threat]; + const consequence = createConsequence("Consequence"); + consequence.mitigationBarriers = [createBarrier("Mitigation")]; + bt.consequences = [consequence]; + return bt; +} + +describe("nodeBoxHeight", () => { + it("respects the minimum height", () => { + expect(nodeBoxHeight("", 200, 72)).toBeGreaterThanOrEqual(72); + }); + + it("grows for longer labels that wrap", () => { + const short = nodeBoxHeight("Short", 120, 40); + const long = nodeBoxHeight( + "A very long barrier label that will certainly wrap across several lines in a narrow box", + 120, + 40 + ); + expect(long).toBeGreaterThan(short); + }); +}); + +describe("barrierHeaderHeightFor", () => { + it("is at least the configured header height", () => { + const barrier = createBarrier("B"); + expect(barrierHeaderHeightFor(barrier, DEFAULT_LAYOUT)).toBeGreaterThanOrEqual( + DEFAULT_LAYOUT.barrierHeaderHeight + ); + }); +}); + +describe("bezierPointAt", () => { + it("returns endpoints at t=0 and t=1", () => { + expect(bezierPointAt(0, 0, 100, 0, 0)).toEqual({ x: 0, y: 0 }); + expect(bezierPointAt(0, 0, 100, 0, 1)).toEqual({ x: 100, y: 0 }); + }); + + it("returns the midpoint x for a horizontal segment at t=0.5", () => { + const p = bezierPointAt(0, 0, 100, 0, 0.5); + expect(p.x).toBeCloseTo(50, 5); + expect(p.y).toBeCloseTo(0, 5); + }); +}); + +describe("layoutBowtie", () => { + it("produces nodes, edges, and positive bounds", () => { + const layout = layoutBowtie(sample(), DEFAULT_LAYOUT); + expect(layout.nodes.length).toBeGreaterThan(0); + expect(layout.edges.length).toBeGreaterThan(0); + expect(layout.bounds.width).toBeGreaterThan(0); + expect(layout.bounds.height).toBeGreaterThan(0); + }); + + it("places threat left of top event left of consequence", () => { + const layout = layoutBowtie(sample(), DEFAULT_LAYOUT); + const threat = layout.nodes.find((n) => n.kind === "threat")!; + const topEvent = layout.nodes.find((n) => n.kind === "topEvent")!; + const consequence = layout.nodes.find((n) => n.kind === "consequence")!; + expect(threat.x).toBeLessThan(topEvent.x); + expect(topEvent.x).toBeLessThan(consequence.x); + }); + + it("places the hazard above its top event", () => { + const layout = layoutBowtie(sample(), DEFAULT_LAYOUT); + const hazard = layout.nodes.find((n) => n.kind === "hazard")!; + const topEvent = layout.nodes.find((n) => n.kind === "topEvent")!; + expect(hazard.y).toBeLessThan(topEvent.y); + }); +}); diff --git a/tests/model.test.ts b/tests/model.test.ts new file mode 100644 index 0000000..4f4985f --- /dev/null +++ b/tests/model.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + bowtieStructureSignature, + cloneBowtie, + createBarrier, + createBowtie, + createConsequence, + createThreat, + deserializeBowtie, + serializeBowtie, + type Bowtie, +} from "../src/model"; + +function sampleBowtie(): Bowtie { + const bt = createBowtie("Sample"); + bt.events[0].label = "Loss of containment"; + bt.events[0].hazard = "High pressure"; + const threat = createThreat("Corrosion"); + threat.preventionBarriers = [createBarrier("Inspection")]; + bt.threats = [threat]; + bt.consequences = [createConsequence("Fire")]; + return bt; +} + +describe("createBowtie", () => { + it("creates a valid single-event bowtie", () => { + const bt = createBowtie("New"); + expect(bt.events).toHaveLength(1); + expect(bt.threats).toEqual([]); + expect(bt.consequences).toEqual([]); + expect(bt.id).toBeTruthy(); + }); +}); + +describe("serialize/deserialize", () => { + it("round-trips structure", () => { + const bt = sampleBowtie(); + const restored = deserializeBowtie(serializeBowtie(bt)); + expect(restored.name).toBe(bt.name); + expect(restored.events[0].label).toBe("Loss of containment"); + expect(restored.events[0].hazard).toBe("High pressure"); + expect(restored.threats[0].preventionBarriers[0].label).toBe("Inspection"); + expect(restored.consequences[0].label).toBe("Fire"); + }); + + it("adds a default view when missing", () => { + const bt = sampleBowtie(); + delete bt.view; + const restored = deserializeBowtie(serializeBowtie(bt)); + expect(restored.view).toEqual({ zoom: 1, panX: 0, panY: 0 }); + }); +}); + +describe("legacy migration", () => { + it("moves top-level hazard/topEvent into events", () => { + const legacy = JSON.stringify({ + id: "x", + name: "Legacy", + hazard: "Ethylene", + topEvent: "Loss of containment", + threats: [], + consequences: [], + }); + const bt = deserializeBowtie(legacy); + expect(bt.events).toHaveLength(1); + expect(bt.events[0].hazard).toBe("Ethylene"); + expect(bt.events[0].label).toBe("Loss of containment"); + expect((bt as Bowtie & { topEvent?: string }).topEvent).toBeUndefined(); + expect((bt as Bowtie & { hazard?: string }).hazard).toBeUndefined(); + }); + + it("converts barrier escalationFactors into degradationChains", () => { + const legacy = JSON.stringify({ + id: "x", + name: "Legacy", + hazard: "H", + topEvent: "T", + threats: [ + { + id: "t1", + label: "Threat", + preventionBarriers: [ + { + id: "b1", + label: "Barrier", + escalationFactors: [ + { + id: "ef1", + label: "Deferred inspection", + escalationBarriers: [{ id: "eb1", label: "Audit" }], + }, + ], + }, + ], + }, + ], + consequences: [], + }); + const bt = deserializeBowtie(legacy); + const barrier = bt.threats[0].preventionBarriers[0]; + expect(barrier.degradationChains).toHaveLength(1); + const chain = barrier.degradationChains[0]; + const labels = chain.safeguards.map((s) => s.label); + expect(labels).toContain("Deferred inspection"); + expect(labels).toContain("Audit"); + expect((barrier as { escalationFactors?: unknown }).escalationFactors).toBeUndefined(); + }); +}); + +describe("validation", () => { + it("throws when id or name is missing", () => { + expect(() => deserializeBowtie(JSON.stringify({ name: "x", events: [{}], threats: [], consequences: [] }))).toThrow(); + }); + + it("throws when threats/consequences are not arrays", () => { + expect(() => + deserializeBowtie(JSON.stringify({ id: "a", name: "b", events: [{ hazard: "" }], threats: {}, consequences: [] })) + ).toThrow(); + }); + + it("repairs an empty events array into a single event", () => { + const bt = deserializeBowtie( + JSON.stringify({ id: "a", name: "b", events: [], threats: [], consequences: [] }) + ); + expect(bt.events).toHaveLength(1); + expect(typeof bt.events[0].hazard).toBe("string"); + }); +}); + +describe("bowtieStructureSignature", () => { + it("ignores view and timestamps", () => { + const a = sampleBowtie(); + const b = cloneBowtie(a); + b.view = { zoom: 2, panX: 100, panY: 50 }; + b.updatedAt = "2099-01-01T00:00:00.000Z"; + b.createdAt = "2099-01-01T00:00:00.000Z"; + expect(bowtieStructureSignature(a)).toBe(bowtieStructureSignature(b)); + }); + + it("changes when content changes", () => { + const a = sampleBowtie(); + const b = cloneBowtie(a); + b.threats[0].label = "Different"; + expect(bowtieStructureSignature(a)).not.toBe(bowtieStructureSignature(b)); + }); +}); diff --git a/tests/panZoom.test.ts b/tests/panZoom.test.ts new file mode 100644 index 0000000..dd1f19f --- /dev/null +++ b/tests/panZoom.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + clampZoom, + computeFit, + computeZoomAt, + MAX_ZOOM, + MIN_ZOOM, + wheelFactorFromDelta, +} from "../src/panZoom"; + +describe("clampZoom", () => { + it("clamps to the configured range", () => { + expect(clampZoom(0.01)).toBe(MIN_ZOOM); + expect(clampZoom(99)).toBe(MAX_ZOOM); + expect(clampZoom(1)).toBe(1); + }); +}); + +describe("wheelFactorFromDelta", () => { + it("zooms in for negative delta and out for positive delta", () => { + expect(wheelFactorFromDelta(-100, false)).toBeGreaterThan(1); + expect(wheelFactorFromDelta(100, false)).toBeLessThan(1); + }); + + it("caps the per-notch step", () => { + expect(wheelFactorFromDelta(-100000, false)).toBeLessThanOrEqual(1.06); + expect(wheelFactorFromDelta(100000, true)).toBeGreaterThanOrEqual(1 / 1.08); + }); +}); + +describe("computeZoomAt", () => { + it("keeps the point under the cursor fixed", () => { + const view = { zoom: 1, panX: 0, panY: 0 }; + const next = computeZoomAt(view, 200, 100, 2)!; + expect(next.zoom).toBe(2); + // World point under (200,100) before zoom: (200,100). After zoom it must map back to (200,100). + const worldX = (200 - next.panX) / next.zoom; + const worldY = (100 - next.panY) / next.zoom; + expect(worldX).toBeCloseTo(200, 5); + expect(worldY).toBeCloseTo(100, 5); + }); + + it("returns null when clamped zoom does not change", () => { + const view = { zoom: MAX_ZOOM, panX: 0, panY: 0 }; + expect(computeZoomAt(view, 0, 0, 2)).toBeNull(); + }); +}); + +describe("computeFit", () => { + it("centers the diagram within the viewport", () => { + const fit = computeFit(400, 200, 1000, 600, 40); + expect(fit.zoom).toBeGreaterThan(0); + const renderedW = 400 * fit.zoom; + const renderedH = 200 * fit.zoom; + expect(fit.panX).toBeCloseTo((1000 - renderedW) / 2, 5); + expect(fit.panY).toBeCloseTo((600 - renderedH) / 2, 5); + }); +}); diff --git a/versions.json b/versions.json index 35fab51..9019aa6 100644 --- a/versions.json +++ b/versions.json @@ -6,5 +6,6 @@ "1.0.4": "1.4.0", "1.0.5": "1.4.0", "1.0.6": "1.4.0", - "1.0.7": "1.4.0" + "1.0.7": "1.4.0", + "1.0.8": "1.4.0" } \ No newline at end of file