diff --git a/AGENTS.md b/AGENTS.md index 97dde07..b727db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,29 @@ -# AGENTS.md — Ember +# AGENTS.md: Ember -Project-specific notes for the Ember study. Read [`README.md`](./README.md) -first for the traced motion references and the client decisions. +Project-specific notes for the Ember study. Read this file and task-relevant +product documents before changing the project. ## Repository checks The checks under `scripts/` and `.github/workflows/` were borrowed by hand from the `kiaquila/web-design` template and trimmed to this project. They are this -repository's own files — edit them here when they are wrong. Nothing is synced -from upstream, so do not reintroduce a baseline lock file, a release manifest, -a managed-file list or an updater; see the **Repository baseline** section of -[`README.md`](./README.md). Ordinary npm lock files are a different thing and -belong here: the root pins the guard's YAML parser, `website/` pins wrangler. +repository's own files, so edit them here when they are wrong. Nothing is +synced from upstream. Do not reintroduce a baseline lock file, a release +manifest, a managed-file list, or an updater. Ordinary npm lock files are a +different thing and belong here: the root pins the guard's YAML parser, while +`website/` pins wrangler. `.gitattributes` and `.github/dependabot.yml` were borrowed the same way and are required by `npm run check`. Keep `linguist-vendored` on the borrowed -harness only — never on the page, its build scripts or the Worker — and keep -the Dependabot entries pointed at directories that really hold a `package.json` -and a lockfile. `semver-*-days` stays off the GitHub Actions ecosystem: action -tags are not guaranteed to be semantic versions. +harness only. Never apply it to the page, its build scripts, or the Worker. +Keep the Dependabot entries pointed at directories that really hold a +`package.json` and a lockfile. `semver-*-days` stays off the GitHub Actions +ecosystem because action tags are not guaranteed to be semantic versions. ## Project rules - This is a self-contained lab piece, not a client business site. The page - lives in [`website/src/index.html`](./website/src/index.html) — keep it a + lives in [`website/src/index.html`](./website/src/index.html). Keep it a single dependency-free file (inline CSS/JS, canvas 2D, Web Audio synthesis, system fonts, zero network requests). Its only siblings are the two baked favicon PNGs for Safari and the baked `og.png` social card (regenerated by @@ -31,15 +31,15 @@ tags are not guaranteed to be semantic versions. four files and fails on a fifth, so a new asset is a deliberate decision. Exactly two absolute URLs are allowed, and only as the complete `content` value of an `og:url`/`og:image` meta tag: the canonical page and its - `og.png` (enumerated in `build.mjs`). Every other absolute URL — any other - host, path, query, or attribute — still fails the build; do not widen that + `og.png` (enumerated in `build.mjs`). Every other absolute URL, regardless + of host, path, query, or attribute, still fails the build. Do not widen that allowance. The card is pinned to the page's figure code by a fingerprint (`scripts/og-fingerprint.mjs`): the tests compare the hash baked into `og.png` against the shipped page, and `make-og.mjs` refuses to render while its hand-ported geometry disagrees with the page (`PORTED_FIGURE_FINGERPRINT`). On a mismatch, update the port in `make-og.mjs` to match the page, bump that constant, rerun `npm run og`, - and commit the regenerated card — never bump the constant alone. + and commit the regenerated card. Never bump the constant alone. - The stage is a Cloudflare Worker named `ember`, configured as documented in [`docs/stage-hosting.md`](./docs/stage-hosting.md). Its Content-Security- Policy allows inline script and style because the page is one file by @@ -49,16 +49,17 @@ tags are not guaranteed to be semantic versions. cutover, so `main` here deploys the stage and pull requests get a Cloudflare preview build. The `ember/website` path in `kiaquila/web-design` no longer builds this Worker and is kept only as the rollback route. -- The motion concept is traced in [`README.md`](./README.md). Do not copy - assets or code from the referenced Pinterest pin or reactive-dots site; - only the documented motion idea is reproduced. +- The motion references are the Pinterest pin and + . Treat them only as references for the + smoldering particle motion and centered interaction layout. Do not copy + assets, code, or text from either source. - The tuning-fork sound is synthesized (136.1 Hz fundamental). Do not add audio files or third-party libraries for it. - Preserve the approved interaction contract: hover = local burn + recovery; - Play = one full burn → rebirth cycle with strikes — the figure burns down + Play = one full burn → rebirth cycle with strikes. The figure burns down completely, reassembles as a new silhouette, and the control resets from Stop back to Play when the cycle completes; the ash rebirth is the point - of no return — Stop before it aborts the cycle and recovers the original + of no return. Stop before it aborts the cycle and recovers the original figure, Stop after it lets the new silhouette finish reassembling (the shape-shifting rebirth was rolled back on 2026-08-19 and explicitly requested back on 2026-08-26 as this single-cycle ending; do not bring @@ -69,7 +70,7 @@ tags are not guaranteed to be semantic versions. gradient dot on the baseline, hugging the KS with a wide gap before DESIGN (`--brand-dot`, indigo `#818cf8` into cyan `#22d3ee` at 135°, same pair as the ks portfolio's `--brand-dot`; the client swapped it in for the flat - brand-gold `#e8a038` on 2026-08-28) — header only, + brand-gold `#e8a038` on 2026-08-28), used in the header only, while the footer credit stays plain "ks-design" text; the centered footer credit links to . - Audio must remain gesture-gated (autoplay policy) and fully silenced by @@ -77,4 +78,5 @@ tags are not guaranteed to be semantic versions. - Before pushing: exercise hover, the full play cycle, stop, mute, and a narrow viewport; run `npm --prefix website run check`, `npm run check` and `npm test`. - Sound needs a real gesture — a scripted `click()` grants no user activation. + Sound needs a real gesture because a scripted `click()` grants no user + activation. diff --git a/CLAUDE.md b/CLAUDE.md index 8636e6d..6d8b59a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,6 @@ -# CLAUDE.md — Ember +# CLAUDE.md: Ember @AGENTS.md -Use `AGENTS.md` as the instruction source of truth. Also read `README.md` for -the traced motion references, the client decisions, and how this repository's -checks were borrowed from the `kiaquila/web-design` template. +Use `AGENTS.md` as the instruction source of truth. Read task-relevant product +documents before changing the project. diff --git a/README.md b/README.md index 5fbd774..961683a 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,31 @@ -# Ember — interactive burn study +# Ember -A single-page interactive study for the **ks·design lab**: a dark tangled -wireframe figure smolders with golden embers, burns down completely, and -reassembles — scored by a deep meditative tuning-fork tone (136.1 Hz, -synthesized with the Web Audio API, no audio files). +A dark, tangled wireframe figure smolders with golden embers, burns down +completely, and reassembles as a new silhouette to a synthesized 136.1 Hz +tuning-fork tone. -## Source provenance - -- Motion reference: Pinterest pin — a hero - animation by `@skvortsov.design` (shared by NewForm's "Designer of the - Week"). Only the motion idea is reproduced (smoldering particle figure with - golden embers, smoke, and flying gold shards on a warm light-gray stage); - no assets, code, or copy were taken from it. -- Interaction/layout reference: — a - centered figure with a minimal control row at the bottom. -- Client decisions (Kristina, 2026-08-19; Play revised 2026-08-26): hover - makes the figure burn locally and then recover instead of burning away; - Play runs the burn-and-reassemble cycle with the tuning-fork strike exactly - once — the figure burns down completely, reassembles as a new silhouette, - and the control resets from Stop back to Play; the ash rebirth is the - point of no return — Stop before it recovers the original figure, Stop - after it lets the new silhouette finish reassembling (the shape-shifting rebirth - was tried and rolled back on 2026-08-19, then explicitly requested again on - 2026-08-26 as the ending of this single cycle; the endless repeat of the - cycle was dropped the same day); controls are - limited to play/stop and mute; the favicon is the wireframe ball with an - inverted palette for dark color schemes plus PNG fallbacks for Safari; the - wordmark is "ks·design" — tracked caps with the cornflower gradient dot on - the baseline, hugging the KS with a wide gap before DESIGN (indigo `#818cf8` - into cyan `#22d3ee` at 135°, shared with the ks portfolio's `--brand-dot`; - it replaced the flat brand-gold `#e8a038` on 2026-08-28) — used in the - header only; the footer credit stays plain - text: "Designed by ks-design · Built with AI workflows", linking to - . +View the live work at [ember.ks-design.art](https://ember.ks-design.art). ## Implementation -- [`website/src/index.html`](./website/src/index.html) — the whole page: - inline CSS and vanilla JS, canvas 2D rendering, Web Audio synthesis. No - dependencies, no network requests, system fonts only. The only sibling files - are the two baked favicon PNGs and the baked social card. -- [`website/scripts/build.mjs`](./website/scripts/build.mjs) — copies those - four files into `dist/` and refuses to build a page that gained an - off-origin reference or lost a favicon. The `og:` meta tags are the one - place the page may name its own origin in full (scrapers ignore relative - URLs); any other absolute URL still fails the build. -- [`website/scripts/make-og.mjs`](./website/scripts/make-og.mjs) — renders - [`website/src/og.png`](./website/src/og.png), the 1200×630 social card, in - pure seeded Node (`npm run og`). It ports the page's own figure builder and - draw pass — with the lump deformation flattened to a clean sphere — frozen - at the approved hover moment: a local smolder with gold shards, plus the - favicon's wireframe ball as a corner mark. No text is baked in; wording - stays in `og:title`/`og:description`. Composition is a client decision - (Kristina, 2026-08-20): sphere with the smolder at the upper right, chosen - as variant 1 from ten seeded candidates. The constants in the script pin - that choice; rerun `npm run og` to reproduce it byte for byte — the script - carries its own fixed-Huffman deflate, so the bytes do not depend on the - runtime's zlib build. A fingerprint of the page's figure-geometry section - is baked into the PNG and re-checked by the tests - ([`website/scripts/og-fingerprint.mjs`](./website/scripts/og-fingerprint.mjs)), - so reshaping the page's figure without regenerating the card fails the - build instead of leaving a stale card in other people's feeds. -- [`website/worker/index.ts`](./website/worker/index.ts) — the Cloudflare - Worker that serves `dist/` and attaches the security headers. -- Audio starts only after a user gesture (browser autoplay policy). The page - follows the same pattern as the reactive-dots reference: a best-effort - `AudioContext` resume on the first pointer move (works on returning visits - where the browser already trusts the site), a guaranteed unlock on any - click/tap/key (with a one-sample silent buffer for iOS), and the mute - button silences everything. -- The favicon is an inline SVG data URI with a `prefers-color-scheme: dark` - media query inside the SVG (supported by Chrome and Firefox). Safari - ignores SVG favicons, so [`website/favicon-32.png`](./website/src/favicon-32.png) - and [`website/apple-touch-icon.png`](./website/src/apple-touch-icon.png) carry - the same ball baked onto the page's warm-gray ground. -- `prefers-reduced-motion` disables rotation, sparks, and smoke while keeping - the play cycle functional. - -## Stage - -The study is published as its own Cloudflare Worker, `ember`, at -[ember.ks-design.workers.dev](https://ember.ks-design.workers.dev) and at the -custom domain [ember.ks-design.art](https://ember.ks-design.art) the portfolio -links. Its dashboard settings live in -[`docs/stage-hosting.md`](./docs/stage-hosting.md); the deploy itself is run -by Cloudflare, not by this repository. - -**Cloudflare builds the Worker from this repository.** The Git connection was -moved here from `kiaquila/web-design` after this repository was created, so -`main` here is what updates the stage and pull requests get a Cloudflare -preview build. The cutover, its verification and its rollback are written down -in [`docs/stage-hosting.md`](./docs/stage-hosting.md). The `ember/` path in the -old repository is kept in place as the rollback route. - -## Repository baseline - -The checks in this repository were **borrowed by hand** from the -`kiaquila/web-design` template — `scripts/check-repository.mjs` and the Codex -review gate started as copies of that template's own, at commit -`ea8501fdb90236fcb891e97b15f7a42a62f76ff1`, and were then cut down to what one -static page actually needs. - -That is the whole relationship. There is no baseline lock file, no release -manifest, no managed-file list and no updater: nothing here is synced, and -nothing upstream can change this repository. (`package-lock.json` at the root -and in `website/` is ordinary npm dependency pinning and unrelated to that -machinery.) Taking a later improvement means reading the -template again and porting the part that is worth porting, in a normal pull -request. These files are this project's own and may be edited freely. - -### Dependency and language policy - -Two more files were borrowed from the same template (`kiaquila/web-design#49`) -and trimmed the same way: - -- [`.github/dependabot.yml`](./.github/dependabot.yml) — weekly updates for - GitHub Actions and for the two directories that hold a `package.json` and a - lockfile (the root's YAML parser and `website/`'s build tooling). Minor and - patch releases arrive as one grouped pull request per ecosystem, majors stay - separate, and a cooldown holds a freshly published version back: 7 days by - default, and 14/7/3 by semver level for npm. Action tags are not guaranteed - to be semantic versions, so the GitHub Actions entry carries only the - default. This file schedules routine version updates and nothing else — - Dependabot alerts and security updates are repository settings and are - enabled there. -- [`.gitattributes`](./.gitattributes) — the four borrowed harness scripts and - their two test files are `linguist-vendored`, so the language bar describes - the page rather than its guardrails. The page, its build scripts and the - Worker are product code and stay counted. - -The `osv-scan` job in [`ci.yml`](./.github/workflows/ci.yml) follows the -template's two-step form: the scanner writes JSON under `continue-on-error`, -then `osv-reporter-action` turns it into pull-request annotations and fails the -job on a vulnerability. Both steps are pinned to the same SHA and the workflow's -permissions are unchanged (`contents: read`). - -How this repository was extracted from the monorepository, and the proofs taken -at the time, are recorded in -[`docs/migration/source-provenance.md`](./docs/migration/source-provenance.md). - -## Checks - -- `npm --prefix website run check` — the build plus its tests, including the - size budget for the four published files. This is the project's real check. -- `npm run check` — the repository guard: tracked generated output, committed - secrets, symbolic links, workflow permissions and action pinning, and the - presence of the two policy files below. It parses the workflows with the same - YAML the runner uses, which is the root's one dependency; `npm ci` installs - it. -- `npm test` — the guard's own tests and the Codex review gate's rules. -- `npm --prefix website run dev` — build and serve `dist/` on port 4660. -- Verify by hand: hover ignition and recovery, the single full Play cycle - (burn → gone → a new silhouette reassembles → the control resets to Play), - Stop both before and after the rebirth, mute, the footer link, the - favicon in light - and dark browser themes, and a narrow-viewport layout. Sound needs a real - gesture — a scripted `click()` grants no user activation, and a browser - profile that has already earned media engagement resumes the context - immediately, which hides exactly the bug a fresh profile reveals. - -## License - -Released under the [MIT License](./LICENSE). © 2026 Kristina Aquila. The -borrowed guard and review-gate scripts carry their own upstream notice in -[`third-party-notices.md`](./third-party-notices.md). +- The artwork is one dependency-free page in `website/src/index.html`, with + inline CSS and JavaScript, Canvas 2D, Web Audio, and system fonts. +- Hover creates a local burn and recovery. Play runs one complete burn and + rebirth cycle. Sound starts only after a user gesture and can be fully muted. +- The build publishes exactly four allowlisted files and rejects unexpected + files and off-origin references. The Cloudflare Worker serves them with + security headers. +- User-facing motion honors `prefers-reduced-motion`, and the controls use + native accessible elements. + +## Structure + +```text +website/ +├── src/index.html # page, rendering, interaction, and sound +├── src/*.png # two favicons and the social card +├── scripts/ # build, social card, and local preview +├── tests/ # shipped-output and policy tests +├── worker/index.ts # security headers for static assets +└── wrangler.json # deployable Worker configuration +``` diff --git a/docs/migration/source-provenance.md b/docs/migration/source-provenance.md index ec4ca73..ca02b83 100644 --- a/docs/migration/source-provenance.md +++ b/docs/migration/source-provenance.md @@ -14,7 +14,7 @@ were carried over by `git filter-repo` and then proved against the source. | Source subtree | `ember/` | | Source subtree tree object | `d813adb9d9e5794e4f9d73d74b68cc00a8e73ce6` | | Rewritten `main` | `ca149515a25da1bb218273c9ff4c993926ecebc6` | -| Tags | none — the source project carried no tag | +| Tags | none - the source project carried no tag | ## How the history was rewritten @@ -26,8 +26,8 @@ along: git filter-repo --path ember/ --path-rename ember/: ``` -The rename lifts `ember/website` to `website` and the two project documents — -`README.md` and `AGENTS.md` — to the repository root, which is the only topology +The rename lifts `ember/website` to `website` and the two project documents - +`README.md` and `AGENTS.md` - to the repository root, which is the only topology change the history rewrite makes. ## Proof taken before any migration edit @@ -36,7 +36,7 @@ All four checks were run on the filtered clone, before the baseline or any adaptation was committed. 1. **Exact tree.** The root tree of the rewritten `main` is - `d813adb9d9e5794e4f9d73d74b68cc00a8e73ce6` — the same tree object the source + `d813adb9d9e5794e4f9d73d74b68cc00a8e73ce6` - the same tree object the source repository published under `ember/` at `3b99cb3d23328013c28eb73ab8525b13b6992d9e`. All 15 files are therefore byte-identical to the source, not merely equivalent. @@ -55,7 +55,7 @@ adaptation was committed. 143 upstream commits and pruned the 141 that never touched `ember/`. 3. **No stray refs.** Only `main` was pushed. The source repository's one tag, `chaijana-iteration-01`, belongs to another project and was pruned by the - rewrite — `ref-map` records it as deleted — so no tag was carried over. + rewrite - `ref-map` records it as deleted - so no tag was carried over. 4. **Object integrity.** `git fsck --full --strict` reports no problem. ### The uncommitted `make-og.mjs` was not carried over @@ -93,8 +93,8 @@ The proof is the same tree object as above, and, for that one file, the blob: ## Commit map `git filter-repo` wrote a full old→new commit map for all 143 rewritten -commits. It is not committed — it describes the migration event, not the -product — and is kept locally at +commits. It is not committed - it describes the migration event, not the +product - and is kept locally at `~/projects/web-design/.claude/migration/ember-2026-08-21/`: | File | SHA-256 | @@ -139,7 +139,7 @@ they were. the reduced-motion handling, the favicons and the Worker's inline-source CSP exception all carry over unchanged. -## Repository checks — borrowed by hand +## Repository checks - borrowed by hand The checks in this repository were copied from the `kiaquila/web-design` template at commit `ea8501fdb90236fcb891e97b15f7a42a62f76ff1` and then reduced @@ -147,7 +147,7 @@ to what this project needs: `scripts/check-repository.mjs` keeps the tracked-fil secret, symlink and workflow rules and drops the policy engine around them, and the Codex review gate keeps its trust model and drops the marker comment and the second dispatching workflow. The guard reads workflows with a YAML parser rather -than the template's line patterns — the repository root's one dependency — so a +than the template's line patterns - the repository root's one dependency - so a quoted key or a flow-style step map cannot slip a rule. **This is a one-time manual borrowing, not an installation.** No baseline lock @@ -163,7 +163,7 @@ exceeds by design. The four published files are budgeted individually in `website/tests/site.test.mjs` from their measured sizes instead, and `og.png` keeps the 1 MiB limit this project already had. -## Cloudflare — prepared, not switched +## Cloudflare - prepared, not switched Nothing in Cloudflare was changed during this migration. When this repository was created the Worker `ember` still built from `kiaquila/web-design` at root diff --git a/docs/stage-hosting.md b/docs/stage-hosting.md index 2754918..47bb923 100644 --- a/docs/stage-hosting.md +++ b/docs/stage-hosting.md @@ -9,8 +9,8 @@ or committed here. The page is one self-contained HTML file, so its build copies rather than compiles: [`website/scripts/build.mjs`](../website/scripts/build.mjs) assembles -`dist/` from `src/` — exactly `index.html`, `favicon-32.png`, -`apple-touch-icon.png` and `og.png` — and fails if the page ever gains an +`dist/` from `src/` - exactly `index.html`, `favicon-32.png`, +`apple-touch-icon.png` and `og.png` - and fails if the page ever gains an off-origin reference, loses a favicon, references a file the build does not publish, or if `src/` grows a fifth file. @@ -41,7 +41,7 @@ headers are attached to every response, `html_handling: which serves the one page for any unknown path. The custom domain is bound to the Worker in Cloudflare, not to the Git -connection. Changing which repository builds this Worker does not touch it — +connection. Changing which repository builds this Worker does not touch it - which is exactly why the cutover below reused this Worker rather than creating a second one. @@ -52,7 +52,7 @@ absolute URLs are the only ones the build allows, and only as the complete another domain would therefore be a code change here, not just a dashboard change. -## Current connection — this repository builds the Worker +## Current connection - this repository builds the Worker **The cutover is done: the Worker `ember` builds from `kiaquila/ember`.** The Worker was created while the project lived in the `kiaquila/web-design` @@ -76,12 +76,12 @@ them, and no Cloudflare credential is stored in GitHub. | Build command | `npm run build` | | Production deploy command | `npm run stage:deploy` | | Non-production deploy command | `npm run stage:preview` | -| Included build watch path | default — this repository holds one project | +| Included build watch path | default - this repository holds one project | `kiaquila/web-design` no longer builds this Worker. Its `ember/` source path is still present there, which is what keeps the full rollback below available. -## Cutover to this repository — completed +## Cutover to this repository - completed This is the procedure the account owner followed, kept as the record of the order it takes and as the procedure to repeat if the connection ever has to be @@ -95,7 +95,7 @@ credentials live in Cloudflare. 3. **Disconnect the existing Git connection** from `kiaquila/web-design` before connecting the new one. Two repositories must never be able to build the same Worker at the same time. -4. Connect `kiaquila/ember` to the same Worker — do not create a second Worker, +4. Connect `kiaquila/ember` to the same Worker - do not create a second Worker, and do not rename this one. Cloudflare requires the dashboard name to match `name` in `website/wrangler.json`, and the `ember.ks-design.art` custom domain is attached to this Worker. @@ -114,7 +114,7 @@ every build, which is why it was cleared back to the default; narrowing it to ## Verify the stage - `https://ember.ks-design.workers.dev` and `https://ember.ks-design.art` both - return the study, and an unknown path returns the same page — the asset + return the study, and an unknown path returns the same page - the asset configuration is a single-page-application fallback, not a 404. - The security headers from `website/worker/index.ts` are present, including the Content-Security-Policy with `script-src 'self' 'unsafe-inline'`, @@ -132,7 +132,7 @@ every build, which is why it was cleared back to the default; narrowing it to ## Rollback -- **Fastest:** in Cloudflare, roll the Worker back to an earlier version — +- **Fastest:** in Cloudflare, roll the Worker back to an earlier version - including the pre-cutover version id recorded in step 1. That restores a previously served build without any Git change, and the custom domain follows it because it is bound to the Worker. @@ -148,6 +148,6 @@ every build, which is why it was cleared back to the default; narrowing it to `ember / stage` GitHub environment. Now that this repository owns the Worker, that entry describes a stage the monorepository no longer builds. Removing it is the account owner's next step there: its own pull request in that -repository, following its documented procedure for retiring a stage — and the +repository, following its documented procedure for retiring a stage - and the project source and history stay in place, both as history and as the full rollback route above. diff --git a/package.json b/package.json index 771a761..e097527 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "engines": { "node": ">=22.18.0" }, - "description": "Ember — an interactive burn study for the ks·design lab", + "description": "Ember: an interactive burn study for the ks·design lab", "devDependencies": { "yaml": "^2.9.0" } diff --git a/scripts/check-repository.mjs b/scripts/check-repository.mjs index 28c15c0..48bb264 100644 --- a/scripts/check-repository.mjs +++ b/scripts/check-repository.mjs @@ -3,7 +3,7 @@ It is deliberately small: Ember is one page, one Worker and a handful of scripts, so the guard checks the few things that would actually hurt if - they landed — generated output or a credential committed by accident, a + they landed - generated output or a credential committed by accident, a symbolic link, and a workflow that could hand its token to a pull request. Anything beyond that is left to review; a policy engine would be more code than the project it guards. @@ -18,7 +18,7 @@ import { spawnSync } from "node:child_process"; import { parse } from "yaml"; -/** Directories that are built, installed or cached — never committed. */ +/** Directories that are built, installed or cached - never committed. */ const GENERATED_DIRECTORIES = new Set([ ".wrangler", "coverage", @@ -93,7 +93,7 @@ export function checkRepository(root, files = trackedFiles(root)) { if (!stat.isFile() || stat.size > MAX_SCANNED_BYTES) continue; const buffer = readFileSync(path); - /* A NUL byte in the head means binary — og.png and the favicons land + /* A NUL byte in the head means binary - og.png and the favicons land here, and base64 pixel data would match the patterns below. */ if (buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0)) continue; const text = buffer.toString("utf8"); @@ -124,7 +124,7 @@ export function checkRepository(root, files = trackedFiles(root)) { /* GitHub reads a workflow file from one of two places. `issue_comment`, `pull_request_review`, `schedule` and `workflow_run` always run the default - branch's copy, so a branch cannot rewrite them — which is why the + branch's copy, so a branch cannot rewrite them - which is why the review-rerun workflow may hold `actions: write`. `pull_request` and `workflow_dispatch` run the copy on the ref being proposed or selected, and `push` runs the copy in the commit that was pushed; in those, a write grant @@ -142,8 +142,8 @@ const BRANCH_CONTROLLED_TRIGGERS = new Set([ const DEFAULT_BRANCH = "main"; /* Workflows are parsed rather than pattern-matched. Reading them line by line - invites a long tail of valid YAML spellings the reader does not know — - a quoted key, a flow-style step map — each of which is a hole rather than a + invites a long tail of valid YAML spellings the reader does not know - + a quoted key, a flow-style step map - each of which is a hole rather than a cosmetic miss, so the guard uses the same YAML the runner does. */ function triggerNames(on) { if (typeof on === "string") return [on]; @@ -167,7 +167,7 @@ function pushRestrictedToDefaultBranch(on) { if (!push || typeof push !== "object") return false; /* A `branches` filter alone means tag pushes do not fire the workflow. A `tags` filter turns them back on, and a tag push runs the tagged commit's - copy — branch-controlled again. `tags-ignore` only re-admits the tags it + copy - branch-controlled again. `tags-ignore` only re-admits the tags it does not name, so it is safe when it excludes every one of them. */ if ("tags" in push) return false; if ("tags-ignore" in push && !excludesEveryTag(push["tags-ignore"])) return false; @@ -246,7 +246,7 @@ export function checkWorkflow(name, text) { failures.push(`Workflow may not use write-all: ${name}`); } /* A branch-controlled workflow may not hold any write scope, however it is - spelled — `write-all` is only the loudest version of the same grant. */ + spelled - `write-all` is only the loudest version of the same grant. */ const branchControlled = branchControlledTriggers(on); if (branchControlled.length > 0 && grants.some((grant) => grant === "write" || grant === "write-all")) { failures.push( @@ -307,8 +307,8 @@ const ACTOR_CONTROLLED_REF = [ /github\.event\.(?:issue|comment|pull_request|client_payload|workflow_run)\b/ ]; -/* `if:` routes a job or step without fetching anything — it is where the - review-rerun workflow legitimately tests `github.event.comment` — and +/* `if:` routes a job or step without fetching anything - it is where the + review-rerun workflow legitimately tests `github.event.comment` - and `name:` is a label. Both are skipped only where they mean that: as a key of a job or a step. The same words nested inside a value, `strategy.matrix.name` or a `with.name` input, are data that can still reach a checkout. */ @@ -325,7 +325,7 @@ function deepStrings(value) { /** Every string under a job or step that could decide what is run. The walk is what stops this rule from becoming a list of places a ref can - sit — env, step inputs, reusable-workflow inputs, `run`, + sit - env, step inputs, reusable-workflow inputs, `run`, `working-directory`, `strategy.matrix` and whatever GitHub adds next all reach it by default. */ function executionInfluencingValues(node) { @@ -361,7 +361,7 @@ function actorControlledRefs(workflow) { /* A tag or branch reference is mutable, so a compromised action would run here on the next push without any change landing in this repository. A local - `./` action is this repository's own reviewed code — but its manifest can + `./` action is this repository's own reviewed code - but its manifest can itself call out to a mutable action, which is why checkActionManifest below holds those to the same rule. */ function unpinnedActions(name, references) { diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 26b0a16..4e421aa 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -5,7 +5,7 @@ Codex review for *this exact head* exists and came back clean. The chain is deliberately short: a trusted human comments "@codex review ", Codex answers, and this gate reads that answer. There is no marker - comment and no second dispatching workflow — the human comment is the + comment and no second dispatching workflow - the human comment is the record, and rerunning this run is what re-reads it. */ import { appendFileSync } from "node:fs"; diff --git a/scripts/codex-review-helpers.mjs b/scripts/codex-review-helpers.mjs index 638d70b..5e2aaf7 100644 --- a/scripts/codex-review-helpers.mjs +++ b/scripts/codex-review-helpers.mjs @@ -105,7 +105,7 @@ export function latestCodexNativeReviewResult(reviews = [], reviewComments = [], } /** Codex reports "no findings" as a plain comment rather than a review, so - that shape is accepted too — but only for this head and only after the + that shape is accepted too - but only for this head and only after the request it answers. */ export function isAcceptableCodexSummaryComment(comment, headSha, requestedAt, requestCommentId) { const body = String(comment?.body || ""); diff --git a/tests/check-repository.test.mjs b/tests/check-repository.test.mjs index 9cc6eb2..33b09ca 100644 --- a/tests/check-repository.test.mjs +++ b/tests/check-repository.test.mjs @@ -249,7 +249,7 @@ test("an inline permission map is a grant like any other", () => { test("push counts as branch-controlled unless it is pinned to the trusted branch", () => { /* A push workflow runs the pushed commit's own copy, so any branch that - can be pushed can rewrite it — unless the filter excludes every branch + can be pushed can rewrite it - unless the filter excludes every branch but the trusted one. */ const write = ["permissions:", " actions: write"].join("\n"); const unfiltered = ["on:", " push:", write].join("\n"); @@ -271,7 +271,7 @@ test("push counts as branch-controlled unless it is pinned to the trusted branch assert.deepEqual(checkWorkflow("pinned-inline.yml", pinnedInline), []); /* A `branches` filter alone keeps tag pushes from firing at all, but naming - any tag filter turns them back on — and a tag push runs the tagged + any tag filter turns them back on - and a tag push runs the tagged commit's copy, which anyone able to push a tag controls. */ const tagged = ["on:", " push:", " branches: [main]", " tags: ['*']", write].join("\n"); assert.match(checkWorkflow("tagged.yml", tagged).join("\n"), /branch-controlled trigger \(push\)/); @@ -285,7 +285,7 @@ test("push counts as branch-controlled unless it is pinned to the trusted branch test("a write-capable workflow may not name an actor-controlled ref", () => { /* A trusted event runs the default branch's workflow file, so the file is - trusted — but the code it checks out need not be. */ + trusted - but the code it checks out need not be. */ const pinned = `actions/checkout@${"a".repeat(40)}`; const checkout = [ "on:", @@ -307,7 +307,7 @@ test("a write-capable workflow may not name an actor-controlled ref", () => { ); /* The shell can fetch one too, so enumerating checkout actions is not - enough — the ref itself is what is banned. */ + enough - the ref itself is what is banned. */ const viaCli = [ "on:", " issue_comment:", @@ -327,7 +327,7 @@ test("a write-capable workflow may not name an actor-controlled ref", () => { assert.deepEqual(checkWorkflow("read.yml", readOnly), []); /* Parking the ref in an env value and expanding it later is the same - checkout, so env values count as well — at any level. */ + checkout, so env values count as well - at any level. */ const viaEnv = [ "on:", " issue_comment:", @@ -448,7 +448,7 @@ test("a write-capable workflow may not name an actor-controlled ref", () => { ].join("\n"); assert.deepEqual(checkWorkflow("routing.yml", routing), []); - /* `github.event.repository.*` is chosen by the repository, not an actor — + /* `github.event.repository.*` is chosen by the repository, not an actor - it is how the review-rerun workflow pins its trusted checkout. */ const trusted = [ "on:", @@ -502,7 +502,7 @@ test("a merge-queue run is branch-controlled too", () => { test("a local action's own steps are pinned like a workflow's", () => { /* `uses: ./...` is skipped in a workflow because it is this repository's - reviewed code — but that manifest can call out to a mutable action, so + reviewed code - but that manifest can call out to a mutable action, so it is held to the same rule. */ const composite = ["runs:", " using: composite", " steps:", " - uses: owner/action@main"].join("\n"); assert.match( diff --git a/website/package.json b/website/package.json index 2767ecd..2660ebf 100644 --- a/website/package.json +++ b/website/package.json @@ -2,7 +2,7 @@ "name": "ember-website", "version": "1.0.0", "private": true, - "description": "Ember — an interactive burn study for the ks·design lab", + "description": "Ember: an interactive burn study for the ks·design lab", "engines": { "node": ">=22.18.0" }, diff --git a/website/scripts/build.mjs b/website/scripts/build.mjs index f320349..70c24d2 100644 --- a/website/scripts/build.mjs +++ b/website/scripts/build.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /* Static build. The study is one self-contained HTML file plus the two baked favicon PNGs Safari needs and the baked social card, so the build copies - rather than compiles — and then checks the two properties the project + rather than compiles - and then checks the two properties the project promises, because a page that quietly grew a CDN link or lost its favicon would still "build" fine. @@ -35,11 +35,11 @@ const META_OWN_ORIGIN = new RegExp( ); /* The page must stay dependency-free: nothing fetched from another origin. - Enumerating the mechanisms that can pull bytes — element `src`, `srcset`, - `poster`, ``, CSS `url()` and `@import` — is a losing game, so + Enumerating the mechanisms that can pull bytes - element `src`, `srcset`, + `poster`, ``, CSS `url()` and `@import` - is a losing game, so the check inverts it: the one thing allowed to point outward is an anchor's href, and once exactly that attribute is blanked no off-origin - reference may remain anywhere in the document — an anchor's other + reference may remain anywhere in the document - an anchor's other attributes (an inline background, a `ping`) stay visible to the scan. Data URIs are stripped first because their payload is inert and base64 happily contains `//`. Character references are decoded before any of it, @@ -62,7 +62,7 @@ function decodeCharacterReferences(markup) { const DATA_URI = /data:[^"'\s)]+/gi; const ANCHOR_HREF = /(]*?\bhref\s*=\s*)(["'])[^"']*\2/gi; /* Two shapes of an off-origin reference: anything with `//`, and a special - scheme written without slashes — browsers normalise `http:cdn.example` to + scheme written without slashes - browsers normalise `http:cdn.example` to `http://cdn.example`, so the scheme alone is enough to leave the site. */ const OFF_ORIGIN_ANYWHERE = /(?:\b(?:https?|ftp|wss?):[^\s"'()<>]+)|(?:(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s"'()<>]+)/gi; diff --git a/website/scripts/make-og.mjs b/website/scripts/make-og.mjs index e006d71..a5a0dc7 100644 --- a/website/scripts/make-og.mjs +++ b/website/scripts/make-og.mjs @@ -1,27 +1,27 @@ #!/usr/bin/env node -/* Renders src/og.png — the 1200×630 social card for the Ember study. +/* Renders src/og.png - the 1200×630 social card for the Ember study. The figure is the page's own: buildFigure() and the draw pass from src/index.html are ported line for line (fibonacci-sphere shell, short edges capped at degree four, stray threads), so the card cannot drift away from what the study actually shows. The one deliberate departure: the lump - deformation is flattened (LUMP = 0), so the card reads as a clean sphere — + deformation is flattened (LUMP = 0), so the card reads as a clean sphere - the client picked this composition from ten candidates on 2026-08-20 (variant 1: sphere, smolder at the upper right). The state is the approved - hover moment — a local smolder with gold shards rising — not the full + hover moment - a local smolder with gold shards rising - not the full burn, because the card has to read at thumbnail size. Wording is left to og:title and og:description, so nothing here needs a font: only the figure, the warm stage, and the small wireframe-ball mark (the favicon's exact geometry) in the corner. - Pure Node and fully seeded — a float framebuffer, supersampled 2× and + Pure Node and fully seeded - a float framebuffer, supersampled 2× and encoded as a PNG by hand, including the deflate stream itself (fixed Huffman + greedy LZ77, integer math only). Node's zlib is deliberately not used for compression: its byte stream differs between zlib builds (Node 22 and Node 24+ ship different ones), which would leave the committed og.png dirty after a faithful `npm run og` on another runtime. With the encoder - in this file, the bytes depend on nothing but this file — reproducible on + in this file, the bytes depend on nothing but this file - reproducible on any Node the package supports. zlib is still imported, but only to verify the stream round-trips before the PNG is written. @@ -78,7 +78,7 @@ if (pageFingerprint !== PORTED_FIGURE_FINGERPRINT) { ); } -/* ---- seeded PRNG (mulberry32) — the only source of randomness ---------- */ +/* ---- seeded PRNG (mulberry32) - the only source of randomness ---------- */ let prngState = SEED >>> 0; function rand() { prngState = (prngState + 0x6d2b79f5) >>> 0; @@ -249,8 +249,8 @@ const lumps = []; for (let k = 0; k < 5; k++) { lumps.push({ ax: rnd(-2.4, 2.4), ay: rnd(-2.4, 2.4), az: rnd(-2.4, 2.4), - /* the rnd draw stays even at LUMP = 0 so the PRNG stream — and with it - every seeded variant — keeps the same layout as the candidate sheet */ + /* the rnd draw stays even at LUMP = 0 so the PRNG stream - and with it + every seeded variant - keeps the same layout as the candidate sheet */ ph: rnd(0, Math.PI * 2), amp: rnd(0.05, 0.13) * LUMP }); } @@ -670,6 +670,6 @@ const png = Buffer.concat([ writeFileSync(OUT, png); console.log( - `Wrote ${join("src", "og.png")} — ${W}×${H}, ${edges.length} edges, ` + + `Wrote ${join("src", "og.png")} - ${W}×${H}, ${edges.length} edges, ` + `${(png.length / 1024).toFixed(0)} KB, figure ${pageFingerprint.slice(0, 12)}` ); diff --git a/website/scripts/og-fingerprint.mjs b/website/scripts/og-fingerprint.mjs index 5d71443..c5c56ec 100644 --- a/website/scripts/og-fingerprint.mjs +++ b/website/scripts/og-fingerprint.mjs @@ -6,10 +6,10 @@ feed. This fingerprint is the tripwire, applied twice. make-og.mjs bakes the hash of the page's figure-geometry section into a tEXt chunk of og.png, and the tests recompute it from the shipped page and fail on a - mismatch — so a changed page with a stale card is a red build. And + mismatch - so a changed page with a stale card is a red build. And make-og.mjs itself refuses to render while the page hash disagrees with - PORTED_FIGURE_FINGERPRINT — the hash its hand-ported code was written - against — so rerunning it cannot quietly bless a stale render either. + PORTED_FIGURE_FINGERPRINT - the hash its hand-ported code was written + against - so rerunning it cannot quietly bless a stale render either. Only the geometry section is hashed, on purpose: palette or motion tweaks elsewhere in the page change how the live study moves, not what shape the diff --git a/website/scripts/serve.mjs b/website/scripts/serve.mjs index 612cb78..e8c4e45 100644 --- a/website/scripts/serve.mjs +++ b/website/scripts/serve.mjs @@ -20,7 +20,7 @@ async function resolveFile(pathname) { try { /* Decoding belongs inside the guard: a malformed escape like `/%` throws a URIError, which outside it would take the whole preview down. A bad - path and a missing file mean the same thing here — nothing to serve. */ + path and a missing file mean the same thing here - nothing to serve. */ const relative = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ""); const candidate = join(dist, relative); if (!candidate.startsWith(dist)) return null; @@ -39,7 +39,7 @@ createServer(async (request, response) => { const file = await resolveFile(url.pathname); if (!file) { /* The build publishes exactly the page and its two favicons, so there is - no 404 document to stream — reading one would crash the preview. */ + no 404 document to stream - reading one would crash the preview. */ response.writeHead(404, { "content-type": TYPES[".txt"] }); response.end(`Not found: ${url.pathname}\n`); return; diff --git a/website/src/index.html b/website/src/index.html index 58e7be2..8b01e6c 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -3,10 +3,10 @@ -Ember Study — ks-design lab - - - +Ember Study | ks-design lab + + + @@ -71,7 +71,7 @@ } .tag strong { color: var(--ink); font-weight: 600; } /* The brand dot is decorative, so the wordmark needs a real separator in the - accessible name — without it the mark is announced as one word. */ + accessible name - without it the mark is announced as one word. */ .visually-hidden { position: absolute; width: 1px; @@ -82,7 +82,7 @@ clip-path: inset(50%); white-space: nowrap; } - /* The dot sits nearer the KS than the DESIGN in a strict 1:2 proportion — + /* The dot sits nearer the KS than the DESIGN in a strict 1:2 proportion - 0.15em after the S, 0.30em before the D. The tag's 0.22em letter-spacing already pads the left side, so the left margin only trims it back. */ .dot { @@ -164,11 +164,11 @@
ks design · lab - study 01 — ember + study 01 · ember
-

hover to smolder — play to burn it down

+

hover to smolder · play to burn it down