from __future__ import annotations from collections import Counter from html import escape from typing import Iterable from urllib.parse import quote from sir import NodeKind, SirSnapshot _HTML5_OBJECT_CONTEXT_KINDS = { NodeKind.CATALOG.value, NodeKind.DOCUMENT.value, NodeKind.REGISTER.value, NodeKind.COMMON_MODULE.value, NodeKind.CONSTANT.value, NodeKind.DOCUMENT_JOURNAL.value, NodeKind.ENUM.value, NodeKind.REPORT.value, NodeKind.DATA_PROCESSOR.value, NodeKind.CHART_OF_CHARACTERISTIC_TYPES.value, NodeKind.CHART_OF_ACCOUNTS.value, NodeKind.CHART_OF_CALCULATION_TYPES.value, NodeKind.EXCHANGE_PLAN.value, NodeKind.EXTERNAL_DATA_SOURCE.value, NodeKind.SCHEDULED_JOB.value, NodeKind.BUSINESS_PROCESS.value, NodeKind.TASK.value, } if hasattr(NodeKind, "EVENT_SUBSCRIPTION"): _HTML5_OBJECT_CONTEXT_KINDS.add(NodeKind.EVENT_SUBSCRIPTION.value) def render_html5_index(projects: Iterable[object]) -> str: project_list = list(projects) return _page( "SFERA HTML5", f"""

SFERA HTML5

Server-first рабочее место 1С

Основной HTML собирает API-сервер. Браузер получает готовую страницу без React/Next runtime.

{len(project_list)} проектов

Проекты

{render_html5_project_create_form()}
{render_html5_project_rows(project_list)}
ПроектСтатусSnapshot
""", ) def render_html5_project_create_form() -> str: return """
""" def render_html5_project_rows(projects: Iterable[object]) -> str: project_rows = "\n".join(_project_row(project) for project in projects) if not project_rows: return 'Проекты пока не настроены' return project_rows def render_html5_operations(jobs: Iterable[object]) -> str: job_list = list(jobs) return _page( "SFERA HTML5 operations", f"""

SFERA HTML5

Операции сервера

Очередь фоновых задач отрисовывается API-сервером и обновляется htmx polling без React runtime.

{len(job_list)} jobs

Очередь

Проекты
{render_html5_operation_summary(job_list)}
{render_html5_operation_rows(job_list)}
JobПроектСтатусStageСообщение
""", ) def render_html5_operation_summary(jobs: Iterable[object]) -> str: job_list = list(jobs) counts = Counter(_enum_text(getattr(job, "status", "unknown")) for job in job_list) running = counts.get("RUNNING", 0) queued = counts.get("QUEUED", 0) succeeded = counts.get("SUCCEEDED", 0) failed = counts.get("FAILED", 0) return f"""
{_metric("Всего", len(job_list))} {_metric("В работе", running)} {_metric("В очереди", queued)} {_metric("Успешно", succeeded)} {_metric("Ошибки", failed)}
""" def render_html5_operation_rows(jobs: Iterable[object]) -> str: rows = "\n".join(_operation_row(job) for job in jobs) if not rows: return 'Фоновые операции пока не запускались' return rows def render_html5_editor( *, project_id: str, projects: Iterable[object], snapshot: SirSnapshot | None, error: str | None = None, q: str = "", ) -> str: project_nav = "\n".join(_project_link(project, project_id) for project in projects) if error or snapshot is None: content = f"""
{_topbar(project_id, project_nav)}

Проект не готов к HTML5 IDE

{escape(error or "Snapshot не найден")}

К списку проектов
""" return _page(f"SFERA HTML5 - {project_id}", content) counts = Counter(str(node.kind.value if hasattr(node.kind, "value") else node.kind) for node in snapshot.nodes) modules = [node for node in snapshot.nodes if node.kind == NodeKind.MODULE] objects = [ node for node in snapshot.nodes if node.kind in { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.COMMON_MODULE, NodeKind.REPORT, NodeKind.DATA_PROCESSOR, } ] tree_nodes = objects[:120] or modules[:120] selected_module = modules[0] if modules else None content = f"""
{_topbar(project_id, project_nav)}

HTML5 editor

{escape(selected_module.qualified_name if selected_module else project_id)}

{render_html5_source(selected_module)}
""" return _page(f"SFERA HTML5 - {project_id}", content) def render_html5_flowchart( project_id: str, flowchart: object | None, *, focus: str | None = None, depth: int = 1, oob: bool = False, ) -> str: normalized_depth = min(max(depth, 1), 3) hx_url = _flowchart_url(project_id, focus, normalized_depth) oob_attr = ' hx-swap-oob="outerHTML"' if oob else "" if flowchart is None: return f"""
Карта связей

Сервер собирает граф проекта.

""" nodes = getattr(flowchart, "nodes", []) or [] edges = getattr(flowchart, "edges", []) or [] mode = str(getattr(flowchart, "mode", "overview")) body = "".join(_flowchart_edge_item(project_id, item, nodes, normalized_depth) for item in edges[:10]) if not body: body = "".join(_flowchart_node_item(project_id, item, normalized_depth) for item in nodes[:10]) if not body: body = '

Связи проекта не найдены

' return f"""
Карта связей · {escape(mode)}
{_flowchart_depth_actions(project_id, focus, normalized_depth)}
{_metric("Nodes", len(nodes))} {_metric("Edges", len(edges))} {_metric("Total nodes", getattr(flowchart, "total_nodes", 0))} {_metric("Total edges", getattr(flowchart, "total_edges", 0))}
{body}
""" def render_html5_project_report(project_id: str, report: dict | None) -> str: if report is None: return f"""
Отчет проекта

Сервер готовит сводку проекта.

