Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/test/site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test("the site builds: markers filled, files copied, every command copyable", ()
const rows = html.match(/<div class="cmd[^"]*"><code data-copy>/g) ?? [];
assert.equal(rows.length, (html.match(/<button class="copy"/g) ?? []).length);
assert.ok(rows.length >= 6);
assert.match(html, /<code data-copy>npx skills add buildinternet\/buildnotes<\/code>/);
assert.match(html, /<code data-copy>npx skills add buildinternet\/(<wbr>)?buildnotes<\/code>/); // <wbr>: where it may wrap on a phone
// local references point at copied files, and .github/assets is published as assets/
for (const f of files) assert.ok(existsSync(join(out, f)), f);
assert.ok(files.includes("assets/digest-card-hero.webp"));
Expand Down
45 changes: 42 additions & 3 deletions site/assets/hero/hero.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,22 +276,61 @@ async function start(stage) {
});
const r = rooms.find((m) => m.i === idx) || rooms[0]; if (sun.parent !== r.g) lightRoom(r);
};
let dur = GLIDE; // a glide's length: the full 2.8 s, or shorter when a swipe lets go
const slideAt = (now) => {
if (g0 < 0) return to;
const p = Math.min(1, (now - g0) / GLIDE);
const p = Math.min(1, (now - g0) / dur);
if (p < 1) return from + (to - from) * glideEase(p);
g0 = -1;
if (idx === N) { idx = 0; to = 0; active(); } // the copy is in view: snap back to the real first cluster
return to;
};
const go = (i) => { from = slideAt(performance.now()); to = i * PITCH; idx = i; g0 = performance.now(); active(); kick(); };
const go = (i, ms = GLIDE) => { from = slideAt(performance.now()); to = i * PITCH; idx = i; dur = ms; g0 = performance.now(); active(); kick(); };
const schedule = () => { clearInterval(timer); if (live) timer = setInterval(() => go(idx + 1), DWELL); };
stops.forEach((b, i) => b.addEventListener('click', () => { go(i); schedule(); }));

// ---- Swipe: drag the table sideways to the next or previous example. The stage keeps vertical page
// scrolling (touch-action: pan-y in the CSS); a drag that starts mostly sideways takes the table with the
// finger, and letting go glides on to the nearest example, or the next one if it was flicked.
let drag = null;
const along = (dx) => dx / (s * Math.cos(22 * R)); // screen px to table px along the table's length, near enough
stage.addEventListener('pointerdown', (e) => {
if (!e.isPrimary || e.button > 0 || !live) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, base: 0, on: false, lx: e.clientX, lt: e.timeStamp, v: 0 };
});
stage.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy)) return;
drag.on = true; clearInterval(timer);
try { stage.setPointerCapture(drag.id); } catch {} // keeps the drag when the finger leaves the stage
drag.base = slideAt(performance.now()); g0 = -1;
if (drag.base < PITCH / 2 && dx > 0) { drag.base += N * PITCH; idx = N; active(); } // back from the first: use its copy at the end
ptrIn = false; ptrDirty = true;
}
to = Math.min(N * PITCH, Math.max(0, drag.base - along(dx)));
const dt = e.timeStamp - drag.lt;
if (dt > 0) { drag.v = .7 * drag.v + .3 * (e.clientX - drag.lx) / dt; drag.lx = e.clientX; drag.lt = e.timeStamp; }
kick();
});
const release = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const d = drag; drag = null;
if (!d.on) return;
const at = to / PITCH, flick = Math.abs(d.v) > .3;
let i = flick ? (d.v < 0 ? Math.floor(at + .02) + 1 : Math.ceil(at - .02) - 1) : Math.round(at);
i = Math.min(N, Math.max(0, i));
go(i, 650 + 250 * Math.abs(i - at)); schedule();
};
stage.addEventListener('pointerup', release);
stage.addEventListener('pointercancel', release);

