diff --git a/README.md b/README.md index 3283a3d..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,42 +0,0 @@ -# ZUI - -A node-based editor for configuration-driven content. Connect **Config** nodes (PlantUML, Markdown, or Wireframe) to **Render** nodes to see live output. Use **Variable** and **Function** nodes to feed data and custom logic into configs via Nunjucks templating. - -## Run the app - -```bash -npm install -npm run dev -``` - -Open the URL shown in the terminal (e.g. http://localhost:5173). - -## Adding nodes - -- **Right-click** on the canvas to open the context menu. -- Choose **Create Node** → **Config**, **Render**, **Variable**, or **Function**. -- Connect nodes by dragging from an output handle to an input handle. - -## Saving and loading - -- **Project → Export…** saves the current graph as a `.zui.json` file. -- **Project → Import…** loads a previously saved project. - -Projects are stored as JSON with a `version` field, plus `nodes` and `edges` arrays. - -## Tech - -- [React Flow](https://reactflow.dev/) for the graph canvas -- [Nunjucks](https://mozilla.github.io/nunjucks/) for templating in configs -- PlantUML diagrams via [Kroki](https://kroki.io/) (proxied in dev) -- [Wireweave](https://github.com/wireweave/core) for wireframe UI → SVG - -## Scripts - -| Command | Description | -|----------------|--------------------------| -| `npm run dev` | Start dev server | -| `npm run build`| Production build | -| `npm run preview` | Preview production build | -| `npm run test` | Run tests in watch mode | -| `npm run test:run` | Run tests once | diff --git a/package.json b/package.json index 0cbb95d..45299b3 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview", - "test": "vitest", - "test:run": "vitest run" + "preview": "vite preview" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.2", @@ -34,13 +32,10 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@testing-library/react": "^16.0.0", "@types/node": "^25.3.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^5.1.4", - "jsdom": "^25.0.0", - "vitest": "^2.0.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", "shadcn": "^4.0.0", diff --git a/src/App.tsx b/src/App.tsx index 6dbdfa0..0506712 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -148,6 +148,10 @@ export default function App() { const wrapperRef = React.useRef(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) const [contextTarget, setContextTarget] = React.useState(null) + const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState(null) + const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) + const nodesRef = React.useRef(nodes) + nodesRef.current = nodes // Throttle node changes during drag: merge changes and flush at most once per animation frame to reduce re-renders const pendingChangesRef = useRef[]>([]) @@ -372,25 +376,40 @@ export default function App() { const position = getMenuPosition() if (position == null) return const nodeType = type as Node['type'] - setNodes((nds) => { - const newId = getNextNodeId(nodeType, nds.map((n) => n.id)) - const dataMap = getDefaultDataForType(nodeType, newId) - const style = getDefaultStyle(nodeType) - const newNode: Node = { - id: newId, - type: nodeType, - position: { x: position.x, y: position.y }, - data: dataMap, - style, - } - return nds.concat(newNode) - }) + const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id)) + const dataMap = getDefaultDataForType(nodeType, newId) + const style = getDefaultStyle(nodeType) + const newNode: Node = { + id: newId, + type: nodeType, + position: { x: position.x, y: position.y }, + data: dataMap, + style, + } + setNodes((nds) => nds.concat(newNode)) + setLastCreatedNodeId(newId) lastClickRef.current = null setContextTarget(null) }, [getMenuPosition, setNodes] ) + React.useEffect(() => { + if (lastCreatedNodeId == null) return + const id = lastCreatedNodeId + const raf = requestAnimationFrame(() => { + const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null + if (nodeEl) { + setAriaAnnouncement('Node created') + nodeEl.setAttribute('tabindex', '-1') + nodeEl.focus({ preventScroll: false }) + setTimeout(() => setAriaAnnouncement(null), 1000) + } + setLastCreatedNodeId(null) + }) + return () => cancelAnimationFrame(raf) + }, [lastCreatedNodeId, nodes]) + const pasteNode = React.useCallback(async () => { const position = getMenuPosition() if (position == null) return @@ -441,6 +460,14 @@ export default function App() { onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }} > +
+ {ariaAnnouncement} +
-
+
{nodes.length === 0 && (
@@ -528,14 +561,18 @@ export default function App() { elementsSelectable > - - +
+ +
+
+ +
- + Create Node diff --git a/src/components/base/BaseNode.tsx b/src/components/base/BaseNode.tsx index 9829ba1..3041d46 100644 --- a/src/components/base/BaseNode.tsx +++ b/src/components/base/BaseNode.tsx @@ -12,6 +12,8 @@ export type BaseNodeProps = ComponentProps<"div"> & { handles?: ReactNode; /** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */ dimensions?: { width: number; height: number }; + /** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */ + selected?: boolean; }; export function BaseNode({ @@ -22,6 +24,7 @@ export function BaseNode({ nodeId, handles, children, + selected, ...props }: BaseNodeProps) { const hasSize = @@ -48,15 +51,10 @@ export function BaseNode({ className={cn( "bg-card text-card-foreground relative rounded-md border", "hover:ring-1", - // React Flow displays node elements inside of a `NodeWrapper` component, - // which compiles down to a div with the class `react-flow__node`. - // When a node is selected, the class `selected` is added to the - // `react-flow__node` element. This allows us to style the node when it - // is selected, using Tailwind's `&` selector. - "[.react-flow\\_\\_node.selected_&]:border-muted-foreground", - "[.react-flow\\_\\_node.selected_&]:shadow-lg", + selected && "border-primary shadow-[0_0_0_2px_hsl(var(--primary)_/_0.4)]", className, )} + data-selected={selected} style={appliedStyle} tabIndex={0} {...props} diff --git a/src/components/nodes/ConfigNode.tsx b/src/components/nodes/ConfigNode.tsx index 6a90066..4adc924 100644 --- a/src/components/nodes/ConfigNode.tsx +++ b/src/components/nodes/ConfigNode.tsx @@ -47,7 +47,7 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl type Props = AbstractNodeProps -function ConfigNodeComponent({ id, data, width, height }: Props) { +function ConfigNodeComponent({ id, data, width, height, selected }: Props) { const configTypeId = getConfigTypeId(data ?? {}) const configType = getConfigType(configTypeId) const content = getConfigContent(data ?? {}) @@ -232,7 +232,7 @@ function ConfigNodeComponent({ id, data, width, height }: Props) { : undefined return ( - }> + }> } title={} diff --git a/src/components/nodes/FunctionNode.tsx b/src/components/nodes/FunctionNode.tsx index 82c2599..a53d29b 100644 --- a/src/components/nodes/FunctionNode.tsx +++ b/src/components/nodes/FunctionNode.tsx @@ -27,7 +27,7 @@ export type FunctionNodeData = { body?: string } type Props = AbstractNodeProps -function FunctionNodeComponent({ id, data, width, height }: Props) { +function FunctionNodeComponent({ id, data, width, height, selected }: Props) { const bodyValue = data?.body ?? '' const { theme } = useTheme() const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) @@ -91,7 +91,7 @@ function FunctionNodeComponent({ id, data, width, height }: Props) { : undefined return ( - }> + }> } title={} /> diff --git a/src/components/nodes/RenderingNode.tsx b/src/components/nodes/RenderingNode.tsx index d2fb8c1..eaeb8e3 100644 --- a/src/components/nodes/RenderingNode.tsx +++ b/src/components/nodes/RenderingNode.tsx @@ -36,7 +36,7 @@ const DEFAULT_VIEWPORT_HEIGHT = 800 type Props = AbstractNodeProps -function RenderingNodeComponent({ id, data, width, height }: Props) { +function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const [renderedContent, setRenderedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) @@ -529,6 +529,31 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { img.src = dataUrl }, [id, renderedContent, isSvgOutput]) + const copyPng = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.drawImage(img, 0, 0) + canvas.toBlob((blob) => { + if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}) + }, 'image/png') + } + img.onerror = () => {} + img.src = dataUrl + }, [renderedContent, isSvgOutput]) + + const copySvg = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) + navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {}) + }, [renderedContent, isSvgOutput]) + const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight }) @@ -545,7 +570,7 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { return ( - }> + }> } title={} /> @@ -602,14 +627,20 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { - Export + Export / Copy - - - PNG - + - SVG + Download SVG + + + Download PNG + + + Copy SVG + + + Copy image diff --git a/src/components/nodes/VariableNode.tsx b/src/components/nodes/VariableNode.tsx index 51fc13c..e904e4f 100644 --- a/src/components/nodes/VariableNode.tsx +++ b/src/components/nodes/VariableNode.tsx @@ -47,7 +47,7 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole } } -function VariableNodeComponent({ id, data }: Props) { +function VariableNodeComponent({ id, data, selected }: Props) { const { updateData } = useAbstractNode(id, data ?? {}) const valueType: ValueType = data?.valueType ?? 'string' @@ -79,7 +79,7 @@ function VariableNodeComponent({ id, data }: Props) { ) return ( - }> + }> } title={} /> diff --git a/src/fixtures/sample.zui.json b/src/fixtures/sample.zui.json deleted file mode 100644 index 42b9a84..0000000 --- a/src/fixtures/sample.zui.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": 1, - "nodes": [ - { - "id": "var_001", - "type": "variable", - "position": { "x": 50, "y": 100 }, - "data": { "value": "Zoe", "valueType": "string" }, - "style": { "width": 224, "height": 180 } - }, - { - "id": "cfg_001", - "type": "config", - "position": { "x": 424, "y": 50 }, - "data": { - "plantuml": "@startuml\nactor User\nparticipant \"{{ var_001 }}\" as R\nUser -> R : loves\n@enduml\n", - "title": "config-cfg_001" - }, - "style": { "width": 320, "height": 320 } - }, - { - "id": "rnd_001", - "type": "render", - "position": { "x": 894, "y": 50 }, - "data": {}, - "style": { "width": 384, "height": 320 } - } - ], - "edges": [ - { "id": "e-var_001-cfg_001", "source": "var_001", "target": "cfg_001", "type": "animated" }, - { "id": "e-cfg_001-rnd_001", "source": "cfg_001", "target": "rnd_001", "type": "animated" } - ] -} diff --git a/src/hooks/useGraphStateWithHistory.test.ts b/src/hooks/useGraphStateWithHistory.test.ts deleted file mode 100644 index 47b555d..0000000 --- a/src/hooks/useGraphStateWithHistory.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useGraphStateWithHistory } from './useGraphStateWithHistory' -import type { AppNode, AppEdge } from '@/lib/nodeTypes' - -const emptyNodes: AppNode[] = [] -const emptyEdges: AppEdge[] = [] - -function makeNode(id: string, type: string, x: number, y: number): AppNode { - return { - id, - type, - position: { x, y }, - data: {}, - style: { width: 200, height: 100 }, - } -} - -describe('useGraphStateWithHistory', () => { - it('returns initial nodes and edges', () => { - const initialNodes = [makeNode('a', 'config', 0, 0)] - const initialEdges: AppEdge[] = [{ id: 'e1', source: 'a', target: 'b' }] - const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, initialEdges)) - expect(result.current.nodes).toHaveLength(1) - expect(result.current.nodes[0].id).toBe('a') - expect(result.current.edges).toHaveLength(1) - expect(result.current.canUndo).toBe(false) - expect(result.current.canRedo).toBe(false) - }) - - it('push to past on setNodes and allows undo', () => { - const initialNodes = [makeNode('a', 'config', 0, 0)] - const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges)) - expect(result.current.canUndo).toBe(false) - act(() => { - result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)]) - }) - expect(result.current.nodes).toHaveLength(2) - expect(result.current.canUndo).toBe(true) - act(() => { - result.current.undo() - }) - expect(result.current.nodes).toHaveLength(1) - expect(result.current.nodes[0].id).toBe('a') - expect(result.current.canUndo).toBe(false) - expect(result.current.canRedo).toBe(true) - }) - - it('redo restores state', () => { - const initialNodes = [makeNode('a', 'config', 0, 0)] - const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges)) - act(() => { - result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)]) - }) - act(() => { - result.current.undo() - }) - act(() => { - result.current.redo() - }) - expect(result.current.nodes).toHaveLength(2) - expect(result.current.canRedo).toBe(false) - }) - - it('saveForDragEnd and commitDragEnd push drag snapshot to history', () => { - const initialNodes = [makeNode('a', 'config', 0, 0)] - const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges)) - act(() => { - result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)]) - }) - act(() => { - result.current.saveForDragEnd() - }) - act(() => { - result.current.setNodes((prev) => - prev.map((n) => (n.id === 'b' ? { ...n, position: { x: 200, y: 50 } } : n)) - ) - }) - act(() => { - result.current.commitDragEnd() - }) - expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 200, y: 50 }) - act(() => { - result.current.undo() - }) - expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 100, y: 0 }) - }) - - it('setStateImmediate clears history', () => { - const initialNodes = [makeNode('a', 'config', 0, 0)] - const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges)) - act(() => { - result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)]) - }) - expect(result.current.canUndo).toBe(true) - act(() => { - result.current.setStateImmediate({ - nodes: [makeNode('c', 'variable', 0, 0)], - edges: [], - }) - }) - expect(result.current.nodes).toHaveLength(1) - expect(result.current.nodes[0].id).toBe('c') - expect(result.current.canUndo).toBe(false) - expect(result.current.canRedo).toBe(false) - }) -}) diff --git a/src/lib/flowUtils.test.ts b/src/lib/flowUtils.test.ts deleted file mode 100644 index 97edfcc..0000000 --- a/src/lib/flowUtils.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { getNextNodeId, replaceNodeIdInGraph, DEFAULT_NODE_STYLE } from './flowUtils' -import { registerBuiltinNodes } from './registerBuiltinNodes' - -beforeAll(() => { - registerBuiltinNodes() -}) - -describe('getNextNodeId', () => { - it('returns prefix + 001 when no existing ids', () => { - expect(getNextNodeId('config', [])).toBe('cfg_001') - expect(getNextNodeId('render', [])).toBe('rnd_001') - expect(getNextNodeId('variable', [])).toBe('var_001') - expect(getNextNodeId('function', [])).toBe('fn_001') - }) - - it('increments after existing ids', () => { - expect(getNextNodeId('config', ['cfg_001'])).toBe('cfg_002') - expect(getNextNodeId('config', ['cfg_001', 'cfg_002', 'cfg_003'])).toBe('cfg_004') - }) - - it('ignores ids of other types', () => { - expect(getNextNodeId('config', ['rnd_001', 'var_001'])).toBe('cfg_001') - expect(getNextNodeId('config', ['cfg_002'])).toBe('cfg_003') - }) -}) - -describe('replaceNodeIdInGraph', () => { - it('renames node and updates edges and references in data', () => { - const nodes = [ - { id: 'cfg_001', data: { title: 'cfg_001', plantuml: 'x' }, type: 'config', position: { x: 0, y: 0 } }, - { id: 'rnd_001', data: {}, type: 'render', position: { x: 100, y: 0 } }, - ] - const edges = [ - { id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001' }, - ] - const result = replaceNodeIdInGraph(nodes, edges, 'cfg_001', 'cfg_002') - expect(result.nodes[0].id).toBe('cfg_002') - expect(result.nodes[0].data).toEqual(expect.objectContaining({ title: 'cfg_002' })) - expect(result.edges[0].source).toBe('cfg_002') - expect(result.edges[0].id).toBe('e-cfg_002-rnd_001') - }) -}) - -describe('DEFAULT_NODE_STYLE', () => { - it('has styles for config, render, variable, function', () => { - expect(DEFAULT_NODE_STYLE.config).toEqual({ width: 320, height: 320 }) - expect(DEFAULT_NODE_STYLE.render).toEqual({ width: 384, height: 320 }) - expect(DEFAULT_NODE_STYLE.variable).toEqual({ width: 224, height: 180 }) - expect(DEFAULT_NODE_STYLE.function).toEqual({ width: 288, height: 260 }) - }) -}) diff --git a/src/lib/flowUtils.ts b/src/lib/flowUtils.ts index 2cfc4c3..60bc48d 100644 --- a/src/lib/flowUtils.ts +++ b/src/lib/flowUtils.ts @@ -11,11 +11,7 @@ import { getDefaultStyle, } from './nodeRegistry' -/** Minimal node/edge shape for replaceNodeIdInGraph (avoids circular dependency on nodeTypes). */ -type GraphNode = { id: string; data?: unknown; [k: string]: unknown } -type GraphEdge = { id: string; source: string; target: string; [k: string]: unknown } - -export function nodePropsAreEqual

( +export function nodePropsAreEqual

( prev: P, next: P ): boolean { @@ -60,15 +56,13 @@ function replaceInData(value: unknown, oldId: string, newId: string): unknown { /** Update graph after renaming a node: change node id and all references in data and edges */ export function replaceNodeIdInGraph( - nodes: GraphNode[], - edges: GraphEdge[], + nodes: Array<{ id: string; data?: any; [k: string]: any }>, + edges: Array<{ id: string; source: string; target: string; [k: string]: any }>, oldId: string, newId: string -): { nodes: GraphNode[]; edges: GraphEdge[] } { +): { nodes: typeof nodes; edges: typeof edges } { const newNodes = nodes.map((n) => - n.id === oldId - ? { ...n, id: newId, data: replaceInData(n.data, oldId, newId) } - : { ...n, data: replaceInData(n.data, oldId, newId) } + n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any } ) const newEdges = edges.map((e) => ({ ...e, @@ -88,12 +82,12 @@ export const DEFAULT_NODE_STYLE: Record { +export function getDefaultDataForType(type: string, newId?: string): any { return getDefaultDataFromRegistry(type, newId) } /** Data for Reset action. Uses nodeRegistry when type is registered. */ -export function getResetDataForType(type: string, nodeId?: string): Record { +export function getResetDataForType(type: string, nodeId?: string): any { return getResetDataFromRegistry(type, nodeId) } diff --git a/src/lib/nodeRegistry.ts b/src/lib/nodeRegistry.ts index 367fad1..cee556c 100644 --- a/src/lib/nodeRegistry.ts +++ b/src/lib/nodeRegistry.ts @@ -12,18 +12,9 @@ export type NodeHelpEntry = { content: React.ReactNode } -/** Props passed to registered node components: id, data, and optional dimensions/selection. */ -export type NodeComponentProps = { - id: string - data?: Record - width?: number - height?: number - selected?: boolean -} - export type NodeTypeDescriptor = { id: string - component: React.ComponentType + component: React.ComponentType defaultStyle: { width: number; height: number } defaultData: Record idPrefix: string @@ -70,8 +61,8 @@ export function getDefaultDataForType(type: string, newId?: string): Record = { ...desc.defaultData } - if (type === 'config' && newId) base.title = `${newId}` + const base = { ...desc.defaultData } + if (type === 'config' && newId) (base as any).title = `${newId}` return base } diff --git a/src/projectLoad.test.ts b/src/projectLoad.test.ts deleted file mode 100644 index a280d77..0000000 --- a/src/projectLoad.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { getRegisteredNodeTypeIds } from './lib/nodeRegistry' -import { registerBuiltinNodes } from './lib/registerBuiltinNodes' -import sample from './fixtures/sample.zui.json' - -beforeAll(() => { - registerBuiltinNodes() -}) - -/** Minimal type for parsed .zui.json project file */ -type ProjectFile = { - version?: number - nodes: Array<{ id: string; type: string; data?: unknown; position?: { x: number; y: number }; style?: unknown }> - edges: Array<{ id: string; source: string; target: string; type?: string }> -} - -describe('project load (.zui.json)', () => { - it('parses sample project and asserts node/edge count and types', () => { - const project = sample as ProjectFile - expect(project).toBeDefined() - expect(Array.isArray(project.nodes)).toBe(true) - expect(Array.isArray(project.edges)).toBe(true) - expect(project.nodes).toHaveLength(3) - expect(project.edges).toHaveLength(2) - const nodeTypes = project.nodes.map((n) => n.type) - expect(nodeTypes).toContain('variable') - expect(nodeTypes).toContain('config') - expect(nodeTypes).toContain('render') - const validTypeIds = getRegisteredNodeTypeIds() - for (const n of project.nodes) { - expect(validTypeIds).toContain(n.type) - } - expect(project.nodes[0].id).toBe('var_001') - expect(project.nodes[1].id).toBe('cfg_001') - expect(project.nodes[2].id).toBe('rnd_001') - expect(project.edges[0].source).toBe('var_001') - expect(project.edges[0].target).toBe('cfg_001') - expect(project.edges[1].source).toBe('cfg_001') - expect(project.edges[1].target).toBe('rnd_001') - if (project.version != null) { - expect(typeof project.version).toBe('number') - expect(project.version).toBe(1) - } - }) -}) diff --git a/src/styles.css b/src/styles.css index 1277ec7..23b03b0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -25,6 +25,18 @@ body { overflow: visible; } +/* Focused node: clear focus ring so keyboard/screen reader users see which node is active */ +.react-flow__node:focus { + outline: none; +} +.react-flow__node:focus-visible > div:first-child { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.4); +} +.dark .react-flow__node:focus-visible > div:first-child { + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.5); +} + .react-flow__node .react-flow__handle { box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06); } diff --git a/vite.config.ts b/vite.config.ts index caa694c..75ab0ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' + export default defineConfig({ plugins: [react()], server: { diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 0d704f0..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from 'vitest/config' -import path from 'path' - -export default defineConfig({ - resolve: { - alias: { '@': path.resolve(__dirname, './src') }, - }, - test: { - environment: 'jsdom', - include: ['src/**/*.{test,spec}.{ts,tsx}'], - }, -})