""" metrics = [ ("Objects", report.get("node_count", 0)), ("Edges", report.get("edge_count", 0)), ("Procedures", report.get("procedure_count", 0)), ("Queries", report.get("query_count", 0)), ("Writes", report.get("write_count", 0)), ("Roles", report.get("role_count", 0)), ("Unowned", report.get("unowned_object_count", 0)), ("Sensitive", report.get("unclassified_sensitive_count", 0)), ] return f"""
Отчет проекта
{''.join(_metric(label, value) for label, value in metrics)}
""" def render_html5_object_report( project_id: str, impact: object, *, access: object | None = None, privacy: object | None = None, runtime: Iterable[object] | None = None, integrations: Iterable[object] | None = None, oob: bool = False, ) -> str: obj = getattr(impact, "object", None) name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or getattr(impact, "object_name", "object") grants = getattr(access, "grants", []) if access is not None else [] markers = getattr(privacy, "markers", []) if privacy is not None else [] runtime_items = list(runtime or []) integration_items = list(integrations or []) metrics = [ ("Routines", len(getattr(impact, "routines", []) or [])), ("Commands", len(getattr(impact, "commands", []) or [])), ("Reads", len(getattr(impact, "query_tables", []) or [])), ("Writes", len(getattr(impact, "writes", []) or [])), ("Roles", len(grants) or len(getattr(impact, "roles", []) or [])), ("Runtime", len(runtime_items)), ("Privacy", len(markers)), ("Integrations", len(integration_items)), ] oob_attr = ' hx-swap-oob="outerHTML"' if oob else "" return f"""
Отчет объекта
{escape(str(name))} server focused summary
{''.join(_metric(label, value) for label, value in metrics)}
""" def render_html5_review( project_id: str, findings: list[dict] | None, *, title: str = "Review", oob: bool = False, ) -> str: oob_attr = ' hx-swap-oob="outerHTML"' if oob else "" if findings is None: return f"""
{escape(title)}

Сервер готовит findings.

""" if not findings: body = '

Findings не найдены

' else: body = "".join(_review_item(project_id, finding) for finding in findings[:12]) return f"""
{escape(title)} · {len(findings)}
{body}
""" def render_html5_object_context( project_id: str, schema: object | None, impact: object | None, access: object | None = None, ui: object | None = None, runtime: Iterable[object] | None = None, knowledge: Iterable[object] | None = None, privacy: object | None = None, integrations: Iterable[object] | None = None, flowchart: object | None = None, mode: str = "overview", ) -> str: if schema is None or impact is None: return f"""
Object context

Выберите объект метаданных, чтобы сервер собрал schema/impact контекст проекта {escape(project_id)}.

""" obj = getattr(schema, "object", None) or getattr(impact, "object", None) name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or "object" attributes = getattr(schema, "attributes", []) or [] sections = getattr(schema, "tabular_sections", []) or [] modules = getattr(impact, "modules", []) or [] routines = getattr(impact, "routines", []) or [] forms = getattr(impact, "forms", []) or [] commands = getattr(impact, "commands", []) or [] roles = getattr(impact, "roles", []) or [] jobs = getattr(impact, "jobs", []) or [] callees = getattr(impact, "callees", []) or [] query_tables = getattr(impact, "query_tables", []) or [] writes = getattr(impact, "writes", []) or [] grants = getattr(access, "grants", []) if access is not None else [] ui_forms = getattr(ui, "forms", []) if ui is not None else [] runtime_items = list(runtime or []) knowledge_items = list(knowledge or []) privacy_markers = getattr(privacy, "markers", []) if privacy is not None else [] integration_items = list(integrations or []) flow_nodes = getattr(flowchart, "nodes", []) if flowchart is not None else [] flow_edges = getattr(flowchart, "edges", []) if flowchart is not None else [] normalized_mode = mode if mode in {"overview", "schema", "impact", "privacy"} else "overview" if normalized_mode == "schema": compact_body = ( ''.join(_named_node_item("attr", item) for item in attributes[:12]) or '

Реквизиты не найдены

' ) compact_body += ''.join(_tabular_section_item(item) for item in sections[:8]) compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:8]) elif normalized_mode == "impact": compact_body = ''.join(_integration_endpoint_item(item) for item in integration_items[:8]) compact_body += ''.join(_named_node_item("command", item) for item in commands[:8]) compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:8]) compact_body += ''.join(_named_node_item("write", item) for item in writes[:8]) compact_body += ''.join(_named_node_item("call", item) for item in callees[:8]) compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8]) compact_body += ''.join(_named_node_item("routine", item) for item in routines[:8]) compact_body += ''.join(_named_node_item("job", item) for item in jobs[:6]) compact_body = compact_body or '

Impact-связи не найдены

' elif normalized_mode == "privacy": compact_body = ''.join(_role_access_item(item) for item in grants[:12]) compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:12]) compact_body = compact_body or '

Доступы и privacy-маркеры не найдены

' else: compact_body = ( ''.join(_named_node_item("attr", item) for item in attributes[:6]) or '

Реквизиты не найдены

' ) compact_body += ''.join(_tabular_section_item(item) for item in sections[:4]) compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:4]) compact_body += ''.join(_role_access_item(item) for item in grants[:6]) compact_body += ''.join(_integration_endpoint_item(item) for item in integration_items[:4]) compact_body += ''.join(_named_node_item("command", item) for item in commands[:6]) compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:4]) compact_body += ''.join(_named_node_item("write", item) for item in writes[:4]) compact_body += ''.join(_named_node_item("call", item) for item in callees[:6]) compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8]) compact_body += ''.join(_runtime_summary_item(item) for item in runtime_items[:6]) compact_body += ''.join(_knowledge_record_item(item) for item in knowledge_items[:6]) compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:6]) compact_body += ''.join(_named_node_item("routine", item) for item in routines[:6]) compact_body += ''.join(_named_node_item("job", item) for item in jobs[:4]) return f"""
Object context · {escape(normalized_mode)}
{_object_breadcrumb(str(name))}
{escape(str(name))} {escape(str(getattr(obj, "kind", "object")))}
{_object_action_links(project_id, str(name), getattr(obj, "lineage_id", ""), modules, normalized_mode)} {_object_summary( len(attributes), len(sections), len(commands), len(query_tables), len(writes), len(callees), len(integration_items), len(grants) or len(roles), len(runtime_items), len(privacy_markers), )}
{_metric("Attrs", len(attributes))} {_metric("Tables", len(sections))} {_metric("Modules", len(modules))} {_metric("Routines", len(routines))} {_metric("Forms", len(ui_forms) or len(forms))} {_metric("Commands", len(commands))} {_metric("Roles", len(grants) or len(roles))} {_metric("Reads", len(query_tables))} {_metric("Writes", len(writes))} {_metric("Calls", len(callees))} {_metric("Integrations", len(integration_items))} {_metric("Graph nodes", len(flow_nodes))} {_metric("Graph edges", len(flow_edges))} {_metric("Runtime", len(runtime_items))} {_metric("Knowledge", len(knowledge_items))} {_metric("Privacy", len(privacy_markers))}
{compact_body}
""" def render_html5_authoring_changes(project_id: str, changes: Iterable[object] | None) -> str: if changes is None: return f"""
Authoring

