feat(web): токены AURA, шрифт Mulish, проверка цветов (#65)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 00:01:03 +04:00
parent aad35177bf
commit b5a015b827
12 changed files with 239 additions and 35 deletions

8
web/package-lock.json generated
View File

@ -7,7 +7,7 @@
"name": "jira-hub-web",
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"@fontsource/mulish": "5.3.0",
"@tanstack/react-query": "5.104.0",
"react": "19.3.0",
"react-dom": "19.3.0",
@ -674,10 +674,10 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-sans": {
"node_modules/@fontsource/mulish": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
"integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
"resolved": "https://registry.npmjs.org/@fontsource/mulish/-/mulish-5.3.0.tgz",
"integrity": "sha512-Fru/fmJwg4ZlKUU4jELz6/guZhba6Lz1vraAROqooYnUbtj1iMi7BXFkQU7AkB6QP5swpvwRnO5chUp6mNh5mA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"

View File

@ -12,7 +12,7 @@
},
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"@fontsource/mulish": "5.3.0",
"@tanstack/react-query": "5.104.0",
"react": "19.3.0",
"react-dom": "19.3.0",

View File

@ -4,7 +4,7 @@ import { createBrowserRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "./api/queryClient.ts";
import { AuthGate } from "./auth/AuthGate.tsx";
import { routes } from "./routes.tsx";
import "./styles/theme.css";
import "./styles/tokens.css";
import "./styles/base.css";
import "./styles/controls.css";

View File

@ -95,7 +95,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.edges path.closed{opacity:.4}
.card.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:var(--shadow-popover);z-index:2}
.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto}
.card .key{cursor:pointer}
.card .meta{cursor:default}

View File

@ -63,6 +63,6 @@
/* окно карточки задачи (design 13) */
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
.issue-modal::backdrop{background:rgb(0 0 0 / .45)}
.issue-modal::backdrop{background:var(--color-overlay)}
.issue-modal > .issue{padding:0 20px 20px}
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}

View File

@ -1,9 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
// шрифты входят в сборку: приложение не ходит в интернет (T2.2)
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fontsource/mulish/400.css";
import "@fontsource/mulish/600.css";
import "@fontsource/mulish/700.css";
import "@fontsource/ibm-plex-mono/500.css";
import { App } from "./App.tsx";
import { applyTheme, readTheme } from "./shell/theme.ts";

View File

@ -2,7 +2,7 @@
.appbar .project-picker{font:inherit;font-size:14px;height:30px;max-width:280px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px}
.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0}
.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px}
.project-dialog::backdrop{background:rgba(0,0,0,.4)}
.project-dialog::backdrop{background:var(--color-overlay)}
.project-dialog form{display:flex;flex-direction:column;gap:10px}
.project-dialog h2{margin:0 0 4px;font-size:18px}
.project-dialog label{display:flex;flex-direction:column;gap:2px;font-size:13px;color:var(--muted)}

View File

@ -1,7 +1,7 @@
/* 2. Основа */
html,body{min-height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;margin:0}
h1{font-size:20px;font-weight:600;margin:0}
body{font:var(--fs-body)/var(--lh-body) var(--font-ui);background:var(--color-bg-canvas);color:var(--color-text-primary);margin:0}
h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;margin:0}
[hidden]{display:none!important}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.spacer{flex:1 1 auto}
@ -9,3 +9,5 @@ h1{font-size:20px;font-weight:600;margin:0}
.warn{color:var(--p-high)}
.page{padding-inline:16px;padding-block:0 32px}
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}

View File

@ -14,7 +14,7 @@
.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dd-btn .car{font-size:10px;opacity:.7}
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow-popover);padding:8px;display:flex;flex-direction:column;gap:6px}
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
@ -38,5 +38,5 @@
.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
.confirm .row{flex-wrap:nowrap}
.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}
.confirm .btn.yes{background:var(--p-crit);color:var(--color-text-on-primary);border-color:var(--p-crit)}

View File

