@acta-dev/web 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/astro.config.mjs +31 -0
  3. package/package.json +62 -0
  4. package/public/favicon.png +0 -0
  5. package/src/components/DocumentSearchList.astro +150 -0
  6. package/src/components/DocumentView.astro +198 -0
  7. package/src/components/LanguageSwitcher.tsx +115 -0
  8. package/src/components/SidebarToggle.tsx +42 -0
  9. package/src/components/ThemeToggle.tsx +129 -0
  10. package/src/components/graph/DocumentGraphIsland.tsx +226 -0
  11. package/src/components/graph/GraphContext.ts +15 -0
  12. package/src/components/graph/graph.css +330 -0
  13. package/src/components/graph/layout.ts +48 -0
  14. package/src/components/graph/nodes.tsx +80 -0
  15. package/src/components/ui/Button.astro +105 -0
  16. package/src/components/ui/Chip.astro +147 -0
  17. package/src/components/ui/Field.astro +29 -0
  18. package/src/components/ui/Input.astro +34 -0
  19. package/src/components/ui/Pill.astro +71 -0
  20. package/src/components/ui/SegmentedControl.astro +105 -0
  21. package/src/components/ui/Select.astro +41 -0
  22. package/src/components/ui/Tooltip.astro +94 -0
  23. package/src/layouts/BaseLayout.astro +147 -0
  24. package/src/lib/documents.test.ts +175 -0
  25. package/src/lib/documents.ts +156 -0
  26. package/src/lib/i18n-client.ts +32 -0
  27. package/src/lib/i18n.ts +92 -0
  28. package/src/lib/project.test.ts +24 -0
  29. package/src/lib/project.ts +120 -0
  30. package/src/lib/search-client.ts +192 -0
  31. package/src/lib/search.test.ts +94 -0
  32. package/src/lib/search.ts +153 -0
  33. package/src/locales/en/common.json +6 -0
  34. package/src/locales/en/dashboard.json +8 -0
  35. package/src/locales/en/documents.json +63 -0
  36. package/src/locales/en/graph.json +19 -0
  37. package/src/locales/en/search.json +7 -0
  38. package/src/locales/en/sidebar.json +25 -0
  39. package/src/locales/en/validation.json +13 -0
  40. package/src/locales/ru/common.json +6 -0
  41. package/src/locales/ru/dashboard.json +8 -0
  42. package/src/locales/ru/documents.json +63 -0
  43. package/src/locales/ru/graph.json +19 -0
  44. package/src/locales/ru/search.json +7 -0
  45. package/src/locales/ru/sidebar.json +25 -0
  46. package/src/locales/ru/validation.json +13 -0
  47. package/src/pages/documents/[id]/index.astro +39 -0
  48. package/src/pages/graph.astro +54 -0
  49. package/src/pages/index.astro +41 -0
  50. package/src/pages/ru/documents/[id]/index.astro +39 -0
  51. package/src/pages/ru/graph.astro +54 -0
  52. package/src/pages/ru/index.astro +41 -0
  53. package/src/pages/ru/search-index-full.json.ts +1 -0
  54. package/src/pages/ru/search.astro +27 -0
  55. package/src/pages/ru/validation.astro +63 -0
  56. package/src/pages/search-index-full.json.ts +12 -0
  57. package/src/pages/search-index.json.ts +12 -0
  58. package/src/pages/search.astro +27 -0
  59. package/src/pages/validation.astro +63 -0
  60. package/src/styles/global.css +1391 -0
  61. package/src/styles/themes/dark.css +61 -0
  62. package/src/styles/themes/light.css +63 -0
  63. package/src/styles/tokens/primitives.css +32 -0
  64. package/src/styles/tokens/semantic.css +34 -0
  65. package/src/styles/tokens/typography.css +28 -0
  66. package/tsconfig.json +11 -0
