lintlane 2.5.0 → 4.0.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.
package/ui/index.html CHANGED
@@ -12,27 +12,30 @@
12
12
  </script>
13
13
  <style>
14
14
  :root {
15
- --bg: #fafaf9; --panel: #ffffff; --row-hover: #f5f5f4; --row-sel: #efeeed; --text: #1c1917; --muted: #78716c; --border: #e7e5e4;
15
+ --bg: #fafafa; --panel: #ffffff; --row-hover: #f4f4f5; --row-sel: #e4e4e7; --text: #18181b; --muted: #71717a; --border: #d4d4d8;
16
16
  --err: #b91c1c; --warn: #b45309; --info: #1d4ed8; --ok: #15803d; --err-row: #fdf2f2;
17
17
  --add-bg: #e6ffec; --add-fg: #116329; --del-bg: #ffebe9; --del-fg: #82071e;
18
- --accent: #1c1917; --accent-fg: #fafaf9;
18
+ --accent: #18181b; --accent-fg: #fafafa;
19
+ --surface: #f4f4f5; --silver: #c4c4cc; --log-bg: #fafafa; --log-head: #f4f4f5; --log-fg: #27272a; --log-muted: #71717a;
19
20
  }
20
21
  :root { color-scheme: light; }
21
22
  @media (prefers-color-scheme: dark) {
22
23
  :root:not([data-theme="light"]) {
23
24
  color-scheme: dark;
24
- --bg: #0c0a09; --panel: #1c1917; --row-hover: #24201e; --row-sel: #2b2724; --text: #f5f5f4; --muted: #a8a29e; --border: #292524;
25
+ --bg: #09090b; --panel: #18181b; --row-hover: #202023; --row-sel: #27272a; --text: #f4f4f5; --muted: #a1a1aa; --border: #3f3f46;
25
26
  --err: #f87171; --warn: #fbbf24; --info: #60a5fa; --ok: #4ade80; --err-row: #231414;
26
27
  --add-bg: #0f2d1a; --add-fg: #7ee787; --del-bg: #3a1618; --del-fg: #ffa198;
27
- --accent: #f5f5f4; --accent-fg: #0c0a09;
28
+ --accent: #f4f4f5; --accent-fg: #09090b;
29
+ --surface: #18181b; --silver: #52525b; --log-bg: #101012; --log-head: #18181b; --log-fg: #e4e4e7; --log-muted: #a1a1aa;
28
30
  }
29
31
  }
30
32
  :root[data-theme="dark"] {
31
33
  color-scheme: dark;
32
- --bg: #0c0a09; --panel: #1c1917; --row-hover: #24201e; --row-sel: #2b2724; --text: #f5f5f4; --muted: #a8a29e; --border: #292524;
34
+ --bg: #09090b; --panel: #18181b; --row-hover: #202023; --row-sel: #27272a; --text: #f4f4f5; --muted: #a1a1aa; --border: #3f3f46;
33
35
  --err: #f87171; --warn: #fbbf24; --info: #60a5fa; --ok: #4ade80; --err-row: #231414;
34
36
  --add-bg: #0f2d1a; --add-fg: #7ee787; --del-bg: #3a1618; --del-fg: #ffa198;
35
- --accent: #f5f5f4; --accent-fg: #0c0a09;
37
+ --accent: #f4f4f5; --accent-fg: #09090b;
38
+ --surface: #18181b; --silver: #52525b; --log-bg: #101012; --log-head: #18181b; --log-fg: #e4e4e7; --log-muted: #a1a1aa;
36
39
  }
37
40
  * { box-sizing: border-box; }
