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
8 changes: 8 additions & 0 deletions .claude/rules/grain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
paths:
- 'js/grain.js'
- 'grain.html'
- 'css/grain.css'
---

@specs/F07_GRAIN.md
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ jobs:
- name: Palette is in step across every copy
run: python3 scripts/check_palette.py

# Cannot re-run js/grain.js's AES-256-GCM against its own fixtures —
# stdlib Python has no AES-GCM — so this is a regression pin, not a
# proof, and says so in its own header comment: see
# scripts/check_grain.py for exactly what it does and does not catch.
- name: Grain fixtures still match js/grain.js's format
run: python3 scripts/check_grain.py

- name: .nojekyll present and sitemap in step
run: python3 scripts/check_repo.py

Expand Down
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ A static personal site: plain HTML and CSS, no build step, no package manager,
served by GitHub Pages from `main`. There is nothing to install and nothing to
compile. Edit the files directly.

**There are exactly five JavaScript files of our own: `js/ask.js`, `js/game.js`,
`js/mocap.js`, `js/mocap-retarget.js` and `js/iris.js`.** Everything else — the work index on the home page — is
**There are exactly six JavaScript files of our own: `js/ask.js`, `js/game.js`,
`js/mocap.js`, `js/mocap-retarget.js`, `js/iris.js` and `js/grain.js`.** Everything else — the work index on the home page — is
[htmx](https://htmx.org) asking for static HTML fragments under `fragments/` and
swapping them in. htmx arrives from a CDN pinned by version and SRI digest.

Expand Down Expand Up @@ -103,6 +103,18 @@ mechanism to actually be true, and the CSP violation line it produces in the
console is deliberate, not a regression — see that section before "cleaning
up" the console output on `iris.html`.

`js/grain.js` earns the sixth exception on different grounds again: it is not
a boundary in front of a vendored runtime at all, it is the whole feature.
Grain encrypts a file with a passphrase (AES-256-GCM via WebCrypto, key
stretched through PBKDF2) and hides the ciphertext in a carrier PNG's pixels
with least-significant-bit steganography, entirely with browser-native APIs —
`crypto.subtle` and `<canvas>` — and nothing vendored. There is no markup this
could be instead: a passphrase-derived key, an authenticated 42-byte header
and a bit-level pixel encoding are computation, not a document a server could
have sent. `grain.html` carries `connect-src 'none'`, tighter than the `'self'`
every htmx page needs, because this page has no fragment to ask for in the
first place — see [specs/F07_GRAIN.md](specs/F07_GRAIN.md).

Do not introduce a bundler, framework or package manager to solve a problem that a
few lines of CSS would solve. The absence of a toolchain is a design decision, not
an oversight — see [ARCHITECTURE.md](ARCHITECTURE.md#no-build-step). `game/` is
Expand Down Expand Up @@ -188,6 +200,12 @@ not pretend to cover them. **A green CI does not mean a change is verified.**
12. **[CI]** If any colour changed, `python3 scripts/check_palette.py`. The palette
is written out in **five** places and nothing but this script keeps them in
step; see the bullet under "Other things not to break".
13. **[CI]** If you touched `js/grain.js`'s header format, bit order or crypto
parameters, `python3 scripts/check_grain.py`. It is a regression pin against
the fixtures in `scripts/fixtures/grain/`, not a proof — stdlib Python has no
AES-GCM to check the encryption itself against, so read the script's own
header comment for exactly what it can and cannot catch. If it fails on a
deliberate format change, regenerate the fixtures rather than editing the pin.

## Accessibility invariants

Expand Down
272 changes: 272 additions & 0 deletions css/grain.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/* Grain. Layout only — every colour below is one of css/style.css's shared
custom properties, so this file is not a sixth place
scripts/check_palette.py would have to track. */

/* Neither section on this page ever gets the `hidden` attribute — encode and
decode are both always present — but the sample-carrier <img> and the
result panels do, and the browser's own `[hidden]` rule is too low
specificity to survive this file's other selectors. Same fix as
css/iris.css and css/game.css. */
[hidden] {
display: none !important;
}

.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--bg-alt);
color: var(--text);
padding: 0.5rem 1rem;
border-radius: 0 0 8px 0;
z-index: 10;
}

