[794-5][@ant] feat(spa): редизайн отчёта верификатора + заголовок из meta.title
CI / build (push) Failing after 7s

UI переведён на минималистичную систему с бордо-акцентом и мягкими поверхностями:

- Заголовок документа берётся из meta.title (чистая кириллица), а не из имени
  файла zip — устраняет mojibake «Ð овЅ». В шапку добавлены подписант
  («Фамилия И.О.» из сертификата) и дата подписи.
- Inter Variable (latin+cyrillic) подключён реально (@fontsource-variable),
  раньше был только в font-family → fallback.
- Эмодзи-иконки (⚠) заменены на SVG в цветных кружках (StatusIcon).
- Карточки: мягкая тень + hairline вместо резкой рамки; кнопки/вердикт-бейдж/
  sync-индикатор — единые токены поверхностей и статусов.
- Палитра статусов отделена от бордо (вердикт ≠ бренд).

typecheck чист, vite собирает без ошибок.
This commit is contained in:
coopops
2026-06-03 18:17:57 +00:00
parent 832888051f
commit 2ae5582ddd
13 changed files with 362 additions and 135 deletions
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@verifier/shared": "workspace:*",
"@wharfkit/antelope": "^1.0.13",
"fflate": "^0.8.2",
+51 -27
View File
@@ -17,6 +17,8 @@ async function onParsed(parsed: ParseResult) {
busy.value = true;
try {
results.value = await Promise.all(parsed.documents.map(verifyDocument));
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
busy.value = false;
}
@@ -33,42 +35,64 @@ function printReport() {
</script>
<template>
<div class="mx-auto max-w-3xl px-6 py-10">
<header class="no-print mb-8 flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold" style="color: var(--color-brand-9)">
{{ t('app.title') }}
</h1>
<p class="text-sm" style="color: var(--color-mauve-11)">{{ t('app.subtitle') }}</p>
<div class="mx-auto min-h-screen max-w-2xl px-5 py-10 sm:py-14">
<!-- Хедер -->
<header class="no-print mb-9 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span
class="inline-flex h-10 w-10 items-center justify-center rounded-xl text-white"
style="background: var(--color-brand)"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<div>
<h1 class="text-[17px] font-semibold leading-tight" style="color: var(--color-ink)">
{{ t('app.title') }}
</h1>
<p class="text-xs" style="color: var(--color-ink-3)">{{ t('app.subtitle') }}</p>
</div>
</div>
<SyncIndicator />
</header>
<main class="flex flex-col gap-6">
<UploadDropzone v-if="results.length === 0" @parsed="onParsed" @error="error = $event" />
<main class="flex flex-col gap-5">
<UploadDropzone v-if="results.length === 0 && !busy" @parsed="onParsed" @error="error = $event" />
<p v-if="error" class="text-sm" style="color: var(--color-verdict-invalid)">{{ error }}</p>
<p v-if="busy" class="text-sm" style="color: var(--color-mauve-11)">Проверка</p>
<div
v-if="error"
class="rounded-xl px-4 py-3 text-sm"
style="background: var(--color-neg-soft); color: var(--color-neg)"
>
{{ error }}
</div>
<template v-if="results.length">
<div
v-if="busy"
class="card flex items-center justify-center gap-3 px-6 py-14 text-sm"
style="color: var(--color-ink-2)"
>
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
style="color: var(--color-brand)"
/>
Проверка
</div>
<template v-if="results.length && !busy">
<DocumentReport v-for="(r, i) in results" :key="i" :result="r" />
<div class="no-print flex gap-3">
<button
class="rounded-lg px-4 py-2 text-sm font-medium text-white"
style="background: var(--color-brand-9)"
@click="reset"
>
Новая проверка
</button>
<button
class="rounded-lg border px-4 py-2 text-sm font-medium"
style="border-color: var(--color-mauve-6); color: var(--color-mauve-12)"
@click="printReport"
>
{{ t('print') }}
</button>
<div class="no-print mt-1 flex gap-3">
<button class="btn btn-primary" @click="reset">Новая проверка</button>
<button class="btn btn-ghost" @click="printReport">{{ t('print') }}</button>
</div>
</template>
</main>
<footer class="no-print mt-12 text-center text-xs" style="color: var(--color-ink-3)">
{{ t('upload.hint') }}
</footer>
</div>
</template>
+14 -10
View File
@@ -2,24 +2,28 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ExtendedCheck } from '../lib/verify.js';
import StatusIcon from './StatusIcon.vue';
const props = defineProps<{ check: ExtendedCheck }>();
const { t } = useI18n();
const icon = computed(() =>
props.check.status === 'pass' ? '✅' : props.check.status === 'fail' ? '❌' : '⚠',
);
const title = computed(() => t(`checks.${props.check.check}`));
</script>
<template>
<div class="flex items-start gap-3 py-2">
<span class="mt-0.5">{{ icon }}</span>
<div>
<div class="text-sm font-medium" style="color: var(--color-mauve-12)">
{{ check.check }} · {{ title }}
<div class="flex items-start gap-3 py-3">
<StatusIcon :status="check.status" :size="22" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold tracking-wide"
style="background: var(--color-bg); color: var(--color-ink-3)"
>{{ check.check }}</span
>
<span class="text-sm font-medium" style="color: var(--color-ink)">{{ title }}</span>
</div>
<div class="text-xs" style="color: var(--color-mauve-11)">{{ check.detail }}</div>
<p class="mt-0.5 break-words text-xs leading-relaxed" style="color: var(--color-ink-2)">
{{ check.detail }}
</p>
</div>
</div>
</template>
+38 -13
View File
@@ -1,32 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DocumentVerification } from '../lib/verify.js';
import Plane from './Plane.vue';
import VerdictBadge from './VerdictBadge.vue';
import CheckRow from './CheckRow.vue';
defineProps<{ result: DocumentVerification }>();
const props = defineProps<{ result: DocumentVerification }>();
const { t } = useI18n();
const signedAtText = computed(() => {
if (!props.result.signedAt) return '';
const d = new Date(props.result.signedAt);
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
});
</script>
<template>
<Plane>
<header class="mb-4 flex items-center justify-between gap-4">
<div>
<h3 class="text-base font-semibold" style="color: var(--color-mauve-12)">
<article class="card overflow-hidden">
<!-- Шапка: заголовок документа + подписант/дата + вердикт -->
<header class="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div class="min-w-0">
<h2 class="text-base font-semibold leading-snug" style="color: var(--color-ink)">
{{ result.name }}
</h3>
</h2>
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs" style="color: var(--color-ink-2)">
<span v-if="result.signers.length">
{{ result.signers.join(', ') }}
</span>
<span v-if="result.signers.length && signedAtText" style="color: var(--color-ink-3)">·</span>
<span v-if="signedAtText">{{ signedAtText }}</span>
</div>
<span
v-if="result.legacy"
class="text-xs"
style="color: var(--color-verdict-warn)"
class="mt-2 inline-block rounded-md px-2 py-0.5 text-[11px] font-medium"
style="background: var(--color-warn-soft); color: var(--color-warn)"
>{{ t('legacy') }}</span
>
</div>
<VerdictBadge :verdict="result.verdict" />
<VerdictBadge :verdict="result.verdict" class="shrink-0" />
</header>
<div class="divide-y" style="border-color: var(--color-mauve-6)">
<CheckRow v-for="(c, i) in result.checks" :key="i" :check="c" />
<!-- Список проверок -->
<div class="border-t px-5 py-1" style="border-color: var(--color-line)">
<div class="divide-y" style="border-color: var(--color-line)">
<CheckRow v-for="(c, i) in result.checks" :key="i" :check="c" />
</div>
</div>
</Plane>
</article>
</template>
-10
View File
@@ -1,10 +0,0 @@
<script setup lang="ts">
/** Плоскость — базовая подложка с hairline-границей (не material-карточка). */
defineProps<{ padded?: boolean }>();
</script>
<template>
<section class="plane" :class="padded === false ? '' : 'p-6'">
<slot />
</section>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue';
/** Статусная иконка в цветном кружке: pass/fail/warn. SVG, не эмодзи. */
const props = withDefaults(
defineProps<{ status: 'pass' | 'fail' | 'warn'; size?: number }>(),
{ size: 20 },
);
const palette = computed(() => {
switch (props.status) {
case 'pass':
return { fg: 'var(--color-pos)', bg: 'var(--color-pos-soft)' };
case 'fail':
return { fg: 'var(--color-neg)', bg: 'var(--color-neg-soft)' };
case 'warn':
return { fg: 'var(--color-warn)', bg: 'var(--color-warn-soft)' };
}
});
</script>
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:style="{ width: `${size}px`, height: `${size}px`, background: palette.bg, color: palette.fg }"
>
<svg
:width="size * 0.62"
:height="size * 0.62"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline v-if="status === 'pass'" points="20 6 9 17 4 12" />
<template v-else-if="status === 'fail'">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</template>
<template v-else>
<line x1="12" y1="8" x2="12" y2="13" />
<line x1="12" y1="17" x2="12" y2="17" />
</template>
</svg>
</span>
</template>
+28 -11
View File
@@ -17,19 +17,36 @@ onMounted(() => {
onUnmounted(() => window.clearInterval(timer));
const status = computed(() => health.value?.status ?? 'offline');
const color = computed(() =>
status.value === 'synced'
? 'var(--color-verdict-valid)'
: status.value === 'lagging'
? 'var(--color-verdict-warn)'
: 'var(--color-verdict-invalid)',
);
const tone = computed(() => {
switch (status.value) {
case 'synced':
return { dot: 'var(--color-pos)', bg: 'var(--color-pos-soft)', fg: 'var(--color-pos)' };
case 'lagging':
return { dot: 'var(--color-warn)', bg: 'var(--color-warn-soft)', fg: 'var(--color-warn)' };
default:
return { dot: 'var(--color-neg)', bg: 'var(--color-neg-soft)', fg: 'var(--color-neg)' };
}
});
</script>
<template>
<div class="flex items-center gap-2 text-xs" style="color: var(--color-mauve-11)">
<span class="inline-block h-2 w-2 rounded-full" :style="{ background: color }" />
<span>{{ t(`health.${status}`) }}</span>
<span v-if="health"> · head {{ health.head_block }} / current {{ health.current_block }} </span>
<div class="flex items-center gap-2.5">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium"
:style="{ background: tone.bg, color: tone.fg }"
>
<span class="relative inline-flex h-2 w-2">
<span
v-if="status === 'synced'"
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60"
:style="{ background: tone.dot }"
/>
<span class="relative inline-flex h-2 w-2 rounded-full" :style="{ background: tone.dot }" />
</span>
{{ t(`health.${status}`) }}
</span>
<span v-if="health" class="hidden font-mono text-[11px] sm:inline" style="color: var(--color-ink-3)">
{{ health.current_block.toLocaleString('ru-RU') }}
</span>
</div>
</template>
+41 -19
View File
@@ -35,28 +35,50 @@ function onDrop(e: DragEvent) {
</script>
<template>
<div
class="plane flex flex-col items-center justify-center gap-3 p-12 text-center transition-colors"
:style="dragging ? { borderColor: 'var(--color-brand-9)', background: 'var(--color-brand-3)' } : {}"
<label
class="card flex cursor-pointer flex-col items-center justify-center gap-4 px-6 py-14 text-center transition-all"
:style="
dragging
? { borderColor: 'var(--color-brand)', boxShadow: '0 0 0 4px var(--color-brand-ring)' }
: {}
"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<p class="text-sm" style="color: var(--color-mauve-12)">{{ t('upload.drop') }}</p>
<p class="text-xs" style="color: var(--color-mauve-11)">{{ t('upload.or') }}</p>
<label
class="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-white"
style="background: var(--color-brand-9)"
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-2xl"
:style="{ background: dragging ? 'var(--color-brand-soft)' : 'var(--color-bg)', color: 'var(--color-brand)' }"
>
{{ t('upload.choose') }}
<input
type="file"
class="hidden"
multiple
accept=".pdf,.sig,.zip"
@change="handleFiles(($event.target as HTMLInputElement).files)"
/>
</label>
<p class="text-xs" style="color: var(--color-mauve-11)">{{ t('upload.hint') }}</p>
</div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</span>
<div>
<p class="text-sm font-medium" style="color: var(--color-ink)">{{ t('upload.drop') }}</p>
<p class="mt-0.5 text-xs" style="color: var(--color-ink-3)">{{ t('upload.or') }}</p>
</div>
<span class="btn btn-primary">{{ t('upload.choose') }}</span>
<input
type="file"
class="hidden"
multiple
accept=".pdf,.sig,.zip"
@change="handleFiles(($event.target as HTMLInputElement).files)"
/>
<p class="flex items-center gap-1.5 text-xs" style="color: var(--color-ink-3)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{{ t('upload.hint') }}
</p>
</label>
</template>
+9 -8
View File
@@ -2,29 +2,30 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Verdict } from '../lib/verify.js';
import StatusIcon from './StatusIcon.vue';
const props = defineProps<{ verdict: Verdict }>();
const { t } = useI18n();
// Вердикт — ОТДЕЛЬНАЯ семантика, НЕ бордо.
const style = computed(() => {
const v = computed(() => {
switch (props.verdict) {
case 'valid':
return { icon: '✅', color: 'var(--color-verdict-valid)', label: t('verdict.valid') };
return { status: 'pass' as const, fg: 'var(--color-pos)', bg: 'var(--color-pos-soft)', label: t('verdict.valid') };
case 'invalid':
return { icon: '❌', color: 'var(--color-verdict-invalid)', label: t('verdict.invalid') };
return { status: 'fail' as const, fg: 'var(--color-neg)', bg: 'var(--color-neg-soft)', label: t('verdict.invalid') };
case 'warn':
return { icon: '⚠', color: 'var(--color-verdict-warn)', label: t('verdict.warn') };
return { status: 'warn' as const, fg: 'var(--color-warn)', bg: 'var(--color-warn-soft)', label: t('verdict.warn') };
}
});
</script>
<template>
<div
class="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium"
:style="{ color: style.color, border: `1px solid ${style.color}` }"
class="inline-flex items-center gap-2 rounded-full py-1.5 pl-1.5 pr-3.5 text-sm font-semibold"
:style="{ color: v.fg, background: v.bg }"
>
<span>{{ style.icon }}</span>
<span>{{ style.label }}</span>
<StatusIcon :status="v.status" :size="22" />
<span>{{ v.label }}</span>
</div>
</template>
+31 -1
View File
@@ -19,12 +19,27 @@ export interface ExtendedCheck {
}
export interface DocumentVerification {
/** Человекочитаемый заголовок документа (из meta.title, не из имени файла). */
name: string;
/** Подписант(ы) — «Фамилия И.О.» из сертификата. */
signers: string[];
/** Момент подписи (ISO) первой подписи — для шапки отчёта. */
signedAt: string | null;
legacy: boolean;
checks: ExtendedCheck[];
verdict: Verdict;
}
/** «Фамилия И.О.» из сертификата подписанта. */
function signerName(cert: { last_name?: string; first_name?: string; middle_name?: string; username?: string }): string {
const last = cert.last_name?.trim() ?? '';
const initials = [cert.first_name, cert.middle_name]
.map((n) => (n?.trim() ? `${n.trim()[0]}.` : ''))
.join('');
const full = `${last} ${initials}`.trim();
return full || cert.username || '—';
}
const deps = { sha256: webCryptoSha256 };
/**
@@ -97,7 +112,22 @@ export async function verifyDocument(doc: ParsedDocument): Promise<DocumentVerif
else if (c6Degraded) verdict = 'warn';
else verdict = 'valid';
return { name: doc.name, legacy: doc.legacy, checks, verdict };
const metaTitle = typeof doc.sig.meta?.title === 'string' ? (doc.sig.meta.title as string) : '';
const signers = doc.sig.signatures
.map((s) => (s.signer_certificate ? signerName(s.signer_certificate) : ''))
.filter(Boolean);
const signedAt = doc.sig.signatures[0]?.signed_at
? toUtcIso(doc.sig.signatures[0].signed_at)
: null;
return {
name: metaTitle || doc.name,
signers: [...new Set(signers)],
signedAt,
legacy: doc.legacy,
checks,
verdict,
};
}
function toExt(c: CheckResult): ExtendedCheck {
+1
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './lib/i18n.js';
import '@fontsource-variable/inter/wght.css'; // Inter variable, latin + cyrillic
import './style.css';
createApp(App).use(i18n).mount('#app');
+91 -36
View File
@@ -1,55 +1,106 @@
@import 'tailwindcss';
/*
* Дизайн-токены D4.3a — минималистичный пространственный дизайн на плоскостях.
* ВЕДУЩИЙ цвет — бордо #9f1239 (бренд кооперативной экономики). Только chrome
* (хедер/нав/CTA/focus). Вердикт — ОТДЕЛЬНАЯ семантика, бордо НЕ сигнал вердикта.
* Нейтраль — Radix mauve (тёплый серый под красный hue). НЕ Material.
* Дизайн-система «Верификатор» — минимализм, бордо-акцент, мягкие поверхности.
* Бордо #9f1239 бренд кооперативной экономики, только chrome (CTA/focus/активное).
* Вердикт и статусы проверок — ОТДЕЛЬНАЯ семантика (зелёный/красный/янтарь), не бордо.
* Нейтраль — тёплый серый. Шрифт — Inter Variable (latin+cyrillic).
*/
@theme {
/* ВЕДУЩИЙ — бордо (chrome) */
--color-brand-9: #9f1239; /* бордо — ведущий акцент (CTA, активное, focus) */
--color-brand-10: #881337; /* бордо hover */
--color-brand-11: #fb7185; /* бордо-текст на тёмном (rose-400) */
--color-brand-3: #fff1f3; /* бордо-подложка (мягкий фон акцента) */
/* Бордо — ведущий акцент (chrome) */
--color-brand: #9f1239;
--color-brand-hover: #881337;
--color-brand-soft: #fff1f3; /* мягкая подложка акцента */
--color-brand-ring: #fecdd3; /* кольцо focus / drag */
/* Нейтраль — Radix mauve */
--color-mauve-1: #fdfcfd;
--color-mauve-2: #f9f8f9;
--color-mauve-3: #f3f1f3;
--color-mauve-6: #dcd8de;
--color-mauve-8: #bcb3c4;
--color-mauve-11: #6f6779;
--color-mauve-12: #1c1a20;
/* Нейтраль — тёплый серый */
--color-bg: #faf9fb; /* фон страницы */
--color-surface: #ffffff; /* поверхность карточки */
--color-line: #ecebef; /* hairline-граница */
--color-line-strong: #dedce4;
--color-ink: #1a1820; /* основной текст */
--color-ink-2: #5b5666; /* вторичный текст */
--color-ink-3: #8e8a98; /* подписи/мьютед */
/* Вердикт — ОТДЕЛЬНАЯ семантика (не бордо) */
--color-verdict-valid: #30a46c; /* ✅ зелёный (Radix grass-9) */
--color-verdict-invalid: #e5484d; /* ❌ яркий красный (Radix red-9) — ярче бордо, различим */
--color-verdict-warn: #ffa057; /* ⚠ amber */
/* Статусы (вердикт + проверки) */
--color-pos: #16a34a;
--color-pos-soft: #ecfdf3;
--color-neg: #dc2626;
--color-neg-soft: #fef2f2;
--color-warn: #d97706;
--color-warn-soft: #fffbeb;
/* Пространство / радиусы */
--radius-plane: 0.75rem;
--spacing-air: 2rem;
/* Радиусы / тени */
--radius-card: 1rem;
--radius-md: 0.625rem;
--shadow-card: 0 1px 2px rgb(17 12 24 / 0.04), 0 8px 24px -12px rgb(17 12 24 / 0.12);
--shadow-soft: 0 1px 2px rgb(17 12 24 / 0.04);
--font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
:root {
color-scheme: light;
}
body {
margin: 0;
background: var(--color-mauve-1);
color: var(--color-mauve-12);
font-family:
'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
* {
box-sizing: border-box;
}
/* Плоскость — подложка + hairline-граница, без material-elevation/ripple */
.plane {
background: var(--color-mauve-1);
border: 1px solid var(--color-mauve-6);
border-radius: var(--radius-plane);
body {
margin: 0;
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-feature-settings: 'cv01', 'cv03', 'ss01';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Карточка — мягкая поверхность с тонкой границей и лёгкой тенью */
.card {
background: var(--color-surface);
border: 1px solid var(--color-line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
/* Кнопки */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-md);
padding: 0.5625rem 1rem;
font-size: 0.875rem;
font-weight: 550;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-brand-ring);
}
.btn-primary {
background: var(--color-brand);
color: #fff;
border: 1px solid transparent;
}
.btn-primary:hover {
background: var(--color-brand-hover);
}
.btn-ghost {
background: var(--color-surface);
color: var(--color-ink);
border: 1px solid var(--color-line-strong);
}
.btn-ghost:hover {
background: var(--color-bg);
border-color: var(--color-ink-3);
}
/* Печать: чистый отчёт без chrome */
@@ -60,4 +111,8 @@ body {
body {
background: #fff;
}
.card {
box-shadow: none;
border-color: var(--color-line-strong);
}
}
+8
View File
@@ -85,6 +85,9 @@ importers:
apps/spa:
dependencies:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@verifier/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -539,6 +542,9 @@ packages:
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -2504,6 +2510,8 @@ snapshots:
- '@vue/composition-api'
- vue
'@fontsource-variable/inter@5.2.8': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0