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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ on:

# The runner images are pinned to the platforms thirdshift supports.
jobs:
site-test:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm test

test:
runs-on: ubuntu-24.04
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
/site/node_modules/
.obsidian/plugins/obsidian-quiet-outline/markdown-states.json
.obsidian/user/Jacob-MacBook-Pro/workspace.json
.obsidian/user/Jacob-MacBook-Pro/plugins/remote-ssh/data.json
Expand Down
28 changes: 28 additions & 0 deletions site/main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
// Fragment links on the Prompts and skills page reveal their folded target.
function revealFoldedTarget() {
let id;
try {
id = decodeURIComponent(location.hash.slice(1));
} catch {
return;
}
const target = document.getElementById(id);
if (!target) return;
let fold = target.matches(".job-sheet")
? target.querySelector("details.fold")
: target.closest("details.fold");
if (!fold) return;
while (fold) {
fold.open = true;
fold = fold.parentElement.closest("details.fold");
}
target.scrollIntoView({ block: "start" });
}
revealFoldedTarget();
addEventListener("hashchange", revealFoldedTarget);
document.addEventListener("click", event => {
const link = event.target.closest("a[href^='#']");
// Repeating the current link doesn't fire hashchange.
if (link && link.hash === location.hash) revealFoldedTarget();
});

