tosijs-ui 1.12.5 → 1.12.6

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,124 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.6
4
+
5
+ ### `<tosi-diff resolvable>` — "do I want to accept this?"
6
+
7
+ The diff viewer can now be operated, not just read. Set `resolvable` and every change gets a
8
+ pair of buttons; `value` is the text those choices imply and `change` fires when one moves. It
9
+ is a review surface for a **proposed** edit — an AI suggestion, a collaborator's revision, a
10
+ file that moved under you while you were editing it.
11
+
12
+ The labels are yours: `originalLabel`/`modifiedLabel`. "Mine"/"Theirs" is git's framing,
13
+ "Current"/"Proposed" suits reviewing a suggestion, and this component's own vocabulary is
14
+ original/modified — none of which is right for everyone, so none of it is hardcoded. Also
15
+ `acceptAll()`, `rejectAll()`, `changeCount`, and a `resolutions` array you can read and write.
16
+ `diffBlocks()` and `resolveDiff()` are exported too, if you want the model without the DOM.
17
+
18
+ The unit of decision is a change **block**, not a line. The LCS walk can emit
19
+ remove/add/remove/add for a single multi-line edit, and offering four independent choices there
20
+ lets a reviewer accept half an edit — producing text neither side wrote.
21
+
22
+ The read-only path is deliberately unchanged: grouping reorders interleaved edits, which is
23
+ better to resolve and a **different rendering** for the two components already shipping this
24
+ one. Additive means additive.
25
+
26
+ Two notes on how this was tested, because **both** bugs that nearly shipped were invisible to
27
+ the unit lane, and for the same underlying reason.
28
+
29
+ The first version rendered **nothing**: `dataset: {…}` in an element creator assigns
30
+ `el.dataset`, a read-only accessor, so `render()` threw and left an empty shadow root.
31
+ Twenty-six green tests sat behind it, every one asserting `value` — which is computed from the
32
+ model and is correct whether or not a single element reaches the DOM. tosijs renders on an
33
+ animation frame and happy-dom runs none, so a unit test that merely _mounts_ an element asserts
34
+ against an empty shadow root; appending hydrates, and `render()` must be called explicitly.
35
+
36
+ The second was worse, because the render-level tests added for the first one _also_ passed. The
37
+ buttons did nothing when clicked, in every browser. `<tosi-diff>` is a shadow-DOM component and
38
+ tosijs delegates `on*` handlers from above the shadow boundary, so a real click is
39
+ **retargeted**: `event.target` is the host, not the button, and `event.target.closest(…)` — the
40
+ idiom this project documents, and which is correct for a light-DOM component — is `null` every
41
+ time. The fix is `composedPath()`, the only one of the two that survives the boundary.
42
+
43
+ The happy-dom test could not see it: it called `.click()` on a node it had already looked up and
44
+ then asserted the value moved. Measured against a deliberately broken build, **30 unit tests
45
+ pass and all 3 browser tests fail** — so `tests/diff-resolve.pw.ts` now clicks what a person
46
+ clicks and reads what a host reads. The rule this leaves behind: **shadow-DOM interaction needs
47
+ a browser test**, not a DOM-shaped unit test.
48
+
49
+ ### Selective revert in live examples
50
+
51
+ The diff resolver is wired into `<tosi-example>`'s **View changes**, and into `<tosi-code>`
52
+ generally via a new `diffResolvable` (plus `diffOriginalLabel`/`diffModifiedLabel`).
53
+
54
+ Revert was all-or-nothing, which is the wrong shape for what actually happens: you tried four
55
+ things and three worked. Each change in the diff now carries keep/revert buttons — labelled
56
+ **Source** and **Yours** in an example — defaulting to _keep_, so the feature costs nothing to
57
+ ignore and doing nothing leaves your edit exactly as it was.
58
+
59
+ Choices are applied when the diff **closes**, not as you click, and a `change` event fires if
60
+ the text moved. Writing each choice straight back into `value` would feed the new text into the
61
+ overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes — so live
62
+ application would wipe the choices making it and renumber the hunks under the pointer.
63
+
64
+ ### Fixed: `<tosi-diff>` and notifications ignored dark mode
65
+
66
+ Both hardcoded a light palette — `#fff`/`#222` for the diff, `#fafafa`/`#444` for
67
+ notifications — so on a dark page you got a white block, and in the notification's case the one
68
+ element in the library that appears unbidden over whatever you are looking at was also the one
69
+ that ignored your theme.
70
+
71
+ Both now fall back to the theme (`--background`/`--text-color`) with the old literals as the
72
+ final fallback, so an unthemed page is unchanged and every `--tosi-diff-*` /
73
+ `--notification-*` override still wins exactly as before.
74
+
75
+ The diff needed both halves at once: darkening the surface alone would have been **worse** than
76
+ leaving it, because light text on a solid `#e6ffed` row is unreadable. The add/remove tints are
77
+ now translucent (`color-mix` with `transparent`), so one value reads correctly as a wash over
78
+ either surface.
79
+
80
+ ### 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))
81
+
82
+ 1.12.5 added a build lock that records `pid`, `role`, `root` and `port` per project, so a
83
+ `stop` command can identify what is running instead of `pkill`-ing sibling checkouts. It worked
84
+ — and none of it was reachable from outside the package. `import('tosijs-ui/site')` gave
85
+ `currentHolder === undefined`, and the deep path is blocked by the exports map.
86
+
87
+ `currentHolder`, `describeHolder`, `lockPathFor`, `lockDecision`, `isProcessAlive` and the
88
+ `LockHolder`/`LockDecision` types are now re-exported from `tosijs-ui/site`.
89
+
90
+ The reporter deliberately did **not** re-derive the lock path locally, and was right not to:
91
+ that means copying the FNV-1a hash of the resolved root, and if the hash ever changed the
92
+ consumer would report "nothing running" _while a server runs_. Silently-wrong is the exact
93
+ failure this whole area exists to eliminate; duplicating the algorithm reintroduces it one
94
+ layer out.
95
+
96
+ ### haltija floor raised to `^1.12.6`
97
+
98
+ The floor encodes fixes, not a date. 1.12.6 is where a `--private` instance finally gained a
99
+ **lifetime bound** (haltija#39, filed from here after one was found 12 days old at 5.7 GB and
100
+ ~150% CPU on a machine at load average 212); where **LAN and Bonjour access started working at
101
+ all** (served scripts handed the browser `localhost:<port>`, and `localhost` in a served script
102
+ means the _browser's_ machine, so a phone was told to connect to itself); where the widget sends
103
+ `X-Haltija-Token`, so a `--token` server and a page that can talk back stopped being mutually
104
+ exclusive; where results carry `paintAgeMs`, making a rAF-starved tab detectable rather than
105
+ silently wrong; and where Electron went 40.6.1 → 43.4.1, clearing two context-isolation
106
+ bypasses in the thing we spawn.
107
+
108
+ (Internal, but worth recording: our own `CLAUDE.md` documented this pin as `^1.6.1` long after
109
+ the code had moved to `^1.11.2` — a stale number in the one place a reader would trust for it.
110
+ Corrected.)
111
+
112
+ ### Docs: our haltija gate covers the bridge, not the port
113
+
114
+ The `haltijaDev: 'tunnel'` documentation read as a complete security story — session cookie,
115
+ 404-on-unauthorized, relay rather than hole — and a reader could reasonably conclude the whole
116
+ surface was gated. It is not. Enabling `haltijaDev` at all means haltija's **own** port is
117
+ running, and a default instance binds beyond loopback with `Access-Control-Allow-Origin: *` and
118
+ no token, where `POST /terminal/command` reaches a shell. Set `HALTIJA_TOKEN` on a network you
119
+ do not control; it only became genuinely usable in haltija 1.12.6, which is why older advice
120
+ said to skip it.
121
+
3
122
  ## 1.12.5
4
123
 
5
124
  ### 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>;