pi-midcompact 0.5.3 → 0.6.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.
@@ -14,313 +14,324 @@
14
14
  } catch (e) {}
15
15
  </script>
16
16
  <style>
17
+ /* ---- tokens: zinc-neutral surfaces, color reserved for semantics ---- */
17
18
  :root {
18
- --bg: #0b0e14;
19
- --panel: #11151f;
20
- --panel-2: #161b28;
21
- --elev: #1d2333;
22
- --line: #232a3d;
23
- --line-soft: #1a2032;
24
- --fg: #e6e9f2;
25
- --fg-dim: #8f9ab4;
26
- --fg-mute: #5f6a84;
27
- --accent: #7aa2f7;
28
- --accent-2: #bb9af7;
29
- --keep: #9ece6a;
30
- --range: #7dcfff;
31
- --warn: #e0af68;
32
- --danger: #f7768e;
33
- --mark: rgba(224,175,104,.28);
34
- --shadow: 0 10px 34px rgba(0,0,0,.45);
35
- --ring: rgba(122,162,247,.45);
36
- --r: 8px;
37
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
38
- --sans: "HarmonyOS Sans SC", "HarmonyOS Sans", "Noto Sans CJK SC", "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", system-ui, -apple-system, "Segoe UI", sans-serif;
19
+ --font-sans: Inter, "HarmonyOS Sans SC", "Noto Sans CJK SC", "Microsoft YaHei UI", system-ui, "Segoe UI", sans-serif;
20
+ --font-mono: "JetBrains Mono", ui-monospace, Consolas, Menlo, monospace;
21
+ --r: 8px; --r-lg: 10px;
22
+ --ring: rgba(24, 24, 27, .25);
23
+ --shadow: 0 18px 50px rgba(9, 9, 11, .22);
39
24
  }
40
25
  [data-theme="light"] {
41
- --bg: #f3f5fa;
42
- --panel: #ffffff;
43
- --panel-2: #f7f9fc;
44
- --elev: #eaeef6;
45
- --line: #dbe1ee;
46
- --line-soft: #e8ecf5;
47
- --fg: #1a1f2b;
48
- --fg-dim: #5b6479;
49
- --fg-mute: #8a93a8;
50
- --accent: #3b6ef6;
51
- --accent-2: #7c3aed;
52
- --keep: #17914f;
53
- --range: #0d84c4;
54
- --warn: #b45309;
55
- --danger: #d43b52;
56
- --mark: rgba(224,175,104,.42);
57
- --shadow: 0 10px 30px rgba(31,41,55,.12);
58
- --ring: rgba(59,110,246,.35);
26
+ --bg: #fafafa; --panel: #ffffff; --card: #ffffff; --elev: #f4f4f5;
27
+ --border: #e4e4e7; --border-soft: #ececef; --border-strong: #a1a1aa;
28
+ --fg: #09090b; --fg-dim: #52525b; --fg-mute: #71717a;
29
+ --primary: #18181b; --primary-fg: #fafafa;
30
+ --keep: #059669; --warn: #b45309; --danger: #dc2626;
31
+ --sel-bg: #f4f4f5; --sel-border: #09090b;
32
+ --bar-now: #18181b; --bar-fill: rgba(24, 24, 27, .72); --bar-track: rgba(24, 24, 27, .18);
33
+ --tint-read: rgba(14, 165, 233, .14); --tint-read-fg: #0369a1;
34
+ --tint-bash: rgba(245, 158, 11, .17); --tint-bash-fg: #92400e;
35
+ --tint-asst: rgba(139, 92, 246, .14); --tint-asst-fg: #6d28d9;
36
+ --tint-lock: rgba(9, 9, 11, .06); --tint-lock-fg: #52525b;
37
+ }
38
+ [data-theme="dark"] {
39
+ --bg: #09090b; --panel: #0c0c0d; --card: #101012; --elev: #18181b;
40
+ --border: #27272a; --border-soft: #202024; --border-strong: #52525b;
41
+ --fg: #fafafa; --fg-dim: #d4d4d8; --fg-mute: #a1a1aa;
42
+ --primary: #fafafa; --primary-fg: #18181b;
43
+ --keep: #10b981; --warn: #f59e0b; --danger: #ef4444;
44
+ --sel-bg: #18181b; --sel-border: #fafafa;
45
+ --bar-now: #fafafa; --bar-fill: rgba(250, 250, 250, .7); --bar-track: rgba(250, 250, 250, .16);
46
+ --tint-read: rgba(56, 189, 248, .16); --tint-read-fg: #7dd3fc;
47
+ --tint-bash: rgba(245, 158, 11, .18); --tint-bash-fg: #fcd34d;
48
+ --tint-asst: rgba(167, 139, 250, .16); --tint-asst-fg: #c4b5fd;
49
+ --tint-lock: rgba(250, 250, 250, .07); --tint-lock-fg: #a1a1aa;
59
50
  }
60
51
 
61
52
  * { box-sizing: border-box; }
62
53
  html, body { height: 100%; margin: 0; }
63
54
  body {
64
55
  background: var(--bg); color: var(--fg);
65
- font: 13px/1.55 var(--sans);
56
+ font: 14px/1.6 var(--font-sans);
66
57
  display: flex; flex-direction: column;
67
58
  -webkit-font-smoothing: antialiased;
68
59
  }
69
60
  button, input, textarea { font: inherit; color: inherit; }
70
- :focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
71
- ::selection { background: var(--ring); }
72
- .mono { font-family: var(--mono); }
73
-
74
- /* ---------- header ---------- */
75
- header {
76
- background: var(--panel); border-bottom: 1px solid var(--line);
77
- padding: 12px 18px 14px; flex: 0 0 auto;
78
- }
79
- .bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
80
- .brand { font-size: 15px; font-weight: 650; letter-spacing: 0; margin: 0; }
81
- .brand b { color: var(--accent); font-weight: 750; }
82
- .chip {
83
- background: var(--panel-2); border: 1px solid var(--line); color: var(--fg-dim);
84
- padding: 3px 9px; border-radius: 99px; font-size: 11px; font-family: var(--mono); white-space: nowrap;
61
+ :focus-visible { outline: 2px solid var(--border-strong); outline-offset: 1px; }
62
+ .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
63
+ @media (prefers-reduced-motion: reduce) {
64
+ *, *::before, *::after { transition: none !important; }
85
65
  }
86
- .chip.dirty { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
87
- .spacer { flex: 1 1 auto; }
88
66
 
67
+ /* ---- dead session banner ---- */
68
+ .banner { display: none; padding: 9px 18px; font-size: 13px; background: color-mix(in srgb, var(--danger) 12%, var(--panel)); border-bottom: 1px solid var(--danger); color: var(--danger); flex: 0 0 auto; }
69
+ .banner.show { display: block; }
70
+ body.dead .field, body.dead .btn, body.dead .ritem, body.dead .srow { pointer-events: none; opacity: .55; }
71
+
72
+ /* ---- header ---- */
73
+ header { background: var(--panel); border-bottom: 1px solid var(--border); padding: 10px 16px 12px; flex: 0 0 auto; }
74
+ .bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
75
+ .brand { font-size: 15px; font-weight: 650; margin: 0; }
76
+ .brand b { color: var(--primary); font-weight: 700; }
77
+ .chip { background: var(--elev); border: 1px solid var(--border); color: var(--fg-dim); padding: 2px 10px; border-radius: 99px; font-size: 12px; font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
78
+ .chip.dirty { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); }
79
+ .chip.ok { color: var(--keep); border-color: color-mix(in srgb, var(--keep) 40%, var(--border)); }
80
+ .spacer { flex: 1 1 auto; }
89
81
  .btn {
90
- border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
91
- padding: 6px 12px; border-radius: 8px; cursor: pointer;
92
- transition: background .13s, border-color .13s, transform .06s, opacity .13s;
82
+ border: 1px solid var(--border); background: var(--card); color: var(--fg);
83
+ padding: 6px 13px; border-radius: 8px; cursor: pointer; font-size: 13px;
93
84
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
85
+ transition: background .12s, border-color .12s, opacity .12s;
94
86
  }
95
- .btn:hover { background: var(--elev); border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
87
+ .btn:hover { background: var(--elev); }
96
88
  .btn:active { transform: translateY(1px); }
97
- .btn[disabled] { opacity: .42; cursor: not-allowed; transform: none; }
98
- .btn-primary { background: var(--accent); border-color: var(--accent); color: #0b0e14; font-weight: 600; }
99
- [data-theme="light"] .btn-primary { color: #fff; }
100
- .btn-primary:hover { filter: brightness(1.07); background: var(--accent); }
101
- .btn-danger { background: transparent; border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger); }
102
- .btn-danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: var(--danger); }
89
+ .btn[disabled] { opacity: .45; cursor: not-allowed; transform: none; }
90
+ .btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); font-weight: 600; }
91
+ .btn-primary:hover { background: var(--primary); opacity: .9; }
92
+ .btn-danger { background: transparent; border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
93
+ .btn-danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); border-color: var(--danger); }
103
94
  .btn-danger.armed { background: var(--danger); border-color: var(--danger); color: #fff; }
104
- .btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
95
+ .btn-sm { padding: 4px 11px; font-size: 12.5px; border-radius: 7px; }
105
96
  .btn-icon { padding: 6px 9px; }
106
97
 
107
- .metrics { display: grid; grid-template-columns: 2fr repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
108
- .metric {
109
- background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r); padding: 9px 12px 10px;
110
- }
111
- .metric > label {
112
- display: block; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute);
113
- }
114
- .metric .val { font-family: var(--mono); font-size: 14px; margin-top: 3px; }
115
- .metric .val small { color: var(--fg-dim); font-size: 11px; }
116
- .track {
117
- position: relative; height: 7px; background: var(--elev); border-radius: 99px; margin-top: 8px; overflow: hidden;
118
- }
119
- .track i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; display: block; }
120
- .track .anchor { background: color-mix(in srgb, var(--warn) 55%, transparent); }
121
- .track .proj { background: var(--accent); }
122
- .legend { display: flex; gap: 12px; margin-top: 6px; font-size: 10.5px; color: var(--fg-dim); flex-wrap: wrap; }
123
- .legend b { font-weight: 500; font-family: var(--mono); color: var(--fg); }
124
- .dotm { width: 7px; height: 7px; border-radius: 99px; display: inline-block; margin-right: 4px; vertical-align: baseline; }
125
-
126
- /* ---------- layout ---------- */
127
- main {
128
- flex: 1 1 auto; min-height: 0;
129
- display: grid; gap: 1px; background: var(--line);
130
- grid-template-columns: 276px minmax(0, 1fr) 388px;
131
- grid-template-areas: "ranges timeline editor";
98
+ /* ---- decision strip ---- */
99
+ .strip {
100
+ display: flex; align-items: center; flex-wrap: wrap;
101
+ margin-top: 12px; background: var(--card); border: 1px solid var(--border);
102
+ border-radius: var(--r-lg); padding: 11px 16px;
132
103
  }
104
+ .di { display: flex; align-items: baseline; gap: 7px; padding: 0 17px; }
105
+ .di:first-child { padding-left: 0; }
106
+ .di + .di { border-left: 1px solid var(--border); }
107
+ .di label { font-size: 12.5px; color: var(--fg-mute); }
108
+ .di .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 15px; font-weight: 600; white-space: nowrap; }
109
+ .di .val.save { color: var(--keep); font-weight: 700; }
110
+ .di .val.pend { color: var(--warn); }
111
+ .pbar { flex: 1 1 260px; min-width: 240px; padding-left: 17px; border-left: 1px solid var(--border); }
112
+ .pbar .pl { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--fg-mute); margin-bottom: 5px; flex-wrap: wrap; }
113
+ .pbar .pl b { color: var(--fg-dim); font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 600; }
114
+ .pbar .pl .delta { color: var(--keep); font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; }
115
+ .pbar .tr { position: relative; height: 12px; background: var(--elev); border-radius: 99px; }
116
+ .pbar .kept { position: absolute; left: 0; top: 0; bottom: 0; background: var(--bar-now); border-radius: 99px 0 0 99px; }
117
+ .pbar .free { position: absolute; top: 0; bottom: 0; background: repeating-linear-gradient(45deg, var(--bar-fill) 0 4px, var(--bar-track) 4px 8px); border-radius: 0 99px 99px 0; }
118
+ .pbar .na { position: absolute; inset: 0; display: grid; place-items: center; font-size: 11.5px; color: var(--fg-mute); }
119
+
120
+ .pbar[data-toggle-mode] { cursor: pointer; }
121
+ .pbar[data-toggle-mode]:hover { background: color-mix(in srgb, var(--elev) 70%, transparent); }
122
+
123
+ /* ---- layout ---- */
124
+ main { flex: 1 1 auto; min-height: 0; display: grid; gap: 1px; background: var(--border); grid-template-columns: 288px minmax(0, 1fr) 430px; grid-template-areas: "ranges timeline editor"; }
133
125
  .pane { background: var(--bg); min-width: 0; min-height: 0; display: flex; flex-direction: column; }
134
126
  .pane.ranges { grid-area: ranges; background: var(--panel); }
135
- .pane.timeline { grid-area: timeline; }
136
127
  .pane.editor { grid-area: editor; background: var(--panel); }
137
- .pane-head {
138
- flex: 0 0 auto; padding: 10px 12px; border-bottom: 1px solid var(--line-soft);
139
- display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
140
- }
141
- .pane-title { font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute); }
128
+ .pane-head { flex: 0 0 auto; padding: 10px 14px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; position: sticky; top: 0; background: inherit; z-index: 3; }
129
+ .pane-title { font-size: 11.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute); font-weight: 600; }
142
130
  .scroll { flex: 1 1 auto; overflow: auto; min-height: 0; scrollbar-width: thin; }
143
131
  .scroll::-webkit-scrollbar { width: 10px; height: 10px; }
