Skip to content

Commit 2521db8

Browse files
authored
Landing: Pharos engraving hero; snapshot revert hardening (#9)
* fix(snapshot): survive transient git failures instead of deleting files The 'unicode filenames modification and restore' test failed CI on push 43b5e18 with ENOENT after revert: every file in the sandbox was deleted. That happens when the 'git add' inside track() fails transiently and .nothrow() swallows it — write-tree then snapshots an empty index, and revert() against that tree treats every file as 'not in snapshot' and unlinks it. - stage via a shared stageAll() that retries once on failure and reports it; track() returns no snapshot instead of a wrong one when staging or write-tree fails - revert() no longer deletes a file when the ls-tree verification itself errors; deletion now requires a successful ls-tree that confirms the file was absent from the snapshot * Landing: Pharos engraving hero; drop og.png The Lighthouse of Alexandria plate becomes the hero backdrop — the beam sweeps from the tower down to the small ship steering by its light at bottom-left. The wordmark moves to the top-left over the dark sky and the headline block to the bottom-right, so both sit on quiet areas of the plate. A light uniform scrim plus two corner shields keep the type crisp while the beam and the ship stay in view; the plate is monochrome warm sepia already, so it ships unfiltered. Replaces the constellation canvas. og.png and its meta tags go — the twitter card drops to summary. * fix(snapshot): verify revert deletions against a full tree listing The per-file 'ls-tree <hash> -- <pathspec>' check behaved differently across platforms for unusual filenames, so revert either kept files it should delete or vice versa depending on OS. List the snapshot tree once per patch with core.quotepath=false (the same mechanism patch() uses) and check membership in code: deterministic on every platform, one git call per snapshot instead of per file, and still fails safe — if the listing errors, files are kept rather than deleted on an unverified miss.
1 parent caf283d commit 2521db8

5 files changed

Lines changed: 99 additions & 133 deletions

File tree

backend/cli/src/snapshot/index.ts

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ export namespace Snapshot {
4747
log.info("cleanup", { prune })
4848
}
4949

50+
// A failed `git add` must never be swallowed: write-tree would then snapshot a
51+
// stale (or empty) index, and a later revert() against that tree deletes every
52+
// file the tree is missing. Retry once for transient failures (index.lock
53+
// contention), then report the failure to the caller.
54+
async function stageAll(git: string) {
55+
let result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
56+
.quiet()
57+
.cwd(Instance.directory)
58+
.nothrow()
59+
if (result.exitCode !== 0) {
60+
log.warn("add failed, retrying", { exitCode: result.exitCode, stderr: result.stderr.toString() })
61+
result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
62+
.quiet()
63+
.cwd(Instance.directory)
64+
.nothrow()
65+
}
66+
if (result.exitCode !== 0) {
67+
log.error("add failed", { exitCode: result.exitCode, stderr: result.stderr.toString() })
68+
return false
69+
}
70+
return true
71+
}
72+
5073
export async function track() {
5174
if (Instance.project.vcs !== "git") return
5275
const cfg = await Config.get()
@@ -65,14 +88,18 @@ export namespace Snapshot {
6588
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
6689
log.info("initialized")
6790
}
68-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
69-
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
91+
if (!(await stageAll(git))) return
92+
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
7093
.quiet()
7194
.cwd(Instance.directory)
7295
.nothrow()
73-
.text()
96+
if (result.exitCode !== 0) {
97+
log.error("write-tree failed", { exitCode: result.exitCode, stderr: result.stderr.toString() })
98+
return
99+
}
100+
const hash = result.text().trim()
74101
log.info("tracking", { hash, cwd: Instance.directory, git })
75-
return hash.trim()
102+
return hash
76103
}
77104

78105
export const Patch = z.object({
@@ -83,7 +110,7 @@ export namespace Snapshot {
83110

84111
export async function patch(hash: string): Promise<Patch> {
85112
const git = gitdir()
86-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
113+
await stageAll(git)
87114
const result =
88115
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
89116
.quiet()
@@ -131,6 +158,33 @@ export namespace Snapshot {
131158
const files = new Set<string>()
132159
const git = gitdir()
133160
for (const item of patches) {
161+
// One full listing per snapshot instead of a per-file ls-tree pathspec:
162+
// membership is checked in code on quotepath=false output, the same
163+
// mechanism patch() uses, so unusual filenames behave identically on
164+
// every platform. If the listing itself fails we know nothing about the
165+
// snapshot, and deleting on an unverified miss would be destructive —
166+
// keep files in that case.
167+
const listing = await $`git -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} ls-tree -r --name-only ${item.hash}`
168+
.quiet()
169+
.cwd(Instance.worktree)
170+
.nothrow()
171+
const snapshotFiles =
172+
listing.exitCode === 0
173+
? new Set(
174+
listing
175+
.text()
176+
.split("\n")
177+
.map((x) => x.trim())
178+
.filter(Boolean)
179+
.map((x) => path.join(Instance.worktree, x)),
180+
)
181+
: undefined
182+
if (!snapshotFiles)
183+
log.warn("could not list snapshot tree", {
184+
hash: item.hash,
185+
exitCode: listing.exitCode,
186+
stderr: listing.stderr.toString(),
187+
})
134188
for (const file of item.files) {
135189
if (files.has(file)) continue
136190
log.info("reverting", { file, hash: item.hash })
@@ -139,13 +193,9 @@ export namespace Snapshot {
139193
.cwd(Instance.worktree)
140194
.nothrow()
141195
if (result.exitCode !== 0) {
142-
const relativePath = path.relative(Instance.worktree, file)
143-
const checkTree =
144-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
145-
.quiet()
146-
.cwd(Instance.worktree)
147-
.nothrow()
148-
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
196+
if (!snapshotFiles) {
197+
log.warn("could not verify file against snapshot, keeping", { file })
198+
} else if (snapshotFiles.has(file)) {
149199
log.info("file existed in snapshot but checkout failed, keeping", {
150200
file,
151201
})
@@ -161,7 +211,7 @@ export namespace Snapshot {
161211

162212
export async function diff(hash: string) {
163213
const git = gitdir()
164-
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
214+
await stageAll(git)
165215
const result =
166216
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
167217
.quiet()

frontend/landing/index.html

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,9 @@
1414
<meta property="og:description" content="Literature, code, experiments, and the write-up, in one place. Open source and model-agnostic, on your keys." />
1515
<meta property="og:url" content="https://openscience.sh" />
1616
<meta property="og:site_name" content="OpenScience" />
17-
<meta property="og:image" content="https://openscience.sh/og.png" />
18-
<meta property="og:image:secure_url" content="https://openscience.sh/og.png" />
19-
<meta property="og:image:type" content="image/png" />
20-
<meta property="og:image:width" content="1200" />
21-
<meta property="og:image:height" content="630" />
22-
<meta property="og:image:alt" content="OpenScience. The open-source AI workbench for scientific research." />
23-
<meta name="twitter:card" content="summary_large_image" />
17+
<meta name="twitter:card" content="summary" />
2418
<meta name="twitter:title" content="OpenScience. The open-source AI workbench for scientific research." />
2519
<meta name="twitter:description" content="Literature, code, experiments, and the write-up, in one place. On any model, with your keys." />
26-
<meta name="twitter:image" content="https://openscience.sh/og.png" />
27-
<meta name="twitter:image:alt" content="OpenScience. The open-source AI workbench for scientific research." />
2820
<meta name="twitter:site" content="@SynScience" />
2921

3022
<link rel="preconnect" href="https://fonts.googleapis.com" />

frontend/landing/public/og.png

-518 KB
Binary file not shown.
619 KB
Loading

frontend/landing/src/pages/Landing.tsx

Lines changed: 35 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
22
import workspaceShot from "@/assets/workspace.png";
33
import modelPickerShot from "@/assets/model-picker.png";
4+
import heroPlate from "@/assets/hero.webp";
45

56
/* OpenScience. CMU Concrete, warm dark, coral accents.
67
Same design family as the Atlas landing page.
@@ -259,100 +260,25 @@ function Section({
259260
);
260261
}
261262

262-
/* --------------------------- Hero constellation ------------------------- */
263-
/* A slow, drifting field of faint points joined into a constellation.
264-
Cream nodes, a few coral. Pure canvas, no library. */
265-
266-
function Constellation() {
267-
const ref = useRef<HTMLCanvasElement>(null);
268-
useEffect(() => {
269-
const canvas = ref.current;
270-
if (!canvas) return;
271-
const ctx = canvas.getContext("2d");
272-
if (!ctx) return;
273-
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
274-
const dpr = Math.min(window.devicePixelRatio || 1, 2);
275-
let raf = 0;
276-
let w = 0;
277-
let h = 0;
278-
type Pt = { x: number; y: number; vx: number; vy: number; r: number; hot: boolean };
279-
let pts: Pt[] = [];
280-
281-
const resize = () => {
282-
const rect = canvas.getBoundingClientRect();
283-
w = rect.width;
284-
h = rect.height;
285-
canvas.width = Math.round(w * dpr);
286-
canvas.height = Math.round(h * dpr);
287-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
288-
const count = Math.min(120, Math.max(50, Math.floor((w * h) / 15000)));
289-
pts = Array.from({ length: count }, () => ({
290-
x: Math.random() * w,
291-
y: Math.random() * h,
292-
vx: (Math.random() - 0.5) * 0.14,
293-
vy: (Math.random() - 0.5) * 0.14,
294-
r: Math.random() < 0.14 ? 1.7 : 1.05,
295-
hot: Math.random() < 0.055,
296-
}));
297-
};
298-
299-
const LINK = 120;
300-
const draw = () => {
301-
ctx.clearRect(0, 0, w, h);
302-
for (let i = 0; i < pts.length; i++) {
303-
const a = pts[i];
304-
for (let j = i + 1; j < pts.length; j++) {
305-
const b = pts[j];
306-
const dx = a.x - b.x;
307-
const dy = a.y - b.y;
308-
const d2 = dx * dx + dy * dy;
309-
if (d2 < LINK * LINK) {
310-
const t = 1 - Math.sqrt(d2) / LINK;
311-
ctx.strokeStyle = `hsla(42, 32%, 88%, ${(t * 0.13).toFixed(3)})`;
312-
ctx.lineWidth = 0.6;
313-
ctx.beginPath();
314-
ctx.moveTo(a.x, a.y);
315-
ctx.lineTo(b.x, b.y);
316-
ctx.stroke();
317-
}
318-
}
319-
}
320-
for (const p of pts) {
321-
ctx.beginPath();
322-
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
323-
ctx.fillStyle = p.hot ? "hsla(14, 70%, 64%, 0.75)" : "hsla(42, 32%, 88%, 0.4)";
324-
ctx.fill();
325-
}
326-
};
327-
328-
const step = () => {
329-
for (const p of pts) {
330-
p.x += p.vx;
331-
p.y += p.vy;
332-
if (p.x < -8) p.x = w + 8;
333-
if (p.x > w + 8) p.x = -8;
334-
if (p.y < -8) p.y = h + 8;
335-
if (p.y > h + 8) p.y = -8;
336-
}
337-
draw();
338-
raf = window.requestAnimationFrame(step);
339-
};
340-
341-
resize();
342-
draw();
343-
if (!reduced) raf = window.requestAnimationFrame(step);
344-
const onResize = () => {
345-
resize();
346-
draw();
347-
};
348-
window.addEventListener("resize", onResize);
349-
return () => {
350-
window.cancelAnimationFrame(raf);
351-
window.removeEventListener("resize", onResize);
352-
};
353-
}, []);
354-
return <canvas ref={ref} className="absolute inset-0 w-full h-full" aria-hidden />;
355-
}
263+
/* ----------------------------- Hero plate ------------------------------- */
264+
/* The Pharos of Alexandria engraving — the beam sweeps from the tower at the
265+
right down to a small ship steering by its light at bottom-left. The plate
266+
is already monochrome warm sepia, the same hue family as the site's cream,
267+
so it ships unfiltered. Anchored right; on wide viewports the background
268+
color fills the left edge and the veil blends the seam. */
269+
270+
const HERO_NOISE =
271+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
272+
273+
/* Veil: a light uniform scrim softens the plate; a soft top-left shield sits
274+
under the wordmark and a bottom-right shield under the copy, so the beam
275+
and the ship at bottom-left stay in view. */
276+
const HERO_VEIL = [
277+
"linear-gradient(hsl(30 14% 7% / 0.2), hsl(30 14% 7% / 0.2))",
278+
"radial-gradient(ellipse 55% 45% at 5% 6%, hsl(30 14% 7% / 0.8) 0%, hsl(30 14% 7% / 0.4) 55%, transparent 85%)",
279+
"radial-gradient(ellipse 85% 75% at 96% 94%, hsl(var(--background)) 0%, hsl(30 14% 7% / 0.84) 38%, hsl(30 14% 7% / 0.28) 66%, transparent 90%)",
280+
"linear-gradient(180deg, hsl(28 18% 4% / 0.5) 0%, transparent 15%)",
281+
].join(", ");
356282

357283
/* ------------------------------ Hero ----------------------------------- */
358284

@@ -389,46 +315,44 @@ function Hero() {
389315
return (
390316
<section className="relative h-screen min-h-[680px] w-full bg-background overflow-hidden grain">
391317
<div ref={backdrop} className="absolute inset-0 will-change-transform" aria-hidden>
392-
<div className="absolute inset-0 graticule opacity-[0.05]" />
393318
<div
394-
className="absolute inset-0"
395-
style={{
396-
maskImage: "linear-gradient(to bottom, black 55%, transparent 96%)",
397-
WebkitMaskImage: "linear-gradient(to bottom, black 55%, transparent 96%)",
398-
}}
399-
>
400-
<Constellation />
401-
</div>
319+
className="absolute inset-0 bg-background bg-no-repeat [background-position:62%_14%] [background-size:cover] sm:[background-position:right_center] sm:[background-size:auto_114%]"
320+
style={{ backgroundImage: `url(${heroPlate})` }}
321+
/>
322+
<div
323+
className="absolute inset-0 opacity-[0.32] mix-blend-overlay"
324+
style={{ backgroundImage: HERO_NOISE }}
325+
/>
326+
<div className="absolute inset-0" style={{ background: HERO_VEIL }} />
402327
</div>
403328

404329
<div
405330
className="pointer-events-none absolute inset-x-0 bottom-0 z-[5]"
406331
style={{
407-
height: "56%",
408-
background:
409-
"linear-gradient(to top, hsl(var(--background)) 0%, hsl(var(--background) / 0.9) 18%, hsl(var(--background) / 0.55) 46%, hsl(var(--background) / 0) 100%)",
332+
height: "16%",
333+
background: "linear-gradient(to top, hsl(var(--background)) 0%, hsl(var(--background) / 0) 100%)",
410334
}}
411335
/>
412336

413337
<div className="absolute inset-0 z-10 mx-auto flex h-full max-w-[1400px] flex-col px-6 sm:px-10">
414-
<div className="hero-text rise self-end text-right mt-[9vh]" style={{ animationDelay: "120ms" }}>
338+
<div className="hero-text rise self-start mt-[9vh]" style={{ animationDelay: "120ms" }}>
415339
<div className="text-[clamp(40px,6.4vw,96px)] leading-[0.9] tracking-[-0.04em]">openscience</div>
416-
<div className="mt-3 text-[13px] tracking-[0.04em] text-foreground/50">by Synthetic Sciences</div>
340+
<div className="mt-3 text-[13px] tracking-[0.04em] text-foreground/55">by Synthetic Sciences</div>
417341
</div>
418342

419-
<div ref={copy} className="hero-text mt-auto mb-[7vh] max-w-[820px]">
343+
<div ref={copy} className="hero-text mt-auto mb-[7vh] self-end text-right max-w-[820px]">
420344
<div className="rise" style={{ animationDelay: "260ms" }}>
421345
<h1 className="text-balance text-[clamp(34px,4.6vw,62px)] leading-[1.04] tracking-[-0.024em] text-foreground">
422346
The open-source AI workbench for scientists.
423347
</h1>
424348
</div>
425349
<div className="rise" style={{ animationDelay: "420ms" }}>
426-
<p className={`mt-6 max-w-[54ch] ${P_BIG} text-foreground/80`}>
350+
<p className={`mt-6 ml-auto max-w-[44ch] ${P_BIG} text-foreground/85`}>
427351
One environment for the whole loop: literature, code, experiments, figures,
428352
and the write-up. In your browser, on any model, with your keys.
429353
</p>
430354
</div>
431-
<div className="rise mt-9 flex flex-wrap items-center gap-3 [text-shadow:none]" style={{ animationDelay: "580ms" }}>
355+
<div className="rise mt-9 flex flex-wrap items-center justify-end gap-3 [text-shadow:none]" style={{ animationDelay: "580ms" }}>
432356
<Cta href="#install">Install OpenScience</Cta>
433357
<Cta href={GITHUB} variant="ghost" arrow={false} external>
434358
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden>

0 commit comments

Comments
 (0)