okstra 0.154.1 → 0.154.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.154.1",
3
+ "version": "0.154.2",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.154.1",
3
- "builtAt": "2026-08-05T18:07:27.600Z",
2
+ "package": "0.154.2",
3
+ "builtAt": "2026-08-05T18:59:33.609Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -24,6 +24,7 @@ from .filters import (
24
24
  paragraphs,
25
25
  )
26
26
  from .models import HtmlRunMeta
27
+ from .report_index import inject_report_index
27
28
  from .router import HtmlRenderError, resolve_html_route
28
29
 
29
30
 
@@ -133,7 +134,8 @@ def render_v2_html_view(
133
134
  # threading the index through every macro and call site.
134
135
  anchors = anchor_index(data)
135
136
  chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
136
- env.globals["t"] = make_jinja_global(chrome)
137
+ translate = make_jinja_global(chrome)
138
+ env.globals["t"] = translate
137
139
  env.filters["code_evidence"] = code_evidence
138
140
  env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
139
141
  env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
@@ -154,7 +156,10 @@ def render_v2_html_view(
154
156
  "js": response_js + "\n" + base_js,
155
157
  }
156
158
  output_path = _html_path(data_path)
157
- output_path.write_text(env.get_template(route.template_name).render(**context), encoding="utf-8")
159
+ document = env.get_template(route.template_name).render(**context)
160
+ output_path.write_text(
161
+ inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
162
+ )
158
163
  if context["clarificationItems"]:
159
164
  # The footer tells the reader to drop the exported file here, so the
160
165
  # directory has to exist before they go looking for it.
@@ -0,0 +1,75 @@
1
+ """Build the reader's index that heads the rendered report.
2
+
3
+ The index is derived from the rendered document instead of being declared in
4
+ each task template. A template that gains a section gets an index entry with
5
+ it, and there is no second list to keep in step.
6
+
7
+ Anchors come from the id a section already carries or from its
8
+ ``data-report-section`` slug — never from the heading text, which changes with
9
+ the report language and would move every link with it.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # Every section of the human view opens with its own h2. A block that does not
16
+ # is not a place the reader navigates to, so it stays out of the index.
17
+ _SECTION_HEAD_RE = re.compile(
18
+ r'<section\b(?P<attrs>[^>]*)>(?P<heading_open>\s*<h2[^>]*>)(?P<title>.*?)</h2>',
19
+ re.DOTALL,
20
+ )
21
+ _MAIN_OPEN_RE = re.compile(r"<main\b[^>]*>")
22
+ _ID_RE = re.compile(r'\bid="([^"]+)"')
23
+ _SLUG_RE = re.compile(r'\bdata-report-section="([^"]+)"')
24
+ _TAG_RE = re.compile(r"<[^>]+>")
25
+
26
+ INDEX_TITLE_ID = "report-index-title"
27
+
28
+
29
+ def _heading_text(title_markup: str) -> str:
30
+ """The heading's words without the markup a link cannot carry."""
31
+ return " ".join(_TAG_RE.sub("", title_markup).split())
32
+
33
+
34
+ def inject_report_index(document: str, *, label: str) -> str:
35
+ """Return ``document`` with a section index at the top of ``<main>``.
36
+
37
+ Sections that lack both an id and a slug are skipped rather than given a
38
+ generated anchor: a link whose target moves between renders is worse than
39
+ an entry the reader never had.
40
+ """
41
+ opening = _MAIN_OPEN_RE.search(document)
42
+ if opening is None:
43
+ return document
44
+ head, body = document[: opening.end()], document[opening.end() :]
45
+ entries: list[tuple[str, str]] = []
46
+
47
+ def _anchor_section(match: re.Match[str]) -> str:
48
+ attrs = match.group("attrs")
49
+ existing = _ID_RE.search(attrs)
50
+ if existing:
51
+ anchor = existing.group(1)
52
+ else:
53
+ slug = _SLUG_RE.search(attrs)
54
+ if slug is None:
55
+ return match.group(0)
56
+ anchor = f"section-{slug.group(1)}"
57
+ # Last, not first: templates lead a section with the attribute the
58
+ # tests and the validator select it by.
59
+ attrs = f'{attrs} id="{anchor}"'
60
+ entries.append((anchor, _heading_text(match.group("title"))))
61
+ return f'<section{attrs}>{match.group("heading_open")}{match.group("title")}</h2>'
62
+
63
+ body = _SECTION_HEAD_RE.sub(_anchor_section, body)
64
+ if not entries:
65
+ return document
66
+ items = "".join(
67
+ f'<li><a href="#{anchor}">{text}</a></li>' for anchor, text in entries
68
+ )
69
+ index = (
70
+ f'<nav class="report-index" aria-labelledby="{INDEX_TITLE_ID}">'
71
+ f'<h2 id="{INDEX_TITLE_ID}">{label}</h2>'
72
+ f"<ol>{items}</ol>"
73
+ "</nav>"
74
+ )
75
+ return f"{head}\n{index}{body}"
@@ -16,6 +16,13 @@ main { display: grid; gap: 1rem; padding-bottom: 3rem; }
16
16
  section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
17
17
  h2 { margin-top: 0; font-size: 1.45rem; }
18
18
  h3 { margin-bottom: .35rem; }
19
+ /* The report opens with what it holds. It scrolls away with the rest rather
20
+ than sticking: on a phone a pinned index of a dozen sections is the page. */
21
+ nav.report-index { padding: 1.2rem 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
22
+ nav.report-index h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .08em; color: GrayText; margin-bottom: .6rem; }
23
+ nav.report-index ol { margin: 0; padding-left: 1.4rem; columns: 2; column-gap: 2.5rem; }
24
+ nav.report-index li { margin: .2em 0; break-inside: avoid; }
25
+ nav.report-index a { color: inherit; }
19
26
  /* One card per row. Fitting three across left each body about sixteen
20
27
  characters wide — a third of what reads comfortably — and turned every card
21
28
  into a narrow vertical strip. These carry paragraphs, not labels. */
@@ -92,6 +99,6 @@ button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
92
99
  button[data-action="export-user-response"] { background: Highlight; border-color: Highlight; color: HighlightText; font-weight: 600; }
93
100
  button[data-action="export-user-response"]:hover { background: color-mix(in srgb, Highlight 82%, CanvasText); }
94
101
  /* A seven-column table needs 42em of floor, more than a phone can give. */
95
- @media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } }
102
+ @media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
96
103
  @media print { .skip-link, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
97
104
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
@@ -72,6 +72,7 @@
72
72
  },
73
73
  "base": {
74
74
  "skip-to-report-content": "Skip to report content",
75
+ "contents": "Contents",
75
76
  "task": "Task",
76
77
  "task-key": "Task key",
77
78
  "written": "Written",
@@ -72,6 +72,7 @@
72
72
  },
73
73
  "base": {
74
74
  "skip-to-report-content": "리포트 본문으로 건너뛰기",
75
+ "contents": "목차",
75
76
  "task": "태스크",
76
77
  "task-key": "태스크 키",
77
78
  "written": "작성",
@@ -32,7 +32,7 @@
32
32
 
33
33
  {% macro clarification_responses(items) -%}
34
34
  {% if items %}
35
- <section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
35
+ <section class="clarification-responses" data-report-section="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
36
36
  <h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
37
37
  <p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
38
38
  {% for row in items %}