diff --git a/web/package-lock.json b/web/package-lock.json index 30ac58f..ca9ca9d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 5927f5e..6239a5d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 02634e5..c5c022c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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"; diff --git a/web/src/board/board.css b/web/src/board/board.css index d67ec3a..e8fa494 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -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} diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index b2ed1d2..d334333 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -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}} diff --git a/web/src/main.tsx b/web/src/main.tsx index 16cb33f..8a7a891 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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"; diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css index 54c61a5..579aa30 100644 --- a/web/src/projects/projects.css +++ b/web/src/projects/projects.css @@ -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)} diff --git a/web/src/styles/base.css b/web/src/styles/base.css index c9878e9..9b5edde 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -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}} diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css index fe53af7..9240509 100644 --- a/web/src/styles/controls.css +++ b/web/src/styles/controls.css @@ -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)} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css deleted file mode 100644 index a2044ca..0000000 --- a/web/src/styles/theme.css +++ /dev/null @@ -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 } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..dc10dcd --- /dev/null +++ b/web/src/styles/tokens.css @@ -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); +} diff --git a/web/src/styles/tokens.test.ts b/web/src/styles/tokens.test.ts new file mode 100644 index 0000000..ab0e13b --- /dev/null +++ b/web/src/styles/tokens.test.ts @@ -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"); + }); +});