Files
houseplan-card/scripts/generate-moon-assets.mjs
T
Sergey MatyuninandClaude Opus 5.5 c8c470ed57 feat(view): the moon in its phase over the "Follow the Sun" background (#661)
At dawn, dusk and night the environment shows the moon in the top-left
corner of the scene, behind the plan: computed in the card from the home
coordinates and the browser clock (short Meeus series + topocentric
parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under
a continuous phase mask with the lit side always on the left (owner
2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of
the window rays. Everything but a small gate lives in the lazy
moon-runtime chunk, with its own 30 s ticker. General settings: "Sun"
becomes "Sun and Moon" with one switch, on for new installations
(DEFAULT_CONFIG), off for existing ones.

The initial View graph sat 728 B under its budget: the gate is paid for by
moving fifteen dialog-only strings of General settings into the lazy
settings dictionary (#459) and by one build fingerprint literal instead of
three, so the graph ends 4 B above dev. Golden: two new moon scenes, and
the two General settings help frames show «Sun and Moon»; the WSL artifact
test fixture now models scenes whose first capture awaits acceptance.

Issue: #661
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 12:25:46 +03:00

100 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Designer moon pack → `src/moon-art.generated.ts` (#661, variant A).
//
// The pack is one full-moon SVG; the card applies the phase as a mask (C5).
// The generator is the gate for what the designer's file may contain: a square
// 512 viewBox with the documented disc, vector shapes and gradients only — no
// filters, raster, fonts, scripts or external references — and at most 30 KB.
// Ids are prefixed so the art can live in the card's shadow root next to
// everything else. `--check` fails when the committed module has drifted.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MOON_PACK_DIR = path.join(ROOT, 'assets', 'moon', 'houseplan-1.0.0');
const OUT = path.join(ROOT, 'src', 'moon-art.generated.ts');
/** Brief to the designer: ≤ 30 KB before compression. */
export const MOON_SVG_MAX_BYTES = 30 * 1024;
const ALLOWED_TAGS = new Set([
'svg', 'title', 'defs', 'g', 'path', 'circle', 'ellipse', 'mask', 'radialGradient', 'linearGradient', 'stop',
]);
const ID_PREFIX = 'hp-moon-';
const fail = (message) => { throw new Error(`Moon pack: ${message}`); };
/** pack.json: identity, licence and the disc geometry the mask is built for. */
export function validateMoonPack(pack) {
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) fail('pack.json must be an object');
if (pack.schema_version !== 1) fail('schema_version must be 1');
if (pack.pack_id !== 'houseplan') fail('pack_id must be "houseplan"');
if (typeof pack.pack_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(pack.pack_version)) fail('pack_version must be semver');
if (pack.license !== 'MIT') fail('license must be MIT');
if (typeof pack.source_url !== 'string' || !pack.source_url.startsWith('https://')) fail('source_url must be https');
if (pack.file !== 'moon.svg') fail('file must be moon.svg');
if (JSON.stringify(pack.view_box) !== '[0,0,512,512]') fail('view_box must be [0, 0, 512, 512]');
if (JSON.stringify(pack.disc) !== '{"cx":256,"cy":256,"r":240}') fail('disc must be centred at 256 with r 240');
if (pack.mirror !== false) fail('mirror must be false (owner 2026-09-29)');
if (pack.lit_side !== 'left') fail('lit_side must be "left" (owner 2026-09-29)');
return pack;
}
/** The SVG body the card inlines: validated, title dropped, ids prefixed. */
export function moonArtFromSvg(source) {
const text = String(source).replace(/^/, '').trim();
if (Buffer.byteLength(text, 'utf8') > MOON_SVG_MAX_BYTES) fail(`moon.svg is larger than ${MOON_SVG_MAX_BYTES} bytes`);
if (/<!DOCTYPE|<!ENTITY|<\?|<!--|<!\[CDATA/i.test(text)) fail('moon.svg contains active XML');
if (/href\s*=|url\((?!#)|@import|base64|font-family|style\s*=/i.test(text)) fail('moon.svg references outside itself or carries styles');
for (const [, name] of text.matchAll(/<\/?([A-Za-z][\w:-]*)/g)) {
if (!ALLOWED_TAGS.has(name)) fail(`moon.svg contains <${name}>`);
}
const root = text.match(/^<svg\b([^>]*)>([\s\S]*)<\/svg>$/);
if (!root) fail('moon.svg is not one SVG root');
if (!/\bviewBox="0 0 512 512"/.test(root[1])) fail('moon.svg must have viewBox="0 0 512 512"');
let body = root[2].replace(/<title>[\s\S]*?<\/title>/g, '');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
if (new Set(ids).size !== ids.length) fail('moon.svg repeats an id');
for (const [, ref] of body.matchAll(/url\(#([^)]+)\)/g)) {
if (!ids.includes(ref)) fail(`moon.svg references a missing id: ${ref}`);
}
if (/[`\\]|\$\{/.test(body)) fail('moon.svg contains template-literal syntax');
body = body
.replace(/\bid="([^"]+)"/g, (_, id) => `id="${ID_PREFIX}${id}"`)
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${ID_PREFIX}${id})`);
return body;
}
export function generateMoonModule(packDir = MOON_PACK_DIR) {
const pack = validateMoonPack(JSON.parse(fs.readFileSync(path.join(packDir, 'pack.json'), 'utf8')));
const svg = fs.readFileSync(path.join(packDir, pack.file), 'utf8');
const art = moonArtFromSvg(svg);
const sha = createHash('sha256').update(svg).digest('hex');
return [
'/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */',
`// Designer pack assets/moon/${pack.pack_id}-${pack.pack_version} (#661). Lazy: imported only by`,
'// moon-runtime.ts, never by the initial View graph (C7).',
'// A static Lit template: no directive, no binding, nothing parsed at runtime but the art.',
"import { svg } from 'lit';",
`export const MOON_PACK = ${JSON.stringify(`${pack.pack_id}-${pack.pack_version}`)};`,
`export const MOON_ART_SHA256 = ${JSON.stringify(sha)};`,
`export const MOON_ART = svg\`${art}\`;`,
'',
].join('\n');
}
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) {
const next = generateMoonModule();
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
if (current !== next) {
console.error('src/moon-art.generated.ts is stale: run node scripts/generate-moon-assets.mjs');
process.exit(1);
}
console.log('moon art is up to date');
} else {
fs.writeFileSync(OUT, next);
console.log(`wrote ${path.relative(ROOT, OUT)}`);
}
}