induscode 0.1.0__py3-none-any.whl
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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
'''Page-shell template — the standalone HTML/CSS scaffold the transcript
|
|
2
|
+
publisher fills.
|
|
3
|
+
|
|
4
|
+
Port of TS ``src/transcript-export/template.ts`` (constants verbatim; the
|
|
5
|
+
embedded client renderer stays JavaScript).
|
|
6
|
+
|
|
7
|
+
This module is a freshly authored single-file page: its own element names
|
|
8
|
+
(``<main class="xscript">``, ``.turn``, ``.turn-tag``, ``.turn-body``,
|
|
9
|
+
``.tool-frame``, ``.aside-note``), its own stylesheet, and its own
|
|
10
|
+
``{{TOKEN}}`` placeholder names — the
|
|
11
|
+
:data:`~induscode.transcript_export.contract.SHELL_SLOTS` values from the
|
|
12
|
+
transcript-export contract (``THEME_VARS``/``PAGE_SURFACE``/…/``MARKED_LIB``/
|
|
13
|
+
``HIGHLIGHT_LIB``). None of the class names, DOM structure, placeholder
|
|
14
|
+
tokens, or color values are carried over from any prior export template.
|
|
15
|
+
|
|
16
|
+
The publisher computes a value for every slot and calls :func:`fill` to splice
|
|
17
|
+
them in. The page is self-contained: theme colors arrive as CSS custom
|
|
18
|
+
properties in ``:root``, the library license notices are inlined, the session
|
|
19
|
+
payload is embedded as base64, and a small client renderer (the ``SCRIPT``
|
|
20
|
+
slot) hydrates the transcript on load.
|
|
21
|
+
|
|
22
|
+
Port note: the TS client script carried an in-browser ``highlight.js``
|
|
23
|
+
fallback (re-highlighting code fences when a turn arrived without
|
|
24
|
+
pre-rendered HTML). In this port every turn ships fully rendered at publish
|
|
25
|
+
time — markdown through markdown-it-py, fences through Pygments — so the
|
|
26
|
+
in-browser highlighter path is dropped; the client renderer only decodes the
|
|
27
|
+
payload and injects the already-built HTML.
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from typing import Final, TypeAlias
|
|
34
|
+
|
|
35
|
+
from .contract import SHELL_SLOTS
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"CLIENT_SCRIPT",
|
|
39
|
+
"PAGE_SHELL",
|
|
40
|
+
"PAGE_STYLES",
|
|
41
|
+
"SlotValues",
|
|
42
|
+
"fill",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Stylesheet
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
#: The page stylesheet, emitted into the ``STYLES`` slot. Reads its colors
|
|
51
|
+
#: from the ``--x-*`` custom properties the ``THEME_VARS`` slot defines, so
|
|
52
|
+
#: re-theming is a matter of changing the variables, not the rules. Class
|
|
53
|
+
#: names are this page's own (``xscript``, ``turn``, ``tool-frame``,
|
|
54
|
+
#: ``aside-note``, the ``sgr-*`` family the painter emits, and the highlight
|
|
55
|
+
#: ``xhl`` block).
|
|
56
|
+
PAGE_STYLES: Final[str] = """
|
|
57
|
+
:root { color-scheme: dark light; }
|
|
58
|
+
* { box-sizing: border-box; }
|
|
59
|
+
html, body { margin: 0; padding: 0; }
|
|
60
|
+
body {
|
|
61
|
+
background: var(--x-page-surface);
|
|
62
|
+
color: var(--x-ink);
|
|
63
|
+
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
64
|
+
-webkit-font-smoothing: antialiased;
|
|
65
|
+
}
|
|
66
|
+
.xscript {
|
|
67
|
+
max-width: 56rem;
|
|
68
|
+
margin: 0 auto;
|
|
69
|
+
padding: 2.5rem 1.25rem 6rem;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
gap: 1.25rem;
|
|
73
|
+
}
|
|
74
|
+
.xscript-head {
|
|
75
|
+
border-bottom: 1px solid var(--x-border);
|
|
76
|
+
padding-bottom: 1rem;
|
|
77
|
+
margin-bottom: 0.5rem;
|
|
78
|
+
}
|
|
79
|
+
.xscript-head h1 {
|
|
80
|
+
margin: 0;
|
|
81
|
+
font-size: 1.35rem;
|
|
82
|
+
font-weight: 650;
|
|
83
|
+
letter-spacing: -0.01em;
|
|
84
|
+
}
|
|
85
|
+
.xscript-head .xscript-meta {
|
|
86
|
+
margin-top: 0.35rem;
|
|
87
|
+
color: var(--x-ink-muted);
|
|
88
|
+
font-size: 0.85rem;
|
|
89
|
+
}
|
|
90
|
+
.turn {
|
|
91
|
+
background: var(--x-card-surface);
|
|
92
|
+
border: 1px solid var(--x-border);
|
|
93
|
+
border-radius: 0.65rem;
|
|
94
|
+
padding: 1rem 1.15rem;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
}
|
|
97
|
+
.turn[data-role="user"] { border-left: 3px solid var(--x-user-role); }
|
|
98
|
+
.turn[data-role="agent"] { border-left: 3px solid var(--x-agent-role); }
|
|
99
|
+
.turn[data-role="tool"] { border-left: 3px solid var(--x-tool-role); }
|
|
100
|
+
.turn-tag {
|
|
101
|
+
display: inline-block;
|
|
102
|
+
font-size: 0.72rem;
|
|
103
|
+
font-weight: 650;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
letter-spacing: 0.06em;
|
|
106
|
+
margin-bottom: 0.6rem;
|
|
107
|
+
color: var(--x-ink-muted);
|
|
108
|
+
}
|
|
109
|
+
.turn[data-role="user"] .turn-tag { color: var(--x-user-role); }
|
|
110
|
+
.turn[data-role="agent"] .turn-tag { color: var(--x-agent-role); }
|
|
111
|
+
.turn[data-role="tool"] .turn-tag { color: var(--x-tool-role); }
|
|
112
|
+
.turn-body { word-break: break-word; }
|
|
113
|
+
.turn-body > :first-child { margin-top: 0; }
|
|
114
|
+
.turn-body > :last-child { margin-bottom: 0; }
|
|
115
|
+
.turn-body a { color: var(--x-accent); text-decoration: none; }
|
|
116
|
+
.turn-body a:hover { text-decoration: underline; }
|
|
117
|
+
.turn-body p { margin: 0.6rem 0; }
|
|
118
|
+
.turn-body img { max-width: 100%; border-radius: 0.4rem; }
|
|
119
|
+
.turn-body code {
|
|
120
|
+
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
|
121
|
+
font-size: 0.88em;
|
|
122
|
+
background: var(--x-note-surface);
|
|
123
|
+
padding: 0.1em 0.35em;
|
|
124
|
+
border-radius: 0.3rem;
|
|
125
|
+
}
|
|
126
|
+
.turn-body pre {
|
|
127
|
+
background: var(--x-note-surface);
|
|
128
|
+
border: 1px solid var(--x-border);
|
|
129
|
+
border-radius: 0.5rem;
|
|
130
|
+
padding: 0.9rem 1rem;
|
|
131
|
+
overflow-x: auto;
|
|
132
|
+
margin: 0.75rem 0;
|
|
133
|
+
}
|
|
134
|
+
.turn-body pre code { background: none; padding: 0; }
|
|
135
|
+
.tool-frame {
|
|
136
|
+
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
|
137
|
+
font-size: 0.85rem;
|
|
138
|
+
background: var(--x-note-surface);
|
|
139
|
+
border: 1px solid var(--x-border);
|
|
140
|
+
border-radius: 0.5rem;
|
|
141
|
+
padding: 0.85rem 1rem;
|
|
142
|
+
margin: 0.5rem 0;
|
|
143
|
+
overflow-x: auto;
|
|
144
|
+
white-space: pre-wrap;
|
|
145
|
+
}
|
|
146
|
+
.aside-note {
|
|
147
|
+
background: var(--x-note-surface);
|
|
148
|
+
border: 1px dashed var(--x-border);
|
|
149
|
+
border-radius: 0.5rem;
|
|
150
|
+
padding: 0.75rem 1rem;
|
|
151
|
+
color: var(--x-ink-muted);
|
|
152
|
+
font-size: 0.9rem;
|
|
153
|
+
}
|
|
154
|
+
.sgr-bold { font-weight: 700; }
|
|
155
|
+
.sgr-dim { opacity: 0.72; }
|
|
156
|
+
.sgr-italic { font-style: italic; }
|
|
157
|
+
.sgr-underline { text-decoration: underline; }
|
|
158
|
+
.sgr-strike { text-decoration: line-through; }
|
|
159
|
+
.sgr-underline.sgr-strike { text-decoration: underline line-through; }
|
|
160
|
+
.sgr-blink { animation: x-blink 1.1s steps(2, start) infinite; }
|
|
161
|
+
.sgr-hidden { visibility: hidden; }
|
|
162
|
+
@keyframes x-blink { 50% { opacity: 0; } }
|
|
163
|
+
.xhl-keyword { color: var(--x-accent); }
|
|
164
|
+
.xhl-string { color: var(--x-user-role); }
|
|
165
|
+
.xhl-comment { color: var(--x-ink-muted); font-style: italic; }
|
|
166
|
+
.xhl-number, .xhl-literal { color: var(--x-tool-role); }
|
|
167
|
+
""".strip()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Client renderer
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
#: The client-side renderer, emitted into the ``SCRIPT`` slot — JavaScript,
|
|
175
|
+
#: kept as JavaScript. Decodes the base64 session payload, walks its turns,
|
|
176
|
+
#: and injects each turn's already-built HTML into the ``.xscript`` root.
|
|
177
|
+
#: Pre-rendered markdown, highlighted fences (the page's own ``xhl-*``
|
|
178
|
+
#: classes), tool widgets, and SGR-painted text all arrive already-HTML in the
|
|
179
|
+
#: payload and are inserted verbatim; a turn that somehow lacks ``html`` falls
|
|
180
|
+
#: back to escaped text.
|
|
181
|
+
CLIENT_SCRIPT: Final[str] = """
|
|
182
|
+
(function () {
|
|
183
|
+
"use strict";
|
|
184
|
+
var root = document.getElementById("xscript-root");
|
|
185
|
+
if (!root) return;
|
|
186
|
+
|
|
187
|
+
var raw;
|
|
188
|
+
try {
|
|
189
|
+
raw = JSON.parse(decodeURIComponent(escape(atob(window.__XSCRIPT_PAYLOAD__ || ""))));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
root.textContent = "Unable to decode transcript payload.";
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function md(text) {
|
|
196
|
+
var div = document.createElement("div");
|
|
197
|
+
div.textContent = text;
|
|
198
|
+
return div.innerHTML;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function el(tag, cls) {
|
|
202
|
+
var node = document.createElement(tag);
|
|
203
|
+
if (cls) node.className = cls;
|
|
204
|
+
return node;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderTurn(turn) {
|
|
208
|
+
if (turn.kind === "note") {
|
|
209
|
+
var note = el("div", "aside-note");
|
|
210
|
+
note.innerHTML = turn.html != null ? turn.html : md(turn.text || "");
|
|
211
|
+
return note;
|
|
212
|
+
}
|
|
213
|
+
var card = el("section", "turn");
|
|
214
|
+
card.setAttribute("data-role", turn.role || "agent");
|
|
215
|
+
var tag = el("span", "turn-tag");
|
|
216
|
+
tag.textContent = turn.label || turn.role || "";
|
|
217
|
+
card.appendChild(tag);
|
|
218
|
+
var body = el("div", "turn-body");
|
|
219
|
+
if (turn.html != null) {
|
|
220
|
+
body.innerHTML = turn.html;
|
|
221
|
+
} else if (turn.frame) {
|
|
222
|
+
var frame = el("div", "tool-frame");
|
|
223
|
+
frame.innerHTML = turn.frame;
|
|
224
|
+
body.appendChild(frame);
|
|
225
|
+
} else {
|
|
226
|
+
body.innerHTML = md(turn.text || "");
|
|
227
|
+
}
|
|
228
|
+
card.appendChild(body);
|
|
229
|
+
return card;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
var turns = Array.isArray(raw.turns) ? raw.turns : [];
|
|
233
|
+
for (var i = 0; i < turns.length; i++) {
|
|
234
|
+
root.appendChild(renderTurn(turns[i]));
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
""".strip()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# The shell
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
#: The HTML page shell carrying every :data:`SHELL_SLOTS` placeholder as a
|
|
245
|
+
#: ``{{TOKEN}}`` marker. :func:`fill` replaces each marker with its computed
|
|
246
|
+
#: value. The structure: a themed ``:root``, the inlined stylesheet, the two
|
|
247
|
+
#: preserved library notices, the base64 payload bound to
|
|
248
|
+
#: ``window.__XSCRIPT_PAYLOAD__``, and the client renderer that hydrates
|
|
249
|
+
#: ``#xscript-root``.
|
|
250
|
+
PAGE_SHELL: Final[str] = """<!doctype html>
|
|
251
|
+
<html lang="en">
|
|
252
|
+
<head>
|
|
253
|
+
<meta charset="utf-8" />
|
|
254
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
255
|
+
<title>Session Transcript</title>
|
|
256
|
+
<style>
|
|
257
|
+
:root {
|
|
258
|
+
{{THEME_VARS}}
|
|
259
|
+
--x-page-surface: {{PAGE_SURFACE}};
|
|
260
|
+
--x-card-surface: {{CARD_SURFACE}};
|
|
261
|
+
--x-note-surface: {{NOTE_SURFACE}};
|
|
262
|
+
}
|
|
263
|
+
{{STYLES}}
|
|
264
|
+
</style>
|
|
265
|
+
</head>
|
|
266
|
+
<body>
|
|
267
|
+
<main class="xscript">
|
|
268
|
+
<header class="xscript-head">
|
|
269
|
+
<h1>Session Transcript</h1>
|
|
270
|
+
<div class="xscript-meta">Self-contained HTML export</div>
|
|
271
|
+
</header>
|
|
272
|
+
<div id="xscript-root"></div>
|
|
273
|
+
</main>
|
|
274
|
+
<script>
|
|
275
|
+
/* {{MARKED_LIB}} */
|
|
276
|
+
</script>
|
|
277
|
+
<script>
|
|
278
|
+
/* {{HIGHLIGHT_LIB}} */
|
|
279
|
+
</script>
|
|
280
|
+
<script>
|
|
281
|
+
window.__XSCRIPT_PAYLOAD__ = "{{PAYLOAD}}";
|
|
282
|
+
</script>
|
|
283
|
+
<script>
|
|
284
|
+
{{SCRIPT}}
|
|
285
|
+
</script>
|
|
286
|
+
</body>
|
|
287
|
+
</html>"""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Fill
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
#: The map a :func:`fill` call supplies — one value per
|
|
295
|
+
#: :data:`~induscode.transcript_export.contract.ShellSlot` token. A value may
|
|
296
|
+
#: be omitted, in which case its placeholder is replaced with the empty string
|
|
297
|
+
#: (so a partial fill leaves no stray ``{{TOKEN}}`` in the output).
|
|
298
|
+
SlotValues: TypeAlias = Mapping[str, str]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def fill(template: str, values: SlotValues) -> str:
|
|
302
|
+
"""Replace every ``{{TOKEN}}`` placeholder in a template with the value
|
|
303
|
+
supplied for that slot, returning the filled string.
|
|
304
|
+
|
|
305
|
+
Walks the known :data:`SHELL_SLOTS` (not an open scan), so only
|
|
306
|
+
sanctioned placeholders are substituted and a stray ``{{…}}`` in user
|
|
307
|
+
content is left untouched. Each token is replaced globally; a slot with no
|
|
308
|
+
supplied value collapses to the empty string. The replacement is
|
|
309
|
+
value-literal (plain ``str.replace``, no pattern language), so nothing in
|
|
310
|
+
a value is ever interpreted.
|
|
311
|
+
|
|
312
|
+
:param template: the shell carrying ``{{TOKEN}}`` markers
|
|
313
|
+
:param values: the per-slot values to splice in
|
|
314
|
+
"""
|
|
315
|
+
out = template
|
|
316
|
+
for token in SHELL_SLOTS.values():
|
|
317
|
+
value = values.get(token, "")
|
|
318
|
+
out = out.replace("{{" + token + "}}", value)
|
|
319
|
+
return out
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""ThemeBridge — the color service behind the HTML transcript export.
|
|
2
|
+
|
|
3
|
+
Port of TS ``src/transcript-export/theme-bridge.ts`` (verbatim logic).
|
|
4
|
+
|
|
5
|
+
Everything the publisher needs to turn an :class:`ExportTheme` token bag into
|
|
6
|
+
concrete page colors lives here: parsing CSS colors into :class:`Rgb`,
|
|
7
|
+
measuring a color's WCAG relative luminance through a precomputed lookup
|
|
8
|
+
table, deciding whether the theme reads :data:`ThemeMode` light or dark,
|
|
9
|
+
deriving lighter/darker surface variants from a base, and projecting the whole
|
|
10
|
+
theme to the CSS custom properties the page shell consumes.
|
|
11
|
+
|
|
12
|
+
Two facts are interface-dictated and kept faithful:
|
|
13
|
+
|
|
14
|
+
- The WCAG relative-luminance formula — linearize each sRGB channel with the
|
|
15
|
+
gamma piecewise curve, then weight ``0.2126 R + 0.7152 G + 0.0722 B``. The
|
|
16
|
+
per-channel linearization is precomputed once into
|
|
17
|
+
:func:`build_luminance_lut` (a :data:`LuminanceLut`) so a luminance read is
|
|
18
|
+
three table lookups plus a weighted sum, never a ``pow`` per pixel.
|
|
19
|
+
- Readable foreground selection — given a background, pick the foreground
|
|
20
|
+
(from the theme's own ink choices) whose contrast against that background is
|
|
21
|
+
the larger, so text on any derived surface stays legible.
|
|
22
|
+
|
|
23
|
+
Every default color the bridge falls back to is this rebuild's own
|
|
24
|
+
:data:`FALLBACK_EXPORT_THEME` (a deep-slate palette), never a legacy export's
|
|
25
|
+
magic constant.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
import re
|
|
32
|
+
from typing import Final, Mapping
|
|
33
|
+
|
|
34
|
+
from .contract import (
|
|
35
|
+
FALLBACK_EXPORT_THEME,
|
|
36
|
+
ExportTheme,
|
|
37
|
+
LuminanceLut,
|
|
38
|
+
Rgb,
|
|
39
|
+
ThemeMode,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DefaultThemeBridge",
|
|
44
|
+
"build_luminance_lut",
|
|
45
|
+
"create_theme_bridge",
|
|
46
|
+
"format_color",
|
|
47
|
+
"parse_color",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Luminance lookup table
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_luminance_lut() -> LuminanceLut:
|
|
57
|
+
"""Build the 256-entry sRGB→linear lookup table the WCAG
|
|
58
|
+
relative-luminance formula needs for each channel.
|
|
59
|
+
|
|
60
|
+
For a channel value ``v`` in ``0–255``, normalize to ``s = v / 255``, then
|
|
61
|
+
apply the standard gamma-expansion: ``s / 12.92`` when ``s ≤ 0.03928``,
|
|
62
|
+
else ``((s + 0.055) / 1.055) ** 2.4``. The table holds the linearized
|
|
63
|
+
value at each integer channel so a luminance read avoids the per-call
|
|
64
|
+
``pow``. The math is the published standard's; the table is this
|
|
65
|
+
module's.
|
|
66
|
+
"""
|
|
67
|
+
lut: list[float] = []
|
|
68
|
+
for v in range(256):
|
|
69
|
+
s = v / 255
|
|
70
|
+
lut.append(s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4)
|
|
71
|
+
return tuple(lut)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
#: The shared, module-level luminance table — built once, read everywhere.
|
|
75
|
+
_LUMINANCE_LUT: Final[LuminanceLut] = build_luminance_lut()
|
|
76
|
+
|
|
77
|
+
#: The luminance midpoint that separates a :data:`ThemeMode` ``light`` page
|
|
78
|
+
#: from a ``dark`` one. A page surface at or above this reads light; below it
|
|
79
|
+
#: reads dark. The value is the perceptual midpoint of the 0–1
|
|
80
|
+
#: relative-luminance range.
|
|
81
|
+
_MODE_THRESHOLD: Final[float] = 0.18
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Color parsing & formatting
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expand_short_hex(body: str) -> str:
|
|
90
|
+
"""Expand a 3- or 4-digit shorthand hex body to its 6-digit form."""
|
|
91
|
+
if len(body) in (3, 4):
|
|
92
|
+
return "".join(c + c for c in body[:3])
|
|
93
|
+
return body[:6]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: A full six-digit lowercase hex body.
|
|
97
|
+
_HEX_BODY = re.compile(r"^[0-9a-f]{6}$")
|
|
98
|
+
|
|
99
|
+
#: The functional ``rgb(...)`` / ``rgba(...)`` notation.
|
|
100
|
+
_RGB_FN = re.compile(r"^rgba?\(([^)]+)\)$")
|
|
101
|
+
|
|
102
|
+
#: Leading decimal-number prefix of one channel field (the TS port used
|
|
103
|
+
#: ``parseFloat``, which reads the numeric prefix and ignores the rest).
|
|
104
|
+
_FLOAT_PREFIX = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_float_prefix(text: str) -> float | None:
|
|
108
|
+
"""Read the leading decimal number of a string, or ``None`` when the
|
|
109
|
+
string carries no numeric prefix (the ``parseFloat`` NaN case)."""
|
|
110
|
+
m = _FLOAT_PREFIX.match(text)
|
|
111
|
+
return float(m.group(0)) if m is not None else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_color(color: str) -> Rgb | None:
|
|
115
|
+
"""Parse a CSS color string into its :class:`Rgb` channels. Recognizes
|
|
116
|
+
``#rgb``, ``#rrggbb`` (and their alpha-bearing ``#rgba`` / ``#rrggbbaa``
|
|
117
|
+
forms, alpha dropped) and the functional ``rgb(r, g, b)`` /
|
|
118
|
+
``rgba(r, g, b, a)`` notations. Any unrecognized input resolves to
|
|
119
|
+
``None``, which the bridge treats as "fall back to the theme default"
|
|
120
|
+
rather than raising.
|
|
121
|
+
|
|
122
|
+
:param color: a CSS color string
|
|
123
|
+
"""
|
|
124
|
+
text = color.strip().lower()
|
|
125
|
+
|
|
126
|
+
if text.startswith("#"):
|
|
127
|
+
body = _expand_short_hex(text[1:])
|
|
128
|
+
if _HEX_BODY.match(body) is None:
|
|
129
|
+
return None
|
|
130
|
+
return Rgb(
|
|
131
|
+
r=int(body[0:2], 16),
|
|
132
|
+
g=int(body[2:4], 16),
|
|
133
|
+
b=int(body[4:6], 16),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
fn = _RGB_FN.match(text)
|
|
137
|
+
if fn is not None:
|
|
138
|
+
parts = [p for p in re.split(r"[,\s/]+", fn.group(1)) if len(p) > 0]
|
|
139
|
+
if len(parts) < 3:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def channel(p: str) -> int:
|
|
143
|
+
n = (
|
|
144
|
+
(_parse_float_prefix(p) or 0.0) / 100 * 255
|
|
145
|
+
if p.endswith("%")
|
|
146
|
+
else _parse_float_prefix(p)
|
|
147
|
+
)
|
|
148
|
+
return 0 if n is None else _clamp_byte(n)
|
|
149
|
+
|
|
150
|
+
return Rgb(r=channel(parts[0]), g=channel(parts[1]), b=channel(parts[2]))
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _clamp_byte(value: float) -> int:
|
|
156
|
+
"""Clamp and round a number into the 0–255 integer channel range.
|
|
157
|
+
|
|
158
|
+
Rounding is the half-up rule (the TS port's ``Math.round``), not Python's
|
|
159
|
+
banker's rounding — ``0.5`` fractions always step toward the brighter
|
|
160
|
+
channel.
|
|
161
|
+
"""
|
|
162
|
+
if value <= 0:
|
|
163
|
+
return 0
|
|
164
|
+
if value >= 255:
|
|
165
|
+
return 255
|
|
166
|
+
return math.floor(value + 0.5)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_color(rgb: Rgb) -> str:
|
|
170
|
+
"""Render an :class:`Rgb` back to a ``#rrggbb`` CSS string."""
|
|
171
|
+
|
|
172
|
+
def hex_pair(v: int) -> str:
|
|
173
|
+
return format(_clamp_byte(v), "02x")
|
|
174
|
+
|
|
175
|
+
return f"#{hex_pair(rgb.r)}{hex_pair(rgb.g)}{hex_pair(rgb.b)}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Luminance & contrast
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _luminance_of(rgb: Rgb, lut: LuminanceLut) -> float:
|
|
184
|
+
"""Compute the WCAG relative luminance (0–1) of parsed channels, reading
|
|
185
|
+
each channel's linearized value from the supplied :data:`LuminanceLut` and
|
|
186
|
+
combining them with the standard ``0.2126 / 0.7152 / 0.0722`` weights.
|
|
187
|
+
"""
|
|
188
|
+
lr = lut[_clamp_byte(rgb.r)]
|
|
189
|
+
lg = lut[_clamp_byte(rgb.g)]
|
|
190
|
+
lb = lut[_clamp_byte(rgb.b)]
|
|
191
|
+
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _contrast_ratio(a: float, b: float) -> float:
|
|
195
|
+
"""The WCAG contrast ratio between two luminances,
|
|
196
|
+
``(L1 + 0.05) / (L2 + 0.05)`` with the brighter as ``L1``. Ranges from 1
|
|
197
|
+
(identical) to 21 (black vs. white).
|
|
198
|
+
"""
|
|
199
|
+
hi = max(a, b)
|
|
200
|
+
lo = min(a, b)
|
|
201
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Surface derivation
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _step_channels(rgb: Rgb, amount: float) -> Rgb:
|
|
210
|
+
"""Step every channel of a base color by a signed amount, clamping to the
|
|
211
|
+
byte range. A positive amount lightens (toward white), a negative one
|
|
212
|
+
darkens (toward black). The step is linear in 0–255 space — simple and
|
|
213
|
+
predictable for deriving the small surface deltas the page uses.
|
|
214
|
+
"""
|
|
215
|
+
return Rgb(
|
|
216
|
+
r=_clamp_byte(rgb.r + amount),
|
|
217
|
+
g=_clamp_byte(rgb.g + amount),
|
|
218
|
+
b=_clamp_byte(rgb.b + amount),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# The bridge
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
#: The token names of an :class:`ExportTheme`, in the order the page shell
|
|
227
|
+
#: expects them as CSS custom properties. Kept as one list so
|
|
228
|
+
#: :class:`DefaultThemeBridge`'s ``to_css_vars`` walks a single source.
|
|
229
|
+
_THEME_TOKENS: Final[tuple[str, ...]] = (
|
|
230
|
+
"pageSurface",
|
|
231
|
+
"cardSurface",
|
|
232
|
+
"noteSurface",
|
|
233
|
+
"ink",
|
|
234
|
+
"inkMuted",
|
|
235
|
+
"accent",
|
|
236
|
+
"border",
|
|
237
|
+
"userRole",
|
|
238
|
+
"agentRole",
|
|
239
|
+
"toolRole",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class DefaultThemeBridge:
|
|
244
|
+
"""The concrete :class:`~induscode.transcript_export.contract.ThemeBridge`.
|
|
245
|
+
|
|
246
|
+
Holds a resolved :class:`ExportTheme` and a :data:`LuminanceLut`, and
|
|
247
|
+
serves every color query the publisher makes against them. Pure with
|
|
248
|
+
respect to its inputs — construction resolves the theme once and the
|
|
249
|
+
methods only read.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self, theme: ExportTheme | None = None, lut: LuminanceLut | None = None
|
|
254
|
+
) -> None:
|
|
255
|
+
""":param theme: an :class:`ExportTheme` to serve; defaults to the
|
|
256
|
+
rebuild's own :data:`FALLBACK_EXPORT_THEME`.
|
|
257
|
+
:param lut: the luminance lookup table to read; defaults to the shared
|
|
258
|
+
module-level table.
|
|
259
|
+
"""
|
|
260
|
+
# The resolved export theme this bridge serves.
|
|
261
|
+
self.theme: ExportTheme = theme if theme is not None else FALLBACK_EXPORT_THEME
|
|
262
|
+
self._lut: LuminanceLut = lut if lut is not None else _LUMINANCE_LUT
|
|
263
|
+
|
|
264
|
+
def luminance(self, color: str) -> float:
|
|
265
|
+
"""The WCAG relative luminance (0–1) of a CSS color; unparseable
|
|
266
|
+
input reads 0."""
|
|
267
|
+
rgb = parse_color(color)
|
|
268
|
+
return 0.0 if rgb is None else _luminance_of(rgb, self._lut)
|
|
269
|
+
|
|
270
|
+
def mode(self) -> ThemeMode:
|
|
271
|
+
"""Whether the theme's page surface reads light or dark."""
|
|
272
|
+
return "light" if self.luminance(self.theme.pageSurface) >= _MODE_THRESHOLD else "dark"
|
|
273
|
+
|
|
274
|
+
def derive_surface(self, base: str, amount: float) -> str:
|
|
275
|
+
"""Derive a surface variant from a base color. The ``amount`` is a
|
|
276
|
+
signed lightness delta in channel space; the sign is honored directly
|
|
277
|
+
so a caller can lighten (``+``) or darken (``−``) explicitly. When
|
|
278
|
+
``amount`` is ``0`` the base is returned formatted but unchanged.
|
|
279
|
+
Unparseable bases fall through to the theme's card surface so a
|
|
280
|
+
derivation never yields an empty string.
|
|
281
|
+
|
|
282
|
+
:param base: the base CSS color to adjust
|
|
283
|
+
:param amount: signed lightness delta (positive lightens, negative
|
|
284
|
+
darkens)
|
|
285
|
+
"""
|
|
286
|
+
rgb = parse_color(base)
|
|
287
|
+
if rgb is None:
|
|
288
|
+
return self.theme.cardSurface
|
|
289
|
+
return format_color(_step_channels(rgb, amount))
|
|
290
|
+
|
|
291
|
+
def readable_ink(self, background: str) -> str:
|
|
292
|
+
"""Pick the more readable of the theme's two ink colors for a given
|
|
293
|
+
background by comparing each ink's WCAG contrast against it and
|
|
294
|
+
returning the higher. This is the bridge's own readable-foreground
|
|
295
|
+
chooser — used when text sits on a derived surface whose lightness is
|
|
296
|
+
not known ahead of time.
|
|
297
|
+
|
|
298
|
+
:param background: the surface the text will sit on
|
|
299
|
+
"""
|
|
300
|
+
bg = self.luminance(background)
|
|
301
|
+
ink = self.luminance(self.theme.ink)
|
|
302
|
+
muted = self.luminance(self.theme.inkMuted)
|
|
303
|
+
return (
|
|
304
|
+
self.theme.ink
|
|
305
|
+
if _contrast_ratio(ink, bg) >= _contrast_ratio(muted, bg)
|
|
306
|
+
else self.theme.inkMuted
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def to_css_vars(self) -> Mapping[str, str]:
|
|
310
|
+
"""Project the theme to its CSS custom properties, keyed by token
|
|
311
|
+
name."""
|
|
312
|
+
css_vars: dict[str, str] = {}
|
|
313
|
+
for token in _THEME_TOKENS:
|
|
314
|
+
css_vars[token] = getattr(self.theme, token)
|
|
315
|
+
return css_vars
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def create_theme_bridge(theme: ExportTheme | None = None) -> DefaultThemeBridge:
|
|
319
|
+
"""Build a theme bridge over an optional :class:`ExportTheme`. The
|
|
320
|
+
convenience factory the publisher calls; omitting the theme yields a
|
|
321
|
+
bridge over the rebuild's own fallback palette.
|
|
322
|
+
|
|
323
|
+
:param theme: an export palette to serve, or ``None`` for the fallback
|
|
324
|
+
"""
|
|
325
|
+
return DefaultThemeBridge(theme if theme is not None else FALLBACK_EXPORT_THEME)
|