// ---- Hover: a ray from the pointer picks the item under it, which lifts with E2's easing.
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
let ptrIn = false, ptrDirty = false;
stage.addEventListener('pointermove', (e) => {
if (e.pointerType === 'touch' || drag?.on) return; // a finger drags the table; it doesn't hover
const r = renderer.domElement.getBoundingClientRect();
ptr.set((e.clientX - r.left) / r.width * 2 - 1, -(e.clientY - r.top) / r.height * 2 + 1); ptrIn = true; ptrDirty = true; kick();
});
Expand All @@ -317,7 +356,7 @@ async function start(stage) {
raf = 0;
const dt = prev ? Math.min(100, now - prev) : 0; prev = now;
if (ptrDirty) hover(now);
let moving = g0 >= 0;
let moving = g0 >= 0 || !!drag?.on;
plane.position.x = -slideAt(now);
for (const it of items) {
if (it.t === it.to) continue;
Expand Down
2 changes: 2 additions & 0 deletions site/src/examples.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
.examples > div { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
.examples > div > div { display: grid; gap: 8px; align-content: start; }
.examples img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; object-position: top; border-radius: 4px; border: 1px solid var(--rule); }
/* In one column the picture is full width, so it shows whole rather than cropped to 4:3. */
@media (max-width: 719px) { .examples img { aspect-ratio: auto; } }
.show-head h1 { font-family: var(--font-display); font-size: 1.75rem; line-height: 1.2; }
.examples h2 { font-family: var(--font-display); font-size: 1.375rem; line-height: 1.2; }
.examples h2 a { color: var(--ink); text-decoration: none; }
Expand Down
58 changes: 54 additions & 4 deletions site/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
html { overflow-x: clip; }
.hero-table { margin-inline: calc(50% - 50vw); }
.hero-table .table { position: relative; }
.hero-table .stage { position: relative; height: clamp(380px, 50vw, 600px); overflow: hidden; --s: .64; }
.hero-table .stage { position: relative; height: clamp(380px, 50vw, 600px); overflow: hidden; --s: .64; touch-action: pan-y; } /* sideways drags swipe the table (hero.js) */
.hero-table .stage::after { content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: linear-gradient(var(--paper), transparent 20%, transparent 66%, var(--paper) 96%), linear-gradient(90deg, var(--paper), transparent 14%, transparent 86%, var(--paper)); }
.hero-table .stage canvas { position: absolute; inset: 0; z-index: 1; display: block; width: 100%; height: 100%; }
Expand Down Expand Up @@ -141,6 +141,11 @@
.out, .outs > * { min-width: 0; }
.out { grid-template-columns: minmax(0, 1fr); }
.cmd.sm { width: 100%; }
/* On a phone, commands wrap rather than scroll out of their box; the install one breaks after "buildinternet/". */
@media (max-width: 640px) {
.cmd code { white-space: normal; overflow-wrap: break-word; line-height: 1.45; }
.cmd { align-items: flex-start; }
}
.hero .agents { font-size: 0.8125rem; color: var(--muted); }
/* The request's shape: the command, the output word (one of three) and the brief. A template, so no copy button. */
.usage { display: grid; gap: 6px; justify-items: center; }
Expand All @@ -165,6 +170,8 @@

/* How it works */
.drawing { overflow-x: auto; }
/* Narrower than the drawing, it scrolls sideways: the right edge fades out until it has been scrolled to the end. */
.drawing.more { -webkit-mask-image: linear-gradient(90deg, #000 82%, transparent); mask-image: linear-gradient(90deg, #000 82%, transparent); }
.drawing svg { display: block; min-width: 560px; width: 100%; height: auto; }
.steps3 { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 700px) { .steps3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
Expand Down Expand Up @@ -216,6 +223,11 @@
.scroller img { position: absolute; top: 0; left: 0; width: 100%; height: auto; transform: translateY(var(--rest)); transition: transform 0.8s cubic-bezier(.22,1,.36,1); }
.scroller:hover img, .scroller:focus-visible img { transform: translateY(var(--end)); transition: transform 14s linear; }
.scroller .hint { position: absolute; right: 8px; bottom: 8px; font: 400 0.625rem/1 var(--font-mono); color: #64645f; background: #fcfcfa; border: 1px solid #e3e3dd; border-radius: 3px; padding: 4px 6px; }
/* Touch screens can't point, so there the post sits in a scroll box of its own (the script below moves the
image into .box): it opens at the first figure, drifts down once it's in view, and a finger takes over. */
.scroller .box { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
.scroller .box::-webkit-scrollbar { display: none; }
.scroller .box img { position: static; transform: none; transition: none; }
.two { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 760px) { .two { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.two figure { display: grid; gap: 10px; align-content: start; }
Expand Down Expand Up @@ -282,7 +294,7 @@
<div class="hero">
<h1>Turn pull requests into posts and videos worth sharing. <span>How it was built, and why.</span></h1>
<p>A skill for coding agents. It reads a feature's issues, PRs and reviews, and finds the few decisions that shaped it.</p>
<div class="cmd"><code data-copy>npx skills add buildinternet/buildnotes</code><button class="copy" type="button">copy</button></div>
<div class="cmd"><code data-copy>npx skills add buildinternet/<wbr>buildnotes</code><button class="copy" type="button">copy</button></div>
<p class="agents">For Claude Code, Codex and OpenCode</p>
<div class="usage"><span class="label">Then, inside your repo:</span><code>/buildnotes <b>[post | video | deck]</b> <i>what to explain</i></code></div>
</div>
Expand Down Expand Up @@ -387,7 +399,7 @@ <h2 id="more-h">More examples</h2>
<section id="install" aria-labelledby="in-h">
<h2 id="in-h">Install</h2>
<p class="sub">Needs Node 22, git and Chrome.</p>
<div class="cmd wide"><code data-copy>npx skills add buildinternet/buildnotes</code><button class="copy" type="button">copy</button></div>
<div class="cmd wide"><code data-copy>npx skills add buildinternet/<wbr>buildnotes</code><button class="copy" type="button">copy</button></div>
<p class="sub">Then, inside your repo:</p>
<div class="cmd wide"><code data-copy>/buildnotes write up how we built preview links, from PRs 120, 131 and 140</code><button class="copy" type="button">copy</button></div>
</section>
Expand All @@ -407,9 +419,47 @@ <h2 id="in-h">Install</h2>
} catch (e) { selectIt(); }
});
});
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (still) {
document.querySelectorAll('video[autoplay]').forEach(function (v) { v.removeAttribute('autoplay'); v.pause(); v.setAttribute('controls', ''); });
}

// Touch screens: nothing to point with, so the words say tap, and the post panel becomes a scroll box. It
// opens at the post's first figure and, once it's well in view, drifts down the post (at the hover scroll's
// pace) until a finger takes over. Scrolling inside it never opens the post; a tap still does.
if (matchMedia('(hover: none)').matches) {
document.querySelectorAll('.cap-top').forEach(function (p) { p.textContent = p.textContent.replace('Point at a note', 'Tap a note'); });
document.querySelectorAll('.scroller').forEach(function (a) {
var img = a.querySelector('img'), box = document.createElement('div'), hint = a.querySelector('.hint');
box.className = 'box'; a.prepend(box); box.append(img);
if (hint) hint.textContent = 'swipe to scroll';
a.setAttribute('aria-label', a.getAttribute('aria-label').replace('Point at it to scroll through the whole post.', 'Swipe inside it to scroll through the whole post.'));
var pct = function (n) { return -parseFloat(a.style.getPropertyValue(n)) / 100; };
var drift = 0, held = false;
var start = function () { box.scrollTop = pct('--rest') * img.offsetHeight; };
if (img.complete) start(); else img.addEventListener('load', start);
['pointerdown', 'wheel', 'touchstart'].forEach(function (t) { box.addEventListener(t, function () { held = true; cancelAnimationFrame(drift); }, { passive: true }); });
if (still) return;
new IntersectionObserver(function (es) {
cancelAnimationFrame(drift);
if (!es[0].isIntersecting || held) return;
var end = Math.min(pct('--end') * img.offsetHeight, box.scrollHeight - box.clientHeight);
var speed = (pct('--end') - pct('--rest')) * img.offsetHeight / 14000, y = box.scrollTop, t0 = 0; // px per ms
var step = function (t) {
if (t0) y = Math.min(end, y + speed * (t - t0));
t0 = t; box.scrollTop = y;
if (y < end) drift = requestAnimationFrame(step);
};
drift = requestAnimationFrame(step);
}, { threshold: 0.6 }).observe(a);
});
}

// The pipeline drawing is wider than a phone: until it's scrolled to its end, its right edge fades out.
document.querySelectorAll('.drawing').forEach(function (d) {
var check = function () { d.classList.toggle('more', d.scrollLeft + d.clientWidth < d.scrollWidth - 4); };
d.addEventListener('scroll', check, { passive: true }); addEventListener('resize', check); check();
});
</script>
<script type="module" src="assets/hero/hero.js"></script>
</body>
Expand Down
16 changes: 14 additions & 2 deletions skills/buildnotes/assets/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@
/* A note shows only while its hotspot is pointed at or focused; hidden, it takes no room (so it never widens the page). */
.hs .tip { display: none; position: absolute; top: 50%; left: calc(100% - 2px); width: max-content; max-width: 15rem; transform: translateY(-50%); padding: 0.55rem 0.75rem; background: var(--paper); border: 1px solid var(--rule); border-radius: 6px; box-shadow: 0 1px 0 var(--rule); font: 400 0.875rem/1.4 var(--font-body); color: var(--ink); text-align: left; pointer-events: none; z-index: 2; }
.hs.left .tip { left: auto; right: calc(100% - 2px); }
.hs:is(:hover, :focus-visible) .tip { display: block; animation: tip-in 0.16s ease-out; }
.hs:is(:hover, :focus-visible, .tap) .tip { display: block; animation: tip-in 0.16s ease-out; } /* .tap: tapped on a touch screen */
.keyed:has(.hs:hover) .hs:not(:hover) .tip { display: none; }
@keyframes tip-in { from { opacity: 0; } }
/* In a narrow figure the note opens below its hotspot, shifted by the hotspot's own position so it stays inside. */
Expand Down Expand Up @@ -1085,7 +1085,19 @@ <h2>Further reading</h2>
}
const light = (k) => fig.querySelectorAll("[data-k]").forEach((el) => el.classList.toggle("on", el.dataset.k === k));
fig.addEventListener("pointerover", (e) => { const k = e.target.closest?.("[data-k]")?.dataset.k; light(k ?? null); });
fig.addEventListener("pointerleave", () => light(null));
fig.addEventListener("pointerleave", (e) => { if (e.pointerType !== "touch") light(null); });
// A touch screen has no hover to hold a note open: a tap lights it, and opens a hotspot's note, until the
// next tap, inside the figure or out of it.
let tapped = null;
const tap = (el) => {
tapped?.classList.remove("tap");
tapped = el?.classList.contains("hs") ? el : null;
tapped?.classList.add("tap");
light(el?.dataset.k ?? null);
};
document.addEventListener("pointerdown", (e) => {
if (e.pointerType === "touch") tap(fig.contains(e.target) ? e.target.closest?.("[data-k]") ?? null : null);
});
fig.addEventListener("focusin", (e) => light(e.target.closest?.(".hs")?.dataset.k ?? null));
fig.addEventListener("focusout", () => light(null));
}
Expand Down
Loading