feat(ui): фаза 7 — self-host шрифты Inter/DM Mono (woff2, precache в SW v13), возвращён focus-visible (кольцо --ring), active:scale для кнопок (фикс мёртвого not-disabled), плавные появление/исчезновение биллинг-оверлея, длительности тостов по шкале токенов

This commit is contained in:
2026-09-08 22:11:32 +03:00
parent 820e8865a5
commit b94406e057
14 changed files with 134 additions and 33 deletions

View File

@@ -13,14 +13,14 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.svg" />
<title>iistwin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Загружаем только шрифты, реально используемые встроенными скинами:
<!-- Шрифты self-hosted (client/public/fonts), @font-face — в src/index.css:
Inter (variable) — sans во всех скинах (iistwin, twenty, omegaprom);
DM Mono 400/500 — mono в скине twenty (остальные скины используют системные mono).
Preload только latin-подмножества Inter — оно нужно при любом первом рендере;
остальные subset'ы подгружаются браузером по unicode-range.
TODO: если кастомные скины организаций будут задавать другие font-family,
их стоит подгружать по требованию отдельно, а не одним общим линком. -->
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
их стоит подгружать по требованию отдельно. -->
<link rel="preload" href="/fonts/inter-var-latin.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<div id="root"></div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,19 @@
const CACHE_NAME = 'iistwin-pwa-v12';
const CACHE_NAME = 'iistwin-pwa-v13';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.svg'
'/favicon.svg',
// Self-hosted шрифты — precache, чтобы PWA офлайн рендерился без FOUT/fallback
// (имена файлов должны совпадать с @font-face в client/src/index.css)
'/fonts/inter-var-latin.woff2',
'/fonts/inter-var-latin-ext.woff2',
'/fonts/inter-var-cyrillic.woff2',
'/fonts/inter-var-cyrillic-ext.woff2',
'/fonts/dm-mono-400-latin.woff2',
'/fonts/dm-mono-400-latin-ext.woff2',
'/fonts/dm-mono-500-latin.woff2',
'/fonts/dm-mono-500-latin-ext.woff2'
];
// Активный чат, о котором сообщает клиент через postMessage.

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, AlertTriangle } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Sidebar } from '@/components/Sidebar';
import { AddBookmarkFAB } from '@/components/AddBookmarkFAB';
import { apiRequest } from '@/lib/queryClient';
import { isAdmin } from '@/lib/permissions';
import { cn } from '@/lib/utils';
/**
* Пути страниц с общим хромом (Sidebar/MobileBottomNav, биллинг-оверлей, FAB) —
@@ -40,21 +40,19 @@ const SHELL_ROUTE_PATTERNS: RegExp[] = [
/^\/[^/]+\/(tasks|forms)\//,
];
/** Оверлей блокировки биллинга с плавным появлением (opacity transition) */
/** Оверлей блокировки биллинга: плавное появление и исчезновение (fade 150ms).
Exit-анимацию обеспечивает AnimatePresence в AppShell; reducedMotion="user"
(MotionConfig в App) оставляет opacity-переход, но отключает трансформы. */
function BillingBlockedOverlay() {
// Состояние mounted → запускаем fade-in после монтирования
const [visible, setVisible] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, []);
return (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-base",
visible ? "opacity-100" : "opacity-0"
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// Длительность и easing — по шкале дизайн-системы:
// 0.15s = var(--duration-base), [0.2, 0, 0, 1] = var(--ease-standard)
transition={{ duration: 0.15, ease: [0.2, 0, 0, 1] }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
>
<div
role="dialog"
@@ -79,7 +77,7 @@ function BillingBlockedOverlay() {
Перейти к биллингу
</a>
</div>
</div>
</motion.div>
);
}
@@ -135,7 +133,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<Sidebar>
{showBlockedOverlay && <BillingBlockedOverlay />}
{/* AnimatePresence нужен для exit-анимации (fade 150ms) биллинг-оверлея */}
<AnimatePresence>
{showBlockedOverlay && <BillingBlockedOverlay />}
</AnimatePresence>
{children}
<AddBookmarkFAB />
</Sidebar>

View File

@@ -23,7 +23,9 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-[color,background-color,border-color,box-shadow,transform,opacity] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
// duration-base привязывает animate-in/animate-out (150ms по умолчанию в
// tailwindcss-animate) и transition к var(--duration-base) из шкалы дизайн-системы
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-base ease-standard data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {

View File

@@ -22,7 +22,11 @@ export type ControlSize = 'sm' | 'md' | 'lg';
// Тема 2.0: радиус кнопок — var(--button-radius), fallback = прежний rounded-md
export const buttonRecipe = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
// active:scale — микровзаимодействие нажатия; disabled-кнопки защищены
// disabled:pointer-events-none (событие :active на них не наступает).
// Вариант not-disabled НЕ использовать: Tailwind v3 его не поддерживает,
// класс молча не компилируется (проверено сборкой).
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {

View File

@@ -1,7 +1,84 @@
/* Шрифты подключаются в client/index.html (variable Inter + DM Mono).
Не добавлять @import шрифтов сюда — это дублирует запросы и ломает порядок каскада. */
/* Шрифты self-hosted из client/public/fonts (variable Inter + DM Mono).
Не добавлять @import шрифтов сюда — это дублирует запросы и ломает порядок каскада.
Inter italic сознательно не подключаем (в codebase ~20 классов `italic` на мелких
вспомогательных текстах) — браузер синтезирует наклон из variable-начертания.
DM Mono кириллицы не имеет (её нет и в Google Fonts) — кириллический mono
рендерится системным fallback из --font-mono, как и раньше. */
@import './themes/styles/generated-skins.css';
/* Inter variable (opsz 14..32, wght 100..900), normal — subset cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-var-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* Inter variable — subset cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-var-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* Inter variable — subset latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-var-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Inter variable — subset latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-var-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* DM Mono 400 — subset latin-ext */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/dm-mono-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* DM Mono 400 — subset latin */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/dm-mono-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* DM Mono 500 — subset latin-ext */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/dm-mono-500-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* DM Mono 500 — subset latin */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/dm-mono-500-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -382,13 +459,20 @@ td.formula-highlight {
background: var(--tw-font-extra-light);
}
/* Focus visible — removed per user request */
/* Focus visible — видимое кольцо фокуса для клавиатурной навигации.
Компонентные рецепты (recipes.ts, ui/*) переопределяют его своими ring-стилями. */
@layer base {
*:focus-visible {
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
/* <main> получает фокус программно при смене роута (tabIndex=-1,
App.tsx useRouteTitleAndFocus) — кольцо ему не нужно.
Перестраховка: программный focus() обычно не триггерит :focus-visible,
но поведение зависит от браузера. */
main:focus-visible {
outline: none;
--tw-ring-offset-shadow: none;
--tw-ring-shadow: none;
box-shadow: none;
}
}