From 4d51e65226cc2de94a910ed6dd02b3419b9520a6 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 21 Aug 2026 05:11:38 +0300 Subject: [PATCH] feat: make creator cabinet ids easier to use --- cmd/creator-web/server_test.go | 6 + cmd/creator-web/static/assets/console.css | 124 +++++++- cmd/creator-web/static/assets/console.js | 294 +++++++++++++++++- cmd/creator-web/static/index.html | 94 +++++- dev/ACTIVE_TASK.json | 6 +- ...DEV-000074-creator-cabinet-operations.json | 92 ++++++ 6 files changed, 568 insertions(+), 48 deletions(-) create mode 100644 dev/tasks/DEV-000074-creator-cabinet-operations.json diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 1d90693..e1f46e7 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -233,6 +233,12 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") || + !strings.Contains(response.Body.String(), "context-preset") || + !strings.Contains(response.Body.String(), "context-state") || + !strings.Contains(response.Body.String(), "copy-context") || + !strings.Contains(response.Body.String(), "К содержимому") || + !strings.Contains(response.Body.String(), "aria-current=\"page\"") || + !strings.Contains(response.Body.String(), "section-nav-link") || !strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") || !strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") || !strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") || diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 8dbd237..008a05d 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -1,12 +1,12 @@ :root { - --ink: #e9eee7; - --muted: #8c9a91; - --faint: #58645d; - --base: #0b100e; - --panel: #101714; - --panel-high: #151e1a; - --line: #2a352f; - --line-soft: #1d2822; + --ink: #eef2ec; + --muted: #a7b2aa; + --faint: #718078; + --base: #0f1512; + --panel: #17201b; + --panel-high: #202b25; + --line: #34423a; + --line-soft: #26332c; --signal: #c9a85f; --signal-bright: #e7c97b; --good: #78c6a3; @@ -21,13 +21,15 @@ * { box-sizing: border-box; } [hidden] { display: none !important; } -html { background: var(--base); color: var(--ink); font-family: var(--sans); } -body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .15), transparent 38%), var(--base); } +html { background: var(--base); color: var(--ink); font-family: var(--sans); scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .13), transparent 38%), var(--base); } button, input, textarea { font: inherit; } button { color: inherit; } -button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; } +button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; } +.skip-link { position: fixed; top: 10px; left: 10px; z-index: 30; padding: 10px 13px; color: #11150f; background: var(--signal-bright); font: 600 10px/1 var(--mono); text-decoration: none; transform: translateY(-160%); transition: transform .18s; } +.skip-link:focus { transform: translateY(0); } -.grain { position: fixed; inset: 0; pointer-events: none; opacity: .18; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); } +.grain { position: fixed; inset: 0; pointer-events: none; opacity: .045; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); } .eyebrow, .rail-index { margin: 0 0 12px; color: var(--signal); font: 600 11px/1 var(--mono); letter-spacing: .18em; text-transform: uppercase; } .login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; position: relative; overflow: hidden; } @@ -63,6 +65,9 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .wordmark strong { font: 400 20px/1 var(--serif); } .wordmark small { margin-top: 4px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } .topbar-actions { display: flex; align-items: center; gap: 10px; } +.topbar-nav { display: flex; align-items: center; gap: 4px; margin-right: 8px; } +.topbar-nav a { padding: 9px 10px; color: var(--muted); text-decoration: none; font: 600 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.topbar-nav a:hover, .topbar-nav a:focus-visible { color: var(--ink); background: var(--panel-high); outline: 0; } .connection { margin-right: 12px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } .connection i { width: 7px; height: 7px; display: inline-block; margin-right: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 14px rgba(120, 198, 163, .6); } .connection.offline i { background: var(--bad); box-shadow: none; } @@ -73,17 +78,54 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .context-rail h2 { margin: 0; font: 400 29px/1.1 var(--serif); } .rail-copy { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } .context-form { display: grid; } +.context-status-row { margin-bottom: 16px; padding: 10px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line-soft); background: rgba(120,198,163,.04); } +.context-status-label { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; } +.context-status-row strong { color: var(--warn); font: 600 9px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; } +.context-status-row strong[data-state="ready"] { color: var(--good); } +.context-presets { display: grid; gap: 7px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); } +.context-presets label { margin: 0; } +.context-presets select { width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 10px/1 var(--mono); } +.context-presets small { color: var(--faint); font-size: 10px; line-height: 1.35; } +.context-field { display: grid; gap: 7px; } +.context-field + .context-field { margin-top: 13px; } +.context-field label { margin: 0; } +.context-field label span { color: var(--signal); font-size: 8px; letter-spacing: .06em; } +.context-input-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 5px; } +.context-input-row input { min-width: 0; } +.copy-context { width: 34px; height: 40px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; } +.copy-context:hover { color: var(--ink); border-color: var(--faint); background: var(--panel-high); } +.id-actions { min-height: 31px; display: flex; align-items: center; gap: 7px; margin-top: 12px; } +.id-action { min-height: 30px; padding: 0 9px; border: 1px solid var(--line-soft); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; font: 9px/1 var(--mono); letter-spacing: .04em; } +.id-action:hover { color: var(--ink); border-color: var(--signal); background: rgba(201,168,95,.07); } +.icon-id-action { width: 30px; padding: 0; color: var(--faint); } .context-form label { margin-top: 15px; } .context-form label:first-child { margin-top: 0; } .context-form input { height: 40px; padding: 0 10px; font: 11px/1 var(--mono); } +.context-hint { margin: 12px 0 0; color: var(--faint); font-size: 10px; line-height: 1.45; } +.advanced-context { margin-top: 17px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +.advanced-context summary { padding: 12px 0; color: var(--muted); cursor: pointer; list-style: none; font: 600 9px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.advanced-context summary::before { content: "+"; display: inline-block; width: 18px; color: var(--signal); font-size: 13px; } +.advanced-context[open] summary::before { content: "−"; } +.advanced-context > div { padding: 0 0 10px; } +.advanced-context label { margin-top: 13px; } +.context-actions { display: grid; gap: 8px; } +.context-actions .secondary-button { margin-top: 18px; } +.context-actions .text-button { margin: 0; justify-self: start; padding-inline: 0; } .secondary-button { height: 42px; margin-top: 20px; color: #11150f; background: var(--signal); font-size: 12px; } +.section-nav { display: grid; gap: 3px; margin-top: 4px; } +.section-nav-link { display: grid; grid-template-columns: 28px 1fr; gap: 2px 10px; padding: 11px 10px; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; transition: background .18s, border-color .18s, color .18s; } +.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="page"] { border-left-color: var(--signal); color: var(--ink); background: rgba(201,168,95,.07); outline: 0; } +.section-nav-link span { grid-row: 1 / span 2; color: var(--signal); font: 9px/1.4 var(--mono); } +.section-nav-link b { font: 600 11px/1.2 var(--sans); } +.section-nav-link small { color: var(--faint); font: 9px/1.2 var(--mono); } .session-stamp { margin-top: auto; padding-top: 20px; display: grid; gap: 6px; border-top: 1px solid var(--line); } .session-stamp span { margin-top: 8px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; } .session-stamp strong { overflow: hidden; color: var(--muted); font: 10px/1.4 var(--mono); text-overflow: ellipsis; } -.workspace { grid-column: 2; grid-row: 2; padding: clamp(28px, 4vw, 58px); min-width: 0; } +.workspace { grid-column: 2; grid-row: 2; width: min(100%, 1740px); justify-self: start; padding: 42px clamp(24px, 3vw, 48px) 64px; min-width: 0; } +.workspace > section { scroll-margin-top: 94px; } .workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; } -.workspace-head h1 { margin: 0; font: 400 clamp(36px, 5vw, 58px)/1 var(--serif); letter-spacing: -.035em; } +.workspace-head h1 { margin: 0; font: 400 clamp(34px, 4vw, 50px)/1 var(--serif); letter-spacing: -.035em; } .last-update { color: var(--faint); font: 10px/1.4 var(--mono); text-align: right; } .signal-strip { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); } .signal-strip div { padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-right: 1px solid var(--line); } @@ -94,6 +136,46 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .signal-strip strong[data-state="error"] { color: var(--bad); } .signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.diagnostics-panel, .settings-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16,23,20,.82); } +.diagnostics-panel { border-color: rgba(120,198,163,.32); box-shadow: inset 3px 0 0 rgba(120,198,163,.45); } +.panel-heading { min-height: 68px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); } +.panel-heading p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } +.panel-heading h2 { margin: 0; font: 400 23px/1 var(--serif); } +.panel-heading-tools { display: flex; align-items: center; gap: 14px; } +.panel-heading-tools > span, .settings-saved { color: var(--muted); font: 9px/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; } +.compact-button { width: auto; min-width: 142px; height: 38px; margin: 0; padding: 0 13px; } +.diagnostic-intro { padding: 13px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line-soft); color: var(--muted); } +.diagnostic-intro p { margin: 0; font-size: 11px; line-height: 1.45; } +.diagnostic-pulse { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--good); box-shadow: 0 0 12px rgba(120,198,163,.7); } +.diagnostic-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.diagnostic-list[aria-busy="true"] { opacity: .7; } +.diagnostic-item { min-width: 0; min-height: 92px; padding: 14px 16px; display: grid; grid-template-columns: 9px 1fr auto; gap: 9px; align-items: start; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +.diagnostic-item:nth-child(4n) { border-right: 0; } +.diagnostic-item > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--faint); } +.diagnostic-item[data-state="ok"] > i { background: var(--good); box-shadow: 0 0 9px rgba(120,198,163,.6); } +.diagnostic-item[data-state="error"] > i { background: var(--bad); } +.diagnostic-item[data-state="loading"] > i { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.diagnostic-item strong { display: block; color: var(--ink); font: 600 10px/1.3 var(--mono); } +.diagnostic-item span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.35; } +.diagnostic-item em { color: var(--faint); font: normal 9px/1.2 var(--mono); text-transform: uppercase; } +.diagnostic-item[data-state="ok"] em { color: var(--good); } +.diagnostic-item[data-state="error"] em { color: var(--bad); } +.settings-grid { padding: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; } +.setting-field, .setting-check { min-width: 0; display: grid; gap: 7px; color: var(--ink); } +.setting-field > span, .setting-check b { font: 600 10px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.setting-field small, .setting-check small { color: var(--muted); font-size: 11px; line-height: 1.4; } +.setting-field select { width: 100%; min-height: 40px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 11px/1 var(--mono); } +.setting-check { grid-template-columns: 18px 1fr; align-items: start; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-soft); } +.setting-check input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--signal); } +.setting-check span { display: grid; gap: 7px; } +.settings-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; grid-column: 1 / -1; padding-top: 2px; } +.settings-actions .text-button { margin: 0; } +html[data-density="compact"] .data-card { padding: 18px; } +html[data-density="compact"] .data-card dl div { padding-block: 10px; } +html[data-density="compact"] .activity-row { min-height: 58px; padding-block: 8px; } +html[data-density="compact"] .learning-row { min-height: 54px; padding-block: 9px; } +html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, html[data-reduced-motion="true"] *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } + .advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); } .advisor-intro { align-self: center; } .advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; } @@ -278,6 +360,9 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .advisor-panel { grid-template-columns: 1fr; } .capability-card { grid-column: auto; } .prospective-card { grid-column: auto; } + .diagnostic-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .diagnostic-item:nth-child(4n) { border-right: 1px solid var(--line-soft); } + .diagnostic-item:nth-child(2n) { border-right: 0; } } @media (max-width: 680px) { @@ -285,14 +370,25 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .console { display: block; } .topbar { height: 66px; padding: 0 14px; } .wordmark small, .connection, .text-button { display: none; } + .topbar-nav { display: none; } .context-rail { padding: 24px 18px; border-right: 0; border-bottom: 1px solid var(--line); } .rail-copy { max-width: 48ch; } .session-stamp { margin-top: 8px; grid-template-columns: auto 1fr; align-items: center; } .session-stamp span { margin: 0; } .workspace { padding: 30px 16px 50px; } + .section-nav { display: flex; gap: 6px; margin: 10px -18px -24px; padding: 10px 18px; overflow-x: auto; border-top: 1px solid var(--line-soft); background: #0e1512; scrollbar-width: thin; } + .section-nav-link { min-width: 142px; padding: 10px; border-left: 0; border-bottom: 2px solid transparent; } + .section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="page"] { border-left-color: transparent; border-bottom-color: var(--signal); } .workspace-head { align-items: start; flex-direction: column; } .last-update { text-align: left; } .signal-strip { grid-template-columns: 1fr; } + .panel-heading { align-items: flex-start; flex-direction: column; } + .panel-heading-tools { width: 100%; justify-content: space-between; } + .diagnostic-list { grid-template-columns: 1fr; } + .diagnostic-item, .diagnostic-item:nth-child(2n), .diagnostic-item:nth-child(4n) { border-right: 0; } + .settings-grid { grid-template-columns: 1fr; padding: 16px; } + .settings-actions { grid-column: auto; align-items: stretch; flex-direction: column; } + .settings-actions .primary-button { width: 100%; } .signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); } .advisor-panel { padding: 20px 16px; gap: 20px; } .advisor-controls { align-items: stretch; flex-direction: column; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 9ccec82..b201a79 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -7,14 +7,50 @@ const advisoryForm = byId("advisory-form"); const loginError = byId("login-error"); const toast = byId("toast"); const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"]; +const settingsStorageKey = "domovoy.creator.settings"; +const contextPresetsStorageKey = "domovoy.creator.context.presets"; let sessionToken = sessionStorage.getItem("domovoy.creator.session") || ""; let toastTimer; +let refreshTimer; let currentTask = null; let currentProspective = null; let pendingCommand = null; let pendingNotification = null; let lastAuthorizedActionID = ""; let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false}; +let uiSettings = {density: "comfortable", refresh: "off", motion: false}; + +const sectionNavLinks = Array.from(document.querySelectorAll(".section-nav-link")); +const cabinetSections = ["overview", "diagnostics", "operations", "settings"] + .map((id) => byId(id)) + .filter(Boolean); + +function setActiveSection(id) { + sectionNavLinks.forEach((link) => { + const active = link.getAttribute("href") === `#${id}`; + link.classList.toggle("active", active); + if (active) link.setAttribute("aria-current", "page"); + else link.removeAttribute("aria-current"); + }); +} + +if ("IntersectionObserver" in window) { + const sectionObserver = new IntersectionObserver((entries) => { + const visible = entries.filter((entry) => entry.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; + if (visible) setActiveSection(visible.target.id); + }, {rootMargin: "-18% 0px -64% 0px", threshold: [0, .25, .6, 1]}); + cabinetSections.forEach((section) => sectionObserver.observe(section)); +} + +sectionNavLinks.forEach((link) => link.addEventListener("click", () => { + const id = link.getAttribute("href").slice(1); + setActiveSection(id); +})); +window.addEventListener("hashchange", () => { + const id = window.location.hash.slice(1); + if (cabinetSections.some((section) => section.id === id)) setActiveSection(id); +}); function setText(id, value, fallback = "—") { byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value); @@ -39,6 +75,18 @@ function notify(message) { toastTimer = setTimeout(() => { toast.hidden = true; }, 4200); } +function safeClientError(error) { + const message = error && typeof error.message === "string" ? error.message : ""; + switch (message) { + case "creator authentication denied": + return "Логин или пароль не принят."; + case "Сессия отклонена": + return "Сессия истекла. Войдите снова."; + default: + return "Операция недоступна. Попробуйте ещё раз."; + } +} + async function request(path, options = {}) { const headers = new Headers(options.headers || {}); if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`); @@ -50,7 +98,9 @@ async function request(path, options = {}) { } if (!response.ok) { const detail = (await response.text()).trim(); - throw new Error(detail || `Ошибка ${response.status}`); + const error = new Error(detail || `Ошибка ${response.status}`); + error.status = response.status; + throw error; } return response.json(); } @@ -64,15 +114,57 @@ function restoreContext() { } catch (_) { localStorage.removeItem("domovoy.creator.context"); } + renderContextState(); + renderContextPresets(); } function saveContext() { const saved = {}; contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); }); localStorage.setItem("domovoy.creator.context", JSON.stringify(saved)); + renderContextState(); return saved; } +function readContextPresets() { + try { + const value = JSON.parse(localStorage.getItem(contextPresetsStorageKey) || "[]"); + return Array.isArray(value) ? value.filter((item) => item && item.session && item.workspace) : []; + } catch (_) { + localStorage.removeItem(contextPresetsStorageKey); + return []; + } +} + +function renderContextState() { + const state = byId("context-state"); + if (!state) return; + const ready = Boolean(byId("session-id").value.trim() && byId("workspace-id").value.trim()); + state.textContent = ready ? "готов к проверке" : "не выбран"; + state.dataset.state = ready ? "ready" : "empty"; +} + +function renderContextPresets() { + const select = byId("context-preset"); + if (!select) return; + const presets = readContextPresets(); + select.replaceChildren(new Option("Выберите сохранённый набор…", "")); + presets.forEach((preset, index) => { + const session = preset.session.length > 18 ? `${preset.session.slice(0, 8)}…${preset.session.slice(-6)}` : preset.session; + const workspace = preset.workspace.length > 18 ? `${preset.workspace.slice(0, 8)}…${preset.workspace.slice(-6)}` : preset.workspace; + select.appendChild(new Option(`${session} · ${workspace}`, String(index))); + }); +} + +function rememberContextPreset(context) { + if (!context["session-id"] || !context["workspace-id"]) return; + const next = {session: context["session-id"], workspace: context["workspace-id"], savedAt: Date.now()}; + const presets = readContextPresets().filter((item) => item.session !== next.session || item.workspace !== next.workspace); + presets.unshift(next); + localStorage.setItem(contextPresetsStorageKey, JSON.stringify(presets.slice(0, 5))); + renderContextPresets(); +} + async function showConsole() { const session = await request("/api/session/current"); setText("session-actor", session.ActorID); @@ -80,6 +172,7 @@ async function showConsole() { setText("session-expiry", expiry); loginView.hidden = true; consoleView.hidden = false; + restoreSettings(); restoreContext(); await loadAdvisoryPolicy(); const context = saveContext(); @@ -88,10 +181,13 @@ async function showConsole() { loadActionHistory(); loadLearning(); } + runDiagnostics(); } function endSession(message = "Сессия завершена.") { sessionToken = ""; + clearInterval(refreshTimer); + refreshTimer = null; sessionStorage.removeItem("domovoy.creator.session"); consoleView.hidden = true; loginView.hidden = false; @@ -128,7 +224,7 @@ loginForm.addEventListener("submit", async (event) => { byId("creator-password").value = ""; await showConsole(); } catch (error) { - loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message; + loginError.textContent = safeClientError(error); } finally { button.disabled = false; button.firstElementChild.textContent = "Открыть консоль"; @@ -144,14 +240,129 @@ byId("toggle-secret").addEventListener("click", () => { contextForm.addEventListener("submit", (event) => { event.preventDefault(); - saveContext(); + const context = saveContext(); + rememberContextPreset(context); loadDashboard(); }); +for (const input of [byId("session-id"), byId("workspace-id")]) { + input.addEventListener("input", renderContextState); +} + +byId("context-preset").addEventListener("change", (event) => { + const index = Number(event.target.value); + if (!Number.isInteger(index)) return; + const preset = readContextPresets()[index]; + if (!preset) return; + byId("session-id").value = preset.session; + byId("workspace-id").value = preset.workspace; + renderContextState(); + loadDashboard(); + notify("Контекст из недавнего набора загружен."); +}); + +for (const button of document.querySelectorAll("[data-copy-context]")) { + button.addEventListener("click", async () => { + const value = byId(button.dataset.copyContext).value.trim(); + if (!value) { + notify("Сначала укажите идентификатор."); + return; + } + try { + await navigator.clipboard.writeText(value); + notify("Идентификатор скопирован."); + } catch (_) { + notify("Не удалось скопировать идентификатор."); + } + }); +} + +for (const button of document.querySelectorAll("[data-copy-value], [data-use-value]")) { + button.addEventListener("click", async () => { + const source = byId(button.dataset.copyValue || button.dataset.useValue); + const value = source?.textContent.trim() || ""; + if (!value || value === "—") { + notify("Сначала загрузите значение."); + return; + } + if (button.dataset.useValue) { + const target = byId(button.dataset.targetId); + if (!target) return; + target.value = value; + document.querySelector(".advanced-context").open = true; + saveContext(); + notify("ID подставлен в расширенную проверку."); + return; + } + try { + await navigator.clipboard.writeText(value); + notify("ID скопирован."); + } catch (_) { + notify("Не удалось скопировать ID."); + } + }); +} + +byId("reset-context").addEventListener("click", () => { + contextFields.forEach((id) => { byId(id).value = id === "capability-id" ? "cognitive-proposal" : ""; }); + localStorage.removeItem("domovoy.creator.context"); + loadDashboard(); + notify("Рабочий контекст сброшен."); +}); + +function restoreSettings() { + try { + const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}"); + uiSettings = { + density: ["comfortable", "compact"].includes(saved.density) ? saved.density : "comfortable", + refresh: ["off", "30", "60", "300"].includes(saved.refresh) ? saved.refresh : "off", + motion: saved.motion === true, + }; + } catch (_) { + uiSettings = {density: "comfortable", refresh: "off", motion: false}; + } + byId("setting-density").value = uiSettings.density; + byId("setting-refresh").value = uiSettings.refresh; + byId("setting-motion").checked = uiSettings.motion; + applySettings(); +} + +function applySettings() { + document.documentElement.dataset.density = uiSettings.density; + document.documentElement.dataset.reducedMotion = uiSettings.motion ? "true" : "false"; + clearInterval(refreshTimer); + refreshTimer = null; + if (sessionToken && uiSettings.refresh !== "off") { + refreshTimer = setInterval(() => loadDashboard(), Number(uiSettings.refresh) * 1000); + } +} + +byId("settings-form").addEventListener("submit", (event) => { + event.preventDefault(); + uiSettings = { + density: byId("setting-density").value, + refresh: byId("setting-refresh").value, + motion: byId("setting-motion").checked, + }; + localStorage.setItem(settingsStorageKey, JSON.stringify(uiSettings)); + applySettings(); + setText("settings-saved", "Сохранено только в этом браузере"); + notify("Настройки кабинета сохранены."); +}); + +byId("reset-settings").addEventListener("click", () => { + uiSettings = {density: "comfortable", refresh: "off", motion: false}; + localStorage.removeItem(settingsStorageKey); + restoreSettings(); + setText("settings-saved", "Восстановлены значения по умолчанию"); + notify("Настройки сброшены."); +}); + byId("refresh-all").addEventListener("click", loadDashboard); byId("refresh-actions").addEventListener("click", loadActionHistory); byId("refresh-learning").addEventListener("click", loadLearning); byId("logout").addEventListener("click", () => endSession()); +byId("run-diagnostics").addEventListener("click", runDiagnostics); advisoryForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -185,7 +396,7 @@ advisoryForm.addEventListener("submit", async (event) => { byId("advisory-result").hidden = false; } catch (error) { setText("advisory-state", "ОШИБКА"); - setText("advisory-text", error.message); + setText("advisory-text", safeClientError(error)); byId("advisory-result").hidden = false; } finally { byId("advisory-egress-ack").checked = false; @@ -246,6 +457,57 @@ async function loadDashboard() { setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`); } +async function runDiagnostics() { + const list = byId("diagnostic-list"); + const summary = byId("diagnostic-summary"); + const checks = [ + ["Сессия", "identity", () => request("/api/session/current")], + ["Политика совета", "advisory", () => request("/api/advisory-policy")], + ["Когнитивная возможность", "capability", () => request("/api/capabilities/cognitive-proposal/health")], + ["Сегодня", "today", () => request("/api/today")], + ["Внимание", "attention", () => request("/api/attention")], + ["Действия", "actions", () => request("/api/actions?limit=1")], + ["Learning evidence", "learning", () => request("/api/learning/recent?limit=1")], + ]; + list.setAttribute("aria-busy", "true"); + list.replaceChildren(); + checks.forEach(([label, key]) => list.append(createDiagnosticItem(label, key, "loading"))); + setText("diagnostic-summary", "Проверяю…"); + const results = await Promise.all(checks.map(async ([label, key, run]) => { + const started = performance.now(); + try { + await run(); + return {label, key, state: "ok", detail: `Ответ получен · ${Math.round(performance.now() - started)} мс`}; + } catch (error) { + const detail = error?.status === 400 ? "Нужны параметры рабочего контекста" : error?.status === 401 ? "Сессия истекла" : error?.status >= 500 ? "Сервис временно недоступен" : "Проверка не пройдена"; + return {label, key, state: "error", detail}; + } + })); + list.replaceChildren(...results.map((result) => createDiagnosticItem(result.label, result.key, result.state, result.detail))); + list.setAttribute("aria-busy", "false"); + const passed = results.filter((result) => result.state === "ok").length; + setText("diagnostic-summary", `${passed}/${results.length} проверок пройдено`); +} + +function createDiagnosticItem(label, key, state, detail = "Проверяю…") { + const item = document.createElement("article"); + item.className = "diagnostic-item"; + item.dataset.state = state; + item.dataset.check = key; + const marker = document.createElement("i"); + marker.setAttribute("aria-hidden", "true"); + const copy = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = label; + const description = document.createElement("span"); + description.textContent = detail; + copy.append(title, description); + const status = document.createElement("em"); + status.textContent = state === "ok" ? "OK" : state === "error" ? "ошибка" : "…"; + item.append(marker, copy, status); + return item; +} + async function loadLearning() { const experienceList = byId("experience-list"); const reflectionList = byId("reflection-list"); @@ -272,7 +534,7 @@ async function loadLearning() { list.replaceChildren(); const failed = document.createElement("p"); failed.className = "register-empty activity-error"; - failed.textContent = error.message; + failed.textContent = safeClientError(error); list.append(failed); }); setText("learning-count", "ОШИБКА"); @@ -371,7 +633,7 @@ async function loadActionHistory() { list.replaceChildren(); const failed = document.createElement("p"); failed.className = "register-empty activity-error"; - failed.textContent = error.message; + failed.textContent = safeClientError(error); list.append(failed); setText("action-count", "ОШИБКА"); } finally { @@ -430,7 +692,7 @@ async function reconcileHistoryAction(actionID, button) { await loadActionHistory(); if (currentProspective) await loadProspectiveRegister(currentProspective.ID); } catch (error) { - notify(error.message); + notify(safeClientError(error)); button.disabled = false; button.textContent = "Сверить"; } @@ -450,7 +712,7 @@ async function loadFocus(query) { } catch (error) { setSignal("focus", "ошибка", "error"); setBadge("focus", "НЕДОСТУПЕН", "bad"); - setText("focus-message", error.message); + setText("focus-message", safeClientError(error)); byId("connection-state").classList.add("offline"); } } @@ -472,7 +734,7 @@ async function loadTask(taskID, query) { currentTask = null; setSignal("task", "отклонена", "error"); setBadge("task", "ВНЕ ФОКУСА", "bad"); - setText("task-message", error.message); + setText("task-message", safeClientError(error)); updateActionReadiness(); } } @@ -511,12 +773,12 @@ async function loadProspectiveRegister(selectedID) { list.replaceChildren(); const failed = document.createElement("p"); failed.className = "register-empty"; - failed.textContent = error.message; + failed.textContent = safeClientError(error); list.append(failed); setText("prospective-count", "ОШИБКА"); setSignal("prospective", "ошибка", "error"); setBadge("prospective", "НЕДОСТУПНА", "bad"); - setText("prospective-message", error.message); + setText("prospective-message", safeClientError(error)); } } @@ -572,7 +834,7 @@ async function loadProspective(prospectiveID) { setSignal("prospective", "ошибка", "error"); setBadge("prospective", "НЕДОСТУПНА", "bad"); setText("prospective-state", "UNKNOWN"); - setText("prospective-message", error.message); + setText("prospective-message", safeClientError(error)); updateActionReadiness(); } } @@ -594,7 +856,7 @@ async function loadCapability(capabilityID) { setSignal("capability", "ошибка", "error"); setBadge("capability", "НЕДОСТУПНА", "bad"); setText("capability-state", "UNKNOWN"); - setText("capability-reason", error.message); + setText("capability-reason", safeClientError(error)); } } @@ -700,7 +962,7 @@ byId("confirm-notification").addEventListener("click", async () => { await loadActionHistory(); notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки."); } catch (error) { - setText("notification-confirmation-error", error.message); + setText("notification-confirmation-error", safeClientError(error)); } finally { button.disabled = false; button.textContent = "Подтверждаю отправку"; @@ -730,7 +992,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => { notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки."); await loadProspectiveRegister(command.ProspectiveItemID); } catch (error) { - setText("confirmation-error", error.message); + setText("confirmation-error", safeClientError(error)); } finally { button.disabled = false; button.textContent = "Подтверждаю переход"; @@ -764,7 +1026,7 @@ byId("reconcile-action").addEventListener("click", async () => { notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен."); if (currentProspective) await loadProspectiveRegister(currentProspective.ID); } catch (error) { - notify(error.message); + notify(safeClientError(error)); } finally { button.disabled = false; button.textContent = "Проверить исход"; diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 86e9619..942183e 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -6,9 +6,10 @@ Домовой — консоль создателя - + +
@@ -44,6 +45,12 @@
система доступна +
@@ -56,18 +63,45 @@

Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.

- - - - - - - - - - - +
+ ТЕКУЩИЙ КОНТЕКСТ + не выбран +
+
+ + + Сохраняются только в этом браузере. +
+
+ +
+
+
+ +
+
+

Для «Состояния» нужны только эти два значения. Задача, ожидание и возможность находятся ниже в расширенных полях.

+
+ Расширенные идентификаторы +
+ + + + + + +
+
+
+
ACTOR — @@ -77,7 +111,7 @@
-
+

операционная картина

Состояние сейчас

@@ -92,6 +126,20 @@
ВОЗМОЖНОСТЬожидает
+
+
+

Проверяемая система

Диагностика кабинета

+
Проверка ещё не запускалась
+
+
+ +

Здесь видны только ответы текущей авторизованной сессии. Проверка не меняет данные и помогает отличить отсутствие контекста от недоступности сервиса.

+
+
+

Нажмите «Проверить всё», чтобы получить актуальный снимок.

+
+
+

COGNITIVE / ADVISORY V1

@@ -120,7 +168,7 @@
-
+

Continuity / ближайшие

Реестр ожиданий

0 ЗАПИСЕЙ @@ -167,6 +215,19 @@
+
+
+

Локальные предпочтения

Настройки рабочего кабинета

+ Сохраняются в этом браузере +
+
+ + + +
+
+
+
01

Situation

Активный фокус

НЕ ЗАГРУЖЕН
@@ -188,6 +249,7 @@
Ревизия
—

Укажите задачу для проверки относительно активного фокуса.

+
@@ -205,6 +267,7 @@
Ревизия
—

Укажите идентификатор ProspectiveItem для проверки владельца и состояния.

+

Commit / контролируемый переход

@@ -240,6 +303,7 @@
Ревизия
—
Наблюдалось
—
+
@@ -286,6 +350,6 @@ - + diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 3052249..07af7d1 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000072-semantic-interpretation-deferral-boundary.json", - "task_id": "DEV-000072", - "task_revision": 1 + "task_file": "dev/tasks/DEV-000074-creator-cabinet-operations.json", + "task_id": "DEV-000074", + "task_revision": 2 } diff --git a/dev/tasks/DEV-000074-creator-cabinet-operations.json b/dev/tasks/DEV-000074-creator-cabinet-operations.json new file mode 100644 index 0000000..66914c0 --- /dev/null +++ b/dev/tasks/DEV-000074-creator-cabinet-operations.json @@ -0,0 +1,92 @@ +{ + "task_id": "DEV-000074", + "task_revision": 2, + "architecture_revision": "DEV-000073-baseline", + "base_repo_revision": "11a6a23f6e4f44dd88265a04846419a27f54df98", + "risk_tier": "E", + "intent": "Make the authenticated Creator Web cabinet understandable and operable for a human by adding section navigation, read-only diagnostics, and browser-local interface preferences without changing domain state or authority boundaries.", + "requirement_ids": [ + "Human can move between overview, diagnostics, operations and settings", + "Human can run all currently exposed read-only Creator checks from one diagnostic panel", + "Interface preferences remain local to the browser and never change canonical state", + "Existing explicit confirmation remains required for canonical mutations", + "Desktop and mobile layouts remain usable" + ], + "allowed_change_scope": { + "modules": ["cmd/creator-web"], + "paths": [ + "cmd/creator-web/static/index.html", + "cmd/creator-web/static/assets/console.css", + "cmd/creator-web/static/assets/console.js", + "cmd/creator-web/server_test.go", + "cmd/creator-web/chat_test.go", + "docs/PROJECT_STATUS.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000074-creator-cabinet-operations.json" + ] + }, + "forbidden_change_scope": { + "paths": [ + "architecture/**", + "security/**", + "database/**", + "modules/**", + "contracts/**", + "adapters/**", + "cmd/core-runtime/**", + "cmd/commit-runtime/**", + "cmd/continuity-runtime/**", + "deploy/**" + ] + }, + "allowed_architecture_delta": { + "new_cross_module_dependencies": 0, + "new_canonical_owners": 0, + "new_privileges": 0, + "new_third_party_dependencies": 0, + "new_provider_adapters": 0, + "new_database_owner_roles": 0 + }, + "contracts": { + "consume": ["existing authenticated Creator gateway endpoints"], + "may_change": [] + }, + "semantic_owner": "none-interface-adapter", + "canonical_state": [], + "state_transitions": [ + "diagnostic request -> read-only gateway observation", + "settings form submit -> browser-local preference update" + ], + "side_effects_and_privileges": [ + "No new privilege or mutation endpoint", + "No database migration", + "Existing Commit actions retain explicit confirmation dialogs", + "No deployment or neighboring project mutation" + ], + "persistence_and_migrations": ["No canonical persistence; localStorage only for interface preferences"], + "failure_retry_restart": ["Diagnostic failures are shown per check without exposing upstream error details"], + "assumptions": [ + "Existing Creator API routes remain the source of truth", + "Browser-local settings are disposable and do not affect domain behavior" + ], + "acceptance_scenarios": [ + "Authenticated cabinet exposes navigation to all four human-facing sections", + "read-only diagnostics checks session, advisory policy, capability, today, attention, actions and learning", + "density, auto-refresh and reduced-motion preferences save and reset locally", + "mobile layout remains readable", + "existing Creator Web tests pass", + "architecture and diff scope checks pass" + ], + "required_tests": [ + "node --check cmd/creator-web/static/assets/console.js", + "go test ./cmd/creator-web", + "go test ./...", + "go vet ./...", + "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", + "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", + "go run ./cmd/devctl check-architecture", + "git diff --check", + "responsive browser inspection" + ], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0