mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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
100 lines
5.3 KiB
JavaScript
100 lines
5.3 KiB
JavaScript
// 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)}`);
|
||
}
|
||
}
|