Skip to content

Commit 5a51999

Browse files
rdmuellerclaude
andcommitted
feat: add light/dark theme, browser language detection, localStorage persistence
- Create src/theme.js with CSS custom properties for dark/light palettes and a useTheme() hook that respects prefers-color-scheme and localStorage - Replace all ~50+ inline hex colors in RiskRadar.jsx with semantic CSS vars - Auto-detect browser language (de/en) via navigator.language - Persist language and theme choices in localStorage - Add theme toggle button (sun/moon) in top bar - Listen to OS theme changes when no explicit user preference is saved - Bump version to 1.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b21612d commit 5a51999

3 files changed

Lines changed: 145 additions & 47 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vibe-coding-risk-radar",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "MECE risk framework for AI-generated code — interactive React component with AsciiDoc documentation",
55
"type": "module",
66
"scripts": {

src/RiskRadar.jsx

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useState, useRef, useEffect, useMemo } from "react";
22
import Asciidoctor from "@asciidoctor/core";
33
import T from "./i18n.js";
4+
import { useTheme } from "./theme.js";
45

5-
const VERSION = "1.2.0";
6+
const VERSION = "1.3.0";
67

78
const TIER_BG = ["#10b981", "#f59e0b", "#f97316", "#ef4444"];
89
const TYPE_COLORS = {
@@ -31,24 +32,24 @@ function RadarChart({ values, dimensions, size = 320 }) {
3132
{[1, 2, 3, 4, 5].map((l) => {
3233
const r = (maxR / levels) * l;
3334
const pts = Array.from({ length: n }, (_, i) => polarToCartesian(cx, cy, r, i * step));
34-
return <polygon key={l} points={pts.map((p) => `${p.x},${p.y}`).join(" ")} fill="none" stroke={l === 5 ? "#475569" : "#334155"} strokeWidth={l === 5 ? 1.5 : 0.7} />;
35+
return <polygon key={l} points={pts.map((p) => `${p.x},${p.y}`).join(" ")} fill="none" stroke={l === 5 ? "var(--grid-line-outer)" : "var(--grid-line)"} strokeWidth={l === 5 ? 1.5 : 0.7} />;
3536
})}
3637
{dimensions.map((_, i) => {
3738
const p = polarToCartesian(cx, cy, maxR, i * step);
38-
return <line key={`a${i}`} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#334155" strokeWidth={0.7} />;
39+
return <line key={`a${i}`} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="var(--grid-line)" strokeWidth={0.7} />;
3940
})}
4041
{(() => {
4142
const pts = dimensions.map((d, i) => polarToCartesian(cx, cy, (maxR / levels) * (values[d.key] + 1), i * step));
4243
return (
4344
<>
4445
<polygon points={pts.map((p) => `${p.x},${p.y}`).join(" ")} fill={tc} fillOpacity={0.25} stroke={tc} strokeWidth={2.5} />
45-
{pts.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={5} fill={tc} stroke="#0f172a" strokeWidth={1.5} />)}
46+
{pts.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={5} fill={tc} stroke="var(--dot-stroke)" strokeWidth={1.5} />)}
4647
</>
4748
);
4849
})()}
4950
{dimensions.map((d, i) => {
5051
const lp = polarToCartesian(cx, cy, maxR + 26, i * step);
51-
return <text key={`l${i}`} x={lp.x} y={lp.y} textAnchor="middle" dominantBaseline="middle" fill="#94a3b8" fontSize="11" fontWeight="600">{d.shortLabel}</text>;
52+
return <text key={`l${i}`} x={lp.x} y={lp.y} textAnchor="middle" dominantBaseline="middle" fill="var(--text-secondary)" fontSize="11" fontWeight="600">{d.shortLabel}</text>;
5253
})}
5354
</svg>
5455
);
@@ -57,30 +58,30 @@ function RadarChart({ values, dimensions, size = 320 }) {
5758
function MitigationCard({ group, active, accent, t }) {
5859
const [open, setOpen] = useState(false);
5960
return (
60-
<div style={{ border: `2px solid ${active ? accent : "#1e293b"}`, borderRadius: 12, background: active ? `${accent}10` : "#0f172a", padding: "12px 14px", opacity: active ? 1 : 0.5, transition: "all 0.3s" }}>
61+
<div style={{ border: `2px solid ${active ? accent : "var(--border-subtle)"}`, borderRadius: 12, background: active ? `${accent}10` : "var(--bg-main)", padding: "12px 14px", opacity: active ? 1 : 0.5, transition: "all 0.3s" }}>
6162
<div onClick={() => active && setOpen(!open)} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", cursor: active ? "pointer" : "default" }}>
6263
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
6364
<span style={{ fontSize: 18 }}>{group.icon}</span>
6465
<div>
65-
<div style={{ fontWeight: 700, fontSize: 13, color: active ? "#f8fafc" : "#94a3b8" }}>{group.title}</div>
66-
<div style={{ fontSize: 10, color: "#94a3b8" }}>{group.measures.length} {group.measures.length !== 1 ? t.measures : t.measure}</div>
66+
<div style={{ fontWeight: 700, fontSize: 13, color: active ? "var(--text-heading)" : "var(--text-secondary)" }}>{group.title}</div>
67+
<div style={{ fontSize: 10, color: "var(--text-secondary)" }}>{group.measures.length} {group.measures.length !== 1 ? t.measures : t.measure}</div>
6768
</div>
6869
</div>
69-
{active && <span style={{ fontSize: 16, color: "#94a3b8", transform: open ? "rotate(180deg)" : "rotate(0)", transition: "transform 0.2s" }}></span>}
70+
{active && <span style={{ fontSize: 16, color: "var(--text-secondary)", transform: open ? "rotate(180deg)" : "rotate(0)", transition: "transform 0.2s" }}></span>}
7071
</div>
7172
{active && open && (
7273
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
7374
{group.measures.map((m, i) => {
7475
const tc = TYPE_COLORS[m.type];
7576
return (
76-
<div key={i} style={{ background: "#1e293b", borderRadius: 8, padding: "8px 10px", borderLeft: `3px solid ${tc.color}` }}>
77+
<div key={i} style={{ background: "var(--bg-card)", borderRadius: 8, padding: "8px 10px", borderLeft: `3px solid ${tc.color}` }}>
7778
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
78-
<span style={{ fontWeight: 600, fontSize: 12, color: "#e2e8f0" }}>{m.name}</span>
79+
<span style={{ fontWeight: 600, fontSize: 12, color: "var(--text-primary)" }}>{m.name}</span>
7980
<span style={{ fontSize: 8, fontWeight: 700, color: tc.color, background: tc.bg, padding: "2px 5px", borderRadius: 3, textTransform: "uppercase", letterSpacing: "0.05em" }}>
8081
{t.typeBadges[m.type]}
8182
</span>
8283
</div>
83-
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 3, lineHeight: 1.4 }}>{m.desc}</div>
84+
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginTop: 3, lineHeight: 1.4 }}>{m.desc}</div>
8485
</div>
8586
);
8687
})}
@@ -94,10 +95,10 @@ const adoc = Asciidoctor();
9495

9596
const ADOC_SIDEBAR_STYLES = `
9697
.adoc-content p { margin: 0.5em 0; }
97-
.adoc-content a { color: #38bdf8; text-decoration: underline; text-decoration-color: #38bdf844; }
98-
.adoc-content a:hover { text-decoration-color: #38bdf8; }
99-
.adoc-content strong { color: #e2e8f0; }
100-
.adoc-content code { background: #1e293b; padding: 1px 4px; border-radius: 3px; font-size: 0.92em; }
98+
.adoc-content a { color: var(--link); text-decoration: underline; text-decoration-color: var(--link-underline); }
99+
.adoc-content a:hover { text-decoration-color: var(--link); }
100+
.adoc-content strong { color: var(--text-primary); }
101+
.adoc-content code { background: var(--bg-card); padding: 1px 4px; border-radius: 3px; font-size: 0.92em; }
101102
`;
102103

103104
function DocSidebar({ docs, open, onClose }) {
@@ -126,36 +127,36 @@ function DocSidebar({ docs, open, onClose }) {
126127
style={{
127128
position: "fixed", top: 0, right: 0, bottom: 0,
128129
width: open ? "min(480px, 85vw)" : "0",
129-
background: "#111827", borderLeft: open ? "1px solid #334155" : "none",
130+
background: "var(--bg-sidebar)", borderLeft: open ? "1px solid var(--border)" : "none",
130131
overflowY: "auto", overflowX: "hidden",
131132
transition: "width 0.3s ease",
132133
zIndex: 1000,
133-
boxShadow: open ? "-8px 0 30px rgba(0,0,0,0.5)" : "none",
134+
boxShadow: open ? "-8px 0 30px var(--shadow)" : "none",
134135
}}
135136
>
136137
{open && (
137138
<div style={{ padding: "24px 20px" }}>
138139
<style>{ADOC_SIDEBAR_STYLES}</style>
139140
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
140-
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: "#f8fafc" }}>📖 {docs.title}</h2>
141-
<button onClick={onClose} style={{ background: "#1e293b", border: "1px solid #334155", borderRadius: 6, color: "#94a3b8", padding: "4px 10px", cursor: "pointer", fontSize: 12 }}></button>
141+
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: "var(--text-heading)" }}>{docs.title}</h2>
142+
<button onClick={onClose} style={{ background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-secondary)", padding: "4px 10px", cursor: "pointer", fontSize: 12 }}></button>
142143
</div>
143144
{rendered.map((sec) => (
144145
<div key={sec.id} style={{
145146
marginBottom: 28,
146-
...(sec.id === "disclaimer" ? { background: "#1e293b", borderRadius: 10, padding: "14px 16px", border: "1px solid #334155" } : {}),
147+
...(sec.id === "disclaimer" ? { background: "var(--bg-card)", borderRadius: 10, padding: "14px 16px", border: "1px solid var(--border)" } : {}),
147148
}}>
148-
<h3 style={{ fontSize: 15, fontWeight: 700, color: sec.id === "disclaimer" ? "#f59e0b" : "#e2e8f0", margin: "0 0 10px", paddingBottom: 6, borderBottom: sec.id === "disclaimer" ? "none" : "1px solid #1e293b" }}>
149+
<h3 style={{ fontSize: 15, fontWeight: 700, color: sec.id === "disclaimer" ? "#f59e0b" : "var(--text-primary)", margin: "0 0 10px", paddingBottom: 6, borderBottom: sec.id === "disclaimer" ? "none" : "1px solid var(--border-subtle)" }}>
149150
{sec.id === "disclaimer" ? "\u26A0\uFE0F " : ""}{sec.title}
150151
</h3>
151152
<div
152153
className="adoc-content"
153-
style={{ fontSize: 13, color: "#94a3b8", lineHeight: 1.7 }}
154+
style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}
154155
dangerouslySetInnerHTML={{ __html: sec.html }}
155156
/>
156157
</div>
157158
))}
158-
<div style={{ borderTop: "1px solid #1e293b", paddingTop: 16, marginTop: 8, fontSize: 11, color: "#94a3b8", textAlign: "center" }}>
159+
<div style={{ borderTop: "1px solid var(--border-subtle)", paddingTop: 16, marginTop: 8, fontSize: 11, color: "var(--text-secondary)", textAlign: "center" }}>
159160
Generated with data from Veracode, CodeRabbit, BaxBench, Unit 42, Aikido Security, CSA, and others.
160161
</div>
161162
</div>
@@ -164,8 +165,18 @@ function DocSidebar({ docs, open, onClose }) {
164165
);
165166
}
166167

