Merge pull request 'fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264)' (#2279) from fix/tradein-v2-a11y-round3 into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m27s
Deploy Trade-In / deploy (push) Has been cancelled
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m27s
Deploy Trade-In / deploy (push) Has been cancelled
This commit is contained in:
commit
703f6f60c0
8 changed files with 224 additions and 56 deletions
|
|
@ -10,7 +10,7 @@
|
|||
// a pending or errored street-deals / analytics call degrades its section to a
|
||||
// fallback inside the mapper instead of blanking the page.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -247,8 +247,10 @@ function PlaceholderPanel({
|
|||
fontFamily: tokens.font.sans,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 2,
|
||||
|
|
@ -256,7 +258,7 @@ function PlaceholderPanel({
|
|||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
|
@ -351,8 +353,10 @@ function SummaryPlaceholder() {
|
|||
fontFamily: tokens.font.sans,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.5,
|
||||
|
|
@ -360,7 +364,7 @@ function SummaryPlaceholder() {
|
|||
}}
|
||||
>
|
||||
СВОДКА ОБЪЕКТА
|
||||
</div>
|
||||
</h2>
|
||||
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
||||
Появится после оценки квартиры.
|
||||
</div>
|
||||
|
|
@ -382,6 +386,13 @@ export default function TradeInV2Page() {
|
|||
|
||||
const [nav, setNav] = useState(0);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// #2264 C7: after a fresh estimate lands we move focus onto the result region
|
||||
// (a labelled, tabIndex={-1} container in ResultPanel) so keyboard/SR users
|
||||
// don't get dumped back on <body>. `focusedEstimateRef` guards against
|
||||
// re-focusing on unrelated re-renders / sub-hook updates.
|
||||
const resultRegionRef = useRef<HTMLDivElement>(null);
|
||||
const focusedEstimateRef = useRef<string | null>(null);
|
||||
// Locally-held just-computed estimate (so we render instantly after submit
|
||||
// without a round-trip through the restore query).
|
||||
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
|
||||
|
|
@ -498,6 +509,23 @@ export default function TradeInV2Page() {
|
|||
[estimate, analyticsData, streetDealsData],
|
||||
);
|
||||
|
||||
// Move focus to the result region once a user-submitted estimate is rendered.
|
||||
// Gated on `freshResult` (a submit, not a restore-by-id cold load, which must
|
||||
// not steal focus) and on the result actually being on screen (!loading +
|
||||
// resultPanelData). Fires once per distinct estimate id.
|
||||
useEffect(() => {
|
||||
if (
|
||||
freshResult &&
|
||||
!loading &&
|
||||
hasEstimate &&
|
||||
resultPanelData &&
|
||||
focusedEstimateRef.current !== freshResult.estimate_id
|
||||
) {
|
||||
focusedEstimateRef.current = freshResult.estimate_id;
|
||||
resultRegionRef.current?.focus();
|
||||
}
|
||||
}, [freshResult, loading, hasEstimate, resultPanelData]);
|
||||
|
||||
// ── Overlay (section) mapped data. Computed unconditionally: the mappers
|
||||
// accept nulls and degrade per-section, so an overlay opened before/without
|
||||
// an estimate shows an honest empty shell — never the design fixtures as if
|
||||
|
|
@ -604,7 +632,13 @@ export default function TradeInV2Page() {
|
|||
/>
|
||||
);
|
||||
} else if (estimate && !insufficient && resultPanelData) {
|
||||
middleContent = <ResultPanel data={resultPanelData} onNavigate={setNav} />;
|
||||
middleContent = (
|
||||
<ResultPanel
|
||||
data={resultPanelData}
|
||||
onNavigate={setNav}
|
||||
regionRef={resultRegionRef}
|
||||
/>
|
||||
);
|
||||
} else if (estimate && insufficient) {
|
||||
middleContent = <InsufficientPanel />;
|
||||
} else {
|
||||
|
|
@ -655,6 +689,25 @@ export default function TradeInV2Page() {
|
|||
>
|
||||
<style>{HELPER_STYLES}</style>
|
||||
|
||||
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
|
||||
so give the document a real, visually-hidden <h1> (page-has-heading-one
|
||||
+ a top of the heading order for the overlay/panel <h2>s). */}
|
||||
<h1
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
Мера — оценка квартиры
|
||||
</h1>
|
||||
|
||||
{/* Polite, visually-hidden status announcer for SR users. */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export default function HeroBar({
|
|||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const pdfBtnInner = (
|
||||
<>
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
||||
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
|
||||
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
|
||||
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
|
||||
|
|
@ -367,8 +367,21 @@ export default function HeroBar({
|
|||
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
||||
/>
|
||||
<div
|
||||
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
|
||||
// without switching the element to a <button> (which, with preflight
|
||||
// off, would need a UA-chrome reset and risk the row's exact box
|
||||
// geometry / negative-margin hover bleed). Opens the methodology drawer.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="hero-coef-row"
|
||||
onClick={onOpenInfo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenInfo();
|
||||
}
|
||||
}}
|
||||
aria-label="Пояснение к расчёту коэффициента локации"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -431,6 +444,7 @@ export default function HeroBar({
|
|||
color: tokens.accent,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
|
|
@ -451,7 +465,7 @@ export default function HeroBar({
|
|||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -95,9 +95,18 @@ export function ObjectSummary({
|
|||
>
|
||||
03
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 1.5 }}>
|
||||
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin
|
||||
inline; visuals unchanged). */}
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.5,
|
||||
}}
|
||||
>
|
||||
СВОДКА ОБЪЕКТА
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -151,6 +160,7 @@ export function ObjectSummary({
|
|||
viewBox="0 0 15 18"
|
||||
fill="none"
|
||||
style={{ marginTop: 1 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.5 17C7.5 17 14 11 14 6.5A6.5 6.5 0 1 0 1 6.5C1 11 7.5 17 7.5 17Z"
|
||||
|
|
@ -167,7 +177,7 @@ export function ObjectSummary({
|
|||
{data.object.city}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
|
||||
stroke={tokens.muted2}
|
||||
|
|
@ -276,7 +286,7 @@ export function ObjectSummary({
|
|||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,28 @@ import type {
|
|||
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
||||
|
||||
// Russian plural picker (one / few / many) for SR announcements.
|
||||
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||
const m10 = n % 10;
|
||||
const m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return one;
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
// Visually-hidden style for the polite suggestion-count live region.
|
||||
const srOnly: CSSProperties = {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
};
|
||||
|
||||
interface DdProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
|
|
@ -286,7 +308,7 @@ const styles = `
|
|||
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
|
||||
.pp-dd-opt{transition:background .15s}
|
||||
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
|
||||
.pp-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
|
||||
.pp-input:focus-visible{outline:none;border-color:${tokens.accentDeep};box-shadow:0 0 0 2px ${tokens.accentDeep}}
|
||||
.pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
|
||||
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
||||
.pp-eval-btn:active{transform:translateY(1px)}
|
||||
|
|
@ -765,6 +787,14 @@ export default function ParamsPanel({
|
|||
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
|
||||
const addressListboxOpen = addressPopupOpen && addressList.length > 0;
|
||||
|
||||
// #2264 C9: a polite announcement of the suggestion COUNT so SR users know the
|
||||
// list changed under the combobox (role="listbox" is otherwise silent on
|
||||
// update). The loading / empty states are already announced by the visible
|
||||
// role="status" note below, so this region carries only the count.
|
||||
const addressAnnounce = addressListboxOpen
|
||||
? `${addressList.length} ${pluralRu(addressList.length, "подсказка", "подсказки", "подсказок")}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -856,6 +886,7 @@ export default function ParamsPanel({
|
|||
<svg
|
||||
viewBox="0 0 440 210"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
|
|
@ -1201,6 +1232,7 @@ export default function ParamsPanel({
|
|||
? addressOptId(addressActive)
|
||||
: undefined
|
||||
}
|
||||
aria-required="true"
|
||||
aria-invalid={fieldErrors.address ? true : undefined}
|
||||
aria-describedby={
|
||||
fieldErrors.address ? "pp-address-err" : undefined
|
||||
|
|
@ -1222,7 +1254,12 @@ export default function ParamsPanel({
|
|||
: addressField
|
||||
}
|
||||
/>
|
||||
{/* Polite announcement of the suggestion count (#2264 C9). */}
|
||||
<div aria-live="polite" role="status" style={srOnly}>
|
||||
{addressAnnounce}
|
||||
</div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
|
|
@ -1308,6 +1345,7 @@ export default function ParamsPanel({
|
|||
type="text"
|
||||
inputMode="decimal"
|
||||
value={area}
|
||||
aria-required="true"
|
||||
aria-invalid={fieldErrors.area ? true : undefined}
|
||||
aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
|
||||
onChange={(e) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import type { Ref } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import {
|
||||
ranges,
|
||||
|
|
@ -47,6 +48,11 @@ const RESULT_FIXTURE: ResultPanelData = {
|
|||
interface ResultPanelProps {
|
||||
data?: ResultPanelData;
|
||||
onNavigate: (i: number) => void;
|
||||
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
|
||||
// programmatically-focusable region) so keyboard/SR users land on the result
|
||||
// instead of the <body>. tabIndex={-1} makes it focus()-able without adding a
|
||||
// Tab stop.
|
||||
regionRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// Mini-histogram column tint: peak column = accent, its neighbours = mid, rest = lo.
|
||||
|
|
@ -85,10 +91,21 @@ function sourceName(name: string) {
|
|||
export default function ResultPanel({
|
||||
data = RESULT_FIXTURE,
|
||||
onNavigate,
|
||||
regionRef,
|
||||
}: ResultPanelProps) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
|
||||
ref={regionRef}
|
||||
role="region"
|
||||
aria-labelledby="v2-result-heading"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
minWidth: 0,
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.rp-more { transition: opacity 120ms ease; }
|
||||
|
|
@ -107,9 +124,19 @@ export default function ResultPanel({
|
|||
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
|
||||
02
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
|
||||
{/* Panel heading — real <h2> + the region's accessible name
|
||||
(aria-labelledby). Preflight not loaded → reset UA margin inline. */}
|
||||
<h2
|
||||
id="v2-result-heading"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 2,
|
||||
}}
|
||||
>
|
||||
РЕЗУЛЬТАТ ОЦЕНКИ
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -371,7 +398,7 @@ export default function ResultPanel({
|
|||
height: 58,
|
||||
}}
|
||||
>
|
||||
<svg width="58" height="58" viewBox="0 0 58 58">
|
||||
<svg width="58" height="58" viewBox="0 0 58 58" aria-hidden="true">
|
||||
<circle
|
||||
cx="29"
|
||||
cy="29"
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ export default function SectionOverlay({
|
|||
analytics,
|
||||
cache,
|
||||
}: SectionOverlayProps) {
|
||||
// Modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused` keeps
|
||||
// the element that had focus before the overlay opened so we can restore it on
|
||||
// close, and `onCloseRef` holds the latest onClose so the mount-only effect
|
||||
// (deps []) never re-runs / re-traps focus on parent re-renders even though
|
||||
// page.tsx passes a fresh arrow each render.
|
||||
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
|
||||
// keeps the element that had focus before the overlay opened so we can restore
|
||||
// it on close, and `onCloseRef` holds the latest onClose so the mount-only
|
||||
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
|
||||
// though page.tsx passes a fresh arrow each render.
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocused = useRef<Element | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
|
@ -60,12 +60,23 @@ export default function SectionOverlay({
|
|||
lastFocused.current = document.activeElement;
|
||||
dialog?.focus();
|
||||
|
||||
// This overlay is an HONEST NON-MODAL fullscreen view (no aria-modal): the
|
||||
// live top nav is a sibling that is deliberately NOT inerted, so a keyboard
|
||||
// user can switch sections from within the overlay. The Tab cycle therefore
|
||||
// spans [live nav tabs … + overlay content] in DOM order — we query the whole
|
||||
// artboard (the dialog's parent) and drop anything inside an inert subtree
|
||||
// (the dashboard body under the overlay). This keeps focus out of the hidden
|
||||
// <main>/<footer> while letting it flow between the nav and the overlay.
|
||||
const FOCUSABLE_SEL =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getFocusable(): HTMLElement[] {
|
||||
if (!dialog) return [];
|
||||
const nodes = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
|
||||
const root = dialog?.parentElement;
|
||||
if (!root) return [];
|
||||
const nodes = root.querySelectorAll<HTMLElement>(FOCUSABLE_SEL);
|
||||
return Array.from(nodes).filter(
|
||||
(el) => !el.hasAttribute("disabled") && !el.closest("[inert]"),
|
||||
);
|
||||
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -78,23 +89,26 @@ export default function SectionOverlay({
|
|||
|
||||
const focusable = getFocusable();
|
||||
if (focusable.length === 0) {
|
||||
// Nothing tabbable inside — keep focus pinned on the dialog itself.
|
||||
// Nothing tabbable at all — keep focus pinned on the dialog itself.
|
||||
e.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const root = dialog.parentElement;
|
||||
const first = focusable[0]; // first live nav tab
|
||||
const last = focusable[focusable.length - 1]; // last overlay control
|
||||
const activeEl = document.activeElement;
|
||||
const inside = dialog.contains(activeEl);
|
||||
const inRoot = root ? root.contains(activeEl) : false;
|
||||
|
||||
// Only wrap at the two extremes; every middle step (incl. nav ↔ overlay)
|
||||
// uses the browser's native order, which already skips the inert body.
|
||||
if (e.shiftKey) {
|
||||
if (activeEl === first || activeEl === dialog || !inside) {
|
||||
if (activeEl === first || !inRoot) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (activeEl === last || !inside) {
|
||||
} else if (activeEl === last || !inRoot) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
|
|
@ -135,7 +149,9 @@ export default function SectionOverlay({
|
|||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
// No aria-modal: the top nav stays live/reachable behind this overlay
|
||||
// (only <main>/<footer> are inerted), so claiming full modality would be
|
||||
// dishonest to SR users (#2264 C1). role=dialog + label + focus mgmt stay.
|
||||
aria-labelledby="v2-overlay-title"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
|
|
@ -215,14 +231,21 @@ export default function SectionOverlay({
|
|||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
color: tokens.accent,
|
||||
// accentDeep (not accent): the section number is 15px mono text,
|
||||
// and accent #2e8bff on the near-white overlay header is only
|
||||
// ~2.9:1; accentDeep #0d6fd6 clears AA (#2264 C4).
|
||||
color: tokens.accentDeep,
|
||||
}}
|
||||
>
|
||||
{overlayNums[active]}
|
||||
</span>
|
||||
<span
|
||||
{/* Section heading — real <h2> (page h1 is the sr-only «Мера …»).
|
||||
Preflight is not loaded here, so the h2 UA defaults are reset
|
||||
inline (margin/size/weight) to keep the visuals unchanged. */}
|
||||
<h2
|
||||
id="v2-overlay-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 2.5,
|
||||
|
|
@ -230,7 +253,7 @@ export default function SectionOverlay({
|
|||
}}
|
||||
>
|
||||
{overlayTitles[active]}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export default function TopNav({
|
|||
viewBox="100 440 812 238"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform="translate(0,-20)">
|
||||
<path
|
||||
|
|
@ -370,7 +371,7 @@ export default function TopNav({
|
|||
</div>
|
||||
|
||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="5"
|
||||
|
|
@ -388,7 +389,7 @@ export default function TopNav({
|
|||
</div>
|
||||
|
||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 1.5h6l3 3v9H3z"
|
||||
stroke={tokens.muted}
|
||||
|
|
@ -402,7 +403,9 @@ export default function TopNav({
|
|||
marginLeft: "auto",
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: "10px",
|
||||
color: tokens.accent,
|
||||
// accentDeep (not accent): 10px counter text; accent #2e8bff
|
||||
// on the white menu is ~2.9:1, accentDeep clears AA (#2264 C4).
|
||||
color: tokens.accentDeep,
|
||||
}}
|
||||
>
|
||||
{reports}
|
||||
|
|
@ -410,7 +413,7 @@ export default function TopNav({
|
|||
</div>
|
||||
|
||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="7.5"
|
||||
|
|
@ -428,7 +431,7 @@ export default function TopNav({
|
|||
</div>
|
||||
|
||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="7.5"
|
||||
cy="7.5"
|
||||
|
|
@ -468,7 +471,7 @@ export default function TopNav({
|
|||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
||||
stroke="#cd6868"
|
||||
|
|
|
|||
|
|
@ -24,18 +24,18 @@ export const tokens = {
|
|||
body2: "#46586c",
|
||||
|
||||
// Muted text (more prominent → quieter). Darkened to clear WCAG AA (≥4.5:1)
|
||||
// against the worst-case (darkest) canvas stop #e6eef7 — the artboard bg is a
|
||||
// light gradient #f4f8fc→#e6eef7 plus a faint blueprint grid, so micro-labels
|
||||
// (9–11px, further shrunk ~0.75× by scale-fit) were failing AA badly
|
||||
// (audit #2081 C1: muted2 2.5:1, muted4 1.7:1). Ratios below are vs #e6eef7;
|
||||
// they are higher over glass surfaces and the lighter gradient stops. Primary
|
||||
// labels should use body2/ink2 (6+:1) — these muted stops are for genuinely
|
||||
// secondary / hint text only. HSV hue+saturation preserved from the originals.
|
||||
muted: "#586777", // 4.95:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
|
||||
muted2: "#5f6a76", // 4.71:1 — card titles / form field captions
|
||||
muted3: "#626c77", // 4.56:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
|
||||
muted4: "#656c75", // 4.54:1 — true hints only («опц.»/«скоро»/version)
|
||||
hint: "#5f6b77", // 4.65:1 — small helper / caption text
|
||||
// against the true worst-case canvas: #dce6f1 — the darkest point of the
|
||||
// artboard, where two faint blueprint grid lines (rgba(120,150,180,.045))
|
||||
// intersect over the darkest gradient stop. Ratios below are vs #dce6f1 with a
|
||||
// ≥0.1 safety margin (audit #2264 C3); they are higher over glass surfaces and
|
||||
// the lighter gradient stops. Primary labels should use body2/ink2 (6+:1) —
|
||||
// these muted stops are for genuinely secondary / hint text only. Hue
|
||||
// preserved from the originals (lightness-only darkening).
|
||||
muted: "#586777", // 4.59:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
|
||||
muted2: "#5c6672", // 4.62:1 — card titles / form field captions
|
||||
muted3: "#5d6671", // 4.61:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
|
||||
muted4: "#5f666e", // 4.60:1 — true hints only («опц.»/«скоро»/version)
|
||||
hint: "#5b6672", // 4.64:1 — small helper / caption text
|
||||
|
||||
// Borders & hairlines (strong → soft)
|
||||
line: "#c2d3e3",
|
||||
|
|
@ -78,8 +78,8 @@ export const tokens = {
|
|||
|
||||
// Disabled / no-data source card (02 RESULT · inactive source tile, design lines 366-384)
|
||||
disabledBorder: "#d8e2ec", // inactive source card edge
|
||||
disabledValue: "#656b71", // 4.61:1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
|
||||
disabledLabel: "#636b73", // 4.62:1 — "нет данных" caption (#2081 C1: was #aab7c5 1.7:1)
|
||||
disabledValue: "#60666c", // 4.60:1 vs #dce6f1 — "—" no-data value (#2081 C1: was #c2cdd9 1.4:1)
|
||||
disabledLabel: "#5f666e", // 4.60:1 vs #dce6f1 — "нет данных" caption (#2081 C1: was #aab7c5 1.7:1)
|
||||
|
||||
// Accent glow (range-marker dot halo, design lines 303/341)
|
||||
accentGlow: "rgba(46,139,255,.5)",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue