Merge branch 'main' into fix/mera-keep-floor-in-draft
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m21s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-08-30 10:23:10 +00:00
commit f3e05e1907
10 changed files with 482 additions and 49 deletions

View file

@ -810,6 +810,33 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно:
# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний
# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не
# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить
# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно.
_RE_ADDR_WS = re.compile(r"\s+")
_RE_ADDR_BARE_MARKER = re.compile(
r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер"
r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$",
re.IGNORECASE,
)
def tidy_address(s: str) -> str:
"""«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29».
Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером.
"""
parts = []
for raw in s.split(","):
part = _RE_ADDR_WS.sub(" ", raw).strip()
if not part or _RE_ADDR_BARE_MARKER.match(part):
continue
parts.append(part)
return ", ".join(parts)
@dataclass(frozen=True, slots=True)
class GeocodeSuggestion:
label: str # формат "Малышева 30, Октябрьский район"
@ -822,6 +849,12 @@ class GeocodeSuggestion:
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
fias_id: str | None = None
def __post_init__(self) -> None:
# Единственная точка, через которую проходят ВСЕ тиры (cadastral/
# geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого.
object.__setattr__(self, "label", tidy_address(self.label))
object.__setattr__(self, "full_address", tidy_address(self.full_address))
# ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется

View file

@ -0,0 +1,70 @@
"""Склейка адреса в подсказках: пустые части ВЫПАДАЮТ, а не печатаются.
Прод-дефект: «г Екатеринбург, ул Фролова , д. 29, корп.» двойной пробел
перед запятой и висячее «корп.» без номера (ЕГРН `readable_address` отдаёт
маркер даже когда значения нет, и все тиры /suggest пропускали строку насквозь).
"""
import pytest
from app.services.geocoder import GeocodeSuggestion, tidy_address
@pytest.mark.parametrize(
("raw", "expected"),
[
# Прод-случай целиком.
(
"г Екатеринбург, ул Фролова , д. 29, корп.",
"г Екатеринбург, ул Фролова, д. 29",
),
# Непустой корпус ОБЯЗАН остаться.
(
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
),
# Соседние типы частей — та же болезнь, тот же механизм.
("г Екатеринбург, ул Мира, д. 1, стр.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, лит.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, кв.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, стр. 5", "г Екатеринбург, ул Мира, д. 1, стр. 5"),
("г Екатеринбург, ул Мира, д. 1, лит. А", "г Екатеринбург, ул Мира, д. 1, лит. А"),
# Пустая часть посередине и подряд идущие запятые.
("г Екатеринбург, , ул Мира, корп. , д. 1", "г Екатеринбург, ул Мира, д. 1"),
# Маркер без точки и без номера.
("г Екатеринбург, ул Мира, д 1, корпус", "г Екатеринбург, ул Мира, д 1"),
# Чистая строка не меняется.
("г Екатеринбург, ул Малышева, д 30", "г Екатеринбург, ул Малышева, д 30"),
],
)
def test_tidy_address(raw: str, expected: str) -> None:
assert tidy_address(raw) == expected
@pytest.mark.parametrize(
"raw",
[
"г Екатеринбург, ул Фролова , д. 29, корп.",
"г Екатеринбург, , ул Мира, корп. , д. 1",
"г Екатеринбург, ул Мира, д. 1, стр. , лит.",
],
)
def test_no_double_spaces_or_dangling_parts(raw: str) -> None:
out = tidy_address(raw)
assert " " not in out
assert " ," not in out
assert not out.endswith(",")
assert out.split(", ")[-1] not in {"корп.", "стр.", "лит.", "кв.", "д."}
def test_suggestion_cleans_both_label_and_full_address() -> None:
"""Чистка стоит в единой точке — её проходят ВСЕ тиры /suggest."""
s = GeocodeSuggestion(
label="ул Фролова , д. 29, корп.",
full_address="г Екатеринбург, ул Фролова , д. 29, корп.",
lat=56.8,
lon=60.6,
kind="house",
)
assert s.label == "ул Фролова, д. 29"
assert s.full_address == "г Екатеринбург, ул Фролова, д. 29"

