tosijs-ui 1.12.5 → 1.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,172 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.7
4
+
5
+ ### Live-example and doc-test failures now tell you where
6
+
7
+ A failing doc test used to report one entry for the whole block, with no file, no line and no
8
+ stack:
9
+
10
+ ```
11
+ ✗ Test execution: Arg string terminates parameters early
12
+ ```
13
+
14
+ The only way to narrow that is to bisect the file by hand, which is what it cost the person who
15
+ reported it ([#111](https://github.com/tonioloewald/tosijs-ui/issues/111)) — and both theories
16
+ the bisection bought were wrong, because the message named nothing that was actually involved.
17
+
18
+ Three things were wrong, and none of them were the transpiler:
19
+
20
+ - **The stack was discarded.** The catch kept `err.message` only — fifteen lines below the code
21
+ that tags the body `//# sourceURL=inline-test` precisely so that stack would carry usable line
22
+ numbers. Failures now read `message | the offending source (line N)`.
23
+ - **Live examples never tagged a sourceURL at all**, so a broken example's stack pointed into
24
+ the minified bundle. Now tagged, and the preview error carries the same location.
25
+ `notDefinedAnywhere is not defined` became
26
+ `notDefinedAnywhere is not defined | notDefinedAnywhere() (line 3)`.
27
+ - **The line numbers were off by two, and always had been.** The `Function` constructor
28
+ synthesizes a header, so every line an engine reports is shifted from the line the author
29
+ wrote — measured at **+2** in Chromium and Firefox, for `Function` and `AsyncFunction` alike,
30
+ regardless of parameter count, and additive. So assertion failures have been naming the line
31
+ _two below_ the failing assertion for as long as that feature has existed, and quoting that
32
+ line's source alongside it. That is worse than saying nothing: it reads as correct, because
33
+ the quoted text is real code from the same file.
34
+
35
+ The offset is **calibrated at runtime** by one cached probe rather than hardcoded to 2 — it is
36
+ an unspecified detail of whichever engine is running, and a stale constant would reintroduce
37
+ the defect it fixes. If the correction would produce a line below 1, no line is reported rather
38
+ than a false one. WebKit produces no locatable frame for constructed functions, so it falls back
39
+ to the bare message, exactly as before.
40
+
41
+ ### A constructor failure now names the context keys
42
+
43
+ The remaining case has no line to report at all, because nothing ran: the `AsyncFunction`
44
+ constructor rejected the code before executing it. That was #111's actual failure.
45
+
46
+ If the same body compiles with **no** parameters, the body is fine and the caller's `context`
47
+ keys are at fault — so the message now says that and lists the parameter list they became. A key
48
+ that cannot be an identifier (a subpath like `pkg/sub`, a scoped name, a leading digit) is the
49
+ usual cause, and none of that was inferable from `Arg string terminates parameters early`.
50
+
51
+ ## 1.12.6
52
+
53
+ ### `<tosi-diff resolvable>` — "do I want to accept this?"
54
+
55
+ The diff viewer can now be operated, not just read. Set `resolvable` and every change gets a
56
+ pair of buttons; `value` is the text those choices imply and `change` fires when one moves. It
57
+ is a review surface for a **proposed** edit — an AI suggestion, a collaborator's revision, a
58
+ file that moved under you while you were editing it.
59
+
60
+ The labels are yours: `originalLabel`/`modifiedLabel`. "Mine"/"Theirs" is git's framing,
61
+ "Current"/"Proposed" suits reviewing a suggestion, and this component's own vocabulary is
62
+ original/modified — none of which is right for everyone, so none of it is hardcoded. Also
63
+ `acceptAll()`, `rejectAll()`, `changeCount`, and a `resolutions` array you can read and write.
64
+ `diffBlocks()` and `resolveDiff()` are exported too, if you want the model without the DOM.
65
+
66
+ The unit of decision is a change **block**, not a line. The LCS walk can emit
67
+ remove/add/remove/add for a single multi-line edit, and offering four independent choices there
68
+ lets a reviewer accept half an edit — producing text neither side wrote.
69
+
70
+ The read-only path is deliberately unchanged: grouping reorders interleaved edits, which is
71
+ better to resolve and a **different rendering** for the two components already shipping this
72
+ one. Additive means additive.
73
+
74
+ Two notes on how this was tested, because **both** bugs that nearly shipped were invisible to
75
+ the unit lane, and for the same underlying reason.
76
+
77
+ The first version rendered **nothing**: `dataset: {…}` in an element creator assigns
78
+ `el.dataset`, a read-only accessor, so `render()` threw and left an empty shadow root.
79
+ Twenty-six green tests sat behind it, every one asserting `value` — which is computed from the
80
+ model and is correct whether or not a single element reaches the DOM. tosijs renders on an
81
+ animation frame and happy-dom runs none, so a unit test that merely _mounts_ an element asserts
82
+ against an empty shadow root; appending hydrates, and `render()` must be called explicitly.
83
+
84
+ The second was worse, because the render-level tests added for the first one _also_ passed. The
85
+ buttons did nothing when clicked, in every browser. `<tosi-diff>` is a shadow-DOM component and
86
+ tosijs delegates `on*` handlers from above the shadow boundary, so a real click is
87
+ **retargeted**: `event.target` is the host, not the button, and `event.target.closest(…)` — the
88
+ idiom this project documents, and which is correct for a light-DOM component — is `null` every
89
+ time. The fix is `composedPath()`, the only one of the two that survives the boundary.
90
+
91
+ The happy-dom test could not see it: it called `.click()` on a node it had already looked up and
92
+ then asserted the value moved. Measured against a deliberately broken build, **30 unit tests
93
+ pass and all 3 browser tests fail** — so `tests/diff-resolve.pw.ts` now clicks what a person
94
+ clicks and reads what a host reads. The rule this leaves behind: **shadow-DOM interaction needs
95
+ a browser test**, not a DOM-shaped unit test.
96
+
97
+ ### Selective revert in live examples
98
+
99
+ The diff resolver is wired into `<tosi-example>`'s **View changes**, and into `<tosi-code>`
100
+ generally via a new `diffResolvable` (plus `diffOriginalLabel`/`diffModifiedLabel`).
101
+
102
+ Revert was all-or-nothing, which is the wrong shape for what actually happens: you tried four
103
+ things and three worked. Each change in the diff now carries keep/revert buttons — labelled
104
+ **Source** and **Yours** in an example — defaulting to _keep_, so the feature costs nothing to
105
+ ignore and doing nothing leaves your edit exactly as it was.
106
+
107
+ Choices are applied when the diff **closes**, not as you click, and a `change` event fires if
108
+ the text moved. Writing each choice straight back into `value` would feed the new text into the
109
+ overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes — so live
110
+ application would wipe the choices making it and renumber the hunks under the pointer.
111
+
112
+ ### Fixed: `<tosi-diff>` and notifications ignored dark mode
113
+
114
+ Both hardcoded a light palette — `#fff`/`#222` for the diff, `#fafafa`/`#444` for
115
+ notifications — so on a dark page you got a white block, and in the notification's case the one
116
+ element in the library that appears unbidden over whatever you are looking at was also the one
117
+ that ignored your theme.
118
+
119
+ Both now fall back to the theme (`--background`/`--text-color`) with the old literals as the
120
+ final fallback, so an unthemed page is unchanged and every `--tosi-diff-*` /
121
+ `--notification-*` override still wins exactly as before.
122
+
123
+ The diff needed both halves at once: darkening the surface alone would have been **worse** than
124
+ leaving it, because light text on a solid `#e6ffed` row is unreadable. The add/remove tints are
125
+ now translucent (`color-mix` with `transparent`), so one value reads correctly as a wash over
126
+ either surface.
127
+
128
+ ### Fixed: a consumer could not use the build lock ([#117](https://github.com/tonioloewald/tosijs-ui/issues/117), [#118](https://github.com/tonioloewald/tosijs-ui/issues/118))
129
+
130
+ 1.12.5 added a build lock that records `pid`, `role`, `root` and `port` per project, so a
131
+ `stop` command can identify what is running instead of `pkill`-ing sibling checkouts. It worked
132
+ — and none of it was reachable from outside the package. `import('tosijs-ui/site')` gave
133
+ `currentHolder === undefined`, and the deep path is blocked by the exports map.
134
+
135
+ `currentHolder`, `describeHolder`, `lockPathFor`, `lockDecision`, `isProcessAlive` and the
136
+ `LockHolder`/`LockDecision` types are now re-exported from `tosijs-ui/site`.
137
+
138
+ The reporter deliberately did **not** re-derive the lock path locally, and was right not to:
139
+ that means copying the FNV-1a hash of the resolved root, and if the hash ever changed the
140
+ consumer would report "nothing running" _while a server runs_. Silently-wrong is the exact
141
+ failure this whole area exists to eliminate; duplicating the algorithm reintroduces it one
142
+ layer out.
143
+
144
+ ### haltija floor raised to `^1.12.6`
145
+
146
+ The floor encodes fixes, not a date. 1.12.6 is where a `--private` instance finally gained a
147
+ **lifetime bound** (haltija#39, filed from here after one was found 12 days old at 5.7 GB and
148
+ ~150% CPU on a machine at load average 212); where **LAN and Bonjour access started working at
149
+ all** (served scripts handed the browser `localhost:<port>`, and `localhost` in a served script
150
+ means the _browser's_ machine, so a phone was told to connect to itself); where the widget sends
151
+ `X-Haltija-Token`, so a `--token` server and a page that can talk back stopped being mutually
152
+ exclusive; where results carry `paintAgeMs`, making a rAF-starved tab detectable rather than
153
+ silently wrong; and where Electron went 40.6.1 → 43.4.1, clearing two context-isolation
154
+ bypasses in the thing we spawn.
155
+
156
+ (Internal, but worth recording: our own `CLAUDE.md` documented this pin as `^1.6.1` long after
157
+ the code had moved to `^1.11.2` — a stale number in the one place a reader would trust for it.
158
+ Corrected.)
159
+
160
+ ### Docs: our haltija gate covers the bridge, not the port
161
+
162
+ The `haltijaDev: 'tunnel'` documentation read as a complete security story — session cookie,
163
+ 404-on-unauthorized, relay rather than hole — and a reader could reasonably conclude the whole
164
+ surface was gated. It is not. Enabling `haltijaDev` at all means haltija's **own** port is
165
+ running, and a default instance binds beyond loopback with `Access-Control-Allow-Origin: *` and
166
+ no token, where `POST /terminal/command` reaches a shell. Set `HALTIJA_TOKEN` on a network you
167
+ do not control; it only became genuinely usable in haltija 1.12.6, which is why older advice
168
+ said to skip it.
169
+
3
170
  ## 1.12.5
4
171
 
5
172
  ### Fixed: 49 controls a screen reader announced as "button" and "edit text"
@@ -30,6 +30,9 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
30
30
  private diffOverlay;
31
31
  private _pendingDiff;
32
32
  get showingDiff(): boolean;
33
+ diffResolvable: boolean;
34
+ diffOriginalLabel: string;
35
+ diffModifiedLabel: string;
33
36
  showDiff(on: boolean): void;
34
37
  static initAttributes: {
35
38
  mode: string;
@@ -24,6 +24,8 @@ body {
24
24
  | `mode` | `javascript`, `typescript`, `tjs`, `ajs`, `css`, `html`, `markdown` |
25
25
  | `disabled` | makes the editor read-only |
26
26
  | `original` + `showDiff(on)` | diff the current `value` against a baseline, as an overlay |
27
+ | `diffResolvable` | make that overlay **resolvable**: each change gets keep/revert buttons, and the choices are applied to `value` when the diff closes |
28
+ | `diffOriginalLabel` / `diffModifiedLabel` | what the two buttons say (default `Original`/`Modified`) |
27
29
  | `editor` | the underlying CodeMirror [`EditorView`](https://codemirror.net/docs/ref/#view.EditorView) (`undefined` until loaded) |
28
30
  | `undo()` / `redo()` / `canUndo()` / `canRedo()` | history control |
29
31
  | `tjsAutocomplete` | runtime-value autocomplete hooks (tjs mode) — see below |
@@ -38,6 +40,29 @@ runtime values** — including proxy members no static analysis can see:
38
40
  codeEl.tjsAutocomplete = { getLiveBindings: () => ({ app, elements }) }
39
41
  ```
40
42
 
43
+ ## Selective revert
44
+
45
+ `showDiff(on)` shows a read-only diff. Set `diffResolvable` first and the overlay becomes a
46
+ review surface instead: every change carries keep/revert buttons, defaulting to **keep**, and
47
+ whatever you leave alone survives. This is what `<tosi-example>`'s **View changes** uses — you
48
+ tried four things and three worked, so revert the one you regret rather than all four.
49
+
50
+ The choices are applied when the diff **closes**, not as you click, and a `change` event fires
51
+ if the text moved. That is deliberate: writing each choice straight back into `value` would
52
+ feed the new text into the overlay's `modified`, and `<tosi-diff>` resets its decisions when
53
+ either input changes — so live application would wipe the choices making it and renumber the
54
+ hunks under the pointer.
55
+
56
+ ```typescript
57
+ codeEl.original = savedSource
58
+ codeEl.diffResolvable = true
59
+ codeEl.diffOriginalLabel = 'Source'
60
+ codeEl.diffModifiedLabel = 'Yours'
61
+ codeEl.showDiff(true)
62
+ // …user reverts a hunk or two…
63
+ codeEl.showDiff(false) // codeEl.value now reflects their choices
64
+ ```
65
+
41
66
  ## Bundling
42
67
 
43
68
  CodeMirror is a **lazy chunk**: with a bundler (ESM), a page that never uses
@@ -154,6 +179,17 @@ export class CodeEditor extends WebComponent {
154
179
  return this._pendingDiff ?? false;
155
180
  return !this.parts.diffHost.hidden;
156
181
  }
182
+ /*
183
+ Make the diff overlay RESOLVABLE, turning it from a review into selective revert.
184
+
185
+ Off by default, so `showDiff()` stays exactly what it was. With it on, every change gets
186
+ keep/revert buttons and the ones you leave alone are kept — which is the useful default when
187
+ the "modified" side is your own edit: you are reverting the two lines you regret, not
188
+ re-accepting the twenty you meant.
189
+ */
190
+ diffResolvable = false;
191
+ diffOriginalLabel = 'Original';
192
+ diffModifiedLabel = 'Modified';
157
193
  showDiff(on) {
158
194
  if (!this.hydrated) {
159
195
  this._pendingDiff = on;
@@ -165,9 +201,32 @@ export class CodeEditor extends WebComponent {
165
201
  this.diffOverlay = tosiDiff();
166
202
  diffHost.append(this.diffOverlay);
167
203
  }
204
+ this.diffOverlay.resolvable = this.diffResolvable;
205
+ this.diffOverlay.originalLabel = this.diffOriginalLabel;
206
+ this.diffOverlay.modifiedLabel = this.diffModifiedLabel;
168
207
  this.diffOverlay.original = this.original;
169
208
  this.diffOverlay.modified = this.value;
170
209
  }
210
+ else if (this.diffOverlay !== undefined && this.diffResolvable) {
211
+ /*
212
+ Resolutions land on the way OUT, not as you click.
213
+
214
+ Writing each choice straight back into `value` would feed the editor's new text into the
215
+ overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes —
216
+ by design, because choice #2 of the old diff is not choice #2 of the new one. Live
217
+ application would therefore wipe the very choices making it, and the hunks would
218
+ renumber under the pointer. Applying once, when the diff closes, sidesteps that
219
+ completely and matches how a reviewer thinks: decide, then commit.
220
+
221
+ The `change` event matters as much as the value: it is how a host recomputes "does this
222
+ still differ from the source" after a partial revert.
223
+ */
224
+ const resolved = this.diffOverlay.value;
225
+ if (resolved !== this.value) {
226
+ this.value = resolved;
227
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: resolved } }));
228
+ }
229
+ }
171
230
  diffHost.hidden = !on;
172
231
  }
173
232
  static initAttributes = {
package/dist/diff.d.ts CHANGED
@@ -4,6 +4,35 @@ export interface DiffLine {
4
4
  op: DiffOp;
5
5
  text: string;
6
6
  }
7
+ /** Which side of a change block won. */
8
+ export type DiffResolution = 'original' | 'modified';
9
+ /**
10
+ * A run of unchanged lines, or one CHANGE — the unit a reviewer accepts or rejects.
11
+ *
12
+ * `diffLines` emits removes and adds in walk order, which for a multi-line edit can
13
+ * interleave (`r a`, `+ A`, `r b`, `+ B`). That is fine to *read* and wrong to *resolve*:
14
+ * accepting half of an edit is not a state anybody asked for. Grouping every consecutive
15
+ * non-context run into one block is what makes "do I want this change?" a single decision.
16
+ */
17
+ export type DiffBlock = {
18
+ kind: 'context';
19
+ lines: string[];
20
+ } | {
21
+ kind: 'change';
22
+ removed: string[];
23
+ added: string[];
24
+ };
25
+ /** Group a line diff into context runs and change blocks, in display order. */
26
+ export declare function diffBlocks(lines: DiffLine[]): DiffBlock[];
27
+ /**
28
+ * Rebuild the text implied by a set of per-change choices.
29
+ *
30
+ * The invariant worth holding onto, and the reason this is a pure function with its own
31
+ * tests: all-`original` reproduces the original EXACTLY and all-`modified` reproduces the
32
+ * modified exactly. A resolver that cannot round-trip its own endpoints is one you cannot
33
+ * trust in the middle.
34
+ */
35
+ export declare function resolveDiff(blocks: DiffBlock[], choices: DiffResolution[]): string;
7
36
  /**
8
37
  * Line-level diff of two strings via longest-common-subsequence, returning each
9
38
  * line tagged `context` (unchanged), `remove` (only in `before`), or `add` (only
@@ -18,6 +47,9 @@ export declare class TosiDiff extends Component<DiffParts> {
18
47
  static initAttributes: {
19
48
  original: string;
20
49
  modified: string;
50
+ resolvable: boolean;
51
+ originalLabel: string;
52
+ modifiedLabel: string;
21
53
  };
22
54
  static shadowStyleSpec: {
23
55
  ':host': {
@@ -53,8 +85,60 @@ export declare class TosiDiff extends Component<DiffParts> {
53
85
  '.diff-remove .marker': {
54
86
  color: string;
55
87
  };
88
+ '.diff-hunk': {
89
+ display: string;
90
+ position: string;
91
+ margin: string;
92
+ borderRadius: string;
93
+ boxShadow: string;
94
+ };
95
+ '.diff-choices': {
96
+ display: string;
97
+ gap: string;
98
+ alignItems: string;
99
+ padding: string;
100
+ };
101
+ '.diff-choices button': {
102
+ font: string;
103
+ cursor: string;
104
+ padding: string;
105
+ borderRadius: string;
106
+ border: string;
107
+ color: string;
108
+ background: string;
109
+ };
110
+ '.diff-choices button[aria-pressed="true"]': {
111
+ background: string;
112
+ color: string;
113
+ };
114
+ '.diff-line.not-chosen': {
115
+ opacity: string;
116
+ textDecoration: string;
117
+ textDecorationColor: string;
118
+ };
56
119
  };
57
120
  content: () => HTMLDivElement[];
121
+ private _resolutions;
122
+ private resolvedOriginal;
123
+ private resolvedModified;
124
+ /** One entry per change block, in document order. */
125
+ get resolutions(): DiffResolution[];
126
+ set resolutions(choices: DiffResolution[]);
127
+ private get blocks();
128
+ /** Number of decisions this diff asks for. `0` means the two texts agree. */
129
+ get changeCount(): number;
130
+ /**
131
+ * The text implied by the current choices. With everything accepted this is exactly
132
+ * `modified`, and with everything rejected exactly `original`, so a host that never shows
133
+ * the controls still gets a sensible value.
134
+ */
135
+ get value(): string;
136
+ private setAll;
137
+ acceptAll: () => void;
138
+ rejectAll: () => void;
139
+ private syncResolutions;
140
+ private choose;
141
+ private lineElement;
58
142
  render(): void;
59
143
  }
60
144
  export declare const tosiDiff: ElementCreator<TosiDiff>;