@@ -0,0 +1,129 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ type Pref = "system" | "light" | "dark";
4
+
5
+ const STORAGE_KEY = "acta-theme";
6
+
7
+ function resolveTheme(pref: Pref): "light" | "dark" {
8
+ if (pref !== "system") return pref;
9
+ return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
10
+ ? "dark"
11
+ : "light";
12
+ }
13
+
14
+ function readPref(): Pref {
15
+ if (typeof window === "undefined") return "system";
16
+ const raw = window.localStorage.getItem(STORAGE_KEY);
17
+ return raw === "light" || raw === "dark" || raw === "system" ? raw : "system";
18
+ }
19
+
20
+ function applyPref(pref: Pref): void {
21
+ const resolved = resolveTheme(pref);
22
+ document.documentElement.dataset.theme = resolved;
23
+ document.documentElement.dataset.themePref = pref;
24
+ }
25
+
26
+ const SYMBOLS: Record<Pref, string> = { system: "◐", light: "☀", dark: "☾" };
27
+ const OPTIONS: Pref[] = ["system", "light", "dark"];
28
+
29
+ type Props = {
30
+ labels: { legend: string; system: string; light: string; dark: string };
31
+ };
32
+
33
+ export default function ThemeToggle({ labels }: Props) {
34
+ const [pref, setPref] = useState<Pref>("system");
35
+ const [open, setOpen] = useState(false);
36
+ const dropdownRef = useRef<HTMLDivElement>(null);
37
+
38
+ useEffect(() => {
39
+ setPref(readPref());
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ if (pref !== "system") return;
44
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
45
+ const handler = () => applyPref("system");
46
+ mq.addEventListener("change", handler);
47
+ return () => mq.removeEventListener("change", handler);
48
+ }, [pref]);
49
+
50
+ useEffect(() => {
51
+ if (!open) return;
52
+ const onClick = (event: MouseEvent) => {
53
+ if (!dropdownRef.current?.contains(event.target as Node)) setOpen(false);
54
+ };
55
+ const onKey = (event: KeyboardEvent) => {
56
+ if (event.key === "Escape") setOpen(false);
57
+ };
58
+ document.addEventListener("mousedown", onClick);
59
+ document.addEventListener("keydown", onKey);
60
+ return () => {
61
+ document.removeEventListener("mousedown", onClick);
62
+ document.removeEventListener("keydown", onKey);
63
+ };
64
+ }, [open]);
65
+
66
+ const update = (next: Pref) => {
67
+ setPref(next);
68
+ setOpen(false);
69
+ window.localStorage.setItem(STORAGE_KEY, next);
70
+ applyPref(next);
71
+ };
72
+
73
+ return (
74
+ <div className="theme-control">
75
+ {/* Expanded sidebar: inline circular toggle */}
76
+ <fieldset className="ui-theme-toggle" aria-label={labels.legend}>
77
+ <legend>{labels.legend}</legend>
78
+ {OPTIONS.map((value) => (
79
+ <label key={value}>
80
+ <input
81
+ type="radio"
82
+ name="acta-theme"
83
+ value={value}
84
+ checked={pref === value}
85
+ onChange={() => update(value)}
86
+ />
87
+ <span title={labels[value]}>
88
+ <span className="sr-only">{labels[value]}</span>
89
+ <span aria-hidden="true">{SYMBOLS[value]}</span>
90
+ </span>
91
+ </label>
92
+ ))}
93
+ </fieldset>
94
+
95
+ {/* Collapsed sidebar: dropdown (mirrors the language switcher) */}
96
+ <div className="theme-dropdown" ref={dropdownRef}>
97
+ <button
98
+ type="button"
99
+ className="theme-trigger"
100
+ aria-label={labels.legend}
101
+ aria-haspopup="listbox"
102
+ aria-expanded={open}
103
+ onClick={() => setOpen((value) => !value)}
104
+ >
105
+ <span aria-hidden="true">{SYMBOLS[pref]}</span>
106
+ </button>
107
+ {open && (
108
+ <div className="theme-menu" role="listbox" aria-label={labels.legend}>
109
+ {OPTIONS.map((value) => (
110
+ <button
111
+ key={value}
112
+ type="button"
113
+ role="option"
114
+ aria-selected={pref === value}
115
+ className={pref === value ? "is-active" : ""}
116
+ onClick={() => update(value)}
117
+ >
118
+ <span className="sym" aria-hidden="true">
119
+ {SYMBOLS[value]}
120
+ </span>
121
+ <span className="name">{labels[value]}</span>
122
+ </button>
123
+ ))}
124
+ </div>
125
+ )}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,226 @@
1
+ import {
2
+ Background,
3
+ BackgroundVariant,
4
+ Controls,
5
+ type Edge,
6
+ type EdgeChange,
7
+ MiniMap,
8
+ type Node,
9
+ type NodeChange,
10
+ ReactFlow,
11
+ ReactFlowProvider,
12
+ } from "@xyflow/react";
13
+ import type React from "react";
14
+ import { useCallback, useMemo, useState } from "react";
15
+ import "@xyflow/react/dist/style.css";
16
+ import "./graph.css";
17
+
18
+ import type { DocumentGraph } from "@acta-dev/core";
19
+ import type { FilterOptions } from "@lib/documents.js";
20
+ import { GraphContext } from "./GraphContext.js";
21
+ import { computeLayout } from "./layout.js";
22
+ import { nodeTypes } from "./nodes.js";
23
+
24
+ type GraphLabels = {
25
+ filtersLegend: string;
26
+ filterKind: string;
27
+ filterStatus: string;
28
+ all: string;
29
+ areaLabel: string;
30
+ legendLabel: string;
31
+ legendDependency: string;
32
+ legendRelated: string;
33
+ legendSupersession: string;
34
+ };
35
+
36
+ type Props = {
37
+ graph: DocumentGraph;
38
+ filterOptions: FilterOptions;
39
+ hrefForId: Record<string, string>;
40
+ labels: GraphLabels;
41
+ };
42
+
43
+ export default function DocumentGraphIsland({ graph, filterOptions, hrefForId, labels }: Props) {
44
+ const [kindFilter, setKindFilter] = useState("");
45
+ const [statusFilter, setStatusFilter] = useState("");
46
+ const [selectedId, setSelectedId] = useState<string | null>(null);
47
+
48
+ const { nodes: layoutNodes, edges: layoutEdges } = useMemo(() => computeLayout(graph), [graph]);
49
+
50
+ const visibleNodeIds = useMemo(() => {
51
+ const ids = new Set<string>();
52
+ for (const node of graph.nodes) {
53
+ const kindMatch = !kindFilter || node.kind === kindFilter;
54
+ const statusMatch = !statusFilter || node.status === statusFilter;
55
+ if (kindMatch && statusMatch) ids.add(node.id);
56
+ }
57
+ return ids;
58
+ }, [graph.nodes, kindFilter, statusFilter]);
59
+
60
+ // Stable nodes — does NOT depend on selectedId, so hover never rebuilds this
61
+ const nodes: Node[] = useMemo(() => {
62
+ return layoutNodes.map((node) => ({
63
+ ...node,
64
+ hidden: !visibleNodeIds.has(node.id),
65
+ data: {
66
+ ...node.data,
67
+ href: hrefForId[node.id],
68
+ },
69
+ }));
70
+ }, [layoutNodes, visibleNodeIds, hrefForId]);
71
+
72
+ const connectedIds = useMemo((): Set<string> => {
73
+ if (!selectedId) return new Set();
74
+ const ids = new Set<string>([selectedId]);
75
+ for (const edge of graph.edges) {
76
+ if (edge.source === selectedId) ids.add(edge.target);
77
+ if (edge.target === selectedId) ids.add(edge.source);
78
+ }
79
+ return ids;
80
+ }, [selectedId, graph.edges]);
81
+
82
+ // Edges update on selectedId change — acceptable since edges are cheap SVG paths
83
+ const edges: Edge[] = useMemo(() => {
84
+ return layoutEdges.map((edge) => {
85
+ const sourceHidden = !visibleNodeIds.has(edge.source);
86
+ const targetHidden = !visibleNodeIds.has(edge.target);
87
+ const isActive =
88
+ selectedId !== null && (edge.source === selectedId || edge.target === selectedId);
89
+ const isDimmed = selectedId !== null && !isActive;
90
+ const linkType = (edge.data as { linkType?: string })?.linkType ?? "";
91
+ return {
92
+ ...edge,
93
+ hidden: sourceHidden || targetHidden,
94
+ className: [
95
+ `edge-${linkType}`,
96
+ isActive ? "edge-highlighted" : "",
97
+ isDimmed ? "edge-dimmed" : "",
98
+ ]
99
+ .filter(Boolean)
100
+ .join(" "),
101
+ };
102
+ });
103
+ }, [layoutEdges, visibleNodeIds, selectedId]);
104
+
105
+ const onNodeMouseEnter = useCallback((_: React.MouseEvent, node: Node) => {
106
+ setSelectedId(node.id);
107
+ }, []);
108
+
109
+ const onNodeMouseLeave = useCallback(() => {
110
+ setSelectedId(null);
111
+ }, []);
112
+
113
+ const onPaneClick = useCallback(() => {
114
+ setSelectedId(null);
115
+ }, []);
116
+
117
+ const onNodesChange = useCallback((_: NodeChange[]) => {}, []);
118
+ const onEdgesChange = useCallback((_: EdgeChange[]) => {}, []);
119
+
120
+ const contextValue = useMemo(() => ({ selectedId, connectedIds }), [selectedId, connectedIds]);
121
+
122
+ return (
123
+ <GraphContext.Provider value={contextValue}>
124
+ <div>
125
+ <section className="graph-toolbar section-grid" aria-label={labels.filtersLegend}>
126
+ <label className="ui-field">
127
+ <span>{labels.filterKind}</span>
128
+ <select
129
+ className="ui-select"
130
+ value={kindFilter}
131
+ onChange={(e) => setKindFilter(e.target.value)}
132
+ >
133
+ <option value="">{labels.all}</option>
134
+ {filterOptions.kinds.map((kind) => (
135
+ <option key={kind} value={kind}>
136
+ {kind.toUpperCase()}
137
+ </option>
138
+ ))}
139
+ </select>
140
+ </label>
141
+ <label className="ui-field">
142
+ <span>{labels.filterStatus}</span>
143
+ <select
144
+ className="ui-select"
145
+ value={statusFilter}
146
+ onChange={(e) => setStatusFilter(e.target.value)}
147
+ >
148
+ <option value="">{labels.all}</option>
149
+ {filterOptions.statuses.map((status) => (
150
+ <option key={status} value={status}>
151
+ {status}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ </label>
156
+ </section>
157
+
158
+ <section className="graph-shell" aria-label={labels.areaLabel}>
159
+ <div className="graph-rf-wrapper">
160
+ <ReactFlowProvider>
161
+ <ReactFlow
162
+ nodes={nodes}
163
+ edges={edges}
164
+ onNodesChange={onNodesChange}
165
+ onEdgesChange={onEdgesChange}
166
+ nodeTypes={nodeTypes}
167
+ onNodeMouseEnter={onNodeMouseEnter}
168
+ onNodeMouseLeave={onNodeMouseLeave}
169
+ onPaneClick={onPaneClick}
170
+ fitView
171
+ fitViewOptions={{ padding: 0.15 }}
172
+ minZoom={0.2}
173
+ maxZoom={2.5}
174
+ nodesDraggable={false}
175
+ proOptions={{ hideAttribution: false }}
176
+ >
177
+ <Background
178
+ variant={BackgroundVariant.Dots}
179
+ gap={20}
180
+ size={1}
181
+ color="var(--border)"
182
+ />
183
+ <Controls />
184
+ <MiniMap
185
+ nodeColor={(node) =>
186
+ (node.data as { kind?: string }).kind === "adr"
187
+ ? "var(--accent)"
188
+ : "var(--muted)"
189
+ }
190
+ maskColor="color-mix(in srgb, var(--panel) 80%, transparent)"
191
+ />
192
+ </ReactFlow>
193
+ </ReactFlowProvider>
194
+ </div>
195
+ </section>
196
+
197
+ <section className="graph-legend" aria-label={labels.legendLabel}>
198
+ <span>
199
+ <i className="legend-line solid"></i>
200
+ {labels.legendDependency}
201
+ </span>
202
+ <span>
203
+ <i className="legend-line related"></i>
204
+ {labels.legendRelated}
205
+ </span>
206
+ <span>
207
+ <i className="legend-line supersession"></i>
208
+ {labels.legendSupersession}
209
+ </span>
210
+ {filterOptions.kinds.map((kind) => (
211
+ <span key={`kind-${kind}`}>
212
+ <i className="legend-kind-bar" data-kind={kind}></i>
213
+ {kind.toUpperCase()}
214
+ </span>
215
+ ))}
216
+ {filterOptions.statuses.map((status) => (
217
+ <span key={status}>
218
+ <i className="legend-swatch" data-status={status}></i>
219
+ {status}
220
+ </span>
221
+ ))}
222
+ </section>
223
+ </div>
224
+ </GraphContext.Provider>
225
+ );
226
+ }
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ type GraphContextValue = {
4
+ selectedId: string | null;
5
+ connectedIds: Set<string>;
6
+ };
7
+
8
+ export const GraphContext = createContext<GraphContextValue>({
9
+ selectedId: null,
10
+ connectedIds: new Set(),
11
+ });
12
+
13
+ export function useGraphContext() {
14
+ return useContext(GraphContext);
15
+ }
@@ -0,0 +1,330 @@
1
+ .graph-rf-wrapper {
2
+ width: 100%;
3
+ min-height: 620px;
4
+ height: 620px;
5
+ }
6
+
7
+ .react-flow__background {
8
+ background: var(--color-panel);
9
+ }
10
+
11
+ .react-flow__panel {
12
+ color: var(--color-text);
13
+ }
14
+
15
+ .react-flow__controls button {
16
+ background: var(--color-panel);
17
+ border-color: var(--color-border);
18
+ color: var(--color-text);
19
+ fill: var(--color-text);
20
+ }
21
+
22
+ .react-flow__controls button:hover {
23
+ background: var(--color-panel-muted);
24
+ }
25
+
26
+ .react-flow__minimap {
27
+ background: var(--color-panel-muted) !important;
28
+ border: 1px solid var(--color-border);
29
+ border-radius: var(--radius-lg);
30
+ }
31
+
32
+ /* Node card */
33
+ .graph-rf-node-link {
34
+ text-decoration: none;
35
+ color: inherit;
36
+ display: block;
37
+ }
38
+
39
+ .graph-rf-node {
40
+ width: 220px;
41
+ height: 80px;
42
+ padding: var(--space-3) var(--space-3-5) var(--space-3) var(--space-3);
43
+ border: 1.5px solid var(--color-border);
44
+ border-left-width: 4px;
45
+ border-radius: var(--radius-lg);
46
+ background: var(--color-panel-muted);
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: var(--space-1);
50
+ cursor: pointer;
51
+ transition:
52
+ border-color 0.15s,
53
+ box-shadow 0.15s,
54
+ opacity 0.15s;
55
+ box-sizing: border-box;
56
+ }
57
+
58
+ .graph-rf-node--adr {
59
+ border-left-color: var(--kind-adr-color);
60
+ }
61
+
62
+ .graph-rf-node--spec {
63
+ border-left-color: var(--kind-spec-color);
64
+ }
65
+
66
+ .graph-rf-node__head {
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ gap: var(--space-2);
71
+ }
72
+
73
+ .graph-rf-node__kind {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ height: 14px;
77
+ padding: 0 5px;
78
+ border-radius: var(--radius-sm);
79
+ font-family: var(--font-mono);
80
+ font-size: 9px;
81
+ font-weight: var(--weight-bold);
82
+ letter-spacing: 0.06em;
83
+ line-height: 1;
84
+ }
85
+
86
+ .graph-rf-node__kind--adr {
87
+ background: color-mix(in srgb, var(--kind-adr-color) 18%, transparent);
88
+ color: var(--kind-adr-color);
89
+ }
90
+
91
+ .graph-rf-node__kind--spec {
92
+ background: color-mix(in srgb, var(--kind-spec-color) 18%, transparent);
93
+ color: var(--kind-spec-color);
94
+ }
95
+
96
+ /* Status-driven node coloring (per-status semantic tokens) */
97
+ .graph-rf-node[data-status="proposed"] {
98
+ border-top-color: var(--status-proposed-border);
99
+ border-right-color: var(--status-proposed-border);
100
+ border-bottom-color: var(--status-proposed-border);
101
+ background: var(--status-proposed-bg);
102
+ }
103
+ .graph-rf-node[data-status="accepted"] {
104
+ border-top-color: var(--status-accepted-border);
105
+ border-right-color: var(--status-accepted-border);
106
+ border-bottom-color: var(--status-accepted-border);
107
+ background: var(--status-accepted-bg);
108
+ }
109
+ .graph-rf-node[data-status="rejected"] {
110
+ border-top-color: var(--status-rejected-border);
111
+ border-right-color: var(--status-rejected-border);
112
+ border-bottom-color: var(--status-rejected-border);
113
+ background: var(--status-rejected-bg);
114
+ }
115
+ .graph-rf-node[data-status="deprecated"] {
116
+ border-top-color: var(--status-deprecated-border);
117
+ border-right-color: var(--status-deprecated-border);
118
+ border-bottom-color: var(--status-deprecated-border);
119
+ background: var(--status-deprecated-bg);
120
+ }
121
+ .graph-rf-node[data-status="superseded"] {
122
+ border-top-color: var(--status-superseded-border);
123
+ border-right-color: var(--status-superseded-border);
124
+ border-bottom-color: var(--status-superseded-border);
125
+ background: var(--status-superseded-bg);
126
+ }
127
+ .graph-rf-node[data-status="draft"] {
128
+ border-top-color: var(--status-draft-border);
129
+ border-right-color: var(--status-draft-border);
130
+ border-bottom-color: var(--status-draft-border);
131
+ background: var(--status-draft-bg);
132
+ }
133
+ .graph-rf-node[data-status="active"] {
134
+ border-top-color: var(--status-active-border);
135
+ border-right-color: var(--status-active-border);
136
+ border-bottom-color: var(--status-active-border);
137
+ background: var(--status-active-bg);
138
+ }
139
+ .graph-rf-node[data-status="paused"] {
140
+ border-top-color: var(--status-paused-border);
141
+ border-right-color: var(--status-paused-border);
142
+ border-bottom-color: var(--status-paused-border);
143
+ background: var(--status-paused-bg);
144
+ }
145
+ .graph-rf-node[data-status="implemented"] {
146
+ border-top-color: var(--status-implemented-border);
147
+ border-right-color: var(--status-implemented-border);
148
+ border-bottom-color: var(--status-implemented-border);
149
+ background: var(--status-implemented-bg);
150
+ }
151
+ .graph-rf-node[data-status="obsolete"] {
152
+ border-top-color: var(--status-obsolete-border);
153
+ border-right-color: var(--status-obsolete-border);
154
+ border-bottom-color: var(--status-obsolete-border);
155
+ background: var(--status-obsolete-bg);
156
+ }
157
+
158
+ .graph-rf-node:hover {
159
+ border-color: var(--color-accent);
160
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
161
+ }
162
+
163
+ .graph-rf-node--highlighted {
164
+ border-color: var(--color-accent);
165
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 22%, transparent);
166
+ }
167
+
168
+ .graph-rf-node--dimmed {
169
+ opacity: 0.2;
170
+ }
171
+
172
+ .graph-rf-node__id {
173
+ font-family: var(--font-mono);
174
+ font-size: var(--text-xs);
175
+ font-weight: 650;
176
+ color: var(--color-text-muted);
177
+ line-height: 1;
178
+ }
179
+
180
+ .graph-rf-node__title {
181
+ font-size: var(--text-base);
182
+ font-weight: 650;
183
+ color: var(--color-text);
184
+ line-height: var(--leading-tight);
185
+ overflow: hidden;
186
+ white-space: nowrap;
187
+ text-overflow: ellipsis;
188
+ }
189
+
190
+ .graph-rf-node__status {
191
+ font-size: 10px;
192
+ font-weight: var(--weight-semibold);
193
+ text-transform: uppercase;
194
+ letter-spacing: 0.04em;
195
+ color: var(--color-text-muted);
196
+ margin-top: auto;
197
+ }
198
+
199
+ /* Status text color follows per-status fg */
200
+ .graph-rf-node[data-status="proposed"] .graph-rf-node__status {
201
+ color: var(--status-proposed-fg);
202
+ }
203
+ .graph-rf-node[data-status="accepted"] .graph-rf-node__status {
204
+ color: var(--status-accepted-fg);
205
+ }
206
+ .graph-rf-node[data-status="rejected"] .graph-rf-node__status {
207
+ color: var(--status-rejected-fg);
208
+ }
209
+ .graph-rf-node[data-status="deprecated"] .graph-rf-node__status {
210
+ color: var(--status-deprecated-fg);
211
+ }
212
+ .graph-rf-node[data-status="superseded"] .graph-rf-node__status {
213
+ color: var(--status-superseded-fg);
214
+ }
215
+ .graph-rf-node[data-status="draft"] .graph-rf-node__status {
216
+ color: var(--status-draft-fg);
217
+ }
218
+ .graph-rf-node[data-status="active"] .graph-rf-node__status {
219
+ color: var(--status-active-fg);
220
+ }
221
+ .graph-rf-node[data-status="paused"] .graph-rf-node__status {
222
+ color: var(--status-paused-fg);
223
+ }
224
+ .graph-rf-node[data-status="implemented"] .graph-rf-node__status {
225
+ color: var(--status-implemented-fg);
226
+ }
227
+ .graph-rf-node[data-status="obsolete"] .graph-rf-node__status {
228
+ color: var(--status-obsolete-fg);
229
+ }
230
+
231
+ /* Edges */
232
+ .react-flow__edge-path {
233
+ stroke: var(--color-text-muted);
234
+ stroke-width: 1.6;
235
+ opacity: 0.72;
236
+ }
237
+
238
+ .edge-related .react-flow__edge-path {
239
+ stroke-dasharray: 5 5;
240
+ opacity: 0.45;
241
+ }
242
+
243
+ .edge-supersedes .react-flow__edge-path,
244
+ .edge-replacedBy .react-flow__edge-path {
245
+ stroke: var(--color-warning);
246
+ stroke-width: 2;
247
+ opacity: 0.85;
248
+ }
249
+
250
+ .edge-highlighted .react-flow__edge-path {
251
+ stroke: var(--color-accent);
252
+ stroke-width: 2.4;
253
+ opacity: 1;
254
+ }
255
+
256
+ .edge-dimmed .react-flow__edge-path {
257
+ opacity: 0.12;
258
+ }
259
+
260
+ .react-flow__handle {
261
+ width: 8px;
262
+ height: 8px;
263
+ background: var(--color-border);
264
+ border-color: var(--color-border);
265
+ }
266
+
267
+ .react-flow__handle:hover {
268
+ background: var(--color-accent);
269
+ }
270
+
271
+ .graph-toolbar {
272
+ grid-template-columns: repeat(2, minmax(140px, 180px));
273
+ }
274
+
275
+ .legend-kind-bar {
276
+ display: inline-block;
277
+ width: 14px;
278
+ height: 4px;
279
+ border-radius: 2px;
280
+ }
281
+
282
+ .legend-kind-bar[data-kind="adr"] {
283
+ background: var(--kind-adr-color);
284
+ }
285
+
286
+ .legend-kind-bar[data-kind="spec"] {
287
+ background: var(--kind-spec-color);
288
+ }
289
+
290
+ /* Graph status swatches in legend */
291
+ .legend-swatch[data-status="proposed"] {
292
+ background: var(--status-proposed-bg);
293
+ border-color: var(--status-proposed-border);
294
+ }
295
+ .legend-swatch[data-status="accepted"] {
296
+ background: var(--status-accepted-bg);
297
+ border-color: var(--status-accepted-border);
298
+ }
299
+ .legend-swatch[data-status="rejected"] {
300
+ background: var(--status-rejected-bg);
301
+ border-color: var(--status-rejected-border);
302
+ }
303
+ .legend-swatch[data-status="deprecated"] {
304
+ background: var(--status-deprecated-bg);
305
+ border-color: var(--status-deprecated-border);
306
+ }
307
+ .legend-swatch[data-status="superseded"] {
308
+ background: var(--status-superseded-bg);
309
+ border-color: var(--status-superseded-border);
310
+ }
311
+ .legend-swatch[data-status="draft"] {
312
+ background: var(--status-draft-bg);
313
+ border-color: var(--status-draft-border);
314
+ }
315
+ .legend-swatch[data-status="active"] {
316
+ background: var(--status-active-bg);
317
+ border-color: var(--status-active-border);
318
+ }
319
+ .legend-swatch[data-status="paused"] {
320
+ background: var(--status-paused-bg);
321
+ border-color: var(--status-paused-border);
322
+ }
323
+ .legend-swatch[data-status="implemented"] {
324
+ background: var(--status-implemented-bg);
325
+ border-color: var(--status-implemented-border);
326
+ }
327
+ .legend-swatch[data-status="obsolete"] {
328
+ background: var(--status-obsolete-bg);
329
+ border-color: var(--status-obsolete-border);
330
+ }