View file

@ -0,0 +1,152 @@
/**
* contrast-tokens контраст WCAG 2.1 AA прямо на ЗНАЧЕНИЯХ токенов `b2c`,
* без браузера и без axe.
*
* Зачем. 30.08.2026 axe нашёл на публичном лэндинге 32 узла `color-contrast`
* (все severity=serious), и все они следствие одного и того же: пара
* «цвет × фон» подбиралась глазами, а число в комментарии писалось по памяти.
* Шапка `b2c-tokens.ts` утверждала «onAccent на accentDeep 4.6:1», реальный
* замер 4.41, то есть главная кнопка призыва не проходила AA, а
* документация уверяла, что проходит. Браузерный аудит ловит это раз в
* квартал; здесь то же самое считается на каждом прогоне тестов.
*
* Проверяются ОБА фона страницы: светлый (`pageBg`/`surface`/`surfaceSoft`) и
* тёмная плашка (`surfaceDark`). Половина из 32 нарушений была именно светлым
* токеном, положенным на тёмную плашку, на ней шкала перевёрнута.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { b2c } from "../b2c-tokens";
/** Относительная яркость по WCAG 2.1 (§ relative luminance). */
function luminance(hex: string): number {
const h = hex.replace("#", "");
const channels = [0, 2, 4]
.map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return (
0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
);
}
/** Контраст пары цветов по WCAG 2.1 (§ contrast ratio). */
export function contrast(fg: string, bg: string): number {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/** Норма AA для обычного текста. Крупный (≥24px / ≥18.66px bold) — 3.0. */
const AA_NORMAL = 4.5;
/** Светлые фоны лэндинга. Худший из них — `surfaceSoft`, он темнее `pageBg`. */
const LIGHT_BACKGROUNDS: ReadonlyArray<readonly [string, string]> = [
["surface", b2c.surface],
["pageBg", b2c.pageBg],
["surfaceSoft", b2c.surfaceSoft],
];
describe("контраст токенов b2c", () => {
it("формула считает эталонные пары верно", () => {
// Реперы: чёрное на белом — 21:1, цвет сам на себе — 1:1.
expect(contrast("#000000", "#FFFFFF")).toBeCloseTo(21, 1);
expect(contrast("#0E8496", "#0E8496")).toBeCloseTo(1, 5);
// Прежнее значение accentDeep — то самое 4.41, из-за которого кнопка
// призыва попала в отчёт axe. Если формула сломается, репер покажет это.
expect(contrast("#FFFFFF", "#0E8496")).toBeCloseTo(4.41, 2);
});
it("текстовые токены проходят AA на всех светлых фонах", () => {
const textTokens: ReadonlyArray<readonly [string, string]> = [
["ink", b2c.ink],
["muted", b2c.muted],
["accentText", b2c.accentText],
["success", b2c.success],
["danger", b2c.danger],
];
for (const [fgName, fg] of textTokens) {
for (const [bgName, bg] of LIGHT_BACKGROUNDS) {
expect(
contrast(fg, bg),
`${fgName} на ${bgName}`,
).toBeGreaterThanOrEqual(AA_NORMAL);
}
}
});
it("белый текст на заливке кнопки проходит AA", () => {
// .heroSubmitBtn, .hdrCta, .gameBtnAccent — сплошная заливка accentDeep.
expect(contrast(b2c.onAccent, b2c.accentDeep)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
});
it("на тёмной плашке текст красится mutedDark/accent, а не muted/accentText", () => {
// Что ГОДИТСЯ на surfaceDark.
expect(contrast(b2c.mutedDark, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
expect(contrast(b2c.accent, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
expect(contrast(b2c.onAccent, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
// Что НЕ годится — это и были метки тикера (2.85 и 2.7). Ассерт держит
// правило «на тёмном берём светлый конец шкалы»: если кто-то осветлит
// muted до проходного на плашке, он сломает его же на светлом фоне, и
// предыдущий тест покраснеет.
expect(contrast(b2c.muted, b2c.surfaceDark)).toBeLessThan(AA_NORMAL);
expect(contrast(b2c.accentText, b2c.surfaceDark)).toBeLessThan(AA_NORMAL);
});
it("accent не используется как текст на светлом: он и не может", () => {
// Ради этого в палитре и живёт отдельный accentText. Ассерт фиксирует
// причину, чтобы «упростить до одного акцента» нельзя было молча.
expect(contrast(b2c.accent, b2c.surface)).toBeLessThan(AA_NORMAL);
});
/**
* Тест выше защищает ЗНАЧЕНИЯ токенов но не поймал бы `color: #9fb0b4`,
* вписанный в CSS мимо палитры. Именно так и появились .artSoon (2.24),
* .gameScale, .repPaidFine, .artFootnote: сырой hex не проходит ни через
* какой аудит, потому что его никто не считает токеном.
*
* ponytail: список разрешённых снимок, а не расчёт: по CSS нельзя узнать,
* на какой подложке лежит правило (#ffffff годится на плашке и провален на
* карточке). Когда фон станет выводим (напр. все тёмные секции получат
* общий класс), эту тройку можно заменить настоящим расчётом.
*/
it("в CSS лэндинга нет новых сырых hex-цветов текста", () => {
const css = readFileSync(
join(__dirname, "..", "landing-v3.module.css"),
"utf8",
);
const found = [
...css.matchAll(/(?:color|fill):\s*(#[0-9a-fA-F]{3,8})\b/g),
].map((m) => m[1].toLowerCase());
const allowed = new Set([
"#ffffff", // текст на surfaceDark-плашках, 17:1
"#3fd1a6", // .tickerGain на плашке, 9.1:1
"#3a4a4f", // .gameVerdict на светлой карточке, 9.2:1
]);
expect([...new Set(found)].filter((h) => !allowed.has(h))).toEqual([]);
// Сам сканер проверен: он обязан находить hex вообще.
expect(found.length).toBeGreaterThan(0);
});
it("декоративные линии различимы как границы (норма 3.0 не для них)", () => {
// Границы — не текст, AA к ним не применяется; проверяем лишь, что они
// вообще отличаются от своей подложки, иначе рамка невидима.
expect(contrast(b2c.line, b2c.surface)).toBeGreaterThan(1.2);
expect(contrast(b2c.lineDark, b2c.surfaceDark)).toBeGreaterThan(1.2);
});
});

View file

@ -10,6 +10,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import type { LandingStat, ShowcaseResponse } from "../public-api";
@ -136,3 +137,31 @@ describe("витрина лэндинга v3 без данных", () => {
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
});
});
/**
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести,
* а не по величине ошибки, крупный промах в ней штатен. Проверяется, что
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой.
*/
describe("лента сделок: контекст разброса", () => {
const tickerDeal = (err_pct: number) => ({
...SHOWCASE.deals[0],
err_pct,
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
});
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
expect(note.textContent).toContain("11,5 %");
expect(note.textContent).toContain("75,7 %");
});
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
expect(note.textContent).toContain("7,8 %");
expect(note.textContent).not.toContain("75,7 %");
});
});

View file

@ -0,0 +1,90 @@
/**
* Инфраструктурные имена не должны попадать в файлы пользовательской копии.
*
* 30.08.2026 «Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026)»
* доехала до экрана: `CITY_COVERAGE_SOURCE` собирается в `coverage-copy.ts` и
* рендерится в `EstimateFlow.tsx:323`. На первом экране лендинга её не видно
* она появляется в форме проверки, после выбора города. Поэтому grep по
* отрендеренной главной давал ноль, и дефект выглядел несуществующим.
*
* Отсюда форма гейта: он смотрит в ИСХОДНИК, а не в вывод. Какой именно путь
* рендера сегодня показывает строку, знать не нужно завтра он будет другим,
* а хостнейм прода посетителю не сообщает ничего ни на одном из них.
*
* Скан идёт по всему тексту файла, включая комментарии: комментарий рядом с
* копией заготовка следующей строки копии, и «на боевой базе» в нём ничем
* не хуже имени машины.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..");
/**
* Имена машин, контейнеров и внутренних хостов. Список ЯВНЫЙ, а не эвристика:
* эвристика на «что-то похожее на хостнейм» ловила бы доменные имена в текстах
* про источники данных (domclick.ru, avito.ru), которые тут законны.
*
* `gendsgn.ru` в список НЕ входит намеренно: это публичный домен B2B, на
* который лендинг ссылается кнопкой «Для бизнеса» (`B2B_URL` в content.ts).
* Запрет на него сломал бы рабочую ссылку граница проходит по «внутреннее
* имя машины» против «адрес, который посетитель и так увидит в браузере».
*/
const BANNED = [
"poincare",
"beget",
"tradein-postgres",
"tradein-backend",
"tradein-scraper",
"gendesign-postgres",
"gendesign-backend",
"glitchtip",
"forgejo",
];
/** Файлы копии: то, из чего собирается видимый посетителю текст. */
function copySources(dir: string, acc: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (name === "__tests__" || name === "node_modules") continue;
const full = join(dir, name);
if (statSync(full).isDirectory()) {
copySources(full, acc);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
acc.push(full);
}
}
return acc;
}
describe("публичная копия не содержит инфраструктурных имён", () => {
const files = copySources(ROOT);
it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
// Без этой проверки сломанный обход каталога дал бы пустой список и
// «нарушений нет» на любом содержимом.
expect(files.length).toBeGreaterThan(20);
expect(files.some((f) => f.endsWith("landing-facts.ts"))).toBe(true);
expect(files.some((f) => f.endsWith("coverage-copy.ts"))).toBe(true);
});
it.each(BANNED)("нигде не встречается «%s»", (needle) => {
const hits: string[] = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
text.split("\n").forEach((line, i) => {
if (line.toLowerCase().includes(needle)) {
hits.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
}
});
}
expect(
hits,
`«${needle}» — инфраструктурное имя в файле пользовательской копии. ` +
`Посетителю оно не сообщает ничего: «на боевой базе» несёт весь смысл. ` +
`Строка может не рендериться сегодня и начать рендериться завтра — ` +
`так и вышло с CITY_COVERAGE_SOURCE в EstimateFlow.tsx.`,
).toEqual([]);
});
});

