@dirsigler/techradar 1.0.4
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.
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/config.ts +54 -0
- package/index.ts +11 -0
- package/integration.ts +85 -0
- package/package.json +43 -0
- package/schemas.ts +13 -0
- package/src/components/MovedIndicator.astro +10 -0
- package/src/components/Radar.astro +266 -0
- package/src/components/RadarLegend.astro +120 -0
- package/src/components/RingBadge.astro +49 -0
- package/src/components/TechnologyCard.astro +46 -0
- package/src/components/ThemeToggle.astro +115 -0
- package/src/layouts/Base.astro +149 -0
- package/src/lib/radar.ts +124 -0
- package/src/pages/404.astro +34 -0
- package/src/pages/index.astro +206 -0
- package/src/pages/segments/[segment].astro +83 -0
- package/src/pages/technology/[...slug].astro +112 -0
- package/src/styles/global.css +37 -0
- package/src/themes/catppuccin-mocha.css +55 -0
- package/src/themes/default.css +171 -0
- package/virtual.d.ts +9 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { RING_RADII, RING_LABELS, RINGS, quadrantAngles, positionDots } from '../lib/radar';
|
|
3
|
+
|
|
4
|
+
interface Technology {
|
|
5
|
+
title: string;
|
|
6
|
+
ring: 'adopt' | 'trial' | 'assess' | 'hold';
|
|
7
|
+
moved: number;
|
|
8
|
+
slug: string;
|
|
9
|
+
segment: string;
|
|
10
|
+
color: string;
|
|
11
|
+
order: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Segment {
|
|
15
|
+
title: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
color: string;
|
|
18
|
+
order: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
technologies: Technology[];
|
|
23
|
+
segments: Segment[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { technologies, segments } = Astro.props;
|
|
27
|
+
const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/';
|
|
28
|
+
|
|
29
|
+
const dots = positionDots(technologies);
|
|
30
|
+
|
|
31
|
+
const CENTER = 400;
|
|
32
|
+
const OUTER_RADIUS = 400;
|
|
33
|
+
|
|
34
|
+
const ringRadii = [100, 200, 300, 400];
|
|
35
|
+
|
|
36
|
+
const quadrantLines = [
|
|
37
|
+
{ x1: CENTER, y1: 0, x2: CENTER, y2: CENTER * 2 },
|
|
38
|
+
{ x1: 0, y1: CENTER, x2: CENTER * 2, y2: CENTER },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const segmentLabels = segments.map((seg) => {
|
|
42
|
+
const angles = quadrantAngles(seg.order);
|
|
43
|
+
const midAngle = (angles.start + angles.end) / 2;
|
|
44
|
+
const labelR = OUTER_RADIUS - 30;
|
|
45
|
+
return {
|
|
46
|
+
x: CENTER + labelR * Math.cos(midAngle),
|
|
47
|
+
y: CENTER + labelR * Math.sin(midAngle),
|
|
48
|
+
title: seg.title,
|
|
49
|
+
color: seg.color,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<div class="radar-container relative mx-auto w-full max-w-4xl">
|
|
55
|
+
<svg
|
|
56
|
+
viewBox="0 0 800 800"
|
|
57
|
+
class="w-full"
|
|
58
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
59
|
+
>
|
|
60
|
+
<defs>
|
|
61
|
+
<radialGradient id="radar-bg" cx="50%" cy="50%" r="50%">
|
|
62
|
+
<stop offset="0%" style="stop-color: var(--radar-chart-center)" />
|
|
63
|
+
<stop offset="50%" style="stop-color: var(--radar-chart-mid)" />
|
|
64
|
+
<stop offset="100%" style="stop-color: var(--radar-chart-edge)" />
|
|
65
|
+
</radialGradient>
|
|
66
|
+
</defs>
|
|
67
|
+
|
|
68
|
+
<!-- Background with radial gradient -->
|
|
69
|
+
<circle cx={CENTER} cy={CENTER} r={OUTER_RADIUS} fill="url(#radar-bg)" />
|
|
70
|
+
|
|
71
|
+
<!-- Ring fill bands -->
|
|
72
|
+
{RINGS.map((_ring, i) => {
|
|
73
|
+
const { outer } = RING_RADII[_ring];
|
|
74
|
+
const opacities = [0.06, 0.04, 0.03, 0.02];
|
|
75
|
+
return (
|
|
76
|
+
<circle
|
|
77
|
+
cx={CENTER}
|
|
78
|
+
cy={CENTER}
|
|
79
|
+
r={outer}
|
|
80
|
+
style={`fill: rgba(var(--radar-chart-band), ${opacities[i]})`}
|
|
81
|
+
stroke="none"
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}).reverse()}
|
|
85
|
+
|
|
86
|
+
<!-- Ring circles -->
|
|
87
|
+
{ringRadii.map((r) => (
|
|
88
|
+
<circle
|
|
89
|
+
cx={CENTER}
|
|
90
|
+
cy={CENTER}
|
|
91
|
+
r={r}
|
|
92
|
+
fill="none"
|
|
93
|
+
style="stroke: var(--radar-chart-ring)"
|
|
94
|
+
stroke-width="1"
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
|
|
98
|
+
<!-- Quadrant divider lines -->
|
|
99
|
+
{quadrantLines.map((line) => (
|
|
100
|
+
<line
|
|
101
|
+
x1={line.x1}
|
|
102
|
+
y1={line.y1}
|
|
103
|
+
x2={line.x2}
|
|
104
|
+
y2={line.y2}
|
|
105
|
+
style="stroke: var(--radar-chart-divider)"
|
|
106
|
+
stroke-width="1"
|
|
107
|
+
opacity="0.6"
|
|
108
|
+
/>
|
|
109
|
+
))}
|
|
110
|
+
|
|
111
|
+
<!-- Ring labels -->
|
|
112
|
+
{RINGS.map((ring) => {
|
|
113
|
+
const { inner, outer } = RING_RADII[ring];
|
|
114
|
+
const midR = (inner + outer) / 2;
|
|
115
|
+
return (
|
|
116
|
+
<text
|
|
117
|
+
x={CENTER + midR}
|
|
118
|
+
y={CENTER - 6}
|
|
119
|
+
style="fill: var(--radar-chart-label)"
|
|
120
|
+
font-size="14"
|
|
121
|
+
text-anchor="middle"
|
|
122
|
+
font-family="system-ui, sans-serif"
|
|
123
|
+
>
|
|
124
|
+
{RING_LABELS[ring]}
|
|
125
|
+
</text>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
|
|
129
|
+
<!-- Segment labels (multiline for long names) -->
|
|
130
|
+
{segmentLabels.map((label) => {
|
|
131
|
+
const words = label.title.split(' ');
|
|
132
|
+
const mid = Math.ceil(words.length / 2);
|
|
133
|
+
const lines = words.length > 1
|
|
134
|
+
? [words.slice(0, mid).join(' '), words.slice(mid).join(' ')]
|
|
135
|
+
: [label.title];
|
|
136
|
+
const lineHeight = 20;
|
|
137
|
+
const startY = label.y - ((lines.length - 1) * lineHeight) / 2;
|
|
138
|
+
return (
|
|
139
|
+
<text
|
|
140
|
+
x={label.x}
|
|
141
|
+
y={startY}
|
|
142
|
+
fill={label.color}
|
|
143
|
+
font-size="15"
|
|
144
|
+
font-weight="700"
|
|
145
|
+
text-anchor="middle"
|
|
146
|
+
dominant-baseline="middle"
|
|
147
|
+
font-family="system-ui, sans-serif"
|
|
148
|
+
>
|
|
149
|
+
{lines.map((line, i) => (
|
|
150
|
+
<tspan x={label.x} dy={i === 0 ? 0 : lineHeight}>
|
|
151
|
+
{line}
|
|
152
|
+
</tspan>
|
|
153
|
+
))}
|
|
154
|
+
</text>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
|
|
158
|
+
<!-- Technology dots -->
|
|
159
|
+
{dots.map((dot) => (
|
|
160
|
+
<a href={`${base}technology/${dot.slug}/`} class="radar-link">
|
|
161
|
+
<circle
|
|
162
|
+
cx={dot.x}
|
|
163
|
+
cy={dot.y}
|
|
164
|
+
r="9"
|
|
165
|
+
fill={dot.color}
|
|
166
|
+
style="stroke: var(--radar-dot-stroke)"
|
|
167
|
+
stroke-width="2"
|
|
168
|
+
class="radar-dot"
|
|
169
|
+
data-title={dot.title}
|
|
170
|
+
data-ring={dot.ring}
|
|
171
|
+
data-segment={dot.segment}
|
|
172
|
+
/>
|
|
173
|
+
{dot.moved === 1 && (
|
|
174
|
+
<polygon
|
|
175
|
+
points={`${dot.x},${dot.y - 16} ${dot.x - 5},${dot.y - 11} ${dot.x + 5},${dot.y - 11}`}
|
|
176
|
+
style="fill: var(--radar-moved-in)"
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
{dot.moved === -1 && (
|
|
180
|
+
<polygon
|
|
181
|
+
points={`${dot.x},${dot.y + 16} ${dot.x - 5},${dot.y + 11} ${dot.x + 5},${dot.y + 11}`}
|
|
182
|
+
style="fill: var(--radar-moved-out)"
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</a>
|
|
186
|
+
))}
|
|
187
|
+
</svg>
|
|
188
|
+
|
|
189
|
+
<!-- Tooltip -->
|
|
190
|
+
<div id="radar-tooltip" class="radar-tooltip hidden"></div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<script>
|
|
194
|
+
const svg = document.querySelector('.radar-container svg');
|
|
195
|
+
const tooltip = document.getElementById('radar-tooltip');
|
|
196
|
+
if (svg && tooltip) {
|
|
197
|
+
svg.querySelectorAll('.radar-dot').forEach((dot) => {
|
|
198
|
+
dot.addEventListener('mouseenter', (e) => {
|
|
199
|
+
const target = e.target as SVGCircleElement;
|
|
200
|
+
const title = target.getAttribute('data-title') ?? '';
|
|
201
|
+
const ring = target.getAttribute('data-ring') ?? '';
|
|
202
|
+
tooltip.textContent = `${title} (${ring})`;
|
|
203
|
+
tooltip.classList.remove('hidden');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
dot.addEventListener('mousemove', (e) => {
|
|
207
|
+
const container = svg.closest('.radar-container') as HTMLElement;
|
|
208
|
+
const rect = container.getBoundingClientRect();
|
|
209
|
+
const me = e as MouseEvent;
|
|
210
|
+
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
|
|
211
|
+
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
dot.addEventListener('mouseleave', () => {
|
|
215
|
+
tooltip.classList.add('hidden');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
</script>
|
|
220
|
+
|
|
221
|
+
<style>
|
|
222
|
+
.radar-tooltip {
|
|
223
|
+
position: absolute;
|
|
224
|
+
display: none;
|
|
225
|
+
pointer-events: none;
|
|
226
|
+
padding: 0.5rem 0.75rem;
|
|
227
|
+
border-radius: 0.5rem;
|
|
228
|
+
font-size: 0.875rem;
|
|
229
|
+
font-weight: 500;
|
|
230
|
+
background-color: var(--radar-hover-bg);
|
|
231
|
+
color: var(--radar-text);
|
|
232
|
+
border: 1px solid var(--radar-border-subtle);
|
|
233
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
234
|
+
}
|
|
235
|
+
.radar-tooltip:not(.hidden) {
|
|
236
|
+
display: block;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.radar-link {
|
|
240
|
+
outline: none;
|
|
241
|
+
color: inherit;
|
|
242
|
+
text-decoration: none;
|
|
243
|
+
-webkit-tap-highlight-color: transparent;
|
|
244
|
+
}
|
|
245
|
+
.radar-link:visited,
|
|
246
|
+
.radar-link:link,
|
|
247
|
+
.radar-link:active {
|
|
248
|
+
color: inherit;
|
|
249
|
+
}
|
|
250
|
+
.radar-link:focus,
|
|
251
|
+
.radar-link:focus-visible {
|
|
252
|
+
outline: none;
|
|
253
|
+
}
|
|
254
|
+
.radar-link:hover .radar-dot,
|
|
255
|
+
.radar-link:focus .radar-dot {
|
|
256
|
+
r: 13;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.radar-dot {
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
transition: r 0.15s ease, filter 0.15s ease;
|
|
262
|
+
}
|
|
263
|
+
.radar-dot:hover {
|
|
264
|
+
filter: brightness(1.3) drop-shadow(0 0 8px rgba(205, 214, 244, 0.4));
|
|
265
|
+
}
|
|
266
|
+
</style>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
import RingBadge from './RingBadge.astro';
|
|
3
|
+
import MovedIndicator from './MovedIndicator.astro';
|
|
4
|
+
import { RINGS } from '../lib/radar';
|
|
5
|
+
|
|
6
|
+
interface Technology {
|
|
7
|
+
title: string;
|
|
8
|
+
ring: 'adopt' | 'trial' | 'assess' | 'hold';
|
|
9
|
+
moved: number;
|
|
10
|
+
href: string;
|
|
11
|
+
segment: string;
|
|
12
|
+
color: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Segment {
|
|
16
|
+
title: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
color: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
technologies: Technology[];
|
|
23
|
+
segments: Segment[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { technologies, segments } = Astro.props;
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<div class="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
|
30
|
+
{segments.map((segment) => {
|
|
31
|
+
const segTechs = technologies.filter((t) => t.segment === segment.slug);
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<h3 class="legend-heading">
|
|
35
|
+
<span class="inline-block h-3.5 w-3.5 rounded-full" style={`background-color: ${segment.color}`} />
|
|
36
|
+
{segment.title}
|
|
37
|
+
</h3>
|
|
38
|
+
<ul class="legend-grid">
|
|
39
|
+
{RINGS.map((ring) => {
|
|
40
|
+
const ringTechs = segTechs.filter((t) => t.ring === ring);
|
|
41
|
+
return ringTechs.map((tech) => (
|
|
42
|
+
<li class="contents">
|
|
43
|
+
<a
|
|
44
|
+
href={tech.href}
|
|
45
|
+
class="legend-row"
|
|
46
|
+
data-segment={tech.segment}
|
|
47
|
+
data-ring={tech.ring}
|
|
48
|
+
>
|
|
49
|
+
<span class="legend-indicator"><MovedIndicator moved={tech.moved} /></span>
|
|
50
|
+
<span class="legend-title">{tech.title}</span>
|
|
51
|
+
<span class="legend-badge"><RingBadge ring={tech.ring} /></span>
|
|
52
|
+
</a>
|
|
53
|
+
</li>
|
|
54
|
+
));
|
|
55
|
+
})}
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.legend-heading {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.625rem;
|
|
67
|
+
margin-bottom: 1rem;
|
|
68
|
+
padding-bottom: 0.75rem;
|
|
69
|
+
border-bottom: 1px solid var(--radar-border);
|
|
70
|
+
font-size: 1rem;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
color: var(--radar-text-secondary);
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.legend-grid {
|
|
77
|
+
display: grid;
|
|
78
|
+
grid-template-columns: 1.25rem 1fr auto;
|
|
79
|
+
column-gap: 0.75rem;
|
|
80
|
+
row-gap: 0.375rem;
|
|
81
|
+
list-style: none;
|
|
82
|
+
padding: 0;
|
|
83
|
+
margin: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.legend-row {
|
|
87
|
+
display: grid;
|
|
88
|
+
grid-template-columns: subgrid;
|
|
89
|
+
grid-column: 1 / -1;
|
|
90
|
+
align-items: center;
|
|
91
|
+
column-gap: 0.75rem;
|
|
92
|
+
padding: 0.375rem 0.625rem;
|
|
93
|
+
border-radius: 0.5rem;
|
|
94
|
+
font-size: 1rem;
|
|
95
|
+
color: var(--radar-text-muted);
|
|
96
|
+
text-decoration: none;
|
|
97
|
+
transition: background-color 0.15s, color 0.15s, opacity 0.3s ease;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.legend-row:hover {
|
|
101
|
+
background-color: var(--radar-hover-bg);
|
|
102
|
+
color: var(--radar-text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.legend-indicator {
|
|
106
|
+
display: flex;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
width: 1.25rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.legend-title {
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
overflow: hidden;
|
|
114
|
+
text-overflow: ellipsis;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.legend-badge {
|
|
118
|
+
justify-self: end;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
ring: 'adopt' | 'trial' | 'assess' | 'hold';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { ring } = Astro.props;
|
|
7
|
+
|
|
8
|
+
const labels: Record<string, string> = {
|
|
9
|
+
adopt: 'Adopt',
|
|
10
|
+
trial: 'Trial',
|
|
11
|
+
assess: 'Assess',
|
|
12
|
+
hold: 'Hold',
|
|
13
|
+
};
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<span class={`ring-badge ring-${ring}`}>
|
|
17
|
+
{labels[ring]}
|
|
18
|
+
</span>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.ring-badge {
|
|
22
|
+
display: inline-block;
|
|
23
|
+
padding: 0.25rem 0.75rem;
|
|
24
|
+
border-radius: 9999px;
|
|
25
|
+
border: 1px solid;
|
|
26
|
+
font-size: 0.875rem;
|
|
27
|
+
font-weight: 500;
|
|
28
|
+
}
|
|
29
|
+
.ring-adopt {
|
|
30
|
+
background-color: var(--radar-ring-adopt-bg);
|
|
31
|
+
color: var(--radar-ring-adopt);
|
|
32
|
+
border-color: var(--radar-ring-adopt-border);
|
|
33
|
+
}
|
|
34
|
+
.ring-trial {
|
|
35
|
+
background-color: var(--radar-ring-trial-bg);
|
|
36
|
+
color: var(--radar-ring-trial);
|
|
37
|
+
border-color: var(--radar-ring-trial-border);
|
|
38
|
+
}
|
|
39
|
+
.ring-assess {
|
|
40
|
+
background-color: var(--radar-ring-assess-bg);
|
|
41
|
+
color: var(--radar-ring-assess);
|
|
42
|
+
border-color: var(--radar-ring-assess-border);
|
|
43
|
+
}
|
|
44
|
+
.ring-hold {
|
|
45
|
+
background-color: var(--radar-ring-hold-bg);
|
|
46
|
+
color: var(--radar-ring-hold);
|
|
47
|
+
border-color: var(--radar-ring-hold-border);
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
import RingBadge from './RingBadge.astro';
|
|
3
|
+
import MovedIndicator from './MovedIndicator.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
title: string;
|
|
7
|
+
ring: 'adopt' | 'trial' | 'assess' | 'hold';
|
|
8
|
+
moved: number;
|
|
9
|
+
href: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { title, ring, moved, href } = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<a href={href} class="tech-card">
|
|
16
|
+
<span class="tech-card-title">
|
|
17
|
+
<span>{title}</span>
|
|
18
|
+
<MovedIndicator moved={moved} />
|
|
19
|
+
</span>
|
|
20
|
+
<RingBadge ring={ring} />
|
|
21
|
+
</a>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.tech-card {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
padding: 0.75rem 1rem;
|
|
29
|
+
border-radius: 0.5rem;
|
|
30
|
+
border: 1px solid var(--radar-border);
|
|
31
|
+
background-color: var(--radar-bg-secondary);
|
|
32
|
+
text-decoration: none;
|
|
33
|
+
transition: border-color 0.15s, background-color 0.15s;
|
|
34
|
+
}
|
|
35
|
+
.tech-card:hover {
|
|
36
|
+
border-color: var(--radar-border-subtle);
|
|
37
|
+
background-color: var(--radar-hover-bg);
|
|
38
|
+
}
|
|
39
|
+
.tech-card-title {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 0.5rem;
|
|
43
|
+
font-weight: 500;
|
|
44
|
+
color: var(--radar-text);
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Theme toggle — sun/moon button that cycles: system → light → dark → system
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<button
|
|
6
|
+
id="theme-toggle"
|
|
7
|
+
type="button"
|
|
8
|
+
class="theme-toggle"
|
|
9
|
+
title="Toggle theme"
|
|
10
|
+
aria-label="Toggle theme"
|
|
11
|
+
>
|
|
12
|
+
<!-- Sun icon (shown in dark mode) -->
|
|
13
|
+
<svg class="theme-icon sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
14
|
+
<circle cx="12" cy="12" r="5" />
|
|
15
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
16
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
17
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
18
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
19
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
20
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
21
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
22
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
23
|
+
</svg>
|
|
24
|
+
<!-- Moon icon (shown in light mode) -->
|
|
25
|
+
<svg class="theme-icon moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
26
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
27
|
+
</svg>
|
|
28
|
+
</button>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
const toggle = document.getElementById('theme-toggle');
|
|
32
|
+
|
|
33
|
+
function getEffectiveTheme(): 'light' | 'dark' {
|
|
34
|
+
const stored = localStorage.getItem('theme');
|
|
35
|
+
if (stored === 'light' || stored === 'dark') return stored;
|
|
36
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyTheme() {
|
|
40
|
+
const stored = localStorage.getItem('theme');
|
|
41
|
+
if (stored === 'light' || stored === 'dark') {
|
|
42
|
+
document.documentElement.setAttribute('data-theme', stored);
|
|
43
|
+
} else {
|
|
44
|
+
document.documentElement.removeAttribute('data-theme');
|
|
45
|
+
}
|
|
46
|
+
updateIcon();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function updateIcon() {
|
|
50
|
+
const effective = getEffectiveTheme();
|
|
51
|
+
document.documentElement.classList.toggle('is-dark', effective === 'dark');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
toggle?.addEventListener('click', () => {
|
|
55
|
+
const current = localStorage.getItem('theme');
|
|
56
|
+
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
57
|
+
|
|
58
|
+
let next: string | null;
|
|
59
|
+
if (!current) {
|
|
60
|
+
// system → explicit opposite
|
|
61
|
+
next = systemDark ? 'light' : 'dark';
|
|
62
|
+
} else {
|
|
63
|
+
// explicit → back to system
|
|
64
|
+
next = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (next) {
|
|
68
|
+
localStorage.setItem('theme', next);
|
|
69
|
+
} else {
|
|
70
|
+
localStorage.removeItem('theme');
|
|
71
|
+
}
|
|
72
|
+
applyTheme();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Listen for system preference changes
|
|
76
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
77
|
+
if (!localStorage.getItem('theme')) {
|
|
78
|
+
updateIcon();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Initial apply
|
|
83
|
+
applyTheme();
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.theme-toggle {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
padding: 0.5rem;
|
|
92
|
+
border-radius: 0.5rem;
|
|
93
|
+
border: 1px solid var(--radar-border);
|
|
94
|
+
background: transparent;
|
|
95
|
+
color: var(--radar-text-muted);
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
transition: color 0.15s, border-color 0.15s;
|
|
98
|
+
}
|
|
99
|
+
.theme-toggle:hover {
|
|
100
|
+
color: var(--radar-text);
|
|
101
|
+
border-color: var(--radar-border-subtle);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.theme-icon {
|
|
105
|
+
width: 1.25rem;
|
|
106
|
+
height: 1.25rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Show moon by default (light mode), sun when dark */
|
|
110
|
+
.sun { display: none; }
|
|
111
|
+
.moon { display: block; }
|
|
112
|
+
|
|
113
|
+
:global(.is-dark) .sun { display: block; }
|
|
114
|
+
:global(.is-dark) .moon { display: none; }
|
|
115
|
+
</style>
|