Skip to content

Commit 3f4b68e

Browse files
committed
feat(frontend): add retro display clock faces
Adds four hardware clock faces to the tap-to-cycle list on CurrentTime, built on the glowbox display cores: nixie tubes, a VFD panel, a split-flap board and a character LCD module. The VFD panel is declared as fixed hardware at module scope (frame plus a digits layout) and driven through the values record by element name, so a re-render never re-compiles the anode inventory. Nixie tubes are keyed by slot rather than by value, which keeps the canvas mounted across ticks instead of remounting it and losing the cathode cross-fade. Light theme: the split-flap board takes theme colours for its cards, ink and frame, since a flap is printed plastic rather than a light source, and the LCD switches between the positive green STN glass and the negative blue one. The nixie and VFD faces stay dark in both themes -- they are emissive hardware, and a dark panel on a pale page is a dark panel. Also drops the Instrument Serif face and its Google Fonts request, removes the unused @glowbox/svelte dependency, and restores the vendored yarn release that .yarnrc.yml pins but 6c9c5d8 left untracked.
1 parent 6c9c5d8 commit 3f4b68e

5 files changed

Lines changed: 1260 additions & 37 deletions

File tree

‎frontend/.yarn/releases/yarn-4.18.0.cjs‎

Lines changed: 1000 additions & 0 deletions
Large diffs are not rendered by default.

‎frontend/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<link rel="alternate icon" href="/favicon.ico" />
2222
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
2323
<link rel="manifest" href="/manifest.webmanifest" />
24-
<link href="https://fonts.googleapis.com/css2?family=Abril+Fatface&family=DM+Serif+Display&family=Instrument+Serif&family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet" />
24+
<link href="https://fonts.googleapis.com/css2?family=Abril+Fatface&family=DM+Serif+Display&family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet" />
2525
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet" />
2626
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
2727
<title>halo</title>

‎frontend/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dependencies": {
2020
"@emotion/react": "^11.14.0",
2121
"@floating-ui/react": "^0.27.20",
22+
"@glowbox/react": "^1.10.0",
2223
"chart.js": "^4.5.1",
2324
"chartjs-adapter-date-fns": "^3.0.0",
2425
"chartjs-plugin-datalabels": "^2.2.0",

‎frontend/src/components/CurrentTime.tsx‎

Lines changed: 183 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useTheme } from "@emotion/react";
2+
import { LcdModule, NixieTube, SplitFlap, type VfdElement, VfdPanel } from "@glowbox/react";
23
import { format } from "date-fns";
34
import { fi } from "date-fns/locale/fi";
45
import React, { memo } from "react";
@@ -19,12 +20,11 @@ const DISPLAY_FONTS = [
1920
weight: 400,
2021
tracking: "-0.02em",
2122
},
22-
{
23-
family: '"Instrument Serif", Georgia, serif',
24-
weight: 400,
25-
tracking: "-0.02em",
26-
},
2723
{ family: '"Inter", system-ui, sans-serif', weight: 300, tracking: "-0.1em" },
24+
{ family: "Nixie" },
25+
{ family: "Vfd" },
26+
{ family: "SplitFlap" },
27+
{ family: "Lcd" },
2828
];
2929