View file

@ -12,6 +12,15 @@
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
* объект описан тем, что есть комнаты, площадь, район, этаж.
*
* ПОД лентой подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
* что видит посетитель страницы про точность, промах в семьдесят процентов
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
* на первом же пересчёте витрины, а два числа рядом не протухают.
*
* Список дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
*
@ -24,34 +33,45 @@
import type { ShowcaseDeal } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { dealTitle, errPct, rub } from "./deal-view";
import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view";
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
const items = [...deals, ...deals];
// Разброс считается по `deals`, а не по `items`: `items` — та же двадцатка,
// склеенная сама с собой ради бесшовного marquee. Медиана и худшая на ней
// совпали бы, а `n` — нет, и подпись начала бы врать при первой же правке.
const spread = shownSpread(deals);
return (
<div className={styles.tickerRoot}>
<div className={styles.tickerBadge}>
<span className={styles.tickerDot} aria-hidden="true" />
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
<div className={styles.tickerStrip}>
<div className={styles.tickerBadge}>
<span className={styles.tickerDot} aria-hidden="true" />
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
</div>
</div>
{spread && (
<p className={styles.tickerNote}>
{`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
</p>
)}
</div>
);
}

View file

@ -4,7 +4,7 @@
* ЧИСЛА ИЗ МАКЕТА СНЯТЫ (29.08.2026). Макет утверждал «по нашим данным,
* средний разрыв между первой ценой в объявлении и ценой сделки 4,1%, а у
* провисевших дольше трёх месяцев доходит до 9%», «расхождение с кадастровой
* до 40%» и плитки 4,3% / ×2,4 / 83%. Прод-замер (poincare, 29.08.2026)
* до 40%» и плитки 4,3% / ×2,4 / 83%. Прод-замер (29.08.2026)
* не подтвердил ни одно:
*
* | макет | что показал замер |

View file

@ -22,10 +22,30 @@
* старом theme.ts было для #2e8bff);
* - `accentText` (#0B6B79) тот же оттенок, затемнённый до **5.8:1** на
* фоне страницы; им красится текст ссылок и акцентные подписи;
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) 4.6:1, годится
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0C7B8B) 4.97:1, годится
* для текста кнопок.
* Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а
* читаемость меняется принципиально.
*
* ПРАВКА 30.08.2026 (axe: 32 узла `color-contrast`, все serious). Прежние
* значения считались «по ощущению», и четыре из них не добирали до 4.5 а
* подписанное в этой шапке «4.6:1» для `accentDeep` #0E8496 было просто
* неверным замером: реально **4.41**. Затемнены до запаса 4.8 на худшем
* светлом фоне (`surfaceSoft` #EAF5F7 он темнее `pageBg`):
* accentDeep #0E8496 #0C7B8B (белый текст 4.41 4.97)
* muted #667579 #5B686C (на pageBg 4.45 5.36)
* success #1B8A5A #177A50 (на pageBg 4.04 4.96)
* danger #C0442E #B33D28 (на surfaceSoft 4.60 5.23)
*
* ТЁМНАЯ ПЛАШКА (`surfaceDark` #121A1D) второй «фон-тема» этой страницы, и
* правила там ПЕРЕВЁРНУТЫ: `muted` там 2.7:1, `accentText` 2.85:1. На тёмном
* текст красится `mutedDark` (6.2:1) и `accent` (6.24:1), т.е. светлым концом
* шкалы. Отсюда: перед тем как взять токен для текста, смотри, на ЧЁМ он
* лежит половина из 32 нарушений была именно светлым токеном на тёмной
* плашке.
*
* Числа проверяются тестом `__tests__/contrast-tokens.test.ts` он считает
* яркость по формуле WCAG прямо на значениях отсюда, без браузера.
*/
export const b2c = {
@ -41,7 +61,7 @@ export const b2c = {
/** Основной текст. */
ink: "#121A1D",
/** Вторичный текст, подписи. */
muted: "#667579",
muted: "#5B686C",
/** Границы, разделители. */
line: "#CBD7DA",
@ -73,16 +93,16 @@ export const b2c = {
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
accent: "#16A9BC",
/** Акцент для заливки кнопок (под белым текстом). */
accentDeep: "#0E8496",
accentDeep: "#0C7B8B",
/** Акцент для ТЕКСТА — затемнён до AA. */
accentText: "#0B6B79",
/** Текст на акцентной заливке. */
onAccent: "#FFFFFF",
/** Положительная дельта («выше средней цены»). */
success: "#1B8A5A",
success: "#177A50",
/** Отрицательная дельта, предупреждения. */
danger: "#C0442E",
danger: "#B33D28",
font: {
sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif",

View file

@ -44,7 +44,7 @@ export interface MeasuredValue {
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
const BACKTEST_SOURCE =
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
/**
@ -204,7 +204,7 @@ export interface CityCoverageMeasure {
}
export const CITY_COVERAGE_SOURCE =
"Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " +
"Симуляция пробы покрытия на боевой базе (30.08.2026): случайные адреса " +
"активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " +
"число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " +
"собственный адрес из когорты исключён";

View file

@ -635,8 +635,10 @@
}
.heroSubmitBtn {
/* accentDeep, не accent: белый текст на #16A9BC даёт ~2.8:1 (провал AA),
на accentDeep см. обоснование в b2c-tokens.ts. Затемнение на hover
/* accentDeep, не accent: белый текст на #16A9BC даёт 2.82:1 (провал AA), на
accentDeep 4.97:1 после затемнения токена 30.08 (до него было 4.41, и
axe считал главную кнопку призыва нарушением). См. b2c-tokens.ts.
Затемнение на hover
через filter, чтобы не заводить ещё один цветовой литерал (тот же приём,
что в .hdrCta выше). */
background: var(--b2c-accent-deep);
@ -1397,9 +1399,22 @@
/* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */
.tickerRoot {
background: var(--b2c-surface-dark);
}
/* Бегущая часть отделена от подписи: `overflow: hidden` и точка отсчёта для
абсолютного бейджа нужны ТОЛЬКО ей, иначе бейдж растянулся бы на всю высоту
вместе с подписью. */
.tickerStrip {
position: relative;
overflow: hidden;
background: var(--b2c-surface-dark);
}
.tickerNote {
margin: 0;
padding: 0 clamp(20px, 5vw, 56px) 10px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--b2c-muted);
}
.tickerBadge {
position: absolute;
@ -1438,7 +1453,7 @@
align-items: center;
gap: 20px;
padding: 0 30px;
border-right: 1px solid #2c3a3f;
border-right: 1px solid var(--b2c-line-dark);
white-space: nowrap;
}
.tickerObj {
@ -1451,23 +1466,27 @@
align-items: center;
gap: 8px;
}
/* Лента лежит на `surface-dark`, поэтому светлые токены здесь МЕНЯЮТСЯ
МЕСТАМИ со светлой темой: `muted` даёт на плашке 2.7:1, `accent-text`
2.85:1 (замер axe 30.08: обе метки в списке из 32 нарушений). На тёмном
читаются `muted-dark` (6.2:1) и сам `accent` (6.24:1). */
.tickerLabelMuted {
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--b2c-muted);
color: var(--b2c-muted-dark);
}
.tickerLabelAccent {
composes: tickerLabelMuted;
color: var(--b2c-accent-text);
color: var(--b2c-accent);
}
.tickerSaid {
font-size: 14px;
font-weight: 600;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.tickerArrow {
color: #2c3a3f;
color: var(--b2c-muted-dark);
font-size: 15px;
}
.tickerFact {
@ -1478,7 +1497,7 @@
.tickerErr {
font-family: var(--b2c-font-mono);
font-size: 11px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.tickerGain {
display: flex;
@ -1654,7 +1673,7 @@
.gameMeta {
margin-top: 8px;
font-size: 14px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.gameTags {
display: flex;
@ -1665,7 +1684,7 @@
.gameTag {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
border: 1px solid var(--b2c-line-dark);
border-radius: 4px;
padding: 5px 10px;
@ -1739,7 +1758,7 @@
margin-bottom: 24px;
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
color: var(--b2c-muted);
}
.gameBtnAccent {
display: block;
@ -1809,7 +1828,7 @@
}
.gameRowLabelMuted {
composes: gameRowLabel;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.gameRowValue {
font-weight: 800;
@ -1936,7 +1955,7 @@
}
.pathBodyDark {
composes: pathBody;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.pathBtnLight {
display: block;
@ -2368,7 +2387,7 @@
margin: 0;
font-size: 13px;
line-height: 1.55;
color: #9fb0b4;
color: var(--b2c-muted);
}
/* --- Раздел статей и хаб документов (макет 29.08) ------------------------- */
@ -2422,7 +2441,7 @@
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: #9fb0b4;
color: var(--b2c-muted);
}
.artToc {
margin-bottom: clamp(24px, 3vw, 32px);
@ -2463,7 +2482,7 @@
margin: 0 0 16px;
font-size: 16px;
line-height: 1.7;
color: #2c3a3f;
color: var(--b2c-line-dark);
text-wrap: pretty;
}
.artH2 {
@ -2565,7 +2584,7 @@
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: #9fb0b4;
color: var(--b2c-muted);
}
.artTiles {
display: grid;
@ -2614,7 +2633,7 @@
margin: 0 0 20px;
font-size: 15px;
line-height: 1.6;
color: #9fb0b4;
color: var(--b2c-muted-dark);
max-width: 520px;
}
.artCta .gameBtnAccent {