diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 096ce07..4e2289b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: matrix: node: [22, 24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} cache: npm @@ -38,12 +38,15 @@ jobs: - name: Unit + pipeline tests run: npm test + - name: Palette PNGs must be current + run: node scripts/render-palette.mjs --check + - name: Package VSIX run: npx vsce package --out alone.vsix - name: Upload VSIX if: matrix.node == 24 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: alone-vsix path: alone.vsix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..984991c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +# Tag-driven release: `git tag v2.0.0 && git push origin v2.0.0` +# build → verify → test → package the VSIX → GitHub Release (tags only) +# marketplace → publish that VSIX to the VS Code Marketplace (VSCE_PAT) +# open-vsx → publish the same VSIX to Open VSX (OVSX_PAT) +# The two registry jobs are independent, so if one fails (outage, expired +# token) "Re-run failed jobs" retries only that one — the Marketplace rejects +# a version that is already published, so re-running a combined job could +# never repair a half-finished release. A publish job is skipped when its +# secret is absent, so a workflow_dispatch run on a fork or before the +# accounts exist still produces a Release + VSIX. See docs/PUBLISHING.md. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + publish: + description: 'Publish to Marketplace / Open VSX (needs secrets)' + type: boolean + default: false + +permissions: + contents: write + +jobs: + build: + name: verify · package · release + runs-on: ubuntu-latest + outputs: + vsix: ${{ steps.pkg.outputs.vsix }} + publish: ${{ steps.flags.outputs.publish }} + has_vsce_pat: ${{ steps.flags.outputs.has_vsce_pat }} + has_ovsx_pat: ${{ steps.flags.outputs.has_ovsx_pat }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Tag must match package.json version + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + pkg="$(node -p "require('./package.json').version")" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag v$tag does not match package.json version $pkg"; exit 1 + fi + + - name: Build · verify · palette PNGs · test + run: npm run check && npm test + + - name: Package VSIX + id: pkg + run: | + npx vsce package --out "alone-$(node -p "require('./package.json').version").vsix" + echo "vsix=$(ls alone-*.vsix)" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v7 + with: + name: vsix + path: ${{ steps.pkg.outputs.vsix }} + if-no-files-found: error + + - name: GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + # Idempotent so a re-run of this job after a downstream failure succeeds. + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" --clobber + else + gh release create "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" \ + --title "Alone $GITHUB_REF_NAME" \ + --notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) for details." \ + --verify-tag + fi + + - name: Publish flags + id: flags + # `secrets` is not available in a job-level `if`, so surface presence as + # outputs (booleans only — no secret material crosses the job boundary). + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + DO_PUBLISH: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish == true }} + run: | + { + echo "publish=$DO_PUBLISH" + echo "has_vsce_pat=$([ -n "$VSCE_PAT" ] && echo true || echo false)" + echo "has_ovsx_pat=$([ -n "$OVSX_PAT" ] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + marketplace: + name: publish · VS Code Marketplace + needs: build + if: needs.build.outputs.publish == 'true' && needs.build.outputs.has_vsce_pat == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - uses: actions/download-artifact@v8 + with: + name: vsix + - name: vsce publish + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: npx vsce publish --packagePath "${{ needs.build.outputs.vsix }}" + + open-vsx: + name: publish · Open VSX + needs: build + if: needs.build.outputs.publish == 'true' && needs.build.outputs.has_ovsx_pat == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v7 + with: + node-version: 24 + - uses: actions/download-artifact@v8 + with: + name: vsix + - name: ovsx publish + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx --yes ovsx@1 publish "${{ needs.build.outputs.vsix }}" --pat "$OVSX_PAT" diff --git a/.vscodeignore b/.vscodeignore index eba21d3..9d50b4f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -12,6 +12,9 @@ themes/_src/** themes/_snapshot/** samples/** images/icon.svg +# README images are served from the GitHub repo (vsce rewrites relative URLs); only the icon ships +images/palette-*.png +images/screenshot-*.png *.vsix package-lock.json *.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 12697f8..320436f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,13 @@ New `tokenColors` rules and `semanticTokenColors` selectors in `themes/_src/base - **FAQ** rewritten ("Why no blue or cyan?", new "Does it protect my night vision?" and "Is it colour-blind safe?"). - **`CONTRIBUTING.md`** added; the "Building the Themes (Contributors)" section moves there and grows into layout, verifier contract, palette-change and new-variant procedures. `package.json` description no longer claims dark-adaptation protection. +### Added — Release pipeline, palette images, publishing guide + +- **`.github/workflows/release.yml`**: on a `v*` tag — tag/version match check → `npm run check && npm test` → `vsce package` → GitHub Release with the VSIX, then two independent publish jobs, `vsce publish` (`VSCE_PAT`) and `ovsx publish` (`OVSX_PAT`), so a failed registry can be re-run alone; a publish job skips when its secret is absent, and `workflow_dispatch` gives a dry run. CI actions bumped to `checkout@v7` / `setup-node@v7` / `upload-artifact@v7`. +- **`scripts/render-palette.mjs`** (`npm run render:palette`): dependency-free PNG palette strips per variant (`images/palette-*.png` — ladder with hex + L\*, bracket pairs, ANSI slots) shown in the README; `--check` runs in `npm run check`, CI and a new test. README images are excluded from the VSIX (`vsce` serves them from the repository). +- **`scripts/capture-screenshots.sh`**: reproducible macOS editor screenshots per variant (throwaway VS Code profile, recommended settings, `samples/demo.tsx`); README carries a labelled slot until they are captured. +- **`docs/PUBLISHING.md`**: Marketplace publisher + Azure DevOps PAT, Open VSX namespace/token, repository secrets, first-publish smoke test, screenshots, badges, troubleshooting. + ### Changed — Internal: verifier v2, tests, CI - **Verifier v2** (`scripts/verify-palette.mjs`). New checks alongside the L\* ladder, wavelength band, and key parity: an **APCA Lc** column and per-role floors (body 60 / syntax 40 / special 37 / punctuation 28 / comments 22, overridable per variant via `verify.apcaFloors` — Alone Soft declares a scaled set); **ΔE2000** on every tight ladder gap; a **colour-vision-deficiency** table (Viénot protan/deutan/tritan simulation) over the ten most confusable role pairs, passing on ΔE2000 ≥ 5 or a font-style difference; **ANSI** pairwise ΔE2000 among the eight normal terminal slots (≥ 10); and a **whole-theme wavelength scan** — every chromatic hex in every variant, not just the ten headline roles, must have a dominant wavelength ≥ 575 nm. The perceptual checks were introduced as warnings against the 1.3.1 palette (which missed several — comments Lc 16, ANSI blue/cyan ΔE 5.2, Variables/Parameter under CVD) and are hard failures from 2.0.0. Each check's severity is a one-line `POLICY` entry. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c1aef6..7e1d42d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,8 @@ The shipped `themes/*.json` files are **generated**. Don't edit them directly npm ci npm run build:themes # regenerate themes/*.json from _src/ npm run verify # L* ladder, APCA floors, CVD + ANSI ΔE2000, wavelength scan, key parity, README tables -npm run check # both, in sequence +npm run check # build + verify + palette-PNG freshness +npm run render:palette # regenerate images/palette-*.png after a palette change npm test # node --test tests/ node scripts/verify-palette.mjs --write-readme # re-render the verifier-owned README tables after a palette change npx @vscode/vsce package # build the VSIX locally @@ -45,6 +46,7 @@ npx @vscode/vsce package # build the VSIX locally - **ANSI** — the eight normal terminal slots pairwise ΔE2000 ≥ 10. - **Wavelength** — two parts. The ten ladder roles must pass the variant's declared band (`warm` = no blue/cyan hex). Separately, *every* chromatic hex in the theme — UI chrome, terminal, everything — must have a dominant wavelength ≥ 575 nm (`SCAN_MIN_NM`); neutrals below the chroma threshold are skipped. Note the whole-theme scan does not apply the declared band: a narrower band (a future `red-only` variant, say) constrains the ten roles, not the rest of the theme. - **Parity** — every variant has exactly the same keys, rules and selectors as every other. +- **Palette PNGs** — `images/palette-*.png` must match what `scripts/render-palette.mjs` produces (`--check`). - **README** — the tables between `` markers must match what the palette produces. The header of the script explains the policy: since 2.0.0 every check is a hard failure. If you are deliberately moving the palette, demote the affected check to `warn` in the same PR that changes the palette and restore it before merge — do not add environment overrides. @@ -53,7 +55,7 @@ The header of the script explains the policy: since 2.0.0 every check is a hard 1. Edit the hex in `themes/_src/variants/.yaml` (or `base.yaml` if it is shared by every variant). 2. `npm run check` — read the verifier output; fix any failure rather than relaxing the threshold. -3. `node scripts/verify-palette.mjs --write-readme` and commit the README diff along with the regenerated `themes/*.json`. +3. `node scripts/verify-palette.mjs --write-readme` and `npm run render:palette`; commit the README diff and `images/palette-*.png` along with the regenerated `themes/*.json`. 4. If you touched an ANSI slot, mirror it in the four `terminal/` files (the README ANSI table is the reference). 5. Note the change under `## [Unreleased]` in `CHANGELOG.md`. Visible look changes are a minor/major bump; fixes that don't change rendered colours are patch. @@ -74,3 +76,7 @@ Edit `base.yaml` only. Bind the new rule to an existing role token (`${tokenColo - Conventional commit prefixes: `feat:`, `fix:`, `docs:`, `test:`, `chore:`, `refactor:`. - `npm run check && npm test` green locally; CI runs the same plus `git diff --exit-code themes/` (committed JSON must be what the build produces) and a `vsce package` dry run on Node 22 and 24. - Keep generated JSON, README tables and CHANGELOG in the same PR as the palette change that caused them. + +## Releasing + +See [docs/PUBLISHING.md](docs/PUBLISHING.md): bump `version`, move CHANGELOG entries out of `[Unreleased]`, merge, tag `vX.Y.Z` — the release workflow packages, creates the GitHub Release and publishes to the Marketplace and Open VSX. diff --git a/README.md b/README.md index b1d9044..a48c96e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ **A theme for those who thrive coding alone in the dark.** - + +![Alone palette — syntax ladder, bracket pairs, ANSI slots](images/palette-alone.png) Alone is a warm, low-luminance dark theme for people who code in dark rooms for hours. Every colour in it — syntax, UI chrome, terminal — has a dominant wavelength between 575 and 611 nm: gold, amber, olive, terracotta, dusty rose. There is no blue, no cyan, no purple, and no white text. Foregrounds sit well below the brightness of a typical dark theme, contrast is tuned with APCA rather than maxed out, and a verifier script fails the build if any of that drifts. @@ -263,14 +264,20 @@ The full-featured theme with balanced contrast for extended coding sessions. Def For pitch-black rooms and light-sensitive eyes. All syntax colours reduced ~20 % in brightness, backgrounds slightly lifted to reduce contrast. Verified against its own, lower APCA floors. +![Alone Soft palette](images/palette-alone-soft.png) + ### Alone Focused For concentration. UI chrome is muted — activity-bar badges dimmed, sidebar de-emphasised, borders hidden. Syntax highlighting unchanged. Your code takes centre stage. +![Alone Focused palette](images/palette-alone-focused.png) + ### Alone Roman The Standard palette with **no italics** — for astigmatic readers who find slanted monospace edges fringe, or anyone who simply dislikes italic code. Every rule that Standard sets in italic (comments, strings, docstrings, regex, decorators, interfaces, type parameters, namespaces, `*.defaultLibrary`, `*.async`, `this`/`self`) is upright here; the only italic left is Markdown `*emphasis*`, which is the document's own formatting. Bold keywords/escapes/errors are unchanged. Two hexes differ from Standard so the pairs that Standard tells apart with italics stay separable on colour alone under red-green colour-vision deficiency: strings `#9A8B60 → #9C8B4A` and `*.defaultLibrary` `#B08C50 → #AA884C` (both verified ΔE2000 ≥ 5 after protan/deutan simulation). What you give up is the italic-only distinctions — interfaces look like classes, library calls like local ones, async like sync. +![Alone Roman palette](images/palette-alone-roman.png) + --- ## Terminal Themes diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..68eaa4a --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,124 @@ +# Publishing Alone + +Everything below the "Once" line is done once by the repository owner. After that a release is `git tag vX.Y.Z && git push origin vX.Y.Z` and the [release workflow](../.github/workflows/release.yml) does the rest. + +## How a release works + +1. Bump `version` in `package.json` (and `package-lock.json`, `npm version --no-git-tag-version X.Y.Z` does both), move the `[Unreleased]` CHANGELOG entries under `## [X.Y.Z] - YYYY-MM-DD`, commit on `main` through a PR. +2. `git tag vX.Y.Z && git push origin vX.Y.Z`. +3. The workflow runs three jobs. `build` checks the tag matches `package.json`, runs `npm run check && npm test`, packages `alone-X.Y.Z.vsix` and creates a GitHub Release with the VSIX attached. `marketplace` and `open-vsx` then each publish that same VSIX to their registry (`VSCE_PAT` / `OVSX_PAT`). The two publish jobs are independent: if one fails (outage, expired token) use **Re-run failed jobs** and only that one runs again — the other registry, which already has the version, is left alone. A publish job is skipped when its secret is missing, so the workflow is safe to run before the accounts exist. +4. Verify on the Marketplace page (a few minutes) and Open VSX (usually seconds). + +A dry run without publishing: **Actions → Release → Run workflow** with *publish* unchecked — produces the VSIX artifact and exercises everything except the two publish jobs. + +--- + +## Once: accounts, tokens, secrets + +### 1. VS Code Marketplace publisher `crypticpy` + +`package.json` already declares `"publisher": "crypticpy"`; the publisher ID on the Marketplace must match exactly. + +1. Sign in at with a Microsoft account (create one if needed — it can be tied to any email). +2. **Create publisher** → ID `crypticpy`, display name of your choice. If `crypticpy` is taken, pick another ID and change `"publisher"` in `package.json` before the first publish. + +### 2. Azure DevOps Personal Access Token (for `vsce`) + +The Marketplace authenticates `vsce` with an Azure DevOps PAT, not a Marketplace-side key. + +1. Go to with the same Microsoft account. If you have no organisation, create one (any name; it's only a container for the token). +2. User settings (top-right) → **Personal access tokens** → **New Token**: + - Name: `vsce-alone` + - Organization: **All accessible organizations** (required — a single-org token cannot publish) + - Expiration: custom, up to 1 year (put the expiry in your calendar) + - Scopes: **Custom defined** → *Show all scopes* → **Marketplace: Manage** (nothing else) +3. Copy the token now; it is shown once. +4. Sanity check locally (also verifies the publisher exists and the token has the right scope): + ```bash + npx vsce login crypticpy # paste the PAT + npx vsce ls-publishers + ``` + +### 3. Open VSX namespace + token (for `ovsx`) + +Open VSX is what VSCodium, Gitpod, Theia and Cursor-style forks read. + +1. Sign in at with GitHub. +2. Profile → **Access Tokens** → *Generate New Token* (name `ovsx-alone`). Copy it. +3. Claim the namespace once (it must equal the `publisher` field): + ```bash + npx --yes ovsx@1 create-namespace crypticpy --pat + ``` + Optionally file the [publisher-agreement / namespace ownership](https://github.com/EclipseFdn/open-vsx.org/wiki/Namespace-Access) issue so the namespace shows as verified. + +### 4. Repository secrets + +GitHub → repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Value | +| ---------- | ---------------------------------- | +| `VSCE_PAT` | the Azure DevOps PAT from step 2 | +| `OVSX_PAT` | the Open VSX access token from step 3 | + +The workflow only reads them on `v*` tags (or a manual run with *publish* checked). + +### 5. First publish (smoke test) + +Do the very first publish by hand so any Marketplace-side rejection (icon size, README links, publisher mismatch) is visible immediately: + +```bash +npm ci && npm run check && npm test +npx vsce package # alone-2.0.0.vsix +npx vsce publish --packagePath alone-2.0.0.vsix # uses the PAT from `vsce login` +npx --yes ovsx@1 publish alone-2.0.0.vsix --pat +``` + +Then check: + +- renders README, icon, palette PNGs; the four themes are listed under *Contributions*. +- exists. +- `code --install-extension crypticpy.alone` installs it and **Preferences: Color Theme** offers all four variants. + +From the next version on, just tag. + +--- + +## Screenshots + +`images/palette-.png` are generated (`npm run render:palette`, checked in CI). Real editor screenshots are captured on macOS with: + +```bash +scripts/capture-screenshots.sh # → images/screenshot-{alone,alone-soft,alone-focused,alone-roman}.png +``` + +The terminal you run it from needs **Screen Recording** and **Accessibility** permission (System Settings → Privacy & Security); without them the output is the desktop wallpaper. Install JetBrains Mono first so the shot matches the README's recommended settings. Then reference the images from README: + +```markdown + ← replace this comment with: +![Alone — samples/demo.tsx](images/screenshot-alone.png) +``` + +and add the Soft / Focused / Roman shots under **Theme Variants**. Keep each PNG under ~500 KB. README images are not packed into the VSIX (`.vscodeignore` excludes `images/palette-*.png` and `images/screenshot-*.png`); `vsce` rewrites relative README URLs to the GitHub repository, so the Marketplace page fetches them from `main`. That means images must be committed *before* the tag that publishes the README referencing them. + +Capture checklist if you prefer to do it by hand: window ~1440×900, `samples/demo.tsx` open, minimap on, no sidebar/panel, cursor on a bracket so the active-guide shows, one hover tooltip visible if you like; save as PNG, no window shadow (`screencapture -o`). + +--- + +## Badges (once live) + +Paste under the README title: + +```markdown +[![Marketplace](https://img.shields.io/visual-studio-marketplace/v/crypticpy.alone?label=Marketplace&color=D4A048)](https://marketplace.visualstudio.com/items?itemName=crypticpy.alone) +[![Installs](https://img.shields.io/visual-studio-marketplace/i/crypticpy.alone?color=C08868)](https://marketplace.visualstudio.com/items?itemName=crypticpy.alone) +[![Open VSX](https://img.shields.io/open-vsx/v/crypticpy/alone?label=Open%20VSX&color=9A8B60)](https://open-vsx.org/extension/crypticpy/alone) +[![CI](https://github.com/crypticpy/alone/actions/workflows/ci.yml/badge.svg)](https://github.com/crypticpy/alone/actions/workflows/ci.yml) +``` + +## Troubleshooting + +- `vsce publish` → *401 / The Personal Access Token used has expired or is invalid*: token scope must be **Marketplace: Manage** with **All accessible organizations**. +- *Publisher 'crypticpy' not found*: create it at the manage URL above (the ID must match `package.json`). +- *Extension version already exists*: bump `version`; the Marketplace never accepts re-publishing a version. +- Release workflow fails at *Tag must match*: `package.json` version and the tag differ — bump and re-tag (`git tag -d vX.Y.Z && git push --delete origin vX.Y.Z` first). +- Open VSX *namespace not found*: run `create-namespace` (step 3) once. diff --git a/images/palette-alone-focused.png b/images/palette-alone-focused.png new file mode 100644 index 0000000..abb0c07 Binary files /dev/null and b/images/palette-alone-focused.png differ diff --git a/images/palette-alone-roman.png b/images/palette-alone-roman.png new file mode 100644 index 0000000..d739840 Binary files /dev/null and b/images/palette-alone-roman.png differ diff --git a/images/palette-alone-soft.png b/images/palette-alone-soft.png new file mode 100644 index 0000000..841bdac Binary files /dev/null and b/images/palette-alone-soft.png differ diff --git a/images/palette-alone.png b/images/palette-alone.png new file mode 100644 index 0000000..8f436f1 Binary files /dev/null and b/images/palette-alone.png differ diff --git a/package.json b/package.json index 6645165..a218893 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,9 @@ "scripts": { "build:themes": "node scripts/build-themes.mjs", "verify": "node scripts/verify-palette.mjs", - "check": "npm run build:themes && npm run verify", + "check": "npm run build:themes && npm run verify && node scripts/render-palette.mjs --check", "test": "node --test tests/*.test.mjs", - "package": "vsce package" + "package": "vsce package", + "render:palette": "node scripts/render-palette.mjs" } } diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh new file mode 100755 index 0000000..1f4a489 --- /dev/null +++ b/scripts/capture-screenshots.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Capture README/Marketplace screenshots of every variant on macOS. +# +# scripts/capture-screenshots.sh # all four variants → images/screenshot-.png +# scripts/capture-screenshots.sh "Alone Soft" +# +# What it does: packages the current tree into a VSIX, installs it into a +# throwaway VS Code profile under /tmp (short path — VS Code's IPC socket +# path limit rejects long --user-data-dir values), opens samples/demo.tsx +# with the README's recommended editor settings and the given theme, waits +# for the window, and captures it with `screencapture`. +# +# Requirements (one-time, macOS): the terminal you run this from needs +# System Settings → Privacy & Security → Screen Recording (for screencapture) +# and → Accessibility (so osascript can read the window bounds). Without them +# the capture is the desktop wallpaper — that's how you know they're missing. +# +# JetBrains Mono (or the Nerd Font build) should be installed for the shot to +# match the README's recommended settings; the fallback is the system mono. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROFILE=/tmp/alone-shot +CODE_BIN="${CODE_BIN:-/Applications/Visual Studio Code.app/Contents/MacOS/Code}" +SAMPLE="${SAMPLE:-$ROOT/samples/demo.tsx}" +WAIT="${WAIT:-10}" +VARIANTS=("$@") +if [ ${#VARIANTS[@]} -eq 0 ]; then VARIANTS=("Alone" "Alone Soft" "Alone Focused" "Alone Roman"); fi + +[ -x "$CODE_BIN" ] || { echo "VS Code binary not found at $CODE_BIN (set CODE_BIN)"; exit 3; } +command -v screencapture >/dev/null || { echo "screencapture not found — this script is macOS-only"; exit 3; } + +rm -rf "$PROFILE" && mkdir -p "$PROFILE/user/User" "$PROFILE/ext" +(cd "$ROOT" && npx vsce package --out "$PROFILE/alone.vsix" >/dev/null) +"$CODE_BIN" --user-data-dir "$PROFILE/user" --extensions-dir "$PROFILE/ext" --install-extension "$PROFILE/alone.vsix" >/dev/null 2>&1 + +slug() { echo "$1" | tr '[:upper:] ' '[:lower:]-'; } + +for variant in "${VARIANTS[@]}"; do + cat > "$PROFILE/user/User/settings.json" </dev/null 2>&1 & + pid=$! + sleep "$WAIT" + out="$ROOT/images/screenshot-$(slug "$variant").png" + bounds="$(osascript -e 'tell application "System Events" to tell (first process whose name is "Code" and background only is false) to get {position, size} of window 1' 2>/dev/null || true)" + if [ -n "$bounds" ]; then + # "x, y, w, h" → screencapture -R x,y,w,h + rect="$(echo "$bounds" | tr -d ' ')" + screencapture -x -R "$rect" "$out" + else + echo " (no window bounds via osascript — capturing the whole main display; crop by hand)" + screencapture -x -m "$out" + fi + echo "wrote ${out#$ROOT/}" + kill "$pid" 2>/dev/null || true + pkill -f "$PROFILE" 2>/dev/null || true + sleep 2 +done + +rm -rf "$PROFILE" +echo "Done. Review images/screenshot-*.png, then reference them from README.md (see docs/PUBLISHING.md § Screenshots)." diff --git a/scripts/render-palette.mjs b/scripts/render-palette.mjs new file mode 100644 index 0000000..e38317d --- /dev/null +++ b/scripts/render-palette.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Render a palette strip PNG for every variant → images/palette-.png + * + * node scripts/render-palette.mjs # write all + * node scripts/render-palette.mjs --check # exit 1 if any PNG differs from what the palette produces + * + * Dependency-free: raw RGBA buffer + node:zlib deflate + hand-rolled PNG + * chunks, with a tiny 5×7 bitmap font for the labels. The strip shows the + * ten-rung syntax ladder (swatch, role, hex, L*), the six bracket colours and + * the sixteen ANSI slots, on the variant's own editor background. Used by the + * README / Marketplace page as an always-in-sync visual of the palette. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { hexToRgb255, cielabL } from './lib/color.mjs'; +import { LADDER_ROLES, resolveRole, bracketColors, ansiColors } from './lib/theme-roles.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const THEMES_DIR = path.join(ROOT, 'themes'); +const OUT_DIR = path.join(ROOT, 'images'); +const CHECK = process.argv.includes('--check'); + +// ─── 5×7 bitmap font (uppercase, digits, a few marks) ───────────────────── +// Each glyph: 7 rows of 5 bits, MSB = leftmost pixel. +const FONT = { + A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], + C: [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e], D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], + E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], + G: [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f], H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], + I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], + K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], + M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], N: [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], + O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], + Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], + S: [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e], T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], + W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], + Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], + 0: [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], 1: [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], + 2: [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], 3: [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], + 4: [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], 5: [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], + 6: [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], 7: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + 8: [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], 9: [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], + '#': [0x0a, 0x0a, 0x1f, 0x0a, 0x1f, 0x0a, 0x0a], '*': [0x00, 0x04, 0x15, 0x0e, 0x15, 0x04, 0x00], + '-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], '.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c], + '/': [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], ' ': [0, 0, 0, 0, 0, 0, 0], + '(': [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], ')': [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], + ',': [0x00, 0x00, 0x00, 0x00, 0x0c, 0x04, 0x08], +}; + +// ─── Canvas ───────────────────────────────────────────────────────────── +class Canvas { + constructor(w, h, bg) { + this.w = w; this.h = h; this.px = Buffer.alloc(w * h * 4); + this.rect(0, 0, w, h, bg); + } + set(x, y, [r, g, b]) { + if (x < 0 || y < 0 || x >= this.w || y >= this.h) return; + const i = (y * this.w + x) * 4; + this.px[i] = r; this.px[i + 1] = g; this.px[i + 2] = b; this.px[i + 3] = 255; + } + rect(x, y, w, h, rgb) { + for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.set(i, j, rgb); + } + /** Draw `text` (uppercased; unknown glyphs render as space) at (x,y), pixel scale `s`. Returns advance width. */ + text(x, y, str, rgb, s = 2) { + let cx = x; + for (const ch of String(str).toUpperCase()) { + const g = FONT[ch] ?? FONT[' ']; + for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) { + if (g[row] & (0x10 >> col)) this.rect(cx + col * s, y + row * s, s, s, rgb); + } + cx += 6 * s; + } + return cx - x; + } + png() { + const stride = this.w * 4; + const raw = Buffer.alloc((stride + 1) * this.h); + for (let y = 0; y < this.h; y++) { + raw[y * (stride + 1)] = 0; // filter: none + this.px.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(this.w, 0); ihdr.writeUInt32BE(this.h, 4); + ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // 8-bit RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); + } +} + +const CRC_TABLE = new Uint32Array(256).map((_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); + const td = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td), 0); + return Buffer.concat([len, td, crc]); +} + +// ─── Layout ───────────────────────────────────────────────────────────── +const W = 1200, PAD = 32, ROW = 40, SW = 28; + +export function renderVariant(theme) { + const bg = hexToRgb255(theme.colors['editor.background']); + const fg = hexToRgb255(theme.colors['editor.foreground']); + const dim = hexToRgb255(theme.colors['editorLineNumber.foreground'] ?? theme.colors['editor.foreground']); + const ladder = LADDER_ROLES.map((r) => resolveRole(theme, r)); + const brackets = bracketColors(theme); + const ansi = ansiColors(theme); + const H = PAD + 24 + ladder.length * ROW + 24 + 60 + 24 + 60 + PAD; + const c = new Canvas(W, H, bg); + let y = PAD; + c.text(PAD, y, `${theme.name} - syntax ladder, brackets, ANSI`, fg, 2); y += 24; + for (const r of ladder) { + c.rect(PAD, y, SW * 2, SW, hexToRgb255(r.hex)); + const label = `${r.name}${r.style.italic ? ' (italic)' : ''}${r.style.bold ? ' (bold)' : ''}`; + c.text(PAD + SW * 2 + 16, y + 6, label.padEnd(22), hexToRgb255(r.hex), 2); + c.text(PAD + SW * 2 + 16 + 22 * 12 + 16, y + 6, r.hex, hexToRgb255(r.hex), 2); + c.text(PAD + SW * 2 + 16 + 22 * 12 + 16 + 8 * 12 + 16, y + 6, `L* ${cielabL(r.hex).toFixed(0)}`, dim, 2); + // a bar proportional to L*, like the README ladder + const barX = PAD + SW * 2 + 16 + 22 * 12 + 16 + 8 * 12 + 16 + 6 * 12 + 16; + c.rect(barX, y + 8, Math.round((W - PAD - barX) * cielabL(r.hex) / 100), SW - 16, hexToRgb255(r.hex)); + y += ROW; + } + y += 8; + c.text(PAD, y, 'bracket pairs (depth 1-6)', dim, 2); y += 20; + brackets.forEach((b, i) => c.rect(PAD + i * (SW * 2 + 8), y, SW * 2, SW, hexToRgb255(b.hex))); + y += SW + 28; + c.text(PAD, y, 'ANSI 0-7 / 8-15', dim, 2); y += 20; + ansi.forEach((a, i) => c.rect(PAD + (i % 8) * (SW * 2 + 8), y + Math.floor(i / 8) * (SW + 6), SW * 2, SW, hexToRgb255(a.hex))); + return c.png(); +} + +function main() { + const files = fs.readdirSync(THEMES_DIR).filter((f) => f.endsWith('-color-theme.json')).sort(); + if (!CHECK) fs.mkdirSync(OUT_DIR, { recursive: true }); + let drift = 0; + for (const f of files) { + const theme = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, f), 'utf8')); + const out = path.join(OUT_DIR, `palette-${f.replace('-color-theme.json', '')}.png`); + const png = renderVariant(theme); + if (CHECK) { + const cur = fs.existsSync(out) ? fs.readFileSync(out) : null; + if (!cur || !cur.equals(png)) { console.error(`✗ ${path.relative(ROOT, out)} is stale — run: node scripts/render-palette.mjs`); drift++; } + else console.log(`✓ ${path.relative(ROOT, out)} current`); + } else { + fs.writeFileSync(out, png); + console.log(`wrote ${path.relative(ROOT, out)} (${png.length} bytes)`); + } + } + if (drift) process.exit(1); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/tests/render-palette.test.mjs b/tests/render-palette.test.mjs new file mode 100644 index 0000000..4ab9852 --- /dev/null +++ b/tests/render-palette.test.mjs @@ -0,0 +1,41 @@ +/** + * The palette-strip renderer must produce a well-formed, deterministic PNG + * for every committed theme, and the committed PNGs must be current. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { renderVariant } from '../scripts/render-palette.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const themes = fs.readdirSync(path.join(ROOT, 'themes')).filter((f) => f.endsWith('-color-theme.json')).sort(); + +test('renderVariant emits a valid RGBA PNG whose IDAT inflates to width*height*4 (+filter bytes)', () => { + for (const f of themes) { + const theme = JSON.parse(fs.readFileSync(path.join(ROOT, 'themes', f), 'utf8')); + const png = renderVariant(theme); + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], `${f}: PNG signature`); + assert.equal(png.toString('latin1', 12, 16), 'IHDR'); + const w = png.readUInt32BE(16), h = png.readUInt32BE(20); + assert.equal(png[24], 8); assert.equal(png[25], 6); // 8-bit RGBA + const idatLen = png.readUInt32BE(33); + assert.equal(png.toString('latin1', 37, 41), 'IDAT'); + const raw = zlib.inflateSync(png.subarray(41, 41 + idatLen)); + assert.equal(raw.length, (w * 4 + 1) * h, `${f}: decoded size`); + assert.equal(png.toString('latin1', png.length - 8, png.length - 4), 'IEND'); + } +}); + +test('renderVariant is deterministic and images/palette-*.png are current', () => { + for (const f of themes) { + const theme = JSON.parse(fs.readFileSync(path.join(ROOT, 'themes', f), 'utf8')); + const a = renderVariant(theme), b = renderVariant(theme); + assert.ok(a.equals(b), `${f}: two renders differ`); + const out = path.join(ROOT, 'images', `palette-${f.replace('-color-theme.json', '')}.png`); + assert.ok(fs.existsSync(out), `${path.relative(ROOT, out)} missing — run npm run render:palette`); + assert.ok(fs.readFileSync(out).equals(a), `${path.relative(ROOT, out)} stale — run npm run render:palette`); + } +});