Сервер загружает историю рабочих изменений.

""" change_list = list(changes) if not change_list: body = '

Изменений пока нет

' else: body = "".join(_authoring_change_item(change) for change in change_list[:12]) return f"""
Authoring · {len(change_list)}
{body}
{render_html5_authoring_change_detail(project_id, None)}
""" def render_html5_authoring_preview(project_id: str, preview: object | None, error: str | None = None) -> str: if preview is None and error is None: return f"""
Authoring preview
{render_html5_authoring_preview_result(project_id)}
{render_html5_authoring_diff_result(project_id)}
""" return render_html5_authoring_preview_result(project_id, preview, error) def render_html5_authoring_preview_result(project_id: str, preview: object | None = None, error: str | None = None) -> str: if preview is None and error is None: return '
' if error: return f"""
Preview result

{escape(error)}

""" allowed = bool(getattr(preview, "allowed", False)) insert_text = str(getattr(preview, "insert_text", "")) checks = getattr(preview, "checks", []) or [] diff = getattr(preview, "semantic_diff", []) or [] context = getattr(preview, "context", None) object_node = getattr(context, "object", None) routine_node = getattr(context, "routine", None) object_name = getattr(object_node, "qualified_name", None) or getattr(object_node, "name", None) or "object unavailable" routine_name = getattr(routine_node, "name", None) or "routine unavailable" check_rows = "".join(_authoring_check_item(check) for check in checks[:8]) diff_rows = "".join(_authoring_diff_item(line) for line in diff[:8]) or '

Diff пустой

' return f"""
Preview result · {'allowed' if allowed else 'blocked'}
{escape(str(object_name))} {escape(str(routine_name))} {escape(insert_text[:180] or "insert text unavailable")}
{check_rows}
{diff_rows}
""" def render_html5_authoring_diff_result( project_id: str, preview: object | None = None, error: str | None = None, request_payload: dict | None = None, ) -> str: if preview is None and error is None: return '
' if error: return f"""
Diff preview

{escape(error)}

""" changed = bool(getattr(preview, "changed", False)) added = getattr(preview, "added_lines", 0) removed = getattr(preview, "removed_lines", 0) target = getattr(preview, "target", None) target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" checks = getattr(preview, "checks", []) or [] diff = getattr(preview, "semantic_diff", []) or [] version_preview = getattr(preview, "version_preview", None) next_version_id = str(getattr(version_preview, "next_version_id", "")) check_rows = "".join(_authoring_check_item(check) for check in checks[:8]) diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '

Diff пустой

' apply_form = ( _authoring_apply_change_set_form(project_id, request_payload or {}, next_version_id) if changed and next_version_id else "" ) return f"""
Diff preview · {'changed' if changed else 'unchanged'}
{escape(str(target_name))} +{escape(str(added))} / -{escape(str(removed))} {escape(next_version_id or "version preview unavailable")}
{check_rows}
{diff_rows}
{apply_form}
""" def render_html5_authoring_apply_result(project_id: str, result: object | None = None, error: str | None = None) -> str: if result is None and error is None: return '
' if error: return f"""
Apply change-set

{escape(error)}

""" status = str(getattr(result, "status", "UNKNOWN")) change_id = str(getattr(result, "change_id", "")) version = getattr(result, "version", None) version_id = str(getattr(version, "version_id", "")) return f"""
Apply change-set
{escape(status)} {escape(change_id)} {escape(version_id)}

Change-set применен в workspace для проекта {escape(project_id)}.

""" def render_html5_metadata_authoring(project_id: str) -> str: return f"""
Metadata draft
{render_html5_metadata_preview_result(project_id)}
""" def render_html5_metadata_preview_result( project_id: str, preview: object | None = None, error: str | None = None, request_payload: dict | None = None, ) -> str: if preview is None and error is None: return '
' if error: return f"""
Metadata preview

{escape(error)}

""" changed = bool(getattr(preview, "changed", False)) added = getattr(preview, "added_lines", 0) target = getattr(preview, "target", None) target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" checks = getattr(preview, "checks", []) or [] diff = getattr(preview, "semantic_diff", []) or [] version_preview = getattr(preview, "version_preview", None) next_version_id = str(getattr(version_preview, "next_version_id", "")) check_rows = "".join(_authoring_check_item(check) for check in checks[:8]) diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '

Diff пустой

' apply_form = ( _metadata_apply_form(project_id, request_payload or {}, next_version_id) if changed and next_version_id else "" ) return f"""
Metadata preview · {'changed' if changed else 'unchanged'}
{escape(str(target_name))} +{escape(str(added))} / -0 {escape(next_version_id or "version preview unavailable")}
{check_rows}
{diff_rows}
{apply_form}
""" def render_html5_metadata_apply_result(project_id: str, result: object | None = None, error: str | None = None) -> str: if result is None and error is None: return '
' if error: return f"""
Metadata apply

{escape(error)}

""" status = str(getattr(result, "status", "UNKNOWN")) change_id = str(getattr(result, "change_id", "")) version = getattr(result, "version", None) version_id = str(getattr(version, "version_id", "")) return f"""
Metadata apply
{escape(status)} {escape(change_id)} {escape(version_id)}

Metadata draft применен в workspace для проекта {escape(project_id)}.

""" def render_html5_authoring_change_detail(project_id: str, preview: object | None) -> str: if preview is None: return f"""
Rollback preview

Выберите изменение, чтобы сервер рассчитал rollback diff для проекта {escape(project_id)}.