38
41
  body { margin: 0; background: var(--bg); color: var(--text);
@@ -125,12 +128,121 @@
125
128
  @media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
126
129
  .log { margin: 0; padding: 14px 18px; font-size: 12.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
127
130
  .error-box { background: var(--del-bg); color: var(--del-fg); padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; }
131
+ /* Actions-style workflow workspace */
132
+ #ciLayout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
133
+ #ciSteps { background: transparent; border: 0; }
134
+ #ciPanel { background: var(--log-bg); color: var(--log-fg); border: 1px solid var(--silver); border-radius: 8px; overflow: hidden; }
135
+ #ciStats { display: flex; gap: 24px; padding: 12px 0 20px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
136
+ #ciStats .stat { display: flex; align-items: baseline; gap: 7px; padding: 0; background: none; border: 0; }
137
+ #ciStats .stat b { font-size: 16px; }
138
+ #ciStats .stat span { font-size: 13px; margin: 0; }
139
+ .ci-tools { font-size: 13px; }
140
+ .ci-tools button { font-size: 12px; padding: 5px 10px; }
141
+ .ci-jobs-title { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); padding: 10px 12px; }
142
+ .ci-job { display: flex; align-items: center; gap: 8px; border-radius: 6px; margin: 3px 0; padding: 4px 8px; border: 1px solid var(--silver); }
143
+ .ci-job.sel { background: var(--row-sel); box-shadow: inset 3px 0 var(--info); }
144
+ .ci-job button { flex: 1; text-align: left; border: 0; padding: 8px 2px; background: transparent; min-width: 0; }
145
+ .ci-job b { display: block; font-size: 13px; font-weight: 600; }
146
+ .ci-job small { font-size: 11px; }
147
+ .ci-step { border-bottom: 1px solid var(--border); }
148
+ .ci-step:last-child { border-bottom: 0; }
149
+ .ci-step-head { display: flex; align-items: center; gap: 12px; padding: 14px 18px; width: 100%; border: 0; border-radius: 0; background: var(--log-head); color: var(--log-fg); text-align: left; }
150
+ .ci-step-head:hover:not(:disabled) { background: var(--row-hover); }
151
+ .ci-step-label { flex: 1; font-weight: 600; font-size: 13px; }
152
+ .ci-time { color: var(--log-muted); font: 12px ui-monospace, monospace; }
153
+ .ci-log-toolbar { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--border); font-size: 13px; }
154
+ .ci-log-toolbar .ci-time { margin-left: auto; color: var(--log-muted); }
155
+ .ci-command { padding: 14px 24px; color: var(--log-muted); font-size: 12px; border-bottom: 1px solid var(--row-hover); overflow-wrap: anywhere; }
156
+ .ci-log-body { max-height: 520px; overflow: auto; padding: 12px 0; scrollbar-color: var(--silver) var(--log-bg); }
157
+ .ci-log-line { display: flex; font: 12px/1.8 ui-monospace, "SF Mono", Menlo, monospace; min-height: 22px; }
158
+ .ci-line-number { flex: 0 0 52px; text-align: right; padding-right: 16px; color: var(--log-muted); user-select: none; }
159
+ .ci-line-text { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; padding-right: 24px; }
160
+ .ci-log-line:hover { background: var(--log-head); }
161
+ #ciPanel .st-pass { color: var(--ok); } #ciPanel .st-fail { color: var(--err); } #ciPanel .st-run { color: var(--info); }
162
+ button:focus-visible, input:focus-visible, .ci-log-body:focus-visible { outline: 2px solid var(--info); outline-offset: 3px; }
163
+ #ciFollow[aria-pressed="true"] { border-color: #58a6ff; color: #58a6ff; }
164
+ #ciRetry[hidden] { display: none; }
165
+ @media (max-width: 760px) { #ciStats { gap: 16px; } .ci-tools { gap: 8px; } main { padding: 20px 14px; } #ciLayout { grid-template-columns: minmax(0, 1fr); } .ci-step-head { flex-wrap: wrap; } }
166
+ /* Shared component polish: zinc surfaces, silver outlines, restrained density. */
167
+ body { font-size: 14px; -webkit-font-smoothing: antialiased; }
168
+ header { min-height: 72px; padding: 16px 32px; gap: 24px; }
169
+ .brand { display: flex; gap: 10px; align-items: center; }
170
+ .brand-mark { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--silver); border-radius: 7px; background: var(--surface); }
171
+ .brand-mark svg { width: 20px; height: 20px; }
172
+ h1 { font-size: 15px; letter-spacing: -.04em; font-weight: 700; }
173
+ .brand-divider { margin: 0 4px; color: var(--silver); font-size: 21px; font-weight: 300; }
174
+ .workspace-label { font-size: 12px; color: var(--muted); }
175
+ main { max-width: 1440px; padding: 36px 32px 64px; }
176
+ .page-heading { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 30px; }
177
+ .eyebrow { color: var(--muted); font-size: 11px; font-weight: 500; margin-bottom: 8px; }
178
+ h2 { font-size: 27px; font-weight: 650; letter-spacing: -.045em; line-height: 1.25; margin: 0; }
179
+ .page-heading p { font-size: 13px; color: var(--muted); margin: 9px 0 0; }
180
+ .environment { display: inline-flex; align-items: center; gap: 7px; font: 11px ui-monospace, monospace; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; white-space: nowrap; }
181
+ .environment > span { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
182
+ button { font-size: 12px; font-weight: 500; border-radius: 6px; min-height: 32px; padding: 6px 12px; box-shadow: 0 1px 2px #00000006; transition: background-color .12s, border-color .12s; }
183
+ button:hover:not(:disabled) { background: var(--row-hover); border-color: var(--silver); }
184
+ button.primary:hover:not(:disabled) { background: var(--accent); opacity: .88; }
185
+ button.theme { width: 32px; height: 32px; font-size: 14px; padding: 0; }
186
+ .tabs { padding: 3px; border-radius: 7px; background: var(--surface); border: 1px solid var(--border); gap: 2px; margin: 0; }
187
+ .tab { padding: 4px 15px; min-height: 27px; border-radius: 4px; font-size: 12px; }
188
+ .tab[aria-current="page"] { background: var(--panel); box-shadow: 0 1px 3px #00000012; }
189
+ .badge { font-size: 10px; padding: 2px 7px; background: var(--surface); text-transform: capitalize; border-radius: 5px; }
190
+ .actions { gap: 8px; }
191
+ .check { font-size: 12px; gap: 5px; }
192
+ input[type="checkbox"] { accent-color: var(--text); width: 13px; height: 13px; cursor: pointer; }
193
+ .stats { gap: 12px; }
194
+ .stat { border-radius: 8px; padding: 20px; display: flex; flex-direction: column-reverse; gap: 12px; box-shadow: 0 1px 2px #00000004; }
195
+ .stat b { font-size: 28px; font-weight: 600; font-variant-numeric: tabular-nums; }
196
+ .stat span { font-size: 12px; margin: 0; }
197
+ .card { border-radius: 8px; border-color: var(--silver); box-shadow: 0 1px 3px #00000004; }
198
+ .toolbar { margin-bottom: 16px; gap: 10px; }
199
+ .toolbar input { font-size: 12px; padding: 8px 12px; border-radius: 6px; min-width: 180px; }
200
+ .chips { gap: 3px; border: 1px solid var(--border); border-radius: 6px; padding: 3px; background: var(--surface); }
201
+ .chip { border: 0; border-radius: 4px; min-height: 26px; padding: 3px 12px; font-size: 11px; background: transparent; }
202
+ .chip[aria-pressed="true"] { background: var(--panel); box-shadow: 0 1px 3px #00000012; }
203
+ th { background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 500; padding: 11px 16px; }
204
+ td { padding: 13px 16px; font-size: 12px; }
205
+ .empty { padding: 64px 20px; display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 12px; }
206
+ .empty strong { color: var(--text); font-size: 14px; font-weight: 500; }
207
+ .empty-symbol { display: grid; place-items: center; width: 40px; height: 40px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 10px; font: 22px ui-monospace, monospace; }
208
+ .panel-head { padding: 12px 16px; font-size: 12px; }
209
+ .diag { font-size: 12px; padding: 14px 16px; }
210
+ .diag .meta { font-size: 10px; margin-top: 5px; }
211
+ .verdict { border-radius: 8px; padding: 14px 18px; font-size: 13px; border: 1px solid var(--silver); background: var(--panel); }
212
+ .verdict.pass { background: var(--panel); border-color: var(--silver); color: var(--ok); box-shadow: inset 3px 0 var(--ok); }
213
+ .verdict.fail { background: var(--panel); border-color: var(--silver); color: var(--err); box-shadow: inset 3px 0 var(--err); }
214
+ .verdict small { color: var(--muted); opacity: 1; font-size: 12px; margin-top: 4px; }
215
+ #ciStats { gap: 28px; padding: 2px 0 18px; margin-bottom: 20px; }
216
+ #ciStats .stat { flex-direction: row; box-shadow: none; }
217
+ #ciStats .stat b { font-size: 14px; }
218
+ #ciStats .stat span { font-size: 11px; }
219
+ #ciLayout { grid-template-columns: 248px minmax(0, 1fr); gap: 20px; }
220
+ #ciSteps { padding: 8px; border: 1px solid var(--silver); border-radius: 8px; background: var(--panel); }
221
+ .ci-jobs-title { padding: 8px 8px 16px; font-size: 11px; text-transform: none; letter-spacing: 0; font-weight: 600; color: var(--text); }
222
+ .ci-jobs-title > span { display: block; font-size: 10px; margin-top: 5px; font-weight: 400; }
223
+ .ci-job { border-color: transparent; margin: 2px 0; padding: 3px 8px; gap: 9px; }
224
+ .ci-job:hover { background: var(--row-hover); }
225
+ .ci-job.sel { border-color: var(--silver); background: var(--surface); box-shadow: none; }
226
+ .ci-job b { font-size: 12px; font-weight: 500; }
227
+ .ci-job small { display: block; font-size: 10px; margin-top: 5px; }
228
+ .ci-log-toolbar { padding: 16px 18px; font-size: 12px; background: var(--log-bg); }
229
+ .log-caption { display: block; font-size: 10px; color: var(--log-muted); margin-top: 4px; }
230
+ .ci-log-toolbar strong { font-weight: 500; }
231
+ .ci-step-head { padding: 15px 18px; font-size: 11px; }
232
+ .ci-step-label { font-size: 12px; font-weight: 500; }
233
+ .ci-step-head .ci-time { font-size: 10px; min-width: 45px; text-align: right; }
234
+ .ci-command { padding: 12px 18px; font-size: 11px; }
235
+ .ci-log-line { font-size: 11px; line-height: 1.9; }
236
+ #ciFollow[aria-pressed="true"] { border-color: var(--silver); color: var(--text); background: var(--surface); }
237
+ @media (max-width: 950px) { header { gap: 12px; padding: 12px 20px; } .workspace-label, .brand-divider { display: none; } .actions { flex-wrap: wrap; } }
238
+ @media (max-width: 760px) { main { padding: 24px 16px 48px; } #ciLayout { grid-template-columns: minmax(0, 1fr); } .page-heading { align-items: flex-start; } .environment { display: none; } .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } header { gap: 10px; } header .actions { width: 100%; order: 2; } .chips { margin-left: 0; } .ci-tools { gap: 8px; } }
239
+ @media (prefers-reduced-motion: reduce) { button { transition: none; } }
128
240
  </style>
129
241
  </head>
130
242
  <body>
131
243
  <header>
132
- <h1>Lintlane</h1>
133
- <nav class="tabs">
244
+ <div class="brand"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 4 4-4 4M13 16h6M13 8h6"/></svg></span><h1>Lintlane</h1><span class="brand-divider">/</span><span class="workspace-label">Workspace</span></div>
245
+ <nav class="tabs" aria-label="Dashboard views">
134
246
  <button type="button" class="tab" data-view="lint" aria-current="page">Lint</button>
135
247
  <button type="button" class="tab" data-view="ci">CI</button>
136
248
  </nav>
@@ -148,6 +260,7 @@
148
260
  <button type="button" id="theme" class="theme" aria-label="Toggle light/dark mode"></button>
149
261
  </header>
150
262
  <main id="view-lint">
263
+ <div class="page-heading"><div><div class="eyebrow">Code quality</div><h2>Lint overview</h2><p>Review diagnostics and apply fixes before your next commit.</p></div><span class="environment"><span></span>Local workspace</span></div>
151
264
  <div id="notice"></div>
152
265
  <div class="stats" id="stats"></div>
153
266
  <div class="toolbar">
@@ -165,12 +278,21 @@
165
278
  </main>
166
279
 
167
280
  <main id="view-ci" hidden>
281
+ <div class="page-heading"><div><div class="eyebrow">Continuous integration</div><h2>Workflow runs</h2><p>Run your project checks and inspect the output, step by step.</p></div><span class="environment"><span></span>Local workspace</span></div>
168
282
  <div id="ciNotice"></div>
169
283
  <div id="ciVerdict"></div>
170
- <div class="stats" id="ciStats"></div>
171
- <div class="layout has-panel" id="ciLayout">
172
- <div class="card" id="ciSteps"></div>
173
- <div class="card panel" id="ciPanel"></div>
284
+ <div class="stats" id="ciStats" aria-live="polite"></div>
285
+ <div class="toolbar ci-tools">
286
+ <span class="muted">Local workflow / <strong>Quality checks</strong></span>
287
+ <div class="spacer"></div>
288
+ <button type="button" id="ciRetry" hidden>Re-run failed checks</button>
289
+ <button type="button" id="ciFollow" aria-pressed="true">Follow active step</button>
290
+ <button type="button" id="ciExpand">Expand all logs</button>
291
+
292
+ </div>
293
+ <div id="ciLayout">
294
+ <div id="ciSteps"></div>
295
+ <div id="ciPanel"></div>
174
296
  </div>
175
297
  </main>
176
298
 
@@ -293,8 +415,8 @@ function renderStats() {
293
415
  function renderTable() {
294
416
  const files = visibleFiles();
295
417
  const card = $("#tableCard");
296
- if (!state) { card.innerHTML = `<div class="empty">Click “Run check” to lint this project.</div>`; return; }
297
- if (state.files.length === 0) { card.innerHTML = `<div class="empty">✔ No issues found.</div>`; return; }
418
+ if (!state) { card.innerHTML = `<div class="empty"><span class="empty-symbol" aria-hidden="true">⌘</span><strong>Your next clean commit starts here</strong><span>Run a check to review lint and formatting diagnostics.</span></div>`; return; }
419
+ if (state.files.length === 0) { card.innerHTML = `<div class="empty"><span class="empty-symbol fixable" aria-hidden="true">✓</span><strong>No issues found</strong><span>Your code matches the project’s lint and formatting rules.</span></div>`; return; }
298
420
  if (files.length === 0) { card.innerHTML = `<div class="empty">No files match your filters.</div>`; return; }
299
421
  card.innerHTML = `<table><thead><tr><th>File</th><th>Level</th><th class="num">Issues</th><th>Fix</th></tr></thead><tbody>${files.map((f) => {
300
422
  const w = worst(f);
@@ -471,6 +593,8 @@ for (const tab of document.querySelectorAll(".tab")) tab.addEventListener("click
471
593
 
472
594
  // ---------------------------------------------------------------- CI view
473
595
  const ci = { steps: null, enabled: new Set(), results: {}, selected: null, run: null, finished: false, startedAt: 0, seconds: null, error: null, follow: true };
596
+ ci.cancelled = false;
597
+ ci.expanded = new Set();
474
598
  const ESC = String.fromCharCode(27);
475
599
  const BEL = String.fromCharCode(7);
476
600
  const ANSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "g");
@@ -479,8 +603,15 @@ const fmtMs = (ms) => (ms == null ? "–" : ms < 1000 ? `${ms}ms` : `${(ms / 100
479
603
 
480
604
  async function ciLoad() {
481
605
  try {
482
- const { steps } = await api("/api/ci/steps");
606
+ const { steps, problems } = await api("/api/ci/steps");
483
607
  ci.steps = steps;
608
+ ci.problems = [...problems];
609
+ try {
610
+ const report = await api("/api/doctor");
611
+ for (const f of report.findings) {
612
+ if (f.status === "fail") ci.problems.push(`Setup: ${f.message}${f.fix ? ` — ${f.fix}` : ""}`);
613
+ }
614
+ } catch {}
484
615
  for (const st of steps) if (st.defaultOn) ci.enabled.add(st.id);
485
616
  ci.selected = steps.find((st) => st.defaultOn)?.id ?? steps[0]?.id ?? null;
486
617
  } catch (e) { ci.error = e.message; }
@@ -517,46 +648,52 @@ function ciRenderVerdict() {
517
648
  if (!ci.finished) { el.innerHTML = ""; return; }
518
649
  const res = ci.steps.filter((st) => ci.results[st.id]);
519
650
  const failed = res.find((st) => ci.results[st.id].status === "fail");
520
- const cancelled = res.some((st) => ci.results[st.id].status === "cancelled");
651
+ const cancelled = ci.cancelled || res.some((st) => ci.results[st.id].status === "cancelled");
521
652
  if (cancelled) el.innerHTML = `<div class="verdict">Run stopped.</div>`;
653
+ else if (ci.error) el.innerHTML = `<div class="verdict fail">Run could not complete<small>${esc(ci.error)}</small></div>`;
522
654
  else if (failed) el.innerHTML = `<div class="verdict fail">CI would fail<small>First failure: ${esc(failed.label)}. Fix it before pushing.</small></div>`;
523
- else if (res.length) el.innerHTML = `<div class="verdict pass">All ${plural(res.length, "step")} passed — safe to push<small>Same checks as your CI workflow, run locally.</small></div>`;
655
+ else if (res.length) el.innerHTML = `<div class="verdict pass">All ${plural(res.length, "step")} passed<small>Same checks as your CI workflow, run locally.</small></div>`;
524
656
  }
525
657
 
526
658
  function ciRenderTable() {
527
659
  const card = $("#ciSteps");
528
- if (!ci.steps) { card.innerHTML = `<div class="empty">Loading steps…</div>`; return; }
529
- const running = !!ci.run;
530
- card.innerHTML = `<table><thead><tr><th></th><th>Step</th><th>Status</th><th class="num">Time</th></tr></thead><tbody>${ci.steps.map((st) => {
660
+ if (!ci.steps) { card.innerHTML = '<div class="empty">Loading steps…</div>'; return; }
661
+ card.innerHTML = '<div class="ci-jobs-title">Workflow steps<span class="muted">Select checks to include in this run</span></div>' + ci.steps.map((st) => {
531
662
  const r = ci.results[st.id];
532
- return `<tr data-id="${esc(st.id)}" class="${r?.status === "fail" ? "has-err" : ""} ${st.id === ci.selected ? "sel" : ""}">
533
- <td class="chk"><input type="checkbox" data-enable="${esc(st.id)}" aria-label="Include ${esc(st.label)}" ${ci.enabled.has(st.id) ? "checked" : ""} ${running ? "disabled" : ""} /></td>
534
- <td class="step"><b>${esc(st.label)}</b><small class="mono">${esc(st.display)}</small>${st.note ? `<small> · ${esc(st.note)}</small>` : ""}</td>
535
- <td class="status">${ciStatusCell(r)}</td><td class="num muted">${fmtMs(r?.ms)}</td></tr>`;
536
- }).join("")}</tbody></table>`;
663
+ return `<div class="ci-job ${st.id === ci.selected ? "sel" : ""}"><input type="checkbox" data-enable="${esc(st.id)}" aria-label="Include ${esc(st.label)}" ${ci.enabled.has(st.id) ? "checked" : ""} ${ci.run ? "disabled" : ""} /><button type="button" data-select="${esc(st.id)}" ${st.id === ci.selected ? 'aria-current="step"' : ""}><b>${esc(st.label)}</b><small>${ciStatusCell(r)} · ${fmtMs(r?.ms)}</small></button></div>`;
664
+ }).join("");
537
665
  }
538
666
 
539
667
  function ciRenderPanel() {
540
668
  const panel = $("#ciPanel");
541
- const st = ci.steps?.find((x) => x.id === ci.selected);
542
- if (!st) { panel.innerHTML = `<div class="empty">Select a step to see its output.</div>`; return; }
543
- const r = ci.results[st.id];
544
- const text = r?.log ? r.log.replace(ANSI, "") : "";
545
- const placeholder = r?.status === "running" ? "Waiting for output…" : "No output yet. Run CI to see this step’s logs.";
546
- panel.innerHTML = `<div class="panel-head"><span class="path mono">${esc(st.display)}</span>${ciStatusCell(r)}</div>
547
- <pre class="log mono" id="ciLog">${text ? esc(text) : `<span class="muted">${placeholder}</span>`}</pre>`;
548
- if (r?.status === "running") panel.scrollTop = panel.scrollHeight;
669
+ const scrolls = new Map([...panel.querySelectorAll(".ci-log-body")].map((el) => [el.dataset.log, el.scrollTop]));
670
+ if (!ci.steps) { panel.innerHTML = '<div class="empty">Loading workflow…</div>'; return; }
671
+ panel.innerHTML = '<div class="ci-log-toolbar"><div><strong>Execution logs</strong><span class="log-caption">Quality checks</span></div><span class="ci-time">Local run</span></div>' + ci.steps.filter((st) => ci.enabled.has(st.id) || ci.results[st.id]).map((st) => {
672
+ const r = ci.results[st.id];
673
+ const open = ci.expanded.has(st.id);
674
+ const text = (r?.log ?? "").replace(ANSI, "");
675
+ const placeholder = r?.status === "running" ? "Waiting for output…" : r?.status === "skip" ? "This step was skipped." : r?.status === "cancelled" ? "This step was stopped." : "No output recorded.";
676
+ return `<section class="ci-step" id="ci-step-${esc(st.id)}"><button type="button" class="ci-step-head" data-toggle="${esc(st.id)}" aria-expanded="${open}" aria-controls="ci-body-${esc(st.id)}"><span aria-hidden="true">${open ? "▾" : "▸"}</span><span class="ci-step-label">${esc(st.label)}</span>${ciStatusCell(r)}<span class="ci-time">${fmtMs(r?.ms)}</span></button>${open ? `<div id="ci-body-${esc(st.id)}"><div class="ci-command mono">$ ${esc(st.display)}</div><div class="ci-log-body" data-log="${esc(st.id)}" tabindex="0" aria-label="${esc(st.label)} output">${(text || placeholder).split("\n").map((line, i) => `<div class="ci-log-line"><span class="ci-line-number">${i + 1}</span><span class="ci-line-text">${esc(line)}</span></div>`).join("")}</div></div>` : ""}</section>`;
677
+ }).join("");
678
+ for (const el of panel.querySelectorAll(".ci-log-body")) el.scrollTop = ci.follow && ci.results[el.dataset.log]?.status === "running" ? el.scrollHeight : scrolls.get(el.dataset.log) ?? 0;
549
679
  }
550
680
 
551
681
  function ciRender() {
552
682
  const busy = !!ci.run;
553
683
  const ciRunBtn = $("#ciRun");
554
- ciRunBtn.textContent = busy ? "Stop" : "Run CI";
684
+ ciRunBtn.textContent = busy ? "Cancel run" : ci.finished ? "Re-run checks" : "Run checks";
555
685
  ciRunBtn.classList.toggle("primary", !busy);
556
686
  ciRunBtn.disabled = !ci.steps || (!busy && ci.enabled.size === 0);
557
687
  $("#ciStop").disabled = busy;
558
- $("#ciStatus").textContent = busy ? "running…" : !ci.finished ? "idle" : Object.values(ci.results).some((r) => r.status === "fail") ? "failed" : "passed";
559
- $("#ciNotice").innerHTML = ci.error ? `<div class="error-box">${esc(ci.error)}</div>` : "";
688
+ $("#ciStatus").textContent = busy ? "running…" : !ci.finished ? "idle" : ci.cancelled ? "stopped" : ci.error ? "error" : Object.values(ci.results).some((r) => r.status === "fail") ? "failed" : "passed";
689
+ $("#ciNotice").innerHTML = ci.error
690
+ ? `<div class="error-box">${esc(ci.error)}</div>`
691
+ : (ci.problems ?? []).map((p) => `<div class="error-box">${esc(p)}</div>`).join("");
692
+ const visible = ci.steps?.filter((st) => ci.enabled.has(st.id) || ci.results[st.id]) ?? [];
693
+ $("#ciExpand").textContent = visible.length && visible.every((st) => ci.expanded.has(st.id)) ? "Collapse all logs" : "Expand all logs";
694
+ $("#ciExpand").disabled = !visible.length;
695
+ $("#ciFollow").setAttribute("aria-pressed", String(ci.follow));
696
+ $("#ciRetry").hidden = busy || !Object.values(ci.results).some((r) => r.status === "fail");
560
697
  ciRenderStats(); ciRenderVerdict(); ciRenderTable(); ciRenderPanel();
561
698
  }
562
699
 
@@ -567,18 +704,19 @@ function ciSoftRender() {
567
704
  }
568
705
 
569
706
  function ciEvent(e) {
570
- if (e.type === "start") { ciRes(e.id).status = "running"; if (ci.follow) ci.selected = e.id; ciRender(); }
571
- else if (e.type === "log") { ciRes(e.id).log += e.text; if (e.id === ci.selected) ciSoftRender(); }
572
- else if (e.type === "end") { const r = ciRes(e.id); r.status = e.code === 0 ? "pass" : "fail"; r.ms = e.ms; if (e.code !== 0 && ci.follow) ci.selected = e.id; ciRender(); }
707
+ if (e.type === "start") { ciRes(e.id).status = "running"; if (ci.follow) { ci.selected = e.id; ci.expanded.add(e.id); } ciRender(); }
708
+ else if (e.type === "log") { ciRes(e.id).log += e.text; if (ci.expanded.has(e.id)) ciSoftRender(); }
709
+ else if (e.type === "end") { const r = ciRes(e.id); r.status = e.code === 0 ? "pass" : "fail"; r.ms = e.ms; if (e.code !== 0) { ci.expanded.add(e.id); if (ci.follow) ci.selected = e.id; } else if (ci.follow) ci.expanded.delete(e.id); ciRender(); }
573
710
  else if (e.type === "skip") { ciRes(e.id).status = "skip"; ciRender(); }
574
711
  }
575
712
 
576
- async function ciStart() {
577
- const ids = ci.steps.filter((st) => ci.enabled.has(st.id)).map((st) => st.id);
713
+ async function ciStart(onlyIds) {
714
+ const ids = onlyIds ?? ci.steps.filter((st) => ci.enabled.has(st.id)).map((st) => st.id);
578
715
  if (!ids.length) return;
716
+ ci.expanded.clear();
579
717
  ci.results = {};
580
718
  for (const id of ids) ciRes(id);
581
- ci.finished = false; ci.error = null; ci.seconds = null; ci.follow = true; ci.startedAt = Date.now();
719
+ ci.finished = false; ci.cancelled = false; ci.error = null; ci.seconds = null; ci.follow = true; ci.startedAt = Date.now();
582
720
  const ctrl = new AbortController();
583
721
  ci.run = ctrl;
584
722
  const tick = setInterval(ciRenderStats, 1000);
@@ -606,6 +744,7 @@ async function ciStart() {
606
744
  }
607
745
  } catch (e) {
608
746
  if (e.name === "AbortError") {
747
+ ci.cancelled = true;
609
748
  for (const r of Object.values(ci.results)) {
610
749
  if (r.status === "running") r.status = "cancelled";
611
750
  else if (r.status === "pending") r.status = "skip";
@@ -627,9 +766,36 @@ $("#ciSteps").addEventListener("click", (e) => {
627
766
  ciRender();
628
767
  return;
629
768
  }
630
- const tr = e.target.closest("tr[data-id]");
631
- if (tr) { ci.selected = tr.dataset.id; ci.follow = false; ciRender(); }
769
+ const job = e.target.closest("[data-select]");
770
+ if (job) {
771
+ ci.selected = job.dataset.select; ci.expanded.add(ci.selected); ci.follow = false; ciRender();
772
+ document.getElementById(`ci-step-${ci.selected}`)?.scrollIntoView({ block: "nearest" });
773
+ }
774
+ });
775
+
776
+ $("#ciPanel").addEventListener("click", (e) => {
777
+ const head = e.target.closest("[data-toggle]");
778
+ if (!head) return;
779
+ const id = head.dataset.toggle;
780
+ if (ci.expanded.has(id)) ci.expanded.delete(id); else ci.expanded.add(id);
781
+ ci.selected = id; ci.follow = false; ciRender();
782
+ $("#ciPanel").querySelector(`[data-toggle="${id}"]`)?.focus();
783
+ });
784
+ $("#ciExpand").addEventListener("click", () => {
785
+ const visible = ci.steps?.filter((st) => ci.enabled.has(st.id) || ci.results[st.id]) ?? [];
786
+ const allOpen = visible.every((st) => ci.expanded.has(st.id));
787
+ for (const st of visible) { if (allOpen) ci.expanded.delete(st.id); else ci.expanded.add(st.id); }
788
+ ci.follow = false; ciRender();
789
+ });
790
+ $("#ciFollow").addEventListener("click", () => {
791
+ ci.follow = !ci.follow;
792
+ if (ci.follow) {
793
+ const active = ci.steps?.find((st) => ci.results[st.id]?.status === "running");
794
+ if (active) { ci.selected = active.id; ci.expanded.add(active.id); }
795
+ }
796
+ ciRender();
632
797
  });
798
+ $("#ciRetry").addEventListener("click", () => ciStart(ci.steps.filter((st) => ci.results[st.id]?.status === "fail").map((st) => st.id)));
633
799
 
634
800
  showView(location.hash === "#ci" ? "ci" : "lint");
635
801
  </script>
package/bin/ci.mjs DELETED
@@ -1,162 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- const MAX_LOG_CHARS = 2_000_000;
5
-
6
- const RUN_SCRIPT = {
7
- bun: (name) => ["bun", "run", name],
8
- npm: (name) => ["npm", "run", name],
9
- pnpm: (name) => ["pnpm", "run", name],
10
- yarn: (name) => ["yarn", "run", name],
11
- };
12
-
13
- // Same commands the generated GitHub workflow uses for its install step.
14
- const FROZEN_INSTALL = {
15
- bun: ["bun", "install", "--frozen-lockfile"],
16
- npm: ["npm", "ci"],
17
- pnpm: ["pnpm", "install", "--frozen-lockfile"],
18
- yarn: ["yarn", "install", "--frozen-lockfile"],
19
- };
20
-
21
- const NPM_DEFAULT_TEST = /no test specified/i;
22
-
23
- function firstScript(scripts, names) {
24
- return names.find((name) => typeof scripts[name] === "string");
25
- }
26
-
27
- /**
28
- * Work out which CI steps this project can run locally. Only commands the
29
- * project itself defines (package.json scripts, its own Biome) are offered,
30
- * and the browser only ever sends step ids back — never command lines.
31
- */
32
- export function detectCiSteps({ cwd, biome, pmName, pkg }) {
33
- const scripts = pkg?.scripts ?? {};
34
- const run = RUN_SCRIPT[pmName] ?? RUN_SCRIPT.npm;
35
- const steps = [];
36
-
37
- steps.push({
38
- id: "install",
39
- label: "Install (frozen lockfile)",
40
- cmd: FROZEN_INSTALL[pmName] ?? FROZEN_INSTALL.npm,
41
- defaultOn: false,
42
- note: "What CI does first. Reinstalls node_modules, so it's off by default.",
43
- });
44
-
45
- steps.push({
46
- id: "biome",
47
- label: "Biome CI (lint + format)",
48
- cmd: [biome, "ci", "--colors=off", "."],
49
- display: "biome ci .",
50
- defaultOn: true,
51
- note: "The same check the GitHub workflow runs.",
52
- });
53
-
54
- const typecheck = firstScript(scripts, ["typecheck", "type-check", "check-types", "tsc"]);
55
- if (typecheck) {
56
- steps.push({
57
- id: "typecheck",
58
- label: "Typecheck",
59
- cmd: run(typecheck),
60
- defaultOn: true,
61
- });
62
- } else if (
63
- existsSync(join(cwd, "tsconfig.json")) &&
64
- existsSync(join(cwd, "node_modules", ".bin", "tsc"))
65
- ) {
66
- steps.push({
67
- id: "typecheck",
68
- label: "Typecheck",
69
- cmd: [join(cwd, "node_modules", ".bin", "tsc"), "--noEmit"],
70
- display: "tsc --noEmit",
71
- defaultOn: true,
72
- });
73
- }
74
-
75
- if (typeof scripts.build === "string") {
76
- steps.push({ id: "build", label: "Build", cmd: run("build"), defaultOn: true });
77
- }
78
-
79
- if (typeof scripts.test === "string" && !NPM_DEFAULT_TEST.test(scripts.test)) {
80
- steps.push({ id: "test", label: "Tests", cmd: run("test"), defaultOn: true });
81
- }
82
-
83
- return steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") }));
84
- }
85
-
86
- /**
87
- * Run steps one after another and stream NDJSON events:
88
- * start / log / end / skip / done
89
- * Closing the response (the browser's Stop button) kills the running process.
90
- */
91
- export function ciStream({ cwd, steps, stopOnFail, release }) {
92
- const encoder = new TextEncoder();
93
- let proc = null;
94
- let cancelled = false;
95
-
96
- return new ReadableStream({
97
- async start(controller) {
98
- const send = (event) => {
99
- if (!cancelled) controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
100
- };
101
- let failed = false;
102
-
103
- try {
104
- for (const step of steps) {
105
- if (cancelled) break;
106
- if (failed && stopOnFail) {
107
- send({ type: "skip", id: step.id });
108
- continue;
109
- }
110
-
111
- send({ type: "start", id: step.id });
112
- const t0 = performance.now();
113
- let code = 1;
114
- let sent = 0;
115
- let truncated = false;
116
-
117
- const pump = async (readable) => {
118
- const decoder = new TextDecoder();
119
- for await (const chunk of readable) {
120
- const text = decoder.decode(chunk, { stream: true });
121
- if (sent < MAX_LOG_CHARS) {
122
- sent += text.length;
123
- send({ type: "log", id: step.id, text });
124
- } else if (!truncated) {
125
- truncated = true;
126
- send({ type: "log", id: step.id, text: "\n… output truncated …\n" });
127
- }
128
- }
129
- };
130
-
131
- try {
132
- proc = Bun.spawn(step.cmd, {
133
- cwd,
134
- env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
135
- stdin: "ignore",
136
- stdout: "pipe",
137
- stderr: "pipe",
138
- });
139
- await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
140
- code = await proc.exited;
141
- } catch (error) {
142
- send({ type: "log", id: step.id, text: `\n${error.message}\n` });
143
- code = 127;
144
- }
145
- proc = null;
146
-
147
- if (cancelled) break;
148
- if (code !== 0) failed = true;
149
- send({ type: "end", id: step.id, code, ms: Math.round(performance.now() - t0) });
150
- }
151
- send({ type: "done" });
152
- } finally {
153
- release();
154
- if (!cancelled) controller.close();
155
- }
156
- },
157
- cancel() {
158
- cancelled = true;
159
- proc?.kill();
160
- },
161
- });
162
- }
@@ -1,24 +0,0 @@
1
- // Reads the optional commit settings from the project's package.json:
2
- //
3
- // "lintlane": {
4
- // "commit": {
5
- // "types": ["feat", "fix", "chore"],
6
- // "messageLengthLimit": 72,
7
- // "allowedPrefixes": ["WIP"],
8
- // "retryAfterFailure": true
9
- // }
10
- // }
11
- //
12
- // Plain Node, never throws: a missing or broken file just means defaults.
13
- import { readFileSync } from "node:fs";
14
- import { join } from "node:path";
15
-
16
- export function loadCommitConfig(cwd = process.cwd()) {
17
- try {
18
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
19
- const commit = pkg?.lintlane?.commit;
20
- return commit && typeof commit === "object" ? commit : {};
21
- } catch {
22
- return {};
23
- }
24
- }