3030
const CurrentTime: React.FC<CurrentTimeProps> = ({ className }) => {
@@ -48,41 +48,59 @@ const CurrentTime: React.FC<CurrentTimeProps> = ({ className }) => {
4848
alignItems: "center",
4949
}}
5050
>
51-
<div
52-
onClick={cycleFont}
53-
css={{
54-
fontFamily: displayFont.family,
55-
fontSize: "7em",
56-
lineHeight: 1,
57-
fontWeight: displayFont.weight,
58-
letterSpacing: displayFont.tracking,
59-
fontVariantNumeric: "tabular-nums",
60-
display: "flex",
61-
alignItems: "baseline",
62-
gap: "0.05em",
63-
cursor: "pointer",
64-
userSelect: "none",
65-
}}
66-
>
67-
<span>{hh}</span>
68-
<span css={{ color: theme.colors.activity.on }}>.</span>
69-
<span>{mm}</span>
70-
<span
51+
{displayFont.family === "Nixie" ? (
52+
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
53+
<NixieClock hh={hh} mm={mm} ss={ss} />
54+
</div>
55+
) : displayFont.family === "Vfd" ? (
56+
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
57+
<VfdClock hh={hh} mm={mm} ss={ss} />
58+
</div>
59+
) : displayFont.family === "SplitFlap" ? (
60+
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
61+
<SplitFlapClock hh={hh} mm={mm} ss={ss} />
62+
</div>
63+
) : displayFont.family === "Lcd" ? (
64+
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
65+
<LcdClock hh={hh} mm={mm} ss={ss} />
66+
</div>
67+
) : (
68+
<div
69+
onClick={cycleFont}
7170
css={{
72-
fontFamily: theme.fonts.heading,
73-
fontSize: "0.22em",
74-
fontWeight: 400,
75-
color: theme.colors.text.muted,
76-
marginLeft: "0.4em",
77-
marginBottom: "0.6em",
78-
alignSelf: "flex-end",
79-
letterSpacing: "normal",
71+
fontFamily: displayFont.family,
72+
fontSize: "7em",
73+
lineHeight: 1,
74+
fontWeight: displayFont.weight,
75+
letterSpacing: displayFont.tracking,
8076
fontVariantNumeric: "tabular-nums",
77+
display: "flex",
78+
alignItems: "baseline",
79+
gap: "0.05em",
80+
cursor: "pointer",
81+
userSelect: "none",
8182
}}
8283
>
83-
{ss}
84-
</span>
85-
</div>
84+
<span>{hh}</span>
85+
<span css={{ color: theme.colors.activity.on }}>.</span>
86+
<span>{mm}</span>
87+
<span
88+
css={{
89+
fontFamily: theme.fonts.heading,
90+
fontSize: "0.22em",
91+
fontWeight: 400,
92+
color: theme.colors.text.muted,
93+
marginLeft: "0.4em",
94+
marginBottom: "0.6em",
95+
alignSelf: "flex-end",
96+
letterSpacing: "normal",
97+
fontVariantNumeric: "tabular-nums",
98+
}}
99+
>
100+
{ss}
101+
</span>
102+
</div>
103+
)}
86104
<div
87105
css={{
88106
fontFamily: theme.fonts.heading,
@@ -99,3 +117,132 @@ const CurrentTime: React.FC<CurrentTimeProps> = ({ className }) => {
99117
);
100118
};
101119
export default memo(CurrentTime);
120+
121+
type NixieClockProps = {
122+
hh: string;
123+
mm: string;
124+
ss: string;
125+
};
126+
127+
const NixieClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
128+
// One tube per slot, keyed by slot rather than by value: the key must stay stable
129+
// across ticks or React remounts the canvas and the cathode cross-fade is lost.
130+
const tubes: [string, string][] = [
131+
["h0", hh[0]],
132+
["h1", hh[1]],
133+
["c0", ":"],
134+
["m0", mm[0]],
135+
["m1", mm[1]],
136+
["c1", ":"],
137+
["s0", ss[0]],
138+
["s1", ss[1]],
139+
];
140+
141+
return (
142+
<div css={{ display: "flex", flexDirection: "row" }}>
143+
{tubes.map(([slot, value]) => (
144+
<div key={slot} css={{ height: 120, width: ["c1", "c0"].includes(slot) ? 26 : 78 }}>
145+
<NixieTube value={value} />
146+
</div>
147+
))}
148+
</div>
149+
);
150+
};
151+
152+
// Fixed hardware: declared once at module scope so a re-render never re-compiles the
153+
// anode inventory. Frame units are y-down; the panel canvas fills its parent box.
154+
const VFD_FRAME: [number, number] = [320, 64];
155+
156+
const VFD_LAYOUT: VfdElement[] = [
157+
{
158+
kind: "digits",
159+
name: "time",
160+
x: 10,
161+
y: 12,
162+
w: 232,
163+
h: 44,
164+
chars: 5,
165+
glyphs: "14seg",
166+
align: "right",
167+
},
168+
{
169+
kind: "digits",
170+
name: "secs",
171+
x: 252,
172+
y: 26,
173+
w: 58,
174+
h: 26,
175+
chars: 2,
176+
glyphs: "14seg",
177+
},
178+
];
179+
180+
const VfdClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
181+
return (
182+
<div css={{ width: 640, height: 128 }}>
183+
<VfdPanel
184+
frame={VFD_FRAME}
185+
layout={VFD_LAYOUT}
186+
values={{ time: `${hh}.${mm}`, secs: ss }}
187+
phosphor="amber"
188+
filter="amber"
189+
label={`${hh}:${mm}:${ss}`}
190+
/>
191+
</div>
192+
);
193+
};
194+
195+
// A digits-only drum: 9 rolls over to 0 in two flips instead of spinning the whole
196+
// alphabet. The colon cells sit on the last flap and never move.
197+
const FLAP_CHARSET = "0123456789:";
198+
199+
const SplitFlapClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
200+
const theme = useTheme();
201+
202+
// The only one of the three that can genuinely go light: a flap is printed
203+
// plastic, not a light source, and pale-card boards are real hardware. Dark
204+
// mode keeps the core's own near-black card / warm-white ink.
205+
const light = theme.mode === "light";
206+
207+
return (
208+
<div css={{ width: 560, height: 100 }}>
209+
<SplitFlap
210+
cols={8}
211+
rows={1}
212+
charset={FLAP_CHARSET}
213+
text={`${hh}:${mm}:${ss}`}
214+
card={light ? theme.colors.background.main : undefined}
215+
ink={light ? theme.colors.onAccent : undefined}
216+
board={light ? theme.colors.text.light : undefined}
217+
label={`${hh}:${mm}:${ss}`}
218+
/>
219+
</div>
220+
);
221+
};
222+
223+
const LcdClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
224+
const theme = useTheme();
225+
226+
// The reflective one: dark ink on a lit pane, native to a light page. The green
227+
// STN glass is positive (readable even unlit); the blue is negative — light ink
228+
// that only exists while the backlight is on, which is what suits the dark theme.
229+
const negative = theme.mode === "dark";
230+
231+
return (
232+
<div css={{ width: 480, height: 128 }}>
233+
<LcdModule
234+
cols={8}
235+
rows={1}
236+
text={`${hh}:${mm}:${ss}`}
237+
panel={negative ? "blue" : "green"}
238+
backlight
239+
// A clock has no cursor parked after its last digit.
240+
cursor="none"
241+
// The boot row of solid blocks is a power-up artifact; the mode is
242+
// remounted on every cycle through the font list, so it would replay.
243+
boot={false}
244+
label={`${hh}:${mm}:${ss}`}
245+
/>
246+
</div>
247+
);
248+
};

