diff --git a/README.md b/README.md index af6afb9..ff5fcd9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ThreatForge fills that gap. ## Status -ThreatForge is production-ready. The core application is fully functional with a modern diagramming canvas (40+ typed components), STRIDE threat engine, AI chat pane (BYOK), 13+ themes, keyboard shortcuts, and more. See the [roadmap](docs/plans/roadmap.md) for remaining launch tasks and future plans. +ThreatForge is production-ready. The core application is fully functional with a modern diagramming canvas (44 typed components + text annotations), STRIDE threat engine, AI chat pane (BYOK), 13+ themes, keyboard shortcuts, and more. See the [roadmap](docs/plans/roadmap.md) for remaining launch tasks and future plans. ## Development diff --git a/docs/knowledge/file-format.md b/docs/knowledge/file-format.md index 13123c4..8d622aa 100644 --- a/docs/knowledge/file-format.md +++ b/docs/knowledge/file-format.md @@ -21,7 +21,7 @@ version → metadata → elements → data_flows → trust_boundaries → threat |---------|---------| | `version` | Schema version (currently `"1.0"`). Always first field. | | `metadata` | Title, author, dates, description, authoring info | -| `elements` | DFD nodes: processes, data stores, external entities | +| `elements` | DFD nodes: processes, data stores, external entities, text annotations | | `data_flows` | Connections between elements with protocol and data info | | `trust_boundaries` | Security boundary groups containing elements | | `threats` | STRIDE threats linked to elements/flows | diff --git a/docs/knowledge/glossary.md b/docs/knowledge/glossary.md index 8b8cc04..c64d1c1 100644 --- a/docs/knowledge/glossary.md +++ b/docs/knowledge/glossary.md @@ -26,6 +26,7 @@ | Data Store | Tampering, Information Disclosure, Denial of Service | | External Entity | Spoofing, Repudiation | | Data Flow | Tampering, Information Disclosure, Denial of Service | +| Text (Annotation) | None — excluded from STRIDE analysis | ## Comparable Open-Source Projects diff --git a/docs/knowledge/overview.md b/docs/knowledge/overview.md index 8e01d37..a2ce728 100644 --- a/docs/knowledge/overview.md +++ b/docs/knowledge/overview.md @@ -12,7 +12,7 @@ ThreatForge is an open-source, AI-enhanced, cross-platform desktop threat modeli ThreatForge's differentiation is the combination of three things no other tool brings together: -1. **UX gap filled** — A modern, keyboard-shortcut-driven interface with drag-and-drop canvas, 40+ typed components, custom themes, and resizable panes. The kind of experience developers expect from tools like Linear, Figma, or VS Code. +1. **UX gap filled** — A modern, keyboard-shortcut-driven interface with drag-and-drop canvas, 44 typed components + text annotations, custom themes, and resizable panes. The kind of experience developers expect from tools like Linear, Figma, or VS Code. 2. **File format gap filled** — The `.thf` YAML format is human-readable, git-diffable, and reviewable in pull requests. Microsoft's `.tm7` is binary. Threat Dragon's JSON is verbose. Neither can be opened in a text editor and understood. @@ -36,7 +36,7 @@ ThreatForge's differentiation is the combination of three things no other tool b ThreatForge is production-ready. The core application is fully functional: -- Modern diagramming canvas with 40+ typed components, drag-and-drop, trust boundaries +- Modern diagramming canvas with 44 typed components, drag-and-drop, trust boundaries, text annotations - STRIDE threat engine with auto-generated threats per element - AI chat pane (BYOK) with OpenAI, Anthropic, and Ollama support - `.thf` YAML file format with clean git diffs diff --git a/docs/knowledge/product-design.md b/docs/knowledge/product-design.md index 3c2199f..e65894c 100644 --- a/docs/knowledge/product-design.md +++ b/docs/knowledge/product-design.md @@ -17,12 +17,12 @@ | Feature | Description | |---------|-------------| -| Diagramming canvas | DFD canvas with drag-and-drop, trust boundaries, data flows, 40+ typed components | +| Diagramming canvas | DFD canvas with drag-and-drop, trust boundaries, data flows, text annotations, 44 typed components | | `.thf` YAML file format | Human-readable, git-diffable, schema-validated, single-file | | STRIDE threat engine | Auto-generated threats per element type, rule-based | | AI chat pane (BYOK) | OpenAI, Anthropic, Ollama support; conversational threat analysis | | Cross-platform | macOS, Windows, Linux via Tauri v2 | -| Component library | 40+ typed components across 9 categories with lucide icons | +| Component library | 44 typed components across 10 categories (incl. Annotations) with lucide icons | | Themes | 13+ themes (light and dark), separate light/dark selection | | Keyboard shortcuts | 27+ shortcuts, Cmd+K command palette | | Resizable panes | Draggable left/right panels with min/max constraints | diff --git a/docs/plans/roadmap.md b/docs/plans/roadmap.md index 7b47707..7d5b106 100644 --- a/docs/plans/roadmap.md +++ b/docs/plans/roadmap.md @@ -6,7 +6,7 @@ ThreatForge is production-ready. All core features are implemented and tested. ### What's Done -- Diagramming canvas with 40+ typed components, drag-and-drop, trust boundaries, self-loops +- Diagramming canvas with 44 typed components + text annotations, drag-and-drop, trust boundaries, self-loops - `.thf` YAML file format (single-file, inline layout, clean git diffs) - STRIDE threat engine with auto-generated threats per element type - AI chat pane (BYOK: OpenAI, Anthropic, Ollama) diff --git a/docs/plans/todo.md b/docs/plans/todo.md index 0383195..fd5d8ec 100644 --- a/docs/plans/todo.md +++ b/docs/plans/todo.md @@ -4,6 +4,64 @@ Shared execution plan for humans and LLM agents. Update this file before, during --- +## 2026-03-03 — Text Element Feature (Architecture Annotations) + +### Context +Adding a Text element type so users can place annotation labels, callouts, and descriptions directly on the canvas. Text elements are non-connectable and excluded from STRIDE analysis. + +### Plan +- [x] Step 1: Data model — add `font_size`, `font_weight` to Element (Rust + TS) +- [x] Step 2: Component library — add "text" entry with `ShapeCategory: "text"`, `StrideCategory: "none"` +- [x] Step 3: STRIDE engine exclusion (TS skip "none" category, Rust add `Annotation` variant) +- [x] Step 4: Canvas store — route text elements to `textAnnotation` node type +- [x] Step 5: Create `TextAnnotationNode` component (borderless, no handles, inline edit) +- [x] Step 6: Register `textAnnotation` in DfdCanvas, reject text node connections +- [x] Step 7: Add "Text" to component palette GENERIC_ITEMS +- [x] Step 8: Properties panel — text-specific controls (hide irrelevant fields, add font size/weight) +- [x] Step 9: Tests — component library, STRIDE engine, Rust round-trip + backward compat +- [x] Step 10: Lint & validate — biome, tsc, vitest (415/415), cargo clippy, cargo test (59/59), cargo fmt + +### Deep Audit (breadth pass) +- [x] AI prompts: add "text" to element type lists in `ai-prompt.ts` and `prompt.rs` +- [x] MCP server: add "text" to `VALID_ELEMENT_TYPES` in `mcp/server.rs` +- [x] Context menu: hide "View Threats" for text annotation nodes (`canvas-context-menu.tsx` + `dfd-canvas.tsx`) +- [x] Context menu test: added test for `isTextAnnotation` flag excluding "View Threats" +- [x] Docs: update component counts (44 typed + text annotations) in `README.md`, `overview.md`, `product-design.md`, `roadmap.md` +- [x] Docs: add Text (Annotation) row to STRIDE mapping table in `glossary.md` +- [x] Docs: add text annotations to elements description in `file-format.md` +- [x] Onboarding: update component count in `whats-new-overlay.tsx` (28 → 44 + text annotations) +- [x] Validate: biome clean, tsc clean, vitest 416/416, cargo clippy clean, cargo test 59/59, cargo fmt clean + +### Notes +- New ReactFlow node type `textAnnotation` (alongside `dfdElement` and `trustBoundary`) +- Text styling via existing `fill_color`/`fill_opacity` (text color) + new `font_size`/`font_weight` +- No breaking schema changes — new fields are optional with serde defaults + +### Files Created +| File | Purpose | +|------|---------| +| `src/components/canvas/nodes/text-annotation-node.tsx` | Text element ReactFlow node component | + +### Files Modified +| File | Change | +|------|--------| +| `src-tauri/src/models/threat_model.rs` | Added `font_size`, `font_weight` to `Element` + 3 round-trip tests | +| `src-tauri/src/stride/mod.rs` | Added `Annotation` category, skip text elements, test for zero threats | +| `src-tauri/src/ai/prompt.rs` | Updated Element struct literals with new fields | +| `src-tauri/src/mcp/server.rs` | Updated Element struct literal with new fields | +| `src/types/threat-model.ts` | Added `font_size`, `font_weight` to `Element` interface | +| `src/lib/component-library.ts` | Added `"text"` ShapeCategory, `"none"` StrideCategory, Type icon, text component def | +| `src/lib/component-library.test.ts` | Updated valid shape/stride lists, 5 new text component tests | +| `src/lib/stride-engine.ts` | Skip `"none"` category elements | +| `src/lib/stride-engine.test.ts` | 2 new tests: text produces zero threats, doesn't affect other counts | +| `src/stores/canvas-store.ts` | Route text to `textAnnotation` node type, added `fontSize`/`fontWeight` to DfdNodeData | +| `src/components/canvas/dfd-canvas.tsx` | Register `TextAnnotationNode`, reject text node connections | +| `src/components/palette/component-palette.tsx` | Added "Text" to GENERIC_ITEMS | +| `src/components/panels/properties-tab.tsx` | Text-specific properties (hide irrelevant, add font controls) | +| `docs/plans/todo.md` | This plan | + +--- + ## 2026-03-03 — Revamp Pre-Made Templates (Show-Stopper Level) ### Context diff --git a/src-tauri/src/ai/prompt.rs b/src-tauri/src/ai/prompt.rs index 8ee9d9d..f2d7c22 100644 --- a/src-tauri/src/ai/prompt.rs +++ b/src-tauri/src/ai/prompt.rs @@ -51,7 +51,7 @@ pub fn build_system_prompt(model: &ThreatModel) -> String { Supported actions: add_element, update_element, delete_element, add_data_flow, update_data_flow, delete_data_flow, \ add_trust_boundary, update_trust_boundary, delete_trust_boundary, add_threat, update_threat, delete_threat.\n\ For updates, only include fields that need to change. For deletes, only the id is needed.\n\ - Element types: \"process\", \"data_store\", \"external_entity\". \ + Element types: \"process\", \"data_store\", \"external_entity\", \"text\" (annotation, no STRIDE threats). \ STRIDE categories: Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege. \ Severities: critical, high, medium, low, info.\n\ The user will review and approve actions before they are applied.\n\n", @@ -197,6 +197,8 @@ mod tests { stroke_color: None, fill_opacity: None, stroke_opacity: None, + font_size: None, + font_weight: None, }); let prompt = build_system_prompt(&model); diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 943db97..99dfcb3 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -98,7 +98,7 @@ impl ThreatForgeServer { } /// Valid element types accepted by the MCP server. - const VALID_ELEMENT_TYPES: &[&str] = &["process", "data_store", "external_entity"]; + const VALID_ELEMENT_TYPES: &[&str] = &["process", "data_store", "external_entity", "text"]; /// Generate a unique element ID, appending a suffix on collision. fn unique_element_id(model: &ThreatModel, base: &str) -> String { @@ -316,6 +316,8 @@ impl ThreatForgeServer { stroke_color: None, fill_opacity: None, stroke_opacity: None, + font_size: None, + font_weight: None, }; model.elements.push(element); self.save_locked(&model)?; diff --git a/src-tauri/src/models/threat_model.rs b/src-tauri/src/models/threat_model.rs index 7ef83fc..c440d01 100644 --- a/src-tauri/src/models/threat_model.rs +++ b/src-tauri/src/models/threat_model.rs @@ -113,6 +113,10 @@ pub struct Element { pub fill_opacity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_weight: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -743,4 +747,66 @@ data_flows: serde_json::from_str(&json).expect("Failed to deserialize layout"); assert_eq!(layout, deserialized); } + + #[test] + fn test_font_size_font_weight_round_trip() { + let yaml = r##" +version: "1.0" +metadata: + title: "Font Test" + author: "Test" + created: 2026-03-15 + modified: 2026-03-15 +elements: + - id: text-1 + type: text + name: "API Gateway Cluster" + font_size: 20.0 + font_weight: bold + fill_color: "#94A3B8" + fill_opacity: 0.8 +"##; + let model: ThreatModel = serde_yaml::from_str(yaml).expect("Failed to parse"); + assert_eq!(model.elements[0].element_type, "text"); + assert_eq!(model.elements[0].font_size, Some(20.0)); + assert_eq!(model.elements[0].font_weight, Some("bold".to_string())); + + // Round-trip + let reserialized = serde_yaml::to_string(&model).expect("Failed to serialize"); + let reparsed: ThreatModel = serde_yaml::from_str(&reserialized).expect("Failed to reparse"); + assert_eq!(model, reparsed, "Round-trip should produce equal models"); + } + + #[test] + fn test_font_fields_omitted_when_none() { + let model = ThreatModel::new("Test", "Author"); + let yaml = serde_yaml::to_string(&model).expect("Failed to serialize"); + assert!( + !yaml.contains("font_size"), + "font_size should be omitted from YAML when None" + ); + assert!( + !yaml.contains("font_weight"), + "font_weight should be omitted from YAML when None" + ); + } + + #[test] + fn test_old_yaml_without_font_fields_parses() { + let yaml = r#" +version: "1.0" +metadata: + title: "No Font Fields" + author: "Test" + created: 2026-03-15 + modified: 2026-03-15 +elements: + - id: app + type: process + name: "App" +"#; + let model: ThreatModel = serde_yaml::from_str(yaml).expect("Old format should parse"); + assert!(model.elements[0].font_size.is_none()); + assert!(model.elements[0].font_weight.is_none()); + } } diff --git a/src-tauri/src/stride/mod.rs b/src-tauri/src/stride/mod.rs index 945fcde..402bf56 100644 --- a/src-tauri/src/stride/mod.rs +++ b/src-tauri/src/stride/mod.rs @@ -10,6 +10,7 @@ enum ComponentStrideCategory { Service, Store, Actor, + Annotation, } /// Map a component type string to its STRIDE category. @@ -22,6 +23,8 @@ fn stride_category_for_type(element_type: &str) -> ComponentStrideCategory { // Clients / actors "web_browser" | "mobile_app" | "desktop_app" | "iot_device" | "external_entity" | "api_client" | "cli_tool" => ComponentStrideCategory::Actor, + // Annotations — excluded from STRIDE analysis + "text" => ComponentStrideCategory::Annotation, // Everything else is a service (services, messaging, infra, security, networking, cloud, generic, process) _ => ComponentStrideCategory::Service, } @@ -219,6 +222,9 @@ pub fn analyze(model: &ThreatModel) -> Vec { // Element-based rules for element in &model.elements { let element_category = stride_category_for_type(&element.element_type); + if element_category == ComponentStrideCategory::Annotation { + continue; + } for rule in &rules { if rule.targets_flows { continue; @@ -335,6 +341,8 @@ mod tests { stroke_color: None, fill_opacity: None, stroke_opacity: None, + font_size: None, + font_weight: None, }, Element { id: "db".to_string(), @@ -352,6 +360,8 @@ mod tests { stroke_color: None, fill_opacity: None, stroke_opacity: None, + font_size: None, + font_weight: None, }, Element { id: "user".to_string(), @@ -369,6 +379,8 @@ mod tests { stroke_color: None, fill_opacity: None, stroke_opacity: None, + font_size: None, + font_weight: None, }, ], data_flows: vec![DataFlow { @@ -652,5 +664,53 @@ mod tests { stride_category_for_type("cli_tool"), ComponentStrideCategory::Actor ); + + // Annotations + assert_eq!( + stride_category_for_type("text"), + ComponentStrideCategory::Annotation + ); + } + + #[test] + fn test_text_elements_produce_zero_threats() { + let mut model = sample_model(); + model.elements.push(Element { + id: "text-1".to_string(), + element_type: "text".to_string(), + name: "API Gateway Cluster".to_string(), + trust_zone: String::new(), + subtype: None, + icon: None, + description: String::new(), + technologies: vec![], + stores: None, + encryption: None, + position: None, + fill_color: None, + stroke_color: None, + fill_opacity: None, + stroke_opacity: None, + font_size: Some(20.0), + font_weight: Some("bold".to_string()), + }); + + let threats = analyze(&model); + let text_threats: Vec<_> = threats + .iter() + .filter(|t| t.element.as_deref() == Some("text-1")) + .collect(); + assert_eq!( + text_threats.len(), + 0, + "Text annotation elements should produce zero STRIDE threats" + ); + + // Total count should remain the same as without the text element (14) + assert_eq!( + threats.len(), + 14, + "Total threat count should not change when adding a text element" + ); } } diff --git a/src/components/canvas/canvas-context-menu.test.tsx b/src/components/canvas/canvas-context-menu.test.tsx index d60981a..965acf9 100644 --- a/src/components/canvas/canvas-context-menu.test.tsx +++ b/src/components/canvas/canvas-context-menu.test.tsx @@ -95,6 +95,19 @@ describe("buildNodeMenuItems", () => { const deleteItem = items.find((i) => i.label === "Delete"); expect(deleteItem?.variant).toBe("danger"); }); + + it("excludes View Threats for text annotations", () => { + const items = buildNodeMenuItems({ + onEditProperties: vi.fn(), + onDelete: vi.fn(), + onDuplicate: vi.fn(), + onViewThreats: vi.fn(), + isTextAnnotation: true, + }); + + expect(items).toHaveLength(3); + expect(items.map((i) => i.label)).toEqual(["Edit Properties", "Duplicate", "Delete"]); + }); }); describe("buildEdgeMenuItems", () => { diff --git a/src/components/canvas/canvas-context-menu.tsx b/src/components/canvas/canvas-context-menu.tsx index 343da70..4ef68dc 100644 --- a/src/components/canvas/canvas-context-menu.tsx +++ b/src/components/canvas/canvas-context-menu.tsx @@ -71,18 +71,23 @@ export function buildNodeMenuItems({ onDelete, onDuplicate, onViewThreats, + isTextAnnotation, }: { onEditProperties: () => void; onDelete: () => void; onDuplicate: () => void; onViewThreats: () => void; + isTextAnnotation?: boolean; }): ContextMenuItem[] { - return [ + const items: ContextMenuItem[] = [ { label: "Edit Properties", icon: Pencil, onClick: onEditProperties }, - { label: "View Threats", icon: Shield, onClick: onViewThreats }, - { label: "Duplicate", icon: Copy, onClick: onDuplicate }, - { label: "Delete", icon: Trash2, onClick: onDelete, variant: "danger" }, ]; + if (!isTextAnnotation) { + items.push({ label: "View Threats", icon: Shield, onClick: onViewThreats }); + } + items.push({ label: "Duplicate", icon: Copy, onClick: onDuplicate }); + items.push({ label: "Delete", icon: Trash2, onClick: onDelete, variant: "danger" }); + return items; } /** Build menu items for an edge context menu */ diff --git a/src/components/canvas/dfd-canvas.tsx b/src/components/canvas/dfd-canvas.tsx index 8fe277c..4c73d1b 100644 --- a/src/components/canvas/dfd-canvas.tsx +++ b/src/components/canvas/dfd-canvas.tsx @@ -21,10 +21,12 @@ import { useUiStore } from "@/stores/ui-store"; import { buildEdgeMenuItems, buildNodeMenuItems, CanvasContextMenu } from "./canvas-context-menu"; import { DataFlowEdge } from "./edges/data-flow-edge"; import { DfdElementNode } from "./nodes/dfd-element-node"; +import { TextAnnotationNode } from "./nodes/text-annotation-node"; import { TrustBoundaryNode } from "./nodes/trust-boundary-node"; const nodeTypes = { dfdElement: DfdElementNode, + textAnnotation: TextAnnotationNode, trustBoundary: TrustBoundaryNode, }; @@ -169,10 +171,18 @@ export function DfdCanvas() { ); /** Validates whether a connection is allowed */ - const isValidConnection = useCallback((connection: Connection | DfdEdge) => { - if (!connection.source || !connection.target) return false; - return true; - }, []); + const isValidConnection = useCallback( + (connection: Connection | DfdEdge) => { + if (!connection.source || !connection.target) return false; + // Reject connections to/from text annotation nodes + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + if (sourceNode?.type === "textAnnotation" || targetNode?.type === "textAnnotation") + return false; + return true; + }, + [nodes], + ); const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); @@ -349,6 +359,7 @@ export function DfdCanvas() { const contextMenuItems = useMemo(() => { if (!contextMenu) return []; if (contextMenu.nodeId) { + const node = useCanvasStore.getState().nodes.find((n) => n.id === contextMenu.nodeId); return buildNodeMenuItems({ onEditProperties: () => { useModelStore.getState().setSelectedElement(contextMenu.nodeId ?? null); @@ -374,6 +385,7 @@ export function DfdCanvas() { useModelStore.getState().setSelectedElement(contextMenu.nodeId ?? null); useUiStore.getState().setRightPanelTab("threats"); }, + isTextAnnotation: node?.type === "textAnnotation", }); } if (contextMenu.edgeId) { diff --git a/src/components/canvas/nodes/text-annotation-node.tsx b/src/components/canvas/nodes/text-annotation-node.tsx new file mode 100644 index 0000000..19d2937 --- /dev/null +++ b/src/components/canvas/nodes/text-annotation-node.tsx @@ -0,0 +1,86 @@ +import type { NodeProps } from "@xyflow/react"; +import { useCallback, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { DfdNodeData } from "@/stores/canvas-store"; +import { useCanvasStore } from "@/stores/canvas-store"; +import { useModelStore } from "@/stores/model-store"; + +const DEFAULT_FONT_SIZE = 14; +const DEFAULT_FONT_WEIGHT = "normal"; + +/** + * Convert a hex color (#rrggbb) + opacity (0-1) to an rgba string. + * Returns undefined for invalid/empty hex values (falls back to CSS inheritance). + */ +function hexToRgba(hex: string, opacity: number): string | undefined { + if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return undefined; + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +} + +export function TextAnnotationNode({ id, data, selected }: NodeProps) { + const nodeData = data as DfdNodeData; + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + + const fontSize = (nodeData.fontSize as number) ?? DEFAULT_FONT_SIZE; + const fontWeight = (nodeData.fontWeight as string) ?? DEFAULT_FONT_WEIGHT; + const fillColor = nodeData.elementFillColor as string | undefined; + const fillOpacity = (nodeData.elementFillOpacity as number | undefined) ?? 1.0; + + const textColor = fillColor ? hexToRgba(fillColor, fillOpacity) : undefined; + + const commitLabel = useCallback( + (value: string) => { + const trimmed = value.trim(); + if (trimmed && trimmed !== nodeData.label) { + useModelStore.getState().updateElement(id, { name: trimmed }); + const nodes = useCanvasStore.getState().nodes; + const updatedNodes = nodes.map((n) => + n.id === id ? { ...n, data: { ...n.data, label: trimmed } } : n, + ); + useCanvasStore.setState({ nodes: updatedNodes }); + } + setIsEditing(false); + }, + [id, nodeData.label], + ); + + return ( +
+ {/* No handles — text elements are not connectable */} + {isEditing ? ( + commitLabel(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") commitLabel(e.currentTarget.value); + if (e.key === "Escape") setIsEditing(false); + }} + /> + ) : ( + setIsEditing(true)}> + {nodeData.label} + + )} +
+ ); +} diff --git a/src/components/onboarding/whats-new-overlay.tsx b/src/components/onboarding/whats-new-overlay.tsx index 4605698..305dbe7 100644 --- a/src/components/onboarding/whats-new-overlay.tsx +++ b/src/components/onboarding/whats-new-overlay.tsx @@ -25,7 +25,7 @@ const CHANGELOG: ChangelogEntry[] = [ version: "1.0.0", date: "2026-03-02", changes: [ - "Component library with 28 pre-built technology components", + "Component library with 44 pre-built technology components + text annotations", "STRIDE threat analysis engine with auto-generated threats", "AI chat pane with BYOK support (OpenAI, Anthropic, Ollama)", "Human-readable YAML file format — git-diffable", diff --git a/src/components/palette/component-palette.tsx b/src/components/palette/component-palette.tsx index bfce3d9..223d8e1 100644 --- a/src/components/palette/component-palette.tsx +++ b/src/components/palette/component-palette.tsx @@ -1,5 +1,5 @@ import { useReactFlow } from "@xyflow/react"; -import { Box, Search, ShieldAlert } from "lucide-react"; +import { Box, Search, ShieldAlert, Type } from "lucide-react"; import { useMemo, useState } from "react"; import { COMPONENT_CATEGORIES, @@ -26,6 +26,12 @@ const GENERIC_ITEMS = [ icon: ShieldAlert, description: "Trust zone boundary", }, + { + type: "text" as const, + label: "Text", + icon: Type, + description: "Text annotation", + }, ]; export function ComponentPalette() { diff --git a/src/components/panels/properties-tab.tsx b/src/components/panels/properties-tab.tsx index 68b60f3..329aec5 100644 --- a/src/components/panels/properties-tab.tsx +++ b/src/components/panels/properties-tab.tsx @@ -206,6 +206,8 @@ function FlowThreats({ flowId }: { flowId: string }) { ); } +const FONT_SIZE_OPTIONS = [12, 14, 16, 20, 24, 32, 48]; + function ElementProperties({ elementId }: { elementId: string }) { const model = useModelStore((s) => s.model); const updateElement = useModelStore((s) => s.updateElement); @@ -222,6 +224,7 @@ function ElementProperties({ elementId }: { elementId: string }) { return

Element not found.

; } + const isTextAnnotation = element.type === "text"; const subtypes = getSubtypesForType(element.type); const relatedThreats = model?.threats.filter((t) => t.element === element.id) ?? []; @@ -268,29 +271,33 @@ function ElementProperties({ elementId }: { elementId: string }) {
- {/* Type dropdown */} - + {/* Type dropdown — hidden for text annotations */} + {!isTextAnnotation && ( + + )} + + {isTextAnnotation && } - {/* Sub-type dropdown (conditional) */} - {subtypes.length > 0 && ( + {/* Sub-type dropdown (conditional, not for text) */} + {!isTextAnnotation && subtypes.length > 0 && (