/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/ProjectReportModal.js
1 373 строки
45 KB
Starolat Sergei
chore: sync project state for Gitverse
16 июн 2026, 16:06
16 июн 2026, 16:06
1cb951e
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ProjectReportModal — Модальное окно справки по проекту * @version 0.1.0 * @see specs/widgets/ProjectReportModal.md * * Агрегирует work-level отклонения в project-level отчёт через ProjectReportBuilder * и отображает его в виде секционного narrative без возможности экспорта в файл. */ import { ProjectReportBuilder } from '../services/ProjectReportBuilder.js'; import dataService from '../services/DataService.js'; import ScheduleDeviationAnalyzer from '../components/ScheduleDeviationAnalyzer.js'; import { FileText, X, ArrowDown, RefreshCw, Inbox, AlertTriangle, } from './ConstantinIcons.js'; /** * @typedef {import('../types.js').ProjectReportResult} ProjectReportResult * @typedef {import('../types.js').BlockReportResult} BlockReportResult * @typedef {import('../types.js').ProjectTopRisk} ProjectTopRisk */ /** * Создать SVG-элемент из строки ConstantinIcons * @param {string} svgString * @param {string} [className] * @returns {string} */ function icon(svgString, className = '') { const cls = className ? ` class="${className}"` : ''; return svgString.replace('<svg', `<svg${cls}`); } /** * Форматировать дату из ISO в DD.MM.YYYY * @param {string} isoDate * @returns {string} */ function formatDate(isoDate) { if (!isoDate) return ''; const d = new Date(isoDate); if (isNaN(d.getTime())) return isoDate; return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; } /** * @element project-report-modal * @attr {boolean} open - Открыто ли модальное окно */ class ProjectReportModal extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); /** @type {ProjectReportResult|null} */ this.report = null; /** @type {string|null} */ this.error = null; /** @type {boolean} */ this.isLoading = false; /** @type {'init'|'loading'|'data'|'empty'|'error'} */ this._status = 'init'; /** @type {Set<string>} */ this._expandedBlocks = new Set(); /** @type {HTMLElement|null} */ this._triggerElement = null; /** @type {number|null} */ this._loadStartTime = null; /** @type {ProjectReportBuilder|null} */ this._reportBuilder = null; /** @type {number} */ this._loadTimeoutMs = 10000; // Bind handlers this._handleKeyDown = this._handleKeyDown.bind(this); this._handleOverlayClick = this._handleOverlayClick.bind(this); this._handleCloseClick = this._handleCloseClick.bind(this); this._handleRefreshClick = this._handleRefreshClick.bind(this); this._handleRetryClick = this._handleRetryClick.bind(this); this._handleBlockClick = this._handleBlockClick.bind(this); } static get observedAttributes() { return ['open']; } connectedCallback() { this._renderBase(); this._setupEventListeners(); this._syncTheme(); this._themeObserver = new MutationObserver(() => this._syncTheme()); this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); // Listen for explicit theme-changed events this._themeChangedHandler = () => this._syncTheme(); document.addEventListener('theme-changed', this._themeChangedHandler); } disconnectedCallback() { document.removeEventListener('keydown', this._handleKeyDown); if (this._themeObserver) { this._themeObserver.disconnect(); } if (this._themeChangedHandler) { document.removeEventListener('theme-changed', this._themeChangedHandler); } // Remove delegated click listeners to prevent memory leaks const dialog = this.shadowRoot?.querySelector('dialog'); if (dialog) { dialog.querySelector('.prm-close')?.removeEventListener('click', this._handleCloseClick); dialog.querySelector('.prm-refresh')?.removeEventListener('click', this._handleRefreshClick); dialog.querySelector('.prm-retry')?.removeEventListener('click', this._handleRetryClick); dialog.querySelector('.prm-backdrop')?.removeEventListener('click', this._handleOverlayClick); dialog.querySelector('.prm-report')?.removeEventListener('click', this._handleBlockClick); } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'open') { const isOpen = newValue !== null; if (isOpen) { this.open(); } else { this.close(); } } } // ==================== Public API ==================== /** * Открыть модалку и загрузить отчёт * @public * @returns {Promise<void>} */ async open() { if (this.isLoading) return; this._triggerElement = /** @type {HTMLElement|null} */ (document.activeElement); this.setAttribute('open', ''); this._setStatus('loading'); this._announceToScreenReader('Справка по проекту открыта. Загрузка отчёта.'); this.dispatchEvent(new CustomEvent('modal-open', { detail: { timestamp: Date.now() }, bubbles: true, composed: true })); await this._loadReport(); } /** * Закрыть модалку, сбросить report * @public */ close() { this.removeAttribute('open'); this.report = null; this.error = null; this._setStatus('init'); this._expandedBlocks.clear(); this.dispatchEvent(new CustomEvent('modal-close', { detail: { timestamp: Date.now() }, bubbles: true, composed: true })); if (this._triggerElement) { this._triggerElement.focus(); this._triggerElement = null; } } /** * Принудительно перестроить отчёт * @public * @returns {Promise<void>} */ async refresh() { if (this.isLoading) return; this.report = null; await this._loadReport(); } /** * Раскрыть/свернуть секцию блока * @public * @param {string} blockId */ toggleBlock(blockId) { if (this._expandedBlocks.has(blockId)) { this._expandedBlocks.delete(blockId); } else { this._expandedBlocks.add(blockId); } this._updateBlockExpandState(blockId); } /** * Раскрыть все блоки * @public */ expandAll() { if (!this.report || this.report.blocks.length === 0) return; this.report.blocks.forEach((block, index) => { const blockId = this._blockId(block, index); this._expandedBlocks.add(blockId); this._updateBlockExpandState(blockId); }); } /** * Свернуть все блоки * @public */ collapseAll() { this._expandedBlocks.clear(); if (!this.report) return; this.report.blocks.forEach((block, index) => { this._updateBlockExpandState(this._blockId(block, index)); }); } // ==================== Private Methods ==================== /** * Загрузить отчёт через ProjectReportBuilder * @private */ async _loadReport() { this._loadStartTime = performance.now(); this._setStatus('loading'); const databaseId = this._resolveDatabaseId(); if (!databaseId) { this._setError('Загрузите базу данных'); return; } try { if (!this._reportBuilder) { const analyzer = document.createElement('schedule-deviation-analyzer'); this._reportBuilder = new ProjectReportBuilder( undefined, undefined, undefined, analyzer ); } // Timeout guard const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Превышено время ожидания')), this._loadTimeoutMs); }); const result = await Promise.race([ this._reportBuilder.buildProjectReport(databaseId, { dataDate: new Date().toISOString(), maxBlocks: 50, minBlockSeverity: 1, }), timeoutPromise ]); this.report = result; const durationMs = Math.round(performance.now() - (this._loadStartTime || 0)); if (result.blocks.length === 0) { this._announceToScreenReader('Отчёт загружен. Отклонений не выявлено.'); this._setStatus('empty'); } else { const blockCount = result.blocks.length; this._announceToScreenReader(`Отчёт загружен. ${blockCount} ${this._pluralize(blockCount, 'объект', 'объекта', 'объектов')} с отклонениями.`); this._setStatus('data'); } this.dispatchEvent(new CustomEvent('report-loaded', { detail: { report: result, durationMs }, bubbles: false, composed: true })); } catch (err) { console.error('[ProjectReportModal] Failed to load report:', err); this._setError(err instanceof Error ? err.message : 'Ошибка построения отчёта'); } } /** * Определить ID базы данных * @private * @returns {string|null} */ _resolveDatabaseId() { return dataService.primaryDatabaseId || dataService.getActiveDatabaseId?.() || null; } /** * Установить состояние ошибки * @private * @param {string} message */ _setError(message) { this.error = message; this._setStatus('error'); this._announceToScreenReader(`Ошибка загрузки отчёта: ${message}`); this.dispatchEvent(new CustomEvent('report-error', { detail: { error: message }, bubbles: false, composed: true })); } /** * Переключить UI-состояние * @private * @param {'init'|'loading'|'data'|'empty'|'error'} status */ _setStatus(status) { this._status = status; this.isLoading = status === 'loading'; this._updateVisibility(); } /** * Генерировать стабильный ID для блока * @private * @param {BlockReportResult} block * @param {number} index * @returns {string} */ _blockId(block, index) { return `block-${index}-${block.blockName.replace(/\s+/g, '_').slice(0, 30)}`; } // ==================== Rendering ==================== /** * Базовый рендер структуры модалки * @private */ _renderBase() { const style = document.createElement('style'); style.textContent = this._getStyles(); const dialog = document.createElement('dialog'); dialog.className = 'project-report-modal'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'prm-title'); dialog.innerHTML = ` <div class="prm-backdrop"></div> <div class="prm-container"> <header class="prm-header"> <h2 id="prm-title" tabindex="-1"> ${icon(FileText, 'prm-title-icon')} СПРАВКА ПО ПРОЕКТУ </h2> <button class="prm-close" aria-label="Закрыть"> ${icon(X, 'prm-close-icon')} </button> </header> <main class="prm-body" aria-live="polite" aria-busy="false"> <div class="prm-loading" hidden> <div class="prm-skeleton"> <div class="prm-skeleton-block" style="animation-delay:0ms"></div> <div class="prm-skeleton-block" style="animation-delay:150ms"></div> <div class="prm-skeleton-block" style="animation-delay:300ms"></div> <div class="prm-skeleton-block" style="animation-delay:450ms"></div> <div class="prm-skeleton-block" style="animation-delay:600ms"></div> </div> </div> <div class="prm-empty" hidden> ${icon(Inbox, 'prm-empty-icon')} <p>Отклонений по объектам не выявлено.</p> </div> <div class="prm-error" hidden> ${icon(AlertTriangle, 'prm-error-icon')} <p class="prm-error-text"></p> <button class="compact-button prm-retry"> ${icon(RefreshCw)} Повторить </button> </div> <div class="prm-report" hidden> <section class="prm-overview"></section> <section class="prm-section prm-section--critical"></section> <section class="prm-section prm-section--warning"></section> <section class="prm-section prm-section--info"></section> <section class="prm-forecast"></section> </div> </main> <footer class="prm-footer"> <button class="compact-button prm-refresh" disabled> ${icon(RefreshCw)} Обновить </button> </footer> <div class="prm-announcer" aria-live="polite" aria-atomic="true"></div> </div> `; this.shadowRoot?.appendChild(style); this.shadowRoot?.appendChild(dialog); } /** * Обновить видимость состояний * @private */ _updateVisibility() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const loadingEl = dialog.querySelector('.prm-loading'); const emptyEl = dialog.querySelector('.prm-empty'); const errorEl = dialog.querySelector('.prm-error'); const reportEl = dialog.querySelector('.prm-report'); const refreshBtn = dialog.querySelector('.prm-refresh'); const body = dialog.querySelector('.prm-body'); if (this.hasAttribute('open')) { if (!dialog.open) { dialog.showModal(); // Focus on title for accessibility const title = dialog.querySelector('#prm-title'); if (title) title.focus(); } } else { if (dialog.open) dialog.close(); } if (loadingEl) loadingEl.hidden = this._status !== 'loading'; if (emptyEl) emptyEl.hidden = this._status !== 'empty'; if (errorEl) errorEl.hidden = this._status !== 'error'; if (reportEl) reportEl.hidden = this._status !== 'data'; if (refreshBtn) refreshBtn.disabled = this._status === 'loading'; if (body) body.setAttribute('aria-busy', String(this._status === 'loading')); if (this._status === 'data' && this.report) { this._renderReport(); } else if (this._status === 'error' && this.error && errorEl) { const textEl = errorEl.querySelector('.prm-error-text'); if (textEl) textEl.textContent = this.error; } } /** * Отрисовать содержимое отчёта * @private */ _renderReport() { if (!this.report) return; const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const overviewEl = dialog.querySelector('.prm-overview'); const criticalEl = dialog.querySelector('.prm-section--critical'); const warningEl = dialog.querySelector('.prm-section--warning'); const infoEl = dialog.querySelector('.prm-section--info'); const forecastEl = dialog.querySelector('.prm-forecast'); if (overviewEl) { overviewEl.innerHTML = this._renderOverview(); } // Группировать блоки по severity const criticalBlocks = this.report.blocks.filter(b => b.severity === 'critical'); const warningBlocks = this.report.blocks.filter(b => b.severity === 'warning'); const infoBlocks = this.report.blocks.filter(b => b.severity === 'info'); const renderBlockSafe = (block, idx) => { try { return this._renderBlock(block, idx); } catch (err) { console.warn('[ProjectReportModal] Skipping invalid block:', block.blockName, err); return ''; } }; if (criticalEl) { criticalEl.innerHTML = criticalBlocks.length ? `<h3 class="prm-section-title">${icon(AlertTriangle)} Критические объекты (${criticalBlocks.length})</h3>` + criticalBlocks.map((b, i) => renderBlockSafe(b, i)).join('') : ''; } if (warningEl) { warningEl.innerHTML = warningBlocks.length ? `<h3 class="prm-section-title">${icon(AlertTriangle)} Объекты с отклонениями (${warningBlocks.length})</h3>` + warningBlocks.map((b, i) => renderBlockSafe(b, i + criticalBlocks.length)).join('') : ''; } if (infoEl) { infoEl.innerHTML = infoBlocks.length ? `<h3 class="prm-section-title">Объекты в норме (${infoBlocks.length})</h3>` + infoBlocks.map((b, i) => renderBlockSafe(b, i + criticalBlocks.length + warningBlocks.length)).join('') : ''; } if (forecastEl) { forecastEl.innerHTML = this.report.forecast ? this._renderForecast(this.report.forecast) : ''; } } /** * Отрисовать overview-секцию * @private * @returns {string} */ _renderOverview() { const r = this.report; if (!r) return ''; const dateStr = formatDate(r.dataDate); const severityIcons = { critical: icon(AlertTriangle), warning: icon(AlertTriangle), info: '' }; return ` <div class="prm-overview-header"> <span class="prm-project-name">${this._escapeHtml(r.projectName)}</span> <span class="prm-report-date">· Справка на ${dateStr}</span> </div> <div class="prm-overview-body"> <p class="prm-overview-text"> ${severityIcons[r.severity] || ''} ${this._escapeHtml(r.overview)} </p> ${r.topRisks && r.topRisks.length ? ` <div class="prm-top-risks"> <span class="prm-top-risks-label">Топ-риски:</span> ${r.topRisks.map(risk => ` <span class="prm-risk-chip prm-risk-chip--${risk.severity}"> ${this._escapeHtml(risk.blockName)} </span> `).join('')} </div> ` : ''} </div> `; } /** * Отрисовать блок отчёта * @private * @param {BlockReportResult} block * @param {number} index * @returns {string} */ _renderBlock(block, index) { const blockId = this._blockId(block, index); const isExpanded = this._expandedBlocks.has(blockId); const sectionsHtml = block.sections.map(section => ` <div class="prm-narrative-section"> <h4>${this._escapeHtml(section.headline)}</h4> <p>${section.narrative}</p> ${section.recommendation ? `<p class="prm-recommendation">Рекомендация: ${this._escapeHtml(section.recommendation)}</p>` : ''} </div> `).join(''); return ` <article class="prm-block prm-block--${block.severity}" data-block-id="${blockId}"> <button class="prm-block-toggle" aria-expanded="${isExpanded ? 'true' : 'false'}" data-block-id="${blockId}"> ${icon(ArrowDown, 'prm-chevron')} <span class="prm-block-name">${this._escapeHtml(block.blockName.toUpperCase())}</span> <span class="prm-block-meta">${this._escapeHtml(block.summaryText.slice(0, 80))}${block.summaryText.length > 80 ? '…' : ''}</span> </button> <div class="prm-block-content" ${isExpanded ? '' : 'hidden'}> ${sectionsHtml} </div> </article> `; } /** * Отрисовать прогнозную секцию * @private * @param {{headline:string,narrative:string,severity:string}} forecast * @returns {string} */ _renderForecast(forecast) { return ` <h3 class="prm-section-title">${icon(FileText)} Прогноз по вехам</h3> <div class="prm-forecast-box prm-forecast-box--${forecast.severity}"> <h4>${this._escapeHtml(forecast.headline)}</h4> <p>${forecast.narrative}</p> </div> `; } /** * Обновить состояние раскрытия конкретного блока * @private * @param {string} blockId */ _updateBlockExpandState(blockId) { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const toggle = dialog.querySelector(`.prm-block-toggle[data-block-id="${CSS.escape(blockId)}"]`); const content = toggle?.closest('.prm-block')?.querySelector('.prm-block-content'); if (toggle) { const isExpanded = this._expandedBlocks.has(blockId); toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); } if (content) { content.hidden = !this._expandedBlocks.has(blockId); } } /** * Экранировать HTML * @private * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ==================== Event Handling ==================== /** * Навесить обработчики событий * @private */ _setupEventListeners() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; // Close button const closeBtn = dialog.querySelector('.prm-close'); closeBtn?.addEventListener('click', this._handleCloseClick); // Refresh button const refreshBtn = dialog.querySelector('.prm-refresh'); refreshBtn?.addEventListener('click', this._handleRefreshClick); // Retry button (error state) const retryBtn = dialog.querySelector('.prm-retry'); retryBtn?.addEventListener('click', this._handleRetryClick); // Overlay click const backdrop = dialog.querySelector('.prm-backdrop'); backdrop?.addEventListener('click', this._handleOverlayClick); // Keyboard document.addEventListener('keydown', this._handleKeyDown); // Block toggles (delegated) const reportEl = dialog.querySelector('.prm-report'); reportEl?.addEventListener('click', this._handleBlockClick); } /** * Обработчик клавиатуры * @private * @param {KeyboardEvent} e */ _handleKeyDown(e) { if (!this.hasAttribute('open')) return; if (e.key === 'Escape') { e.preventDefault(); this.close(); return; } if (e.key === 'Enter' || e.key === ' ') { const dialog = this.shadowRoot?.querySelector('dialog'); const active = document.activeElement; if (dialog && active && dialog.contains(active) && active.closest('.prm-block-toggle')) { e.preventDefault(); const toggle = active.closest('.prm-block-toggle'); const blockId = toggle?.getAttribute('data-block-id'); if (blockId) this.toggleBlock(blockId); } } // Ctrl+Alt+E — expand all, Ctrl+Alt+C — collapse all if (e.ctrlKey && e.altKey) { if (e.key.toLowerCase() === 'e') { e.preventDefault(); this.expandAll(); } else if (e.key.toLowerCase() === 'c') { e.preventDefault(); this.collapseAll(); } } // Tab trap if (e.key === 'Tab') { this._trapFocus(e); } } /** * Trap focus inside modal * @private * @param {KeyboardEvent} e */ _trapFocus(e) { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const focusable = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const activeEl = document.activeElement; const isInside = activeEl ? dialog.contains(activeEl) : false; const isFirst = activeEl === first; const isLast = activeEl === last; if (e.shiftKey && (!isInside || isFirst)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (!isInside || isLast)) { e.preventDefault(); first.focus(); } } /** * Клик по кнопке закрытия * @private */ _handleCloseClick() { this.close(); } /** * Клик по кнопке обновления * @private */ _handleRefreshClick() { this.refresh(); } /** * Клик по кнопке повтора * @private */ _handleRetryClick() { this.refresh(); } /** * Делегированный клик по блокам отчёта * @private * @param {MouseEvent} e */ _handleBlockClick(e) { const toggle = /** @type {HTMLElement|null} */ ( e.target.closest('.prm-block-toggle') ); if (toggle) { const blockId = toggle.getAttribute('data-block-id'); if (blockId) this.toggleBlock(blockId); } } /** * Клик по бэкдропу закрывает модалку * @private * @param {MouseEvent} e */ _handleOverlayClick(e) { if (e.target === e.currentTarget) { this.close(); } } /** * Синхронизировать тему * @private */ _syncTheme() { const theme = document.documentElement.getAttribute('data-theme'); if (theme) { this.setAttribute('data-theme', theme); } } /** * Анонсировать сообщение для скринридера через aria-live * @private * @param {string} text */ _announceToScreenReader(text) { const announcer = this.shadowRoot?.querySelector('.prm-announcer'); if (announcer) { announcer.textContent = ''; // Force reflow so identical consecutive texts are re-announced void announcer.offsetHeight; announcer.textContent = text; } } /** * Склонение числительных (русский) * @private * @param {number} n * @param {string} one * @param {string} two * @param {string} many * @returns {string} */ _pluralize(n, one, two, many) { const mod10 = n % 10; const mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return one; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return two; return many; } // ==================== Styles ==================== /** * CSS стили компонента * @private * @returns {string} */ _getStyles() { return ` :host { display: contents; } [hidden] { display: none !important; } .project-report-modal { padding: 0; border: none; background: transparent; max-width: none; max-height: none; width: auto; height: auto; overflow: visible; } .project-report-modal::backdrop { background: rgba(0, 0, 0, 0.5); } .project-report-modal[open] { display: flex; align-items: center; justify-content: center; } .prm-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: -1; } .prm-container { width: 800px; max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column; background: var(--surface-bg, #fff); border-radius: var(--border-radius, 4px); box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.2)); overflow: hidden; } /* Header */ .prm-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px) 12px; min-height: 32px; border-bottom: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; } .prm-header h2 { font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; display: flex; align-items: center; gap: var(--space-2xs, 4px); color: var(--text-primary, #333); font-weight: 600; } .prm-title-icon { width: 16px; height: 16px; flex-shrink: 0; } .prm-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; background: transparent; border: none; cursor: pointer; color: var(--text-secondary, #666); border-radius: var(--border-radius, 4px); } .prm-close:hover { background: rgba(0, 0, 0, 0.04); color: var(--text-primary, #333); } .prm-close-icon { width: 16px; height: 16px; } /* Body */ .prm-body { flex: 1; overflow-y: auto; padding: var(--space-xs, 8px); display: flex; flex-direction: column; gap: var(--space-xs, 8px); min-height: 0; } .prm-body::-webkit-scrollbar { width: 6px; } .prm-body::-webkit-scrollbar-track { background: transparent; } .prm-body::-webkit-scrollbar-thumb { background: var(--border-color, #e0e0e0); border-radius: 3px; } /* Loading */ .prm-skeleton { display: flex; flex-direction: column; gap: var(--space-xs, 8px); } .prm-skeleton-block { height: 48px; border-radius: var(--border-radius, 4px); background: linear-gradient(90deg, var(--border-color, #e0e0e0) 25%, var(--surface-bg, #fff) 50%, var(--border-color, #e0e0e0) 75%); background-size: 200% 100%; animation: prm-skeleton-shimmer 1.5s infinite; } @keyframes prm-skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* Empty / Error */ .prm-empty, .prm-error { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px var(--space-xs, 8px); gap: var(--space-xs, 8px); color: var(--text-secondary, #666); text-align: center; } .prm-empty-icon, .prm-error-icon { width: 32px; height: 32px; opacity: 0.5; } .prm-error { color: var(--color-typo-alert, #d32f2f); } .prm-error-text { margin: 0; font-size: var(--font-size-s, 11px); } .prm-retry { margin-top: var(--space-xs, 8px); } /* Overview */ .prm-overview { padding: var(--space-xs, 8px); border-radius: var(--border-radius, 4px); border: 1px solid var(--border-color, #e0e0e0); background: var(--surface-bg, #fff); } .prm-overview-header { display: flex; align-items: baseline; gap: var(--space-2xs, 4px); margin-bottom: var(--space-2xs, 4px); flex-wrap: wrap; } .prm-project-name { font-size: var(--font-size-s, 11px); font-weight: 600; color: var(--text-primary, #333); } .prm-report-date { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } .prm-overview-text { margin: 0; font-size: var(--font-size-s, 11px); line-height: 1.5; color: var(--text-primary, #333); } .prm-overview-text svg { width: 14px; height: 14px; vertical-align: text-bottom; margin-right: 2px; color: var(--color-typo-alert, #d32f2f); } .prm-top-risks { margin-top: var(--space-xs, 8px); display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2xs, 4px); } .prm-top-risks-label { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); text-transform: uppercase; letter-spacing: 0.05em; } .prm-risk-chip { display: inline-flex; align-items: center; padding: 2px 6px; border-radius: var(--border-radius, 4px); font-size: var(--font-size-xs, 10px); font-weight: 500; background: var(--color-bg-caution, #fff3e0); color: var(--color-typo-caution, #e65100); } .prm-risk-chip--critical { background: var(--color-bg-alert, #ffebee); color: var(--color-typo-alert, #c62828); } /* Sections */ .prm-section { display: flex; flex-direction: column; gap: var(--space-2xs, 4px); } .prm-section:empty { display: none; } .prm-section-title { font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; padding: 4px var(--space-xs, 8px); color: var(--text-secondary, #666); display: flex; align-items: center; gap: var(--space-2xs, 4px); } .prm-section-title svg { width: 14px; height: 14px; } .prm-section--critical .prm-section-title svg { color: var(--color-typo-alert, #c62828); } .prm-section--warning .prm-section-title svg { color: var(--color-typo-caution, #e65100); } /* Blocks */ .prm-block { border-radius: var(--border-radius, 4px); overflow: hidden; border: 1px solid var(--border-color, #e0e0e0); } .prm-block--critical { border-left: 3px solid var(--color-typo-alert, #d32f2f); background: color-mix(in srgb, var(--color-bg-alert, #ffebee) 8%, transparent); } .prm-block--warning { border-left: 3px solid var(--color-typo-caution, #f57c00); background: color-mix(in srgb, var(--color-bg-caution, #fff3e0) 8%, transparent); } .prm-block--info { border-left: 3px solid var(--color-typo-success, #388e3c); background: color-mix(in srgb, var(--color-bg-success, #e8f5e9) 8%, transparent); } .prm-block-toggle { width: 100%; display: flex; align-items: center; gap: var(--space-2xs, 4px); padding: 6px var(--space-xs, 8px); background: transparent; border: none; cursor: pointer; font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-primary, #333); text-align: left; min-height: 24px; } .prm-block-toggle:hover { background: rgba(0, 0, 0, 0.04); } .prm-block-toggle:focus-visible { outline: 2px solid var(--color-control-bg-primary, #1976d2); outline-offset: -2px; } .prm-chevron { width: 14px; height: 14px; transition: transform 200ms ease; flex-shrink: 0; color: var(--text-secondary, #666); } .prm-block-toggle[aria-expanded="true"] .prm-chevron { transform: rotate(180deg); } .prm-block-name { font-weight: 600; } .prm-block-meta { margin-left: auto; font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); text-transform: none; letter-spacing: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 50%; } .prm-block-content { padding: 0 var(--space-xs, 8px) var(--space-xs, 8px); font-size: var(--font-size-s, 11px); line-height: 1.5; } .prm-block-content[hidden] { display: none; } /* Narrative sections */ .prm-narrative-section { margin-top: var(--space-2xs, 4px); padding-left: 8px; border-left: 2px solid var(--border-color, #e0e0e0); } .prm-narrative-section h4 { font-size: var(--font-size-xs, 10px); margin: 0 0 2px 0; color: var(--text-secondary, #666); text-transform: uppercase; letter-spacing: 0.03em; } .prm-narrative-section p { margin: 0; color: var(--text-primary, #333); } .prm-narrative-section p strong { color: var(--text-primary, #333); font-weight: 600; } .prm-recommendation { margin-top: 2px; font-style: italic; color: var(--text-secondary, #666); } /* Forecast */ .prm-forecast:empty { display: none; } .prm-forecast-box { padding: var(--space-xs, 8px); border-radius: var(--border-radius, 4px); border: 1px solid var(--border-color, #e0e0e0); margin-top: var(--space-2xs, 4px); } .prm-forecast-box h4 { font-size: var(--font-size-xs, 10px); margin: 0 0 var(--space-2xs, 4px) 0; text-transform: uppercase; letter-spacing: 0.05em; } .prm-forecast-box p { margin: 0; font-size: var(--font-size-s, 11px); line-height: 1.5; } .prm-forecast-box--critical { border-left: 3px solid var(--color-typo-alert, #d32f2f); background: color-mix(in srgb, var(--color-bg-alert, #ffebee) 8%, transparent); } .prm-forecast-box--warning { border-left: 3px solid var(--color-typo-caution, #f57c00); background: color-mix(in srgb, var(--color-bg-caution, #fff3e0) 8%, transparent); } .prm-forecast-box--info { border-left: 3px solid var(--color-typo-success, #388e3c); background: color-mix(in srgb, var(--color-bg-success, #e8f5e9) 8%, transparent); } /* Footer */ .prm-footer { display: flex; justify-content: center; padding: var(--space-xs, 8px); border-top: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; gap: var(--space-xs, 8px); } .compact-button { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2xs, 4px); height: 24px; padding: 0 12px; font-size: var(--font-size-xs, 10px); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); background: var(--surface-bg, #fff); color: var(--text-primary, #333); cursor: pointer; transition: background 150ms ease; } .compact-button:hover:not(:disabled) { background: var(--color-control-bg-secondary, #f5f5f5); } .compact-button:disabled { opacity: 0.5; cursor: not-allowed; } .compact-button svg { width: 14px; height: 14px; } /* Dark theme overrides */ :host([data-theme="consta-dark"]) .prm-container { background: var(--surface-bg, #1a1a1a); } :host([data-theme="consta-dark"]) .prm-overview, :host([data-theme="consta-dark"]) .prm-block, :host([data-theme="consta-dark"]) .prm-forecast-box { border-color: var(--border-color, #333); } :host([data-theme="consta-dark"]) .prm-block-toggle:hover { background: rgba(255, 255, 255, 0.06); } /* Screen reader announcer (visually hidden but accessible) */ .prm-announcer { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } `; } } customElements.define('project-report-modal', ProjectReportModal); export { ProjectReportModal };