Merge nucleic/dusky-north-quail-hesw into main
Mirrored from corpo 71479be.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.wrangler/
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Blakeslee Research website
|
||||||
|
|
||||||
|
The Blakeslee Research organization site is a static Astro website deployed to Cloudflare Workers.
|
||||||
|
|
||||||
|
This tree is developed in the private `blakeslee-research/corpo` repository under
|
||||||
|
`website/`, and mirrored read-only to the public `blakeslee-research/website`
|
||||||
|
repository. Send changes to the private repository; commits pushed to the public
|
||||||
|
mirror are overwritten on the next sync.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
From this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The production build is written to `dist/`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deployment runs from the private repository only. Configure these Gitea
|
||||||
|
repository secrets on `corpo` before deploying:
|
||||||
|
|
||||||
|
- `CLOUDFLARE_API_TOKEN`: a Cloudflare API token with **Workers Scripts: Edit** permission.
|
||||||
|
- `CLOUDFLARE_ACCOUNT_ID`: the Cloudflare account ID that owns the Worker and domain.
|
||||||
|
|
||||||
|
Pushes to `main` that change the website or its deployment workflow deploy automatically through `.gitea/workflows/deploy.yml`.
|
||||||
|
|
||||||
|
The custom domain `blakeslee.xyz` must be an active zone in the same Cloudflare account so the `custom_domain` route can be provisioned. The deployment also provisions `www.blakeslee.xyz`; remove that route from `wrangler.jsonc` if `www` is handled elsewhere.
|
||||||
|
|
||||||
|
To deploy manually after building:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://blakeslee.xyz',
|
||||||
|
output: 'static',
|
||||||
|
compressHTML: true,
|
||||||
|
});
|
||||||
Generated
+6736
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "blakeslee-research-website",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"profile:runtime": "node scripts/profile-runtime.mjs",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"astro": "^5.18.2",
|
||||||
|
"puppeteer-core": "24.16.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377.8 392.0" width="378" height="392" role="img" aria-label="Wolf head and pine tree mark">
|
||||||
|
<path fill="#c6d8c6" fill-rule="evenodd" d="M241.1 0.0L240.2 0.5L235.0 17.5L234.0 17.4L230.9 8.9L229.9 7.8L229.1 5.4L228.2 5.1L223.0 17.6L217.0 28.5L210.0 39.4L196.0 56.5L185.8 66.9L176.6 74.9L161.8 86.0L160.9 85.8L169.0 77.1L179.8 63.9L187.0 52.2L186.9 49.8L183.9 43.4L176.8 31.6L169.8 21.5L169.1 21.9L166.0 29.6L156.0 47.5L143.0 66.5L137.0 73.6L130.6 80.0L124.6 84.8L92.5 105.2L88.2 109.6L83.2 117.5L80.9 122.4L77.6 126.0L71.5 129.8L60.8 135.0L38.4 145.0L16.2 154.0L0.5 159.1L0.0 159.8L0.1 161.1L2.4 165.6L8.2 174.5L13.1 180.0L19.5 184.1L22.0 186.5L29.2 197.6L31.5 199.9L34.6 201.0L67.5 201.1L86.6 203.2L95.8 206.1L103.5 210.0L108.6 214.1L113.9 219.8L117.0 224.4L120.0 231.6L121.1 240.1L121.0 247.4L119.9 255.0L117.0 267.4L114.6 274.8L115.8 274.8L121.6 270.0L132.0 259.1L133.1 259.4L133.0 262.5L128.0 284.9L123.0 301.0L117.2 315.4L117.4 316.1L119.6 315.0L127.0 308.0L134.8 299.6L144.9 284.6L158.9 259.8L175.2 226.0L183.0 211.6L182.1 210.4L173.1 208.9L152.0 202.0L126.8 191.1L123.6 189.2L122.2 189.1L121.2 188.0L123.0 187.2L128.4 187.5L138.5 186.9L148.9 185.0L159.6 181.9L168.8 177.9L179.8 171.8L192.5 162.8L203.9 151.9L212.9 141.2L217.8 133.4L202.0 135.0L181.4 135.0L180.8 134.1L184.5 131.9L190.8 126.9L202.8 114.8L207.9 107.6L212.8 98.8L216.0 91.0L217.0 87.1L215.5 87.1L205.6 92.0L190.0 98.0L177.2 101.5L177.0 100.8L191.5 89.8L206.8 75.6L219.9 59.5L231.4 41.9L232.0 42.2L231.9 57.2L232.8 76.1L232.0 92.6L229.9 108.5L226.2 119.9L227.6 120.0L235.2 116.1L244.5 112.1L246.4 111.9L247.4 111.0L248.2 111.4L248.2 112.0L237.8 133.6L225.9 152.6L212.9 168.8L201.6 180.1L197.1 183.5L197.2 184.5L211.0 182.9L222.9 179.0L235.6 172.0L246.8 163.8L250.5 160.0L251.2 160.2L249.0 171.6L248.2 173.0L248.0 176.2L240.2 202.9L243.1 208.6L249.4 218.6L260.2 233.6L274.4 250.6L286.4 262.4L285.5 263.2L276.1 262.1L259.0 261.9L258.4 262.8L262.1 268.5L273.2 281.6L287.1 294.9L288.5 294.9L303.4 287.1L314.5 282.2L337.6 273.1L362.2 265.1L377.8 261.1L377.5 260.2L355.5 247.9L335.6 234.0L314.4 216.0L295.1 195.6L295.4 194.9L297.8 195.1L306.0 197.9L332.1 204.0L349.2 207.0L352.5 206.9L352.2 206.1L341.6 197.0L324.5 180.8L306.2 160.8L298.1 150.5L291.1 140.5L274.0 111.4L273.9 110.5L275.0 110.1L276.0 110.9L285.2 113.1L309.5 120.9L311.9 120.8L293.2 100.5L280.1 82.6L269.1 64.8L253.1 31.5L246.1 13.8L245.9 11.8L241.9 0.8Z M221.0 190.2L220.1 190.5L219.0 194.0L213.9 204.8L207.9 215.9L202.9 223.8L190.9 240.5L181.0 252.6L177.0 256.5L176.9 257.6L178.1 257.9L179.8 257.0L182.2 256.8L191.6 253.9L208.1 245.9L208.5 246.8L199.9 260.6L183.8 281.6L169.2 297.8L162.1 304.9L156.6 309.2L156.4 310.2L157.6 310.9L158.5 310.0L163.4 308.9L167.4 307.1L187.6 301.9L197.8 297.9L198.1 299.0L195.9 302.6L180.9 322.8L170.0 335.5L159.8 345.9L151.2 334.6L148.1 328.8L145.2 319.8L145.0 309.9L146.1 300.8L147.0 299.6L146.8 298.6L144.2 300.4L140.4 305.4L129.0 322.6L126.1 326.0L128.1 329.6L149.5 356.4L163.1 371.6L183.8 392.0L184.9 391.6L202.1 369.8L221.2 348.6L243.8 327.2L261.6 312.6L260.8 311.5L251.4 305.9L243.0 298.8L243.4 298.0L253.2 300.4L267.2 306.0L270.6 305.0L279.6 299.6L277.2 297.2L268.5 291.0L261.1 283.6L249.1 268.9L236.2 248.2L236.0 247.1L237.1 246.9L247.0 251.9L256.5 255.0L270.4 257.6L271.6 256.9L266.6 253.0L258.1 244.9L246.4 230.9L238.1 218.6L225.9 197.4Z M139.1 115.4L134.8 119.0L133.5 119.2L116.4 129.0L101.4 133.9L98.5 134.0L97.8 133.2L102.4 126.4L109.0 120.4L113.1 118.1L119.2 116.1L127.0 115.0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,260 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer-core';
|
||||||
|
|
||||||
|
const chromePath = process.env.CHROME_PATH;
|
||||||
|
const targetUrl = process.env.PROFILE_URL ?? 'http://127.0.0.1:4173/';
|
||||||
|
const label = process.env.PROFILE_LABEL ?? 'profile';
|
||||||
|
const steadySeconds = Number(process.env.STEADY_SECONDS ?? 12);
|
||||||
|
const memorySeconds = Number(process.env.MEMORY_SECONDS ?? 30);
|
||||||
|
const settleSeconds = Number(process.env.SETTLE_SECONDS ?? 0);
|
||||||
|
|
||||||
|
if (!chromePath) {
|
||||||
|
throw new Error('Set CHROME_PATH to a Chromium/Chrome executable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
|
function traceSummary(trace) {
|
||||||
|
const threadNames = new Map();
|
||||||
|
|
||||||
|
for (const event of trace.traceEvents) {
|
||||||
|
if (event.ph === 'M' && event.name === 'thread_name') {
|
||||||
|
threadNames.set(`${event.pid}:${event.tid}`, event.args?.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainThreads = new Set(
|
||||||
|
[...threadNames.entries()]
|
||||||
|
.filter(([, name]) => name === 'CrRendererMain')
|
||||||
|
.map(([key]) => key),
|
||||||
|
);
|
||||||
|
const completeMainEvents = trace.traceEvents.filter(
|
||||||
|
(event) =>
|
||||||
|
event.ph === 'X' &&
|
||||||
|
typeof event.dur === 'number' &&
|
||||||
|
mainThreads.has(`${event.pid}:${event.tid}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationFor = (names) =>
|
||||||
|
completeMainEvents
|
||||||
|
.filter((event) => names.has(event.name))
|
||||||
|
.reduce((total, event) => total + event.dur / 1000, 0);
|
||||||
|
const countFor = (names) =>
|
||||||
|
completeMainEvents.filter((event) => names.has(event.name)).length;
|
||||||
|
|
||||||
|
const styleNames = new Set(['RecalculateStyles', 'UpdateLayoutTree']);
|
||||||
|
const layoutNames = new Set(['Layout']);
|
||||||
|
const paintNames = new Set(['Paint']);
|
||||||
|
const prePaintNames = new Set(['PrePaint']);
|
||||||
|
const runTasks = completeMainEvents.filter((event) => event.name === 'RunTask');
|
||||||
|
const taskCandidates = runTasks.length
|
||||||
|
? runTasks
|
||||||
|
: completeMainEvents.filter((event) => event.name.includes('RunTask'));
|
||||||
|
const longTasks = taskCandidates
|
||||||
|
.filter((event) => event.dur > 50_000)
|
||||||
|
.map((event) => ({ name: event.name, durationMs: event.dur / 1000 }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainThread: {
|
||||||
|
style: { count: countFor(styleNames), durationMs: durationFor(styleNames) },
|
||||||
|
layout: { count: countFor(layoutNames), durationMs: durationFor(layoutNames) },
|
||||||
|
paint: { count: countFor(paintNames), durationMs: durationFor(paintNames) },
|
||||||
|
prePaint: { count: countFor(prePaintNames), durationMs: durationFor(prePaintNames) },
|
||||||
|
},
|
||||||
|
longTasks,
|
||||||
|
animationFrameCallbacks: {
|
||||||
|
requested: completeMainEvents.filter((event) => event.name === 'RequestAnimationFrame').length,
|
||||||
|
fired: completeMainEvents.filter((event) => event.name === 'FireAnimationFrame').length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendererProcesses(browserPid) {
|
||||||
|
const processTable = execFileSync('ps', ['-eo', 'pid=,ppid=,args='], { encoding: 'utf8' })
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
||||||
|
return match ? { pid: Number(match[1]), ppid: Number(match[2]), args: match[3] } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const descendants = new Set([browserPid]);
|
||||||
|
let changed = true;
|
||||||
|
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const process of processTable) {
|
||||||
|
if (descendants.has(process.ppid) && !descendants.has(process.pid)) {
|
||||||
|
descendants.add(process.pid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processTable.filter(
|
||||||
|
(process) => descendants.has(process.pid) && process.args.includes('--type=renderer'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processCpuTicks(processes) {
|
||||||
|
const samples = [];
|
||||||
|
for (const process of processes) {
|
||||||
|
try {
|
||||||
|
const stat = (await readFile(`/proc/${process.pid}/stat`, 'utf8')).trim().split(' ');
|
||||||
|
samples.push({
|
||||||
|
pid: process.pid,
|
||||||
|
ticks: Number(stat[13]) + Number(stat[14]),
|
||||||
|
rssPages: Number(stat[23]),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A renderer can exit between process discovery and sampling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpuDelta(before, after, elapsedSeconds) {
|
||||||
|
const ticksPerSecond = Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8' }).trim());
|
||||||
|
const afterByPid = new Map(after.map((sample) => [sample.pid, sample]));
|
||||||
|
const totalTicks = before.reduce((total, sample) => {
|
||||||
|
const end = afterByPid.get(sample.pid);
|
||||||
|
return total + (end ? Math.max(0, end.ticks - sample.ticks) : 0);
|
||||||
|
}, 0);
|
||||||
|
return {
|
||||||
|
rendererPids: before.map((sample) => sample.pid),
|
||||||
|
cpuTimeMs: (totalTicks / ticksPerSecond) * 1000,
|
||||||
|
cpuPercentOfOneCore: (totalTicks / ticksPerSecond / elapsedSeconds) * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordTrace(page, path, action) {
|
||||||
|
await page.tracing.start({
|
||||||
|
path,
|
||||||
|
categories: [
|
||||||
|
'-*',
|
||||||
|
'blink',
|
||||||
|
'cc',
|
||||||
|
'devtools.timeline',
|
||||||
|
'disabled-by-default-devtools.timeline',
|
||||||
|
'disabled-by-default-devtools.timeline.frame',
|
||||||
|
'toplevel',
|
||||||
|
'viz',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await action();
|
||||||
|
await page.tracing.stop();
|
||||||
|
return traceSummary(JSON.parse(await readFile(path, 'utf8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: chromePath,
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--enable-precise-memory-info',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2, isMobile: true });
|
||||||
|
const layerSession = await page.createCDPSession();
|
||||||
|
let lastLayers = [];
|
||||||
|
layerSession.on('LayerTree.layerTreeDidChange', ({ layers }) => {
|
||||||
|
lastLayers = layers ?? [];
|
||||||
|
});
|
||||||
|
await layerSession.send('LayerTree.enable');
|
||||||
|
|
||||||
|
const loadTrace = await recordTrace(page, `/tmp/${label}-load-trace.json`, async () => {
|
||||||
|
await page.goto(targetUrl, { waitUntil: 'networkidle0' });
|
||||||
|
await delay(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paintEntries = await page.evaluate(() =>
|
||||||
|
performance.getEntriesByType('paint').map(({ name, startTime }) => ({ name, startTime })),
|
||||||
|
);
|
||||||
|
const animationsAfterLoad = await page.evaluate(() =>
|
||||||
|
document.getAnimations().map((animation) => ({
|
||||||
|
animationName: animation.animationName,
|
||||||
|
playState: animation.playState,
|
||||||
|
iterations: Number.isFinite(animation.effect?.getTiming().iterations)
|
||||||
|
? animation.effect?.getTiming().iterations
|
||||||
|
: 'Infinity',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await delay(settleSeconds * 1000);
|
||||||
|
const animationsAtSteadyStart = await page.evaluate(() =>
|
||||||
|
document.getAnimations().map((animation) => ({
|
||||||
|
animationName: animation.animationName,
|
||||||
|
playState: animation.playState,
|
||||||
|
iterations: Number.isFinite(animation.effect?.getTiming().iterations)
|
||||||
|
? animation.effect?.getTiming().iterations
|
||||||
|
: 'Infinity',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processes = rendererProcesses(browser.process().pid);
|
||||||
|
const cpuBefore = await processCpuTicks(processes);
|
||||||
|
const steadyTrace = await recordTrace(page, `/tmp/${label}-steady-trace.json`, async () => {
|
||||||
|
await delay(steadySeconds * 1000);
|
||||||
|
});
|
||||||
|
const cpuAfter = await processCpuTicks(processes);
|
||||||
|
|
||||||
|
await layerSession.send('HeapProfiler.collectGarbage');
|
||||||
|
const heapSamples = [];
|
||||||
|
const heapStart = Date.now();
|
||||||
|
while ((Date.now() - heapStart) / 1000 <= memorySeconds) {
|
||||||
|
const { usedSize, totalSize } = await layerSession.send('Runtime.getHeapUsage');
|
||||||
|
heapSamples.push({ elapsedSeconds: (Date.now() - heapStart) / 1000, usedSize, totalSize });
|
||||||
|
await delay(Math.min(5000, Math.max(0, memorySeconds * 1000 - (Date.now() - heapStart))));
|
||||||
|
if ((Date.now() - heapStart) / 1000 >= memorySeconds) break;
|
||||||
|
}
|
||||||
|
await layerSession.send('HeapProfiler.collectGarbage');
|
||||||
|
const heapEnd = await layerSession.send('Runtime.getHeapUsage');
|
||||||
|
heapSamples.push({ elapsedSeconds: (Date.now() - heapStart) / 1000, ...heapEnd });
|
||||||
|
|
||||||
|
const styleLayoutPaintMs =
|
||||||
|
steadyTrace.mainThread.style.durationMs +
|
||||||
|
steadyTrace.mainThread.layout.durationMs +
|
||||||
|
steadyTrace.mainThread.paint.durationMs;
|
||||||
|
const result = {
|
||||||
|
targetUrl,
|
||||||
|
chromiumVersion: await browser.version(),
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true },
|
||||||
|
loadWindowSeconds: 3,
|
||||||
|
settleWindowSeconds: settleSeconds,
|
||||||
|
steadyWindowSeconds: steadySeconds,
|
||||||
|
memoryWindowSeconds: memorySeconds,
|
||||||
|
paintEntries,
|
||||||
|
animationsAfterLoad,
|
||||||
|
animationsAtSteadyStart,
|
||||||
|
loadTrace,
|
||||||
|
steadyTrace: {
|
||||||
|
...steadyTrace,
|
||||||
|
styleLayoutPaintMsPerSecond: styleLayoutPaintMs / steadySeconds,
|
||||||
|
},
|
||||||
|
layers: {
|
||||||
|
total: lastLayers.length,
|
||||||
|
drawsContent: lastLayers.filter((layer) => layer.drawsContent).length,
|
||||||
|
},
|
||||||
|
rendererCpu: cpuDelta(cpuBefore, cpuAfter, steadySeconds),
|
||||||
|
heap: {
|
||||||
|
samples: heapSamples,
|
||||||
|
startUsedBytes: heapSamples[0].usedSize,
|
||||||
|
endUsedBytes: heapEnd.usedSize,
|
||||||
|
growthBytes: heapEnd.usedSize - heapSamples[0].usedSize,
|
||||||
|
minUsedBytes: Math.min(...heapSamples.map((sample) => sample.usedSize)),
|
||||||
|
maxUsedBytes: Math.max(...heapSamples.map((sample) => sample.usedSize)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
const title = 'Page not found — Blakeslee Research';
|
||||||
|
const description = 'The page you requested does not exist.';
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="theme-color" content="#0a100c" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<link rel="icon" href="/blakeslee-icon.svg" type="image/svg+xml" sizes="any" />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/archivo.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-shell">
|
||||||
|
<main class="not-found">
|
||||||
|
<p class="code">404</p>
|
||||||
|
<p class="copy">This page doesn't exist yet.</p>
|
||||||
|
<a class="home-link" href="/">Back home <span aria-hidden="true">↗</span></a>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Archivo';
|
||||||
|
src: url('/fonts/archivo.woff2') format('woff2');
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-stretch: 62% 125%;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #eef3ee;
|
||||||
|
--secondary: #aebdae;
|
||||||
|
--sage: #c6d8c6;
|
||||||
|
--paper: #0a100c;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100svh;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
font-family: 'Archivo', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--sage);
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 1600px);
|
||||||
|
min-height: 100svh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1rem, 2.2vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(4rem, 18vw, 10rem);
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 115%;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: clamp(1rem, 2vw, 1.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-link {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.35rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
---
|
||||||
|
const title = 'Blakeslee Research — research that runs';
|
||||||
|
const description =
|
||||||
|
'Blakeslee Research engineers infrastructure that keeps AI agents—and the digital intelligence to come—forever online and forever active, including Nucleic, the home for persistent, orchestrated, observable coding agents.';
|
||||||
|
const canonical = new URL('/', Astro.site);
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<!-- Not --paper. Safari paints its toolbar and overscroll regions with this flat
|
||||||
|
colour, but the page underneath is the ambient field, which lifts the edges to
|
||||||
|
roughly #0e1813 — against paper that reads as a grey bar sitting behind the
|
||||||
|
browser chrome. Measured off rendered pixels at the top and bottom edges. -->
|
||||||
|
<meta name="theme-color" content="#0f1813" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
<link rel="icon" href="/blakeslee-icon.svg" type="image/svg+xml" sizes="any" />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/archivo.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/jetbrains-mono.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Blakeslee Research" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={new URL('/og.png', Astro.site)} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Blakeslee Research wordmark and wolf-and-pine mark" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={new URL('/og.png', Astro.site)} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient" aria-hidden="true">
|
||||||
|
<span class="ambient-haze"></span>
|
||||||
|
<span class="ambient-drift"></span>
|
||||||
|
<span class="ambient-grain"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="masthead" aria-label="Blakeslee Research">
|
||||||
|
<div class="brand-lockup">
|
||||||
|
<img
|
||||||
|
class="mark"
|
||||||
|
src="/blakeslee-icon.svg"
|
||||||
|
width="378"
|
||||||
|
height="392"
|
||||||
|
alt="Blakeslee Research wolf and pine mark"
|
||||||
|
/>
|
||||||
|
<span class="brand-name">Blakeslee<br />Research</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
|
<!-- Headline and card share one wrapper so they sit on a single flex line
|
||||||
|
that can break. Grid alone cannot: a headline too wide for its columns
|
||||||
|
keeps its area and simply prints over whatever occupies the next one. -->
|
||||||
|
<div class="hero-lead">
|
||||||
|
<h1 id="hero-heading" class="statement" aria-label="Research that runs.">
|
||||||
|
<span class="line line-research"><span class="word">Research</span></span>
|
||||||
|
<span class="line line-runs"><span class="word"><span class="that">that</span> <span class="runs">runs.</span></span></span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<a class="product-card" href="https://nucleic.blakeslee.xyz">
|
||||||
|
<span class="card-index">01 / Flagship system</span>
|
||||||
|
<span class="product-name">Nucleic <span aria-hidden="true">↗</span></span>
|
||||||
|
<span class="product-description">
|
||||||
|
Where agents live — persisted, orchestrated, observed.
|
||||||
|
</span>
|
||||||
|
<span class="card-rule" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mission">
|
||||||
|
We engineer infrastructure that keeps intelligence forever online and forever
|
||||||
|
active — immortal by design.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2026 Blakeslee Research</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="mailto:[email protected]">Contact</a>
|
||||||
|
<a href="https://version.blakeslee.xyz" target="_blank" rel="noopener noreferrer">Code <span aria-hidden="true">↗</span></a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Archivo';
|
||||||
|
src: url('/fonts/archivo.woff2') format('woff2');
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-stretch: 62% 125%;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('/fonts/jetbrains-mono.woff2') format('woff2');
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #eef3ee;
|
||||||
|
--secondary: #aebdae;
|
||||||
|
--muted: #93a494;
|
||||||
|
--pine: #3fae74;
|
||||||
|
--sage: #c6d8c6;
|
||||||
|
--forest: #144b35;
|
||||||
|
--paper: #0a100c;
|
||||||
|
--line: rgb(238 243 238 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100svh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
/* No background here: html paints the canvas, so the fixed ambient layer below
|
||||||
|
(negative z-index) is not covered by body's own background box. */
|
||||||
|
font-family: 'Archivo', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient field: two soft bands of light crossing at different angles, and a fine
|
||||||
|
grain that dithers away the banding flat gradients show on near-black. */
|
||||||
|
.ambient {
|
||||||
|
position: fixed;
|
||||||
|
z-index: -2;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Oversized so the bands can travel a long way without an edge ever entering the
|
||||||
|
frame — what should be visible is the light moving, never the thing carrying it. */
|
||||||
|
.ambient-haze,
|
||||||
|
.ambient-drift {
|
||||||
|
position: absolute;
|
||||||
|
top: -60%;
|
||||||
|
left: -60%;
|
||||||
|
width: 220%;
|
||||||
|
height: 220%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-haze {
|
||||||
|
background: linear-gradient(
|
||||||
|
106deg,
|
||||||
|
transparent 31%,
|
||||||
|
rgb(63 174 116 / 6%) 45%,
|
||||||
|
rgb(96 186 137 / 2.5%) 53%,
|
||||||
|
transparent 63%
|
||||||
|
);
|
||||||
|
transform: translate3d(-9%, 3%, 0);
|
||||||
|
animation: haze-drift 46s -7s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The second band is wider, crosses the first at a shallow angle, and runs on a
|
||||||
|
period that shares no factor with it. Where the two overlap the field lifts a
|
||||||
|
little; because 46s and 67s only realign every 51 minutes, that bright seam
|
||||||
|
wanders instead of pulsing on a beat you can count. */
|
||||||
|
.ambient-drift {
|
||||||
|
background: linear-gradient(
|
||||||
|
-24deg,
|
||||||
|
transparent 20%,
|
||||||
|
rgb(120 196 152 / 3%) 43%,
|
||||||
|
rgb(63 174 116 / 4.5%) 59%,
|
||||||
|
transparent 79%
|
||||||
|
);
|
||||||
|
transform: translate3d(7%, -5%, 0);
|
||||||
|
animation: drift-across 67s -21s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: -50%;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.022;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--sage);
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
/* One inset for everything. It is deliberately generous: at mid widths a tighter
|
||||||
|
margin puts the copy and the window edge in each other's way. */
|
||||||
|
--edge: clamp(1.35rem, 4.8vw, 5rem);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
width: min(100%, 1600px);
|
||||||
|
min-height: 100svh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1.25rem, 2.6vh, 2.5rem) var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead,
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
padding-bottom: clamp(1rem, 2vw, 1.75rem);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: block;
|
||||||
|
width: 2.65rem;
|
||||||
|
height: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-index,
|
||||||
|
.footer {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-index,
|
||||||
|
.footer p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
--hero-gap: clamp(1rem, 2vw, 2rem);
|
||||||
|
/* One column of the 12-column grid, and the spans the hero composition uses.
|
||||||
|
The flex line below is sized off these so it lands on the same lines. */
|
||||||
|
--hero-col: calc((100% - 11 * var(--hero-gap)) / 12);
|
||||||
|
--hero-span-8: calc(var(--hero-col) * 8 + 7 * var(--hero-gap));
|
||||||
|
--hero-span-4: calc(var(--hero-col) * 4 + 3 * var(--hero-gap));
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
column-gap: var(--hero-gap);
|
||||||
|
row-gap: clamp(1.9rem, 4.4vh, 4rem);
|
||||||
|
min-height: 0;
|
||||||
|
align-content: center;
|
||||||
|
padding: clamp(1.75rem, 4.5vh, 4.5rem) 0 clamp(1.5rem, 3vh, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row one of the hero, as a flex line rather than two grid areas. The two bases
|
||||||
|
add up to exactly the grid width, so at normal text size this renders where the
|
||||||
|
8/4 column split always put it — but when text alone is scaled up (Firefox's
|
||||||
|
text-only zoom, Safari's minimum font size, a large browser default) the
|
||||||
|
headline's minimum width outgrows its share, the line breaks, and the card
|
||||||
|
steps down instead of being printed over. */
|
||||||
|
.hero-lead {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
column-gap: var(--hero-gap);
|
||||||
|
row-gap: clamp(1.5rem, 3vh, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement {
|
||||||
|
/* Optical, not metric: the R carries a left side bearing, so a box that starts on
|
||||||
|
the grid line prints ~0.078em to the right of it. The mission copy below opens
|
||||||
|
on a W, whose diagonal reaches the origin with no bearing at all, so the two
|
||||||
|
only look aligned once the headline is pulled back by that much. Measured off
|
||||||
|
rendered pixels, not the font metrics, and kept in em so it holds across the
|
||||||
|
whole font-size clamp. */
|
||||||
|
--optical-inset: 0.078em;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
/* Flex sizes the border box, so the basis has to give back what the negative
|
||||||
|
margin takes or the card lands inside the eight columns. The half-pixel keeps
|
||||||
|
sub-pixel rounding in the two calc()s from ever summing past the hero width and
|
||||||
|
breaking the line at normal size; flex-grow hands it straight back. */
|
||||||
|
flex: 1 1 calc(var(--hero-span-8) + var(--optical-inset) - 0.5px);
|
||||||
|
/* Fills the row the way a stretched grid item did, so on the narrow desktop
|
||||||
|
widths where the card is the taller of the two, the headline's own rows keep
|
||||||
|
the extra height instead of collapsing to the card's baseline. */
|
||||||
|
align-self: stretch;
|
||||||
|
justify-items: start;
|
||||||
|
margin: 0 0 0 calc(-1 * var(--optical-inset));
|
||||||
|
font-size: clamp(3.2rem, min(9vw, 18vh), 8.25rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: block;
|
||||||
|
/* Clips the word's drift exactly as overflow: hidden did, without making the line
|
||||||
|
a scroll container. A scroll container reports an automatic minimum size of
|
||||||
|
zero, which told the flex line above that the headline could always be squeezed
|
||||||
|
into its columns — so it never broke, and the type printed over the card. */
|
||||||
|
clip-path: inset(0);
|
||||||
|
padding: 0 0.08em 0.1em 0;
|
||||||
|
opacity: 0;
|
||||||
|
animation: rise-in 900ms cubic-bezier(0.2, 0.75, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-research {
|
||||||
|
animation-delay: 320ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-runs {
|
||||||
|
margin-left: 2.2em;
|
||||||
|
animation-delay: 480ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word {
|
||||||
|
display: block;
|
||||||
|
padding-right: 0.06em;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rests at zero so the headline's left edge is the one the copy below aligns to.
|
||||||
|
The drift now arrives at that edge instead of parking 0.045em to the right of
|
||||||
|
it, which was what pushed "Research" off the mission line. */
|
||||||
|
.line-research .word {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
animation: drift-right 12s 1.35s ease-in-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deliberately wrappable. There is ~5% of slack between "that runs." and the card
|
||||||
|
at every width, so the pair only ever separates when the type has been scaled
|
||||||
|
past what the column can hold — at which point two short lines inside the
|
||||||
|
column beat one long line across the card. */
|
||||||
|
.line-runs .word {
|
||||||
|
transform: translate3d(0, -0.035em, 0);
|
||||||
|
animation: drift-up 11s 1.5s ease-in-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-runs .that {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-runs .runs {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(100deg, #cdead9 8%, #2f8f5c 82%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission {
|
||||||
|
grid-column: 1 / span 4;
|
||||||
|
grid-row: 2;
|
||||||
|
max-width: 29rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: clamp(0.95rem, 1.25vw, 1.14rem);
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-up 700ms 850ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
/* Never grows, so the half-pixel the headline gives up comes back to the headline
|
||||||
|
alone and the card starts exactly on the ninth column line. */
|
||||||
|
flex: 0 1 var(--hero-span-4);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 1.15rem 0 1.05rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-up 700ms 980ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--pine);
|
||||||
|
content: '';
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 350ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover::after,
|
||||||
|
.product-card:focus-visible::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-index {
|
||||||
|
color: var(--pine);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-size: clamp(1.75rem, 3vw, 2.8rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
max-width: 18rem;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in 700ms 1.1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uppercase mono at 0.12em tracking is wide enough that a phone will break both
|
||||||
|
the copyright and the arrow off its label. Neither is allowed to wrap; the
|
||||||
|
mobile query below stacks them instead when the row no longer fits. */
|
||||||
|
.footer p,
|
||||||
|
.footer a {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-in {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, 72%, 0) rotate(1.5deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(0, 0, 0) rotate(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-up {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, 1rem, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift-right {
|
||||||
|
from { transform: translate3d(-0.045em, 0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift-up {
|
||||||
|
from { transform: translate3d(0, 0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes haze-drift {
|
||||||
|
from { transform: translate3d(-9%, 3%, 0) rotate(-0.8deg); }
|
||||||
|
to { transform: translate3d(10%, -4%, 0) rotate(0.8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Travels further and slower than the haze, and scales as it goes, so the two
|
||||||
|
bands never hold the same relative position for long. */
|
||||||
|
@keyframes drift-across {
|
||||||
|
from { transform: translate3d(7%, -5%, 0) scale(1.04); }
|
||||||
|
to { transform: translate3d(-9%, 7%, 0) scale(1.13); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* A phone has no width to spare, so the inset falls back to what it takes to keep
|
||||||
|
the text off the bezel rather than what the desktop composition wants. */
|
||||||
|
.page-shell {
|
||||||
|
--edge: clamp(1.15rem, 5vw, 1.85rem);
|
||||||
|
|
||||||
|
/* The footer is parked past the fold: on a phone its rule is a third horizontal
|
||||||
|
line competing with the masthead's and the card's, and the page reads calmer
|
||||||
|
without it. The drop has to pay for the footer's own height and the shell's
|
||||||
|
bottom padding before it buys any clearance, hence a value this large. */
|
||||||
|
--footer-drop: clamp(6.5rem, 16vh, 9rem);
|
||||||
|
|
||||||
|
min-height: calc(100svh + var(--footer-drop));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: clamp(1.75rem, 5vh, 3rem);
|
||||||
|
padding-top: clamp(2.5rem, 8vh, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement {
|
||||||
|
font-size: clamp(2.9rem, 14vw, 5.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-runs {
|
||||||
|
margin-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One column, so there is nothing for the headline to collide with and no need
|
||||||
|
for the flex line. Dissolving the wrapper puts all three back on the hero grid,
|
||||||
|
where explicit rows keep the phone order the design has always had — the DOM
|
||||||
|
carries the card first because that is where it reads on the desktop layout. */
|
||||||
|
.hero-lead {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement,
|
||||||
|
.mission,
|
||||||
|
.product-card {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission {
|
||||||
|
grid-row: 2;
|
||||||
|
max-width: 25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked, the card is a separate thought from the hero copy rather than the
|
||||||
|
column beside it, so it wants more air above than the hero's row rhythm gives. */
|
||||||
|
.product-card {
|
||||||
|
grid-row: 3;
|
||||||
|
margin-top: clamp(0.75rem, 3.5vh, 2.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One line each: side by side these two need ~360px of tracking-heavy mono, which
|
||||||
|
is more than a 390px screen has once the edge inset is paid. */
|
||||||
|
.footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: var(--footer-drop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 440px) {
|
||||||
|
.mark {
|
||||||
|
width: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement {
|
||||||
|
font-size: clamp(2.5rem, 13.5vw, 4.2rem);
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Short viewports: compress the vertical rhythm so the composition still lands
|
||||||
|
on one screen. Declared after the width queries so it wins where they overlap. */
|
||||||
|
@media (max-height: 780px) and (min-width: 721px) {
|
||||||
|
.hero {
|
||||||
|
row-gap: clamp(1.1rem, 2.8vh, 2.25rem);
|
||||||
|
padding: clamp(0.75rem, 2.2vh, 2rem) 0 clamp(0.5rem, 1.8vh, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement {
|
||||||
|
font-size: clamp(2.25rem, min(9vw, 15vh), 8.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Short phones can't fit the whole composition at a headline size worth reading,
|
||||||
|
so tighten the spacing but leave the type alone and let the page scroll. */
|
||||||
|
@media (max-height: 700px) and (max-width: 720px) {
|
||||||
|
.hero {
|
||||||
|
row-gap: clamp(1.1rem, 2.9vh, 2rem);
|
||||||
|
padding: clamp(0.75rem, 2.2vh, 1.75rem) 0 clamp(0.5rem, 1.4vh, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The ambient bands are deliberately absent from this list. Each one's base
|
||||||
|
transform is an endpoint of its own keyframes, so with the animation off they
|
||||||
|
hold a frame of it — clearing them would snap both back to the origin and
|
||||||
|
stack the two washes on top of each other. */
|
||||||
|
.masthead,
|
||||||
|
.line,
|
||||||
|
.mission,
|
||||||
|
.product-card,
|
||||||
|
.footer {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "blakeslee-research",
|
||||||
|
"compatibility_date": "2026-08-08", // pinned to max date supported by locked wrangler 4.120.0's workerd; advance when wrangler is upgraded
|
||||||
|
"assets": {
|
||||||
|
"directory": "./dist",
|
||||||
|
"html_handling": "auto-trailing-slash",
|
||||||
|
"not_found_handling": "404-page"
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "blakeslee.xyz",
|
||||||
|
"custom_domain": true
|
||||||
|
},
|
||||||
|
// Remove this entry if www is handled elsewhere.
|
||||||
|
{
|
||||||
|
"pattern": "www.blakeslee.xyz",
|
||||||
|
"custom_domain": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user