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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. 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)