diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx
index 09fc21fc..3ea09a09 100644
--- a/tradein-mvp/frontend/src/app/v2/page.tsx
+++ b/tradein-mvp/frontend/src/app/v2/page.tsx
@@ -403,7 +403,10 @@ export default function TradeInV2Page() {
useEffect(() => {
const compute = () =>
setArtboardScale(
- Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
+ Math.max(
+ 0.88,
+ Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
+ ),
);
compute();
window.addEventListener("resize", compute);
@@ -469,6 +472,11 @@ export default function TradeInV2Page() {
estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null;
+ // M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
+ // «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
+ // Folds in `mounted` so SSR and the first client render agree (false → hidden)
+ // — no hydration drift, same reason the PDF control is gated behind `mounted`.
+ const hasEstimate = mounted && estimate != null && !insufficient;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
// geometry). ──────────────────────────────────────────────────────────
@@ -716,6 +724,7 @@ export default function TradeInV2Page() {
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
+ hasEstimate={hasEstimate}
/>
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
index b6255a2a..ba54df7a 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
@@ -78,6 +78,20 @@ const cardStyle = {
padding: "16px 18px",
} as const;
+// L1 — a "обычно X–Y дн" band is only honest with ≥2 lots AND a non-degenerate
+// span. From a single analog (n<2) or when min===max ("237–237") the band is a
+// fake range, so we suppress it and keep just the point estimate (tier.days) +
+// the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the
+// parsed range endpoints, so either signal alone drops the band.
+function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
+ if (!tier.range || tier.range === "—") return false;
+ const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1
+ if (Number.isFinite(n) && n < 2) return false;
+ const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18–180" → [18,180]
+ if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false;
+ return true;
+}
+
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
onNavigate,
@@ -107,8 +121,29 @@ export default function AnalyticsView({
[avitoPts, yandexDots],
);
+ // L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any
+ // series that drew no points so the subtitle matches the rendered legend (the
+ // swatches below are already gated on *Pts.length). If the note format changes
+ // the replace is a graceful no-op.
+ const activeSeriesNames = [
+ avitoPts.length > 0 ? "Avito" : null,
+ yandexPts.length > 0 ? "Яндекс" : null,
+ ].filter((s): s is string => s != null);
+ const historyNote =
+ activeSeriesNames.length > 0
+ ? data.priceHistory.note.replace(
+ /Avito \+ Яндекс/,
+ activeSeriesNames.join(" + "),
+ )
+ : data.priceHistory.note;
+
return (
-
+
{/* header */}
{kpi.label}
@@ -216,8 +253,12 @@ export default function AnalyticsView({
@@ -240,7 +281,7 @@ export default function AnalyticsView({
История цен в этом доме
- {data.priceHistory.note}
+ {historyNote}
@@ -280,7 +321,10 @@ export default function AnalyticsView({
viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }}
preserveAspectRatio="none"
+ role="img"
+ aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
>
+ История цен в этом доме — медиана ₽/м² по годам
@@ -454,7 +498,10 @@ export default function AnalyticsView({
);
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx
index cfb9c908..cf79a55e 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx
@@ -9,9 +9,12 @@ import type { Report } from "./types";
interface FooterProps {
data?: Report;
+ // M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
+ // (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
+ hasEstimate?: boolean;
}
-export function Footer({ data = report }: FooterProps) {
+export function Footer({ data = report, hasEstimate }: FooterProps) {
return (
+ ) : (
+ // Loading / empty: a status note, NOT a role="listbox" (it has no
+ // selectable options) so aria-required-children stays satisfied.
+
+
+ {suggest.isFetching ? "Поиск…" : "Ничего не найдено"}
+
+
+ ))}
{fieldErrors.address && (
- {data.cards.map((card, ci) => (
+ {data.cards.map((card, ci) => {
+ // Price hierarchy (audit H2). The three cards are NOT equal weight:
+ // ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
+ // ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
+ // ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~−43% → demoted)
+ // The headline gets an accent border + tint + glow + larger accent
+ // value so the user can't confuse it with the registry figure; the
+ // registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
+ // tile. All three values stay (honesty); wiring (mapResultPanel) is
+ // untouched — emphasis is presentation only.
+ const isHeadline = ci === 1;
+ const isRegistry = ci === 2;
+ return (
{/* ranges + radar */}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
index 42207154..435a56d0 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx
@@ -214,7 +214,7 @@ export default function SectionOverlay({
>
←
К ОЦЕНКЕ
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
index dc320c0a..c050341b 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx
@@ -1,23 +1,36 @@
-import type { CSSProperties } from "react";
+import { Fragment } from "react";
+import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
-import type { SourcesData } from "./mappers";
+import type { AdRowData, DealRowData, SourcesData } from "./mappers";
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table).
// Markup is data-driven via the `data` prop; the design fixtures stay as the
-// DEFAULT so unwired/storybook usage still renders pixel-identically.
+// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
+// so the storybook render can differ from the design HTML by a dead column.)
+//
+// M7 — the two "tables" are CSS grids of s. They now carry real table
+// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
+// existing grid so the visual layout is unchanged (the row