.skip-link:focus {
left: 0;
}

.grain--page {
display: block;
max-width: var(--measure);
margin: 0 auto;
padding: 0 1.5rem 3rem;
}

.grain--head {
max-width: var(--measure);
margin: 0 auto;
padding: 2rem 1.5rem 1rem;
}

.grain--sub {
max-width: var(--measure);
color: var(--text-muted);
}

.grain--sub a {
color: var(--accent-text);
text-decoration: underline;
}

.grain--honest {
font-size: 0.9rem;
}

.grain--notice {
color: var(--text-muted);
min-height: 1.25em;
}

.grain--section {
margin: 2rem 0;
padding: 1.25rem 1.5rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 8px;
}

.grain--section h2 {
margin-top: 0;
}

.grain--field {
margin: 0 0 1.5rem;
}

.grain--field-heading {
margin: 0 0 0.35rem;
}

.grain--field-hint {
margin: 0 0 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
}

.grain--drop {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: var(--bg);
border: 1px dashed var(--border);
border-radius: 6px;
}

.grain--drop.grain--drop-over {
border-color: var(--accent-text);
}

.grain--file-label {
font: inherit;
font-weight: 600;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 6px;
color: var(--bg);
background: var(--accent-text);
border: 2px solid var(--accent-text);
}

.grain--file-label:hover {
color: var(--accent-text);
background: var(--bg);
}

.grain--file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

.grain--file-input:focus-visible + .grain--file-label,
.grain--file-label:has(+ .grain--file-input:focus-visible) {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

.grain--or {
margin: 0.75rem 0;
color: var(--text-muted);
font-size: 0.9rem;
}

.grain--sample {
font: inherit;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 6px;
color: var(--accent-text);
background: var(--bg);
border: 2px solid var(--accent-text);
}

.grain--sample:hover {
color: var(--bg);
background: var(--accent-text);
}

.grain--sample:focus-visible {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

.grain--carrier-preview,
.grain--stego-preview {
display: block;
margin: 1rem 0;
max-width: 100%;
max-height: 240px;
border: 1px solid var(--border);
border-radius: 6px;
}

.grain--capacity {
margin: 0.75rem 0 0;
font-size: 0.9rem;
}

.grain--label {
display: block;
margin: 0 0 0.25rem;
font-weight: 600;
}

.grain--pass-row {
margin: 0 0 0.75rem;
}

.grain--input {
font: inherit;
width: 100%;
max-width: 24rem;
padding: 0.5rem 0.75rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}

.grain--input:focus-visible {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

.grain--pass-show-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
}

.grain--checkbox:focus-visible {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

.grain--pass-hint {
margin: 0;
color: var(--text-muted);
font-size: 0.85rem;
}

.grain--action {
font: inherit;
font-weight: 600;
cursor: pointer;
padding: 0.6rem 1.25rem;
border-radius: 6px;
color: var(--bg);
background: var(--accent-text);
border: 2px solid var(--accent-text);
}

.grain--action:hover {
color: var(--accent-text);
background: var(--bg);
}

.grain--action:focus-visible {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

.grain--action[aria-disabled='true'] {
cursor: progress;
opacity: 0.6;
}

.grain--result {
margin-top: 1.5rem;
}

.grain--download {
display: inline-block;
font-weight: 600;
color: var(--accent-text);
text-decoration: underline;
}

.grain--download:focus-visible {
outline: 3px solid var(--accent-text);
outline-offset: 2px;
}

@media (max-width: 750px) {
.grain--head {
padding: 1.5rem 1rem 0.75rem;
}

.grain--page {
padding: 0 1rem 2rem;
}

.grain--drop {
flex-direction: column;
align-items: flex-start;
}
}
Loading