feat(card): LED strips in View — lazy chunk, stripe, linear light (#780)

Stage 3a of #780.

- src/led-strip-gate.ts is the only initial-graph foothold (ТЗ §13.1): which
  markers a space shows as a strip (active and bound), the half-length
  anchor that replaces their icon position, and one page-wide load of the
  lazy led-strip-runtime chunk (fingerprint handshake, a hashed-URL retry on
  the next explicit entry, never in a loop).
- The card: a represented marker takes no auto slot, draws no icon, casts no
  round pool and is placed at the anchor; two call sites render the stripe
  layer and the linear field from the chunk. The space model carries the
  stored strips untouched; scaling, validation and geometry are in the chunk.
- src/led-strip-runtime.ts: the stripe is two strokes of one derived path
  (outline #383838 t, core t/2, round joins and caps, t = 0.08/0.12 D),
  white off, white core + field on with Glow, source-colour core without
  Glow, grey dashed unavailable without a field. The hit path takes the
  card's own device handlers (one action path) with radius max(22 px, t/2)
  and the nearest stripe as owner. The linear field is the exact distance
  field with the shared falloff: opaque grey bands of a luminance mask per
  piece, pieces joined by lighten (the maximum), each piece clipped to what
  its own emitters see, so a hidden part never lights through another part's
  visibility; buried emitters emit nothing; a failed clip is dark. A bounded
  per-space cache (50) counts geometry rebuilds.
- The initial View graph had 501 B of headroom. The furniture library copy
  (furn.*, 104 keys × 4 languages, editor-only) moves into a new lazy `tools`
  namespace (#627 mechanism) that the editor runtime awaits; the initial
  View graph is 298 686 B gzip with the LED gate in it (−1 879 B vs the base).

Tests: test/led-strip-runtime.test.mjs (6: representation, gate anchor =
geometry anchor, falloff, states and radius, per-piece clipping and buried
strips, bounded cache), bundle and i18n fixtures for the LED chunk and the
fourth namespace.

Issue: #780
User-Visible: no
This commit is contained in:
claude[bot]
2026-10-02 11:24:03 +03:00
parent 76cf80fe6b
commit 4a9cec6943
28 changed files with 1383 additions and 488 deletions
+2 -1
View File
@@ -469,6 +469,7 @@ export const NAMESPACE_ENGLISH_CONSUMERS = {
settings: ['lazyEditorFiles', 'lazyOnboardingFiles'],
support: ['lazyEditorFiles'],
topology: ['lazyEditorFiles'],
tools: ['lazyEditorFiles'],
};
/**
@@ -775,7 +776,7 @@ export function assertBundleBudget(
const namespaceLocales = manifest.lazyNamespaceLocaleFiles;
if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) {
throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,`
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology × ru/de/fr)`);
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools × ru/de/fr)`);
}
for (const [graph, label] of [
['initialViewFiles', 'initial View graph'],
+23 -3
View File
@@ -12,13 +12,14 @@ const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__';
/**
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
* namespace × language. Their loader modules are the recognition marker for
* the manifest graph and the target of each second-attempt retry token.
*/
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology']
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools']
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
namespace,
language,
@@ -114,6 +115,10 @@ export function buildBundleManifest(bundle, fingerprint) {
// #661: the moon's math, art and template — loaded only at night.
: modules.some((id) => id.endsWith('/src/moon-runtime.ts'))
? 'moon'
// #780: LED strip geometry, light field and hit paths — loaded
// only when a shown space has an active strip.
: modules.some((id) => id.endsWith('/src/led-strip-runtime.ts'))
? 'led'
// #474: designer furniture artwork — its own lazy chunk, shared by
// the editor (static import) and the View runtime (dynamic).
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
@@ -159,6 +164,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|| path.includes('pdf-export-'));
const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon'
|| path.includes('moon-runtime-'));
const ledRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'led'
|| path.includes('led-strip-runtime-'));
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
@@ -177,6 +184,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const lazyMoon = graphFrom(moonRoots);
const lazyLed = graphFrom(ledRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
@@ -207,6 +215,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyPdfGzipBytes: sum(lazyPdf),
lazyMoonFiles: [...lazyMoon].sort(),
lazyMoonGzipBytes: sum(lazyMoon),
lazyLedFiles: [...lazyLed].sort(),
lazyLedGzipBytes: sum(lazyLed),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
@@ -276,6 +286,9 @@ export function editorRuntimeRetryUrlPlugin() {
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
if (!moon) throw new Error('moon runtime chunk was not emitted');
const led = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-runtime.ts')));
if (!led) throw new Error('LED strip runtime chunk was not emitted');
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
@@ -292,6 +305,7 @@ export function editorRuntimeRetryUrlPlugin() {
let frenchReplacements = 0;
let pdfReplacements = 0;
let moonReplacements = 0;
let ledReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -341,6 +355,12 @@ export function editorRuntimeRetryUrlPlugin() {
moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), led.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset);
}
for (const entry of namespaceLocales) {
if (!chunk.code.includes(entry.token)) continue;
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
@@ -351,9 +371,9 @@ export function editorRuntimeRetryUrlPlugin() {
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1 || moonReplacements !== 1) {
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}, expected 1/1/1/1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}, expected 1/1/1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
+2 -1
View File
@@ -361,7 +361,8 @@ export const SMOKE_LINKS = [
symbols: [
'namespaceLanguageRuntime', 'composeLanguageRuntimes', 'surfaceLanguageRuntime',
'SETTINGS_LANGUAGE_RUNTIME', 'SUPPORT_LANGUAGE_RUNTIME', 'TOPOLOGY_LANGUAGE_RUNTIME',
'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME', 'settingsT', 'supportT', 'topologyT',
'TOOLS_LANGUAGE_RUNTIME', 'EDITOR_LANGUAGE_RUNTIME', 'ONBOARDING_LANGUAGE_RUNTIME',
'settingsT', 'supportT', 'topologyT', 'toolsT',
],
smokes: ['smoke_lazy_admin_locale.mjs', 'smoke_dialog_segments_i18n.mjs'],
because: 'the ru/de/fr namespace dictionaries are separate lazy chunks: only the production '