144
132
  .scroll::-webkit-scrollbar-thumb { background: var(--elev); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
145
- .scroll::-webkit-scrollbar-thumb:hover { background: var(--line); background-clip: content-box; }
146
133
 
147
134
  .search { position: relative; flex: 1 1 140px; }
148
- .search input {
149
- width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
150
- padding: 6px 26px 6px 28px; font-size: 12px;
151
- }
152
- .search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
153
- .search .ico { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--fg-mute); font-size: 12px; }
154
- .search .clr {
155
- position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
156
- background: none; border: 0; color: var(--fg-mute); cursor: pointer; padding: 2px 5px; border-radius: 6px;
157
- }
135
+ .search input { width: 100%; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 6px 26px 6px 30px; font-size: 13px; }
136
+ .search input:focus { outline: none; border-color: var(--border-strong); box-shadow: 0 0 0 3px var(--ring); }
137
+ .search .ico { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--fg-mute); font-size: 12px; }
138
+ .search .clr { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); background: none; border: 0; color: var(--fg-mute); cursor: pointer; padding: 2px 6px; border-radius: 6px; }
158
139
  .search .clr:hover { color: var(--fg); background: var(--elev); }
140
+ .seg { display: inline-flex; background: var(--elev); border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
141
+ .seg button { border: 0; background: none; color: var(--fg-dim); padding: 3px 11px; border-radius: 6px; cursor: pointer; font-size: 12px; }
142
+ .seg button[aria-pressed="true"] { background: var(--card); color: var(--fg); box-shadow: 0 1px 2px rgba(0,0,0,.08); }
159
143
 
160
- .seg { display: inline-flex; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
161
- .seg button {
162
- border: 0; background: none; color: var(--fg-dim); padding: 3px 9px; border-radius: 6px; cursor: pointer; font-size: 11.5px;
163
- }
164
- .seg button[aria-pressed="true"] { background: var(--elev); color: var(--fg); }
165
-
166
- /* ---------- range list ---------- */
167
- .rlist { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
144
+ /* ---- range list ---- */
145
+ .rlist { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
168
146
  .ritem {
169
- width: 100%; text-align: left; background: var(--panel-2); border: 1px solid var(--line);
170
- border-left: 3px solid var(--line); border-radius: 8px; padding: 8px 10px; cursor: pointer;
171
- display: grid; gap: 3px; transition: background .13s, border-color .13s;
172
- }
173
- .ritem:hover { background: var(--elev); }
174
- .ritem[aria-selected="true"] { border-color: var(--range); border-left-color: var(--range); background: color-mix(in srgb, var(--range) 12%, var(--panel-2)); }
175
- .ritem .r1 { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; }
176
- .ritem .rid { color: var(--range); font-weight: 700; }
177
- .ritem .refs { color: var(--fg-mute); font-size: 11px; }
178
- .ritem .save { margin-left: auto; color: var(--keep); font-size: 11px; }
179
- .ritem .rsum {
180
- color: var(--fg-dim); font-size: 11.5px; line-height: 1.45;
181
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
147
+ position: relative; width: 100%; text-align: left; background: var(--card);
148
+ border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: var(--r);
149
+ padding: 11px 12px; cursor: pointer; display: grid; gap: 4px;
150
+ transition: background .12s, border-color .12s;
182
151
  }
183
- .ritem .meta { color: var(--fg-mute); font-size: 10.5px; font-family: var(--mono); }
184
- .udot { width: 6px; height: 6px; border-radius: 99px; background: var(--warn); display: inline-block; }
185
-
186
- /* ---------- timeline ---------- */
187
- .tl { padding: 10px 12px 40px; display: flex; flex-direction: column; gap: 8px; }
188
- .group { border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--panel); }
189
- .group.keepg { background: transparent; border-color: var(--line-soft); }
152
+ .ritem:hover { border-color: var(--border-strong); }
153
+ .ritem[aria-selected="true"] { border-color: var(--sel-border); border-left-color: var(--sel-border); background: var(--sel-bg); }
154
+ .ritem .r1 { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 13px; }
155
+ .ritem .rid { color: var(--fg); font-weight: 700; }
156
+ .ritem .refs { color: var(--fg-mute); font-size: 12px; }
157
+ .ritem .save { margin-left: auto; color: var(--keep); font-size: 12px; font-weight: 700; }
158
+ .ritem .topic { color: var(--fg-dim); font-size: 12px; font-family: var(--font-mono); }
159
+ .ritem .rsum { color: var(--fg-dim); font-size: 12.5px; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
160
+ .ritem .meta { color: var(--fg-mute); font-size: 11.5px; font-family: var(--font-mono); font-variant-numeric: tabular-nums; display: flex; gap: 12px; flex-wrap: wrap; }
161
+ .udot { width: 7px; height: 7px; border-radius: 99px; background: var(--warn); display: inline-block; }
162
+ .pend { color: var(--warn); font-size: 11.5px; }
163
+ .pend::before { content: "● "; font-size: 9px; vertical-align: 1px; }
164
+ .rev { color: var(--keep); font-size: 11.5px; }
165
+ .empty { color: var(--fg-mute); text-align: center; padding: 40px 18px; font-size: 13px; line-height: 1.7; }
166
+
167
+ /* ---- timeline ---- */
168
+ .tl { padding: 12px 14px 44px; display: flex; flex-direction: column; gap: 10px; }
169
+ .group { border: 1px solid var(--border); border-left: 3px solid var(--primary); border-radius: var(--r-lg); overflow: hidden; background: var(--card); }
170
+ .group.keepg { border-left-color: var(--keep); }
171
+ .group.sel { border-color: var(--sel-border); }
190
172
  .ghead {
191
- position: sticky; top: 0; z-index: 2; width: 100%; text-align: left; cursor: pointer;
192
- display: grid; grid-template-columns: 34px auto minmax(0, 1fr) auto; align-items: center; gap: 8px;
193
- padding: 8px 10px; background: var(--panel-2); border: 0; border-bottom: 1px solid var(--line-soft);
173
+ width: 100%; text-align: left; cursor: pointer;
174
+ display: grid; grid-template-columns: 20px auto minmax(0, 1fr) auto; align-items: center; gap: 10px;
175
+ padding: 10px 12px; background: var(--elev); border: 0; border-bottom: 1px solid var(--border-soft);
194
176
  }
195
- .ghead:hover { background: var(--elev); }
196
- .group.sel { border-color: var(--range); box-shadow: 0 0 0 1px color-mix(in srgb, var(--range) 40%, transparent); }
197
- .group.sel .ghead { background: color-mix(in srgb, var(--range) 13%, var(--panel-2)); }
198
- .chev { color: var(--fg-mute); transition: transform .15s; font-size: 11px; text-align: center; }
177
+ .group.keepg .ghead { background: transparent; }
178
+ .ghead:hover { filter: brightness(.97); }
179
+ [data-theme="dark"] .ghead:hover { filter: brightness(1.15); }
180
+ .chev { color: var(--fg-mute); transition: transform .15s; font-size: 10px; text-align: center; }
199
181
  .group.open .chev { transform: rotate(90deg); }
200
- .ghead .gid { font-family: var(--mono); font-weight: 700; color: var(--range); font-size: 12px; }
201
- .group.keepg .ghead .gid { color: var(--keep); }
202
- .ghead .gsum { color: var(--fg-dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
203
- .ghead .gstat { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); white-space: nowrap; }
204
- .ghead .gstat em { color: var(--keep); font-style: normal; }
205
- .gbody { display: none; padding: 4px; }
206
- .group.open .gbody { display: block; }
207
-
208
- .atom {
209
- display: grid; grid-template-columns: 74px 84px 58px minmax(0, 1fr); gap: 10px; align-items: start;
210
- padding: 7px 8px; border-radius: 8px; border: 1px solid transparent;
211
- }
212
- .atom + .atom { border-top: 1px solid var(--line-soft); border-radius: 0; }
213
- .atom:hover { background: var(--panel-2); }
214
- .atom .aref { font-family: var(--mono); font-size: 11.5px; color: var(--accent-2); }
215
- .atom .akind { font-family: var(--mono); font-size: 10.5px; color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; }
216
- .atom .atok { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); text-align: right; }
182
+ .gid { font-family: var(--font-mono); font-weight: 700; color: var(--fg); font-size: 13px; }
183
+ .group.keepg .gid { color: var(--keep); }
184
+ .gsum { color: var(--fg-dim); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
+ .gstat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; color: var(--fg-mute); white-space: nowrap; display: flex; align-items: center; gap: 10px; }
186
+ .gstat em { color: var(--keep); font-style: normal; font-weight: 700; }
187
+ .gbody { padding: 4px; }
188
+ .gbody[hidden] { display: none; }
189
+
190
+ .atom { display: grid; grid-template-columns: 64px minmax(0, 1fr) 78px; gap: 12px; align-items: start; padding: 8px 10px; border-radius: 8px; }
191
+ .atom + .atom { border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); }
192
+ .atom:hover { background: color-mix(in srgb, var(--elev) 55%, transparent); }
193
+ .atom .aref { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; color: var(--fg-mute); }
194
+ .atom .arow { display: flex; align-items: center; gap: 7px; min-width: 0; }
217
195
  .atom .abody {
218
- font-family: var(--mono); font-size: 11.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
219
- display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: zoom-in;
196
+ flex: 1 1 auto; min-width: 0; padding: 0; border: 0; background: none; text-align: left;
197
+ font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; color: var(--fg); opacity: .84;
198
+ white-space: pre-wrap; word-break: break-word;
199
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer;
220
200
  }
221
- .atom.open .abody { -webkit-line-clamp: unset; cursor: zoom-out; }
222
- .atom .tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
223
- .tag {
224
- font-family: var(--mono); font-size: 10px; padding: 0 5px; border-radius: 5px;
225
- background: var(--elev); color: var(--fg-dim); border: 1px solid var(--line-soft);
226
- }
227
- .tag.lock { color: var(--warn); }
228
- .tag.tool { font-weight: 700; color: var(--accent); border-color: var(--accent); }
229
- .tag.tool.read { color: var(--range); border-color: var(--range); }
230
- .tag.tool.write { color: var(--keep); border-color: var(--keep); }
231
- .tag.tool.edit { color: var(--accent-2); border-color: var(--accent-2); }
232
- .tag.tool.bash { color: var(--warn); border-color: var(--warn); }
233
- .atom-actions { display: flex; gap: 6px; margin-top: 8px; }
234
- .atom.selected-atom { background: color-mix(in srgb, var(--range) 10%, transparent); border-color: color-mix(in srgb, var(--range) 35%, transparent); }
235
- .atom.kept-atom { background: color-mix(in srgb, var(--keep) 8%, transparent); }
236
- mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
237
-
238
- /* ---------- editor ---------- */
239
- .epad { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
240
- .etitle { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
241
- .etitle .eid { font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--range); }
242
- .etitle .erefs { font-family: var(--mono); font-size: 11.5px; color: var(--fg-mute); }
243
- .estats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
244
- .estat { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; }
245
- .estat label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-mute); }
246
- .estat span { font-family: var(--mono); font-size: 12.5px; }
247
- .field label {
248
- display: flex; align-items: center; gap: 8px; font-size: 10.5px; text-transform: uppercase;
249
- letter-spacing: .06em; color: var(--fg-mute); margin-bottom: 5px;
250
- }
251
- .field label .count { margin-left: auto; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
201
+ .atom .abody:hover { opacity: 1; }
202
+ .atom.user .abody { font-weight: 500; opacity: 1; }
203
+ .atom .atok { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11.5px; color: var(--fg-dim); font-weight: 500; text-align: right; }
204
+ .sz { display: block; height: 4px; width: 52px; margin: 5px 0 1px auto; border-radius: 99px; background: linear-gradient(to right, var(--bar-fill) var(--w, 30%), var(--bar-track) var(--w, 30%)); }
205
+ .tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; }
206
+ .tag { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; padding: 1px 7px; border-radius: 5px; background: var(--elev); color: var(--fg-dim); border: 1px solid transparent; }
207
+ .tag.kind-user { background: var(--primary); color: var(--primary-fg); font-weight: 600; }
208
+ .tag.kind-assistant { background: var(--tint-asst); color: var(--tint-asst-fg); }
209
+ .tag.kind-compressed, .tag.kind-custom, .tag.kind-other, .tag.kind-orphan_tool_result { background: var(--tint-lock); color: var(--tint-lock-fg); }
210
+ .tag.tool { background: var(--elev); }
211
+ .tag.tool.read { background: var(--tint-read); color: var(--tint-read-fg); }
212
+ .tag.tool.bash { background: var(--tint-bash); color: var(--tint-bash-fg); }
213
+ .tag.lock { background: var(--tint-lock); color: var(--tint-lock-fg); }
214
+ .atom.selected-atom { background: var(--sel-bg); outline: 2px solid var(--sel-border); outline-offset: -2px; box-shadow: inset 3px 0 0 var(--sel-border); }
215
+ .atom.cursor-atom { outline: 2px dashed var(--border-strong); outline-offset: -2px; }
216
+ .atom.locked { opacity: .6; }
217
+ .atom.srow { grid-template-columns: 64px minmax(0, 1fr) 128px 78px; align-items: center; }
218
+ .atom.srow .abody { display: block; white-space: nowrap; text-overflow: ellipsis; }
219
+ .mark-highlight { background: color-mix(in srgb, var(--warn) 30%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
220
+ mark { background: color-mix(in srgb, var(--warn) 30%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
221
+ .srow { cursor: pointer; }
222
+ .mark { font-size: 10.5px; font-family: var(--font-mono); white-space: nowrap; }
223
+ .mark.sel { color: var(--primary); font-weight: 600; }
224
+ .mark.lock { color: var(--fg-mute); }
225
+
226
+ /* ---- projected summary cards ---- */
227
+ .pcard { background: var(--panel); border: 1px solid var(--border); border-left: 3px solid var(--primary); border-radius: var(--r-lg); padding: 12px 14px; }
228
+ .pcard.pend { border-left-color: var(--warn); border-style: dashed; background: color-mix(in srgb, var(--warn) 6%, var(--panel)); }
229
+ .phead { display: flex; align-items: baseline; gap: 9px; margin-bottom: 6px; flex-wrap: wrap; }
230
+ .pid { font-family: var(--font-mono); font-weight: 700; color: var(--fg); }
231
+ .ptopic { color: var(--fg); font-size: 13px; font-weight: 600; }
232
+ .ptopic.warn { color: var(--warn); }
233
+ .pmeta { margin-left: auto; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11.5px; color: var(--fg-mute); white-space: nowrap; }
234
+ .ptext { color: var(--fg-dim); font-size: 13px; line-height: 1.65; }
235
+ .pwarnline { color: var(--warn); font-size: 12.5px; }
236
+ .pactions { margin-top: 10px; display: flex; gap: 8px; }
237
+
238
+ /* ---- editor ---- */
239
+ .epad { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
240
+ .etitle { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
241
+ .etitle .eid { font-family: var(--font-mono); font-size: 16px; font-weight: 700; color: var(--fg); }
242
+ .etitle .erefs { font-family: var(--font-mono); font-size: 12px; color: var(--fg-mute); }
243
+ .checkline { display: inline-flex; align-items: center; gap: 7px; color: var(--fg-dim); font-size: 12.5px; cursor: pointer; user-select: none; margin-left: auto; }
244
+ .checkline input { accent-color: var(--keep); width: 15px; height: 15px; }
245
+ .estats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
246
+ .estat { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 8px 11px; }
247
+ .estat label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-mute); }
248
+ .estat span { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 13px; }
249
+ .field label { display: flex; align-items: center; gap: 8px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-mute); margin-bottom: 5px; }
250
+ .field label .count { margin-left: auto; text-transform: none; letter-spacing: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
252
251
  .field input, .field textarea {
253
- width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
254
- padding: 8px 10px; resize: vertical; font-family: var(--mono); font-size: 12px; line-height: 1.6;
252
+ width: 100%; background: var(--card); border: 1px solid var(--border); border-radius: var(--r-lg);
253
+ padding: 9px 11px; resize: vertical; font-family: var(--font-mono); font-size: 13px; line-height: 1.65;
255
254
  }
256
- .field textarea { min-height: 200px; }
257
- .field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
258
- .field.invalid input, .field.invalid textarea { border-color: var(--danger); }
259
- .erow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
260
- .hint { color: var(--fg-mute); font-size: 11px; }
261
- kbd {
262
- font-family: var(--mono); font-size: 10.5px; background: var(--elev); border: 1px solid var(--line);
263
- border-bottom-width: 2px; border-radius: 5px; padding: 0 4px; color: var(--fg-dim);
255
+ .field textarea { min-height: 130px; }
256
+ .field input:focus, .field textarea:focus { outline: none; border-color: var(--border-strong); box-shadow: 0 0 0 3px var(--ring); }
257
+ .field.invalid textarea { border-color: var(--danger); }
258
+ .erow {
259
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
260
+ position: sticky; bottom: 0; background: var(--panel); border-top: 1px solid var(--border-soft);
261
+ margin: 0 -16px; padding: 12px 16px;
264
262
  }
265
- .empty { color: var(--fg-mute); text-align: center; padding: 44px 18px; font-size: 12.5px; line-height: 1.7; }
266
-
267
- /* ---------- overlays ---------- */
263
+ .nextstep { display: none; gap: 10px; align-items: flex-start; background: color-mix(in srgb, var(--keep) 10%, var(--card)); border: 1px solid color-mix(in srgb, var(--keep) 40%, var(--border)); border-radius: var(--r); padding: 10px 13px; font-size: 12.5px; color: var(--fg-dim); }
264
+ .nextstep.show { display: flex; }
265
+ .nextstep b { color: var(--keep); }
266
+ .hint { color: var(--fg-mute); font-size: 12px; line-height: 1.6; }
267
+ kbd { font-family: var(--font-mono); font-size: 11px; background: var(--elev); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 0 4px; color: var(--fg-dim); }
268
+ kbd.space { padding: 0 8px; }
269
+
270
+ /* ---- overlays ---- */
268
271
  .toast {
269
- position: fixed; bottom: 18px; left: 50%; transform: translate(-50%, 12px);
270
- background: var(--panel-2); border: 1px solid var(--line); color: var(--fg);
271
- padding: 9px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 12.5px;
272
+ position: fixed; bottom: 20px; left: 50%; transform: translate(-50%, 12px);
273
+ background: var(--elev); border: 1px solid var(--border-strong); color: var(--fg);
274
+ padding: 9px 15px; border-radius: var(--r); box-shadow: var(--shadow); font-size: 12.5px;
272
275
  opacity: 0; transition: opacity .18s, transform .18s; pointer-events: none; z-index: 50; max-width: 70vw;
273
276
  }
274
277
  .toast.show { opacity: 1; transform: translate(-50%, 0); }
275
278
  .toast.err { border-color: var(--danger); color: var(--danger); }
276
- .toast.ok { border-color: color-mix(in srgb, var(--keep) 60%, var(--line)); }
277
- .help {
278
- position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; place-items: center; z-index: 60; padding: 20px;
279
- }
280
- .help.show { display: grid; }
281
- .help-card {
282
- background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
283
- padding: 18px 20px; min-width: 300px; max-width: 420px;
279
+ .toast.ok { border-color: color-mix(in srgb, var(--keep) 55%, var(--border)); }
280
+ .overlay { position: fixed; inset: 0; background: rgba(9, 9, 11, .5); display: none; place-items: center; z-index: 60; padding: 20px; }
281
+ .overlay.show { display: grid; }
282
+ .card {
283
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
284
+ box-shadow: var(--shadow); padding: 18px 20px; min-width: 330px; max-width: 470px;
284
285
  }
285
- .help-card h3 { margin: 0 0 12px; font-size: 13px; }
286
- .help-card p { margin: 0; color: var(--fg-dim); }
286
+ .card h3 { margin: 0 0 10px; font-size: 14px; }
287
+ .card p { margin: 0; color: var(--fg-dim); font-size: 12.5px; }
288
+ .warnbox { margin: 10px 0 0; border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--border)); background: color-mix(in srgb, var(--warn) 9%, transparent); border-radius: var(--r); padding: 9px 12px; font-size: 12.5px; color: var(--warn); display: grid; gap: 4px; }
289
+ .warnbox b { font-family: var(--font-mono); }
287
290
  .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
288
- .help-card dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 0; font-size: 12px; }
291
+ .help-card dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 0; font-size: 12.5px; }
289
292
  .help-card dt { text-align: right; }
290
293
  .help-card dd { margin: 0; color: var(--fg-dim); }
291
- .closed { display: grid; place-items: center; height: 100%; color: var(--fg-dim); font-size: 13px; text-align: center; padding: 30px; }
292
294
 
293
- @media (max-width: 1220px) {
294
- main {
295
- grid-template-columns: 250px minmax(0, 1fr);
296
- grid-template-rows: minmax(0, 1fr) minmax(180px, 42vh);
297
- grid-template-areas: "ranges timeline" "editor editor";
298
- }
295
+ /* ---- drawer ---- */
296
+ .drawer-bg { position: fixed; inset: 0; background: rgba(9, 9, 11, .5); display: none; z-index: 60; }
297
+ .drawer-bg.show { display: block; }
298
+ .drawer {
299
+ position: fixed; top: 0; right: 0; bottom: 0; width: min(600px, 92vw);
300
+ background: var(--panel); border-left: 1px solid var(--border); box-shadow: var(--shadow);
301
+ z-index: 61; display: none; flex-direction: column;
299
302
  }
300
- @media (max-width: 860px) {
301
- .metrics { grid-template-columns: 1fr; }
302
- main {
303
- grid-template-columns: minmax(0, 1fr);
304
- grid-template-rows: minmax(120px, 24vh) minmax(220px, 1fr) auto;
305
- grid-template-areas: "ranges" "timeline" "editor";
306
- }
307
- .pane.editor { max-height: 60vh; }
308
- .atom { grid-template-columns: 70px 1fr auto; }
309
- .atom .abody { grid-column: 1 / -1; }
303
+ .drawer.show { display: flex; }
304
+ .drawer .dhead { padding: 12px 16px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; }
305
+ .drawer .dhead .t { font-family: var(--font-mono); font-weight: 700; color: var(--fg); }
306
+ .drawer .dhead .m { font-size: 11.5px; color: var(--fg-mute); font-family: var(--font-mono); }
307
+ .drawer .dbody { flex: 1; overflow: auto; padding: 14px 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; color: var(--fg-dim); }
308
+ .drawer .dfoot { padding: 9px 16px; border-top: 1px solid var(--border-soft); font-size: 11.5px; color: var(--fg-mute); }
309
+ .closed { display: grid; place-items: center; height: 100%; color: var(--fg-mute); font-size: 13px; text-align: center; padding: 30px; }
310
+
311
+ @media (max-width: 1280px) {
312
+ main { grid-template-columns: 264px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(200px, 44vh); grid-template-areas: "ranges timeline" "editor editor"; }
310
313
  }
311
- @media (max-width: 600px) {
312
- .ghead { grid-template-columns: 34px auto minmax(0, 1fr); }
313
- .ghead .gstat { grid-column: 2 / -1; white-space: normal; }
314
- .atom > div { grid-column: 1 / -1; }
314
+ @media (max-width: 880px) {
315
+ .strip .di { padding: 0 12px; }
316
+ main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(130px, 26vh) minmax(240px, 1fr) auto; grid-template-areas: "ranges" "timeline" "editor"; }
317
+ .pane.editor { max-height: 62vh; }
318
+ .atom { grid-template-columns: 64px minmax(0, 1fr) auto; }
319
+ .atom.srow { grid-template-columns: 64px minmax(0, 1fr) auto; }
320
+ .atom.srow .mark { grid-column: 1 / -1; }
321
+ .atom .atok { grid-column: 1 / -1; text-align: left; }
322
+ .atom .sz { margin-left: 0; }
315
323
  }
316
324
  </style>
317
325
  </head>
318
326
  <body>
327
+ <div class="banner" id="dead-banner">Session closed in the terminal — this page is now read-only. You can close this tab.</div>
328
+
319
329
  <header>
320
330
  <div class="bar">
321
331
  <h1 class="brand"><b>Midcompact</b> <span id="mode-title">Review</span></h1>
322
332
  <span class="chip" id="draft-badge"></span>
323
333
  <span class="chip dirty" id="dirty-badge" hidden></span>
334
+ <span class="chip ok" id="saved-badge" hidden>Saved</span>
324
335
  <div class="spacer"></div>
325
336
  <button class="btn btn-sm" id="save-all" hidden>Save all</button>
326
337
  <button class="btn btn-sm btn-primary" id="save-selection" hidden>Save selection</button>
@@ -329,7 +340,7 @@
329
340
  <button class="btn btn-sm btn-icon" id="help-btn" title="Shortcuts (?)" aria-label="Shortcuts">?</button>
330
341
  <button class="btn btn-sm btn-primary" id="close">Close &amp; return</button>
331
342
  </div>
332
- <div class="metrics" id="metrics"></div>
343
+ <div class="strip" id="strip"></div>
333
344
  </header>
334
345
 
335
346
  <main>
@@ -341,15 +352,19 @@
341
352
  <button class="clr" id="q-clear" hidden aria-label="Clear filter">✕</button>
342
353
  </div>
343
354
  </div>
344
- <div class="scroll"><div class="rlist" id="range-list" role="listbox" aria-label="Ranges"></div></div>
355
+ <div class="scroll"><div class="rlist" id="range-list"></div></div>
345
356
  </section>
346
357
 
347
- <section class="pane timeline" aria-label="Anchor timeline">
358
+ <section class="pane" aria-label="Anchor timeline">
348
359
  <div class="pane-head">
349
360
  <span class="pane-title">Timeline</span>
361
+ <span class="seg" id="mode-seg" role="group" aria-label="Raw or projected">
362
+ <button data-mode="raw" aria-pressed="true">Raw</button>
363
+ <button data-mode="proj" aria-pressed="false">Projected</button>
364
+ </span>
350
365
  <span class="chip" id="tl-stat"></span>
351
366
  <div class="spacer"></div>
352
- <div class="seg" role="group" aria-label="Policy filter">
367
+ <div class="seg" id="policy-seg" role="group" aria-label="Policy filter">
353
368
  <button data-filter="all" aria-pressed="true">All</button>
354
369
  <button data-filter="range" aria-pressed="false">Compressed</button>
355
370
  <button data-filter="keep" aria-pressed="false">Kept</button>
@@ -365,27 +380,33 @@
365
380
  </main>
366
381
 
367
382
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
368
- <div class="help" id="help" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
369
- <div class="help-card">
383
+
384
+ <div class="drawer-bg" id="drawer-bg"></div>
385
+ <div class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Atom original text">
386
+ <div class="dhead"><span class="t" id="dw-ref"></span><span class="m" id="dw-meta"></span><div class="spacer"></div><button class="btn btn-sm" id="dw-close">✕</button></div>
387
+ <div class="dbody" id="dw-body"></div>
388
+ <div class="dfoot">Full text is served from the frozen anchor on demand — list previews stay truncated.</div>
389
+ </div>
390
+
391
+ <div class="overlay" id="help" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
392
+ <div class="card help-card">
370
393
  <h3>Keyboard shortcuts</h3>
371
- <dl id="help-list">
372
- <dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Next / previous range</dd>
373
- <dt><kbd>/</kbd></dt><dd>Focus filter</dd>
374
- <dt><kbd>e</kbd></dt><dd>Focus summary editor</dd>
375
- <dt><kbd>⌘/Ctrl</kbd>+<kbd>S</kbd></dt><dd>Save current range</dd>
376
- <dt><kbd>r</kbd></dt><dd>Reload state from server</dd>
377
- <dt><kbd>x</kbd></dt><dd>Expand / collapse selected group</dd>
378
- <dt><kbd>Esc</kbd></dt><dd>Blur field / close dialog</dd>
379
- </dl>
394
+ <dl id="help-list"></dl>
380
395
  </div>
381
396
  </div>
382
- <div class="help" id="close-dialog" role="dialog" aria-modal="true" aria-label="Unsaved changes">
383
- <div class="help-card">
384
- <h3>Unsaved changes</h3>
385
- <p>Save the current DraftPlan before closing this workbench?</p>
397
+
398
+ <div class="overlay" id="close-dialog" role="dialog" aria-modal="true" aria-label="Unsaved changes">
399
+ <div class="card">
400
+ <h3>Close review?</h3>
401
+ <p id="close-dialog-line"></p>
402
+ <div class="warnbox" id="close-warn" hidden>
403
+ <b><span id="close-warn-count"></span> range still has an empty summary</b>
404
+ <span id="close-warn-list"></span>
405
+ </div>
386
406
  <div class="dialog-actions">
387
407
  <button class="btn" id="close-stay">Stay</button>
388
- <button class="btn btn-danger" id="close-discard">Discard</button>
408
+ <button class="btn btn-danger" id="close-discard">Discard edits</button>
409
+ <button class="btn" id="close-fix" hidden>Fix pending</button>
389
410
  <button class="btn btn-primary" id="close-save">Save &amp; close</button>
390
411
  </div>
391
412
  </div>
@@ -393,49 +414,79 @@
393
414
 
394
415
  <script id="state" type="application/json"><!--MIDCOMPACT_STATE--></script>
395
416
  <script type="module">
396
- const EMPTY = { atoms: [], draft: { revision: 0, ranges: [] }, telemetry: {} };
417
+ const EMPTY = { view: 'review', est: null, atoms: [], draft: { revision: 0, ranges: [] }, telemetry: {} };
397
418
  let state = (() => {
398
419
  try {
399
420
  const s = JSON.parse(document.getElementById('state').textContent);
400
- return { view: s.view ?? 'review', atoms: s.atoms ?? [], draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] }, telemetry: s.telemetry ?? {} };
421
+ return {
422
+ view: s.view ?? 'review',
423
+ est: s.est ?? null,
424
+ atoms: s.atoms ?? [],
425
+ draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] },
426
+ telemetry: s.telemetry ?? {},
427
+ };
401
428
  } catch { return EMPTY; }
402
429
  })();
403
430
 
404
431
  // The server owns the keepalive writes. If this page disappears, the stream
405
432
  // closes and the terminal-side UI lock is released without treating inactivity
406
- // in an open tab as disconnection.
433
+ // in an open tab as disconnection. A stream that errors and stays down means
434
+ // the workbench was closed in the terminal: flip the page to read-only.
407
435
  const liveness = new EventSource('/api/liveness');
436
+ let deadTimer = null;
437
+ function markDead() {
438
+ document.body.classList.add('dead');
439
+ document.getElementById('dead-banner').classList.add('show');
440
+ document.querySelectorAll('input, textarea, button, select').forEach(el => { el.disabled = true; });
441
+ }
442
+ liveness.onopen = () => {
443
+ clearTimeout(deadTimer); deadTimer = null;
444
+ document.body.classList.remove('dead');
445
+ document.getElementById('dead-banner').classList.remove('show');
446
+ };
447
+ liveness.onerror = () => {
448
+ if (!deadTimer) deadTimer = setTimeout(markDead, 4000);
449
+ };
408
450
 
409
451
  let selectedId = state.draft.ranges[0]?.id ?? null;
410
452
  let query = '';
411
453
  let policy = 'all';
454
+ let mode = 'raw'; // timeline: raw atoms or projected summaries
412
455
  const edits = new Map(); // rangeId -> { topic, summary }
413
- const collapsed = new Set(); // group keys currently collapsed
414
- const openAtoms = new Set(); // atom refs expanded to full preview
456
+ const collapsed = new Set();
415
457
  let lastScrolled = null;
416
458
  let removeArmed = null;
417
459
  let selectionDirty = false;
460
+ let selectionRefs = new Set();
461
+ let cursor = 0; // selection-view atom cursor
418
462
 
419
- state.draft.ranges.forEach(r => { if (r.id !== selectedId) collapsed.add(r.id); });
463
+ // Reviewed flags are a page-session convenience, not persisted app state.
464
+ let reviewed = (() => {
465
+ try { return new Set(JSON.parse(sessionStorage.getItem('midcompact-reviewed') || '[]')); }
466
+ catch { return new Set(); }
467
+ })();
420
468
 
421
469
  /* ---------- helpers ---------- */
422
470
  const $ = id => document.getElementById(id);
423
- const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
471
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
424
472
  const fmt = n => n == null || Number.isNaN(n) ? '—'
425
473
  : Math.abs(n) >= 1000 ? (n / 1000).toFixed(Math.abs(n) % 1000 ? 1 : 0) + 'k' : String(Math.round(n));
426
474
  const pct = n => n == null || Number.isNaN(n) ? '—' : n.toFixed(n % 1 ? 1 : 0) + '%';
427
475
  const clamp = n => Math.max(0, Math.min(100, n));
428
476
  const isSelection = () => state.view === 'selection';
429
477
  const atomByRef = ref => state.atoms.find(a => a.ref === ref);
478
+ const isLocked = a => !a.compressible || !a.protocolClosed || a.kind === 'compressed';
430
479
  const rangeAtoms = r => {
431
480
  const start = state.atoms.findIndex(a => a.ref === r.startRef);
432
481
  const end = state.atoms.findIndex(a => a.ref === r.endRef);
433
482
  return start >= 0 && end >= start ? state.atoms.slice(start, end + 1) : [];
434
483
  };
435
- let selectionRefs = new Set(state.draft.ranges.flatMap(r => rangeAtoms(r).map(a => a.ref)));
436
- let keepRefs = new Set();
484
+ selectionRefs = new Set(state.draft.ranges.flatMap(r => rangeAtoms(r).map(a => a.ref)));
485
+ // Old-page behavior: unselected ranges start collapsed so the timeline opens
486
+ // on the selection, and Expand all expands from a meaningful state.
487
+ state.draft.ranges.forEach(r => { if (r.id !== selectedId) collapsed.add(r.id); });
437
488
  const rangeById = id => state.draft.ranges.find(r => r.id === id);
438
- const saveOf = r => Math.max(0, (r.originalContentChars ?? 0) - (r.replacementContentChars ?? 0));
489
+ const cssEsc = s => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&');
439
490
 
440
491
  function hl(text, q) {
441
492
  const out = esc(text);
@@ -443,6 +494,50 @@ function hl(text, q) {
443
494
  const re = new RegExp(esc(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
444
495
  return out.replace(re, m => '<mark>' + m + '</mark>');
445
496
  }
497
+ function classify(text) {
498
+ let narrowChars = 0, wideChars = 0;
499
+ for (const ch of String(text ?? '')) (ch.codePointAt(0) <= 0x7f) ? narrowChars++ : wideChars++;
500
+ return { narrowChars, wideChars };
501
+ }
502
+ // Mirrors the assumption table injected from content-metrics (state.est).
503
+ const EST = state.est ?? { narrowTokPerChar: [0.22, 0.3], wideTokPerChar: [0.5, 1.0], imageTok: [700, 1600] };
504
+ function estFromMix(m) {
505
+ const [aLo, aHi] = EST.narrowTokPerChar, [cLo, cHi] = EST.wideTokPerChar, [iLo, iHi] = EST.imageTok;
506
+ const mid = (lo, hi) => (lo + hi) / 2;
507
+ return {
508
+ point: Math.round(m.narrow * mid(aLo, aHi) + m.wide * mid(cLo, cHi) + m.images * mid(iLo, iHi)),
509
+ low: Math.round(m.narrow * aLo + m.wide * cLo + m.images * iLo),
510
+ high: Math.round(m.narrow * aHi + m.wide * cHi + m.images * iHi),
511
+ };
512
+ }
513
+ function mixOf(atoms) {
514
+ let narrow = 0, wide = 0, images = 0;
515
+ for (const a of atoms) { narrow += a.narrowChars || 0; wide += a.wideChars || 0; images += a.imageCount || 0; }
516
+ return { narrow, wide, images };
517
+ }
518
+ function replacementFieldsMix(summary, topic) {
519
+ return classify((topic ? `: ${topic}` : '') + summary);
520
+ }
521
+ function replacementMixOf(r) {
522
+ const savedFields = replacementFieldsMix(r.summary, r.topic);
523
+ const current = draftOf(r);
524
+ const currentFields = replacementFieldsMix(current.summary, current.topic);
525
+ return {
526
+ narrow: Math.max(0, (r.replacementNarrowChars ?? savedFields.narrowChars)
527
+ - savedFields.narrowChars + currentFields.narrowChars),
528
+ wide: Math.max(0, (r.replacementWideChars ?? savedFields.wideChars)
529
+ - savedFields.wideChars + currentFields.wideChars),
530
+ images: 0,
531
+ };
532
+ }
533
+ function replacementCharsOf(r) {
534
+ const mix = replacementMixOf(r);
535
+ return mix.narrow + mix.wide;
536
+ }
537
+ function saveOf(r) {
538
+ return Math.max(0, (r.originalContentChars ?? 0) - replacementCharsOf(r));
539
+ }
540
+
446
541
  function atomText(a) {
447
542
  return [a.ref, a.kind, a.preview, ...(a.toolNames ?? []), ...(a.roles ?? [])].join(' ').toLowerCase();
448
543
  }
@@ -458,6 +553,74 @@ function isDirty(r) {
458
553
  return !!e && (e.topic !== (r.topic ?? '') || e.summary !== r.summary);
459
554
  }
460
555
  const dirtyIds = () => state.draft.ranges.filter(isDirty).map(r => r.id);
556
+ const pendingIds = () => state.draft.ranges.filter(r => !r.summary.trim()).map(r => r.id);
557
+ const selectionAtoms = () => state.atoms.filter(a => selectionRefs.has(a.ref));
558
+ const totalContentChars = () => state.atoms.reduce((s, a) => s + (a.contentChars ?? 0), 0);
559
+ const maxContentChars = () => state.atoms.reduce((m, a) => Math.max(m, a.contentChars ?? 0), 0);
560
+
561
+ function computeSpans() {
562
+ const spans = [];
563
+ state.atoms.forEach((a, index) => {
564
+ if (!selectionRefs.has(a.ref)) return;
565
+ const last = spans.at(-1);
566
+ if (last && last.endIndex + 1 === index) { last.endRef = a.ref; last.endIndex = index; }
567
+ else spans.push({ startRef: a.ref, endRef: a.ref, endIndex: index });
568
+ });
569
+ return spans;
570
+ }
571
+
572
+ // Post-commit projection of the anchor usage. Band propagated from the
573
+ // assumption table by content composition; display-only, never gates anything.
574
+ function projection() {
575
+ const usage = state.telemetry && state.telemetry.anchorUsage;
576
+ if (!usage || usage.tokens == null || !usage.contextWindow) return null;
577
+ const now = usage.tokens, win = usage.contextWindow;
578
+ const removed = estFromMix(mixOf(isSelection() ? selectionAtoms() : state.atoms.filter(a => a.owningRangeId)));
579
+ const addedMix = state.draft.ranges.reduce((total, range) => {
580
+ const mix = replacementMixOf(range);
581
+ total.narrow += mix.narrow;
582
+ total.wide += mix.wide;
583
+ return total;
584
+ }, { narrow: 0, wide: 0, images: 0 });
585
+ const added = isSelection() ? { point: 0, low: 0, high: 0 } : estFromMix(addedMix);
586
+ const post = {
587
+ point: Math.max(0, now - removed.point + added.point),
588
+ low: Math.max(0, now - removed.high + added.low),
589
+ high: Math.max(0, now - removed.low + added.high),
590
+ };
591
+ const spreadPct = post.point > 0
592
+ ? Math.round(Math.max((post.point - post.low) / post.point, (post.high - post.point) / post.point) * 100)
593
+ : 0;
594
+ return {
595
+ now, win,
596
+ nowPct: clamp(now / win * 100),
597
+ postPoint: post.point, postPct: clamp(post.point / win * 100),
598
+ keptPct: clamp(post.point / win * 100),
599
+ freePct: clamp((now - post.point) / win * 100),
600
+ saveLow: Math.max(0, now - post.high),
601
+ saveHigh: Math.max(0, now - post.low),
602
+ spreadPct,
603
+ };
604
+ }
605
+ const EST_TIP = `Estimate only, never a gate: assumption table ≈${EST.narrowTokPerChar[0]}–${EST.narrowTokPerChar[1]} tok per ASCII char, `
606
+ + `≈${EST.wideTokPerChar[0]}–${EST.wideTokPerChar[1]} tok per non-ASCII char, ≈${EST.imageTok[0]}–${EST.imageTok[1]} tok per image. `
607
+ + 'The provider\u2019s report is the final authority.';
608
+
609
+ function pbarHtml(proj, label, interactive, caveat = '') {
610
+ const tip = caveat + (interactive ? 'Click to preview the post-commit context (toggles Raw ⇄ Projected). ' : '') + EST_TIP;
611
+ const attrs = interactive ? ' data-toggle-mode role="button" tabindex="0"' : '';
612
+ if (!proj) {
613
+ return `<div class="pbar"${attrs}><div class="pl"><span>anchor usage</span></div><div class="tr"><span class="na">Pi-reported usage unavailable — the projection needs it</span></div></div>`;
614
+ }
615
+ return `<div class="pbar"${attrs} title="${esc(tip)}">
616
+ <div class="pl">
617
+ <span>now <b>${fmt(proj.now)} tok</b> · ${pct(proj.nowPct)} (reported by Pi)</span>
618
+ <span>${esc(label)} <b>≈${fmt(proj.postPoint)} tok · ${pct(proj.postPct)}</b>
619
+ <span class="delta">−${fmt(proj.saveHigh)} … −${fmt(proj.saveLow)} tok (est. ±${proj.spreadPct}%)</span></span>
620
+ </div>
621
+ <div class="tr"><i class="kept" style="width:${proj.keptPct}%"></i><i class="free" style="left:${proj.keptPct}%;width:${proj.freePct}%"></i></div>
622
+ </div>`;
623
+ }
461
624
 
462
625
  /* ---------- render ---------- */
463
626
  function render() {
@@ -467,16 +630,20 @@ function render() {
467
630
  const dirtyCount = d.length + (selectionDirty ? 1 : 0);
468
631
  $('dirty-badge').hidden = dirtyCount === 0;
469
632
  $('dirty-badge').textContent = `${dirtyCount} unsaved`;
633
+ const saved = $('saved-badge');
634
+ if (isSelection() && !selectionDirty && state.draft.ranges.length > 0) {
635
+ saved.hidden = false;
636
+ saved.textContent = `Saved · v${state.draft.revision}`;
637
+ } else saved.hidden = true;
470
638
  $('mode-title').textContent = isSelection() ? 'Selection' : 'Review';
471
639
  document.title = `Midcompact ${isSelection() ? 'Selection' : 'Review'}`;
472
- document.querySelector('.seg').hidden = isSelection();
473
- if (isSelection()) {
474
- $('help-list').innerHTML = '<dt><kbd>/</kbd></dt><dd>Focus filter</dd><dt><kbd>Click</kbd></dt><dd>Add atom or KEEP</dd><dt><kbd>Save</kbd></dt><dd>Persist DraftPlan</dd><dt><kbd>Esc</kbd></dt><dd>Close dialog</dd>';
475
- }
476
640
  $('save-selection').hidden = !isSelection();
477
641
  $('save-all').hidden = isSelection() || d.length < 2;
478
642
  $('save-all').textContent = `Save all (${d.length})`;
479
- renderMetrics();
643
+ $('policy-seg').hidden = isSelection();
644
+ $('mode-seg').hidden = isSelection();
645
+ renderHelp();
646
+ renderStrip();
480
647
  renderRanges();
481
648
  renderTimeline();
482
649
  renderEditor();
@@ -486,73 +653,60 @@ function render() {
486
653
  document.querySelector(`[data-range="${cssEsc(selectedId)}"]`)?.scrollIntoView({ block: 'nearest' });
487
654
  }
488
655
  }
489
- const cssEsc = s => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&');
490
-
491
- function renderMetrics() {
492
- const t = state.telemetry ?? {};
493
- const usage = t.anchorUsage;
494
- const usageLabel = usage?.tokens == null ? 'unavailable' : `${fmt(usage.tokens)} / ${fmt(usage.contextWindow)} (${pct(usage.percent)})`;
495
- const selected = isSelection()
496
- ? state.atoms.filter(a => selectionRefs.has(a.ref) && a.compressible && !keepRefs.has(a.ref))
497
- : state.atoms.filter(a => a.owningRangeId);
498
- const chars = selected.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
499
- const totalChars = state.atoms.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
500
- const selectedShare = totalChars > 0 ? clamp((chars / totalChars) * 100) : 0;
501
- const images = selected.reduce((sum, a) => sum + (a.imageCount ?? 0), 0);
502
- const pending = state.draft.ranges.filter(r => !r.summary.trim()).length;
503
- const usagePercent = usage?.percent == null ? 0 : clamp(usage.percent);
504
- const contentMetric = isSelection()
505
- ? `<div class="metric"><label>Selected content chars</label><div class="val">${fmt(chars)} / ${fmt(totalChars)} <small>${pct(selectedShare)} of anchor</small></div><div class="track"><i class="proj" style="width:${selectedShare}%"></i></div><div class="legend"><span>up to <b>${pct(selectedShare)}</b> fewer anchor chars · actual is lower after summaries</span></div></div>`
506
- : `<div class="metric"><label>Content chars</label><div class="val">${fmt(chars)}</div><div class="legend"><span>${selected.length} selected atoms</span></div></div>`;
507
- $('metrics').innerHTML = `
508
- <div class="metric"><label>Pi reported anchor</label><div class="val">${usageLabel}</div><div class="track"><i class="anchor" style="width:${usagePercent}%"></i></div><div class="legend"><span>provider usage fact · no local token estimate</span></div></div>
509
- <div class="metric"><label>Draft ranges</label><div class="val">${state.draft.ranges.length} <small>${pending ? `${pending} pending` : 'ready for review'}</small></div><div class="legend"><span>revision <b>${state.draft.revision}</b></span></div></div>
510
- ${contentMetric}
511
- <div class="metric"><label>Images</label><div class="val">${images}</div><div class="legend"><span>payload facts only</span></div></div>`;
512
- }
513
656
 
514
- function proposedRanges() {
515
- const runs = [];
516
- for (const atom of state.atoms) {
517
- if (!selectionRefs.has(atom.ref) || keepRefs.has(atom.ref) || !atom.compressible) continue;
518
- const previous = runs.at(-1);
519
- if (previous && previous.endIndex + 1 === atom.index) {
520
- previous.endRef = atom.ref;
521
- previous.endIndex = atom.index;
522
- previous.atoms.push(atom);
523
- } else runs.push({ startRef: atom.ref, endRef: atom.ref, endIndex: atom.index, atoms: [atom] });
657
+ function renderStrip() {
658
+ const proj = projection();
659
+ let items;
660
+ if (isSelection()) {
661
+ const sel = selectionAtoms();
662
+ const chars = sel.reduce((s, a) => s + (a.contentChars ?? 0), 0);
663
+ const total = totalContentChars();
664
+ const share = total > 0 ? clamp(chars / total * 100) : 0;
665
+ items = `
666
+ <div class="di"><label>selected</label><span class="val">${sel.length} atoms</span></div>
667
+ <div class="di"><label>anchor content</label><span class="val save">−${fmt(chars)} chars (${pct(share)})</span></div>
668
+ <div class="di"><label>will become</label><span class="val">${computeSpans().length} range${computeSpans().length === 1 ? '' : 's'}</span></div>`;
669
+ } else {
670
+ const chars = state.atoms.filter(a => a.owningRangeId).reduce((s, a) => s + (a.contentChars ?? 0), 0);
671
+ const total = totalContentChars();
672
+ const save = state.draft.ranges.reduce((s, r) => s + saveOf(r), 0);
673
+ const share = total > 0 ? clamp(save / total * 100) : 0;
674
+ const coverage = state.draft.ranges.reduce((s, r) => s + r.atomCount, 0);
675
+ const pending = pendingIds().length;
676
+ items = `
677
+ <div class="di"><label>draft</label><span class="val">${state.draft.ranges.length} range${state.draft.ranges.length === 1 ? '' : 's'}</span></div>
678
+ <div class="di"><label>coverage</label><span class="val">${coverage} / ${state.atoms.length} atoms</span></div>
679
+ <div class="di"><label>anchor content</label><span class="val save">−${fmt(save)} chars (−${pct(share)})</span></div>
680
+ <div class="di"><label>pending</label><span class="val ${pending ? 'pend' : ''}">${pending}</span></div>`;
524
681
  }
525
- return runs;
682
+ const caveat = isSelection() ? 'Selected content removal only; future summaries are not included. ' : '';
683
+ $('strip').innerHTML = items + pbarHtml(proj, isSelection() ? 'if committed now' : 'after commit', !isSelection(), caveat);
526
684
  }
527
685
 
528
686
  function renderRanges() {
529
687
  const list = $('range-list');
530
688
  if (isSelection()) {
531
- const ranges = proposedRanges();
532
- if (!ranges.length) {
533
- list.innerHTML = '<div class="empty">No atoms selected.<br>Add a group or atom from the timeline.</div>';
534
- return;
535
- }
536
- list.innerHTML = ranges.map((range, index) => {
537
- const chars = range.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
538
- const images = range.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
539
- return `<div class="ritem" role="option">
540
- <div class="r1"><span class="rid">range ${index + 1}</span><span class="refs">${esc(range.startRef)}→${esc(range.endRef)}</span><span class="spacer"></span><button class="btn btn-sm btn-icon" data-remove-proposed="${index}" title="Remove range" aria-label="Remove range">×</button></div>
541
- <div class="rsum">Pending summary</div>
542
- <div class="meta">${range.atoms.length} atoms · ${fmt(chars)} chars · ${images} images</div>
689
+ const spans = computeSpans();
690
+ if (!state.atoms.length) { list.innerHTML = '<div class="empty">No atoms in anchor snapshot.</div>'; return; }
691
+ if (!spans.length) { list.innerHTML = '<div class="empty">Nothing selected.<br>Click atoms or groups in the timeline, or use <kbd>↑↓</kbd> + <kbd class="space">Space</kbd>.</div>'; return; }
692
+ list.innerHTML = spans.map((span, index) => {
693
+ const atoms = state.atoms.slice(state.atoms.findIndex(a => a.ref === span.startRef), state.atoms.findIndex(a => a.ref === span.endRef) + 1);
694
+ const chars = atoms.reduce((s, a) => s + (a.contentChars ?? 0), 0);
695
+ return `<div class="ritem">
696
+ <div class="r1"><span class="rid">range ${index + 1}</span><span class="refs">${esc(span.startRef)}→${esc(span.endRef)}</span>
697
+ <button class="btn btn-sm btn-icon" data-unsel-start="${esc(span.startRef)}" data-unsel-end="${esc(span.endRef)}" title="Unselect this span" aria-label="Unselect this span">✕</button></div>
698
+ <div class="meta">${atoms.length} atoms · ${fmt(chars)} chars</div>
543
699
  </div>`;
544
700
  }).join('');
545
701
  return;
546
702
  }
703
+ if (!state.draft.ranges.length) { list.innerHTML = '<div class="empty">No compression ranges in this draft.</div>'; return; }
547
704
  const rs = state.draft.ranges.filter(r => !query || rangeText(r).includes(query));
548
- if (!state.draft.ranges.length) {
549
- list.innerHTML = '<div class="empty">No compression ranges in this draft.</div>';
550
- return;
551
- }
552
705
  if (!rs.length) { list.innerHTML = '<div class="empty">No range matches the filter.</div>'; return; }
553
706
  list.innerHTML = rs.map(r => {
554
707
  const d = draftOf(r);
555
- const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
708
+ const replacementChars = replacementCharsOf(r);
709
+ const ratio = r.originalContentChars ? Math.round((1 - replacementChars / r.originalContentChars) * 100) : 0;
556
710
  return `<div class="ritem" role="option" tabindex="0" data-range="${esc(r.id)}" aria-selected="${r.id === selectedId}">
557
711
  <div class="r1">
558
712
  <span class="rid">${hl(r.id, query)}</span>
@@ -560,9 +714,12 @@ function renderRanges() {
560
714
  ${isDirty(r) ? '<span class="udot" title="unsaved edits"></span>' : ''}
561
715
  <span class="save">−${fmt(saveOf(r))}</span>
562
716
  </div>
563
- ${d.topic ? `<div class="meta" style="color:var(--accent-2)">${hl(d.topic, query)}</div>` : ''}
717
+ ${d.topic ? `<div class="topic">${hl(d.topic, query)}</div>` : ''}
564
718
  <div class="rsum">${hl(d.summary, query)}</div>
565
- <div class="meta">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars · ${ratio}% fewer chars</div>
719
+ <div class="meta">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(replacementChars)} chars · ${ratio}% fewer
720
+ ${draftOf(r).summary.trim() ? '' : '<span class="pend">pending</span>'}
721
+ ${reviewed.has(r.id) ? '<span class="rev">✓ reviewed</span>' : ''}
722
+ </div>
566
723
  </div>`;
567
724
  }).join('');
568
725
  }
@@ -584,61 +741,89 @@ function buildGroups() {
584
741
  function renderTimeline() {
585
742
  const tl = $('timeline');
586
743
  if (!state.atoms.length) { tl.innerHTML = '<div class="empty">No atoms in anchor snapshot.</div>'; $('tl-stat').textContent = ''; return; }
744
+ if (isSelection()) { renderSelectionTimeline(tl); return; }
587
745
 
588
746
  let shown = 0;
747
+ const maxChars = maxContentChars();
748
+ const keepGroupMatch = g => {
749
+ const label = `${g.atoms[0]?.ref ?? ''} → ${g.atoms.at(-1)?.ref ?? ''} verbatim ${g.label ?? ''}`.toLowerCase();
750
+ return label.includes(query) || g.atoms.some(a => atomText(a).includes(query));
751
+ };
589
752
  const html = buildGroups().map(g => {
590
- if (!isSelection() && policy === 'range' && !g.key) return '';
591
- if (!isSelection() && policy === 'keep' && g.key) return '';
592
- const r = !isSelection() && g.key ? rangeById(g.key) : null;
593
753
  const gkey = g.key ?? ('keep-' + g.idx);
754
+ if (mode === 'proj') {
755
+ // SPEC §3.2: Raw and Projected share the same policy + query semantics.
756
+ if (!g.key) {
757
+ if (policy === 'range') return '';
758
+ if (query && !keepGroupMatch(g)) return '';
759
+ return keepGroupHtml(g, maxChars);
760
+ }
761
+ if (policy === 'keep') return '';
762
+ const r0 = rangeById(g.key);
763
+ if (!r0) return keepGroupHtml(g, maxChars);
764
+ if (query && !rangeText(r0).includes(query) && !g.atoms.some(a => atomText(a).includes(query))) return '';
765
+ return projectedCardHtml(r0, g);
766
+ }
767
+ if (policy === 'range' && !g.key) return '';
768
+ if (policy === 'keep' && g.key) return '';
769
+ const r = g.key ? rangeById(g.key) : null;
594
770
  const groupMatch = r ? rangeText(r).includes(query) : `${g.label ?? ''} ${gkey}`.toLowerCase().includes(query);
595
771
  const atoms = query && !groupMatch ? g.atoms.filter(a => atomText(a).includes(query)) : g.atoms;
596
772
  if (query && !groupMatch && !atoms.length) return '';
597
773
  shown += atoms.length;
598
-
599
- const chars = g.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
600
- const images = g.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
601
774
  const open = !collapsed.has(gkey) || (query && !groupMatch);
602
- const body = atoms.map(a => atomRow(a)).join('');
603
-
604
- if (isSelection()) {
605
- const allSelected = g.atoms.every(atom => selectionRefs.has(atom.ref));
606
- return `<section class="group ${open ? 'open' : ''} ${allSelected ? 'sel' : ''}" data-group-key="${esc(gkey)}">
607
- <div class="ghead" aria-expanded="${open}">
608
- <button class="btn btn-sm btn-icon" data-toggle="${esc(gkey)}" title="Expand group">▶</button>
609
- <span class="gid">${esc(gkey)}</span>
610
- <span class="gsum">${esc(g.label ?? '')}</span>
611
- <span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img <button class="btn btn-sm" data-select-group="${esc(gkey)}">${allSelected ? 'Clear group' : 'Add group'}</button></span>
612
- </div>
613
- <div class="gbody">${body}</div>
614
- </section>`;
615
- }
616
-
617
- if (!r) {
618
- return `<section class="group keepg ${open ? 'open' : ''}" data-group-key="${esc(gkey)}">
619
- <button class="ghead" data-toggle="${esc(gkey)}" aria-expanded="${open}">
620
- <span class="chev">▶</span>
621
- <span class="gid">KEEP</span>
622
- <span class="gsum">${esc(g.atoms[0].ref)} → ${esc(g.atoms[g.atoms.length - 1].ref)} verbatim</span>
623
- <span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img</span>
624
- </button>
625
- <div class="gbody">${body}</div>
626
- </section>`;
627
- }
775
+ if (!r) return keepGroupHtml(g, maxChars, open, atoms);
628
776
  const d = draftOf(r);
629
- return `<section class="group ${open ? 'open' : ''} ${r.id === selectedId ? 'sel' : ''}" data-group-key="${esc(gkey)}">
630
- <button class="ghead" data-toggle="${esc(gkey)}" data-select="${esc(r.id)}" aria-expanded="${open}">
777
+ return `<section class="group ${open ? 'open' : ''} ${r.id === selectedId ? 'sel' : ''}" data-group-key="${esc(g.key)}">
778
+ <button class="ghead" data-toggle="${esc(g.key)}" data-select="${esc(r.id)}" aria-expanded="${open}">
631
779
  <span class="chev">▶</span>
632
780
  <span class="gid">${esc(r.id)}</span>
633
781
  <span class="gsum">${hl(d.topic || d.summary, query)}</span>
634
- <span class="gstat">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars <em>−${fmt(saveOf(r))}</em></span>
782
+ <span class="gstat">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(replacementCharsOf(r))} <em>−${fmt(saveOf(r))}</em>
783
+ ${draftOf(r).summary.trim() ? '' : '<span class="pend">pending</span>'}${reviewed.has(r.id) ? '<span class="rev">✓</span>' : ''}</span>
635
784
  </button>
636
- <div class="gbody">${body}</div>
785
+ <div class="gbody" ${open ? '' : 'hidden'}>${atoms.map(a => atomRowRaw(a, maxChars)).join('')}</div>
637
786
  </section>`;
638
787
  }).join('');
639
-
640
788
  tl.innerHTML = html || '<div class="empty">Nothing matches the current filter.</div>';
641
- $('tl-stat').textContent = query || policy !== 'all' ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`;
789
+ $('tl-stat').textContent = mode === 'proj'
790
+ ? `${state.atoms.length} atoms · projected`
791
+ : (query || policy !== 'all' ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`);
792
+ }
793
+
794
+ function keepGroupHtml(g, maxChars, open = !collapsed.has('keep-' + g.idx), atoms = g.atoms) {
795
+ const gkey = 'keep-' + g.idx;
796
+ const chars = g.atoms.reduce((s, a) => s + (a.contentChars ?? 0), 0);
797
+ return `<section class="group keepg ${open ? 'open' : ''}" data-group-key="${esc(gkey)}">
798
+ <button class="ghead" data-toggle="${esc(gkey)}" aria-expanded="${open}">
799
+ <span class="chev">▶</span>
800
+ <span class="gid">KEEP</span>
801
+ <span class="gsum">${esc(g.atoms[0].ref)} → ${esc(g.atoms.at(-1).ref)} verbatim</span>
802
+ <span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · verbatim</span>
803
+ </button>
804
+ <div class="gbody" ${open ? '' : 'hidden'}>${atoms.map(a => atomRowRaw(a, maxChars)).join('')}</div>
805
+ </section>`;
806
+ }
807
+
808
+ function projectedCardHtml(r, g) {
809
+ const d = draftOf(r);
810
+ const replacementChars = replacementCharsOf(r);
811
+ if (!d.summary.trim()) {
812
+ return `<div class="pcard pend" data-group-key="${esc(r.id)}">
813
+ <div class="phead"><span class="pid">${esc(r.id)}</span><span class="ptopic warn">pending summary</span>
814
+ <span class="pmeta">${r.atomCount} atoms → 0 chars</span></div>
815
+ <div class="pwarnline">⚠ No summary yet — <span class="mono">/midcompact:commit</span> will refuse this range.</div>
816
+ <div class="pactions"><button class="btn btn-sm" data-fix="${esc(r.id)}">Write summary</button>
817
+ <button class="btn btn-sm" data-orig="${esc(g.atoms[0].ref)}">View raw start</button></div>
818
+ </div>`;
819
+ }
820
+ return `<div class="pcard" data-group-key="${esc(r.id)}">
821
+ <div class="phead"><span class="pid">${esc(r.id)}</span><span class="ptopic">${hl(d.topic, query)}</span>
822
+ <span class="pmeta">${r.atomCount} atoms → ${fmt(replacementChars)} chars · replaces ${esc(r.startRef)}→${esc(r.endRef)}</span></div>
823
+ <div class="ptext">${hl(d.summary, query)}</div>
824
+ <div class="pactions"><button class="btn btn-sm" data-orig="${esc(g.atoms[0].ref)}">View original start</button>
825
+ <button class="btn btn-sm" data-edit="${esc(r.id)}">Edit summary</button></div>
826
+ </div>`;
642
827
  }
643
828
 
644
829
  function toolTag(name) {
@@ -646,49 +831,101 @@ function toolTag(name) {
646
831
  return `<span class="tag tool${tone}">${esc(name)}</span>`;
647
832
  }
648
833
 
649
- function atomRow(a) {
650
- const tags = [
834
+ function atomRowRaw(a, maxChars) {
835
+ const w = maxChars ? Math.round((a.contentChars ?? 0) / maxChars * 100) : 0;
836
+ const lockTags = [
651
837
  ...(a.toolNames ?? []).slice(0, 3).map(toolTag),
652
838
  ...(a.roles ?? []).slice(0, 2).map(t => `<span class="tag">${esc(t)}</span>`),
653
- a.compressible === false ? '<span class="tag lock">not compressible</span>' : '',
839
+ isLocked(a) ? '<span class="tag lock">can\u2019t compress</span>' : '',
654
840
  a.protocolClosed === false ? '<span class="tag lock">open protocol</span>' : '',
655
841
  a.compressedBlockId ? `<span class="tag">blk ${esc(a.compressedBlockId)}</span>` : '',
656
842
  ].filter(Boolean).join('');
657
- const open = openAtoms.has(a.ref);
658
- const selected = selectionRefs.has(a.ref);
659
- const kept = keepRefs.has(a.ref) || !a.compressible;
660
- const actions = isSelection() ? `<div class="atom-actions">
661
- <button class="btn btn-sm" data-toggle-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${selected ? 'Remove' : 'Add'}</button>
662
- <button class="btn btn-sm" data-keep-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${kept ? 'KEEP' : 'Keep'}</button>
663
- </div>` : '';
664
- return `<div class="atom ${open ? 'open' : ''} ${selected ? 'selected-atom' : ''} ${kept ? 'kept-atom' : ''}" data-atom="${esc(a.ref)}">
843
+ return `<div class="atom ${a.kind === 'user' ? 'user' : ''}" data-atom="${esc(a.ref)}">
665
844
  <span class="aref">${hl(a.ref, query)}</span>
666
- <span class="akind">${hl(a.kind ?? '', query)}</span>
667
- <span class="atok">${fmt(a.contentChars)} chars<br>${a.imageCount ?? 0} img</span>
668
845
  <div>
669
- <div class="abody">${hl(a.preview ?? '', query)}</div>
670
- ${tags ? `<div class="tags">${tags}</div>` : ''}
671
- ${actions}
846
+ <div class="arow">
847
+ <span class="tag kind-${esc(a.kind)}">${esc(a.kind)}</span>
848
+ <button type="button" class="abody" data-orig="${esc(a.ref)}" title="View full original">${hl(a.preview ?? '', query)}</button>
849
+ </div>
850
+ ${lockTags ? `<div class="tags">${lockTags}</div>` : ''}
672
851
  </div>
852
+ <span class="atok">${fmt(a.contentChars)} chars<i class="sz" style="--w:${w}%"></i></span>
673
853
  </div>`;
674
854
  }
675
855
 
856
+ function selectionGroupMatches(g) {
857
+ return !query || (g.label ?? '').toLowerCase().includes(query)
858
+ || g.atoms.some(a => atomText(a).includes(query));
859
+ }
860
+ function visibleSelectionIndexes() {
861
+ const visibleRefs = new Set(buildGroups().filter(selectionGroupMatches).flatMap(g => g.atoms.map(a => a.ref)));
862
+ return state.atoms.flatMap((atom, index) => visibleRefs.has(atom.ref) ? [index] : []);
863
+ }
864
+
865
+ function renderSelectionTimeline(tl) {
866
+ const maxChars = maxContentChars();
867
+ const visibleIndexes = visibleSelectionIndexes();
868
+ if (!visibleIndexes.includes(cursor)) cursor = visibleIndexes[0] ?? 0;
869
+ let idx = -1;
870
+ let shown = 0;
871
+ const html = buildGroups().map(g => {
872
+ if (!selectionGroupMatches(g)) { idx += g.atoms.length; return ''; }
873
+ shown += g.atoms.length;
874
+ const free = g.atoms.filter(a => !isLocked(a));
875
+ const selCount = free.filter(a => selectionRefs.has(a.ref)).length;
876
+ const rows = g.atoms.map(a => {
877
+ idx += 1;
878
+ const sel = selectionRefs.has(a.ref);
879
+ const locked = isLocked(a);
880
+ const w = maxChars ? Math.round((a.contentChars ?? 0) / maxChars * 100) : 0;
881
+ return `<div class="atom srow ${a.kind === 'user' ? 'user' : ''} ${sel ? 'selected-atom' : ''} ${locked ? 'locked' : ''} ${idx === cursor ? 'cursor-atom' : ''}"
882
+ data-idx="${idx}" data-atom="${esc(a.ref)}" ${locked ? 'aria-disabled="true"' : ''} tabindex="-1">
883
+ <span class="aref">${hl(a.ref, query)}</span>
884
+ <div>
885
+ <div class="arow">
886
+ <span class="tag kind-${esc(a.kind)}">${esc(a.kind)}</span>
887
+ <div class="abody">${hl(a.preview ?? '', query)}</div>
888
+ </div>
889
+ </div>
890
+ <span class="mark ${sel ? 'sel' : locked ? 'lock' : ''}">${locked ? '🔒 can\u2019t compress' : sel ? '✓ compress' : ''}</span>
891
+ <span class="atok">${fmt(a.contentChars)} chars<i class="sz" style="--w:${w}%"></i></span>
892
+ </div>`;
893
+ }).join('');
894
+ return `<section class="group keepg ${selCount === free.length && free.length ? 'sel' : ''}" data-group-key="${esc(g.key)}">
895
+ <div class="ghead" style="cursor:default">
896
+ <span class="chev">·</span>
897
+ <span class="gid">${esc(g.key)}</span>
898
+ <span class="gsum">${esc(g.label ?? '')}</span>
899
+ <span class="gstat">${selCount}/${free.length} selected
900
+ <button class="btn btn-sm" data-group="${esc(g.key)}">${selCount === free.length && free.length ? 'Clear' : 'Add group'}</button></span>
901
+ </div>
902
+ <div class="gbody">${rows}</div>
903
+ </section>`;
904
+ }).join('');
905
+ tl.innerHTML = html || '<div class="empty">Nothing matches the current filter.</div>';
906
+ $('tl-stat').textContent = query ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`;
907
+ }
908
+
676
909
  function renderEditor() {
677
910
  const panel = $('editor');
678
911
  if (isSelection()) {
679
- const selectedAtoms = state.atoms.filter(atom => selectionRefs.has(atom.ref));
680
- const chars = selectedAtoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
681
- const images = selectedAtoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
912
+ const sel = selectionAtoms();
913
+ const chars = sel.reduce((s, a) => s + (a.contentChars ?? 0), 0);
914
+ const images = sel.reduce((s, a) => s + (a.imageCount ?? 0), 0);
682
915
  panel.innerHTML = `<div class="epad">
683
- <div class="etitle"><span class="eid">Selection</span><span class="erefs">shared DraftPlan</span></div>
916
+ <div class="etitle"><span class="eid">Selection</span><span class="erefs">${computeSpans().length} range${computeSpans().length === 1 ? '' : 's'} pending</span></div>
684
917
  <div class="estats">
685
- <div class="estat"><label>atoms</label><span>${selectedAtoms.length}</span></div>
918
+ <div class="estat"><label>atoms</label><span>${sel.length}</span></div>
686
919
  <div class="estat"><label>chars</label><span>${fmt(chars)}</span></div>
687
920
  <div class="estat"><label>images</label><span>${images}</span></div>
688
921
  </div>
689
- <div class="hint">Add a whole user group or individual atoms. KEEP creates a hole; protected atoms stay verbatim. Save writes normalized, non-overlapping ranges to the same DraftPlan used by Agent.</div>
690
- <button class="btn btn-primary" id="selection-save-side">Save selection</button>
691
- <button class="btn" id="selection-clear">Clear selection</button>
922
+ <div class="hint">Selected atoms are handed to the Agent for summarization; everything left unselected stays verbatim (that is KEEP).
923
+ Locked atoms can never be compressed: existing compressed blocks, incomplete tool protocol, or messages without a persistent anchor entry.
924
+ After saving, return to the terminal and tell the Agent to continue.</div>
925
+ <div class="erow" style="position:static;margin:0;padding:0;border:0">
926
+ <button class="btn btn-primary" id="selection-save-side" ${sel.length ? '' : 'disabled'}>Save selection</button>
927
+ <button class="btn" id="selection-clear" ${sel.length ? '' : 'disabled'}>Clear selection</button>
928
+ </div>
692
929
  </div>`;
693
930
  return;
694
931
  }
@@ -701,36 +938,40 @@ function renderEditor() {
701
938
  }
702
939
  const d = draftOf(r);
703
940
  const dirty = isDirty(r);
704
- const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
941
+ const ready = state.draft.ranges.length > 0 && pendingIds().length === 0 && dirtyIds().length === 0;
705
942
  panel.innerHTML = `<div class="epad">
706
- <div>
707
- <div class="etitle">
708
- <span class="eid">${esc(r.id)}</span>
709
- <span class="erefs">${esc(r.startRef)} ${esc(r.endRef)}</span>
710
- </div>
711
- <div class="estats">
712
- <div class="estat"><label>atoms</label><span>${r.atomCount}</span></div>
713
- <div class="estat"><label>chars</label><span>${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)}</span></div>
714
- <div class="estat"><label>images</label><span>${r.originalImageCount ?? 0}</span></div>
715
- </div>
943
+ <div class="etitle">
944
+ <span class="eid">${esc(r.id)}</span>
945
+ <span class="erefs">${esc(r.startRef)} → ${esc(r.endRef)}</span>
946
+ <label class="checkline"><input type="checkbox" id="rev-check" ${reviewed.has(r.id) ? 'checked' : ''}> reviewed</label>
947
+ </div>
948
+ <div class="nextstep ${ready ? 'show' : ''}" id="nextstep">
949
+ <span>✅</span><span><b>Draft saved — nothing is applied yet.</b><br>
950
+ Next step in the terminal: <kbd>/midcompact:commit</kbd> applies this projection · <kbd>/midcompact:abort</kbd> discards it.</span>
951
+ </div>
952
+ <div class="estats">
953
+ <div class="estat"><label>atoms</label><span>${r.atomCount}</span></div>
954
+ <div class="estat"><label>chars</label><span>${fmt(r.originalContentChars)}→<span id="replacement-chars">${fmt(replacementCharsOf(r))}</span></span></div>
955
+ <div class="estat"><label>images</label><span>${r.originalImageCount ?? 0}</span></div>
716
956
  </div>
717
957
  <div class="field" id="f-topic">
718
958
  <label for="topic">Topic <span class="count">optional</span></label>
719
959
  <input id="topic" value="${esc(d.topic)}" placeholder="short label, e.g. auth refactor" autocomplete="off" spellcheck="false">
720
960
  </div>
721
961
  <div class="field ${d.summary.trim() ? '' : 'invalid'}" id="f-summary">
722
- <label for="summary">Summary <span class="count" id="sum-count"></span></label>
723
- <textarea id="summary" spellcheck="false">${esc(d.summary)}</textarea>
962
+ <label for="summary">Summary — the model will see this instead of ${r.atomCount} atoms <span class="count" id="sum-count"></span></label>
963
+ <textarea id="summary" spellcheck="false" placeholder="What must survive after these atoms are replaced?">${esc(d.summary)}</textarea>
724
964
  </div>
725
965
  <div class="erow">
726
- <button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>Save</button>
966
+ <button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>Save ${esc(r.id)}</button>
727
967
  <button class="btn" id="revert-btn" ${dirty ? '' : 'disabled'}>Revert</button>
728
968
  <div class="spacer"></div>
729
969
  <button class="btn btn-danger ${removeArmed === r.id ? 'armed' : ''}" id="remove-btn">
730
- ${removeArmed === r.id ? 'Confirm remove' : 'Remove range'}
970
+ ${removeArmed === r.id ? 'Confirm remove' : 'Remove ' + esc(r.id)}
731
971
  </button>
732
972
  </div>
733
- <div class="hint">Topic and summary are saved together (<kbd>⌘/Ctrl</kbd>+<kbd>S</kbd>). Removing a range keeps its atoms verbatim.</div>
973
+ <div class="hint">Saving edits the draft only nothing is applied until you run <kbd>/midcompact:commit</kbd> in the terminal.
974
+ Removing a range keeps its atoms verbatim. Topic and summary are saved together (<kbd>⌘/Ctrl</kbd>+<kbd>S</kbd>).</div>
734
975
  </div>`;
735
976
  updateCount();
736
977
  }
@@ -738,8 +979,25 @@ function renderEditor() {
738
979
  function updateCount() {
739
980
  const ta = $('summary');
740
981
  if (!ta) return;
741
- const len = ta.value.length;
742
- $('sum-count').textContent = `${len} chars`;
982
+ $('sum-count').textContent = `${[...ta.value].length} chars`;
983
+ const range = rangeById(selectedId);
984
+ if (range && $('replacement-chars')) $('replacement-chars').textContent = fmt(replacementCharsOf(range));
985
+ }
986
+
987
+ function renderHelp() {
988
+ $('help-list').innerHTML = isSelection()
989
+ ? '<dt><kbd>/</kbd></dt><dd>Focus filter</dd>'
990
+ + '<dt><kbd>↑</kbd> <kbd>↓</kbd></dt><dd>Move atom cursor</dd>'
991
+ + '<dt><kbd class="space">Space</kbd></dt><dd>Select / unselect atom</dd>'
992
+ + '<dt><kbd>G</kbd></dt><dd>Select / clear whole group</dd>'
993
+ + '<dt><kbd>Esc</kbd></dt><dd>Close dialog</dd>'
994
+ : '<dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Next / previous range</dd>'
995
+ + '<dt><kbd>/</kbd></dt><dd>Focus filter</dd>'
996
+ + '<dt><kbd>e</kbd></dt><dd>Focus summary editor</dd>'
997
+ + '<dt><kbd>⌘/Ctrl</kbd>+<kbd>S</kbd></dt><dd>Save current range</dd>'
998
+ + '<dt><kbd>r</kbd></dt><dd>Reload state from server</dd>'
999
+ + '<dt><kbd>x</kbd></dt><dd>Expand / collapse selected group</dd>'
1000
+ + '<dt><kbd>Esc</kbd></dt><dd>Blur field / close dialog</dd>';
743
1001
  }
744
1002
 
745
1003
  /* ---------- state mutation ---------- */
@@ -750,6 +1008,17 @@ function select(id) {
750
1008
  removeArmed = null;
751
1009
  render();
752
1010
  }
1011
+ function afterServerState() {
1012
+ selectionRefs = new Set(state.draft.ranges.flatMap(r => rangeAtoms(r).map(a => a.ref)));
1013
+ reviewed = new Set([...reviewed].filter(id => state.draft.ranges.some(r => r.id === id)));
1014
+ reviewedStoreSave();
1015
+ if (!rangeById(selectedId)) selectedId = state.draft.ranges[0]?.id ?? null;
1016
+ selectionDirty = false;
1017
+ lastScrolled = null;
1018
+ }
1019
+ function reviewedStoreSave() {
1020
+ try { sessionStorage.setItem('midcompact-reviewed', JSON.stringify([...reviewed])); } catch {}
1021
+ }
753
1022
 
754
1023
  async function post(path, body) {
755
1024
  const res = await fetch(path, {
@@ -764,53 +1033,52 @@ async function post(path, body) {
764
1033
  }
765
1034
 
766
1035
  async function saveSelection() {
767
- const indices = state.atoms.map((atom, index) => selectionRefs.has(atom.ref) ? index : -1).filter(index => index >= 0);
768
- const spans = [];
769
- for (const index of indices) {
770
- const last = spans.at(-1);
771
- if (last && last.endIndex + 1 === index) {
772
- last.endRef = state.atoms[index].ref;
773
- last.endIndex = index;
774
- } else spans.push({ startRef: state.atoms[index].ref, endRef: state.atoms[index].ref, endIndex: index });
775
- }
1036
+ if (document.body.classList.contains('dead')) { toast('Session closed in the terminal', 'err'); return false; }
1037
+ const spans = computeSpans().map(({ startRef, endRef }) => ({ startRef, endRef }));
776
1038
  try {
777
- state = await post('/api/selection', { spans: spans.map(({ startRef, endRef }) => ({ startRef, endRef })), keepRefs: [...keepRefs] });
778
- selectionDirty = false;
779
- selectedId = state.draft.ranges[0]?.id ?? null;
1039
+ state = await post('/api/selection', { spans, keepRefs: [] });
1040
+ afterServerState();
780
1041
  render();
781
- toast('DraftPlan saved. Tell the Agent to continue when ready.', 'ok');
1042
+ toast('Selection saved tell the Agent to continue when ready.', 'ok');
782
1043
  return true;
783
1044
  } catch (err) { toast(String(err.message ?? err), 'err'); return false; }
784
1045
  }
785
1046
 
786
- function toggleSelectionAtom(ref) {
787
- const atom = atomByRef(ref);
788
- if (!atom || !atom.compressible) return;
789
- if (selectionRefs.has(ref)) { selectionRefs.delete(ref); keepRefs.delete(ref); }
790
- else selectionRefs.add(ref);
1047
+ function toggleAtom(ref) {
1048
+ const a = atomByRef(ref);
1049
+ if (!a || isLocked(a)) return;
1050
+ selectionRefs.has(ref) ? selectionRefs.delete(ref) : selectionRefs.add(ref);
791
1051
  selectionDirty = true;
792
1052
  render();
793
1053
  }
794
-
795
- function toggleKeepAtom(ref) {
796
- const atom = atomByRef(ref);
797
- if (!atom || !atom.compressible) return;
798
- selectionRefs.add(ref);
799
- if (keepRefs.has(ref)) keepRefs.delete(ref); else keepRefs.add(ref);
1054
+ function toggleGroupAtoms(groupKey) {
1055
+ const g = buildGroups().find(g => String(g.key) === String(groupKey));
1056
+ if (!g) return;
1057
+ const free = g.atoms.filter(a => !isLocked(a));
1058
+ const all = free.length && free.every(a => selectionRefs.has(a.ref));
1059
+ for (const a of free) all ? selectionRefs.delete(a.ref) : selectionRefs.add(a.ref);
800
1060
  selectionDirty = true;
801
1061
  render();
802
1062
  }
803
-
804
- function toggleSelectionGroup(groupRef) {
805
- const atoms = state.atoms.filter(atom => atom.groupRef === groupRef);
806
- const allSelected = atoms.every(atom => selectionRefs.has(atom.ref));
807
- for (const atom of atoms) {
808
- if (allSelected) { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); }
809
- else selectionRefs.add(atom.ref);
810
- }
1063
+ function unselectSpan(startRef, endRef) {
1064
+ const start = state.atoms.findIndex(a => a.ref === startRef);
1065
+ const end = state.atoms.findIndex(a => a.ref === endRef);
1066
+ if (start < 0 || end < start) return;
1067
+ for (let i = start; i <= end; i++) selectionRefs.delete(state.atoms[i].ref);
811
1068
  selectionDirty = true;
812
1069
  render();
813
1070
  }
1071
+ function moveCursor(delta) {
1072
+ const visible = visibleSelectionIndexes();
1073
+ if (!visible.length) return;
1074
+ const current = visible.indexOf(cursor);
1075
+ const next = current < 0
1076
+ ? (delta < 0 ? visible.length - 1 : 0)
1077
+ : Math.max(0, Math.min(visible.length - 1, current + delta));
1078
+ cursor = visible[next];
1079
+ render();
1080
+ document.querySelector(`[data-idx="${cursor}"]`)?.scrollIntoView({ block: 'nearest' });
1081
+ }
814
1082
 
815
1083
  async function saveRange(id) {
816
1084
  const r = rangeById(id);
@@ -818,6 +1086,7 @@ async function saveRange(id) {
818
1086
  if (!r || !e) return false;
819
1087
  if (!e.summary.trim()) { toast('Summary cannot be empty', 'err'); $('summary')?.focus(); return false; }
820
1088
  const base = `/api/range/${encodeURIComponent(id)}`;
1089
+ if (document.body.classList.contains('dead')) { toast('Session closed in the terminal', 'err'); return false; }
821
1090
  if (e.topic.trim() !== (r.topic ?? '').trim()) state = await post(`${base}/edit-topic`, { value: e.topic.trim() });
822
1091
  if (e.summary.trim() !== r.summary) state = await post(`${base}/edit-summary`, { value: e.summary.trim() });
823
1092
  edits.delete(id);
@@ -825,6 +1094,7 @@ async function saveRange(id) {
825
1094
  }
826
1095
 
827
1096
  async function saveCurrent() {
1097
+ if (isSelection()) { await saveSelection(); return; }
828
1098
  if (!selectedId || !isDirty(rangeById(selectedId) ?? {})) return;
829
1099
  try { if (await saveRange(selectedId)) { render(); toast('Saved ' + selectedId, 'ok'); } }
830
1100
  catch (err) { toast(String(err.message ?? err), 'err'); }
@@ -835,25 +1105,42 @@ async function refresh() {
835
1105
  const res = await fetch('/api/state');
836
1106
  if (!res.ok) throw new Error('state fetch failed');
837
1107
  state = await res.json();
838
- selectionRefs = new Set(state.draft.ranges.flatMap(range => rangeAtoms(range).map(atom => atom.ref)));
839
- keepRefs.clear();
840
- selectionDirty = false;
841
- if (!rangeById(selectedId)) selectedId = state.draft.ranges[0]?.id ?? null;
1108
+ afterServerState();
842
1109
  render();
843
1110
  toast('Reloaded', 'ok');
844
1111
  } catch (err) { toast(String(err.message ?? err), 'err'); }
845
1112
  }
846
1113
 
1114
+ /* ---------- drawer ---------- */
1115
+ async function openDrawer(ref) {
1116
+ $('dw-ref').textContent = ref;
1117
+ $('dw-meta').textContent = 'loading…';
1118
+ $('dw-body').textContent = 'Loading…';
1119
+ $('drawer').classList.add('show');
1120
+ $('drawer-bg').classList.add('show');
1121
+ try {
1122
+ const res = await fetch('/api/atom/' + encodeURIComponent(ref));
1123
+ if (!res.ok) {
1124
+ const e = await res.json().catch(() => ({}));
1125
+ throw new Error(e.error || `HTTP ${res.status}`);
1126
+ }
1127
+ const a = await res.json();
1128
+ $('dw-ref').textContent = a.ref;
1129
+ $('dw-meta').textContent = `${a.kind} · ${fmt(a.contentChars)} chars · ${a.imageCount} image${a.imageCount === 1 ? '' : 's'}`;
1130
+ $('dw-body').textContent = a.fullText;
1131
+ } catch (err) {
1132
+ $('dw-body').textContent = `Failed to load atom: ${err.message ?? err}`;
1133
+ }
1134
+ }
1135
+ function closeDrawer() {
1136
+ $('drawer').classList.remove('show');
1137
+ $('drawer-bg').classList.remove('show');
1138
+ }
1139
+
847
1140
  /* ---------- events ---------- */
848
1141
  $('range-list').addEventListener('click', e => {
849
- const remove = e.target.closest('[data-remove-proposed]');
850
- if (remove) {
851
- const range = proposedRanges()[Number(remove.dataset.removeProposed)];
852
- range?.atoms.forEach(atom => { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); });
853
- selectionDirty = true;
854
- render();
855
- return;
856
- }
1142
+ const un = e.target.closest('[data-unsel-start]');
1143
+ if (un) { unselectSpan(un.dataset.unselStart, un.dataset.unselEnd); return; }
857
1144
  const item = e.target.closest('[data-range]');
858
1145
  if (item) select(item.dataset.range);
859
1146
  });
@@ -863,44 +1150,56 @@ $('range-list').addEventListener('keydown', e => {
863
1150
  });
864
1151
 
865
1152
  $('timeline').addEventListener('click', e => {
866
- const atomToggle = e.target.closest('[data-toggle-atom]');
867
- if (atomToggle) { toggleSelectionAtom(atomToggle.dataset.toggleAtom); return; }
868
- const keepToggle = e.target.closest('[data-keep-atom]');
869
- if (keepToggle) { toggleKeepAtom(keepToggle.dataset.keepAtom); return; }
870
- const groupToggle = e.target.closest('[data-select-group]');
871
- if (groupToggle) { toggleSelectionGroup(groupToggle.dataset.selectGroup); return; }
872
- const atom = e.target.closest('[data-atom]');
873
- if (atom && e.target.closest('.abody')) {
874
- const ref = atom.dataset.atom;
875
- openAtoms.has(ref) ? openAtoms.delete(ref) : openAtoms.add(ref);
876
- atom.classList.toggle('open');
1153
+ if (isSelection()) {
1154
+ const groupBtn = e.target.closest('[data-group]');
1155
+ if (groupBtn) { toggleGroupAtoms(groupBtn.dataset.group); return; }
1156
+ const row = e.target.closest('.srow[data-idx]');
1157
+ if (row) { cursor = Number(row.dataset.idx); toggleAtom(row.dataset.atom); return; }
877
1158
  return;
878
1159
  }
1160
+ const orig = e.target.closest('[data-orig]');
1161
+ if (orig) { openDrawer(orig.dataset.orig); return; }
1162
+ const fix = e.target.closest('[data-fix]');
1163
+ if (fix) { mode = 'raw'; syncMode(); select(fix.dataset.fix); $('summary')?.focus(); return; }
1164
+ const edit = e.target.closest('[data-edit]');
1165
+ if (edit) { mode = 'raw'; syncMode(); select(edit.dataset.edit); $('summary')?.focus(); return; }
879
1166
  const head = e.target.closest('[data-toggle]');
880
1167
  if (!head) return;
881
1168
  const key = head.dataset.toggle;
882
1169
  const chevron = e.target.closest('.chev');
883
1170
  if (head.dataset.select && !chevron && head.dataset.select !== selectedId) { select(head.dataset.select); return; }
884
1171
  collapsed.has(key) ? collapsed.delete(key) : collapsed.add(key);
885
- head.closest('.group').classList.toggle('open');
1172
+ const group = head.closest('.group');
1173
+ group.classList.toggle('open');
1174
+ const body = group.querySelector('.gbody');
1175
+ if (body) body.hidden = collapsed.has(key);
886
1176
  head.setAttribute('aria-expanded', String(!collapsed.has(key)));
887
1177
  });
888
1178
 
889
1179
  $('toggle-all').addEventListener('click', () => {
890
1180
  if (collapsed.size) { collapsed.clear(); $('toggle-all').textContent = 'Collapse all'; }
891
1181
  else {
892
- buildGroups().forEach((g, i) => collapsed.add(g.key ?? ('keep-' + g.idx)));
1182
+ buildGroups().forEach(g => collapsed.add(g.key ?? ('keep-' + g.idx)));
893
1183
  $('toggle-all').textContent = 'Expand all';
894
1184
  }
895
1185
  renderTimeline();
896
1186
  });
897
1187
 
898
- document.querySelectorAll('.seg [data-filter]').forEach(b => b.addEventListener('click', () => {
1188
+ document.querySelectorAll('#policy-seg [data-filter]').forEach(b => b.addEventListener('click', () => {
899
1189
  policy = b.dataset.filter;
900
- document.querySelectorAll('.seg [data-filter]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
1190
+ document.querySelectorAll('#policy-seg [data-filter]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
901
1191
  renderTimeline();
902
1192
  }));
903
1193
 
1194
+ function syncMode() {
1195
+ document.querySelectorAll('#mode-seg [data-mode]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.mode === mode)));
1196
+ renderTimeline();
1197
+ }
1198
+ document.querySelectorAll('#mode-seg [data-mode]').forEach(b => b.addEventListener('click', () => {
1199
+ mode = b.dataset.mode;
1200
+ syncMode();
1201
+ }));
1202
+
904
1203
  $('q').addEventListener('input', () => {
905
1204
  query = $('q').value.trim().toLowerCase();
906
1205
  $('q-clear').hidden = !query;
@@ -919,19 +1218,22 @@ $('editor').addEventListener('input', e => {
919
1218
  $('f-summary').classList.toggle('invalid', !$('summary').value.trim());
920
1219
  updateCount();
921
1220
  renderRanges();
1221
+ renderStrip();
922
1222
  const d = dirtyIds();
923
1223
  $('dirty-badge').hidden = d.length === 0;
924
1224
  $('dirty-badge').textContent = `${d.length} unsaved`;
925
1225
  $('save-all').hidden = d.length < 2;
926
1226
  $('save-all').textContent = `Save all (${d.length})`;
927
- if (e.target.id === 'topic') renderTimeline();
1227
+ if (e.target.id === 'topic' || e.target.id === 'summary') renderTimeline();
928
1228
  });
929
1229
 
930
- $('save-selection').addEventListener('click', saveSelection);
931
1230
  $('editor').addEventListener('click', async e => {
932
1231
  if (e.target.closest('#selection-save-side')) { await saveSelection(); return; }
933
- if (e.target.closest('#selection-clear')) {
934
- selectionRefs.clear(); keepRefs.clear(); selectionDirty = true; render(); return;
1232
+ if (e.target.closest('#selection-clear')) { selectionRefs.clear(); selectionDirty = true; render(); return; }
1233
+ if (e.target.closest('#rev-check')) {
1234
+ const r = rangeById(selectedId);
1235
+ if (r) { $('rev-check').checked ? reviewed.add(r.id) : reviewed.delete(r.id); reviewedStoreSave(); renderRanges(); renderTimeline(); }
1236
+ return;
935
1237
  }
936
1238
  const r = rangeById(selectedId);
937
1239
  if (!r) return;
@@ -948,23 +1250,28 @@ $('editor').addEventListener('click', async e => {
948
1250
  try {
949
1251
  state = await post(`/api/range/${encodeURIComponent(r.id)}/remove`, {});
950
1252
  edits.delete(r.id);
951
- selectedId = state.draft.ranges[0]?.id ?? null;
952
- lastScrolled = null;
1253
+ afterServerState();
953
1254
  render();
954
1255
  toast('Removed ' + r.id, 'ok');
955
1256
  } catch (err) { toast(String(err.message ?? err), 'err'); }
956
1257
  }
957
1258
  });
958
1259
 
1260
+ $('save-selection').addEventListener('click', saveSelection);
959
1261
  $('save-all').addEventListener('click', async () => {
960
1262
  try {
961
1263
  for (const id of dirtyIds()) await saveRange(id);
962
1264
  render(); toast('All changes saved', 'ok');
963
1265
  } catch (err) { render(); toast(String(err.message ?? err), 'err'); }
964
1266
  });
965
-
966
1267
  $('refresh').addEventListener('click', refresh);
967
1268
 
1269
+ $('strip').addEventListener('click', e => {
1270
+ if (isSelection() || !e.target.closest('[data-toggle-mode]')) return;
1271
+ mode = mode === 'raw' ? 'proj' : 'raw';
1272
+ syncMode();
1273
+ });
1274
+
968
1275
  async function closeWorkbench() {
969
1276
  try {
970
1277
  await post('/api/close', {});
@@ -973,17 +1280,34 @@ async function closeWorkbench() {
973
1280
  }
974
1281
 
975
1282
  $('close').addEventListener('click', () => {
976
- if (dirtyIds().length || selectionDirty) {
1283
+ const pending = isSelection() ? [] : pendingIds();
1284
+ if (dirtyIds().length || selectionDirty || pending.length) {
1285
+ const dirty = dirtyIds().length > 0 || selectionDirty;
1286
+ $('close-dialog-line').textContent = dirty
1287
+ ? 'You have unsaved edits.'
1288
+ : 'All ranges are saved. Nothing is applied yet — the draft waits in the terminal.';
1289
+ $('close-warn').hidden = !pending.length;
1290
+ if (pending.length) {
1291
+ $('close-warn-count').textContent = pending.length;
1292
+ $('close-warn-list').textContent = `${pending.join(', ')} — /midcompact:commit will refuse ${pending.length === 1 ? 'it' : 'them'} until a summary is written.`;
1293
+ }
1294
+ $('close-fix').hidden = !pending.length;
977
1295
  $('close-dialog').classList.add('show');
978
1296
  return;
979
1297
  }
980
1298
  closeWorkbench();
981
1299
  });
982
1300
  $('close-stay').addEventListener('click', () => $('close-dialog').classList.remove('show'));
1301
+ $('close-fix').addEventListener('click', () => {
1302
+ $('close-dialog').classList.remove('show');
1303
+ const pending = pendingIds()[0];
1304
+ if (pending) { mode = 'raw'; syncMode(); select(pending); $('summary')?.focus(); }
1305
+ });
983
1306
  $('close-discard').addEventListener('click', () => {
984
1307
  edits.clear();
985
1308
  selectionDirty = false;
986
1309
  $('close-dialog').classList.remove('show');
1310
+ render();
987
1311
  closeWorkbench();
988
1312
  });
989
1313
  $('close-save').addEventListener('click', async () => {
@@ -998,6 +1322,10 @@ $('close-save').addEventListener('click', async () => {
998
1322
  await closeWorkbench();
999
1323
  });
1000
1324
 
1325
+ /* drawer */
1326
+ $('dw-close').addEventListener('click', closeDrawer);
1327
+ $('drawer-bg').addEventListener('click', closeDrawer);
1328
+
1001
1329
  /* theme */
1002
1330
  function themeIcon() {
1003
1331
  $('theme-toggle').textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '☀' : '☾';
@@ -1023,14 +1351,40 @@ function moveSel(delta) {
1023
1351
  }
1024
1352
  document.addEventListener('keydown', e => {
1025
1353
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
1026
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); return; }
1354
+ if (document.body.classList.contains('dead')) {
1355
+ if (e.key === 'Escape' && typing) e.target.blur();
1356
+ return;
1357
+ }
1358
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
1359
+ e.preventDefault();
1360
+ if (isSelection()) saveSelection(); else saveCurrent();
1361
+ return;
1362
+ }
1027
1363
  if (e.key === 'Escape') {
1028
1364
  help.classList.remove('show');
1029
1365
  $('close-dialog').classList.remove('show');
1366
+ closeDrawer();
1030
1367
  if (typing) e.target.blur();
1031
1368
  return;
1032
1369
  }
1033
1370
  if (typing) return;
1371
+ if (isSelection()) {
1372
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveCursor(1); return; }
1373
+ if (e.key === 'ArrowUp') { e.preventDefault(); moveCursor(-1); return; }
1374
+ if (e.key === ' ') {
1375
+ e.preventDefault();
1376
+ if (visibleSelectionIndexes().includes(cursor)) toggleAtom(state.atoms[cursor]?.ref);
1377
+ return;
1378
+ }
1379
+ if (e.key === 'g' || e.key === 'G') {
1380
+ const a = visibleSelectionIndexes().includes(cursor) ? state.atoms[cursor] : null;
1381
+ if (a) toggleGroupAtoms(a.groupRef);
1382
+ return;
1383
+ }
1384
+ if (e.key === '/') { e.preventDefault(); $('q').focus(); return; }
1385
+ if (e.key === '?') { help.classList.toggle('show'); return; }
1386
+ return;
1387
+ }
1034
1388
  if (e.key === '/') { e.preventDefault(); $('q').focus(); return; }
1035
1389
  if (e.key === '?') { help.classList.toggle('show'); return; }
1036
1390
  if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); moveSel(1); return; }
@@ -1052,7 +1406,9 @@ function toast(msg, kind) {
1052
1406
  toastTimer = setTimeout(() => t.classList.remove('show'), kind === 'err' ? 3200 : 1800);
1053
1407
  }
1054
1408
 
1055
- window.addEventListener('beforeunload', e => { if (dirtyIds().length || selectionDirty) { e.preventDefault(); e.returnValue = ''; } });
1409
+ window.addEventListener('beforeunload', e => {
1410
+ if (dirtyIds().length || selectionDirty) { e.preventDefault(); e.returnValue = ''; }
1411
+ });
1056
1412
  window.addEventListener('pagehide', () => {
1057
1413
  if (!dirtyIds().length && !selectionDirty) navigator.sendBeacon('/api/close', new Blob(['{}'], { type: 'application/json' }));
1058
1414
  });