Skip to content

Commit 49e52f4

Browse files
committed
some fixes
1 parent 71acacb commit 49e52f4

4 files changed

Lines changed: 131 additions & 4 deletions

File tree

src/components/Hero.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const mottoHalf = Array.from({ length: 4 })
7272
</div>
7373
<pre
7474
class="crest__status terminal__body font-mono"
75+
style={`--terminal-lines:${hero.terminalLines.length}`}
7576
aria-hidden="true"><code set:html={terminalHtml} /></pre>
7677
</aside>
7778
</div>
@@ -228,6 +229,7 @@ const mottoHalf = Array.from({ length: 4 })
228229
/* ── The hero emblem — the GitHub octocat mark on a soft gold halo, with a
229230
slim 3-line mono "mission status" caption below it. */
230231
.hero__crest {
232+
--reveal-y: 0px;
231233
align-self: center;
232234
width: 100%;
233235
padding-top: 0;
@@ -285,6 +287,7 @@ const mottoHalf = Array.from({ length: 4 })
285287
font-size: clamp(0.66rem, 0.6rem + 0.28vw, 0.78rem);
286288
line-height: 1.75;
287289
letter-spacing: 0.01em;
290+
min-block-size: calc(var(--terminal-lines, 4) * 1.75em);
288291
white-space: pre-wrap;
289292
word-break: break-word;
290293
color: var(--fg-faint);

src/scripts/app.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,50 @@ function initTerminal() {
280280
const spans = Array.from(code.querySelectorAll<HTMLElement>('span'));
281281
if (spans.length === 0) return;
282282
const lines = spans.map((el) => ({ el, text: el.textContent ?? '' }));
283+
const terminal = code.closest<HTMLElement>('.terminal__body');
284+
let terminalPrepared = false;
285+
const reserveTerminalHeight = () => {
286+
if (!terminal) return;
287+
const width = terminal.getBoundingClientRect().width;
288+
const clone = terminal.cloneNode(true) as HTMLElement;
289+
clone.querySelectorAll<HTMLElement>('span').forEach((span, i) => {
290+
span.textContent = lines[i]?.text ?? '';
291+
});
292+
clone.style.position = 'absolute';
293+
clone.style.visibility = 'hidden';
294+
clone.style.pointerEvents = 'none';
295+
clone.style.left = '-9999px';
296+
clone.style.top = '0';
297+
clone.style.width = `${width}px`;
298+
clone.style.blockSize = 'auto';
299+
clone.style.minBlockSize = '0';
300+
terminal.after(clone);
301+
const height = clone.getBoundingClientRect().height;
302+
clone.remove();
303+
if (height > 0) terminal.style.minBlockSize = `${height}px`;
304+
};
305+
const prepareTerminal = () => {
306+
if (terminalPrepared) return Promise.resolve();
307+
const markPrepared = () => {
308+
reserveTerminalHeight();
309+
terminalPrepared = true;
310+
};
311+
if ('fonts' in document) {
312+
return Promise.race([
313+
document.fonts.ready,
314+
new Promise<void>((resolve) => window.setTimeout(resolve, 700)),
315+
]).then(
316+
() => markPrepared(),
317+
() => markPrepared()
318+
);
319+
}
320+
markPrepared();
321+
return Promise.resolve();
322+
};
323+
reserveTerminalHeight();
324+
if ('fonts' in document) {
325+
void document.fonts.ready.then(reserveTerminalHeight);
326+
}
283327
const lastEl = spans[spans.length - 1];
284328
const setCaret = (el: HTMLElement) => {
285329
spans.forEach((s) => s.classList.remove('caret'));
@@ -298,7 +342,10 @@ function initTerminal() {
298342
};
299343
(window as Window & { __finishTerminal?: () => void }).__finishTerminal = finish;
300344

301-
if (reduceMotion) return; // keep the static, fully-typed terminal
345+
if (reduceMotion) {
346+
void prepareTerminal();
347+
return; // keep the static, fully-typed terminal
348+
}
302349

303350
const typeAll = () => {
304351
if (done) return;
@@ -331,16 +378,19 @@ function initTerminal() {
331378
};
332379
typeLine();
333380
};
381+
const startTypeAll = () => {
382+
void prepareTerminal().then(typeAll);
383+
};
334384

335385
if (!('IntersectionObserver' in window)) {
336-
typeAll();
386+
startTypeAll();
337387
return;
338388
}
339389
const io = new IntersectionObserver(
340390
(entries, obs) => {
341391
entries.forEach((entry) => {
342392
if (entry.isIntersecting) {
343-
typeAll();
393+
startTypeAll();
344394
obs.unobserve(entry.target);
345395
}
346396
});

src/styles/global.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ p {
645645
for crawlers / a fast no-JS first paint — everything is visible immediately. */
646646
:root.js [data-reveal] {
647647
opacity: 0;
648-
transform: translateY(18px);
648+
transform: translateY(var(--reveal-y, 18px));
649649
transition:
650650
opacity 0.7s var(--ease-out),
651651
transform 0.7s var(--ease-out);

tests/functional.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,80 @@ test.describe('Home page · structure & SEO', () => {
143143
await expect(page.locator('#hero-heading')).toBeVisible();
144144
});
145145

146+
test('hero terminal keeps its anchor when the typewriter clears text @desktop', async ({
147+
page,
148+
}) => {
149+
await page.goto('/');
150+
await prepareForResponsiveAudit(page);
151+
await page.locator('.crest__status').waitFor({ state: 'visible' });
152+
await page.waitForFunction(() =>
153+
document.querySelector<HTMLElement>('.crest__status')?.style.minBlockSize.endsWith('px')
154+
);
155+
156+
const samples = await page.evaluate(async () => {
157+
(window as Window & { __finishTerminal?: () => void }).__finishTerminal?.();
158+
const crest = document.querySelector<HTMLElement>('.hero__crest');
159+
const status = document.querySelector<HTMLElement>('.crest__status');
160+
const code = document.querySelector<HTMLElement>('.terminal__body code');
161+
if (!crest || !status || !code) return null;
162+
163+
const spans = Array.from(code.querySelectorAll<HTMLElement>('span'));
164+
const originalText = spans.map((span) => span.textContent ?? '');
165+
const measure = () => {
166+
const crestRect = crest.getBoundingClientRect();
167+
const statusRect = status.getBoundingClientRect();
168+
return {
169+
crestTop: crestRect.top,
170+
statusTop: statusRect.top,
171+
statusHeight: statusRect.height,
172+
};
173+
};
174+
175+
const frames: Array<ReturnType<typeof measure> & { textLength: number }> = [];
176+
frames.push({ ...measure(), textLength: code.textContent?.length ?? 0 });
177+
178+
spans.forEach((span) => {
179+
span.textContent = '';
180+
span.classList.remove('caret');
181+
});
182+
spans[0]?.classList.add('caret');
183+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
184+
frames.push({ ...measure(), textLength: code.textContent?.length ?? 0 });
185+
186+
if (spans[0]) spans[0].textContent = originalText[0]?.slice(0, 8) ?? '';
187+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
188+
frames.push({ ...measure(), textLength: code.textContent?.length ?? 0 });
189+
190+
spans.forEach((span, i) => {
191+
span.textContent = originalText[i] ?? '';
192+
});
193+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
194+
frames.push({ ...measure(), textLength: code.textContent?.length ?? 0 });
195+
196+
return frames;
197+
});
198+
199+
expect(samples).not.toBeNull();
200+
const frames = samples ?? [];
201+
const heightDelta =
202+
Math.max(...frames.map((frame) => frame.statusHeight)) -
203+
Math.min(...frames.map((frame) => frame.statusHeight));
204+
const crestTopDelta =
205+
Math.max(...frames.map((frame) => frame.crestTop)) -
206+
Math.min(...frames.map((frame) => frame.crestTop));
207+
const statusTopDelta =
208+
Math.max(...frames.map((frame) => frame.statusTop)) -
209+
Math.min(...frames.map((frame) => frame.statusTop));
210+
const typedDelta =
211+
Math.max(...frames.map((frame) => frame.textLength)) -
212+
Math.min(...frames.map((frame) => frame.textLength));
213+
214+
expect(heightDelta).toBeLessThanOrEqual(1);
215+
expect(crestTopDelta).toBeLessThanOrEqual(1);
216+
expect(statusTopDelta).toBeLessThanOrEqual(1);
217+
expect(typedDelta).toBeGreaterThan(10);
218+
});
219+
146220
test('tab-away title is human-readable and restores the SEO title', async ({ page }) => {
147221
await page.addInitScript(() => {
148222
Object.defineProperty(navigator, 'webdriver', { configurable: true, get: () => false });

0 commit comments

Comments
 (0)