Skip to content

Commit a058780

Browse files
committed
feat(snf): add retro-futuristic Serfs & Frauds archive terminal
Add /snf, a 'Black Ragnarok' used-future CRT terminal reimagining of the From Serfs and Frauds archive, alongside the untouched medieval /stories. - Full parity: hub, archive -> volume -> field-report reader, personnel, sites, evidence, operators, case board, and a system-config page - Fully configurable FX persisted via SnfContext: CRT colour theme, scanlines, flicker, glow, chromatic aberration, curvature, grain, vignette, screen shake (intensity + rotation), synthesised ambient audio, UI beeps, decrypt reveals and boot sequence - Continuous-feed printout reader with manifest header, line numbers and reading-progress; EN/TR toggle; mobile drawer; reduced-motion support - Reuses public/stories piml data via a shared useStoriesData hook
1 parent c4eeb1e commit a058780

26 files changed

Lines changed: 4567 additions & 3 deletions

public/sidebar.piml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@
120120
(label) Serfs & Frauds
121121
(to) /stories
122122
(icon) CastleTurretIcon
123+
> (item)
124+
(label) S&F // Terminal
125+
(to) /snf
126+
(icon) TerminalWindowIcon
123127
> (item)
124128
(label) RSS_Feed
125129
(url) /rss.xml