168+
function detectBrowserLanguage() {
169+
const nav = navigator.language || navigator.userLanguage || "";
170+
return nav.startsWith("de") ? "de" : "en";
171+
}
172+
167173
export default function RiskRadar() {
168-
const [lang, setLang] = useState("de");
174+
const [lang, setLang] = useState(() => {
175+
const saved = localStorage.getItem("lang");
176+
if (saved === "de" || saved === "en") return saved;
177+
return detectBrowserLanguage();
178+
});
179+
const { theme, setTheme, isDark } = useTheme();
169180
const [docsOpen, setDocsOpen] = useState(false);
170181
const [values, setValues] = useState({ codeType: 0, language: 1, deployment: 0, data: 0, blastRadius: 0 });
171182
const t = T[lang];
@@ -175,33 +186,41 @@ export default function RiskRadar() {
175186
const set = (k, v) => setValues((p) => ({ ...p, [k]: v }));
176187
const activeCount = t.mitigations.filter((g) => g.tier <= ti + 1).reduce((s, g) => s + g.measures.length, 0);
177188

189+
const toggleLang = () => {
190+
const next = lang === "de" ? "en" : "de";
191+
setLang(next);
192+
localStorage.setItem("lang", next);
193+
};
194+
195+
const btnStyle = { background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-secondary)", padding: "4px 10px", cursor: "pointer", fontSize: 12, fontWeight: 600 };
196+
178197
return (
179-
<div style={{ fontFamily: "'Inter', system-ui, sans-serif", background: "#0f172a", color: "#e2e8f0", minHeight: "100vh", padding: "20px 16px", transition: "margin-right 0.3s", marginRight: docsOpen ? "min(480px, 85vw)" : 0 }}>
198+
<div style={{ fontFamily: "'Inter', system-ui, sans-serif", background: "var(--bg-main)", color: "var(--text-primary)", minHeight: "100vh", padding: "20px 16px", transition: "margin-right 0.3s", marginRight: docsOpen ? "min(480px, 85vw)" : 0 }}>
180199
{/* Top bar */}
181200
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginBottom: 12, maxWidth: 800, margin: "0 auto 12px" }}>
182-
<button
183-
onClick={() => setLang(lang === "de" ? "en" : "de")}
184-
style={{ background: "#1e293b", border: "1px solid #334155", borderRadius: 6, color: "#94a3b8", padding: "4px 10px", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
185-
>
186-
🌐 {t.langSwitch}
201+
<button onClick={() => setTheme(isDark ? "light" : "dark")} style={btnStyle} aria-label="Toggle theme">
202+
{isDark ? "\u2600\uFE0F" : "\uD83C\uDF19"} {isDark ? "Light" : "Dark"}
203+
</button>
204+
<button onClick={toggleLang} style={btnStyle}>
205+
{t.langSwitch}
187206
</button>
188207
<button
189208
onClick={() => setDocsOpen(!docsOpen)}
190-
style={{ background: docsOpen ? `${tc}22` : "#1e293b", border: `1px solid ${docsOpen ? tc : "#334155"}`, borderRadius: 6, color: docsOpen ? "#f8fafc" : "#94a3b8", padding: "4px 10px", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
209+
style={{ ...btnStyle, background: docsOpen ? `${tc}22` : "var(--bg-card)", border: `1px solid ${docsOpen ? tc : "var(--border)"}`, color: docsOpen ? "var(--text-heading)" : "var(--text-secondary)" }}
191210
>
192-
📖 {docsOpen ? t.closeButton : t.docsButton}
211+
{docsOpen ? t.closeButton : t.docsButton}
193212
</button>
194213
</div>
195214

196-
<h1 style={{ fontSize: 22, fontWeight: 700, textAlign: "center", margin: "0 0 4px", color: "#f8fafc" }}>{t.title}</h1>
197-
<p style={{ textAlign: "center", color: "#94a3b8", fontSize: 13, margin: "0 0 18px" }}>{t.subtitle}</p>
215+
<h1 style={{ fontSize: 22, fontWeight: 700, textAlign: "center", margin: "0 0 4px", color: "var(--text-heading)" }}>{t.title}</h1>
216+
<p style={{ textAlign: "center", color: "var(--text-secondary)", fontSize: 13, margin: "0 0 18px" }}>{t.subtitle}</p>
198217

199218
{/* Presets */}
200219
<div style={{ display: "flex", flexWrap: "wrap", gap: 5, justifyContent: "center", marginBottom: 18 }}>
201220
{t.presets.map((p) => {
202221
const active = JSON.stringify(values) === JSON.stringify(p.values);
203222
return (
204-
<button key={p.name} onClick={() => setValues(p.values)} style={{ padding: "4px 9px", fontSize: 11, borderRadius: 6, border: active ? `2px solid ${tc}` : "1px solid #334155", background: active ? `${tc}22` : "#1e293b", color: active ? "#f8fafc" : "#cbd5e1", cursor: "pointer", fontWeight: active ? 600 : 400, transition: "all 0.15s" }}>
223+
<button key={p.name} onClick={() => setValues(p.values)} style={{ padding: "4px 9px", fontSize: 11, borderRadius: 6, border: active ? `2px solid ${tc}` : "1px solid var(--border)", background: active ? `${tc}22` : "var(--bg-card)", color: active ? "var(--text-heading)" : "var(--text-muted)", cursor: "pointer", fontWeight: active ? 600 : 400, transition: "all 0.15s" }}>
205224
{p.name}
206225
</button>
207226
);
@@ -216,7 +235,7 @@ export default function RiskRadar() {
216235
<span style={{ fontSize: 26, fontWeight: 800, color: tc }}>{ti + 1}</span>
217236
<div>
218237
<div style={{ fontWeight: 700, fontSize: 14, color: tc }}>{tier.label}</div>
219-
<div style={{ fontSize: 11, color: "#94a3b8" }}>{tier.desc}</div>
238+
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>{tier.desc}</div>
220239
</div>
221240
</div>
222241

@@ -232,7 +251,7 @@ export default function RiskRadar() {
232251
<span style={{ fontSize: 10, color: sc, fontWeight: 600 }}>{dim.levels[v]}</span>
233252
</div>
234253
<input type="range" min={0} max={4} step={1} value={v} onChange={(e) => set(dim.key, parseInt(e.target.value))} style={{ width: "100%", accentColor: sc, height: 5, cursor: "pointer" }} />
235-
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 9, color: "#94a3b8", marginTop: 1 }}>
254+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 9, color: "var(--text-secondary)", marginTop: 1 }}>
236255
<span>{t.low}</span><span>{t.high}</span>
237256
</div>
238257
</div>
@@ -241,30 +260,30 @@ export default function RiskRadar() {
241260
</div>
242261

243262
{/* Mitigations */}
244-
<div style={{ width: "100%", maxWidth: 500, borderTop: "1px solid #1e293b", paddingTop: 18 }}>
263+
<div style={{ width: "100%", maxWidth: 500, borderTop: "1px solid var(--border-subtle)", paddingTop: 18 }}>
245264
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 12 }}>
246265
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>{t.mitigationHeading}</h2>
247-
<span style={{ fontSize: 11, color: "#94a3b8" }}>{activeCount} {t.active}</span>
266+
<span style={{ fontSize: 11, color: "var(--text-secondary)" }}>{activeCount} {t.active}</span>
248267
</div>
249268
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 12 }}>
250269
{Object.entries(TYPE_COLORS).map(([key, c]) => (
251270
<div key={key} style={{ display: "flex", alignItems: "center", gap: 4 }}>
252271
<div style={{ width: 9, height: 9, borderRadius: 2, background: c.color }} />
253-
<span style={{ fontSize: 10, color: "#94a3b8" }}>{t.typeBadges[key]}</span>
272+
<span style={{ fontSize: 10, color: "var(--text-secondary)" }}>{t.typeBadges[key]}</span>
254273
</div>
255274
))}
256275
</div>
257-
<div style={{ background: "#1e293b", borderRadius: 8, padding: "8px 12px", marginBottom: 14, fontSize: 11, color: "#94a3b8", lineHeight: 1.5, borderLeft: `3px solid ${tc}` }}>
258-
<strong style={{ color: "#e2e8f0" }}>{t.cumulative}:</strong> {t.cumulativeNote(ti, t.mitigations[ti].title)}
276+
<div style={{ background: "var(--bg-card)", borderRadius: 8, padding: "8px 12px", marginBottom: 14, fontSize: 11, color: "var(--text-secondary)", lineHeight: 1.5, borderLeft: `3px solid ${tc}` }}>
277+
<strong style={{ color: "var(--text-primary)" }}>{t.cumulative}:</strong> {t.cumulativeNote(ti, t.mitigations[ti].title)}
259278
</div>
260279
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
261280
{t.mitigations.map((g) => <MitigationCard key={g.tier} group={g} active={g.tier <= ti + 1} accent={TIER_BG[g.tier - 1]} t={t} />)}
262281
</div>
263282
</div>
264283

265-
<div style={{ marginTop: 24, fontSize: 10, color: "#94a3b8", textAlign: "center", lineHeight: 1.8 }}>
266-
<div>v{VERSION} · <a href="https://github.com/LLM-Coding/vibe-coding-risk-radar" target="_blank" rel="noopener" style={{ color: "#94a3b8" }}>{t.footer.github}</a> · <a href={`docs/risk-radar${lang === "en" ? "-en" : ""}.html`} target="_blank" rel="noopener" style={{ color: "#94a3b8" }}>{t.footer.fullDocs}</a></div>
267-
<div>{t.footer.madeBy} <a href="https://www.linkedin.com/in/rdmueller" target="_blank" rel="noopener" style={{ color: "#94a3b8" }}>Ralf D. Müller</a></div>
284+
<div style={{ marginTop: 24, fontSize: 10, color: "var(--text-secondary)", textAlign: "center", lineHeight: 1.8 }}>
285+
<div>v{VERSION} · <a href="https://github.com/LLM-Coding/vibe-coding-risk-radar" target="_blank" rel="noopener" style={{ color: "var(--text-secondary)" }}>{t.footer.github}</a> · <a href={`docs/risk-radar${lang === "en" ? "-en" : ""}.html`} target="_blank" rel="noopener" style={{ color: "var(--text-secondary)" }}>{t.footer.fullDocs}</a></div>
286+
<div>{t.footer.madeBy} <a href="https://www.linkedin.com/in/rdmueller" target="_blank" rel="noopener" style={{ color: "var(--text-secondary)" }}>Ralf D. Müller</a></div>
268287
</div>
269288
</div>
270289

0 commit comments

Comments
 (0)