Merge pull request 'fix(ptica): visual parity 1:1 with prototype (ОКС stretch + restored elements)' (#1854) from fix/ptica-visual-parity into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m16s
Deploy / deploy (push) Successful in 1m10s

This commit is contained in:
bot-backend 2026-06-20 20:10:43 +00:00
commit 721623bd0e
5 changed files with 306 additions and 4 deletions

View file

@ -200,6 +200,19 @@
text-align: center;
line-height: 1.5;
}
/* Rail nav-foot help-dot (?) button (prototype .help-dot). */
.helpDot {
width: 24px;
height: 24px;
border-radius: 50%;
border: var(--line) solid var(--border-strong);
display: grid;
place-items: center;
font-size: 11px;
background: transparent;
color: var(--text-soft);
cursor: pointer;
}
/* main column */
.main {
@ -286,15 +299,52 @@
color: var(--text);
font-weight: 600;
}
/* Topbar sysbar icon-buttons (prototype .icon-btn). */
.iconBtn {
width: 30px;
height: 30px;
border: var(--line) solid transparent;
border-radius: var(--radius-xs);
background: transparent;
display: grid;
place-items: center;
color: var(--text-muted);
}
.iconBtn:hover {
color: var(--text);
background: var(--surface);
border-color: var(--border);
}
.sysbar .iconBtn + .iconBtn {
margin-left: -2px;
}
.iconBtn svg {
width: 16px;
height: 16px;
}
/* canvas (scroll area) */
.canvas {
padding: 14px 18px 24px;
padding: 14px 18px 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Bottom disclaimer footer bar (prototype .app-footer). */
.appFooter {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 20px 14px;
border-top: var(--line) solid var(--border);
color: var(--text-soft);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: var(--font-mono);
}
/* ===================== PANEL / CARD primitives ===================== */
.panel,
.card,
@ -563,6 +613,27 @@
.investPlaceholder {
color: var(--text-soft);
}
/* Hero stat-line semantic value colors (prototype .v.good / .v.warn). */
.vGood {
color: var(--accent-green);
}
.vWarn {
color: var(--accent-yellow);
}
/* Hero invest-score sparkline (prototype .sparkline). */
.sparkline {
display: flex;
align-items: flex-end;
gap: 3px;
height: 26px;
}
.sparkline span {
flex: 1;
background: linear-gradient(var(--accent-cyan), var(--accent-blue));
border-radius: 1px;
min-height: 3px;
opacity: 0.85;
}
.badge {
display: inline-block;
padding: 2px 9px;
@ -712,6 +783,46 @@
margin-top: auto;
align-self: flex-start;
}
/* Scan status-rows (prototype Инженерия / Экология .status-row): dot + label
+ state. Scoped under .scanCard so they never collide with the unrelated
DRAWER .statusRow variant (drDot / drLabel / drState). */
.scanCard .statusRow {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
padding: 3px 0;
}
.scanCard .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px currentColor;
color: var(--accent-green);
flex: none;
}
.scanCard .dotWarn {
background: var(--accent-yellow);
color: var(--accent-yellow);
}
.scanCard .dotBad {
background: var(--accent-red);
color: var(--accent-red);
}
.scanCard .label {
flex: 1;
color: var(--text-muted);
}
.scanCard .state {
font-size: 10px;
}
.scanCard .stateOk {
color: var(--accent-green);
}
.scanCard .stateWarn {
color: var(--accent-yellow);
}
/* Risks card centres its gauge; title stays left-aligned like the other cards. */
.scanCardCentered {
align-items: center;
@ -2200,6 +2311,27 @@
display: grid;
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
gap: 10px;
/* HEADLINE BUG FIX: default align-items:stretch made the sparse/empty ОКС
card (isoBox 84px + emptyState flex:1 + button) stretch to the tallest
sibling, and .emptyState's flex:1 then filled the void huge empty box.
align-items:start lets each card size to its own content. */
align-items: start;
}
/* Lower-grid квартирография bars match the prototype .bar-row (narrow label /
value columns, 2px track radius) SCOPED so the shared base .hbar used by
the drawer (110px 1fr 46px, 3px radius) is NOT crushed. */
.lowerGrid .hbars {
gap: 8px;
}
.lowerGrid .hbar {
grid-template-columns: 46px 1fr 38px;
}
.lowerGrid .hbarTrack {
border-radius: 2px;
}
.lowerGrid .hbarFill {
border-radius: 2px;
}
/* shared detail-btn that needs top separation inside flow cards */
@ -2280,7 +2412,7 @@
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
margin-bottom: 8px;
margin-bottom: 1px;
}
/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */
@ -2341,6 +2473,9 @@
display: grid;
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
gap: 10px;
/* Same grid-stretch defect as .lowerGrid sparse Legal Status / cards must
not stretch to the tallest sibling. Pin each card to the top. */
align-items: start;
}
/* Investment Clearance — bignum row */
@ -2434,6 +2569,9 @@
align-items: center;
gap: 10px;
text-align: center;
/* Prototype .verdict-card plaque gradient scoped dark cockpit (authoritative
here over the global flat-token rule). */
background: linear-gradient(135deg, var(--surface), var(--surface-2));
}
.verdictSplit {
display: grid;
@ -2494,6 +2632,8 @@
}
.checkItemWarn .checkIc {
color: var(--accent-yellow);
/* Prototype removes the icon border ring on warn checklist items. */
border: none;
}
.checkItemBad .checkIc {
color: var(--accent-red);

View file

@ -11,8 +11,19 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt";
import type { PticaValueTone } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
/** Decorative hero sparkline shape (prototype data-spark="4,5,6,5,7,6,8,7,9,8"). */
const SPARK = [4, 5, 6, 5, 7, 6, 8, 7, 9, 8];
const SPARK_MAX = Math.max(...SPARK);
function toneClass(tone: PticaValueTone): string {
if (tone === "good") return styles.vGood;
if (tone === "warn") return styles.vWarn;
return "";
}
interface Props {
analysis: ParcelAnalysis;
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
@ -48,17 +59,31 @@ export function InvestScoreBlock({
{inv.score.caption && (
<span className={styles.fieldCaption}>{inv.score.caption}</span>
)}
<div className={styles.sparkline} aria-hidden="true">
{SPARK.map((v, i) => (
<span
key={i}
style={{ height: `${Math.round((v / SPARK_MAX) * 100)}%` }}
/>
))}
</div>
</div>
<div className={styles.statLine}>
<span className={styles.k}>Потенциал</span>
<span className={styles.v} title={inv.potential.caption}>
<span
className={`${styles.v} ${toneClass(inv.potentialTone)}`}
title={inv.potential.caption}
>
{inv.potential.value}
</span>
</div>
<div className={styles.statLine}>
<span className={styles.k}>Риск</span>
<span className={styles.v} title={inv.risk.caption}>
<span
className={`${styles.v} ${toneClass(inv.riskTone)}`}
title={inv.risk.caption}
>
{inv.risk.value}
</span>
</div>

View file

@ -215,6 +215,16 @@ const BirdMark = ({ className }: { className: string }) => (
</svg>
);
/** Toggle native fullscreen for the whole document (topbar fullscreen icon). */
function toggleFullscreen() {
if (typeof document === "undefined") return;
if (document.fullscreenElement) {
void document.exitFullscreen?.();
} else {
void document.documentElement.requestFullscreen?.();
}
}
interface Props {
activeTab: PticaTab;
onTabChange: (tab: PticaTab) => void;
@ -294,6 +304,15 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
<br />
{clock ?? "—"}
</div>
<button
type="button"
className={styles.helpDot}
title="Справка — скоро"
aria-label="Справка"
aria-disabled="true"
>
?
</button>
</div>
</aside>
@ -330,10 +349,76 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
<div className={styles.clock} suppressHydrationWarning>
<b>{clock ?? "—"}</b>
</div>
<button
type="button"
className={styles.iconBtn}
title="Экспорт / отчёты"
aria-label="Экспорт — открыть отчёты"
onClick={() => onTabChange("reports")}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v3h16v-3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className={styles.iconBtn}
title="Настройки — скоро"
aria-label="Настройки"
aria-disabled="true"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
cx="12"
cy="12"
r="3.2"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M12 2.5l1.4 2.2 2.5-.7.6 2.6 2.6.6-.7 2.5 2.2 1.4-2.2 1.4.7 2.5-2.6.6-.6 2.6-2.5-.7L12 21.5l-1.4-2.2-2.5.7-.6-2.6-2.6-.6.7-2.5L3.4 13l2.2-1.4-.7-2.5 2.6-.6.6-2.6 2.5.7L12 2.5z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className={styles.iconBtn}
title="Полный экран"
aria-label="Полный экран"
onClick={toggleFullscreen}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</header>
<div className={styles.canvas}>{children}</div>
<footer className={styles.appFooter}>
<span>
ПТИЦА · платформа территориального информационно-цифрового анализа
</span>
<span>
Не является публичной офертой · информация носит оценочный характер
</span>
</footer>
</div>
</div>
);

View file

@ -36,6 +36,30 @@ export function ScanCard({ card, drawerKey, onOpen }: Props) {
<div className={styles.emptyState}>{emptyState}</div>
) : (
rows.map((row) => {
// Status-row variant (prototype .status-row): dot + label + state.
if (row.status) {
const dotClass =
row.status === "warn"
? styles.dotWarn
: row.status === "bad"
? styles.dotBad
: "";
const stateClass =
row.status === "ok" ? styles.stateOk : styles.stateWarn;
return (
<div key={row.key} className={styles.statusRow}>
<span className={`${styles.dot} ${dotClass}`} />
<span className={styles.label}>{row.key}</span>
<span
className={`${styles.state} ${stateClass}`}
title={row.field.caption}
>
{row.field.value}
</span>
</div>
);
}
const placeholder = !row.field.isReal;
return (
<div

View file

@ -49,9 +49,18 @@ export interface PticaScanCard {
emptyState?: string;
}
/** Tone for a status-row dot + state label (prototype .dot / .state). */
export type PticaStatusTone = "ok" | "warn" | "bad";
export interface PticaScanRow {
key: string;
field: PticaField;
/**
* When set, the row renders as a prototype status-row (dot + label + state)
* instead of the default kvrow. `field.value` is the state text. Used by the
* Инженерия card (Экология stays honest kvrow placeholders, no status dots).
*/
status?: PticaStatusTone;
}
const PLACEHOLDER = "—";
@ -234,11 +243,18 @@ export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge {
// ── Invest score block (PLACEHOLDER — после прогноза) ─────────────────────────
/** Semantic value tone for a hero stat-line (prototype .v.good / .v.warn). */
export type PticaValueTone = "good" | "warn" | "none";
export interface PticaInvestScore {
score: PticaField;
potential: PticaField;
risk: PticaField;
buySignal: PticaField;
/** Hero stat-line value color for «Потенциал». */
potentialTone: PticaValueTone;
/** Hero stat-line value color for «Риск». */
riskTone: PticaValueTone;
}
/**
@ -275,6 +291,9 @@ export function adaptInvestScore(
risk.value != null
? { value: risk.label, isReal: false, caption: "предв." }
: placeholder("предв.");
// Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise.
const riskTone: PticaValueTone =
risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn";
const overall =
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
@ -294,6 +313,9 @@ export function adaptInvestScore(
potential: placeholder("после финмодели"),
risk: riskField,
buySignal,
// Потенциал stays muted until the financial model surfaces a real value.
potentialTone: "none",
riskTone,
};
}
@ -302,6 +324,8 @@ export function adaptInvestScore(
potential: placeholder("после прогноза (Scenarios)"),
risk: riskField,
buySignal,
potentialTone: "none",
riskTone,
};
}
@ -404,9 +428,13 @@ export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard {
}
return {
title: "Инженерия",
// Prototype Инженерия renders status-rows (dot + label + state). The state
// text is the nearest-node distance; tone is «ok» when the узел is within
// ~1 km, «warn» when further (ограниченная доступность).
rows: summary.map((u) => ({
key: utilityLabel(u.subtype),
field: real(`${formatInt(u.nearest_m)} м`),
status: (u.nearest_m <= 1000 ? "ok" : "warn") as PticaStatusTone,
})),
};
}