""" change_id = str(getattr(preview, "change_id", "")) original_version_id = str(getattr(preview, "original_version_id", "")) rollback_version_id = str(getattr(preview, "rollback_version_id", "")) target = getattr(preview, "target", None) target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" diff = getattr(preview, "semantic_diff", []) or [] checks = getattr(preview, "checks", []) or [] apply_available = bool(getattr(preview, "apply_available", False)) diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '

Diff пустой

' check_rows = "".join(_authoring_check_item(check) for check in checks[:8]) apply_form = _authoring_rollback_form(project_id, change_id, rollback_version_id) if apply_available else "" return f"""
Rollback preview · {'ready' if apply_available else 'blocked'}
{escape(str(target_name))} {escape(original_version_id)} -> {escape(rollback_version_id)} {escape(change_id)}
{check_rows}
{diff_rows}
{apply_form}
""" def render_html5_authoring_rollback_result(project_id: str, result: object | None = None, error: str | None = None) -> str: if result is None and error is None: return '
' if error: return f"""
Rollback apply

{escape(error)}

""" status = str(getattr(result, "status", "UNKNOWN")) change_id = str(getattr(result, "change_id", "")) rollback_change_id = str(getattr(result, "rollback_change_id", "")) version = getattr(result, "version", None) version_id = str(getattr(version, "version_id", "")) return f"""
Rollback apply
{escape(status)} {escape(rollback_change_id)} {escape(version_id)}

Rollback применен в workspace для проекта {escape(project_id)}.

""" def render_html5_project_setup(*, project_id: str, projects: Iterable[object], setup: object) -> str: project_nav = "\n".join(_project_link(project, project_id) for project in projects) name = _setup_name(setup) sources = getattr(setup, "import_sources", []) or [] source_cards = "".join(_import_source_card(source) for source in sources) content = f"""
{_topbar(project_id, project_nav)}
{render_html5_settings_panel(project_id, setup)} {render_html5_setup_actions(project_id, setup)} {render_html5_setup_summary(project_id, setup)}
""" return _page(f"SFERA HTML5 setup - {project_id}", content) def render_html5_settings_panel(project_id: str, setup: object, saved: bool = False) -> str: settings = getattr(setup, "settings", None) name = str(getattr(settings, "name", "") or "") platform_version = str(getattr(settings, "platform_version", "") or "") compatibility_mode = str(getattr(settings, "compatibility_mode", "") or "") notice = 'Сохранено' if saved else "" return f"""
Базовые настройки {notice}
""" def render_html5_setup_actions(project_id: str, setup: object) -> str: sources = getattr(setup, "import_sources", []) or [] current_source = _enum_text(getattr(setup, "current_source", None) or "") source_options = "".join(_source_option(source, current_source) for source in sources) if not source_options: source_options = f'' return f"""
{render_html5_import_check(project_id)} {render_html5_import_job(project_id)} """ def render_html5_import_check(project_id: str, check: object | None = None) -> str: if check is None: return f"""
Проверка импорта

Запустите server-side preflight перед импортом проекта {escape(project_id)}.