@ -1,19 +0,0 @@
/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
:root{
--bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
--accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
--k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
--p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
--font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
:root[data-theme="dark"]{
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }

156
web/src/styles/tokens.css Normal file
View File

@ -0,0 +1,156 @@
/* Токены AURA: единственное место с литералами цвета (проверяет tokens.test.ts).
Светлая тема по умолчанию, тёмная — по системе или по выбору в меню. */
:root {
color-scheme: light;
--color-bg-canvas: #f8f8f6;
--color-bg-surface: #fdfdfc;
--color-bg-subtle: #fafaf8;
--color-bg-selected: #f4faf5;
--color-bg-hover: #f2f2f0;
--color-bg-track: #eeeeeb;
--color-text-primary: #17171a;
--color-text-secondary: #818190;
--color-text-placeholder: #5c6260;
--color-text-disabled: #737d76;
--color-text-on-primary: #ffffff;
--color-border-default: #e5e5e0;
--color-border-strong: #cfcfc8;
--color-border-selected: #568d32;
--color-action-primary: #568d32;
--color-action-primary-hover: #477729;
--color-action-danger: #b42318;
--color-action-danger-hover: #8e1b13;
--color-status-success: #568d32;
--color-status-success-bg: #f4faf5;
--color-status-error: #b42318;
--color-status-error-bg: #fff1f0;
--color-status-warning: #d89614;
--color-status-warning-bg: #fffbe6;
--color-status-warning-border: #ffe58f;
--color-focus-ring: rgb(42 122 70 / 35%);
--color-overlay: rgb(0 0 0 / 45%);
--shadow-popover: 0 10px 28px rgb(0 0 0 / 18%);
--shadow-dialog: 0 24px 72px rgb(28 47 36 / 12%);
--k-decision: #b4542a;
--k-analytics: #8a6d1f;
--k-dev: #1f6f8b;
--k-ui: #5b4b9a;
--k-test: #a63d78;
--k-org: #7a766e;
--p-crit: #b42318;
--p-high: #c7772a;
--p-mid: #7a766e;
--p-low: #a3a39b;
--edge: #a3a39b;
--guide: #c2410c;
--lane-alt: #f1f1ee;
--edge-strong: var(--color-action-primary);
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-7: 28px; --space-8: 32px;
--radius-xs: 4px; --radius-sm: 8px; --radius-md: 10px; --radius-lg: 12px;
--radius-xl: 16px; --radius-2xl: 20px; --radius-round: 999px;
--font-ui: "Mulish", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--fs-caption: 12px; --fs-body: 14px; --fs-lg: 16px; --fs-section: 20px;
--lh-caption: 16px; --lh-body: 20px; --lh-lg: 24px; --lh-section: 28px;
--control-h: 32px; --control-h-lg: 40px; --control-radius: 10px;
--motion: 180ms ease;
/* Псевдонимы на переход: удаляются в Task 10 */
--bg: var(--color-bg-canvas); --fg: var(--color-text-primary); --muted: var(--color-text-secondary);
--line: var(--color-border-default); --card: var(--color-bg-surface); --panel: var(--color-bg-surface);
--soft: var(--color-bg-hover); --accent: var(--color-action-primary); --accent-fg: var(--color-text-on-primary);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg-canvas: #181818;
--color-bg-surface: #1f1f1f;
--color-bg-subtle: #272727;
--color-bg-selected: #272727;
--color-bg-hover: #272727;
--color-bg-track: #272727;
--color-text-primary: #f2f4f2;
--color-text-secondary: #d6dcd7;
--color-text-placeholder: #aab2ac;
--color-text-disabled: #aab2ac;
--color-text-on-primary: #112016;
--color-border-default: rgb(232 238 233 / 10%);
--color-border-strong: rgb(232 238 233 / 17%);
--color-border-selected: #69a93d;
--color-action-primary: #69a93d;
--color-action-primary-hover: #83c657;
--color-action-danger: #ffaea5;
--color-action-danger-hover: #ffcbc5;
--color-status-success: #69a93d;
--color-status-success-bg: #272727;
--color-status-error: #e26c67;
--color-status-error-bg: #402725;
--color-status-warning: #e8c17a;
--color-status-warning-bg: #382f20;
--color-status-warning-border: #8a6d1d;
--color-focus-ring: rgb(116 184 255 / 55%);
--color-overlay: rgb(0 0 0 / 45%);
--shadow-popover: 0 10px 28px rgb(0 0 0 / 55%);
--shadow-dialog: 0 24px 72px rgb(0 0 0 / 60%);
--k-decision: #e08a5a;
--k-analytics: #d4b35a;
--k-dev: #6fb3cc;
--k-ui: #a493d8;
--k-test: #e07fb0;
--k-org: #aab2ac;
--p-crit: #e26c67;
--p-high: #e3a059;
--p-mid: #aab2ac;
--p-low: #6c726b;
--edge: #6c726b;
--guide: #f08a5a;
--lane-alt: #212121;
--edge-strong: var(--color-action-primary);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg-canvas: #181818;
--color-bg-surface: #1f1f1f;
--color-bg-subtle: #272727;
--color-bg-selected: #272727;
--color-bg-hover: #272727;
--color-bg-track: #272727;
--color-text-primary: #f2f4f2;
--color-text-secondary: #d6dcd7;
--color-text-placeholder: #aab2ac;
--color-text-disabled: #aab2ac;
--color-text-on-primary: #112016;
--color-border-default: rgb(232 238 233 / 10%);
--color-border-strong: rgb(232 238 233 / 17%);
--color-border-selected: #69a93d;
--color-action-primary: #69a93d;
--color-action-primary-hover: #83c657;
--color-action-danger: #ffaea5;
--color-action-danger-hover: #ffcbc5;
--color-status-success: #69a93d;
--color-status-success-bg: #272727;
--color-status-error: #e26c67;
--color-status-error-bg: #402725;
--color-status-warning: #e8c17a;
--color-status-warning-bg: #382f20;
--color-status-warning-border: #8a6d1d;
--color-focus-ring: rgb(116 184 255 / 55%);
--color-overlay: rgb(0 0 0 / 45%);
--shadow-popover: 0 10px 28px rgb(0 0 0 / 55%);
--shadow-dialog: 0 24px 72px rgb(0 0 0 / 60%);
--k-decision: #e08a5a;
--k-analytics: #d4b35a;
--k-dev: #6fb3cc;
--k-ui: #a493d8;
--k-test: #e07fb0;
--k-org: #aab2ac;
--p-crit: #e26c67;
--p-high: #e3a059;
--p-mid: #aab2ac;
--p-low: #6c726b;
--edge: #6c726b;
--guide: #f08a5a;
--lane-alt: #212121;
--edge-strong: var(--color-action-primary);
}

View File

@ -0,0 +1,65 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = resolve(__dirname, "..");
const TOKENS = resolve(__dirname, "tokens.css");
function cssFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
if (statSync(p).isDirectory()) return cssFiles(p);
return p.endsWith(".css") ? [p] : [];
});
}
const stripComments = (s: string) => s.replace(/\/\*[\s\S]*?\*\//g, "");
const LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\(|:\s*(?:white|black|red|green|blue|gray|grey|orange|yellow|purple)\b/g;
/** Тело блока, начинающегося с селектора `head` (по балансу скобок). */
function block(css: string, head: string): string {
const at = css.indexOf(head);
if (at < 0) throw new Error(`нет блока ${head}`);
let i = css.indexOf("{", at);
const start = i + 1;
let depth = 0;
for (; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}" && --depth === 0) return css.slice(start, i);
}
throw new Error(`блок ${head} не закрыт`);
}
const names = (body: string) =>
[...body.matchAll(/(--color-[\w-]+)\s*:/g)].map((m) => m[1]).sort();
describe("токены AURA", () => {
it("вне tokens.css нет литералов цвета", () => {
const found: string[] = [];
for (const f of cssFiles(SRC)) {
if (f === TOKENS) continue;
for (const m of stripComments(readFileSync(f, "utf8")).matchAll(LITERAL)) {
found.push(`${relative(SRC, f)}: ${m[0]}`);
}
}
expect(found).toEqual([]);
});
const css = stripComments(readFileSync(TOKENS, "utf8"));
it("обе темы задают одни имена", () => {
const l = names(block(css, ":root {"));
const media = names(block(css, ':root:not([data-theme="light"])'));
const dark = names(block(css, ':root[data-theme="dark"]'));
expect(l.length).toBeGreaterThan(20);
expect(media).toEqual(l);
expect(dark).toEqual(l);
});
it("тёмные блоки и светлый :root задают color-scheme", () => {
expect(block(css, ":root {")).toContain("color-scheme: light");
expect(block(css, ':root:not([data-theme="light"])')).toContain("color-scheme: dark");
expect(block(css, ':root[data-theme="dark"]')).toContain("color-scheme: dark");
});
});