diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acbb084..16faf7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,13 +33,7 @@ jobs: - name: Create release uses: softprops/action-gh-release@v2 with: - body: | - Initial release of O-Tie. - - - Interactive risk bowtie diagram editor - - Barriers, escalation factors, and analysis stacks - - PNG export, undo/redo, help modal - - Auto-save to .bowtie JSON files + generate_release_notes: true files: | main.js manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a3b1ed8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to O-Tie are documented here. + +## [1.0.1] — 2026-06-18 + +### Fixed + +- **Lane add (+) button placement** — Buttons on threat and consequence connectors now sit on the same bezier curve as the diagram edges, so they no longer float above nodes or overlap barrier labels. +- **Layering** — Lane add controls render behind node boxes when paths cross, so a `+` from another row no longer covers mitigation or prevention text. +- **Overlap guard** — Lane add buttons are skipped when they would sit on top of an unrelated node (for example, a direct top-event path crossing another row’s barrier column). + +### Improved + +- **Symmetric connectors** — Threat and consequence lanes both use the same placement logic, so `+` icons appear consistently on curved and straight segments. +- **PNG export** — Export rendering aligned with on-screen diagram styling. + +--- + +## [1.0.0] — 2026-06-12 + +Initial public release. + +- Interactive risk bowtie diagram editor for Obsidian +- Threats, prevention barriers, top event, mitigation barriers, consequences, and hazard +- Escalation factors, escalation barriers, and per-barrier analysis stacks +- Pan/zoom, undo/redo, inspector panel, PNG export, and auto-save to `.bowtie` files diff --git a/README.md b/README.md index 03d7200..59b3cf9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ Open **Settings → O-Tie** to configure: See [examples/steamcracker.bowtie](examples/steamcracker.bowtie). +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release history. + +**Latest (1.0.1):** Fixes lane add (`+`) button placement on curved connectors, improves layering so buttons stay behind nodes, and aligns threat/consequence lane controls. + ## Development ```bash diff --git a/esbuild.config.mjs b/esbuild.config.mjs index e4197e9..7c03ddc 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,6 +1,6 @@ import esbuild from "esbuild"; +import { builtinModules } from "node:module"; import process from "process"; -import builtins from "builtin-modules"; const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD @@ -30,7 +30,7 @@ const context = await esbuild.context({ "@lezer/common", "@lezer/highlight", "@lezer/lr", - ...builtins, + ...builtinModules, ], format: "cjs", target: "es2018", diff --git a/manifest.json b/manifest.json index 4f5b93d..ee29584 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "o-tie", "name": "O-Tie", - "version": "1.0.0", + "version": "1.0.1", "minAppVersion": "1.4.0", "description": "Build and edit risk bowtie diagrams in a visual editor with barriers, escalation factors, analysis stacks, and PNG export.", "author": "Andre482", diff --git a/package-lock.json b/package-lock.json index de4765b..7f57c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "o-tie", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "o-tie", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "html-to-image": "^1.11.13" }, "devDependencies": { "@types/node": "^20.11.0", - "builtin-modules": "^3.3.0", "esbuild": "^0.20.0", "obsidian": "latest", "tslib": "^2.6.2", @@ -481,19 +480,6 @@ "@types/estree": "*" } }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", diff --git a/package.json b/package.json index 298887b..0ed58ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "o-tie", - "version": "1.0.0", + "version": "1.0.1", "description": "Obsidian plugin for building risk bowtie diagrams", "main": "main.js", "scripts": { @@ -17,7 +17,6 @@ "license": "MIT", "devDependencies": { "@types/node": "^20.11.0", - "builtin-modules": "^3.3.0", "esbuild": "^0.20.0", "obsidian": "latest", "tslib": "^2.6.2", diff --git a/src/bowtieView.ts b/src/bowtieView.ts index cee5970..2328b34 100644 --- a/src/bowtieView.ts +++ b/src/bowtieView.ts @@ -11,6 +11,8 @@ import { type EdgePath, type LayoutConfig, type PositionedNode, + bezierPointAt, + connectionPorts, } from "./layout"; import { BARRIER_STACK_FIELDS, @@ -54,11 +56,23 @@ const STACK_ROW_COLOR_OPTIONS: { color: string; label: string }[] = [ 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 = document.createDocumentFragment(); + 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.style.backgroundColor = color; + swatch.setCssStyles({ backgroundColor: color }); if (LIGHT_STACK_ROW_COLORS.has(color)) { swatch.addClass("o-tie-color-swatch-light"); } @@ -279,7 +293,7 @@ export class BowtieView extends TextFileView { this.transformEl = this.stageEl.createDiv({ cls: "o-tie-transform" }); const ns = "http://www.w3.org/2000/svg"; - this.svgEl = document.createElementNS(ns, "svg"); + this.svgEl = activeDocument.createElementNS(ns, "svg"); this.svgEl.classList.add("o-tie-svg"); this.transformEl.appendChild(this.svgEl); @@ -440,7 +454,7 @@ export class BowtieView extends TextFileView { attr: { rows: "2" }, }); notesArea.value = notes; - requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea)); + window.requestAnimationFrame(() => this.fitInspectorNotesArea(notesArea)); notesArea.addEventListener("input", () => this.fitInspectorNotesArea(notesArea)); notesArea.addEventListener("change", () => { if (notesArea.value === notes) return; @@ -497,21 +511,24 @@ export class BowtieView extends TextFileView { } private fitInspectorNotesArea(textarea: HTMLTextAreaElement): void { - textarea.style.height = "0"; - textarea.style.height = `${textarea.scrollHeight}px`; + textarea.addClass("o-tie-inspector-notes-resize"); + textarea.setCssStyles({ height: `${textarea.scrollHeight}px` }); + textarea.removeClass("o-tie-inspector-notes-resize"); } private renderDiagram(): void { const layout = layoutBowtie(this.bowtie, this.getLayoutConfig()); - this.transformEl.style.width = `${layout.bounds.width}px`; - this.transformEl.style.height = `${layout.bounds.height}px`; + this.transformEl.setCssStyles({ + width: `${layout.bounds.width}px`, + height: `${layout.bounds.height}px`, + }); this.svgEl.setAttribute("width", String(layout.bounds.width)); this.svgEl.setAttribute("height", String(layout.bounds.height)); this.svgEl.innerHTML = ""; for (const edge of layout.edges) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const path = activeDocument.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", edge.path); path.setAttribute("class", `o-tie-edge o-tie-edge-${edge.kind}`); this.svgEl.appendChild(path); @@ -519,10 +536,10 @@ export class BowtieView extends TextFileView { } this.nodesEl.empty(); + this.renderLaneAddButtons(layout); for (const node of layout.nodes) { this.renderNode(node); } - this.renderLaneAddButtons(layout); this.applyTransform(); @@ -536,11 +553,11 @@ export class BowtieView extends TextFileView { const { x, y, angleDeg } = edge.arrow; const size = 8; - const group = document.createElementNS(ns, "g"); + const group = activeDocument.createElementNS(ns, "g"); group.setAttribute("class", `o-tie-edge-arrow o-tie-edge-arrow-${edge.kind}`); group.setAttribute("transform", `translate(${x} ${y}) rotate(${angleDeg})`); - const head = document.createElementNS(ns, "path"); + const head = activeDocument.createElementNS(ns, "path"); head.setAttribute("d", `M0 0 L-${size} ${-size * 0.42} L-${size} ${size * 0.42} Z`); group.appendChild(head); this.svgEl.appendChild(group); @@ -551,10 +568,12 @@ export class BowtieView extends TextFileView { cls: `o-tie-node-wrap o-tie-node-wrap-${node.kind}`, attr: { "data-ref": nodeRefKey(node.ref) }, }); - wrap.style.left = `${node.x}px`; - wrap.style.top = `${node.y}px`; - wrap.style.width = `${node.width}px`; - wrap.style.height = `${node.height}px`; + wrap.setCssStyles({ + left: `${node.x}px`, + top: `${node.y}px`, + width: `${node.width}px`, + height: `${node.height}px`, + }); if (this.selectedRef && nodeRefKey(this.selectedRef) === nodeRefKey(node.ref)) { wrap.addClass("o-tie-node-selected"); @@ -665,8 +684,10 @@ export class BowtieView extends TextFileView { : layout.barrierHeaderHeight; const header = el.createDiv({ cls: "o-tie-barrier-header" }); - header.style.height = `${headerH}px`; - header.style.minHeight = `${headerH}px`; + header.setCssStyles({ + height: `${headerH}px`, + minHeight: `${headerH}px`, + }); const stripe = header.createDiv({ cls: "o-tie-node-stripe" }); stripe.setText(node.subtitle); @@ -696,7 +717,7 @@ export class BowtieView extends TextFileView { const chevron = wrap.createEl("button", { cls: `o-tie-stack-chevron${collapsed ? " is-collapsed" : ""}`, }); - chevron.style.top = `${headerH}px`; + chevron.setCssStyles({ top: `${headerH}px` }); chevron.setAttribute("aria-label", collapsed ? "Expand stack" : "Collapse stack"); chevron.addEventListener("mousedown", (e) => e.stopPropagation()); chevron.addEventListener("click", (e) => { @@ -710,7 +731,7 @@ export class BowtieView extends TextFileView { if (stack.length === 0) { const placeholder = stackEl.createDiv({ cls: "o-tie-stack-empty" }); placeholder.setText("Click + to add analysis row"); - placeholder.style.height = `${layout.barrierStackRowHeight}px`; + placeholder.setCssStyles({ height: `${layout.barrierStackRowHeight}px` }); placeholder.addEventListener("click", (e) => { e.stopPropagation(); this.showAddStackRowMenu(e, node.ref); @@ -739,19 +760,21 @@ export class BowtieView extends TextFileView { cls: "o-tie-stack-row", attr: { "data-stack-id": item.id }, }); - row.style.height = `${layout.barrierStackRowHeight}px`; + const rowStyles: Partial = { + height: `${layout.barrierStackRowHeight}px`, + }; if (item.color) { - row.style.backgroundColor = item.color; - if (item.color === "#ffffff" || item.color === "#f4ecf7" || item.color === "#eafaf1") { + rowStyles.backgroundColor = item.color; + if (isLightStackColor(item.color)) { row.addClass("o-tie-stack-row-light"); } } + row.setCssStyles(rowStyles); const fieldDef = item.field ? BARRIER_STACK_FIELDS.find((f) => f.key === item.field) : undefined; if (fieldDef) { - row.style.borderLeftColor = "var(--o-tie-barrier)"; row.addClass("o-tie-stack-row-preset"); } @@ -872,9 +895,9 @@ export class BowtieView extends TextFileView { if (!field) { window.setTimeout(() => { - const rowEl = this.nodesEl.querySelector( + const rowEl = this.nodesEl.querySelector( `[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${item.id}"] .o-tie-stack-row-label` - ) as HTMLElement | null; + ); if (rowEl) { this.startInlineEdit(rowEl, label, (v) => { this.updateStackRowLabel(ref, item.id, v); @@ -944,9 +967,9 @@ export class BowtieView extends TextFileView { } else { menu.addItem((item) => item.setTitle("Edit label").setIcon("pencil").onClick(() => { - const rowEl = this.nodesEl.querySelector( + const rowEl = this.nodesEl.querySelector( `[data-ref="${nodeRefKey(ref)}"] [data-stack-id="${itemId}"] .o-tie-stack-row-label` - ) as HTMLElement | null; + ); if (rowEl) { this.startInlineEdit(rowEl, stackItem.label, (v) => { this.updateStackRowLabel(ref, itemId, v); @@ -1039,7 +1062,7 @@ export class BowtieView extends TextFileView { initial: string, onCommit: (value: string) => void ): void { - const input = document.createElement("input"); + const input = activeDocument.createElement("input"); input.type = "text"; input.className = "o-tie-inline-edit"; input.value = initial; @@ -1154,7 +1177,7 @@ export class BowtieView extends TextFileView { this.nodesEl.querySelectorAll(".o-tie-node-wrap").forEach((n) => n.removeClass("o-tie-node-selected")); }); - this.registerDomEvent(document, "keydown", (e) => { + this.registerDomEvent(activeDocument, "keydown", (e) => { if (this.app.workspace.getActiveViewOfType(BowtieView) !== this) return; const target = e.target as HTMLElement; if (target.closest("input, textarea, [contenteditable='true']")) return; @@ -1208,23 +1231,26 @@ export class BowtieView extends TextFileView { const { panX, panY, zoom } = view; // Pan and zoom on separate layers so zoom anchors correctly under the cursor. - this.viewportEl.style.transform = `translate3d(${panX}px, ${panY}px, 0)`; - this.viewportEl.style.transformOrigin = "0 0"; + this.viewportEl.setCssStyles({ + transform: `translate3d(${panX}px, ${panY}px, 0)`, + transformOrigin: "0 0", + }); if (BowtieView.useCssZoom) { - this.stageEl.style.zoom = String(zoom); - this.stageEl.style.transform = ""; + this.stageEl.setCssStyles({ zoom: String(zoom), transform: "" }); } else { - this.stageEl.style.zoom = "1"; - this.stageEl.style.transform = `scale(${zoom})`; - this.stageEl.style.transformOrigin = "0 0"; + this.stageEl.setCssStyles({ + zoom: "1", + transform: `scale(${zoom})`, + transformOrigin: "0 0", + }); } if (this.containerEl_) { const gs = BowtieView.GRID_SIZE; const bgX = ((panX % gs) + gs) % gs; const bgY = ((panY % gs) + gs) % gs; - this.containerEl_.style.backgroundPosition = `${bgX}px ${bgY}px`; + this.containerEl_.setCssStyles({ backgroundPosition: `${bgX}px ${bgY}px` }); } } @@ -1420,25 +1446,16 @@ export class BowtieView extends TextFileView { ); if (!threatNode) continue; - const lastBarrier = layout.nodes - .filter( - (n) => - n.kind === "preventionBarrier" && n.ref.threatId === threat.id - ) - .sort((a, b) => b.x - a.x)[0]; - + const lastBarrier = this.findLastBarrierInLane( + layout, + (n) => n.kind === "preventionBarrier" && n.ref.threatId === threat.id + ); const fromNode = lastBarrier ?? threatNode; - const fromX = fromNode.x + fromNode.width; - const toX = topEvent.x; - const fromY = fromNode.y + fromNode.height / 2; - const toY = topEvent.y + topEvent.height / 2; - const y = (fromY + toY) / 2; + const pos = this.laneAddPosition(fromNode, topEvent, layout); + if (!pos) continue; - this.createLaneAddButton( - (fromX + toX) / 2 - 13, - y - 13, - "Add prevention barrier", - () => this.addPreventionBarrier(threat.id) + this.createLaneAddButton(pos.x, pos.y, "Add prevention barrier", () => + this.addPreventionBarrier(threat.id) ); } @@ -1448,31 +1465,76 @@ export class BowtieView extends TextFileView { ); if (!consNode) continue; - const mitigationBarriers = layout.nodes - .filter( - (n) => - n.kind === "mitigationBarrier" && - n.ref.consequenceId === consequence.id - ) - .sort((a, b) => a.x - b.x); - const lastMitigation = mitigationBarriers[mitigationBarriers.length - 1]; + const lastMitigation = this.findLastBarrierInLane( + layout, + (n) => + n.kind === "mitigationBarrier" && + n.ref.consequenceId === consequence.id + ); const fromNode = lastMitigation ?? topEvent; - const fromX = fromNode.x + fromNode.width; - const toX = consNode.x; - const fromY = fromNode.y + fromNode.height / 2; - const toY = consNode.y + consNode.height / 2; - const y = (fromY + toY) / 2; + const pos = this.laneAddPosition(fromNode, consNode, layout); + if (!pos) continue; - this.createLaneAddButton( - (fromX + toX) / 2 - 13, - y - 13, - "Add mitigation barrier", - () => this.addMitigationBarrier(consequence.id) + this.createLaneAddButton(pos.x, pos.y, "Add mitigation barrier", () => + this.addMitigationBarrier(consequence.id) ); } } + private findLastBarrierInLane( + layout: import("./layout").LayoutResult, + match: (node: PositionedNode) => boolean + ): PositionedNode | undefined { + return layout.nodes.filter(match).sort((a, b) => a.x - b.x).pop(); + } + + private laneAddPosition( + from: PositionedNode, + to: PositionedNode, + layout: import("./layout").LayoutResult + ): { x: number; y: number } | null { + const LANE_ADD_SIZE = 26; + const LANE_ADD_HALF = 13; + const MIN_GAP = LANE_ADD_SIZE + 4; + + const ports = connectionPorts(from, to); + const gap = ports.to.x - ports.from.x; + if (gap < MIN_GAP) return null; + + const point = bezierPointAt(ports.from.x, ports.from.y, ports.to.x, ports.to.y, 0.5); + const cx = point.x; + const cy = point.y; + + for (const node of layout.nodes) { + if (node === from || node === to) continue; + if (this.laneAddOverlapsButton(cx, cy, LANE_ADD_HALF, node)) { + return null; + } + } + + return { x: cx - LANE_ADD_HALF, y: cy - LANE_ADD_HALF }; + } + + private laneAddOverlapsButton( + cx: number, + cy: number, + half: number, + node: PositionedNode + ): boolean { + const margin = 2; + const left = cx - half; + const right = cx + half; + const top = cy - half; + const bottom = cy + half; + return ( + right > node.x + margin && + left < node.x + node.width - margin && + bottom > node.y + margin && + top < node.y + node.height - margin + ); + } + private createLaneAddButton( x: number, y: number, @@ -1480,8 +1542,7 @@ export class BowtieView extends TextFileView { onClick: () => void ): void { const btn = this.nodesEl.createEl("button", { cls: "o-tie-lane-add o-tie-plus-btn" }); - btn.style.left = `${x}px`; - btn.style.top = `${y}px`; + btn.setCssStyles({ left: `${x}px`, top: `${y}px` }); btn.setAttribute("aria-label", label); btn.addEventListener("mousedown", (e) => e.stopPropagation()); btn.addEventListener("click", (e) => { diff --git a/src/editorModal.ts b/src/editorModal.ts index d65fc47..ccb7876 100644 --- a/src/editorModal.ts +++ b/src/editorModal.ts @@ -21,7 +21,7 @@ export class NewBowtieNameModal extends Modal { text.inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") this.submit(text.getValue()); }); - setTimeout(() => text.inputEl.focus(), 50); + window.setTimeout(() => text.inputEl.focus(), 50); }); const actions = contentEl.createDiv({ cls: "o-tie-actions" }); diff --git a/src/exportImage.ts b/src/exportImage.ts index 9bbf075..94b4651 100644 --- a/src/exportImage.ts +++ b/src/exportImage.ts @@ -38,6 +38,7 @@ const EXPORT_CHROME_SELECTORS = [ function copyThemeVariables(from: HTMLElement, to: HTMLElement): void { const styles = getComputedStyle(from); + const props: Record = {}; for (let i = 0; i < styles.length; i++) { const name = styles[i]; if ( @@ -46,14 +47,15 @@ function copyThemeVariables(from: HTMLElement, to: HTMLElement): void { name.startsWith("--interactive") || name.startsWith("--radius") ) { - to.style.setProperty(name, styles.getPropertyValue(name)); + props[name] = styles.getPropertyValue(name); } } // Node cards always use light pastel fills — keep export text readable in dark mode. - to.style.setProperty("--text-normal", "#1a1a1a"); - to.style.setProperty("--text-muted", "#5d6d7e"); - to.style.setProperty("--background-primary", "#ffffff"); + props["--text-normal"] = "#1a1a1a"; + props["--text-muted"] = "#5d6d7e"; + props["--background-primary"] = "#ffffff"; + to.setCssProps(props); } function prepareExportClone(clone: HTMLElement): void { @@ -122,35 +124,32 @@ function buildDiagramLayer( nodesEl: HTMLElement, bounds: { width: number; height: number } ): HTMLElement { - const layer = document.createElement("div"); + const layer = activeDocument.createElement("div"); layer.className = "o-tie-export-layer"; - layer.style.position = "relative"; - layer.style.width = `${bounds.width}px`; - layer.style.height = `${bounds.height}px`; - layer.style.overflow = "visible"; + layer.setCssStyles({ + width: `${bounds.width}px`, + height: `${bounds.height}px`, + }); - const edgeImg = document.createElement("img"); + const edgeImg = activeDocument.createElement("img"); edgeImg.className = "o-tie-export-edges"; edgeImg.alt = ""; edgeImg.src = svgToDataUrl(svgEl); edgeImg.width = bounds.width; edgeImg.height = bounds.height; - edgeImg.style.position = "absolute"; - edgeImg.style.top = "0"; - edgeImg.style.left = "0"; - edgeImg.style.width = `${bounds.width}px`; - edgeImg.style.height = `${bounds.height}px`; - edgeImg.style.pointerEvents = "none"; + edgeImg.setCssStyles({ + width: `${bounds.width}px`, + height: `${bounds.height}px`, + }); layer.appendChild(edgeImg); const nodesClone = nodesEl.cloneNode(true) as HTMLElement; prepareExportClone(nodesClone); - nodesClone.style.position = "absolute"; - nodesClone.style.top = "0"; - nodesClone.style.left = "0"; - nodesClone.style.width = `${bounds.width}px`; - nodesClone.style.height = `${bounds.height}px`; - nodesClone.style.overflow = "visible"; + nodesClone.classList.add("o-tie-export-nodes"); + nodesClone.setCssStyles({ + width: `${bounds.width}px`, + height: `${bounds.height}px`, + }); layer.appendChild(nodesClone); return layer; @@ -208,48 +207,43 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr // Offscreen wrapper carries the positioning offset so it is NOT inlined // onto the captured element (which would push content out of the frame). - const wrapper = document.createElement("div"); - wrapper.style.cssText = - "position:fixed;left:-20000px;top:0;pointer-events:none;z-index:-1;"; + const wrapper = activeDocument.createElement("div"); + wrapper.className = "o-tie-export-wrapper"; - const root = document.createElement("div"); + const root = activeDocument.createElement("div"); root.className = "o-tie-view-root o-tie-export-root"; - root.style.position = "relative"; - root.style.left = "0"; - root.style.top = "0"; - root.style.overflow = "hidden"; - root.style.opacity = "1"; - root.style.visibility = "visible"; - root.style.width = `${exportWidth}px`; - root.style.height = `${exportHeight}px`; + root.setCssStyles({ + width: `${exportWidth}px`, + height: `${exportHeight}px`, + }); copyThemeVariables(viewRootEl, root); const bgStyles = getComputedStyle(viewRootEl); const dotColor = bgStyles.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0"; - root.style.backgroundColor = backgroundColor; + const rootBg: Partial = { backgroundColor }; if (showGrid) { - root.style.backgroundImage = buildGridBackground(backgroundColor, dotColor); - root.style.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`; - root.style.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`; + rootBg.backgroundImage = buildGridBackground(backgroundColor, dotColor); + rootBg.backgroundSize = `${GRID_SIZE}px ${GRID_SIZE}px`; + rootBg.backgroundPosition = `${EXPORT_PADDING}px ${EXPORT_PADDING}px`; } + root.setCssStyles(rootBg); - const stage = document.createElement("div"); + const stage = activeDocument.createElement("div"); stage.className = "o-tie-export-stage"; - stage.style.position = "absolute"; - stage.style.width = `${bounds.width}px`; - stage.style.height = `${bounds.height}px`; - stage.style.left = `${EXPORT_PADDING - (crop?.x ?? 0)}px`; - stage.style.top = `${EXPORT_PADDING - (crop?.y ?? 0)}px`; - stage.style.overflow = "visible"; - + stage.setCssStyles({ + width: `${bounds.width}px`, + height: `${bounds.height}px`, + left: `${EXPORT_PADDING - (crop?.x ?? 0)}px`, + top: `${EXPORT_PADDING - (crop?.y ?? 0)}px`, + }); stage.appendChild(buildDiagramLayer(svgEl, nodesEl, bounds)); root.appendChild(stage); wrapper.appendChild(root); - document.body.appendChild(wrapper); + activeDocument.body.appendChild(wrapper); try { - await document.fonts.ready; + await activeDocument.fonts.ready; await Promise.all( Array.from(root.querySelectorAll("img.o-tie-export-edges")).map( (img) => { @@ -269,7 +263,7 @@ export async function rasterizeBowtieForExport(options: BowtieExportOptions): Pr export function downloadPng(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); + const anchor = activeDocument.createElement("a"); anchor.href = url; anchor.download = filename.endsWith(".png") ? filename : `${filename}.png`; anchor.click(); diff --git a/src/exportImageModal.ts b/src/exportImageModal.ts index e53068e..2dadd7f 100644 --- a/src/exportImageModal.ts +++ b/src/exportImageModal.ts @@ -61,7 +61,6 @@ export class ExportImageModal extends Modal { slider .setLimits(1, 4, 0.5) .setValue(this.scale) - .setDynamicTooltip() .onChange((value) => { this.scale = value; }) diff --git a/src/helpModal.ts b/src/helpModal.ts index 1fafff8..d2bc6a3 100644 --- a/src/helpModal.ts +++ b/src/helpModal.ts @@ -1,6 +1,5 @@ import { App, Modal } from "obsidian"; - -const PLUGIN_VERSION = "1.0.0"; +import * as manifest from "../manifest.json"; export class HelpModal extends Modal { constructor(app: App) { @@ -14,7 +13,7 @@ export class HelpModal extends Modal { contentEl.createEl("p", { cls: "o-tie-help-version", - text: `Version ${PLUGIN_VERSION}`, + text: `Version ${manifest.version}`, }); const body = contentEl.createDiv({ cls: "o-tie-help-body" }); @@ -45,7 +44,24 @@ export class HelpModal extends Modal { "Top Event (red) — the central loss-of-control event", "Threats (gray) — left side; prevention barriers (green) sit on connectors to the top event", "Consequences (purple) — right side; mitigation barriers (green) sit on connectors from the top event", - "Escalation factors (purple) — branch from barriers; can have escalation barriers", + "Escalation factors (purple, dashed) — branch downward from a barrier when something could weaken that barrier", + "Escalation barriers (purple, dashed) — sit on escalation factors and control those weakening influences", + ]); + + this.addSection(body, "Barriers", [ + "A barrier is a control that stops or reduces risk along the main bowtie path. In O-Tie, barriers are green nodes on the connectors between threats/consequences and the top event.", + "Prevention barriers sit between a threat and the top event. They stop the threat from causing the top event — for example an alarm, an interlock, a procedure, or operator training.", + "Mitigation barriers sit between the top event and a consequence. They limit harm if the top event happens — for example emergency shutdown, fire suppression, or evacuation.", + "Add prevention barriers from a selected threat (+ Prevention Barrier in the inspector, the lane + button, or the barrier + on the threat node). Add mitigation barriers from a selected consequence the same way, or use + Barrier in the toolbar when a threat or consequence is selected.", + "Each barrier can carry an analysis stack (type, effectiveness, criticality, and more) — see Barrier analysis stacks below.", + ]); + + this.addSection(body, "Escalation factors and escalation barriers", [ + "Barriers are not always reliable on their own. An escalation factor describes a condition that could weaken, bypass, or defeat a barrier — such as missed maintenance, fatigue, corrosion, or conflicting procedures.", + "In the diagram, escalation factors appear as purple dashed nodes branching below their parent barrier. Dashed connectors show that this is a secondary path: the factor does not replace the main threat-to-event or event-to-consequence flow, but explains how the barrier might fail.", + "An escalation barrier is a control targeted at the escalation factor itself — the measure that keeps the weakening influence in check. For example, if an escalation factor is “sensor not calibrated”, an escalation barrier might be “annual calibration program”.", + "To add an escalation factor, select a prevention or mitigation barrier, then click + Escalation factor in the inspector, the ⚡ button on the barrier, or use the context menu. To add an escalation barrier, select the escalation factor and use + Escalation barrier, the + button on the factor, or the context menu.", + "Name each node to match your risk assessment. Use notes in the inspector for evidence, assumptions, or actions. A complete bowtie often chains several barriers, each with its own escalation factors and escalation barriers where relevant.", ]); this.addSection(body, "Editing", [ @@ -58,13 +74,6 @@ export class HelpModal extends Modal { "Use the + buttons in lanes between threats/consequences and the top event to add barriers quickly.", ]); - this.addSection(body, "Barriers and escalation", [ - "Prevention barriers block threats from reaching the top event.", - "Mitigation barriers reduce consequences after the top event.", - "Select a barrier, then use + Barrier or the context menu to add escalation factors.", - "Escalation factors can have their own escalation barriers.", - ]); - this.addSection(body, "Barrier analysis stacks", [ "Each barrier can have an expandable stack of analysis rows.", "Preset fields include barrier type, effectiveness, criticality, responsible party, validation method, and status.", diff --git a/src/icons.ts b/src/icons.ts index 1b49c61..dfd1fb6 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -32,5 +32,5 @@ export function registerPluginIcon(): void { /** Make the ribbon button icon slightly larger than default. */ export function styleRibbonIcon(el: HTMLElement): void { - el.style.setProperty("--icon-size", "var(--icon-size-l)"); + el.setCssProps({ "--icon-size": "var(--icon-size-l)" }); } diff --git a/src/layout.ts b/src/layout.ts index 52b331e..a7f0fec 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -25,7 +25,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = { barrierWidth: 140, barrierHeight: 52, barrierHeaderHeight: 52, - barrierStackRowHeight: 22, + barrierStackRowHeight: 24, escalationWidth: 130, escalationHeight: 44, columnGap: 100, @@ -168,10 +168,6 @@ function placeBarriersInLane( ); } -function nodeCenter(node: PositionedNode): { x: number; y: number } { - return { x: node.x + node.width / 2, y: node.y + node.height / 2 }; -} - function nodeRight(node: PositionedNode): { x: number; y: number } { return { x: node.x + node.width, y: node.y + node.height / 2 }; } @@ -180,6 +176,37 @@ function nodeLeft(node: PositionedNode): { x: number; y: number } { return { x: node.x, y: node.y + node.height / 2 }; } +/** Port points used for main-flow bezier edges between two nodes. */ +export function connectionPorts( + from: PositionedNode, + to: PositionedNode +): { from: { x: number; y: number }; to: { x: number; y: number } } { + return { from: nodeRight(from), to: nodeLeft(to) }; +} + +/** Point on the same cubic bezier as {@link makeBezierEdge} at parameter t (0–1). */ +export function bezierPointAt( + x1: number, + y1: number, + x2: number, + y2: number, + t: number, + curve = 0.35 +): { x: number; y: number } { + const dx = (x2 - x1) * curve; + const p1x = x1 + dx; + const p1y = y1; + const p2x = x2 - dx; + const p2y = y2; + const mt = 1 - t; + const mt2 = mt * mt; + const t2 = t * t; + return { + x: mt2 * mt * x1 + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t2 * t * x2, + y: mt2 * mt * y1 + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t2 * t * y2, + }; +} + function nodeBottom(node: PositionedNode): { x: number; y: number } { return { x: node.x + node.width / 2, y: node.y + node.height }; } diff --git a/src/main.ts b/src/main.ts index ea13406..bc79df3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,7 +63,8 @@ export default class OTiePlugin extends Plugin { onunload(): void {} async loadSettings(): Promise { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const loaded = (await this.loadData()) as Partial | null; + this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded ?? {}); } async saveSettings(): Promise { @@ -91,28 +92,32 @@ export default class OTiePlugin extends Plugin { } private async createNewBowtie(): Promise { - new NewBowtieNameModal(this.app, async (name) => { - const safeName = this.sanitizeFileName(name); - const folder = normalizePath(this.settings.defaultFolder); - await this.ensureFolder(folder); - - const basePath = folder ? `${folder}/${safeName}` : safeName; - const filePath = getBowtieFilePath(basePath); - - if (this.app.vault.getAbstractFileByPath(filePath)) { - new Notice(`A bowtie named "${safeName}" already exists.`); - return; - } - - const bowtie = createBowtie(name); - bowtie.hazard = "Hazard"; - bowtie.topEvent = "Top Event"; - - const file = await this.app.vault.create(filePath, serializeBowtie(bowtie)); - await this.openBowtieFile(file); + new NewBowtieNameModal(this.app, (name) => { + void this.createBowtieFromName(name); }).open(); } + private async createBowtieFromName(name: string): Promise { + const safeName = this.sanitizeFileName(name); + const folder = normalizePath(this.settings.defaultFolder); + await this.ensureFolder(folder); + + const basePath = folder ? `${folder}/${safeName}` : safeName; + const filePath = getBowtieFilePath(basePath); + + if (this.app.vault.getAbstractFileByPath(filePath)) { + new Notice(`A bowtie named "${safeName}" already exists.`); + return; + } + + const bowtie = createBowtie(name); + bowtie.hazard = "Hazard"; + bowtie.topEvent = "Top Event"; + + const file = await this.app.vault.create(filePath, serializeBowtie(bowtie)); + await this.openBowtieFile(file); + } + async openBowtieFile(file: TFile): Promise { const leaf = this.app.workspace.getLeaf(true); await leaf.openFile(file); diff --git a/src/settingsTab.ts b/src/settingsTab.ts index 62ac8f3..62a79fa 100644 --- a/src/settingsTab.ts +++ b/src/settingsTab.ts @@ -29,7 +29,7 @@ export class OTieSettingTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - containerEl.createEl("h2", { text: "O-Tie Settings" }); + new Setting(containerEl).setName("O-Tie Settings").setHeading(); new Setting(containerEl) .setName("Default folder") diff --git a/styles.css b/styles.css index 5ec8e0e..2ea6577 100644 --- a/styles.css +++ b/styles.css @@ -100,6 +100,48 @@ --text-normal: #1a1a1a; --text-muted: #5d6d7e; --background-primary: #ffffff; + position: relative; + left: 0; + top: 0; + overflow: hidden; + opacity: 1; + visibility: visible; +} + +.o-tie-export-wrapper { + position: fixed; + left: -20000px; + top: 0; + pointer-events: none; + z-index: -1; +} + +.o-tie-export-layer { + position: relative; + overflow: visible; +} + +.o-tie-export-edges { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.o-tie-export-nodes { + position: absolute; + top: 0; + left: 0; + overflow: visible; +} + +.o-tie-export-stage { + position: absolute; + overflow: visible; +} + +.o-tie-inspector-notes-resize { + height: 0; } /* Root view */ @@ -366,15 +408,16 @@ position: absolute; overflow: visible; cursor: pointer; - z-index: 1; + z-index: 2; + isolation: isolate; } .o-tie-node-wrap:hover { - z-index: 2; + z-index: 3; } .o-tie-node-wrap.o-tie-node-selected { - z-index: 3; + z-index: 4; } .o-tie-node { @@ -545,11 +588,12 @@ padding: 0 8px; font-size: 10px; font-weight: 600; - line-height: 1.2; + line-height: 1; color: #fff; border-top: 1px solid rgba(0, 0, 0, 0.08); overflow: hidden; - flex-shrink: 0; + flex: 0 0 auto; + box-sizing: border-box; } .o-tie-stack-row-light { @@ -570,6 +614,10 @@ } .o-tie-stack-row-label { + display: flex; + align-items: center; + align-self: stretch; + line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -849,7 +897,7 @@ background: var(--o-tie-barrier-bg); color: var(--o-tie-barrier); cursor: pointer; - z-index: 15; + z-index: 1; pointer-events: auto; opacity: 0.85; -webkit-appearance: none; diff --git a/tsconfig.json b/tsconfig.json index 256d2f7..12d3bb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, + "resolveJsonModule": true, "lib": ["DOM", "ES6"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "manifest.json"] } diff --git a/versions.json b/versions.json index 5f12a19..e411b6e 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "1.4.0" + "1.0.0": "1.4.0", + "1.0.1": "1.4.0" }