// Copy buttons: put the command next to the button on the clipboard.
for (const button of document.querySelectorAll(".install button.copy")) {
const code = button.closest(".install").querySelector("code");
Expand Down
41 changes: 41 additions & 0 deletions site/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions site/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"private": true,
"scripts": {
"test": "node --test tests/*.test.cjs"
},
"devDependencies": {
"playwright": "1.64.0"
}
}
354 changes: 278 additions & 76 deletions site/prompts/index.html

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions site/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,16 @@ body::after { /* scanlines */
.job-sheet h3 { font-size: 30px; }
.job-sheet h4 { margin: 10px 0 0; font: 700 13px/1.3 var(--f-mono); color: var(--dim); }
.job-sheet p { margin: 0; max-width: 90ch; }
.job-body { display: grid; gap: 10px; margin-top: 10px; }
.fold > summary { display: block; position: relative; padding-right: 32px; cursor: pointer; }
.fold > summary::-webkit-details-marker { display: none; }
.fold > summary::after { content: "+"; position: absolute; right: 0; top: 0; color: var(--c); font: 700 24px/1 var(--f-mono); }
.fold[open] > summary::after { content: "−"; }
.fold > .sec-head { display: grid; margin-bottom: 0; }
.fold[open] > .sec-head { margin-bottom: 28px; }
.fold > summary h4 { margin: 0; overflow-wrap: anywhere; }
.fold > summary + .job-text { margin-top: 10px; }
.fold > .lede + .job-sheets { margin-top: 28px; }
.sent-by { font-size: 13px; color: var(--dim); }
.job-text { margin: 0; padding: 14px 16px; background: #000; border: 1px solid var(--rule); font-size: 13px; line-height: 1.55;
white-space: pre-wrap; overflow-wrap: anywhere; }
Expand Down
13 changes: 13 additions & 0 deletions site/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Browser tests

The site still serves static files with no build step. To run its browser tests:

```sh
cd site
npm ci
npx playwright install --with-deps chromium
npm test
```

The tests start a local server on an unused port and close it and the browser
before exiting. CI runs them in the `site-test` job.
110 changes: 110 additions & 0 deletions site/tests/prompts.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const { test, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { createServer } = require("node:http");
const { readFile } = require("node:fs/promises");
const { join } = require("node:path");
const { chromium } = require("playwright");

let server, browser, origin;
before(async () => {
server = createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
const file = path === "/prompts/" ? "prompts/index.html" : path.slice(1);
try {
const content = await readFile(join(__dirname, "..", file));
const type = file.endsWith(".js") ? "text/javascript" : file.endsWith(".css") ? "text/css" : "text/html";
response.writeHead(200, { "Content-Type": type }).end(content);
} catch {
response.writeHead(404).end();
}
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
origin = `http://127.0.0.1:${server.address().port}`;
browser = await chromium.launch({ headless: true });
});
after(async () => {
await browser?.close();
if (server) await new Promise(resolve => server.close(resolve));
});

async function pageForTest(t, options) {
const context = await browser.newContext(options);
t.after(() => context.close());
const page = await context.newPage();
await page.route("https://**", route => route.abort());
return page;
}

test("every headed section starts closed and toggles independently by its heading", async (t) => {
const page = await pageForTest(t);
await page.goto(`${origin}/prompts/`);
const headings = page.locator("main :is(h2, h3, h4)");
assert.ok(await headings.count() > 30);
const ids = await headings.evaluateAll(nodes => nodes.map(node => node.id));
for (const id of ids) {
assert.equal(await page.locator(`#${id}`).evaluate(heading => {
const summary = heading.closest("summary");
return Boolean(summary && summary.parentElement.tagName === "DETAILS" && !summary.parentElement.open);
}), true, `${id} must control a closed section`);
}
assert.equal(await page.locator("main details[open]").count(), 0);
for (const id of ids) {
const heading = page.locator(`#${id}`);
// Open only the containing sections, as a reader does before reaching a nested heading.
const ancestors = await heading.evaluate(node => {
const ids = [];
for (let parent = node.closest("details")?.parentElement; parent; parent = parent.parentElement) {
if (parent.tagName === "DETAILS") ids.unshift(parent.querySelector("summary :is(h2,h3,h4)").id);
}
return ids;
});
for (const parent of ancestors) {
if (!await page.locator(`#${parent}`).evaluate(node => node.closest("details").open)) {
await page.locator(`#${parent}`).click();
}
}
await heading.click();
assert.equal(await heading.evaluate(node => node.closest("details").open), true, id);
const content = heading.locator("xpath=ancestor::summary/parent::details/*[not(self::summary)][1]");
assert.equal(await content.isVisible(), true, `${id} reveals its content`);
await heading.click();
assert.equal(await heading.evaluate(node => node.closest("details").open), false, id);
assert.equal(await content.isVisible(), false, `${id} hides its content`);
}
await page.locator("#command-title").click();
await page.locator("#prompts-title").click();
assert.equal(await page.locator("#command-title").evaluate(node => node.closest("details").open), true);
});

test("fragment links reveal the prompt or supporting file and its containing sections", async (t) => {
const page = await pageForTest(t);
await page.goto(`${origin}/prompts/#prompt-resume`);
assert.equal(await page.locator("#prompt-resume .job-text").isVisible(), true);
await page.goto(`${origin}/prompts/`);
await page.locator("#licence-title").click();
await page.locator('a[href="#file-thirdshift-pr-credits-md"]').click();
const credits = page.locator("#file-thirdshift-pr-credits-md").locator("xpath=ancestor::details[1]/pre");
assert.equal(await credits.isVisible(), true);
// A link to the current fragment must reveal a section the reader closed.
await page.locator("#file-thirdshift-pr-credits-md").click();
assert.equal(await credits.isVisible(), false);
await page.locator('a[href="#file-thirdshift-pr-credits-md"]').click();
assert.equal(await credits.isVisible(), true);
});

test("mobile readers can toggle headings with the keyboard without JavaScript", async (t) => {
const page = await pageForTest(t, { javaScriptEnabled: false, viewport: { width: 390, height: 844 } });
await page.goto(`${origin}/prompts/`);
assert.equal(await page.locator("main details[open]").count(), 0);
const prompts = page.locator("#prompts-title").locator("xpath=parent::summary");
await prompts.focus();
await page.keyboard.press("Enter");
assert.equal(await page.locator("#prompt-fresh-title").isVisible(), true);
const fresh = page.locator("#prompt-fresh-title").locator("xpath=parent::summary");
await fresh.focus();
await page.keyboard.press("Space");
assert.equal(await page.locator("#prompt-fresh .job-text").isVisible(), true);
await page.keyboard.press("Space");
assert.equal(await page.locator("#prompt-fresh .job-text").isVisible(), false);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
});
35 changes: 23 additions & 12 deletions src/prompts_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,12 +439,14 @@ fn command_line(html: &mut String) {
html,
r##"<section class="sec" aria-labelledby="command-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">The command line</p><h2 id="command-title">How each session starts</h2></div>
<details class="fold">
<summary class="sec-head"><span class="eyebrow">The command line</span><h2 id="command-title">How each session starts</h2></summary>
<p class="lede">Every session runs <code>claude</code> headless in the Run's worktree, in auto permission mode. The Factory skills are linked into the worktree's <code>.claude/skills/</code>, where Claude Code finds them, and kept out of git. The prompt is one of the templates below.</p>
<pre class="job-text" aria-label="A new session"><code>{new}</code></pre>
<p class="lede">A <a href="#prompt-resume">Resume</a> continues the session that ended:</p>
<pre class="job-text" aria-label="A Resume"><code>{resume}</code></pre>
<p class="lede">{codex}</p>
</details>
</div>
</section>
"##,
Expand All @@ -458,7 +460,8 @@ fn prompt_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="prompts-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Prompts</p><h2 id="prompts-title">What a Run tells the agent</h2></div>
<details class="fold">
<summary class="sec-head"><span class="eyebrow">Prompts</span><h2 id="prompts-title">What a Run tells the agent</h2></summary>
<p class="lede">Every prompt a Run can send, with its placeholders highlighted: they change for each Run.</p>
<div class="job-sheets">
"##,
Expand All @@ -467,10 +470,14 @@ fn prompt_section(html: &mut String) {
let _ = write!(
html,
r##" <article class="job-sheet" id="{id}" aria-labelledby="{id}-title">
<h3 id="{id}-title">{title}</h3>
<details class="fold">
<summary><h3 id="{id}-title">{title}</h3></summary>
<div class="job-body">
<p>{when}</p>
<p class="sent-by">Sent by {sender}</p>
<pre class="job-text"><code>{text}</code></pre>
</div>
</details>
</article>
"##,
id = prompt.id,
Expand All @@ -480,14 +487,15 @@ fn prompt_section(html: &mut String) {
text = highlighted(&prompt.text),
);
}
html.push_str(" </div>\n </div>\n</section>\n");
html.push_str(" </div>\n </details>\n </div>\n</section>\n");
}

fn skills_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="skills-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Factory skills</p><h2 id="skills-title">The skills, as the agent reads them</h2></div>
<details class="fold">
<summary class="sec-head"><span class="eyebrow">Factory skills</span><h2 id="skills-title">The skills, as the agent reads them</h2></summary>
<p class="lede">Every Factory skill and supporting file, raw, exactly as thirdshift writes it out.</p>
<div class="job-sheets">
"##,
Expand All @@ -499,7 +507,9 @@ fn skills_section(html: &mut String) {
let _ = write!(
html,
r##" <article class="job-sheet" id="skill-{name}" aria-labelledby="skill-{name}-title">
<h3 id="skill-{name}-title">{name}</h3>
<details class="fold">
<summary><h3 id="skill-{name}-title">{name}</h3></summary>
<div class="job-body">
<p class="sent-by">Used by {used_by}</p>
"##,
used_by = skill_used_by(&name),
Expand All @@ -516,16 +526,17 @@ fn skills_section(html: &mut String) {
for file in files {
file_block(html, file, " ");
}
html.push_str(" </article>\n");
html.push_str(" </div>\n </details>\n </article>\n");
}
html.push_str(" </div>\n </div>\n</section>\n");
html.push_str(" </div>\n </details>\n </div>\n</section>\n");
}

fn licence_section(html: &mut String) {
html.push_str(
r##"<section class="sec" aria-labelledby="licence-title">
<div class="wrap">
<div class="sec-head"><p class="eyebrow">Licence and credits</p><h2 id="licence-title">Whose skills these are</h2></div>
<details class="fold">
<summary class="sec-head"><span class="eyebrow">Licence and credits</span><h2 id="licence-title">Whose skills these are</h2></summary>
<p class="lede">Most Factory skills are adapted from Matt Pocock's skills, under the licence below. The <a href="#skill-thirdshift-pr">thirdshift-pr</a> skill credits Dex Horthy's <code>show-me</code> skill in its <a href="#file-thirdshift-pr-credits-md">CREDITS.md</a>. The <a href="#skill-thirdshift-security-audit">thirdshift-security-audit</a> skill is copied from <a href="https://github.com/cloudflare/security-audit-skill">Cloudflare's security-audit-skill</a>, under its own <a href="#file-thirdshift-security-audit-license">MIT licence</a>, with its upstream repository and commit recorded in <a href="#file-thirdshift-security-audit-credits-md">CREDITS.md</a>.</p>
"##,
);
Expand All @@ -539,7 +550,7 @@ fn licence_section(html: &mut String) {
);
file_block(html, file, " ");
}
html.push_str(" </div>\n</section>\n");
html.push_str(" </details>\n </div>\n</section>\n");
}

/// Every file under `dir`, at any depth.
Expand All @@ -550,7 +561,7 @@ fn collect_files<'a>(dir: &'a Dir<'a>, files: &mut Vec<&'a File<'a>>) {
}
}

/// A file's path as a heading, then its raw text.
/// A file's path as a disclosure heading, then its raw text.
fn file_block(html: &mut String, file: &File, indent: &str) {
let path = file.path().to_string_lossy();
let text = file
Expand All @@ -563,7 +574,7 @@ fn file_block(html: &mut String, file: &File, indent: &str) {
.collect();
let _ = write!(
html,
"{indent}<h4 id=\"file-{id}\"><code>{path}</code></h4>\n{indent}<pre class=\"job-text\"><code>{text}</code></pre>\n",
"{indent}<details class=\"fold\">\n{indent}<summary><h4 id=\"file-{id}\"><code>{path}</code></h4></summary>\n{indent}<pre class=\"job-text\"><code>{text}</code></pre>\n{indent}</details>\n",
path = escape(&path),
text = escape(text),
);
Expand Down
Loading