Files
houseplan-card/demo/smoke_decor_text.mjs
Claudeandclaude[bot] 5a258f3128 docs(hygiene): свести дубли документов подсистем и снять устаревшее (#679)
Волна 2 эпика #674 — у каждого правила один дом, остальные места ссылаются.

DECOR-EDITOR.md ← BACKDROP.md + LIVE-TEXT.md: один документ с нумерованными
разделами (§3 подложка, §5 текст с живыми значениями), на которые теперь
указывают комментарии кода вместо несуществовавших «BACKDROP §2/§3»;
исправлено утверждение, что space-card не рисует декор (он рисует подложку
и картинки декора, но не фигуры, мебель и текст). LIGHT.md ← матрица
настроек света (перевод, тест назван явно: test/devices.test.mjs «issues
84/88»). DEVICE-PRESENTATION.md ← правила «что показывает маркер» из
FILTERING.md (порядок cover → light sources → device role, шторы,
медиаплееры); «в одном pull request» → «в одном коммите». CANVAS.md: §9.5
«Оптимизировать планы» → CONFIG-COMPATIBILITY.md, overlay и планарные грани
Walls → WALL-THICKNESS.md §10–11, таблицы «было/стало» сняты. TESTING-DEMO.md
→ demo/stand/README.md: карта демо-дома и «чего на стенде нет», ручной
чек-лист снят (ручной фазы в процессе нет). ISOMETRIC.md — только текущее;
история Stage 2/4 — docs/adr/570-isometric-stage4-visual-handoff.md.
SUN.md: удалённый контракт фона снят, правило бумаги — в текущем разделе.
UX-MODES.md: декор над заливками, а не «под комнатами»; «hidden isometric»;
follow-up из #3 — все выпущены. Шапки VACUUM, WARM-REMOUNT («Выровнять всё
по сетке» → «Оптимизировать планы»), WALL-THICKNESS, STYLING-HOOKS,
CONFIG-COMPATIBILITY (#33), PDF-EXPORT — без устаревших статусов и планов.
README EN/RU: абзац про пересъёмку скриншотов → CONTRIBUTING.md, RADAR и
PDF-EXPORT в списке документации, RU догнал EN (2.5D, повторное
использование загруженного изображения, STAIRS). Один список канонических
документов подсистем в AGENTS.md и промпте ревьюера (_process.yml).
WALL-THICKNESS.md ссылается на ADR 282.

Правки src/** и validation.py — только пути документов в комментариях.

Issue: #679
User-Visible: no
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 18:07:37 +00:00

235 lines
14 KiB
JavaScript

// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока и
// после явной правки хранится физически в `size_cm`; старый `size`
// до этого продолжает читаться без визуальной миграции;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
// (шаг 5°, Shift — мимо шага); ВИДИМЫЙ размер ручки — четверть хит-зоны
// (правка владельца 2026-08-05 «уменьшить в 4 раза»), сама хит-зона
// прежняя, пальцевая: 1.8 % видимого вида;
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
// вторую надпись поверх первой.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${id}"]`)
|| sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const ev = (type, target, x, y, extra = {}) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await settleMode();
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
// ================= 1. размера шрифта в диалоге больше нет ================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
c._curSpaceCfg.decor = [
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.legacyLarge = +attr('dtl', 'font-size') === 30;
out.legacySmall = +attr('dts', 'font-size') === 14;
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
// ================= 2. многострочный текст, по центру =====================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
c._decorSaveText(); await c.updateComplete;
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
const tsp = tspans(multi.id);
out.twoLinesRendered = tsp.length === 2
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
// блок центрирован и по вертикали: якорь ровно между строками
const ay = multi.y * 1000;
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
// длинная строка НЕ переносится сама (docs/DECOR-EDITOR.md §5: никаких автопереносов)
open(multi); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
c._decorSaveText(); await c.updateComplete;
out.noAutoWrap = tspans(multi.id).length === 1;
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
c._decorSaveText(); await c.updateComplete;
// ================= 3. ручки масштаба и поворота ==========================
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& !!frame().querySelector('.dtrot');
// --- визуал в 4 раза меньше, хит-зона прежняя (docs/DECOR-EDITOR.md §5.3) ---
out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5;
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
const hitR = rOf('.dthandle.dtrot');
const knobR = rOf('.dtknob');
const vw = c._viewOr(c._baseVb());
const expectHit = Math.max(vw.w, vw.h) * 0.018;
out.hitRadiusUnchanged = Math.abs(hitR - expectHit) < 0.1; // прежние 1.8 %
out.knobIsAQuarterOfTheHit = Math.abs(knobR * 4 - hitR) / Math.max(hitR, 1e-9) < 0.05;
out.knobTakesNoPointer = getComputedStyle(frame().querySelector('.dtknob')).pointerEvents === 'none';
out.handleTakesThePointer = getComputedStyle(frame().querySelector('.dthandle')).pointerEvents !== 'none';
// …и палец, попавший МИМО бусины, но внутрь прежнего круга, всё ещё берёт ручку
const rotEl = frame().querySelector('.dtrot');
const rcx = +rotEl.getAttribute('cx'), rcy = +rotEl.getAttribute('cy');
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
// линия использует общую рамку, но только с двумя ручками концов
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
const lineFrame = sr().querySelector('.dtframe.dtlineframe');
out.lineGetsEndpointFrame = !!lineFrame
&& lineFrame.querySelectorAll('.dthandle.dtendpoint').length === 2
&& !lineFrame.querySelector('.dtrot');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: физический размер растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const mediumSizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtm'));
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await settleLive();
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` становится физическим `size_cm` только после явной правки
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
const legacySizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtl'));
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesPhysical = grown.size === undefined && grown.scale === undefined
&& Math.abs((grown.size_cm || 0) - legacySizeCm0 * 2) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [rx, ry] = anchor();
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
ev('pointerdown', rot, rx + 100, ry);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
await c.updateComplete;
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
await c.updateComplete;
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((x) => x.id === 'dtm').angle;
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
ev('pointerup', stageEl(), rx + 100, ry + 2);
await c.updateComplete;
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
// …а без Shift тот же 1.1° снова прилипает к нулю
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
ev('pointermove', stageEl(), rx + 100, ry);
ev('pointerup', stageEl(), rx + 100, ry);
await c.updateComplete;
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
// ================= 4. инструмент «текст» по надписи = редактировать =======
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
await c.updateComplete;
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
out.chromiumTextUsesAtomicBoundingBox = getComputedStyle(el('dtm')).pointerEvents === 'bounding-box';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
&& c._decorTextDialog.text === target.text;
out.textToolMakesNoSecondLabel = c._decorList.length === before;
out.textToolDoesNotSelect = c._decorSel === null;
c._decorTextDialog = null; await c.updateComplete;
// по пустому месту — новая надпись
ev('pointerdown', stageEl(), 60, 940);
await c.updateComplete;
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
c._decorTextDialog = null; await c.updateComplete;
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
const lineEl = sr().querySelector('.decorlayer line.dshape');
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
ev('pointerdown', lineEl, 250, 800);
await c.updateComplete;
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
&& c._decorSel === null;
c._decorTextDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);