""" status = str(getattr(check, "status", "UNKNOWN")) source = _enum_text(getattr(check, "source", "")) ready = bool(getattr(check, "ready", False)) checks = getattr(check, "checks", []) or [] items = "".join(_preflight_item(item) for item in checks) return f"""
Проверка импорта
{escape(status)} {escape(source)} {'ready' if ready else 'needs attention'}
{items or '

Проверки не вернули результатов

'}
""" def render_html5_import_job(project_id: str, job: object | None = None) -> str: if job is None: return f"""
Фоновый импорт

Фоновая задача импорта для проекта {escape(project_id)} еще не запускалась.

""" job_id = str(getattr(job, "job_id", "")) status = _enum_text(getattr(job, "status", "unknown")) payload = getattr(job, "payload", {}) or {} message = str(payload.get("message") or "") source = str(payload.get("source") or "") stage = str(payload.get("stage") or "") logs = payload.get("logs") if isinstance(payload.get("logs"), list) else [] poll = ' hx-trigger="every 2s" hx-swap="outerHTML"' if status in {"QUEUED", "RUNNING"} else "" logs_html = "".join(f"
  • {escape(str(item))}
  • " for item in logs[-6:]) return f"""
    Фоновый импорт
    {escape(status)} {escape(source)} {escape(stage or job_id)}

    {escape(message or "Ожидание обновления статуса")}

    """ def render_html5_setup_summary(project_id: str, setup: object) -> str: status = _enum_text(getattr(setup, "status", "unknown")) message = str(getattr(setup, "message", "")) current_source = _enum_text(getattr(setup, "current_source", None) or "не выбран") last_import = getattr(setup, "last_import", None) history = getattr(setup, "import_history", []) or [] return f"""

    Server-rendered status

    {escape(status)}

    {escape(current_source)}

    {escape(message)}

    {_metric("Объекты", _import_value(last_import, "object_count"))} {_metric("Модули", _import_value(last_import, "module_count"))} {_metric("Формы", _import_value(last_import, "form_count"))} {_metric("Роли", _import_value(last_import, "role_count"))}
    Последняя загрузка
    {_last_import_block(last_import)}
    История
    {''.join(_history_item(item) for item in history[:6]) or '

    История импорта пока пустая

    '}
    """ def render_html5_status(project_id: str, snapshot: SirSnapshot) -> str: return ( f'project: {escape(project_id)}' f'snapshot: {escape(snapshot.snapshot_id)}' f'nodes: {len(snapshot.nodes)}' f'edges: {len(snapshot.edges)}' f'diagnostics: {len(snapshot.diagnostics)}' 'server-rendered' 'client-js: htmx+sse only' ) def html5_symbol_results(snapshot: SirSnapshot, q: str) -> list[object]: query = q.strip().lower() if not query: modules = [node for node in snapshot.nodes if node.kind == NodeKind.MODULE] return (modules[:12] or snapshot.nodes[:12]) return [ node for node in snapshot.nodes if query in (node.qualified_name or node.name).lower() ][:30] def render_html5_symbols(snapshot: SirSnapshot, q: str, project_id: str | None = None) -> str: results = html5_symbol_results(snapshot, q) if not results: return '

    Нет результатов

    ' return "".join(_symbol_result(node, project_id) for node in results) def render_html5_symbol_detail(project_id: str, references: object | None, *, oob: bool = False) -> str: oob_attr = ' hx-swap-oob="outerHTML"' if oob else "" if references is None: return f"""
    Символ

    Выберите результат поиска для server-side definition/references по проекту {escape(project_id)}.

    """ symbol = getattr(references, "symbol", None) node = getattr(symbol, "node", None) source = getattr(symbol, "source", None) name = getattr(node, "qualified_name", None) or getattr(node, "name", "symbol") kind = getattr(node, "kind", "") source_path = getattr(source, "source_path", None) or "" line = getattr(source, "line_start", None) location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable" refs = getattr(references, "references", []) or [] ref_items = ( "".join(_symbol_reference_item(project_id, ref) for ref in refs[:10]) or '

    References не найдены

    ' ) lineage_id = str(getattr(node, "lineage_id", "")) return f"""
    Символ · {escape(str(kind))}
    {escape(str(name))} {escape(str(location))}
    {_symbol_summary(refs)}
    {ref_items}
    """ def render_html5_source(node: object | None, *, oob: bool = False) -> str: name = "source" if node is None else getattr(node, "qualified_name", None) or getattr(node, "name", "source") kind = "" if node is None else _enum_text(getattr(node, "kind", "")) lineage_id = "" if node is None else str(getattr(node, "lineage_id", "")) source_path = "" if node is None else str( getattr(node, "source_path", None) or getattr(getattr(node, "source_ref", None), "source_path", None) or "" ) line = "" if node is None else str( getattr(node, "line_start", None) or getattr(getattr(node, "source_ref", None), "line_start", None) or "" ) location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable" source_text = _node_source_text(node) line_count = len(source_text.splitlines()) or 1 source_size = len(source_text) oob_attr = ' hx-swap-oob="outerHTML"' if oob else "" return f"""
    {escape(str(name))} {escape(kind or "source")}
    {_metric("Lines", line_count)} {_metric("Location", location)}
    {_source_summary(kind or "source", line_count, source_size, location)}
    {escape(source_text)}
    """ def _source_summary(kind: str, line_count: int, source_size: int, location: str) -> str: return f"""

    {escape(kind)} · {escape(str(line_count))} lines · {escape(str(source_size))} chars · {escape(location)}

    """ def _page(title: str, body: str) -> str: return f""" {escape(title)} {body} """ def _project_row(project: object) -> str: project_id = str(getattr(project, "project_id", "")) name = str(getattr(project, "name", project_id)) status = str(getattr(project, "status", "unknown")) has_snapshot = bool(getattr(project, "has_snapshot", False)) return f""" {escape(name)}{escape(project_id)} {escape(status)} {'yes' if has_snapshot else 'no'} IDE Setup
    """ def _project_link(project: object, active_project_id: str) -> str: project_id = str(getattr(project, "project_id", "")) name = str(getattr(project, "name", project_id)) active = " active" if project_id == active_project_id else "" return f'{escape(name)}' def _operation_row(job: object) -> str: job_id = str(getattr(job, "job_id", "")) kind = str(getattr(job, "kind", "")) status = _enum_text(getattr(job, "status", "")) payload = getattr(job, "payload", {}) or {} project_id = str(payload.get("project_id") or "") stage = str(payload.get("stage") or "") message = str(payload.get("message") or getattr(job, "error", "") or "") project_link = ( f'{escape(project_id)}' if project_id else '-' ) return f""" {escape(kind)}{escape(job_id)} {project_link} {escape(status)} {escape(stage or "-")} {escape(message or "-")} """ def _topbar(project_id: str, project_nav: str) -> str: return f"""
    SFERA API Операции HTML5 Setup Legacy Next
    """ def _setup_name(setup: object) -> str: settings = getattr(setup, "settings", None) return str(getattr(settings, "name", None) or getattr(setup, "project_id", "SFERA Project")) def _enum_text(value: object) -> str: if value is None: return "" return str(value.value if hasattr(value, "value") else value) def _import_value(import_summary: object | None, field: str) -> int | str: if import_summary is None: return "0" return getattr(import_summary, field, 0) def _metric(label: str, value: object) -> str: return f"
    {escape(label)}
    {escape(str(value))}
    " def _last_import_block(import_summary: object | None) -> str: if import_summary is None: return '

    Загрузка еще не выполнялась

    ' source = _enum_text(getattr(import_summary, "source", "")) status = str(getattr(import_summary, "status", "")) source_path = str(getattr(import_summary, "source_path", "") or "source path unavailable") runtime = str(getattr(import_summary, "runtime_mode", "") or "runtime unavailable") return f"""
    {escape(status)} {escape(source)} · {escape(runtime)} {escape(source_path)}
    """ def _history_item(item: object) -> str: source = _enum_text(getattr(item, "source", "")) status = str(getattr(item, "status", "")) objects = getattr(item, "object_count", 0) modules = getattr(item, "module_count", 0) return f"""
    {escape(status)} {escape(source)} {escape(str(objects))} objects · {escape(str(modules))} modules
    """ def _import_source_card(source: object) -> str: kind = _enum_text(getattr(source, "kind", "")) title = str(getattr(source, "title", kind)) description = str(getattr(source, "description", "")) readiness = str(getattr(source, "readiness", "")) return f"""
    {escape(title)} {escape(kind)} {escape(readiness or description)}
    """ def _source_option(source: object, current_source: str) -> str: kind = _enum_text(getattr(source, "kind", "")) title = str(getattr(source, "title", kind)) selected = " selected" if kind == current_source else "" return f'' def _preflight_item(item: object) -> str: title = str(getattr(item, "title", "Check")) status = str(getattr(item, "status", "UNKNOWN")) message = str(getattr(item, "message", "")) return f"""
    {escape(title)} {escape(status)} {escape(message)}
    """ def _review_item(project_id: str, finding: dict) -> str: title = str(finding.get("title") or finding.get("code") or "Finding") severity = str(finding.get("severity") or finding.get("level") or "INFO") message = str(finding.get("message") or finding.get("description") or "") source_path = str(finding.get("source_path") or finding.get("path") or "") line = finding.get("line_start") or finding.get("line") location = f"{source_path}:{line}" if source_path and line else source_path source_link = ( f""" Source """ if source_path else "" ) return f"""
    {escape(title)} {escape(severity)} {escape(message or location or "no details")} {source_link}
    """ def _tree_item(project_id: str, node: object) -> str: name = getattr(node, "qualified_name", None) or getattr(node, "name", "") kind = getattr(node, "kind", "") kind_value = str(kind.value if hasattr(kind, "value") else kind) lineage_id = str(getattr(node, "lineage_id", "")) object_kinds = { NodeKind.CATALOG.value, NodeKind.DOCUMENT.value, NodeKind.REGISTER.value, NodeKind.COMMON_MODULE.value, NodeKind.REPORT.value, NodeKind.DATA_PROCESSOR.value, } if kind_value in object_kinds: htmx_attrs = ( f'hx-get="/html5/projects/{quote(project_id)}/objects/context/{quote(str(name), safe="")}" ' 'hx-target="[data-html5-object-context]" hx-swap="outerHTML"' ) else: htmx_attrs = ( f'hx-get="/html5/projects/{quote(project_id)}/source/{quote(lineage_id, safe="")}" ' 'hx-target="[data-html5-source]" hx-swap="outerHTML"' ) return ( f'' f'{escape(str(name))}{escape(kind_value)}' ) def _symbol_result(node: object, project_id: str | None = None) -> str: name = getattr(node, "qualified_name", None) or getattr(node, "name", "") kind = getattr(node, "kind", "") kind_value = str(kind.value if hasattr(kind, "value") else kind) lineage_id = str(getattr(node, "lineage_id", "")) source_path = getattr(node, "source_path", None) or getattr(getattr(node, "source_ref", None), "source_path", None) or "" line = getattr(node, "line_start", None) or getattr(getattr(node, "source_ref", None), "line_start", None) location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable" htmx_attrs = ( f'hx-get="/html5/projects/{quote(project_id)}/symbols/{quote(lineage_id, safe="")}/detail" ' 'hx-target="[data-html5-symbol-detail]" hx-swap="outerHTML"' if project_id and lineage_id else "" ) return f"""
    {escape(str(name))} {escape(kind_value)} {escape(str(location))}
    """ def _symbol_summary(references: Iterable[object]) -> str: refs = list(references) directions = Counter(str(getattr(ref, "direction", "") or "UNKNOWN") for ref in refs) kinds = Counter(str(getattr(ref, "kind", "") or "REFERENCE") for ref in refs) direction_text = ", ".join(f"{name}: {count}" for name, count in sorted(directions.items())) or "no directions" kind_text = ", ".join(f"{name}: {count}" for name, count in sorted(kinds.items())) or "no kinds" return f"""

    {escape(str(len(refs)))} references · {escape(direction_text)} · {escape(kind_text)}

    """ def _symbol_reference_item(project_id: str, reference: object) -> str: kind = str(getattr(reference, "kind", "")) direction = str(getattr(reference, "direction", "")) source = getattr(reference, "source", None) target = getattr(reference, "target", None) location = getattr(reference, "location", None) source_name = getattr(source, "qualified_name", None) or getattr(source, "name", "") target_name = getattr(target, "qualified_name", None) or getattr(target, "name", "") source_path = getattr(location, "source_path", None) or "" line = getattr(location, "line_start", None) place = f"{source_path}:{line}" if source_path and line else source_path label = f"{source_name} -> {target_name}".strip(" ->") source_lineage = str(getattr(source, "lineage_id", "") or "") source_link = ( f""" Source """ if source_lineage else "" ) return f"""
    {escape(label or kind)} {escape(direction)} · {escape(kind)} {escape(place or "source unavailable")} {source_link}
    """ def _authoring_change_item(change: object) -> str: change_id = str(getattr(change, "change_id", "")) status = str(getattr(change, "status", "")) target = getattr(change, "target", None) target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" approved_by = str(getattr(change, "approved_by", "") or "not approved") task_id = str(getattr(change, "task_id", "") or "no task") added = getattr(change, "added_lines", 0) removed = getattr(change, "removed_lines", 0) production = "production" if bool(getattr(change, "production_applied", False)) else "workspace" project_id = str(getattr(change, "project_id", "")) detail_attrs = ( f'hx-get="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}" ' 'hx-target="[data-html5-authoring-detail]" hx-swap="outerHTML"' if change_id and project_id else "" ) return f"""
    {escape(str(target_name))} {escape(status)} · +{escape(str(added))} / -{escape(str(removed))} · {escape(production)} {escape(task_id)} · {escape(approved_by)} · {escape(change_id)}
    """ def _named_node_item(label: str, node: object) -> str: name = getattr(node, "qualified_name", None) or getattr(node, "name", "") kind = getattr(node, "kind", label) return f"""
    {escape(str(name))} {escape(str(kind))}
    """ def _object_breadcrumb(object_name: str) -> str: parts = [part for part in object_name.split(".") if part] if not parts: return "" items = "".join(f"{escape(part)}" for part in parts) return f'' def _object_summary( attributes: int, sections: int, commands: int, reads: int, writes: int, calls: int, integrations: int, access_rules: int, runtime_signals: int, privacy_markers: int, ) -> str: impact_total = reads + writes + calls status_bits = [ f"{attributes} attrs", f"{sections} tables", f"{commands} commands", f"{impact_total} impact links", f"{access_rules} access rules", ] if integrations: status_bits.append(f"{integrations} integrations") if runtime_signals: status_bits.append(f"{runtime_signals} runtime signals") if privacy_markers: status_bits.append(f"{privacy_markers} privacy markers") return f"""

    {escape(" · ".join(status_bits))}

    """ def _object_action_links( project_id: str, object_name: str, lineage_id: object, modules: Iterable[object], active_mode: str, ) -> str: quoted_project = quote(project_id) quoted_object = quote(object_name, safe="") lineage = str(lineage_id or "") first_module = next(iter(modules), None) module_lineage = str(getattr(first_module, "lineage_id", "") or "") source_link = ( f'Source'.format( project=quoted_project, module=quote(module_lineage, safe=""), ) if module_lineage else "" ) symbol_link = ( f'Symbol'.format( project=quoted_project, lineage=quote(lineage, safe=""), ) if lineage else "" ) def active_attrs(mode: str) -> str: return ' aria-current="page" data-html5-object-action-active="true"' if mode == active_mode else "" return f""" """ def _tabular_section_item(section: object) -> str: tabular_section = getattr(section, "tabular_section", None) columns = getattr(section, "columns", []) or [] name = getattr(tabular_section, "qualified_name", None) or getattr(tabular_section, "name", "") return f"""
    {escape(str(name))} {escape(str(len(columns)))} columns
    """ def _role_access_item(grant: object) -> str: role = getattr(grant, "role", None) permissions = getattr(grant, "permissions", {}) or {} role_name = getattr(role, "qualified_name", None) or getattr(role, "name", "role") enabled = [ str(key) for key, value in sorted(permissions.items()) if str(value).lower() in {"true", "1", "yes", "да"} ] permission_text = ", ".join(enabled) if enabled else "permissions unavailable" return f"""
    {escape(str(role_name))} {escape(permission_text)}
    """ def _ui_form_item(form_semantics: object) -> str: form = getattr(form_semantics, "form", None) commands = getattr(form_semantics, "commands", []) or [] elements = getattr(form_semantics, "elements", []) or [] handlers = getattr(form_semantics, "command_handlers", {}) or {} form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "form") command_names = [ str(getattr(command, "name", getattr(command, "qualified_name", ""))) for command in commands[:3] ] handler_names = [ str(getattr(handler, "name", getattr(handler, "qualified_name", ""))) for handler in list(handlers.values())[:3] ] details = [] if command_names: details.append("cmd: " + ", ".join(command_names)) if handler_names: details.append("handler: " + ", ".join(handler_names)) if elements: details.append(f"{len(elements)} elements") return f"""
    {escape(str(form_name))} {escape(" · ".join(details) or "UI metadata")}
    """ def _runtime_summary_item(item: object) -> str: node = getattr(item, "node", None) name = getattr(node, "qualified_name", None) or getattr(node, "name", "runtime") signal_count = getattr(item, "signal_count", 0) error_count = getattr(item, "error_count", 0) max_duration = getattr(item, "max_duration_ms", None) duration_text = f" · max {max_duration} ms" if max_duration is not None else "" return f"""
    {escape(str(name))} {escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)}
    """ def _knowledge_record_item(record: object) -> str: title = str(getattr(record, "title", "knowledge")) scope = _enum_text(getattr(record, "scope", "")) body = str(getattr(record, "body", "") or "") record_id = str(getattr(record, "record_id", "")) return f"""
    {escape(title)} {escape(scope)} · {escape(record_id)} · {escape(body[:120])}
    """ def _privacy_marker_item(marker: object) -> str: classification = _enum_text(getattr(marker, "classification", "")) reason = str(getattr(marker, "reason", "") or "") target_id = str(getattr(marker, "target_id", "") or "target unavailable") return f"""
    {escape(classification or "privacy")} {escape(reason or target_id)}
    """ def _integration_endpoint_item(endpoint: object) -> str: name = str(getattr(endpoint, "name", "") or "integration") kind = _enum_text(getattr(endpoint, "kind", "UNKNOWN")) direction = str(getattr(endpoint, "direction", "UNKNOWN") or "UNKNOWN") owner = str(getattr(endpoint, "owner", "") or "owner unavailable") return f"""
    {escape(name)} {escape(kind)} · {escape(direction)} · {escape(owner)}
    """ def _object_context_url(project_id: str, name: str) -> str: return f"/html5/projects/{quote(project_id)}/objects/context/{quote(name, safe='')}" def _flowchart_focus_link(project_id: str, name: str, depth: int) -> str: url = _flowchart_url(project_id, name, depth) return f""" {escape(name)} """ def _flowchart_context_link(project_id: str, name: str, kind: str) -> str: if kind not in _HTML5_OBJECT_CONTEXT_KINDS: return "" url = _object_context_url(project_id, name) return f""" Context """ def _flowchart_edge_item(project_id: str, edge: object, nodes: Iterable[object], depth: int) -> str: node_names = { str(getattr(node, "id", "")): str(getattr(node, "qualified_name", "") or getattr(node, "label", "")) for node in nodes } node_kinds = { str(getattr(node, "id", "")): str(getattr(node, "kind", "") or "NODE") for node in nodes } source = node_names.get(str(getattr(edge, "source", "")), str(getattr(edge, "source", ""))) target = node_names.get(str(getattr(edge, "target", "")), str(getattr(edge, "target", ""))) source_kind = node_kinds.get(str(getattr(edge, "source", "")), "") target_kind = node_kinds.get(str(getattr(edge, "target", "")), "") label = str(getattr(edge, "label", "") or getattr(edge, "kind", "") or "link") kind = str(getattr(edge, "kind", "") or "FLOW") return f"""
    {escape(label)} {_flowchart_focus_link(project_id, source, depth)} -> {_flowchart_focus_link(project_id, target, depth)} · {escape(kind)} {_flowchart_context_link(project_id, source, source_kind)} {_flowchart_context_link(project_id, target, target_kind)}
    """ def _flowchart_url(project_id: str, focus: str | None, depth: int) -> str: params = [] if focus: params.append(f"focus={quote(focus, safe='')}") params.append(f"depth={depth}") return f"/html5/projects/{quote(project_id)}/flowchart?{'&'.join(params)}" def _flowchart_depth_actions(project_id: str, focus: str | None, active_depth: int) -> str: buttons = [] for depth in [1, 2, 3]: active = ' aria-current="page" data-html5-object-action-active="true"' if depth == active_depth else "" url = _flowchart_url(project_id, focus, depth) buttons.append( f""" Depth {depth} """ ) return f'' def _flowchart_node_item(project_id: str, node: object, depth: int) -> str: name = str(getattr(node, "qualified_name", "") or getattr(node, "label", "") or "node") kind = str(getattr(node, "kind", "") or "NODE") level = getattr(node, "level", 0) count = getattr(node, "count", 1) url = _flowchart_url(project_id, name, depth) return f"""
    {escape(name)} {escape(kind)} · level {escape(str(level))} · count {escape(str(count))} {_flowchart_context_link(project_id, name, kind)}
    """ def _authoring_diff_item(line: object) -> str: kind = str(getattr(line, "kind", "")) text = str(getattr(line, "text", "")) return f"""
    {escape(kind)} {escape(text)}
    """ def _authoring_check_item(check: object) -> str: name = str(getattr(check, "name", "check")) status = str(getattr(check, "status", "UNKNOWN")) message = str(getattr(check, "message", "")) return f"""
    {escape(name)} {escape(status)} {escape(message)}
    """ def _authoring_rollback_form(project_id: str, change_id: str, rollback_version_id: str) -> str: return f"""
    {render_html5_authoring_rollback_result(project_id)} """ def _authoring_apply_change_set_form(project_id: str, payload: dict, next_version_id: str) -> str: return f"""
    {render_html5_authoring_apply_result(project_id)} """ def _metadata_apply_form(project_id: str, payload: dict, next_version_id: str) -> str: return f"""
    {render_html5_metadata_apply_result(project_id)} """ def _node_source_text(node: object | None) -> str: if node is None: return "// Модуль не найден в snapshot.\n// HTML5 IDE показывает серверный fallback без клиентского JS." attributes = getattr(node, "attributes", {}) or {} source_text = attributes.get("source_text") or attributes.get("text") if isinstance(source_text, str) and source_text.strip(): return source_text name = getattr(node, "qualified_name", None) or getattr(node, "name", "Module") return f"// {name}\n// Исходный текст не сохранен в snapshot.\n// Сервер уже отрисовал контекст, дерево, поиск и метрики." def _css() -> str: return """ :root{color-scheme:light;--bg:#f7f8fb;--card:#fff;--text:#17202a;--muted:#687385;--line:#dce2ea;--brand:#2457d6;--ok:#168457;--warn:#a16207} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}a{color:inherit} .shell{min-height:100vh;padding:32px}.hero{display:flex;justify-content:space-between;gap:24px;align-items:end;margin:0 auto 24px;max-width:1180px}.hero h1{margin:0;font-size:36px;letter-spacing:0}.lead{max-width:640px;color:var(--muted);font-size:16px}.eyebrow{margin:0 0 8px;color:var(--brand);font-size:12px;font-weight:800;text-transform:uppercase}.hero-metrics{min-width:160px;border:1px solid var(--line);background:var(--card);padding:18px}.hero-metrics strong{display:block;font-size:34px}.hero-metrics span,.muted{color:var(--muted)}.toolbar-links{display:flex;gap:8px;flex-wrap:wrap} .band,.panel,.editor{border:1px solid var(--line);background:var(--card)}.band{max-width:1180px;margin:auto;padding:18px}.section-title,.topbar,.editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.button,button{display:inline-flex;align-items:center;justify-content:center;height:32px;border:1px solid var(--line);background:#fff;padding:0 12px;text-decoration:none;font-weight:700;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{border-top:1px solid var(--line);padding:12px;text-align:left}td small{display:block;color:var(--muted)}td .button,td form{margin-right:6px}.delete-project{display:inline-flex;gap:4px;vertical-align:middle}.delete-project input{height:32px;width:120px;border:1px solid var(--line);padding:0 6px} .workspace{min-height:100vh;padding-bottom:34px}.topbar{position:sticky;top:0;z-index:2;height:54px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);padding:0 14px}.brand{font-weight:900;text-decoration:none;color:var(--brand)}.project-nav{display:flex;gap:6px;overflow:auto;flex:1}.project-link{white-space:nowrap;text-decoration:none;border:1px solid var(--line);padding:6px 10px;background:#fff}.project-link.active{background:var(--brand);border-color:var(--brand);color:#fff} .layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)} .editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.source-panel{height:calc(100% - 72px);display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden}.source-head{display:flex;justify-content:space-between;gap:12px;align-items:center;min-height:54px;padding:10px 14px;border-bottom:1px solid var(--line);background:#fff}.source-head strong,.source-head small{display:block}.source-head small{color:var(--muted)}.source-head dl{display:flex;gap:12px;margin:0}.source-head div div{padding:0}.source-head dt{font-size:11px;color:var(--muted)}.source-head dd{margin:0;font-weight:800}.source-summary{margin:0;padding:8px 14px;border-bottom:1px solid var(--line);background:#fffdf8;color:var(--muted);font-size:12px;font-weight:800}.code{height:100%;margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap} .metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.object-actions{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.object-actions .button{height:28px;padding:0 9px;font-size:12px}.object-actions .button[data-html5-object-action-active="true"],.object-actions .button[aria-current="page"]{background:var(--brand);border-color:var(--brand);color:#fff}.object-breadcrumb{display:flex;gap:6px;flex-wrap:wrap;padding:9px 12px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;font-weight:800;color:var(--muted)}.object-breadcrumb span:not(:last-child)::after{content:"/";margin-left:6px;color:#98a2b3}.object-breadcrumb span:last-child{color:var(--ink)}.object-summary,.symbol-summary{margin:0;padding:10px 12px;border-bottom:1px solid var(--line);background:#f8fbff;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.symbol:hover{background:#f8fbff}.symbol span,.symbol small{color:var(--muted)}.symbol-focus,.symbol-reference,.object-focus,.object-context-item{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol-focus small,.symbol-reference span,.symbol-reference small,.object-focus span,.object-context-item small{color:var(--muted)}.inline-actions{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;font-weight:800}.inline-actions a{color:var(--brand);text-decoration:none}.report-grid{display:grid;grid-template-columns:1fr 1fr;margin:0}.report-grid div{padding:10px 12px;border-bottom:1px solid var(--line)}.report-grid dt{color:var(--muted);font-size:12px}.report-grid dd{margin:2px 0 0;font-size:20px;font-weight:900}.review-item,.authoring-change{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.authoring-change[hx-get]{cursor:pointer}.authoring-change[hx-get]:hover{background:#f8fbff}.review-item span,.review-item small,.authoring-change span,.authoring-change small{color:var(--muted)}.diff-item{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;padding:8px 12px;border-bottom:1px solid var(--line)}.diff-item span{color:var(--muted);font-weight:800}.diff-item code{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px} .setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card,.check-item{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px} @media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}} @media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}} """