‎frontend/yarn.lock‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,80 @@ __metadata:
527527
languageName: node
528528
linkType: hard
529529

530+
"@glowbox/flip-dot@npm:^1.10.0":
531+
version: 1.10.0
532+
resolution: "@glowbox/flip-dot@npm:1.10.0"
533+
checksum: 10/fc124409d31b2e7ed51fc6855db90e927c6b1745831100bff360367881a6362a19c162a9f6fdeb3573a6a28a51d1dfeff8094d7af6a434af86cc1995eb7c8b05
534+
languageName: node
535+
linkType: hard
536+
537+
"@glowbox/lcd@npm:^1.10.0":
538+
version: 1.10.0
539+
resolution: "@glowbox/lcd@npm:1.10.0"
540+
checksum: 10/f4423baa6ff8cd100e32e4ef26fff5c988b170b11789fe423fea09f8b42544cb307f62a9e4387fb129edadb782b981996a097fc0b4f3e1399a88f3c8257f7f31
541+
languageName: node
542+
linkType: hard
543+
544+
"@glowbox/led-grid@npm:^1.10.0":
545+
version: 1.10.0
546+
resolution: "@glowbox/led-grid@npm:1.10.0"
547+
checksum: 10/8c4c57c5592558e01cd5caa76190de4dd1278533f928670c8a2a2853b14a355fa354eb65a564caa8f2b812493b8177e14a0a514259745232efe5f6f951409ef6
548+
languageName: node
549+
linkType: hard
550+
551+
"@glowbox/neon@npm:^1.10.0":
552+
version: 1.10.0
553+
resolution: "@glowbox/neon@npm:1.10.0"
554+
checksum: 10/8c109dfdd7dc2e2c57fdbb34a9f191fc139714d00a645a8d09e1136172a98d642696dd4d4739e62c87782c0262d2b56c3c563e9022877671b709755873f2bc91
555+
languageName: node
556+
linkType: hard
557+
558+
"@glowbox/nixie@npm:^1.10.0":
559+
version: 1.10.0
560+
resolution: "@glowbox/nixie@npm:1.10.0"
561+
checksum: 10/1d40c6a874d464db8850a0d223f45e76e123d7fa3fefed57b8b2c61b188bbbd58133faa5705f00d635e6519b1046c1f85da09c3ca4ccd4e39e1c1c967012d909
562+
languageName: node
563+
linkType: hard
564+
565+
"@glowbox/react@npm:^1.10.0":
566+
version: 1.10.0
567+
resolution: "@glowbox/react@npm:1.10.0"
568+
dependencies:
569+
"@glowbox/flip-dot": "npm:^1.10.0"
570+
"@glowbox/lcd": "npm:^1.10.0"
571+
"@glowbox/led-grid": "npm:^1.10.0"
572+
"@glowbox/neon": "npm:^1.10.0"
573+
"@glowbox/nixie": "npm:^1.10.0"
574+
"@glowbox/seven-segment": "npm:^1.10.0"
575+
"@glowbox/split-flap": "npm:^1.10.0"
576+
"@glowbox/vfd": "npm:^1.10.0"
577+
peerDependencies:
578+
react: ^18 || ^19
579+
checksum: 10/8ea61c6593bed65f5aca6a74e835f1957018cfd6a04118104df1e05ad2fd4fb91265d658aa56c4fa1c337c55430d42e31acafcb88986cb2f3e63b0a123672ac9
580+
languageName: node
581+
linkType: hard
582+
583+
"@glowbox/seven-segment@npm:^1.10.0":
584+
version: 1.10.0
585+
resolution: "@glowbox/seven-segment@npm:1.10.0"
586+
checksum: 10/d270754933531a0f71e659d5ea805108f4dbf57e943a4b5d1a36939ae80416f6bc524bd2f990427bfea55358b2e2f1668af6768b6ec56a00685057c450af8101
587+
languageName: node
588+
linkType: hard
589+
590+
"@glowbox/split-flap@npm:^1.10.0":
591+
version: 1.10.0
592+
resolution: "@glowbox/split-flap@npm:1.10.0"
593+
checksum: 10/9ee7ccad587e0ccf866d21e069d3fa9e4d4df9a81a20c4bd3b99fcdbb4db7aa76a665f429a994dac3c60d1afd9d24bd2d6d47220a6aed6bf1a9296f937e190ea
594+
languageName: node
595+
linkType: hard
596+
597+
"@glowbox/vfd@npm:^1.10.0":
598+
version: 1.10.0
599+
resolution: "@glowbox/vfd@npm:1.10.0"
600+
checksum: 10/92b550649e6f965b635d50974da16204a8edf3da1f146eaafc1b8a1166cefff8c12ae09d9c39407e45281b10a264f8d49b909d7673db71df566adffc21579b27
601+
languageName: node
602+
linkType: hard
603+
530604
"@humanfs/core@npm:^0.19.1":
531605
version: 0.19.1
532606
resolution: "@humanfs/core@npm:0.19.1"
@@ -2505,6 +2579,7 @@ __metadata:
25052579
"@anarkisti/eslint-config": "npm:^1.1.0"
25062580
"@emotion/react": "npm:^11.14.0"
25072581
"@floating-ui/react": "npm:^0.27.20"
2582+
"@glowbox/react": "npm:^1.10.0"
25082583
"@types/leaflet": "npm:^1.9.22"
25092584
"@types/react": "npm:^19.2.18"
25102585
"@types/react-dom": "npm:^19.2.4"

0 commit comments

Comments
 (0)