Files
sfera/services/api-server/src/api_server/html5.py
T
m 6f594395f8
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
Link HTML5 object selection to source
2026-05-17 00:44:15 +03:00

1728 lines
81 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
def render_html5_index(projects: Iterable[object]) -> str:
project_list = list(projects)
return _page(
"SFERA HTML5",
f"""
<main class="shell" data-html5-page="projects">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Server-first рабочее место 1С</h1>
<p class="lead">Основной HTML собирает API-сервер. Браузер получает готовую страницу без React/Next runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(project_list)}</strong>
<span>проектов</span>
</div>
</section>
<section class="band">
<div class="section-title">
<h2>Проекты</h2>
<div class="toolbar-links">
<a class="button" href="/html5/operations">Операции</a>
<a class="button" href="/docs">API docs</a>
</div>
</div>
{render_html5_project_create_form()}
<div class="table-wrap">
<table data-html5-projects>
<thead>
<tr><th>Проект</th><th>Статус</th><th>Snapshot</th><th></th></tr>
</thead>
<tbody data-html5-projects-body>{render_html5_project_rows(project_list)}</tbody>
</table>
</div>
</section>
</main>
""",
)
def render_html5_project_create_form() -> str:
return """
<form
class="create-project"
method="post"
action="/html5/projects"
data-html5-project-create
hx-post="/html5/projects"
hx-target="[data-html5-projects-body]"
hx-swap="innerHTML"
>
<input name="project_id" placeholder="project_id" required />
<input name="name" placeholder="Название проекта" />
<button type="submit">Создать</button>
</form>
"""
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 '<tr><td colspan="4" class="muted">Проекты пока не настроены</td></tr>'
return project_rows
def render_html5_operations(jobs: Iterable[object]) -> str:
job_list = list(jobs)
return _page(
"SFERA HTML5 operations",
f"""
<main class="shell" data-html5-page="operations">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Операции сервера</h1>
<p class="lead">Очередь фоновых задач отрисовывается API-сервером и обновляется htmx polling без React runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(job_list)}</strong>
<span>jobs</span>
</div>
</section>
<section class="band">
<div class="section-title">
<h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a>
</div>
{render_html5_operation_summary(job_list)}
<div class="table-wrap">
<table data-html5-operations>
<thead>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th></tr>
</thead>
<tbody
data-html5-operations-body
hx-get="/html5/operations/jobs"
hx-trigger="every 3s"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
</section>
</main>
""",
)
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"""
<div
class="ops-summary"
data-html5-operations-summary
hx-get="/html5/operations/summary"
hx-trigger="every 3s"
hx-swap="outerHTML"
>
{_metric("Всего", len(job_list))}
{_metric("В работе", running)}
{_metric("В очереди", queued)}
{_metric("Успешно", succeeded)}
{_metric("Ошибки", failed)}
</div>
"""
def render_html5_operation_rows(jobs: Iterable[object]) -> str:
rows = "\n".join(_operation_row(job) for job in jobs)
if not rows:
return '<tr><td colspan="5" class="muted">Фоновые операции пока не запускались</td></tr>'
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"""
<main class="workspace" data-html5-page="editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="empty-state" data-html5-error>
<h1>Проект не готов к HTML5 IDE</h1>
<p>{escape(error or "Snapshot не найден")}</p>
<a class="button" href="/html5">К списку проектов</a>
</section>
</main>
"""
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"""
<main class="workspace" data-html5-page="editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="layout">
<aside class="panel tree" data-html5-tree>
<div class="panel-title">Дерево объектов</div>
<nav>{''.join(_tree_item(project_id, node) for node in tree_nodes) or '<p class="muted">Объекты не найдены</p>'}</nav>
</aside>
<section class="editor" data-html5-editor>
<div class="editor-head">
<div>
<p class="eyebrow">HTML5 editor</p>
<h1>{escape(selected_module.qualified_name if selected_module else project_id)}</h1>
</div>
<form
class="search"
action="/html5/projects/{quote(project_id)}/editor"
method="get"
data-html5-search
hx-get="/html5/projects/{quote(project_id)}/symbols"
hx-target="[data-html5-symbol-results]"
hx-swap="innerHTML"
hx-push-url="false"
>
<input name="q" value="{escape(q)}" placeholder="Найти символ на сервере" />
<button type="submit">Найти</button>
</form>
</div>
{render_html5_source(selected_module)}
</section>
<aside class="panel inspector" data-html5-inspector>
<div class="panel-title">Серверный контекст</div>
<dl class="metrics">
<div><dt>Nodes</dt><dd>{len(snapshot.nodes)}</dd></div>
<div><dt>Edges</dt><dd>{len(snapshot.edges)}</dd></div>
<div><dt>Diagnostics</dt><dd>{len(snapshot.diagnostics)}</dd></div>
<div><dt>Modules</dt><dd>{len(modules)}</dd></div>
</dl>
{render_html5_object_context(project_id, None, None)}
<div class="panel-title">Типы</div>
<ul class="compact">{''.join(f'<li><span>{escape(kind)}</span><b>{count}</b></li>' for kind, count in counts.most_common(10))}</ul>
<div class="panel-title">Результаты</div>
<div data-html5-symbol-results>
{render_html5_symbols(snapshot, q, project_id)}
</div>
{render_html5_symbol_detail(project_id, None)}
{render_html5_flowchart(project_id, None)}
{render_html5_project_report(project_id, None)}
{render_html5_review(project_id, None)}
{render_html5_authoring_preview(project_id, None)}
{render_html5_metadata_authoring(project_id)}
{render_html5_authoring_changes(project_id, None)}
</aside>
</section>
<footer class="status" data-html5-status hx-ext="sse" sse-connect="/html5/projects/{quote(project_id)}/events" sse-swap="status">
{render_html5_status(project_id, snapshot)}
</footer>
</main>
"""
return _page(f"SFERA HTML5 - {project_id}", content)
def render_html5_flowchart(
project_id: str,
flowchart: object | None,
*,
focus: str | None = None,
oob: bool = False,
) -> str:
hx_url = f"/html5/projects/{quote(project_id)}/flowchart"
if focus:
hx_url = f"{hx_url}?focus={quote(focus, safe='')}"
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
if flowchart is None:
return f"""
<div
class="flowchart-panel"
data-html5-flowchart
hx-get="{hx_url}"
hx-trigger="load"
hx-swap="outerHTML"
{oob_attr}
>
<div class="panel-title">Карта связей</div>
<p class="muted padded">Сервер собирает граф проекта.</p>
</div>
"""
nodes = getattr(flowchart, "nodes", []) or []
edges = getattr(flowchart, "edges", []) or []
mode = str(getattr(flowchart, "mode", "overview"))
body = "".join(_flowchart_edge_item(item, nodes) for item in edges[:10])
if not body:
body = "".join(_flowchart_node_item(item) for item in nodes[:10])
if not body:
body = '<p class="muted padded">Связи проекта не найдены</p>'
return f"""
<div
class="flowchart-panel"
data-html5-flowchart
hx-get="{hx_url}"
hx-trigger="every 30s"
hx-swap="outerHTML"
{oob_attr}
>
<div class="panel-title">Карта связей · {escape(mode)}</div>
<dl class="report-grid">
{_metric("Nodes", len(nodes))}
{_metric("Edges", len(edges))}
{_metric("Total nodes", getattr(flowchart, "total_nodes", 0))}
{_metric("Total edges", getattr(flowchart, "total_edges", 0))}
</dl>
<div class="compact-list">{body}</div>
</div>
"""
def render_html5_project_report(project_id: str, report: dict | None) -> str:
if report is None:
return f"""
<div
class="report-panel"
data-html5-project-report
hx-get="/html5/projects/{quote(project_id)}/report"
hx-trigger="load"
hx-swap="outerHTML"
>
<div class="panel-title">Отчет проекта</div>
<p class="muted padded">Сервер готовит сводку проекта.</p>
</div>
"""
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"""
<div
class="report-panel"
data-html5-project-report
hx-get="/html5/projects/{quote(project_id)}/report"
hx-trigger="every 15s"
hx-swap="outerHTML"
>
<div class="panel-title">Отчет проекта</div>
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
</div>
"""
def render_html5_review(project_id: str, findings: list[dict] | None) -> str:
if findings is None:
return f"""
<div
class="review-panel"
data-html5-review
hx-get="/html5/projects/{quote(project_id)}/review"
hx-trigger="load"
hx-swap="outerHTML"
>
<div class="panel-title">Review</div>
<p class="muted padded">Сервер готовит findings.</p>
</div>
"""
if not findings:
body = '<p class="muted padded">Findings не найдены</p>'
else:
body = "".join(_review_item(finding) for finding in findings[:12])
return f"""
<div
class="review-panel"
data-html5-review
hx-get="/html5/projects/{quote(project_id)}/review"
hx-trigger="every 20s"
hx-swap="outerHTML"
>
<div class="panel-title">Review · {len(findings)}</div>
<div class="review-list">{body}</div>
</div>
"""
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,
) -> str:
if schema is None or impact is None:
return f"""
<div class="object-context" data-html5-object-context>
<div class="panel-title">Object context</div>
<p class="muted padded">Выберите объект метаданных, чтобы сервер собрал schema/impact контекст проекта {escape(project_id)}.</p>
</div>
"""
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 []
return f"""
<div class="object-context" data-html5-object-context data-html5-object-name="{escape(str(name))}">
<div class="panel-title">Object context</div>
<article class="object-focus">
<strong>{escape(str(name))}</strong>
<span>{escape(str(getattr(obj, "kind", "object")))}</span>
</article>
<dl class="report-grid">
{_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))}
</dl>
<div class="compact-list">
{''.join(_named_node_item("attr", item) for item in attributes[:6]) or '<p class="muted padded">Реквизиты не найдены</p>'}
{''.join(_tabular_section_item(item) for item in sections[:4])}
{''.join(_ui_form_item(item) for item in ui_forms[:4])}
{''.join(_role_access_item(item) for item in grants[:6])}
{''.join(_integration_endpoint_item(item) for item in integration_items[:4])}
{''.join(_named_node_item("command", item) for item in commands[:6])}
{''.join(_named_node_item("read", item) for item in query_tables[:4])}
{''.join(_named_node_item("write", item) for item in writes[:4])}
{''.join(_named_node_item("call", item) for item in callees[:6])}
{''.join(_flowchart_edge_item(item, flow_nodes) for item in flow_edges[:8])}
{''.join(_runtime_summary_item(item) for item in runtime_items[:6])}
{''.join(_knowledge_record_item(item) for item in knowledge_items[:6])}
{''.join(_privacy_marker_item(item) for item in privacy_markers[:6])}
{''.join(_named_node_item("routine", item) for item in routines[:6])}
{''.join(_named_node_item("job", item) for item in jobs[:4])}
</div>
</div>
"""
def render_html5_authoring_changes(project_id: str, changes: Iterable[object] | None) -> str:
if changes is None:
return f"""
<div
class="authoring-panel"
data-html5-authoring-changes
hx-get="/html5/projects/{quote(project_id)}/authoring/changes"
hx-trigger="load"
hx-swap="outerHTML"
>
<div class="panel-title">Authoring</div>
<p class="muted padded">Сервер загружает историю рабочих изменений.</p>
</div>
"""
change_list = list(changes)
if not change_list:
body = '<p class="muted padded">Изменений пока нет</p>'
else:
body = "".join(_authoring_change_item(change) for change in change_list[:12])
return f"""
<div
class="authoring-panel"
data-html5-authoring-changes
hx-get="/html5/projects/{quote(project_id)}/authoring/changes"
hx-trigger="every 15s"
hx-swap="outerHTML"
>
<div class="panel-title">Authoring · {len(change_list)}</div>
<div class="review-list">{body}</div>
{render_html5_authoring_change_detail(project_id, None)}
</div>
"""
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"""
<div class="authoring-preview" data-html5-authoring-preview>
<div class="panel-title">Authoring preview</div>
<form
class="authoring-preview-form"
data-html5-authoring-preview-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/completion-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/completion-preview"
hx-target="[data-html5-authoring-preview-result]"
hx-swap="outerHTML"
>
<input name="object_name" placeholder="object_name" />
<input name="routine_name" placeholder="routine_name" />
<input name="cursor_line" placeholder="line" />
<input name="intent" value="fill-check" />
<input name="user_id" placeholder="user_id" />
<textarea name="source_text" placeholder="BSL source"></textarea>
<button type="submit">Preview</button>
</form>
{render_html5_authoring_preview_result(project_id)}
<form
class="authoring-preview-form"
data-html5-authoring-diff-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/semantic-diff-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/semantic-diff-preview"
hx-target="[data-html5-authoring-diff-result]"
hx-swap="outerHTML"
>
<input name="routine_name" placeholder="routine_name" />
<input name="source_path" placeholder="source_path" />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="user_id" placeholder="user_id" />
<textarea name="original_text" placeholder="Original BSL"></textarea>
<textarea name="proposed_text" placeholder="Proposed BSL"></textarea>
<button type="submit">Diff preview</button>
</form>
{render_html5_authoring_diff_result(project_id)}
</div>
"""
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 '<div class="authoring-preview-result" data-html5-authoring-preview-result></div>'
if error:
return f"""
<div class="authoring-preview-result" data-html5-authoring-preview-result>
<div class="panel-title">Preview result</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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 '<p class="muted padded">Diff пустой</p>'
return f"""
<div class="authoring-preview-result" data-html5-authoring-preview-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Preview result · {'allowed' if allowed else 'blocked'}</div>
<article class="authoring-change">
<strong>{escape(str(object_name))}</strong>
<span>{escape(str(routine_name))}</span>
<small>{escape(insert_text[:180] or "insert text unavailable")}</small>
</article>
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
</div>
"""
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 '<div class="authoring-diff-result" data-html5-authoring-diff-result></div>'
if error:
return f"""
<div class="authoring-diff-result" data-html5-authoring-diff-result>
<div class="panel-title">Diff preview</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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 '<p class="muted padded">Diff пустой</p>'
apply_form = (
_authoring_apply_change_set_form(project_id, request_payload or {}, next_version_id)
if changed and next_version_id
else ""
)
return f"""
<div class="authoring-diff-result" data-html5-authoring-diff-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Diff preview · {'changed' if changed else 'unchanged'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>+{escape(str(added))} / -{escape(str(removed))}</span>
<small>{escape(next_version_id or "version preview unavailable")}</small>
</article>
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
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 '<div class="authoring-apply-result" data-html5-authoring-apply-result></div>'
if error:
return f"""
<div class="authoring-apply-result" data-html5-authoring-apply-result>
<div class="panel-title">Apply change-set</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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"""
<div
class="authoring-apply-result"
data-html5-authoring-apply-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Apply change-set</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(change_id)}</span>
<small>{escape(version_id)}</small>
</article>
<p class="muted padded">Change-set применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
def render_html5_metadata_authoring(project_id: str) -> str:
return f"""
<div class="authoring-preview" data-html5-metadata-authoring>
<div class="panel-title">Metadata draft</div>
<form
class="authoring-preview-form"
data-html5-metadata-preview-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/metadata-object-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/metadata-object-preview"
hx-target="[data-html5-metadata-preview-result]"
hx-swap="outerHTML"
>
<input name="object_kind" value="DOCUMENT" />
<input name="name" placeholder="Имя объекта" required />
<input name="synonym" placeholder="Синоним" />
<input name="attributes" placeholder="Реквизиты: Имя:Тип, ..." />
<input name="tabular_sections" placeholder="ТЧ: Товары[Номенклатура:Строка;Количество:Число]" />
<input name="forms" placeholder="Формы через запятую" />
<input name="commands" placeholder="Команды: Имя:Обработчик" />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="user_id" placeholder="user_id" />
<button type="submit">Metadata preview</button>
</form>
{render_html5_metadata_preview_result(project_id)}
</div>
"""
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 '<div class="metadata-preview-result" data-html5-metadata-preview-result></div>'
if error:
return f"""
<div class="metadata-preview-result" data-html5-metadata-preview-result>
<div class="panel-title">Metadata preview</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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 '<p class="muted padded">Diff пустой</p>'
apply_form = (
_metadata_apply_form(project_id, request_payload or {}, next_version_id)
if changed and next_version_id
else ""
)
return f"""
<div class="metadata-preview-result" data-html5-metadata-preview-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Metadata preview · {'changed' if changed else 'unchanged'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>+{escape(str(added))} / -0</span>
<small>{escape(next_version_id or "version preview unavailable")}</small>
</article>
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
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 '<div class="metadata-apply-result" data-html5-metadata-apply-result></div>'
if error:
return f"""
<div class="metadata-apply-result" data-html5-metadata-apply-result>
<div class="panel-title">Metadata apply</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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"""
<div
class="metadata-apply-result"
data-html5-metadata-apply-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Metadata apply</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(change_id)}</span>
<small>{escape(version_id)}</small>
</article>
<p class="muted padded">Metadata draft применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
def render_html5_authoring_change_detail(project_id: str, preview: object | None) -> str:
if preview is None:
return f"""
<div class="authoring-detail" data-html5-authoring-detail>
<div class="panel-title">Rollback preview</div>
<p class="muted padded">Выберите изменение, чтобы сервер рассчитал rollback diff для проекта {escape(project_id)}.</p>
</div>
"""
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 '<p class="muted padded">Diff пустой</p>'
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"""
<div
class="authoring-detail"
data-html5-authoring-detail
data-html5-authoring-change="{escape(change_id)}"
>
<div class="panel-title">Rollback preview · {'ready' if apply_available else 'blocked'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>{escape(original_version_id)} -> {escape(rollback_version_id)}</span>
<small>{escape(change_id)}</small>
</article>
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
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 '<div class="authoring-result" data-html5-authoring-result></div>'
if error:
return f"""
<div class="authoring-result" data-html5-authoring-result>
<div class="panel-title">Rollback apply</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
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"""
<div
class="authoring-result"
data-html5-authoring-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Rollback apply</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(rollback_change_id)}</span>
<small>{escape(version_id)}</small>
</article>
<p class="muted padded">Rollback применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
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"""
<main class="workspace setup-workspace" data-html5-page="setup" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="setup-layout">
<aside class="panel">
<div class="panel-title">Проект</div>
<div class="setup-card">
<p class="eyebrow">HTML5 setup</p>
<h1>{escape(name)}</h1>
<p class="muted">{escape(project_id)}</p>
<div class="setup-actions">
<a class="button" href="/html5/projects/{quote(project_id)}/editor">Открыть IDE</a>
<a class="button" href="/project-settings?project={quote(project_id)}">Legacy setup</a>
</div>
</div>
<div class="panel-title">Источники</div>
<div class="source-list">{source_cards or '<p class="muted padded">Источники импорта не настроены</p>'}</div>
</aside>
<section class="panel setup-main">
{render_html5_settings_panel(project_id, setup)}
{render_html5_setup_actions(project_id, setup)}
{render_html5_setup_summary(project_id, setup)}
</section>
</section>
</main>
"""
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 = '<span class="saved">Сохранено</span>' if saved else ""
return f"""
<div class="settings-panel" data-html5-settings-panel>
<div class="panel-title flush">Базовые настройки {notice}</div>
<form
class="settings-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/settings"
hx-post="/html5/projects/{quote(project_id)}/setup/settings"
hx-target="[data-html5-settings-panel]"
hx-swap="outerHTML"
>
<label>Название<input name="name" value="{escape(name)}" /></label>
<label>Платформа<input name="platform_version" value="{escape(platform_version)}" placeholder="8.3.24" /></label>
<label>Совместимость<input name="compatibility_mode" value="{escape(compatibility_mode)}" placeholder="8.3.20" /></label>
<button type="submit">Сохранить настройки</button>
</form>
</div>
"""
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'<option value="{escape(current_source or "XML_DUMP")}">{escape(current_source or "XML_DUMP")}</option>'
return f"""
<div class="setup-actions-panel" data-html5-setup-actions>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/source"
hx-post="/html5/projects/{quote(project_id)}/setup/source"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<label>Источник</label>
<select name="source">{source_options}</select>
<button type="submit">Сохранить</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/check"
hx-post="/html5/projects/{quote(project_id)}/setup/check"
hx-target="[data-html5-import-check]"
hx-swap="outerHTML"
>
<button type="submit">Проверить</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/import-job"
hx-post="/html5/projects/{quote(project_id)}/setup/import-job"
hx-target="[data-html5-import-job]"
hx-swap="outerHTML"
>
<button type="submit">Импорт в фоне</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/import"
hx-post="/html5/projects/{quote(project_id)}/setup/import"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Запустить импорт</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/reindex"
hx-post="/html5/projects/{quote(project_id)}/setup/reindex"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Переиндексировать</button>
</form>
</div>
{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"""
<div class="import-check" data-html5-import-check>
<div class="panel-title flush">Проверка импорта</div>
<p class="muted padded">Запустите server-side preflight перед импортом проекта {escape(project_id)}.</p>
</div>
"""
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"""
<div class="import-check" data-html5-import-check>
<div class="panel-title flush">Проверка импорта</div>
<div class="check-head">
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{'ready' if ready else 'needs attention'}</small>
</div>
<div class="check-list">{items or '<p class="muted padded">Проверки не вернули результатов</p>'}</div>
</div>
"""
def render_html5_import_job(project_id: str, job: object | None = None) -> str:
if job is None:
return f"""
<div class="import-job" data-html5-import-job>
<div class="panel-title flush">Фоновый импорт</div>
<p class="muted padded">Фоновая задача импорта для проекта {escape(project_id)} еще не запускалась.</p>
</div>
"""
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"<li>{escape(str(item))}</li>" for item in logs[-6:])
return f"""
<div
class="import-job"
data-html5-import-job
hx-get="/html5/projects/{quote(project_id)}/setup/jobs/{quote(job_id)}"
{poll}
>
<div class="panel-title flush">Фоновый импорт</div>
<div class="check-head">
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{escape(stage or job_id)}</small>
</div>
<p class="muted padded">{escape(message or "Ожидание обновления статуса")}</p>
<ul class="job-log">{logs_html or '<li>Лог пока пустой</li>'}</ul>
</div>
"""
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"""
<div
class="setup-summary"
data-html5-setup-summary
hx-get="/html5/projects/{quote(project_id)}/setup/summary"
hx-trigger="every 5s"
hx-swap="outerHTML"
>
<div class="section-title">
<div>
<p class="eyebrow">Server-rendered status</p>
<h2>{escape(status)}</h2>
</div>
<span class="status-pill">{escape(current_source)}</span>
</div>
<p class="lead compact-lead">{escape(message)}</p>
<dl class="setup-metrics">
{_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"))}
</dl>
<div class="panel-title flush">Последняя загрузка</div>
{_last_import_block(last_import)}
<div class="panel-title flush">История</div>
<div class="history-list">
{''.join(_history_item(item) for item in history[:6]) or '<p class="muted padded">История импорта пока пустая</p>'}
</div>
</div>
"""
def render_html5_status(project_id: str, snapshot: SirSnapshot) -> str:
return (
f'<span>project: {escape(project_id)}</span>'
f'<span>snapshot: {escape(snapshot.snapshot_id)}</span>'
f'<span>nodes: {len(snapshot.nodes)}</span>'
f'<span>edges: {len(snapshot.edges)}</span>'
f'<span>diagnostics: {len(snapshot.diagnostics)}</span>'
'<span>server-rendered</span>'
'<span>client-js: htmx+sse only</span>'
)
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 '<p class="muted">Нет результатов</p>'
return "".join(_symbol_result(node, project_id) for node in results)
def render_html5_symbol_detail(project_id: str, references: object | None) -> str:
if references is None:
return f"""
<div class="symbol-detail" data-html5-symbol-detail>
<div class="panel-title">Символ</div>
<p class="muted padded">Выберите результат поиска для server-side definition/references по проекту {escape(project_id)}.</p>
</div>
"""
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(ref) for ref in refs[:10]) or '<p class="muted padded">References не найдены</p>'
lineage_id = str(getattr(node, "lineage_id", ""))
return f"""
<div
class="symbol-detail"
data-html5-symbol-detail
data-html5-lineage-id="{escape(lineage_id)}"
>
<div class="panel-title">Символ · {escape(str(kind))}</div>
<article class="symbol-focus">
<strong>{escape(str(name))}</strong>
<small>{escape(str(location))}</small>
</article>
<div class="review-list">{ref_items}</div>
</div>
"""
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
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
return f"""
<article
class="source-panel"
data-html5-source
data-html5-source-name="{escape(str(name))}"
data-html5-lineage-id="{escape(lineage_id)}"
{oob_attr}
>
<header class="source-head">
<div>
<strong>{escape(str(name))}</strong>
<small>{escape(kind or "source")}</small>
</div>
<dl>
{_metric("Lines", line_count)}
{_metric("Location", location)}
</dl>
</header>
<pre class="code">{escape(source_text)}</pre>
</article>
"""
def _page(title: str, body: str) -> str:
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escape(title)}</title>
<style>{_css()}</style>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
</head>
<body>{body}</body>
</html>"""
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"""
<tr data-html5-project="{escape(project_id)}">
<td><strong>{escape(name)}</strong><small>{escape(project_id)}</small></td>
<td>{escape(status)}</td>
<td>{'yes' if has_snapshot else 'no'}</td>
<td>
<a class="button" href="/html5/projects/{quote(project_id)}/editor">IDE</a>
<a class="button" href="/html5/projects/{quote(project_id)}/setup">Setup</a>
<form
class="delete-project"
method="post"
action="/html5/projects/{quote(project_id)}/delete"
hx-post="/html5/projects/{quote(project_id)}/delete"
hx-target="[data-html5-projects-body]"
hx-swap="innerHTML"
>
<input name="confirmation" value="{escape(project_id)}" aria-label="confirmation" />
<button type="submit">Удалить</button>
</form>
</td>
</tr>"""
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'<a class="project-link{active}" href="/html5/projects/{quote(project_id)}/editor">{escape(name)}</a>'
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'<a href="/html5/projects/{quote(project_id)}/setup">{escape(project_id)}</a>'
if project_id
else '<span class="muted">-</span>'
)
return f"""
<tr data-html5-operation="{escape(job_id)}">
<td><strong>{escape(kind)}</strong><small>{escape(job_id)}</small></td>
<td>{project_link}</td>
<td>{escape(status)}</td>
<td>{escape(stage or "-")}</td>
<td>{escape(message or "-")}</td>
</tr>"""
def _topbar(project_id: str, project_nav: str) -> str:
return f"""
<header class="topbar" data-html5-topbar>
<a class="brand" href="/html5">SFERA</a>
<nav class="project-nav">{project_nav}</nav>
<a class="button" href="/docs">API</a>
<a class="button" href="/html5/operations">Операции</a>
<a class="button" href="/html5/projects/{quote(project_id)}/setup">HTML5 Setup</a>
<a class="button" href="/editor?project={quote(project_id)}">Legacy Next</a>
</header>"""
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"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
def _last_import_block(import_summary: object | None) -> str:
if import_summary is None:
return '<p class="muted padded">Загрузка еще не выполнялась</p>'
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"""
<div class="setup-detail" data-html5-last-import>
<strong>{escape(status)}</strong>
<span>{escape(source)} · {escape(runtime)}</span>
<small>{escape(source_path)}</small>
</div>
"""
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"""
<article class="history-item" data-html5-import-history>
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{escape(str(objects))} objects · {escape(str(modules))} modules</small>
</article>
"""
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"""
<article class="source-card" data-html5-import-source="{escape(kind)}">
<strong>{escape(title)}</strong>
<span>{escape(kind)}</span>
<small>{escape(readiness or description)}</small>
</article>
"""
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'<option value="{escape(kind)}"{selected}>{escape(title)} · {escape(kind)}</option>'
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"""
<article class="check-item" data-html5-preflight-check="{escape(status)}">
<strong>{escape(title)}</strong>
<span>{escape(status)}</span>
<small>{escape(message)}</small>
</article>
"""
def _review_item(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
return f"""
<article class="review-item" data-html5-review-finding="{escape(severity)}">
<strong>{escape(title)}</strong>
<span>{escape(severity)}</span>
<small>{escape(message or location or "no details")}</small>
</article>
"""
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'<a class="tree-item" href="#{quote(str(name))}" '
f'data-html5-node-kind="{escape(kind_value)}" '
f'data-html5-lineage-id="{escape(lineage_id)}" '
f'{htmx_attrs}>'
f'<span>{escape(str(name))}</span><small>{escape(kind_value)}</small></a>'
)
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"""
<article class="symbol" data-html5-symbol data-html5-lineage-id="{escape(lineage_id)}" {htmx_attrs}>
<strong>{escape(str(name))}</strong>
<span>{escape(kind_value)}</span>
<small>{escape(str(location))}</small>
</article>"""
def _symbol_reference_item(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(" ->")
return f"""
<article class="symbol-reference" data-html5-symbol-reference>
<strong>{escape(label or kind)}</strong>
<span>{escape(direction)} · {escape(kind)}</span>
<small>{escape(place or "source unavailable")}</small>
</article>
"""
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"""
<article class="authoring-change" data-html5-authoring-change="{escape(change_id)}" {detail_attrs}>
<strong>{escape(str(target_name))}</strong>
<span>{escape(status)} · +{escape(str(added))} / -{escape(str(removed))} · {escape(production)}</span>
<small>{escape(task_id)} · {escape(approved_by)} · {escape(change_id)}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="{escape(label)}">
<strong>{escape(str(name))}</strong>
<small>{escape(str(kind))}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="tabular-section">
<strong>{escape(str(name))}</strong>
<small>{escape(str(len(columns)))} columns</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="role-access">
<strong>{escape(str(role_name))}</strong>
<small>{escape(permission_text)}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="ui-form">
<strong>{escape(str(form_name))}</strong>
<small>{escape(" · ".join(details) or "UI metadata")}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="runtime">
<strong>{escape(str(name))}</strong>
<small>{escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="knowledge">
<strong>{escape(title)}</strong>
<small>{escape(scope)} · {escape(record_id)} · {escape(body[:120])}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="privacy">
<strong>{escape(classification or "privacy")}</strong>
<small>{escape(reason or target_id)}</small>
</article>
"""
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"""
<article class="object-context-item" data-html5-object-context-item="integration">
<strong>{escape(name)}</strong>
<small>{escape(kind)} · {escape(direction)} · {escape(owner)}</small>
</article>
"""
def _flowchart_edge_item(edge: object, nodes: Iterable[object]) -> str:
node_names = {
str(getattr(node, "id", "")): str(getattr(node, "qualified_name", "") or getattr(node, "label", ""))
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", "")))
label = str(getattr(edge, "label", "") or getattr(edge, "kind", "") or "link")
kind = str(getattr(edge, "kind", "") or "FLOW")
return f"""
<article class="object-context-item" data-html5-object-context-item="flow-edge">
<strong>{escape(label)}</strong>
<small>{escape(source)} -> {escape(target)} · {escape(kind)}</small>
</article>
"""
def _flowchart_node_item(node: object) -> 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)
return f"""
<article class="object-context-item" data-html5-flowchart-node="{escape(kind)}">
<strong>{escape(name)}</strong>
<small>{escape(kind)} · level {escape(str(level))} · count {escape(str(count))}</small>
</article>
"""
def _authoring_diff_item(line: object) -> str:
kind = str(getattr(line, "kind", ""))
text = str(getattr(line, "text", ""))
return f"""
<article class="diff-item" data-html5-authoring-diff="{escape(kind)}">
<span>{escape(kind)}</span>
<code>{escape(text)}</code>
</article>
"""
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"""
<article class="check-item" data-html5-authoring-check="{escape(status)}">
<strong>{escape(name)}</strong>
<span>{escape(status)}</span>
<small>{escape(message)}</small>
</article>
"""
def _authoring_rollback_form(project_id: str, change_id: str, rollback_version_id: str) -> str:
return f"""
<form
class="rollback-form"
data-html5-authoring-rollback-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}/apply-rollback"
hx-post="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}/apply-rollback"
hx-target="[data-html5-authoring-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="expected_rollback_version_id" value="{escape(rollback_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply rollback</button>
</form>
{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"""
<form
class="authoring-preview-form"
data-html5-authoring-apply-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/apply-change-set"
hx-post="/html5/projects/{quote(project_id)}/authoring/apply-change-set"
hx-target="[data-html5-authoring-apply-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="routine_name" value="{escape(str(payload.get("routine_name") or ""))}" />
<input type="hidden" name="source_path" value="{escape(str(payload.get("source_path") or ""))}" />
<textarea hidden name="original_text">{escape(str(payload.get("original_text") or ""))}</textarea>
<textarea hidden name="proposed_text">{escape(str(payload.get("proposed_text") or ""))}</textarea>
<input type="hidden" name="task_id" value="{escape(str(payload.get("task_id") or ""))}" />
<input type="hidden" name="session_id" value="{escape(str(payload.get("session_id") or ""))}" />
<input type="hidden" name="user_id" value="{escape(str(payload.get("user_id") or ""))}" />
<input type="hidden" name="expected_next_version_id" value="{escape(next_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply change-set</button>
</form>
{render_html5_authoring_apply_result(project_id)}
"""
def _metadata_apply_form(project_id: str, payload: dict, next_version_id: str) -> str:
return f"""
<form
class="authoring-preview-form"
data-html5-metadata-apply-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/apply-metadata-object"
hx-post="/html5/projects/{quote(project_id)}/authoring/apply-metadata-object"
hx-target="[data-html5-metadata-apply-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="object_kind" value="{escape(str(payload.get("object_kind") or ""))}" />
<input type="hidden" name="name" value="{escape(str(payload.get("name") or ""))}" />
<input type="hidden" name="synonym" value="{escape(str(payload.get("synonym") or ""))}" />
<input type="hidden" name="attributes" value="{escape(str(payload.get("_raw_attributes") or ""))}" />
<input type="hidden" name="tabular_sections" value="{escape(str(payload.get("_raw_tabular_sections") or ""))}" />
<input type="hidden" name="forms" value="{escape(str(payload.get("_raw_forms") or ""))}" />
<input type="hidden" name="commands" value="{escape(str(payload.get("_raw_commands") or ""))}" />
<input type="hidden" name="task_id" value="{escape(str(payload.get("task_id") or ""))}" />
<input type="hidden" name="session_id" value="{escape(str(payload.get("session_id") or ""))}" />
<input type="hidden" name="user_id" value="{escape(str(payload.get("user_id") or ""))}" />
<input type="hidden" name="expected_next_version_id" value="{escape(next_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply metadata draft</button>
</form>
{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 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}.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)}.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)}.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}}
"""