diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd235ea..f05c525 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Fixed
+
+- Local `custom/` environment media is dropped from **every** production build, not just `dist:win` — `npm run build` and `npm run desktop` no longer hash contributor trial GIFs into `dist/`. Only the dev server reads the folder; `AVATAR_INCLUDE_CUSTOM=1` opts a production build back in. Replaces the `AVATAR_SHIP=1` flag, which is removed. A reformat or rename of the glob now fails the build instead of silently bundling the folder, and `npm test` covers the embargo (`scripts/custom-envs.test.mjs`) — a clean checkout has an empty `custom/`, so a green build proves nothing on its own. (#13)
+
## [0.6.0] — 2026-08-07
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 29089ca..b599095 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -112,7 +112,7 @@ Docs-only PRs still need a clear description; changelog entry optional unless th
- If a feature is **Electron-only**, say so in UI copy and docs; do not silently no-op in a confusing way when possible.
- If you fix something in the renderer, smoke-test **`dev:desktop`** for overlay/audio when the area touches those systems.
-- Shipping builds use `AVATAR_SHIP=1` on `dist:win` so local `custom/` environment GIFs are **not** packaged — do not “fix” shipping by committing trial media into `custom/`.
+- Every production build (`build`, `desktop`, `dist:win`) drops local `custom/` environment media from the bundle; only the dev server keeps it. Use `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` if you deliberately want trial media in a production build — and do not “fix” shipping by committing trial media into `custom/`.
### Settings and persistence
diff --git a/README.md b/README.md
index 242976a..31462f0 100644
--- a/README.md
+++ b/README.md
@@ -141,7 +141,7 @@ Gear → **Animations** → **Default** (greeting, then motion loop). Catalog: [
-**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default (local only; not shipped in the installer).
+**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default — picked up by the dev server only, and left out of every production build.
## Documentation
diff --git a/avatar/package.json b/avatar/package.json
index d07582b..e26b0c4 100644
--- a/avatar/package.json
+++ b/avatar/package.json
@@ -15,8 +15,8 @@
"desktop": "npm run build && cross-env NODE_ENV=production electron .",
"build": "vite build",
"icons": "node scripts/make-icons.mjs",
- "test": "node --test electron/*.test.cjs",
- "dist:win": "npm run icons && cross-env AVATAR_SHIP=1 vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis",
+ "test": "node --test electron/*.test.cjs scripts/*.test.mjs",
+ "dist:win": "npm run icons && vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis",
"lint": "eslint . --max-warnings=0",
"preview": "vite preview"
},
diff --git a/avatar/scripts/custom-envs.test.mjs b/avatar/scripts/custom-envs.test.mjs
new file mode 100644
index 0000000..536b818
--- /dev/null
+++ b/avatar/scripts/custom-envs.test.mjs
@@ -0,0 +1,225 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test, { after, before, describe } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+import viteConfig from '../vite.config.js';
+
+// A clean checkout has an empty custom/, so a build alone can never show that
+// the embargo still works — it would pass just as green with the plugin
+// deleted. These tests run the real plugin over the real environments.js
+// instead, which is what actually decides whether trial media gets bundled.
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const environmentsId = path.join(here, '..', 'src', 'config', 'environments.js');
+const environmentsSource = fs.readFileSync(environmentsId, 'utf8');
+
+/** Rollup hands `transform` a plugin context; only `error` matters here. */
+const rollupContext = {
+ error(message) {
+ throw new Error(typeof message === 'string' ? message : message.message);
+ },
+};
+
+/** @param {{ command: string, mode?: string }} env */
+function transformEnvironments(env, code = environmentsSource) {
+ const { plugins } = viteConfig({ mode: 'production', ...env });
+ const plugin = plugins
+ .flat(Infinity)
+ .find((entry) => entry && entry.name === 'avatar-strip-custom-envs');
+ assert.ok(plugin, 'vite.config.js no longer registers avatar-strip-custom-envs');
+ return plugin.transform.call(rollupContext, code, environmentsId);
+}
+
+function withoutOptIn(context) {
+ const previous = process.env.AVATAR_INCLUDE_CUSTOM;
+ delete process.env.AVATAR_INCLUDE_CUSTOM;
+ context.after(() => {
+ if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM;
+ else process.env.AVATAR_INCLUDE_CUSTOM = previous;
+ });
+}
+
+test('a production build strips the custom/ glob', (context) => {
+ withoutOptIn(context);
+
+ const result = transformEnvironments({ command: 'build' });
+
+ assert.ok(result, 'expected the plugin to rewrite environments.js');
+ assert.match(result.code, /const customModules = \{\};/);
+ assert.doesNotMatch(result.code, /import\.meta\.glob/);
+});
+
+test('the dev server keeps the custom/ glob', (context) => {
+ withoutOptIn(context);
+
+ assert.equal(transformEnvironments({ command: 'serve' }), null);
+});
+
+test('AVATAR_INCLUDE_CUSTOM=1 opts a build back in', (context) => {
+ withoutOptIn(context);
+ process.env.AVATAR_INCLUDE_CUSTOM = '1';
+
+ assert.equal(transformEnvironments({ command: 'build' }), null);
+});
+
+test('a build fails rather than silently bundling custom/ media', (context) => {
+ withoutOptIn(context);
+ const renamed = environmentsSource.replace(
+ 'const customModules = import.meta.glob(',
+ 'const customEnvModules = import.meta.glob(',
+ );
+ assert.notEqual(renamed, environmentsSource, 'fixture no longer matches environments.js');
+
+ assert.throws(() => transformEnvironments({ command: 'build' }, renamed), {
+ message: /custom\/ media would be bundled/,
+ });
+});
+
+test('other modules are left alone', (context) => {
+ withoutOptIn(context);
+
+ const other = path.join(here, '..', 'src', 'config', 'userSettings.js');
+ const { plugins } = viteConfig({ command: 'build', mode: 'production' });
+ const plugin = plugins
+ .flat(Infinity)
+ .find((entry) => entry && entry.name === 'avatar-strip-custom-envs');
+
+ assert.equal(plugin.transform.call(rollupContext, environmentsSource, other), null);
+});
+
+// The tests above assert the mechanism — that one glob in environments.js gets
+// rewritten. What we actually promise is stronger and only a real build can
+// show it: what sits in custom/ makes no difference to the bundle at all.
+// Three builds, shared by the assertions below, are enough to pin that down.
+
+const projectRoot = path.join(here, '..');
+const viteBin = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js');
+const customDir = path.join(projectRoot, 'src', 'assets', 'environments', 'custom');
+
+/** Deliberately different in count, extension and size from `probeSetB`. */
+const probeSetA = [
+ ['a1.gif', 64 * 1024],
+ ['a2.png', 512 * 1024],
+];
+const probeSetB = [
+ ['b1.gif', 1024],
+ ['b2.jpg', 3 * 1024 * 1024],
+ ['b3.jpeg', 128 * 1024],
+];
+
+/** Every emitted file as `relative/path md5`, sorted — not just asset names. */
+function manifestOf(dir) {
+ /** @type {string[]} */
+ const entries = [];
+ const walk = (current) => {
+ for (const name of fs.readdirSync(current)) {
+ const full = path.join(current, name);
+ if (fs.statSync(full).isDirectory()) walk(full);
+ else {
+ const hash = createHash('md5').update(fs.readFileSync(full)).digest('hex');
+ entries.push(`${path.relative(dir, full).replace(/\\/g, '/')} ${hash}`);
+ }
+ }
+ };
+ walk(dir);
+ return entries.sort();
+}
+
+/**
+ * Plants a named set of files in custom/ (leaving any local trial media
+ * alone), builds, removes them again, and returns the manifest. The stem
+ * depends only on `label`, so two builds of the same set differ by nothing but
+ * their env — otherwise a comparison between them would also be reading
+ * unrelated filename noise.
+ * @param {string} label
+ * @param {[string, number][]} probeSet
+ * @param {Record} env
+ */
+function buildWithProbesInCustom(label, probeSet, env) {
+ const stem = `probe-${process.pid}-${label}`;
+ const planted = probeSet.map(([suffix, size]) => {
+ const file = path.join(customDir, `${stem}-${suffix}`);
+ fs.writeFileSync(file, Buffer.alloc(size));
+ return file;
+ });
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'avatar-custom-envs-'));
+
+ try {
+ execFileSync(process.execPath, [viteBin, 'build', '--outDir', outDir, '--emptyOutDir'], {
+ cwd: projectRoot,
+ env: { ...process.env, ...env },
+ stdio: 'pipe',
+ });
+ } catch (cause) {
+ // stdio: 'pipe' keeps the build quiet when it works; when it does not, the
+ // reason has to reach the CI log or this failure is undiagnosable.
+ throw new Error(`vite build failed for probe set ${label}:\n${cause.stderr}`, { cause });
+ } finally {
+ for (const file of planted) fs.rmSync(file, { force: true });
+ }
+
+ return { stem, outDir, manifest: manifestOf(outDir) };
+}
+
+describe('a real build', { timeout: 600_000 }, () => {
+ /** @type {ReturnType} */
+ let plainA;
+ /** @type {ReturnType} */
+ let plainB;
+ /** @type {ReturnType} */
+ let optedInA;
+
+ before(() => {
+ const previous = process.env.AVATAR_INCLUDE_CUSTOM;
+ delete process.env.AVATAR_INCLUDE_CUSTOM;
+ try {
+ plainA = buildWithProbesInCustom('a', probeSetA, {});
+ plainB = buildWithProbesInCustom('b', probeSetB, {});
+ optedInA = buildWithProbesInCustom('a', probeSetA, { AVATAR_INCLUDE_CUSTOM: '1' });
+ } finally {
+ if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM;
+ else process.env.AVATAR_INCLUDE_CUSTOM = previous;
+ }
+ });
+
+ after(() => {
+ for (const built of [plainA, plainB, optedInA]) {
+ if (built) fs.rmSync(built.outDir, { force: true, recursive: true });
+ }
+ });
+
+ test('leaves custom/ media out of the bundle', () => {
+ assert.deepEqual(
+ plainA.manifest.filter((entry) => entry.includes(plainA.stem)),
+ [],
+ );
+ });
+
+ test('emits that same media when opted in', () => {
+ // Without this control the test above passes just as green when the probes
+ // are never planted or the assertion looks in the wrong place.
+ assert.equal(
+ optedInA.manifest.filter((entry) => entry.includes(optedInA.stem)).length,
+ probeSetA.length,
+ );
+ });
+
+ test('is byte-identical whatever custom/ holds', () => {
+ // Stronger than "our probes are absent": two different populations of
+ // custom/ must produce the same files with the same contents, so nothing
+ // in there can influence chunking or content hashes either.
+ assert.deepEqual(plainA.manifest, plainB.manifest);
+ });
+
+ test('and that comparison would notice if it were not', () => {
+ // Same probe set, same filenames, same manifest function — the only thing
+ // that differs is the opt-in, so a difference here can only mean custom/
+ // reached the bundle. Proves the comparison above can actually see one.
+ assert.notDeepEqual(optedInA.manifest, plainA.manifest);
+ });
+});
diff --git a/avatar/vite.config.js b/avatar/vite.config.js
index b5c62fd..b568260 100644
--- a/avatar/vite.config.js
+++ b/avatar/vite.config.js
@@ -2,41 +2,53 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
- * Production / installer builds must not bundle local custom/ trial GIFs.
- * Dev keeps the eager glob so dropping files into custom/ still works.
+ * Local `custom/` trial media is contributor-only and must never reach a
+ * production bundle — `build`, `desktop` and `dist:win` all drop it. The dev
+ * server keeps the eager glob so dropping files into custom/ still works; set
+ * AVATAR_INCLUDE_CUSTOM=1 to keep them in a build on purpose.
+ * @param {boolean} includeCustom
*/
-function stripCustomEnvsForShip() {
- const ship = process.env.AVATAR_SHIP === '1';
+function stripCustomEnvs(includeCustom) {
return {
name: 'avatar-strip-custom-envs',
transform(code, id) {
- if (!ship) return null;
+ if (includeCustom) return null;
const norm = id.replace(/\\/g, '/');
if (!norm.endsWith('/config/environments.js')) return null;
if (!code.includes('import.meta.glob')) return null;
- return {
- code: code.replace(
- /const customModules = import\.meta\.glob\([\s\S]*?\);/,
- 'const customModules = {};',
- ),
- map: null,
- };
+ const stripped = code.replace(
+ /const customModules = import\.meta\.glob\([\s\S]*?\);/,
+ 'const customModules = {};',
+ );
+ // A silent no-op here would quietly ship every file in custom/, so a
+ // rename or reformat of the glob must break the build, not the embargo.
+ if (stripped === code) {
+ this.error(
+ 'avatar-strip-custom-envs: the customModules glob in config/environments.js no longer ' +
+ 'matches this plugin, so custom/ media would be bundled. Update the pattern.',
+ );
+ }
+ return { code: stripped, map: null };
},
};
}
-export default defineConfig({
- base: './',
- assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'],
- plugins: [react(), stripCustomEnvsForShip()],
- server: {
- watch: {
- // `npm run thumbs` writes generated portraits into this directory while
- // the dev server is running. Watching it means each write invalidates the
- // module graph, reloads the generator, and starts the whole run again —
- // an endless loop that also interrupts in-flight VRM loads. These are
- // build assets; they never need hot reload.
- ignored: ['**/src/assets/avatars/thumbs/**'],
+export default defineConfig(({ command }) => {
+ // `serve` is the dev server; anything that emits a bundle drops custom/.
+ const includeCustom = command === 'serve' || process.env.AVATAR_INCLUDE_CUSTOM === '1';
+ return {
+ base: './',
+ assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'],
+ plugins: [react(), stripCustomEnvs(includeCustom)],
+ server: {
+ watch: {
+ // `npm run thumbs` writes generated portraits into this directory while
+ // the dev server is running. Watching it means each write invalidates the
+ // module graph, reloads the generator, and starts the whole run again —
+ // an endless loop that also interrupts in-flight VRM loads. These are
+ // build assets; they never need hot reload.
+ ignored: ['**/src/assets/avatars/thumbs/**'],
+ },
},
- },
+ };
});
diff --git a/docs/development/project-layout.md b/docs/development/project-layout.md
index 5cb3bdc..aba9fa2 100644
--- a/docs/development/project-layout.md
+++ b/docs/development/project-layout.md
@@ -43,7 +43,7 @@ End users personalize via **Settings → Directories** and **VRoid Hub** (see [U
| `npm run dist:win` | Contributors | Build Windows NSIS installer (local `desktop-setup/` output) |
| `npm run build` | CI / web | Production Vite bundle |
| `npm run lint` | Contributors / CI | ESLint (`src/`, `electron/**/*.cjs`, scripts; `--max-warnings=0`) |
-| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) |
+| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) and build-tooling tests (`scripts/*.test.mjs`, which run three real `vite build`s, so the suite takes ~15s) |
| `npm run thumbs` | Contributors | Re-render committed avatar portraits into `src/assets/avatars/thumbs/` |
### Continuous integration
diff --git a/docs/environments.md b/docs/environments.md
index 3ec5f1b..b181ee4 100644
--- a/docs/environments.md
+++ b/docs/environments.md
@@ -66,12 +66,14 @@ Built-ins are **never** replaced by a custom env directory (unlike avatars).
The `custom/` folder is for **local trial media** when Directories is still Default. Files inside it are **not** committed to git (the empty folder is kept via `.gitkeep`).
-**Shipped Windows builds** do not pack local Custom media. Prefer Settings → Directories for installer users.
+**Production builds** (`npm run build`, `npm run desktop`, `npm run dist:win`) do not pack local Custom media — only the dev server reads this folder. Prefer Settings → Directories for installer users.
1. Drop media into `avatar/src/assets/environments/custom/` (`.gif`, `.png`, `.jpg`, `.jpeg`).
-2. Restart Vite / Electron so Vite’s glob picks them up.
+2. Restart Vite (`npm run dev` / `npm run dev:desktop`) so Vite’s glob picks them up.
3. Gear → Appearance → Environments → open **Custom**.
+To check trial media against a production bundle, opt in explicitly — `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` (works in PowerShell, `cmd` and bash).
+
While Custom is open, built-in thumbs (Stars / Code / Bloom / None) hide so the library has room; close Custom to show them again. Scroll the Custom grid; Color fade stays visible underneath.