refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -16,6 +16,7 @@ const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
);
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
@@ -29,6 +30,7 @@ describe("useAuthenticatedQuery", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json(
resource([
{
+1
View File
@@ -16,6 +16,7 @@ function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
) {
const hasSession = useHasAuthenticatedSession();
return useQueries({
queries: queries.map(
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
+15 -2
View File
@@ -9,14 +9,18 @@ import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => {
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
const refresh = vi.fn(() => {
token = "fresh";
return Promise.resolve(true);
});
const authorization: string[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization.push(request.headers.get("Authorization") ?? "");
return authorization.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json(resource([]));
@@ -50,10 +54,14 @@ describe("apiClient authentication", () => {
});
it("does not attach Bearer auth to authentication endpoints", async () => {
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
let authorization: null | string
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
= null;
server.use(
http.post("*/api/v1/auth/refresh", ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}),
);
@@ -71,12 +79,15 @@ describe("apiClient authentication", () => {
let generation = 0;
let calls = 0;
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
if (++calls === 1)
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
await pending;
return HttpResponse.json(resource([{ id: "previous-user-account" }]));
}),
);
@@ -86,10 +97,12 @@ describe("apiClient authentication", () => {
generation: () => generation,
refresh: () => Promise.resolve(true),
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const response = expect(
apiClient.GET("/api/v1/brokerage-accounts"),
).rejects.toMatchObject({ name: "AbortError" });
await vi.waitFor(() => expect(calls).toBe(2));
generation += 1;
release();
+34 -4
View File
@@ -8,13 +8,16 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
const apiBaseUrl
= typeof configuredBaseUrl === "string"
? configuredBaseUrl
: "http://localhost:3000";
type AuthHandlers = {
accessToken: () => null | string | undefined;
accessToken: () => null
| string
| undefined;
clear: () => void;
generation?: () => number;
refresh: () => Promise<boolean>;
@@ -22,12 +25,16 @@ type AuthHandlers = {
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
const latestReads = new Map<string, { pending: number; sequence: number }>();
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
for (const request of inFlight)
if (isMatches(request.pathname)) request.invalidated = true;
}
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false };
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
hasSession: false,
};
const authSessionListeners = new Set<() => void>();
async function authenticatedFetch(request: Request): Promise<Response> {
@@ -36,17 +43,21 @@ async function authenticatedFetch(request: Request): Promise<Response> {
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { invalidated: false, pathname: url.pathname };
const read
= request.method === "GET"
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
: undefined;
if (read) {
read.sequence += 1;
read.pending += 1;
latestReads.set(key, read);
}
const sequence = read?.sequence;
inFlight.add(ticket);
try {
return await fetchCurrent(
request,
@@ -72,47 +83,65 @@ async function fetchCurrent(
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const session = sessionState.handlers;
const generation = session?.generation?.();
const isCurrent = () =>
(session?.generation
? generation === session.generation()
&& generation === sessionState.handlers?.generation?.()
: session === sessionState.handlers) && isLatest();
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
return new Request(source, { headers });
};
const authorized
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
await response.clone().arrayBuffer();
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED)
if (
isAuthRequest
|| !sessionState.handlers
|| response.status !== HTTP_UNAUTHORIZED
)
return response;
let problem: unknown;
try {
problem = await response.clone().json();
}
catch {
return response;
}
if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized)) return response;
if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized))
return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await sessionState.handlers.refresh())) {
if (isCurrent()) sessionState.handlers.clear();
return response;
}
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
@@ -128,6 +157,7 @@ function setAuthenticatedSession(hasSession: boolean) {
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener);
}
+6 -1
View File
@@ -3,7 +3,12 @@ import { expect, it } from "vitest";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
expect(isApiErrorCode(apiErrorCode({ code: "UNAUTHORIZED" }), ApiErrorCode.Unauthorized)).toBe(true);
expect(
isApiErrorCode(
apiErrorCode({ code: "UNAUTHORIZED" }),
ApiErrorCode.Unauthorized,
),
).toBe(true);
const unknownCode = apiErrorCode({ code: "FUTURE_PROVIDER_ERROR" });
expect(unknownCode).toBe("FUTURE_PROVIDER_ERROR");
expect(isApiErrorCode(unknownCode, ApiErrorCode.Unauthorized)).toBe(false);
+26 -3
View File
@@ -1,9 +1,11 @@
import { type QueryClient,
import {
type QueryClient,
type QueryFunctionContext,
type QueryKey,
useQueryClient,
type UseQueryOptions,
type UseQueryResult } from "@tanstack/react-query";
type UseQueryResult,
} from "@tanstack/react-query";
import { useEffect } from "react";
import type { components } from "./generated/schema";
@@ -18,6 +20,7 @@ type Resource = {
data: unknown;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
function invalidateResourceAccess(
client: QueryClient,
group: string,
@@ -36,10 +39,12 @@ function invalidateResourceAccess(
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
accessEpochs.set(client, epochs);
epochs.set(group, (epochs.get(group) ?? 0) + 1);
const affected = {
predicate: (cached: { meta?: Record<string, unknown> }) =>
cached.meta?.accessGroup === group,
};
void client.cancelQueries(affected);
for (const cached of client.getQueryCache().findAll(affected))
cached.setState({
@@ -57,9 +62,11 @@ function unwrapResourceResult<T extends Resource, E>(
const isMissing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
const status: UseQueryResult<T, E>["status"] = isMissing
? "pending"
: result.status;
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
@@ -81,17 +88,22 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
options: UseQueryOptions<T, E, T, K>,
) {
const client = useQueryClient();
const group
= typeof options.meta?.accessGroup === "string"
? options.meta.accessGroup
: undefined;
let epochs = accessEpochs.get(client);
if (!epochs) {
epochs = new Map();
accessEpochs.set(client, epochs);
}
const groupEpochs = epochs;
const queryFunction = options.queryFn;
const guarded
= typeof queryFunction === "function"
? async (context: QueryFunctionContext<K>) => {
@@ -99,13 +111,16 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
const result = await queryFunction(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
return result;
}
: queryFunction;
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
useEffect(() => {
if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => {
const queryFunction = guarded;
if (
@@ -116,13 +131,16 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
return client.query({
...options,
queryFn: async (context: QueryFunctionContext<K>) => {
const [method, path, input] = context.queryKey;
const init = input as
| undefined
undefined
| { params?: { query?: Record<string, unknown> } };
const queryKey = [
method,
path,
@@ -134,7 +152,9 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
},
},
] as unknown as K;
const response = await queryFunction({ ...context, queryKey });
if (
expectedStateVersion
&& response.meta.stateVersion !== expectedStateVersion
@@ -143,8 +163,10 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
const current = await queryFunction({ ...context, queryKey });
if (current.meta.stateVersion !== expectedStateVersion)
throw new DOMException("Resource changed", "AbortError");
return current;
}
return response;
},
retry: false,
@@ -153,6 +175,7 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
staleTime: 0,
});
};
return {
...unwrapResourceResult(query),
observe,
@@ -1,85 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { bindIconAnimation } from "./bind-icon-animation";
afterEach(() => vi.restoreAllMocks());
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
const { container } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
let finishFirst!: () => void;
let finishLast!: () => void;
const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>();
finishFirst = first.resolve;
finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }];
});
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target);
expect(icon).toHaveAttribute("data-icon-animating");
finishFirst();
await Promise.resolve();
expect(icon).toHaveAttribute("data-icon-animating");
fireEvent.mouseEnter(target);
expect(getAnimations).toHaveBeenCalledTimes(1);
finishLast();
await Promise.resolve();
expect(icon).toHaveAttribute("data-icon-animating");
fireEvent.mouseLeave(target);
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
fireEvent.mouseEnter(target);
expect(getAnimations).toHaveBeenCalledTimes(2);
});
it("keeps playing after keyboard blur and releases cancelled animations", async () => {
const { container, unmount } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
// JSDOM does not model the browser keyboard-focus modality.
vi.spyOn(target, "matches").mockImplementation(
selector => selector === ":focus-visible",
);
let cancel!: () => void;
Object.defineProperty(icon, "getAnimations", {
value: () => [
{
finished: new Promise<void>((_resolve, reject) => {
cancel = () => reject(new Error("cancelled"));
}),
},
],
});
target.focus();
expect(icon).toHaveAttribute("data-icon-animating");
target.blur();
expect(icon).toHaveAttribute("data-icon-animating");
cancel();
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
target.focus();
unmount();
cancel();
await Promise.resolve();
expect(icon).not.toHaveAttribute("data-icon-animating");
});
-68
View File
@@ -1,68 +0,0 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return;
let isHovered = false;
let isFocused = false;
let isRunning = false;
let isDisposed = false;
const release = () => {
if (!isRunning && !isHovered && !isFocused) {
delete icon.dataset.iconAnimating;
}
};
const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = "";
isRunning = true;
// Includes staggered/delayed children and finite iterations, without timers.
const animations = icon.getAnimations({ subtree: true });
void Promise.allSettled(
animations.map(animation => animation.finished),
).then(() => {
if (isDisposed) return;
isRunning = false;
release();
});
};
const enter = () => {
if (!matchMedia("(hover: hover)").matches) return;
isHovered = true;
start();
};
const leave = () => {
isHovered = false;
release();
};
const focus = () => {
isFocused
= target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible");
if (isFocused) start();
};
const blur = (event: FocusEvent) => {
if (
event.relatedTarget instanceof Node
&& target.contains(event.relatedTarget)
)
return;
isFocused = false;
release();
};
target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur);
return () => {
isDisposed = true;
target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur);
delete icon.dataset.iconAnimating;
};
};
export { bindIconAnimation };
+3
View File
@@ -0,0 +1,3 @@
import { z } from "zod";
const calendarDate = z.iso.date();
export { calendarDate };
+12
View File
@@ -0,0 +1,12 @@
import { calendarDate } from "./calendar.schema";
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { isValidPeriod };
+11 -4
View File
@@ -22,11 +22,18 @@ describe("shared display helpers", () => {
);
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD");
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe("0 001 234,50 ₽");
expect(formatMoney({ currency: "RUB", value: "123456789012345678901234567890.01" })).toBe(
"123 456 789 012 345 678 901 234 567 890,01 ₽",
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe(
"0 001 234,50 ₽",
);
expect(formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true })).toBe("1,50 ₽");
expect(
formatMoney({
currency: "RUB",
value: "123456789012345678901234567890.01",
}),
).toBe("123 456 789 012 345 678 901 234 567 890,01 ₽");
expect(
formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }),
).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—");
});
+32 -6
View File
@@ -4,6 +4,7 @@ const CENTS_PRECISION = 2;
type FormatMoneyOptions = {
withZeroCents?: boolean;
};
type Money = { currency: string; value: string };
function formatDate(value: string): string {
@@ -21,23 +22,40 @@ function formatDecimal(value: string): string {
const digits = sign ? integer.slice(1) : integer;
const groupSize = 3;
const groups: string[] = [];
for (let end = digits.length; end > 0; end -= groupSize) {
groups.push(digits.slice(Math.max(0, end - groupSize), end));
}
const grouped = groups.toReversed().join(" ");
const suffix = fraction ? `,${fraction}` : "";
return `${sign}${grouped}${suffix}`;
}
function formatMoney(
money: Money | null | undefined,
money: Money
| null
| undefined,
{ withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const [integerPart, fractionPart = ""] = money.value.split(".", DECIMAL_PART_COUNT);
const normalizedFraction = withZeroCents ? fractionPart.padEnd(CENTS_PRECISION, "0") : fractionPart;
const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart;
const [integerPart, fractionPart = ""] = money.value.split(
".",
DECIMAL_PART_COUNT,
);
const normalizedFraction = withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0")
: fractionPart;
const decimal = normalizedFraction
? `${integerPart}.${normalizedFraction}`
: integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency;
return `${formatDecimal(decimal)} ${currency}`;
}
@@ -51,14 +69,22 @@ function formatSnapshotTime(value: string): string {
const logoSize = 160;
function instrumentLogoUrl(logoName: null | string | undefined): null | string {
function formatChartValue(value: number) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format(
value,
);
}
function instrumentLogoUrl(logoName?: null | string): null | string {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null;
: null; // eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
}
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
+4 -1
View File
@@ -1,8 +1,11 @@
export { bindIconAnimation } from "./bind-icon-animation";
export { isValidPeriod } from "./calendar";
export { calendarDate } from "./calendar.schema";
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export { useImageFallback } from "./useImageFallback";
+10
View File
@@ -0,0 +1,10 @@
import { useState } from "react";
function useImageFallback() {
const [failed, setFailed] = useState(false);
const onError = () => setFailed(true);
return { failed, onError };
}
export { useImageFallback };
+85 -77
View File
@@ -16,53 +16,74 @@ import { colorRoles, designTokens } from "@wealthledger/ui/theme";
const ColorsView: FC = () => {
const theme = useMantineTheme();
return (
<Stack>
<Title order={1}>Цвета и семантические роли</Title>
<Text>
Светлая схема WealthLedger. Источник цветов и ролей —
@wealthledger/tokens/tokens.json. Цвет не заменяет подпись состояния.
</Text>
<SimpleGrid cols={{ base: 1, lg: 3, sm: 2 }}>
{Object.entries(designTokens.color.roles).map(([role, token]) => (
<Paper key={role} p="md" withBorder>
<Stack gap="xs">
<Box aria-hidden="true" bg={`var(--wl-color-${role})`} h={48} />
<Text fw={700}>{role}</Text>
<Code>
--wl-color-
{role}
</Code>
<Text size="sm">
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role]}
{" "}
·
{" "}
{parseThemeColor({ color: /* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role], theme }).value}
{
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
colorRoles[role]
}{" "}
·{" "}
{
parseThemeColor({
color:
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
colorRoles[role],
theme,
}).value
}
</Text>
<Text size="sm">{token.description}</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
{Object.keys(designTokens.color.palettes).map(palette => (
<Stack gap="xs" key={palette}>
<Title order={2}>{palette}</Title>
<SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}>
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.colors[palette].map((value, shade) => (
<Paper key={value} p="xs" withBorder>
<Box aria-hidden="true" bg={value} h={40} />
<Text size="sm">
{palette}
.
{shade}
</Text>
<Text size="xs">{value}</Text>
</Paper>
))}
{
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
theme.colors[palette].map((value, shade) => (
<Paper key={value} p="xs" withBorder>
<Box aria-hidden="true" bg={value} h={40} />
<Text size="sm">
{palette}.{shade}
</Text>
<Text size="xs">{value}</Text>
</Paper>
))
}
</SimpleGrid>
</Stack>
))}
<Text>
Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow —
статусы; orange — дополнительный акцент. Grape — цвет графиков, его
@@ -75,23 +96,20 @@ const ColorsView: FC = () => {
const TypographyView: FC = () => {
const theme = useMantineTheme();
return (
<Stack style={{ overflowWrap: "anywhere" }}>
<Title order={1}>Типографика</Title>
<Text>
Основной шрифт:
{" "}
{theme.fontFamily}
</Text>
<Text ff="monospace">
Моноширинный:
{" "}
{theme.fontFamilyMonospace}
</Text>
<Text>Основной шрифт: {theme.fontFamily}</Text>
<Text ff="monospace">Моноширинный: {theme.fontFamilyMonospace}</Text>
<Text>
Title задаёт смысловую иерархию; выбирайте order по структуре страницы.
Text — основной текст, size sm/xs — подписи; mono — идентификаторы.
</Text>
{Object.entries(theme.headings.sizes).map(([name, value]) => (
<Paper key={name} p="md" withBorder>
<Text
@@ -99,44 +117,32 @@ const TypographyView: FC = () => {
fz={value.fontSize}
lh={value.lineHeight}
>
{name}
{" "}
· Заголовок WealthLedger
{name} · Заголовок WealthLedger
</Text>
<Text size="sm">
{value.fontSize}
{" "}
/
{" "}
{value.lineHeight}
{" "}
· weight
{" "}
{value.fontSize} / {value.lineHeight} · weight{" "}
{theme.headings.fontWeight}
</Text>
</Paper>
))}
{Object.entries(theme.fontSizes).map(([size, value]) => (
<Paper key={size} p="md" withBorder>
<Text size={size}>
Размер
{" "}
{size}
{" "}
· Пример основного текста
</Text>
<Text size={size}>Размер {size} · Пример основного текста</Text>
<Text size="sm">
{value}
{" "}
/
{" "}
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.lineHeights[size]}
{value} /{" "}
{
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
theme.lineHeights[size]
}
</Text>
</Paper>
))}
<Text>
Веса:
{" "}
Веса:{" "}
{Object.entries(theme.fontWeights)
.map(([name, value]) => `${name} ${value}`)
.join("; ")}
@@ -147,66 +153,63 @@ const TypographyView: FC = () => {
const LayoutView: FC = () => {
const theme = useMantineTheme();
return (
<Stack style={{ overflowWrap: "anywhere" }}>
<Title order={1}>Интервалы, форма и адаптивность</Title>
<Text>
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
Шкалы принадлежат tokens.json; адаптер Mantine сохраняет
масштабирование. Default radius:
{theme.defaultRadius}
; scale:
{" "}
{theme.scale}
. Размеры конкретных карточек
{theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
и их особые тени остаются локальными.
</Text>
<Title order={2}>Spacing</Title>
{Object.entries(theme.spacing).map(([name, value]) => (
<Paper key={name} p="sm" withBorder>
<Text>
{name}
{" "}
·
{" "}
{value}
{name} · {value}
</Text>
<Box aria-hidden="true" bg="blue" h={16} w={value} />
</Paper>
))}
<Title order={2}>Radius</Title>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{Object.entries(theme.radius).map(([name, value]) => (
<Paper key={name} p="md" radius={name} withBorder>
<Text>
{name}
{" "}
·
{" "}
{value}
{name} · {value}
</Text>
</Paper>
))}
</SimpleGrid>
<Title order={2}>Shadows</Title>
{Object.entries(theme.shadows).map(([name, value]) => (
<Paper key={name} p="md" shadow={name} withBorder>
<Text fw={700}>{name}</Text>
<Text size="sm">{value}</Text>
</Paper>
))}
<Title order={2}>Breakpoints</Title>
<Text>
Именованные пороги responsive props. Узкий экран — base; не фиксируйте
ширину контента по размеру desktop.
</Text>
{Object.entries(theme.breakpoints).map(([name, value]) => (
<Text key={name}>
{name}
{" "}
·
{" "}
{value}
{name} · {value}
</Text>
))}
</Stack>
@@ -228,7 +231,9 @@ const meta = {
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
export const SecondaryText: Story = {
name: "Вторичный текст",
@@ -238,17 +243,20 @@ export const SecondaryText: Story = {
"var(--wl-color-surface)",
"var(--wl-color-canvas)",
"var(--wl-color-subtle)",
...Object.keys(designTokens.color.palettes).flatMap(palette => palette === "dark"
? []
: [
`var(--mantine-color-${palette}-0)`,
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
]),
...Object.keys(designTokens.color.palettes).flatMap(palette =>
palette === "dark"
? []
: [
`var(--mantine-color-${palette}-0)`,
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
],
),
].map(background => (
<Paper key={background} p="md" style={{ background }} withBorder>
<Text c="dimmed" size="sm">
22.09.2026 · Синтетические данные
</Text>
<Text c="dimmed" size="xs">
Не удалось обновить данные. Сохранён предыдущий результат.
</Text>
+9 -1
View File
@@ -7,16 +7,23 @@ function luminance(color: string) {
.map(value =>
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
);
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
describe("semantic text contrast", () => {
it.each(["positive", "negative", "muted"] as const)(
"keeps %s readable on canvas, white, subtle and tinted surfaces",
(tone) => {
const role = /* eslint-disable-line security/detect-object-injection -- Keys come from the repository’s trusted design-token definitions. */ tokens.color.roles[tone];
const role
// eslint-disable-next-line security/detect-object-injection -- Keys come from the repository’s trusted design-token definitions.
= tokens.color.roles[tone];
const palettes = tokens.color.palettes;
const foreground
= palettes[role.color as keyof typeof palettes][role.shade];
const surfaces = [
"#ffffff",
palettes.gray[0],
@@ -41,6 +48,7 @@ describe("semantic text contrast", () => {
.join("")}`,
),
];
for (const background of surfaces)
expect(
(luminance(background) + 0.05) / (luminance(foreground) + 0.05),
+3
View File
@@ -34,13 +34,16 @@ const meta = {
} satisfies Meta<ButtonStoryArguments>;
export default meta;
type Story = StoryObj<ButtonStoryArguments>;
export const Primary: Story = {
name: "Основное действие",
play: async ({ args, canvasElement }) => {
const button = within(canvasElement).getByRole("button", {
name: typeof args.children === "string" ? args.children : undefined,
});
button.focus();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");