pi-declaw 0.2.0

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 (70) hide show
  1. package/ARCHITECTURE.md +90 -0
  2. package/GLOSSARY.md +48 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +30 -0
  5. package/PLUGIN_GUIDE.md +142 -0
  6. package/README.md +161 -0
  7. package/SOURCES.md +139 -0
  8. package/index.ts +2 -0
  9. package/licenses/asd-ste100-skill-LICENSE +21 -0
  10. package/licenses/i-have-adhd-LICENSE +21 -0
  11. package/licenses/speak-like-you-eat-LICENSE +9 -0
  12. package/licenses/squirrel-mode-LICENSE +21 -0
  13. package/package.json +63 -0
  14. package/playground/README.md +64 -0
  15. package/playground/app.js +123 -0
  16. package/playground/assets/declaw-e.png +0 -0
  17. package/playground/assets/declaw-favicon.png +0 -0
  18. package/playground/assets/declaw-wordmark-charcoal.png +0 -0
  19. package/playground/assets/declaw-wordmark-white.png +0 -0
  20. package/playground/assets/geist.woff2 +0 -0
  21. package/playground/assets/licenses/Claudish-MIT.txt +21 -0
  22. package/playground/assets/licenses/Geist-NOTICE.txt +4 -0
  23. package/playground/assets/licenses/Geist-OFL.txt +93 -0
  24. package/playground/assets/licenses/asd-ste100-skill-LICENSE +21 -0
  25. package/playground/assets/licenses/i-have-adhd-LICENSE +21 -0
  26. package/playground/assets/licenses/paseo-plain-LICENSE +211 -0
  27. package/playground/assets/licenses/squirrel-mode-LICENSE +21 -0
  28. package/playground/build.ts +180 -0
  29. package/playground/credits.html +14 -0
  30. package/playground/diff.ts +65 -0
  31. package/playground/evidence.css +1 -0
  32. package/playground/index.html +96 -0
  33. package/playground/inline-diff.ts +131 -0
  34. package/playground/markdown.ts +239 -0
  35. package/playground/samples.json +244 -0
  36. package/playground/serve.ts +21 -0
  37. package/playground/styles.css +30 -0
  38. package/playground/unified.ts +136 -0
  39. package/src/adapters/command.ts +203 -0
  40. package/src/adapters/model.ts +53 -0
  41. package/src/adapters/pi.ts +56 -0
  42. package/src/adapters/settings.ts +117 -0
  43. package/src/application/rewrite.ts +39 -0
  44. package/src/domain/preservation.ts +62 -0
  45. package/src/domain/rewrite.ts +55 -0
  46. package/src/domain/styles.ts +169 -0
  47. package/src/extension.ts +50 -0
  48. package/src/plugin-api.ts +88 -0
  49. package/src/plugins/built-in/asd-ste100/plugin.ts +19 -0
  50. package/src/plugins/built-in/asd-ste100/prompt.ts +24 -0
  51. package/src/plugins/built-in/catalog.ts +17 -0
  52. package/src/plugins/built-in/i-have-adhd/plugin.ts +19 -0
  53. package/src/plugins/built-in/i-have-adhd/prompt.ts +20 -0
  54. package/src/plugins/built-in/index.ts +17 -0
  55. package/src/plugins/built-in/paseo-plain/plugin.ts +26 -0
  56. package/src/plugins/built-in/paseo-plain/upstream/Claudish-MIT.txt +21 -0
  57. package/src/plugins/built-in/paseo-plain/upstream/LICENSE +211 -0
  58. package/src/plugins/built-in/paseo-plain/upstream/NOTICE +16 -0
  59. package/src/plugins/built-in/paseo-plain/upstream/prompt.ts +351 -0
  60. package/src/plugins/built-in/paseo-plain/upstream/rewriter.ts +46 -0
  61. package/src/plugins/built-in/shared/adapted-rules.ts +28 -0
  62. package/src/plugins/built-in/shared/json-payload.ts +5 -0
  63. package/src/plugins/built-in/speak-like-you-eat/payload.ts +5 -0
  64. package/src/plugins/built-in/speak-like-you-eat/plugin.ts +26 -0
  65. package/src/plugins/built-in/speak-like-you-eat/prompt.ts +4 -0
  66. package/src/plugins/built-in/speak-like-you-eat/upstream/model-rewrite.ts +19 -0
  67. package/src/plugins/built-in/squirrel-mode/plugin.ts +19 -0
  68. package/src/plugins/built-in/squirrel-mode/prompt.ts +24 -0
  69. package/src/plugins/built-in/terse/plugin.ts +18 -0
  70. package/src/plugins/built-in/terse/prompt.ts +9 -0
