Skip to content

Commit bfa55da

Browse files
authored
Discovery website (#2)
1 parent 9d0ebc6 commit bfa55da

File tree

8 files changed

+3316
-138
lines changed

8 files changed

+3316
-138
lines changed

public/404.html

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>404 - Page Not Found</title>
7+
<link rel="stylesheet" href="./assets/styles.css">
8+
<style>
9+
body {
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
min-height: 100vh;
14+
background-color: var(--bg-dark);
15+
margin: 0;
16+
padding: 2rem;
17+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18+
}
19+
20+
.logo-button {
21+
position: fixed;
22+
top: 1rem;
23+
left: 1.5rem;
24+
display: block;
25+
width: 4rem;
26+
height: 4rem;
27+
cursor: pointer;
28+
text-decoration: none;
29+
z-index: 10;
30+
}
31+
32+
.logo-button svg {
33+
width: 100%;
34+
height: 100%;
35+
}
36+
37+
.error-container {
38+
text-align: center;
39+
}
40+
41+
.error-eyebrow {
42+
display: inline-flex;
43+
align-items: center;
44+
gap: 0.5rem;
45+
font-size: 0.875rem;
46+
font-weight: 600;
47+
color: var(--text-secondary);
48+
margin-bottom: 1.5rem;
49+
letter-spacing: 0.05em;
50+
text-transform: uppercase;
51+
}
52+
53+
.error-eyebrow svg {
54+
width: 1rem;
55+
height: 1rem;
56+
fill: var(--text-secondary);
57+
}
58+
59+
.error-code {
60+
font-size: clamp(6rem, 20vw, 15rem);
61+
font-weight: 200;
62+
line-height: .65;
63+
margin: 0 0 2rem 0;
64+
color: var(--primary);
65+
letter-spacing: -0.04em;
66+
}
67+
68+
.error-title {
69+
font-size: clamp(1.5rem, 5vw, 3rem);
70+
font-weight: 200;
71+
margin: 0;
72+
color: var(--text-primary);
73+
letter-spacing: -0.02em;
74+
}
75+
</style>
76+
</head>
77+
<body>
78+
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden;">
79+
<symbol id="elephant" viewBox="0 0 167 114">
80+
<style>
81+
path {
82+
--face: var(--color, currentColor);
83+
--ears: color-mix(in oklch, var(--face), #000 10%);
84+
--sun: color-mix(in oklch, var(--face), #000 4%);
85+
}
86+
</style>
87+
<path fill="#030D14" d="M78 35h49v27H78z"/>
88+
<path fill="var(--ears)"
89+
d="M160.5 61.3c4-9.3 4.8-35-10.2-37.3-7.8-1.2-12.9 1-18.4 6.6-4.5 4.5-7.3 28.4-6.3 33.5 1.2 6 .5 2.7 10.7 6.8 11.3 4.6 19.4 1.7 24.2-9.6ZM48.6 44c-2.7-9.8 5-40.3 20.8-36.1 6.8 1.8 10.2 4.6 13.9 11.6 3.1 6-4.8 9.7-7.3 16.2-2.9 7.5 2 19-6 19.8-9.4 1-18.2.3-21.4-11.5Z"/>
90+
<path fill="var(--face)"
91+
d="M84.5 16.4c9-6 16.4-3.3 26.2-1.2 9.1 2 19.9 3.9 25 14.1 3.3 6.5 2.8 11.3 1.8 18.2a43.2 43.2 0 0 1-6.5 16.1 46.4 46.4 0 0 1-4.4 6 13.1 13.1 0 0 1-3.7 3 17.8 17.8 0 0 1-6.3 1.5h-.4l-3.3 3.3-8 11.7-.1.3-.6 1c-.5 1-1.1 2.1-2 3.4a26.6 26.6 0 0 1-6.3 7.4 23.5 23.5 0 0 1-23.3 2.9c-4-1.4-7-3-9.7-5.5a27.5 27.5 0 0 1-6.2-9 50.4 50.4 0 0 1-2.1-5.3 11 11 0 0 1-.6-2.5 3.4 3.4 0 0 1 .5-2.2c1-1.4 2.3-1.5 3.4-1.5s1.9 0 2.7-.3c.7-.3 1-.8 1.5-1.5l1-1a3.3 3.3 0 0 1 1.8-.5h2c.7.2 1.2.7 1.8 1.3a5.2 5.2 0 0 1 1 3.4c.1 1.1 0 2 .4 2.8a12 12 0 0 0 2.7 4.5 4.8 4.8 0 0 0 4 1.4 4.6 4.6 0 0 0 3.4-1.8 14 14 0 0 0 2-4.7c.4-1.8 0-4.6-.5-7a44.2 44.2 0 0 0-1-4v-.1l-6-8.6v-.1l.9-.4-1 .4a16.2 16.2 0 0 1-.4-1.2 47.5 47.5 0 0 1-3.1-14c-.5-7.9-.7-13.4 3.4-20.5 2.6-4.5 5.4-6.8 9.9-9.8ZM59 79Zm.4 0Zm2.3-.9a3.7 3.7 0 0 1-1.7.8 3.8 3.8 0 0 0 1.8-.8Zm1.6-1.7Zm.1-.1Zm59-4.6h-.2.3l.1-.1h-.1Zm-4.8-27.2c-5.4-1.6-10.2 7.9-11.2 10-.1.2 0 .5.4.4 1.5-.2 5.2-.6 7.8 0 3.1.8 7.7 4.4 7.7 4.4s2.6-12.6-4.7-14.8Zm-44.2 9.9a48.4 48.4 0 0 0-.2-.6l.2.6Zm17.3-15c-5.5-1.6-10.4 7.3-11.2 9.2-.1.2 0 .4.3.4 1.4 0 5.2 0 7.9.6 3.1.7 7.8 3.7 7.8 3.7s2.5-11.6-4.8-13.8Zm-18.3 10 .3 1.7-.1-.7-.2-1Zm0-.6v.2-.2Zm-.4-6Zm0-1Zm-.1-1v-1 1ZM132.5 26l-.3-.4.3.4Zm-.4-.5Zm-.4-.4Zm-.4-.4Zm-13.8-7a65 65 0 0 1-.9-.3l1 .3Zm-27.4-3.1.4-.2-.4.2ZM71.9 41.2v.3-.6.3Z"/>
92+
<path fill="var(--sun)"
93+
d="M85 17.2c8.6-5 15.5-3.1 25.5-1 5 1-8.4 29-8.5 29 0 .1-3.8 8.8-4 14.7 0 3.8.5 5 .5 8.9 0 4.5.9 8-.8 12.3-2.7 6.5-6 11.6-12 13.3-5 1.5-13-1.8-13-1.8s-6.1-4-8.4-7.8c-1.2-2.2-2.2-6-2.2-6s.8-3 2.9-3c1.5-.1 2-.2 3 1 1.3 1.5.4 3.9 1.2 5.8 1.3 3.7 3.8 7 7.8 6.6 3.7-.4 5-3 6-7.3a26 26 0 0 0-1.5-11.7l-5.8-8.7s-3.2-9-3.5-14.8c-.5-8-.8-13.3 3.3-20 2.7-4.5 5-6.8 9.6-9.5Zm5.7 22.4c-5.5-1.7-10.4 7.2-11.2 9-.1.3 0 .6.3.6 1.4 0 5.2 0 7.9.5 3.1.7 7.8 3.7 7.8 3.7s2.5-11.6-4.8-13.8Z"/>
94+
<path fill="#fff"
95+
d="M76.2 60.2c-.6-.3-1.2-.3-1.8-.1-1 .2-3 3-5 4.4-2.4 1.6-6.1 2.4-6.5 3.1-.2.4-.1.6 0 1 .1.8.7 1 1.3 1.5a9.3 9.3 0 0 0 4.3 2c2 .6 3 .4 5 .2 3-.3 7.5-2.7 7.5-2.7s-1-7.2-4.9-9.4ZM116 72.8c1-.2 2.7.1 2.7.1s1.6 3.3 3 5.1c1.8 2.2 5.4 4.7 5.4 4.7v1.1c-.4.8-1.9 1-1.9 1s-2.7.7-4.5.6a14.7 14.7 0 0 1-6.1-1.3c-2.4-1-4.8-4.2-4.8-4.2s2.5-6.6 6.2-7.1Z"/>
96+
</symbol>
97+
<symbol id="logo" viewBox="0 0 167 114">
98+
<style>
99+
.g {
100+
transform-origin: center;
101+
filter: drop-shadow(0 0 2px #0006);
102+
}
103+
.l { color: #D75348; filter: blur(1px); }
104+
.r { color: #2EAD32; }
105+
</style>
106+
<g class="g" transform="translate(-45, 6) rotate(-29) scale(0.74) ">
107+
<use href="#elephant" class="e l"/>
108+
</g>
109+
<g class="g">
110+
<use href="#elephant" class="e r"/>
111+
</g>
112+
</symbol>
113+
</svg>
114+
115+
<a href="/" class="logo-button" title="Home">
116+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 167 114">
117+
<use href="#logo"/>
118+
</svg>
119+
</a>
120+
121+
<div class="error-container">
122+
<div class="error-eyebrow">
123+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
124+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v2h2zm0-6h-2v6h2zm0-6h-2v2h2z"/>
125+
</svg>
126+
Error
127+
</div>
128+
<div class="error-code">404</div>
129+
<h1 class="error-title">Page Not Found</h1>
130+
</div>
131+
</body>
132+
</html>

public/apple-touch-icon.png

-7.6 KB
Loading

public/assets/script.js

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { Application, Controller } from 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js';
2+
3+
const application = Application.start();
4+
application.debug = true;
5+
6+
class ScrollObserverController extends Controller {
7+
initialize() {
8+
if (typeof IntersectionObserver !== 'function') {
9+
this.observer = null;
10+
return;
11+
}
12+
13+
this.observer = new IntersectionObserver((entries) => {
14+
entries.forEach((entry) => {
15+
const eventName = entry.isIntersecting ? 'enter' : 'leave';
16+
this.dispatch(eventName, { prefix: 'scroll' });
17+
});
18+
}, {
19+
root: null,
20+
threshold: 0,
21+
rootMargin: '-40% 0px -55% 0px',
22+
});
23+
}
24+
25+
connect() {
26+
this.observer?.observe(this.element);
27+
}
28+
29+
disconnect() {
30+
this.observer?.disconnect();
31+
}
32+
}
33+
34+
class CopyController extends Controller {
35+
static targets = ['text', 'button'];
36+
37+
connect() {
38+
this.resetTimer = null;
39+
}
40+
41+
disconnect() {
42+
if (this.resetTimer) {
43+
clearTimeout(this.resetTimer);
44+
}
45+
}
46+
47+
async copy(event) {
48+
event?.preventDefault?.();
49+
if (!navigator.clipboard || !this.hasTextTarget || !this.hasButtonTarget) return;
50+
51+
const text = this.textTarget.textContent?.trim();
52+
if (!text) return;
53+
54+
try {
55+
await navigator.clipboard.writeText(text);
56+
this.showFeedback();
57+
} catch (error) {
58+
console.error('Unable to copy command', error);
59+
}
60+
}
61+
62+
showFeedback() {
63+
this.buttonTarget.classList.add('copied');
64+
clearTimeout(this.resetTimer);
65+
this.resetTimer = window.setTimeout(() => {
66+
this.buttonTarget.classList.remove('copied');
67+
this.resetTimer = null;
68+
}, 1500);
69+
}
70+
}
71+
72+
class NavigationController extends Controller {
73+
static targets = ['navButton', 'downLink'];
74+
75+
connect() {
76+
this.currentButton = null;
77+
}
78+
79+
to(event) {
80+
const candidate = this.resolveButton(event);
81+
if (!candidate) return;
82+
83+
if (event?.type === 'scroll:enter') {
84+
this.setCurrentButton(candidate);
85+
}
86+
// clicks fall through and rely on scroll observer to update active state
87+
}
88+
89+
resolveButton(event) {
90+
const number = event?.params?.number ??
91+
event?.currentTarget?.dataset?.navigationNumberParam ??
92+
event?.target?.dataset?.navigationNumberParam;
93+
if (number !== undefined) {
94+
return this.findButtonByNumber(number);
95+
}
96+
return null;
97+
}
98+
99+
setCurrentButton(button) {
100+
if (!button) {
101+
this.updateDownLink(null);
102+
return;
103+
}
104+
105+
if (button === this.currentButton) {
106+
this.updateDownLink(button);
107+
this.notifyStage(button);
108+
return;
109+
}
110+
111+
this.toggleButton(this.currentButton, false);
112+
this.toggleButton(button, true);
113+
this.currentButton = button;
114+
this.updateDownLink(button);
115+
this.notifyStage(button);
116+
}
117+
118+
notifyStage(button) {
119+
const scene = button?.dataset.stageScene;
120+
if (!scene) return;
121+
window.dispatchEvent(new CustomEvent('stage:change', { detail: { scene } }));
122+
}
123+
124+
toggleButton(button, isActive) {
125+
if (!button) return;
126+
if (isActive) {
127+
button.setAttribute('aria-current', 'page');
128+
} else {
129+
button.removeAttribute('aria-current');
130+
}
131+
}
132+
133+
findButtonByNumber(number) {
134+
if (number === undefined || number === null) return null;
135+
const lookup = Number(number);
136+
if (Number.isNaN(lookup)) return null;
137+
return this.navButtonTargets.find(
138+
(button) => Number(button.dataset.navigationNumberParam) === lookup,
139+
) || null;
140+
}
141+
142+
updateDownLink(currentButton) {
143+
if (!this.hasDownLinkTarget) return;
144+
if (!currentButton) {
145+
this.downLinkTarget.removeAttribute('href');
146+
this.downLinkTarget.setAttribute('aria-disabled', 'true');
147+
return;
148+
}
149+
150+
const currentIndex = this.navButtonTargets.indexOf(currentButton);
151+
if (currentIndex === -1) return;
152+
const nextButton = this.navButtonTargets[currentIndex + 1] || null;
153+
if (nextButton) {
154+
this.downLinkTarget.setAttribute('href', nextButton.getAttribute('href') || '#');
155+
this.downLinkTarget.removeAttribute('aria-disabled');
156+
} else {
157+
this.downLinkTarget.removeAttribute('href');
158+
this.downLinkTarget.setAttribute('aria-disabled', 'true');
159+
}
160+
}
161+
}
162+
163+
class StageController extends Controller {
164+
static targets = ['scene'];
165+
166+
connect() {
167+
this.handleChange = (event) => this.show(event.detail?.scene);
168+
window.addEventListener('stage:change', this.handleChange);
169+
this.typingInterval = null;
170+
const initialScene = this.sceneTargets[0]?.dataset.sceneId;
171+
if (initialScene) {
172+
this.show(initialScene);
173+
}
174+
}
175+
176+
disconnect() {
177+
window.removeEventListener('stage:change', this.handleChange);
178+
this.stopTyping();
179+
}
180+
181+
show(sceneId) {
182+
if (!sceneId) return;
183+
this.stopTyping();
184+
185+
let matched = false;
186+
this.sceneTargets.forEach((scene) => {
187+
const isActive = scene.dataset.sceneId === sceneId;
188+
scene.classList.toggle('stage-scene--active', isActive);
189+
if (isActive) {
190+
matched = true;
191+
if (sceneId === 'interact') {
192+
this.startTyping(scene);
193+
}
194+
}
195+
});
196+
if (!matched && this.sceneTargets.length) {
197+
const fallback = this.sceneTargets[0];
198+
fallback.classList.add('stage-scene--active');
199+
}
200+
}
201+
202+
startTyping(scene) {
203+
const textEl = scene.querySelector('.stage-typed-text');
204+
if (!textEl) return;
205+
206+
const fullText = '••••';
207+
let currentIndex = 0;
208+
209+
const typeChar = () => {
210+
if (currentIndex <= fullText.length) {
211+
textEl.textContent = fullText.substring(0, currentIndex);
212+
currentIndex++;
213+
} else {
214+
// Pause then restart
215+
setTimeout(() => {
216+
currentIndex = 0;
217+
textEl.textContent = '';
218+
}, 1000);
219+
}
220+
};
221+
222+
// Start typing after a short delay
223+
setTimeout(() => {
224+
typeChar();
225+
this.typingInterval = setInterval(typeChar, 150);
226+
}, 300);
227+
}
228+
229+
stopTyping() {
230+
if (this.typingInterval) {
231+
clearInterval(this.typingInterval);
232+
this.typingInterval = null;
233+
}
234+
}
235+
}
236+
237+
application.register('scroll-observer', ScrollObserverController);
238+
application.register('copy', CopyController);
239+
application.register('navigation', NavigationController);
240+
application.register('stage', StageController);

0 commit comments

Comments
 (0)