src/components/AnimatedRoutes.jsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ const DndPlacesPage = lazy(() => import('../pages/dnd/DndPlacesPage'));
2323
const DndItemsPage = lazy(() => import('../pages/dnd/DndItemsPage'));
2424
const DndBookPage = lazy(() => import('../pages/dnd/DndBookPage'));
2525
const DndAuthorsPage = lazy(() => import('../pages/dnd/DndAuthorsPage'));
26+
// Serfs & Frauds — "Black Ragnarok" retro-futuristic archive terminal (/snf)
27+
const SnfHubPage = lazy(() => import('../pages/snf/SnfHubPage'));
28+
const SnfArchivePage = lazy(() => import('../pages/snf/SnfArchivePage'));
29+
const SnfVolumePage = lazy(() => import('../pages/snf/SnfVolumePage'));
30+
const SnfDossierPage = lazy(() => import('../pages/snf/SnfDossierPage'));
31+
const SnfPersonnelPage = lazy(() => import('../pages/snf/SnfPersonnelPage'));
32+
const SnfSitesPage = lazy(() => import('../pages/snf/SnfSitesPage'));
33+
const SnfEvidencePage = lazy(() => import('../pages/snf/SnfEvidencePage'));
34+
const SnfAgentsPage = lazy(() => import('../pages/snf/SnfAgentsPage'));
35+
const SnfBoardPage = lazy(() => import('../pages/snf/SnfBoardPage'));
36+
const SnfSettingsPage = lazy(() => import('../pages/snf/SnfSettingsPage'));
37+
const SnfNotFoundPage = lazy(() => import('../pages/snf/SnfNotFoundPage'));
2638
const AppPage = lazy(() => import('../pages/AppPage'));
2739
const IpPage = lazy(() => import('../pages/apps/IpPage'));
2840
const WordCounterPage = lazy(() => import('../pages/apps/WordCounterPage'));
@@ -718,6 +730,95 @@ const AnimatedRoutes = ({
718730
</motion.div>
719731
}
720732
/>
733+
{/* ===== Serfs & Frauds // Black Ragnarok Archive Terminal (/snf) ===== */}
734+
<Route
735+
path="/snf"
736+
element={
737+
<Suspense fallback={<Loading />}>
738+
<SnfHubPage />
739+
</Suspense>
740+
}
741+
/>
742+
<Route
743+
path="/snf/archive"
744+
element={
745+
<Suspense fallback={<Loading />}>
746+
<SnfArchivePage />
747+
</Suspense>
748+
}
749+
/>
750+
<Route
751+
path="/snf/archive/:bookId"
752+
element={
753+
<Suspense fallback={<Loading />}>
754+
<SnfVolumePage />
755+
</Suspense>
756+
}
757+
/>
758+
<Route
759+
path="/snf/archive/:bookId/:episodeId"
760+
element={
761+
<Suspense fallback={<Loading />}>
762+
<SnfDossierPage />
763+
</Suspense>
764+
}
765+
/>
766+
<Route
767+
path="/snf/personnel"
768+
element={
769+
<Suspense fallback={<Loading />}>
770+
<SnfPersonnelPage />
771+
</Suspense>
772+
}
773+
/>
774+
<Route
775+
path="/snf/sites"
776+
element={
777+
<Suspense fallback={<Loading />}>
778+
<SnfSitesPage />
779+
</Suspense>
780+
}
781+
/>
782+
<Route
783+
path="/snf/evidence"
784+
element={
785+
<Suspense fallback={<Loading />}>
786+
<SnfEvidencePage />
787+
</Suspense>
788+
}
789+
/>
790+
<Route
791+
path="/snf/agents"
792+
element={
793+
<Suspense fallback={<Loading />}>
794+
<SnfAgentsPage />
795+
</Suspense>
796+
}
797+
/>
798+
<Route
799+
path="/snf/board"
800+
element={
801+
<Suspense fallback={<Loading />}>
802+
<SnfBoardPage />
803+
</Suspense>
804+
}
805+
/>
806+
<Route
807+
path="/snf/settings"
808+
element={
809+
<Suspense fallback={<Loading />}>
810+
<SnfSettingsPage />
811+
</Suspense>
812+
}
813+
/>
814+
<Route
815+
path="/snf/*"
816+
element={
817+
<Suspense fallback={<Loading />}>
818+
<SnfNotFoundPage />
819+
</Suspense>
820+
}
821+
/>
721822
<Route
722823
path="/random"
723824
element={

src/components/Layout.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import SyntaxSprite from './SyntaxSprite';
2323
import { useProjects } from '../utils/projectParser';
2424

2525
import { DndProvider } from '../context/DndContext';
26+
import { SnfProvider } from '../context/SnfContext';
2627

2728
const Layout = ({
2829
children,
@@ -51,6 +52,7 @@ const Layout = ({
5152
const isAboutPage = location.pathname.startsWith('/about');
5253
const isGraphPage = location.pathname.startsWith('/graph');
5354
const isTerminalPage = location.pathname.startsWith('/terminal');
55+
const isSnfPage = location.pathname.startsWith('/snf');
5456

5557
// Check for special project styles that require hiding the default layout
5658
const projectSlug = location.pathname.startsWith('/projects/')
@@ -80,10 +82,13 @@ const Layout = ({
8082
isSpecialProject ||
8183
isTheVaguePage ||
8284
isTerminalPage ||
85+
isSnfPage ||
8386
(isAppDetail && isAppFullscreen);
8487

8588
const mainContent = location.pathname.startsWith('/stories') ? (
8689
<DndProvider>{children}</DndProvider>
90+
) : isSnfPage ? (
91+
<SnfProvider>{children}</SnfProvider>
8792
) : (
8893
<div
8994
className={`${
@@ -165,8 +170,8 @@ const Layout = ({
165170

166171
return (
167172
<>
168-
<Banner />
169-
<FalloutOverlay />
173+
{!isSnfPage && <Banner />}
174+
{!isSnfPage && <FalloutOverlay />}
170175
{isGarden && !hideLayout && <DigitalFlowers />}
171176
{isAutumn && !hideLayout && <DigitalLeaves />}
172177
{isRain && !hideLayout && <NaturalRain />}
@@ -179,7 +184,7 @@ const Layout = ({
179184
/>
180185
{!hideLayout && <SidePanel />}
181186
{mainContent}
182-
<SyntaxSprite />
187+
{!isSnfPage && <SyntaxSprite />}
183188
</>
184189
);
185190
};
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { useEffect, useRef } from 'react';
2+
import { useSnf } from '../../context/SnfContext';
3+
4+
/**
5+
* Asset-free ambient audio for the terminal, synthesised with the Web Audio API.
6+
* - hum : layered low oscillators through a lowpass (electrical drone)
7+
* - static : white noise through a highpass (dead-channel hiss)
8+
* - rain : filtered noise with slow amplitude flutter (acid rain)
9+
*
10+
* Never autoplays — it only starts once the user enables `audio`, which is a
11+
* user gesture, satisfying browser autoplay policy.
12+
*/
13+
const SnfAmbientAudio = () => {
14+
const { settings } = useSnf();
15+
const ctxRef = useRef(null);
16+
const masterRef = useRef(null);
17+
const nodesRef = useRef([]);
18+
19+
const teardown = () => {
20+
nodesRef.current.forEach((n) => {
21+
try {
22+
n.stop?.();
23+
n.disconnect?.();
24+
} catch {
25+
/* ignore */
26+
}
27+
});
28+
nodesRef.current = [];
29+
};
30+
31+
// Build / rebuild the graph when the master switch or track changes.
32+
useEffect(() => {
33+
if (!settings.audio) {
34+
teardown();
35+
return undefined;
36+
}
37+
const AudioCtx = window.AudioContext || window.webkitAudioContext;
38+
if (!AudioCtx) return undefined;
39+
40+
if (!ctxRef.current) {
41+
ctxRef.current = new AudioCtx();
42+
masterRef.current = ctxRef.current.createGain();
43+
masterRef.current.connect(ctxRef.current.destination);
44+
}
45+
const ctx = ctxRef.current;
46+
const master = masterRef.current;
47+
master.gain.value = settings.volume;
48+
ctx.resume?.();
49+
50+
teardown();
51+
const made = [];
52+
53+
const makeNoise = () => {
54+
const buffer = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
55+
const data = buffer.getChannelData(0);
56+
for (let i = 0; i < data.length; i += 1) data[i] = Math.random() * 2 - 1;
57+
const src = ctx.createBufferSource();
58+
src.buffer = buffer;
59+
src.loop = true;
60+
return src;
61+
};
62+
63+
if (settings.audioTrack === 'hum') {
64+
const lp = ctx.createBiquadFilter();
65+
lp.type = 'lowpass';
66+
lp.frequency.value = 220;
67+
lp.connect(master);
68+
[55, 82.5, 110].forEach((freq, idx) => {
69+
const osc = ctx.createOscillator();
70+
osc.type = idx === 2 ? 'sine' : 'triangle';
71+
osc.frequency.value = freq;
72+
const g = ctx.createGain();
73+
g.gain.value = idx === 0 ? 0.5 : 0.18;
74+
osc.connect(g);
75+
g.connect(lp);
76+
osc.start();
77+
made.push(osc, g);
78+
});
79+
// slow tremolo
80+
const lfo = ctx.createOscillator();
81+
lfo.frequency.value = 0.15;
82+
const lfoGain = ctx.createGain();
83+
lfoGain.gain.value = 60;
84+
lfo.connect(lfoGain);
85+
lfoGain.connect(lp.frequency);
86+
lfo.start();
87+
made.push(lfo, lfoGain, lp);
88+
} else if (settings.audioTrack === 'static') {
89+
const noise = makeNoise();
90+
const hp = ctx.createBiquadFilter();
91+
hp.type = 'highpass';
92+
hp.frequency.value = 1200;
93+
const g = ctx.createGain();
94+
g.gain.value = 0.12;
95+
noise.connect(hp);
96+
hp.connect(g);
97+
g.connect(master);
98+
noise.start();
99+
made.push(noise, hp, g);
100+
} else {
101+
// rain
102+
const noise = makeNoise();
103+
const lp = ctx.createBiquadFilter();
104+
lp.type = 'lowpass';
105+
lp.frequency.value = 1600;
106+
const g = ctx.createGain();
107+
g.gain.value = 0.22;
108+
noise.connect(lp);
109+
lp.connect(g);
110+
g.connect(master);
111+
noise.start();
112+
// flutter
113+
const lfo = ctx.createOscillator();
114+
lfo.frequency.value = 0.8;
115+
const lfoGain = ctx.createGain();
116+
lfoGain.gain.value = 0.08;
117+
lfo.connect(lfoGain);
118+
lfoGain.connect(g.gain);
119+
lfo.start();
120+
made.push(noise, lp, g, lfo, lfoGain);
121+
}
122+
123+
nodesRef.current = made;
124+
return undefined;
125+
// eslint-disable-next-line react-hooks/exhaustive-deps
126+
}, [settings.audio, settings.audioTrack]);
127+
128+
// Live volume changes.
129+
useEffect(() => {
130+
if (masterRef.current) masterRef.current.gain.value = settings.volume;
131+
}, [settings.volume]);
132+
133+
// Cleanup on unmount.
134+
useEffect(
135+
() => () => {
136+
teardown();
137+
try {
138+
ctxRef.current?.close();
139+
} catch {
140+
/* ignore */
141+
}
142+
ctxRef.current = null;
143+
},
144+
[],
145+
);
146+
147+
return null;
148+
};
149+
150+
export default SnfAmbientAudio;

0 commit comments

Comments
 (0)