@@ -0,0 +1,244 @@
1
+ {
2
+ "version": 1,
3
+ "recordedAt": "2026-09-11T20:12:57.434Z",
4
+ "runId": "2026-09-11T20-12-57-433Z-09f4f46a",
5
+ "model": "openai-codex/gpt-5.6-luna",
6
+ "thinking": "low",
7
+ "promptVersion": "plain-lab-2",
8
+ "synthetic": true,
9
+ "modes": [
10
+ {
11
+ "id": "plain",
12
+ "label": "Paseo Plain",
13
+ "description": "Connected prose, everyday words.",
14
+ "source": { "label": "scowalt/paseo-plain", "url": "https://github.com/scowalt/paseo-plain", "relationship": "Inspired by" }
15
+ },
16
+ {
17
+ "id": "terse",
18
+ "label": "Terse",
19
+ "description": "Compact phrasing, less ceremony.",
20
+ "source": { "label": "Terse", "relationship": "Local preset" }
21
+ },
22
+ {
23
+ "id": "adhd",
24
+ "label": "I Have ADHD",
25
+ "description": "Actions and blockers brought forward.",
26
+ "source": { "label": "ayghri/i-have-adhd", "url": "https://github.com/ayghri/i-have-adhd", "relationship": "Adapted from" }
27
+ },
28
+ {
29
+ "id": "squirrel",
30
+ "label": "Squirrel Mode",
31
+ "description": "Small, separately readable sections.",
32
+ "source": { "label": "thgMatajs/squirrel-mode", "url": "https://github.com/thgMatajs/squirrel-mode", "relationship": "Adapted from" }
33
+ },
34
+ {
35
+ "id": "ste",
36
+ "label": "ASD-STE100",
37
+ "description": "Consistent terms and explicit conditions. STE-inspired.",
38
+ "source": { "label": "danyuchn/asd-ste100-skill", "url": "https://github.com/danyuchn/asd-ste100-skill", "relationship": "Adapted from" }
39
+ }
40
+ ],
41
+ "cases": [
42
+ {
43
+ "id": "local-vs-prod",
44
+ "label": "Status update",
45
+ "title": "Local success is not production readiness",
46
+ "sourceTitle": "Local success does not establish production readiness",
47
+ "request": "Does the successful preview test mean the invoice export fix is ready for production?",
48
+ "original": "## Verification boundary\nThe current evidence supports a narrower conclusion than production readiness. I ran `pnpm test:export` against the local fixture database, and the export now includes the previously missing tax column. The generated file at `tmp/invoice-preview.csv` also opened successfully in the local preview tool.\n\nThose checks establish the behavior of the fixture-backed path; they do not establish that the production credentials, storage permissions, or larger customer datasets will behave the same way. No staging or production request was made, and no remote configuration was changed.\n\nBefore calling this ready, we still need a staging export using the deployed service identity and a representative large dataset. That is a proposed verification step, not a completed test. I have also not checked whether older spreadsheet templates accept the additional column, so compatibility with those templates remains unresolved.",
49
+ "mustKeep": [
50
+ "pnpm test:export",
51
+ "tmp/invoice-preview.csv"
52
+ ],
53
+ "reviewFacts": [
54
+ "The local fixture-backed export test passed and the missing tax column is now present.",
55
+ "The local preview file opened successfully.",
56
+ "No staging or production request was made and no remote configuration changed.",
57
+ "Staging identity and large-dataset verification are proposed, not completed.",
58
+ "Compatibility with older spreadsheet templates remains unchecked."
59
+ ],
60
+ "pitfalls": [
61
+ "Do not turn local test success into production readiness or a successful staging test.",
62
+ "Do not present the untested credentials, permissions, dataset size, or template compatibility as confirmed failures.",
63
+ "Do not drop the separate spreadsheet-template uncertainty when compressing the answer."
64
+ ],
65
+ "variants": [
66
+ {
67
+ "mode": "plain",
68
+ "text": "I ran `pnpm test:export` against the local fixture database. The export now includes the previously missing tax column, and `tmp/invoice-preview.csv` opened successfully in the local preview tool.\n\nThese checks confirm the fixture-backed path, but not production readiness. They do not show that production credentials, storage permissions, or larger customer datasets will behave the same way. No staging or production request was made, and no remote configuration was changed.\n\nBefore this is considered ready, it still needs a staging export using the deployed service identity and a representative large dataset. That is a proposed verification step, not a completed test. Compatibility with older spreadsheet templates is also unresolved because it has not been checked."
69
+ },
70
+ {
71
+ "mode": "terse",
72
+ "text": "## Verification boundary\n\nThe local fixture-backed checks passed: `pnpm test:export` now includes the previously missing tax column, and `tmp/invoice-preview.csv` opened in the local preview tool.\n\nThis does **not** establish production readiness. Production credentials, storage permissions, and larger customer datasets are unverified. No staging or production request was made, and no remote configuration changed.\n\nStill required before calling it ready:\n\n- A staging export using the deployed service identity and a representative large dataset. This is proposed, not completed.\n- Verification that older spreadsheet templates accept the additional column; compatibility remains unresolved."
73
+ },
74
+ {
75
+ "mode": "adhd",
76
+ "text": "## Before calling this ready\n\n1. Run a staging export using the deployed service identity and a representative large dataset.\n2. Check whether older spreadsheet templates accept the additional tax column.\n\nThese are proposed verification steps, not completed tests.\n\n## Evidence completed\n\n- `pnpm test:export` passed against the local fixture database.\n- The export includes the previously missing tax column.\n- `tmp/invoice-preview.csv` opened successfully in the local preview tool.\n\nThese checks establish behavior only for the fixture-backed path. They do not establish that production credentials, storage permissions, or larger customer datasets will behave the same way.\n\nNo staging or production request was made, and no remote configuration was changed. Compatibility with older spreadsheet templates remains unresolved."
77
+ },
78
+ {
79
+ "mode": "squirrel",
80
+ "text": "## Decision\n\nNo. The successful local preview test does not establish that the invoice export fix is ready for production.\n\n1. `pnpm test:export` passed against the local fixture database.\n2. The export now includes the previously missing tax column.\n3. `tmp/invoice-preview.csv` opened successfully in the local preview tool.\n4. These checks cover only the fixture-backed path.\n5. No staging or production request was made, and no remote configuration was changed.\n\n## Remaining verification\n\nBefore calling this ready, run a staging export using the deployed service identity and a representative large dataset.\n\nProduction credentials, storage permissions, and larger customer datasets may behave differently. Compatibility with older spreadsheet templates is also unresolved because it has not been checked."
81
+ },
82
+ {
83
+ "mode": "ste",
84
+ "text": "## Verification boundary\n\nThe successful local preview test does not show that the invoice export fix is ready for production.\n\nI ran `pnpm test:export` against the local fixture database. The export now includes the previously missing tax column. The generated file, `tmp/invoice-preview.csv`, opened successfully in the local preview tool.\n\nThese checks establish behavior for the fixture-backed path only. They do not establish that production credentials, storage permissions, or larger customer datasets will behave the same way.\n\nNo staging or production request was made. No remote configuration was changed.\n\nBefore approval, a staging export still needs testing with the deployed service identity and a representative large dataset. This is a proposed verification step, not a completed test.\n\nCompatibility with older spreadsheet templates remains unresolved because it has not been checked."
85
+ }
86
+ ]
87
+ },
88
+ {
89
+ "id": "verbose-explanation",
90
+ "label": "Long-form prose",
91
+ "title": "When caching is the wrong first move",
92
+ "sourceTitle": "Long-form explanation of a caching decision",
93
+ "request": "Why not put a distributed cache in front of the reports page now? A delay of up to 15 minutes is acceptable for displayed report data, but changes to access permissions must take effect immediately. Explain the reasoning and what would change your recommendation.",
94
+ "original": "There are two different questions wrapped up in the suggestion to cache the reports page, and it is useful to separate them before choosing an implementation. One is whether previously calculated report data can be reused without violating the freshness expectation. The other is whether introducing a separate distributed service is necessary to get that reuse. Accepting data that is up to `15 minutes` old makes reuse a possibility, but it does not, by itself, establish that a distributed cache is the appropriate first change. We have not measured which part of the current request consumes the most time. The delay could come from the report query, from preparing the response, or from work in the browser, and those possibilities would not all benefit from the same intervention.\n\nThere is also a boundary between the freshness of the report and the freshness of the decision about who may read it. The accepted delay applies to report data, not to access permissions. Even if the data comes from a cache, the request must use the current access decision before returning it. Separating cached entries by organization would reduce the chance of mixing organizations' data, but that separation would not replace authorization. A person whose access was removed must not continue to receive the report simply because a cached result still exists. None of this establishes that the current implementation has such a bug; it describes a requirement any proposed cache would have to satisfy.\n\nMy recommendation is therefore to measure a representative request before adding the distributed service. If the report query dominates, and equivalent requests recur often enough to make reuse useful, a cache becomes a reasonable candidate. If response preparation or browser work dominates instead, reusing query results may leave the main delay untouched. A distributed cache would also introduce expiration behavior, cache-miss handling, and another service to operate. Those are tradeoffs to evaluate, not proof that caching would be a mistake.\n\nThe measurement has not been run, and neither a cache nor an authorization change has been implemented. I would reconsider the recommendation once we know where the delay occurs and whether repeated requests actually share reusable results. Until then, the freshness allowance supports investigating caching; it is not evidence that caching will improve this page.",
95
+ "mustKeep": [
96
+ "15 minutes"
97
+ ],
98
+ "reviewFacts": [
99
+ "Accepting report data up to 15 minutes old permits investigating reuse, but does not establish a need for a distributed cache.",
100
+ "The main source of request time is unmeasured; report querying, response preparation, and browser work are separate candidates.",
101
+ "The freshness allowance does not apply to permissions: a cached response still requires the current access decision.",
102
+ "Organization-separated cache entries are not a substitute for authorization, and removed access must take effect even when cached data exists.",
103
+ "The security discussion is a proposed-design requirement, not evidence of a current bug.",
104
+ "The recommendation is to measure a representative request before introducing the distributed service.",
105
+ "Query-dominated delay and sufficient recurrence of equivalent requests would make caching a reasonable candidate; other bottlenecks might remain unaffected.",
106
+ "Expiration, cache misses, and operating another service are distinct tradeoffs, not proof against caching.",
107
+ "No measurement, cache implementation, or authorization change has occurred.",
108
+ "The recommendation would be reconsidered using measured delay and evidence of reusable results, not the freshness allowance alone."
109
+ ],
110
+ "pitfalls": [
111
+ "Do not turn permission to return older report data into permission to use stale authorization.",
112
+ "Do not claim that a bottleneck or an access-control bug has been found.",
113
+ "Do not turn a conditional caching recommendation into either a blanket rejection or approval.",
114
+ "Do not omit one of the possible bottlenecks or operating tradeoffs just because the source is verbose.",
115
+ "Do not replace the source's explanation with new implementation steps, a cache technology choice, or invented timing results."
116
+ ],
117
+ "variants": [
118
+ {
119
+ "mode": "plain",
120
+ "text": "Caching the reports page raises two separate questions: whether report data can be reused while being up to `15 minutes` old, and whether a distributed service is necessary. The freshness allowance makes reuse possible, but we have not measured whether the delay comes from the report query, response preparation, or browser work. Only the first would clearly benefit from reusing query results.\n\nReport freshness does not relax authorization. Each request must use the current access decision before returning cached data. Separating entries by organization can reduce the risk of mixing data, but it does not replace authorization; someone whose access was removed must not receive a cached report. This is a requirement for any cache, not evidence that the current implementation has this bug.\n\nI recommend measuring a representative request first. If the query dominates and equivalent requests recur often enough, a cache becomes reasonable. If response preparation or browser work dominates, cached query results may not address the main delay. A distributed cache would also add expiration behavior, cache-miss handling, and another service to operate.\n\nThe measurement has not been run, and neither caching nor an authorization change has been implemented. The `15 minutes` allowance supports investigating caching, but does not show that caching will improve the page."
121
+ },
122
+ {
123
+ "mode": "terse",
124
+ "text": "There are two separate questions:\n\n1. Can previously calculated report data be reused within the allowed `15 minutes`?\n2. Is a distributed cache necessary to achieve that?\n\nThe freshness allowance makes reuse possible, but does not show that a distributed cache is the right first change. We have not measured whether the delay comes from the report query, response preparation, or browser work. Only the first may benefit directly from cached query results.\n\nAuthorization must remain current even when report data is cached. Entries should be separated by organization to reduce cross-organization mixing, but that does not replace authorization. Someone whose access was removed must not receive the report from a stale cache. This is a requirement, not evidence that the current implementation has that bug.\n\nMeasure a representative request first. Caching becomes a reasonable candidate if the report query dominates and equivalent requests recur often enough. If response preparation or browser work dominates, cached query results may not address the main delay. A distributed cache also adds expiration behavior, cache-miss handling, and another service to operate.\n\nNo measurement, cache, or authorization change has been implemented. The recommendation should be revisited after measuring the delay and determining whether requests share reusable results. The `15 minutes` allowance supports investigating caching; it does not establish that caching will improve the page."
125
+ },
126
+ {
127
+ "mode": "adhd",
128
+ "text": "Measure a representative reports-page request before adding a distributed cache.\n\n1. Identify where the delay occurs:\n - Report query\n - Response preparation\n - Browser work\n\n The measurement has not been run. These causes would not all benefit from caching query results.\n\n2. Check whether equivalent requests recur often enough for reuse to help.\n\n3. Preserve the security boundary:\n - Report data may be up to `15 minutes` old.\n - Access permissions must be checked currently before returning cached data.\n - Entries should be separated by organization to reduce the risk of mixing data, but that does not replace authorization.\n - A person whose access was removed must not receive the report because a cached result still exists.\n\nIf the query dominates and reusable requests recur, a cache becomes a reasonable candidate. If response preparation or browser work dominates, query-result reuse may not address the main delay.\n\nA distributed cache would add expiration behavior, cache-miss handling, and another service to operate. These are tradeoffs, not proof that caching is wrong. Neither a cache nor an authorization change has been implemented. The `15 minutes` allowance supports investigating caching; it does not establish that caching will improve the page."
129
+ },
130
+ {
131
+ "mode": "squirrel",
132
+ "text": "## Recommendation\n\nDo not add the distributed cache yet. First measure a representative request to identify whether the delay comes mainly from:\n\n1. The report query.\n2. Response preparation.\n3. Browser work.\n\nThe measurement has not been run.\n\n## Security and freshness\n\n1. Report data may be up to `15 minutes` old.\n2. Access permissions must be evaluated using the current decision on every request.\n3. A cached result must not be returned to someone whose access was removed.\n4. Separating entries by organization can reduce accidental data mixing, but it does not replace authorization.\n5. This is a requirement for any cache; it does not establish that the current implementation has an authorization bug.\n\n## What would change the recommendation\n\nCaching becomes a reasonable candidate if:\n\n1. The report query is the main source of delay.\n2. Equivalent requests recur often enough to reuse results.\n3. The cache can handle expiration and cache misses safely.\n\nIf response preparation or browser work dominates, reusing query results may not improve the main delay.\n\nA distributed cache would also add expiration behavior, cache-miss handling, and another service to operate. These are tradeoffs to evaluate, not proof that caching would be a mistake."
133
+ },
134
+ {
135
+ "mode": "ste",
136
+ "text": "There are two separate questions:\n\n1. Can previously calculated report data be reused within the accepted `15 minutes` freshness window?\n2. Is a separate distributed service necessary for that reuse?\n\nThe freshness allowance makes caching possible, but it does not show that a distributed cache is the right first change. We have not measured the current delay. The report query, response preparation, or browser work may be responsible, and caching would not help all of them.\n\nCached report data must not delay access decisions. Each request must use the current authorization result before returning data. Separating entries by organization can reduce cross-organization mixing, but it does not replace authorization. A person whose access was removed must not receive a cached report. This requirement does not establish that the current implementation has this bug.\n\nMy recommendation is to measure a representative request before adding the distributed service.\n\n- If the report query dominates and equivalent requests recur often, caching becomes a reasonable candidate.\n- If response preparation or browser work dominates, cached query results may not reduce the main delay.\n\nA distributed cache would also add expiration behavior, cache-miss handling, and another service to operate. These are tradeoffs, not proof that caching would be a mistake.\n\nThe measurement has not been run. We have implemented neither a cache nor an authorization change. I would reconsider the recommendation after confirming where the delay occurs and whether repeated requests share reusable results. Until then, the freshness allowance supports investigating caching, but does not show that caching will improve the page."
137
+ }
138
+ ]
139
+ },
140
+ {
141
+ "id": "ordered-recovery",
142
+ "label": "Recovery procedure",
143
+ "title": "Recover safely, in the right order",
144
+ "sourceTitle": "A recovery procedure whose order and branches matter",
145
+ "request": "Write the operator procedure for recovering the paused local indexer without dropping queued work.",
146
+ "original": "## Local recovery procedure\nUse this sequence for the local indexer only. Keep incoming work paused until verification is complete; successful process startup is not sufficient evidence of recovery.\n\n1. Run `indexctl pause --local` and confirm that the status reports paused. If it does not, stop here rather than continuing with a moving queue.\n2. Save the recovery inputs:\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending item count shown by `indexctl status --local`. If the checkpoint cannot be copied, stop and investigate before restarting.\n3. Run `indexctl restart --local`. Do not delete the queue directory as part of this step.\n4. While intake remains paused, verify the recovered queue:\n - Check that the pending item count matches the recorded count.\n - If it differs, leave intake paused and compare the saved checkpoint with the current one; do not resume automatically.\n5. Only after the counts match, run `indexctl resume --local`.\n\nRetain the backup until the resumed indexer has processed the pending work. These are instructions, not a report that recovery has already been performed.",
147
+ "mustKeep": [
148
+ "indexctl pause --local",
149
+ "var/index/checkpoint.json",
150
+ "var/index/checkpoint.backup.json",
151
+ "indexctl status --local",
152
+ "indexctl restart --local",
153
+ "indexctl resume --local"
154
+ ],
155
+ "reviewFacts": [
156
+ "The procedure is for the local indexer and requires confirmed pause before continuing.",
157
+ "The checkpoint is backed up and the pending count recorded before restart; backup failure stops the procedure.",
158
+ "The queue directory must not be deleted.",
159
+ "Intake remains paused through verification; a count mismatch requires investigation without automatic resume.",
160
+ "Resume occurs only after matching counts, and the backup is retained until pending work is processed.",
161
+ "The answer provides a procedure, not evidence that it was executed."
162
+ ],
163
+ "pitfalls": [
164
+ "Do not flatten the checklist into unordered advice or move restart before the recovery inputs are saved.",
165
+ "Do not lose nested failure branches or resume merely because the process started.",
166
+ "Do not generalize local commands into a production recovery procedure."
167
+ ],
168
+ "variants": [
169
+ {
170
+ "mode": "plain",
171
+ "text": "## Local recovery procedure\n\nUse this sequence for the local indexer only. Keep incoming work paused until verification is complete; a successful process startup does not prove recovery.\n\n1. Run `indexctl pause --local` and confirm that the status reports paused. If it does not, stop rather than continuing with a moving queue.\n2. Save the recovery inputs:\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending item count from `indexctl status --local`.\n \n If the checkpoint cannot be copied, stop and investigate before restarting.\n3. Run `indexctl restart --local`. Do not delete the queue directory.\n4. While intake remains paused, verify the recovered queue:\n - Confirm that the pending item count matches the recorded count.\n - If it differs, leave intake paused and compare the saved checkpoint with the current one. Do not resume automatically.\n5. Only after the counts match, run `indexctl resume --local`.\n\nKeep the backup until the resumed indexer has processed the pending work. These are instructions, not a report that recovery has already been performed."
172
+ },
173
+ {
174
+ "mode": "terse",
175
+ "text": "## Local recovery procedure\n\nFor the local indexer only, keep intake paused until verification is complete; successful startup alone does not confirm recovery.\n\n1. Run `indexctl pause --local` and confirm the status reports paused. If not, stop.\n2. Save recovery inputs:\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending count from `indexctl status --local`.\n - If copying fails, stop and investigate before restarting.\n3. Run `indexctl restart --local`. Do not delete the queue directory.\n4. With intake still paused, verify the queue:\n - Confirm the pending count matches the recorded count.\n - If it differs, keep intake paused and compare the saved and current checkpoints; do not resume automatically.\n5. Only if the counts match, run `indexctl resume --local`.\n\nKeep the backup until the resumed indexer processes the pending work. These are instructions, not a report that recovery was performed."
176
+ },
177
+ {
178
+ "mode": "adhd",
179
+ "text": "## Local indexer recovery\n\nKeep incoming work paused until verification is complete. Successful process startup alone does not confirm recovery.\n\n1. Run `indexctl pause --local`.\n - Confirm the status reports paused.\n - If it does not, stop rather than continuing with a moving queue.\n\n2. Save the recovery inputs.\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending item count from `indexctl status --local`.\n - If the checkpoint cannot be copied, stop and investigate before restarting.\n\n3. Run `indexctl restart --local`.\n - Do not delete the queue directory.\n\n4. Verify the recovered queue while intake remains paused.\n - Confirm the pending item count matches the recorded count.\n - If it differs, leave intake paused and compare the saved checkpoint with the current one. Do not resume automatically.\n\n5. Only after the counts match, run `indexctl resume --local`.\n\nRetain the backup until the resumed indexer has processed the pending work. These are instructions, not a report that recovery has already been performed."
180
+ },
181
+ {
182
+ "mode": "squirrel",
183
+ "text": "## Local recovery procedure\n\nKeep incoming work paused until verification is complete. Successful process startup alone does not confirm recovery.\n\n1. Run `indexctl pause --local` and confirm the status reports paused. If it does not, stop rather than continuing with a moving queue.\n\n2. Save the recovery inputs:\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending item count from `indexctl status --local`.\n - If the checkpoint cannot be copied, stop and investigate before restarting.\n\n3. Run `indexctl restart --local`. Do not delete the queue directory.\n\n4. While intake remains paused, verify that the recovered queue’s pending item count matches the recorded count. If it differs, leave intake paused and compare the saved checkpoint with the current one; do not resume automatically.\n\n5. Only after the counts match, run `indexctl resume --local`.\n\nRetain the backup until the resumed indexer has processed the pending work. These are instructions, not a report that recovery has already been performed."
184
+ },
185
+ {
186
+ "mode": "ste",
187
+ "text": "## Local Recovery Procedure\n\nUse this procedure for the local indexer only. Keep incoming work paused until verification is complete. Successful process startup does not prove recovery.\n\n1. Run `indexctl pause --local`.\n2. Confirm that the status reports paused. If it does not, stop. Do not continue with a moving queue.\n3. Save the recovery inputs:\n - Copy `var/index/checkpoint.json` to `var/index/checkpoint.backup.json`.\n - Record the pending item count from `indexctl status --local`.\n - If the checkpoint cannot be copied, stop and investigate before restarting.\n4. Run `indexctl restart --local`. Do not delete the queue directory.\n5. Keep intake paused and verify the recovered queue:\n - Confirm that the pending item count matches the recorded count.\n - If the count differs, leave intake paused.\n - Compare the saved checkpoint with the current checkpoint.\n - Do not resume automatically.\n6. Run `indexctl resume --local` only after the counts match.\n\nRetain the backup until the resumed indexer has processed the pending work.\n\nThese are instructions. They do not report that recovery has already been performed."
188
+ }
189
+ ]
190
+ },
191
+ {
192
+ "id": "comparison-table",
193
+ "label": "Tradeoffs",
194
+ "title": "Three options. Different tradeoffs.",
195
+ "sourceTitle": "A comparison table carries independent tradeoffs",
196
+ "request": "Compare inline processing and queued processing for the thumbnail endpoint. We have no benchmark results yet.",
197
+ "original": "## Processing options\nThe following comparison describes behavior, not measured throughput. Neither option has been benchmarked with the current image set.\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | Thumbnail is ready when the request succeeds | Job is accepted; thumbnail may still be pending |\n| Failure visibility | Processing failure is returned in the request | Processing failure must be exposed through job status |\n| Service ownership | No separate worker to operate | Worker and queue require operation |\n| Traffic bursts | Requests occupy processing capacity immediately | Pending jobs can wait, increasing completion delay |\n\nI recommend inline processing for the initial version if its measured request duration fits the caller's timeout. This avoids introducing a worker before we know one is needed. Choose queued processing if that duration cannot fit and callers can handle a pending result. A queue changes where waiting occurs; it does not by itself make image processing faster. The next useful step is a representative duration measurement, not a throughput claim based on the architecture alone.",
198
+ "mustKeep": [],
199
+ "reviewFacts": [
200
+ "Neither option has been benchmarked with the current image set.",
201
+ "Inline success means a ready thumbnail; queued acceptance can mean work is still pending.",
202
+ "Inline failures appear in the request, whereas queued failures need job-status visibility.",
203
+ "Queued processing introduces worker and queue operation; inline processing has no separate worker.",
204
+ "A queue can defer burst work but increase completion delay and does not itself accelerate processing.",
205
+ "Inline is recommended conditionally on measured duration fitting the timeout; queued processing is conditional on non-fitting duration and caller support for pending results."
206
+ ],
207
+ "pitfalls": [
208
+ "Do not discard table rows as decorative formatting; each row adds a distinct comparison.",
209
+ "Do not claim queued processing is faster or that either option has demonstrated throughput.",
210
+ "Do not reduce a conditional recommendation to an unconditional architecture choice."
211
+ ],
212
+ "variants": [
213
+ {
214
+ "mode": "plain",
215
+ "text": "## Processing options\n\nThis comparison describes behavior, not measured throughput. Neither option has been benchmarked with the current image set.\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | The thumbnail is ready when the request succeeds | The job is accepted; the thumbnail may still be pending |\n| Failure visibility | Processing failures are returned in the request | Processing failures must be exposed through job status |\n| Service ownership | No separate worker to operate | The worker and queue require operation |\n| Traffic bursts | Requests use processing capacity immediately | Pending jobs can wait, increasing completion delay |\n\nI recommend inline processing for the initial version if its measured request duration fits the caller's timeout. This avoids introducing a worker before we know one is needed. Choose queued processing if that duration cannot fit and callers can handle a pending result. A queue changes where waiting occurs; it does not by itself make image processing faster.\n\nThe next useful step is to measure a representative duration, not to make a throughput claim based on the architecture alone.",
216
+ "recording": {
217
+ "runId": "2026-09-12T00-39-56-684Z-9b25936e-baseline",
218
+ "recordedAt": "2026-09-12T00:39:56.684Z",
219
+ "model": "openai-codex/gpt-5.6-luna",
220
+ "promptVersion": "plain-lab-2",
221
+ "context": "answer",
222
+ "promptSource": "plain-lab-2"
223
+ }
224
+ },
225
+ {
226
+ "mode": "terse",
227
+ "text": "## Processing options\n\nThis comparison describes behavior, not measured throughput. Neither option has been benchmarked with the current image set.\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | Thumbnail is ready when the request succeeds | Job is accepted; thumbnail may still be pending |\n| Failure visibility | Processing failure is returned in the request | Processing failure must be exposed through job status |\n| Service ownership | No separate worker to operate | Worker and queue require operation |\n| Traffic bursts | Requests occupy processing capacity immediately | Pending jobs can wait, increasing completion delay |\n\nUse inline processing initially if its measured request duration fits the caller's timeout; this avoids introducing a worker before one is needed. Use queued processing if it cannot fit and callers can handle a pending result. A queue changes where waiting occurs; it does not by itself make image processing faster.\n\nThe next useful step is measuring representative duration, not inferring throughput from the architecture alone."
228
+ },
229
+ {
230
+ "mode": "adhd",
231
+ "text": "## Measure first\n\nNo benchmark exists for the current image set. Measure representative processing duration before choosing the endpoint contract; architecture alone does not establish throughput.\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | Thumbnail is ready when the request succeeds | Job is accepted; thumbnail may still be pending |\n| Failure visibility | Processing failure is returned in the request | Processing failure must be exposed through job status |\n| Service ownership | No separate worker to operate | Worker and queue require operation |\n| Traffic bursts | Requests occupy processing capacity immediately | Pending jobs can wait, increasing completion delay |\n\n## Recommendation\n\n- Choose **inline processing** for the initial version if its measured request duration fits the caller's timeout. This avoids introducing a worker before knowing one is needed.\n- Choose **queued processing** if that duration cannot fit and callers can handle a pending result.\n\nA queue changes where waiting occurs; it does not by itself make image processing faster."
232
+ },
233
+ {
234
+ "mode": "squirrel",
235
+ "text": "## Recommendation\n\n1. Use **inline processing** for the initial version if its measured request duration fits the caller’s timeout.\n\n2. This avoids introducing a worker before we know one is needed.\n\n3. Choose **queued processing** if that duration cannot fit and callers can handle a pending result.\n\n4. A queue changes where waiting occurs; it does not by itself make image processing faster.\n\n5. The next useful step is a representative duration measurement, not a throughput claim based on the architecture alone.\n\n## Comparison\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | Thumbnail is ready when the request succeeds | Job is accepted; thumbnail may still be pending |\n| Failure visibility | Processing failure is returned in the request | Processing failure must be exposed through job status |\n| Service ownership | No separate worker to operate | Worker and queue require operation |\n| Traffic bursts | Requests occupy processing capacity immediately | Pending jobs can wait, increasing completion delay |\n\nNeither option has been benchmarked with the current image set. This comparison describes behavior, not measured throughput."
236
+ },
237
+ {
238
+ "mode": "ste",
239
+ "text": "## Processing options\n\nThis comparison describes behavior, not measured throughput. Neither option has been benchmarked with the current image set.\n\n| Concern | Inline processing | Queued processing |\n| --- | --- | --- |\n| Response meaning | The thumbnail is ready when the request succeeds. | The job is accepted. The thumbnail may still be pending. |\n| Failure visibility | The request returns processing failure. | Job status must expose processing failure. |\n| Service ownership | No separate worker requires operation. | The worker and queue require operation. |\n| Traffic bursts | Requests immediately occupy processing capacity. | Pending jobs can wait, which increases completion delay. |\n\nI recommend inline processing for the initial version if measured request duration fits the caller's timeout. This avoids introducing a worker before we know one is needed.\n\nChoose queued processing if request duration cannot fit the caller's timeout and callers can handle a pending result.\n\nA queue changes where waiting occurs. It does not, by itself, make image processing faster.\n\nThe next useful step is a representative duration measurement. The architecture alone cannot support a throughput claim."
240
+ }
241
+ ]
242
+ }
243
+ ]
244
+ }
@@ -0,0 +1,21 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, resolve, extname, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/playground");
7
+ const port = Number(process.env.PORT ?? 4197);
8
+ const types: Record<string, string> = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".png": "image/png" };
9
+ const server = createServer(async (request, response) => {
10
+ if (request.method !== "GET" && request.method !== "HEAD") { response.writeHead(405).end(); return; }
11
+ try {
12
+ const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
13
+ const file = resolve(root, `.${pathname.endsWith("/") ? `${pathname}index.html` : pathname}`);
14
+ if (!file.startsWith(root + sep)) { response.writeHead(403).end(); return; }
15
+ const bytes = await readFile(file);
16
+ response.writeHead(200, { "Content-Type": types[extname(file)] ?? "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
17
+ response.end(request.method === "HEAD" ? undefined : bytes);
18
+ } catch { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found. Run npm run build:playground first."); }
19
+ });
20
+ server.on("error", error => { console.error(error.message); process.exitCode = 1; });
21
+ server.listen(port, "127.0.0.1", () => console.log(`Declaw playground: http://127.0.0.1:${port} (Ctrl-C to stop)`));
@@ -0,0 +1,30 @@
1
+ @font-face{font-family:Geist;src:url("assets/geist.woff2") format("woff2");font-style:normal;font-weight:100 900;font-display:swap}
2
+ :root{
3
+ color-scheme:dark;--ground:#111214;--surface:#161719;--ink:#eeeff3;--prose:#dedfe5;
4
+ --muted:#a6a7b1;--line:#2c2d32;--accent:#b2a7ff;--selection:#292735;
5
+ --removed:#e9aeb8;--added:#afe0c4;--removed-bg:rgba(215,139,152,.035);--added-bg:rgba(130,208,161,.035);
6
+ font-family:Geist,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;
7
+ -webkit-font-smoothing:antialiased;background:var(--ground);color:var(--ink)
8
+ }
9
+ *{box-sizing:border-box}body{margin:0}[hidden]{display:none!important}button,select{font:inherit;color:inherit}button{cursor:pointer}button:disabled,select:disabled{opacity:.5;cursor:default}button,a,select{touch-action:manipulation;-webkit-tap-highlight-color:transparent}a{color:inherit}button:focus-visible,a:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:focus:not(:focus-visible){outline:none}::selection{background:#665997;color:white}
10
+ .icon-definitions{position:absolute;width:0;height:0;overflow:hidden}.icon{width:17px;height:17px;flex:none;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
11
+ .sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:fixed;top:12px;left:16px;transform:translateY(-200%);z-index:10;background:var(--accent);color:#17122e;padding:12px 16px;border-radius:6px}.skip-link:focus{transform:none}
12
+ .app-shell{height:100vh;height:100dvh;min-height:420px;display:grid;grid-template-columns:210px minmax(0,1fr)}
13
+ .rail{padding:24px 12px 12px;display:flex;flex-direction:column;border-right:1px solid var(--line);overflow:auto;scrollbar-width:thin;scrollbar-color:#44454d transparent}.wordmark{font-size:21px;font-weight:550;letter-spacing:-.025em;line-height:1.2;margin:0 10px 32px}.wordmark img{display:block;width:144px;max-width:100%;height:auto}.rail-group h2{font-size:11px;line-height:1.5;font-weight:450;color:var(--muted);margin:0 10px 7px}.rail-button{display:flex;align-items:center;gap:10px;width:100%;min-height:44px;border:0;border-radius:7px;padding:10px;margin:1px 0;background:transparent;color:#c6c7d0;text-align:left;font-size:13px;line-height:1.4;transition:background-color 120ms,color 120ms}.rail-button .icon{color:#aaaabd}.rail-button:hover{background:#1e1f24;color:var(--ink)}.rail-button[aria-pressed="true"]{background:var(--selection);color:#f0ecff}.rail-button[aria-pressed="true"] .icon{color:var(--accent)}.modes{margin-top:22px;padding-top:20px;border-top:1px solid var(--line)}.rail-footer{margin: auto 10px 0;padding-top:16px;border-top:1px solid var(--line)}.action-link{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:40px;color:var(--muted);font-size:11px;line-height:1.4;text-decoration:none}.action-link .icon{width:14px;height:14px;color:#888995}.action-link:hover{color:var(--ink);text-decoration:underline;text-underline-offset:3px}.action-link:hover .icon{color:var(--accent)}.primary-link{color:#e1d9ff;font-weight:550}.primary-link .icon{color:var(--accent)}.rail-footer .credits-link{margin:0;padding-top:12px}.credits-link{display:flex;align-items:center;min-height:44px;color:var(--muted);font-size:11px;text-decoration:none}.credits-link:hover{color:var(--ink);text-decoration:underline;text-underline-offset:3px}
14
+ main{min-width:0;min-height:0;display:flex;flex-direction:column;padding:16px}.workspace{min-height:0;flex:1;display:flex;flex-direction:column;border:1px solid var(--line);border-radius:8px;background:var(--surface);overflow:hidden}.workspace-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex:none;min-height:60px;padding:8px 20px;border-bottom:1px solid var(--line);background:var(--surface)}.workspace-toolbar h2{font-size:14px;line-height:1.4;font-weight:450;margin:0;min-width:0}.diff-toggle{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;min-width:142px;padding:10px 12px;border:1px solid #494451;border-radius:7px;background:#25232f;color:#e5dfff;font-size:13px;line-height:1.3;white-space:nowrap}.diff-toggle:hover{background:#343043;border-color:#766ca3}.diff-toggle[aria-pressed="true"]{background:#a99cf2;border-color:transparent;color:#17122e}
15
+ .source-attribution{flex:none;margin:0;padding:8px 20px;color:var(--muted);font-size:11px;line-height:1.5;overflow-wrap:anywhere}.source-attribution a{text-underline-offset:3px}.source-attribution a:hover{color:var(--ink)}
16
+ .documents{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));flex:1;min-height:0;overflow:hidden}.document-panel,.unified-panel{display:flex;flex-direction:column;min-width:0;min-height:0}.original-panel{border-right:1px solid var(--line)}.unified-panel{flex:1}.document-header{display:flex;align-items:center;gap:12px;flex:none;min-height:54px;padding:4px 24px}.document-header h3{font-size:13px;font-weight:500;line-height:1.4;margin:0}.icon-button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:10px;border:0;border-radius:6px;background:transparent;color:var(--muted)}.icon-button:hover{background:#292a30;color:var(--ink)}.copy-button{margin-left:auto;margin-right:-10px}.copy-button .icon{width:15px;height:15px}.copy-button[data-copied="true"] .icon{display:none}.copy-button[data-copied="true"]::after{content:"Copied";font-size:10px;color:var(--added)}
17
+ .reading-scroll{flex:1;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-color:#44454d transparent;scroll-padding:20px;padding:8px 24px 28px}.prose{font-size:16px;line-height:1.65;font-weight:400;letter-spacing:0;color:var(--prose);overflow-wrap:anywhere;max-width:72ch;margin-inline:auto}.prose>:first-child{margin-top:0}.prose>:last-child{margin-bottom:0}.prose p{margin:0 0 1em}.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{color:var(--ink);font-weight:550;line-height:1.35;letter-spacing:-.02em;margin:1.5em 0 .6em}.prose h1,.prose h2{font-size:21px}.prose h3{font-size:18px}.prose h4,.prose h5,.prose h6{font-size:16px}.prose strong{font-weight:600;color:var(--ink)}.prose ul,.prose ol{padding-left:1.45em;margin:0 0 1em}.prose li{padding-left:.15em;margin:.4em 0}.prose li>p{margin:.35em 0}.prose li>ul,.prose li>ol{margin:.3em 0 .5em}.prose li::marker{color:#b7b4c5}.prose code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.9em;padding:0 .1em;color:#e6e7ed;white-space:break-spaces}.prose pre{padding:16px;background:#121315;overflow:auto;white-space:pre;line-height:1.6;border-radius:6px;font-size:13px}.prose pre code{padding:0;white-space:pre;font-size:inherit}.prose table{border-collapse:collapse;table-layout:fixed;width:100%;font-size:13px;line-height:1.6;margin:1.25em 0}.prose td,.prose th{text-align:left;vertical-align:top;padding:10px 8px;border-bottom:1px solid #383940;overflow-wrap:anywhere}.prose th{font-weight:550;color:var(--ink);background:#1e2024}.prose th:first-child,.prose td:first-child{width:25%;padding-left:0}.prose a{text-underline-offset:3px}.prose blockquote{margin:1em 0;padding-left:16px;border-left:1px solid var(--line)}.prose hr{border:0;border-top:1px solid var(--line);margin:24px 0}
18
+ /* The reading panes are always clean. Only the combined diff decorates edits. */
19
+ .prose .change{font:inherit;color:inherit;background:transparent;text-decoration:none}.unified-content del.change,.evidence-page[data-inspecting="true"] del.change{color:var(--removed);background:var(--removed-bg);text-decoration:line-through;text-decoration-thickness:1px}.unified-content ins.change,.evidence-page[data-inspecting="true"] ins.change{color:var(--added);background:var(--added-bg);text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}
20
+ .unified-content{font-size:16px;max-width:80ch;margin:0 auto}.diff-row{display:block;margin:0 0 16px}.diff-row>.prose{max-width:none;width:100%;margin:0}.diff-gap{white-space:pre}.diff-legend{display:flex;gap:14px;margin-left:auto;font-size:11px;line-height:1.5}.removed-key{color:var(--removed);text-decoration:line-through}.added-key{color:var(--added);text-decoration:underline;text-underline-offset:3px}.unchanged-note{font-size:11px;color:var(--muted);font-weight:400}.unified-panel .unchanged-note{margin-left:auto}.fallback{font-size:13px;line-height:1.6;padding:12px}.mobile-controls,.mobile-tabs,.mobile-links,.mobile-credits{display:none}
21
+ @media(max-width:1100px) and (min-width:901px){.app-shell{grid-template-columns:192px minmax(0,1fr)}.rail{padding-inline:8px}main{padding:12px}.reading-scroll{padding-inline:20px}.document-header{padding-inline:20px}.prose{font-size:15px}}
22
+ @media(max-width:900px){
23
+ /* Let the browser own vertical scrolling; no viewport-height reading boxes. */
24
+ .app-shell{display:block;height:auto;min-height:100vh;min-height:100dvh}.rail{display:none}main{height:auto;min-height:0;padding:max(10px,env(safe-area-inset-top)) 12px max(8px,env(safe-area-inset-bottom));display:block}.mobile-controls{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px;min-height:44px}.mobile-controls .wordmark{font-size:19px;flex:none;margin:0 4px 0 0}.mobile-controls .wordmark img{width:32px;height:auto}.mobile-controls select{height:44px;min-width:0;border:1px solid var(--line);border-radius:6px;background:#1c1d22;color:var(--ink);padding:0 6px;font-size:16px}.mobile-controls #example-select{flex:1 1 190px;width:auto}.mobile-controls #mode-select{flex:1 1 160px;width:auto}
25
+ .workspace{display:block;overflow:visible;min-height:0}.workspace-toolbar{position:sticky;top:0;z-index:3;height:56px;min-height:56px;padding:6px 12px;gap:10px;border-radius:8px 8px 0 0}.workspace-toolbar h2{font-size:13px;line-height:1.35}.diff-toggle{min-width:142px}.mobile-tabs{position:sticky;top:56px;z-index:2;display:flex;min-height:44px;background:var(--surface);border-bottom:1px solid var(--line);padding:0 10px}.mobile-tabs button{flex:1;min-height:44px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-size:13px}.mobile-tabs button[aria-selected="true"]{color:#e1d9ff;border-bottom-color:var(--accent)}
26
+ .source-attribution{padding:8px 12px}
27
+ .documents{display:block;overflow:visible;min-height:0}.document-panel,.unified-panel{height:auto;min-height:0;display:block}.original-panel{border-right:0}body[data-side="rewrite"] .original-panel,body[data-side="original"] .rewrite-panel{display:none}.document-header{min-height:48px;padding:2px 16px}.document-header h3{font-size:12px}.reading-scroll{height:auto;min-height:0;overflow:visible;padding:8px 18px 24px}.prose{font-size:15px;line-height:1.7}.prose h1,.prose h2{font-size:20px}.prose h3{font-size:17px}.prose table{font-size:12px}.prose td,.prose th{padding:8px 5px}.unified-content{font-size:15px}.unified-panel .reading-scroll{padding-inline:12px}.diff-legend{font-size:10px;gap:10px}.mobile-links{display:flex;flex-direction:column;gap:2px;margin:18px 6px 0;padding:14px 0 0;border-top:1px solid var(--line)}.mobile-links .action-link{min-height:40px;font-size:12px}.mobile-credits{display:flex;justify-content:flex-end;margin:4px 0 0;padding-top:0;min-height:36px}
28
+ }
29
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
30
+ @media(forced-colors:active){.rail-button[aria-pressed="true"],.diff-toggle[aria-pressed="true"]{outline:2px solid Highlight}.unified-content .change{color:CanvasText;background:Canvas;text-decoration-color:CanvasText}}
@@ -0,0 +1,136 @@
1
+ import { mergeInlineBlocks, markBlock } from './inline-diff.ts';
2
+
3
+ type Kind = 'equal' | 'remove' | 'add' | 'change';
4
+ interface Block { html: string; key: string }
5
+ const unmark = (html: string) => html.replace(/<(?:del|ins) class="change">|<\/(?:del|ins)>/g, '');
6
+ const MAX_CELLS = 1_000_000; // At most 4 MiB; larger alignments use adjacent pairs.
7
+
8
+ /** Only for balanced, safe semantic HTML from renderComparison, NOT raw model text.
9
+ * Generated tags have no void elements or attributes containing angle brackets.
10
+ * Scan by tag depth: literal newlines inside paragraphs/code are not boundaries.
11
+ */
12
+ function splitBlocks(html: string): string[] {
13
+ const blocks: string[] = [];
14
+ let depth = 0, start = 0;
15
+ for (const match of html.matchAll(/<(\/?)[a-z][a-z0-9]*(?:\s[^>]*)?>/g)) {
16
+ if (!match[1]) {
17
+ if (depth === 0) start = match.index!;
18
+ depth++;
19
+ } else if (--depth === 0) {
20
+ blocks.push(html.slice(start, match.index! + match[0].length));
21
+ }
22
+ }
23
+ return blocks;
24
+ }
25
+
26
+ function units(html: string): Block[] {
27
+ return splitBlocks(html).flatMap(block => {
28
+ const list = /^<(ol|ul)(?: start="(\d+)")?>/.exec(block);
29
+ if (!list) return [{ html: block, key: unmark(block) }];
30
+ const tag = list[1], start = Number(list[2] ?? 1);
31
+ // Split only direct children; nested lists stay inside their parent item.
32
+ return splitBlocks(block.slice(list[0].length, -(tag.length + 3))).map((item, index) => {
33
+ const wrapper = tag === 'ol' ? `<ol start="${start + index}">` : '<ul>';
34
+ const html = `${wrapper}${item}</${tag}>`;
35
+ return { html, key: unmark(html) };
36
+ });
37
+ });
38
+ }
39
+
40
+ function row(html: string, kind: Kind): string {
41
+ const label = kind === 'equal' ? '' : ` role="group" aria-label="${kind === 'remove' ? 'Removed' : kind === 'add' ? 'Added' : 'Changed'}"`;
42
+ return `<div class="diff-row" data-kind="${kind}"${label}><div class="prose">${html}</div></div>`;
43
+ }
44
+
45
+ /** A single reading with common wording once and replacements merged inline.
46
+ * Separate blocks are used only for incompatible structural changes.
47
+ */
48
+ export function renderUnifiedDiff(beforeHtml: string, afterHtml: string): {
49
+ unifiedHtml: string; visibleChanged: boolean;
50
+ } {
51
+ const beforeClean = unmark(beforeHtml);
52
+ if (beforeClean === unmark(afterHtml)) {
53
+ return { unifiedHtml: beforeClean ? row(beforeClean, 'equal') : '', visibleChanged: false };
54
+ }
55
+ const before = units(beforeHtml), after = units(afterHtml), output: string[] = [];
56
+ let visibleChanged = false;
57
+ const emit = (block: Block, kind: Exclude<Kind, 'change'>) => {
58
+ output.push(row(kind === 'equal' ? block.key : markBlock(block.html, kind), kind));
59
+ if (kind !== 'equal') visibleChanged = true;
60
+ };
61
+ const pair = (from: number, to: number, otherFrom: number, otherTo: number) => {
62
+ while (from < to || otherFrom < otherTo) {
63
+ if (from < to && otherFrom < otherTo && before[from].key === after[otherFrom].key) {
64
+ emit(before[from++], 'equal'); otherFrom++;
65
+ } else {
66
+ // A removed/inserted heading must not offset every paragraph pairing.
67
+ const leftHeading = from < to && /^<h[1-6]>/.test(before[from].key);
68
+ const rightHeading = otherFrom < otherTo && /^<h[1-6]>/.test(after[otherFrom].key);
69
+ if (leftHeading && !rightHeading) { emit(before[from++], 'remove'); continue; }
70
+ if (rightHeading && !leftHeading) { emit(after[otherFrom++], 'add'); continue; }
71
+ if (from < to && otherFrom < otherTo) {
72
+ // A paragraph split must not duplicate its closing sentence as old/new rows.
73
+ // Normalize just a 1↔2 paragraph boundary in the changes view; clean readings
74
+ // and copied Markdown retain their original paragraph structure.
75
+ const paragraphCount = (blocks: Block[], start: number, end: number) => {
76
+ let count = 0;
77
+ while (start + count < end && count < 3 && /^<p>/.test(blocks[start + count].key)) count++;
78
+ return count;
79
+ };
80
+ const leftCount = paragraphCount(before, from, to), rightCount = paragraphCount(after, otherFrom, otherTo);
81
+ if ((leftCount === 1 && rightCount === 2) || (leftCount === 2 && rightCount === 1)) {
82
+ const joined = (blocks: Block[], start: number, count: number) =>
83
+ `<p>${blocks.slice(start, start + count).map(block => block.key.slice(3, -4)).join(' ')}</p>`;
84
+ const left = joined(before, from, leftCount), right = joined(after, otherFrom, rightCount);
85
+ const merged = mergeInlineBlocks(left, right);
86
+ if (merged !== null) {
87
+ const kind = left === right ? 'equal' : 'change';
88
+ output.push(row(merged, kind)); visibleChanged ||= kind === 'change';
89
+ from += leftCount; otherFrom += rightCount; continue;
90
+ }
91
+ }
92
+ const merged = mergeInlineBlocks(before[from].html, after[otherFrom].html);
93
+ if (merged !== null) {
94
+ output.push(row(merged, 'change')); visibleChanged = true;
95
+ from++; otherFrom++; continue;
96
+ }
97
+ }
98
+ if (from < to) emit(before[from++], 'remove');
99
+ if (otherFrom < otherTo) emit(after[otherFrom++], 'add');
100
+ }
101
+ }
102
+ };
103
+ // Trim common ends before budgeting the quadratic alignment work.
104
+ let prefix = 0, end = before.length, otherEnd = after.length;
105
+ while (prefix < end && prefix < otherEnd && before[prefix].key === after[prefix].key) {
106
+ emit(before[prefix++], 'equal');
107
+ }
108
+ while (end > prefix && otherEnd > prefix && before[end - 1].key === after[otherEnd - 1].key) {
109
+ end--; otherEnd--;
110
+ }
111
+ const n = end - prefix, m = otherEnd - prefix, columns = m + 1;
112
+ if (!n || !m || (n + 1) * columns > MAX_CELLS) {
113
+ pair(prefix, end, prefix, otherEnd);
114
+ } else {
115
+ const lengths = new Uint32Array((n + 1) * columns);
116
+ for (let i = n - 1; i >= 0; i--) {
117
+ for (let j = m - 1; j >= 0; j--) {
118
+ lengths[i * columns + j] = before[prefix + i].key === after[prefix + j].key
119
+ ? 1 + lengths[(i + 1) * columns + j + 1]
120
+ : Math.max(lengths[(i + 1) * columns + j], lengths[i * columns + j + 1]);
121
+ }
122
+ }
123
+ let i = 0, j = 0, runStart = 0, otherRunStart = 0;
124
+ while (i < n && j < m) {
125
+ if (before[prefix + i].key === after[prefix + j].key) {
126
+ pair(prefix + runStart, prefix + i, prefix + otherRunStart, prefix + j);
127
+ emit(before[prefix + i], 'equal');
128
+ runStart = ++i; otherRunStart = ++j;
129
+ } else if (lengths[(i + 1) * columns + j] >= lengths[i * columns + j + 1]) i++;
130
+ else j++;
131
+ }
132
+ pair(prefix + runStart, end, prefix + otherRunStart, otherEnd);
133
+ }
134
+ for (let i = end; i < before.length; i++) emit(before[i], 'equal');
135
+ return { unifiedHtml: output.join('\n'), visibleChanged };
136
+ }