github-license-scanner 0.1.0__py3-none-any.whl

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.
gls/webui.py ADDED
@@ -0,0 +1,1828 @@
1
+ """
2
+ NiceGUI web interface for the GitHub License Scanner.
3
+
4
+ Minimal UI: bilingual ES/EN, light/dark, quiet canvas grid,
5
+ progressive scan feedback, empty states, and polished results.
6
+
7
+ Code comments: English.
8
+
9
+ Run:
10
+ gls ui
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Callable
17
+
18
+ from nicegui import app, ui
19
+
20
+ from .auth import auth_enabled, authenticate, history_user_key
21
+ from .config import (
22
+ HOST,
23
+ MAX_BATCH_URLS,
24
+ PORT,
25
+ RATE_LIMIT_SCANS,
26
+ RATE_LIMIT_WINDOW_SECONDS,
27
+ SHOW_BROWSER,
28
+ STORAGE_SECRET,
29
+ )
30
+ from .history_store import append_scan, clear_history, load_history
31
+ from .i18n import DEFAULT_LANG, normalize_lang, t
32
+ from .license_analyzer import analyze_repository, risk_color
33
+ from .models import PackageLicense, ScanResult
34
+ from .rate_limit import SlidingWindowRateLimiter
35
+ from .report import render_markdown_report
36
+ from .sbom_export import render_sbom
37
+
38
+ # Per-process scan limiter (keyed by session / anonymous)
39
+ _scan_limiter = SlidingWindowRateLimiter(
40
+ max_calls=RATE_LIMIT_SCANS,
41
+ window_seconds=RATE_LIMIT_WINDOW_SECONDS,
42
+ )
43
+
44
+
45
+ def _client_rate_key() -> str:
46
+ """Best-effort client key for rate limiting (user or session id)."""
47
+ try:
48
+ username = app.storage.user.get("username")
49
+ if username:
50
+ return f"user:{username}"
51
+ # NiceGUI user storage is cookie-backed; use a stable per-browser key
52
+ key = app.storage.user.get("_gls_rid")
53
+ if not key:
54
+ import secrets
55
+
56
+ key = secrets.token_hex(16)
57
+ app.storage.user["_gls_rid"] = key
58
+ return f"u:{key}"
59
+ except Exception: # noqa: BLE001
60
+ return "u:anonymous"
61
+
62
+
63
+ def _check_scan_budget(n: int = 1) -> str | None:
64
+ """
65
+ Consume rate-limit budget for n scans.
66
+
67
+ Returns an error message if denied, else None.
68
+ """
69
+ key = _client_rate_key()
70
+ for _ in range(max(1, n)):
71
+ if not _scan_limiter.allow(key):
72
+ wait = int(_scan_limiter.retry_after_seconds(key)) + 1
73
+ return t(
74
+ "rate_limited",
75
+ get_lang(),
76
+ wait=wait,
77
+ limit=RATE_LIMIT_SCANS,
78
+ window=RATE_LIMIT_WINDOW_SECONDS,
79
+ )
80
+ return None
81
+
82
+ # Example repos shown as one-click chips (public, fast to scan)
83
+ EXAMPLE_REPOS = [
84
+ ("psf/requests", "https://github.com/psf/requests"),
85
+ ("encode/httpx", "https://github.com/encode/httpx"),
86
+ ("tiangolo/fastapi", "https://github.com/tiangolo/fastapi"),
87
+ ]
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Design system
91
+ # ---------------------------------------------------------------------------
92
+
93
+ CUSTOM_CSS = """
94
+ /* Minimal monochrome UI */
95
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
96
+
97
+ :root {
98
+ --font: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
99
+ --mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
100
+ --r: 10px;
101
+ --r-sm: 8px;
102
+ --r-xs: 6px;
103
+ --accent: #111111;
104
+ --accent-hi: #262626;
105
+ --accent-2: #525252;
106
+ --ok: #171717;
107
+ --warn: #737373;
108
+ --bad: #404040;
109
+ --bg: #fafafa;
110
+ --bg-2: #f5f5f5;
111
+ --surface: #ffffff;
112
+ --surface-2: #f5f5f5;
113
+ --text: #111111;
114
+ --muted: #737373;
115
+ --border: #e5e5e5;
116
+ --shadow: 0 1px 2px rgba(0,0,0,.05), 0 4px 12px rgba(0,0,0,.06);
117
+ --shadow-md: 0 2px 4px rgba(0,0,0,.05), 0 8px 20px rgba(0,0,0,.08);
118
+ --shadow-lg: 0 4px 8px rgba(0,0,0,.04), 0 16px 40px rgba(0,0,0,.10);
119
+ --ring: 0 0 0 2px rgba(17,17,17,.12);
120
+ --topbar-h: 56px;
121
+ }
122
+
123
+ body.body--dark {
124
+ --accent: #fafafa;
125
+ --accent-hi: #e5e5e5;
126
+ --accent-2: #a3a3a3;
127
+ --ok: #e5e5e5;
128
+ --warn: #a3a3a3;
129
+ --bad: #737373;
130
+ --bg: #0a0a0a;
131
+ --bg-2: #111111;
132
+ --surface: #141414;
133
+ --surface-2: #1a1a1a;
134
+ --text: #fafafa;
135
+ --muted: #a3a3a3;
136
+ --border: #262626;
137
+ --shadow: 0 1px 2px rgba(0,0,0,.45), 0 6px 16px rgba(0,0,0,.35);
138
+ --shadow-md: 0 2px 6px rgba(0,0,0,.4), 0 12px 28px rgba(0,0,0,.4);
139
+ --shadow-lg: 0 8px 16px rgba(0,0,0,.4), 0 24px 48px rgba(0,0,0,.5);
140
+ --ring: 0 0 0 2px rgba(250,250,250,.14);
141
+ }
142
+
143
+ html, body, .q-page, .nicegui-content {
144
+ font-family: var(--font) !important;
145
+ background: var(--bg) !important;
146
+ color: var(--text) !important;
147
+ }
148
+ .q-page, .nicegui-content, .q-page-container {
149
+ max-width: none !important;
150
+ width: 100% !important;
151
+ }
152
+ .nicegui-content { padding: 0 !important; }
153
+
154
+ .gls-canvas {
155
+ position: fixed;
156
+ inset: 0;
157
+ width: 100%;
158
+ height: 100%;
159
+ z-index: 0;
160
+ pointer-events: none;
161
+ display: block;
162
+ opacity: 0.45;
163
+ }
164
+
165
+ .gls-app {
166
+ position: relative;
167
+ z-index: 1;
168
+ min-height: 100vh;
169
+ background: transparent;
170
+ color: var(--text);
171
+ }
172
+
173
+ .gls-topbar {
174
+ position: sticky;
175
+ top: 0;
176
+ z-index: 100;
177
+ height: var(--topbar-h);
178
+ display: flex;
179
+ align-items: center;
180
+ background: color-mix(in srgb, var(--surface) 88%, transparent);
181
+ backdrop-filter: blur(10px);
182
+ -webkit-backdrop-filter: blur(10px);
183
+ border-bottom: 1px solid var(--border);
184
+ box-shadow: var(--shadow);
185
+ }
186
+
187
+ .gls-topbar-inner {
188
+ width: 100%;
189
+ margin: 0;
190
+ padding: 0 1.25rem;
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: space-between;
194
+ gap: 0.75rem;
195
+ }
196
+ @media (min-width: 900px) {
197
+ .gls-topbar-inner { padding: 0 1.75rem; }
198
+ }
199
+
200
+ .gls-brand {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 0.65rem;
204
+ min-width: 0;
205
+ }
206
+
207
+ .gls-mark {
208
+ width: 32px;
209
+ height: 32px;
210
+ border-radius: 8px;
211
+ display: grid;
212
+ place-items: center;
213
+ font-size: 0.9rem;
214
+ background: var(--surface);
215
+ color: var(--text);
216
+ border: 1px solid var(--border);
217
+ flex-shrink: 0;
218
+ }
219
+
220
+ .gls-brand h1 {
221
+ margin: 0;
222
+ font-size: 0.92rem;
223
+ font-weight: 600;
224
+ letter-spacing: -0.02em;
225
+ line-height: 1.15;
226
+ white-space: nowrap;
227
+ overflow: hidden;
228
+ text-overflow: ellipsis;
229
+ }
230
+ .gls-brand span {
231
+ display: block;
232
+ font-size: 0.68rem;
233
+ color: var(--muted);
234
+ font-weight: 500;
235
+ letter-spacing: 0.01em;
236
+ }
237
+
238
+ .gls-tools { display: flex; align-items: center; gap: 0.4rem; }
239
+
240
+ .gls-seg {
241
+ display: inline-flex;
242
+ padding: 2px;
243
+ border-radius: 8px;
244
+ background: var(--surface-2);
245
+ border: 1px solid var(--border);
246
+ gap: 2px;
247
+ }
248
+ .gls-seg button {
249
+ border: 0 !important;
250
+ min-height: 28px !important;
251
+ border-radius: 6px !important;
252
+ padding: 0 0.65rem !important;
253
+ font-size: 0.72rem !important;
254
+ font-weight: 600 !important;
255
+ color: var(--muted) !important;
256
+ background: transparent !important;
257
+ }
258
+ .gls-seg button.is-on {
259
+ background: var(--surface) !important;
260
+ color: var(--text) !important;
261
+ box-shadow: var(--shadow);
262
+ }
263
+
264
+ .gls-icon-btn {
265
+ width: 34px !important;
266
+ height: 34px !important;
267
+ border-radius: 8px !important;
268
+ border: 1px solid var(--border) !important;
269
+ background: var(--surface) !important;
270
+ color: var(--text) !important;
271
+ box-shadow: var(--shadow) !important;
272
+ }
273
+
274
+ .gls-wrap {
275
+ width: 100%;
276
+ margin: 0;
277
+ padding: 1rem 1.25rem 2.5rem;
278
+ box-sizing: border-box;
279
+ }
280
+ @media (min-width: 900px) { .gls-wrap { padding: 1.25rem 1.75rem 3rem; } }
281
+ @media (min-width: 1400px) { .gls-wrap { padding: 1.35rem 2.25rem 3rem; } }
282
+
283
+ .gls-grid {
284
+ display: grid;
285
+ grid-template-columns: 1fr;
286
+ gap: 1rem;
287
+ align-items: start;
288
+ width: 100%;
289
+ }
290
+ @media (min-width: 980px) {
291
+ .gls-grid {
292
+ grid-template-columns: minmax(300px, 28vw) minmax(0, 1fr);
293
+ gap: 1.25rem;
294
+ }
295
+ .gls-sidebar {
296
+ position: sticky;
297
+ top: calc(var(--topbar-h) + 1rem);
298
+ }
299
+ .gls-main { min-width: 0; width: 100%; }
300
+ }
301
+ @media (min-width: 1600px) {
302
+ .gls-grid {
303
+ grid-template-columns: minmax(340px, 400px) minmax(0, 1fr);
304
+ }
305
+ }
306
+
307
+ .gls-card {
308
+ background: var(--surface);
309
+ border: 1px solid var(--border);
310
+ border-radius: var(--r);
311
+ box-shadow: var(--shadow-md);
312
+ padding: 1.05rem 1.1rem;
313
+ transition: box-shadow .15s ease;
314
+ }
315
+ .gls-card:hover {
316
+ transform: none;
317
+ box-shadow: var(--shadow-lg);
318
+ }
319
+ .gls-card.soft {
320
+ background: var(--surface-2);
321
+ box-shadow: var(--shadow);
322
+ }
323
+
324
+ .gls-kicker {
325
+ font-size: 0.68rem;
326
+ font-weight: 600;
327
+ letter-spacing: 0.06em;
328
+ text-transform: uppercase;
329
+ color: var(--muted);
330
+ margin: 0 0 0.3rem;
331
+ }
332
+ .gls-title {
333
+ margin: 0;
334
+ font-size: 1.1rem;
335
+ font-weight: 600;
336
+ letter-spacing: -0.025em;
337
+ line-height: 1.25;
338
+ }
339
+ .gls-lead {
340
+ margin: 0.4rem 0 0;
341
+ color: var(--muted);
342
+ font-size: 0.88rem;
343
+ line-height: 1.5;
344
+ }
345
+
346
+ .gls-field .q-field__control {
347
+ border-radius: 8px !important;
348
+ background: var(--surface) !important;
349
+ }
350
+ .gls-field .q-field--focused .q-field__control {
351
+ box-shadow: var(--ring) !important;
352
+ }
353
+
354
+ .gls-examples {
355
+ display: flex;
356
+ flex-wrap: wrap;
357
+ gap: 0.4rem;
358
+ margin-top: 0.6rem;
359
+ }
360
+ .gls-chip {
361
+ border: 1px solid var(--border) !important;
362
+ background: var(--surface) !important;
363
+ color: var(--text) !important;
364
+ border-radius: 999px !important;
365
+ min-height: 30px !important;
366
+ font-size: 0.74rem !important;
367
+ font-weight: 500 !important;
368
+ padding: 0 0.7rem !important;
369
+ box-shadow: var(--shadow) !important;
370
+ }
371
+ .gls-chip:hover {
372
+ background: var(--surface-2) !important;
373
+ color: var(--text) !important;
374
+ transform: none !important;
375
+ box-shadow: var(--shadow-md) !important;
376
+ }
377
+
378
+ .gls-cta {
379
+ width: 100% !important;
380
+ min-height: 42px !important;
381
+ border-radius: 8px !important;
382
+ font-weight: 600 !important;
383
+ font-size: 0.9rem !important;
384
+ letter-spacing: -0.01em;
385
+ background: var(--accent) !important;
386
+ color: var(--surface) !important;
387
+ border: 1px solid var(--accent) !important;
388
+ box-shadow: var(--shadow-md) !important;
389
+ animation: none !important;
390
+ }
391
+ body.body--dark .gls-cta {
392
+ color: var(--bg) !important;
393
+ }
394
+ .gls-cta:hover {
395
+ transform: none !important;
396
+ box-shadow: var(--shadow-lg) !important;
397
+ filter: brightness(1.08);
398
+ }
399
+ .gls-cta:active {
400
+ transform: none !important;
401
+ opacity: 0.9;
402
+ }
403
+
404
+ .gls-steps {
405
+ display: grid;
406
+ grid-template-columns: repeat(4, 1fr);
407
+ gap: 0.35rem;
408
+ margin-top: 0.9rem;
409
+ }
410
+ .gls-step {
411
+ text-align: center;
412
+ padding: 0.45rem 0.2rem;
413
+ border-radius: 8px;
414
+ border: 1px solid var(--border);
415
+ background: var(--surface-2);
416
+ font-size: 0.66rem;
417
+ font-weight: 600;
418
+ color: var(--muted);
419
+ transition: border-color .15s ease, background .15s ease, color .15s ease;
420
+ }
421
+ .gls-step.on {
422
+ color: var(--text);
423
+ border-color: var(--text);
424
+ background: var(--surface);
425
+ transform: none;
426
+ box-shadow: none;
427
+ }
428
+ .gls-step.done {
429
+ color: var(--text);
430
+ border-color: var(--border);
431
+ background: var(--surface);
432
+ }
433
+
434
+ .gls-how { margin-top: 0.85rem; display: grid; gap: 0.4rem; }
435
+ .gls-how-item {
436
+ display: flex; gap: 0.55rem; align-items: flex-start;
437
+ font-size: 0.82rem; color: var(--muted); line-height: 1.4;
438
+ }
439
+ .gls-how-n {
440
+ width: 20px; height: 20px; border-radius: 6px; flex-shrink: 0;
441
+ display: grid; place-items: center; font-size: 0.68rem; font-weight: 600;
442
+ background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
443
+ }
444
+
445
+ .gls-tabs .q-tabs__content { gap: 0.25rem; }
446
+ .gls-tabs .q-tab {
447
+ min-height: 38px; border-radius: 8px; text-transform: none;
448
+ font-weight: 600; font-size: 0.84rem; padding: 0 0.8rem;
449
+ }
450
+ .gls-tabs .q-tab--active {
451
+ background: var(--surface-2);
452
+ border: 1px solid var(--border);
453
+ box-shadow: none;
454
+ }
455
+ .q-primary, .text-primary { color: var(--text) !important; }
456
+ .bg-primary { background: var(--accent) !important; }
457
+
458
+ .gls-results-head {
459
+ display: flex; align-items: center; justify-content: space-between;
460
+ gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap;
461
+ }
462
+
463
+ .gls-empty {
464
+ border: 1px dashed var(--border);
465
+ border-radius: var(--r);
466
+ background: var(--surface);
467
+ padding: 2.2rem 1.4rem;
468
+ text-align: center;
469
+ box-shadow: var(--shadow);
470
+ }
471
+ .gls-empty-icon {
472
+ width: 48px; height: 48px; margin: 0 auto 0.85rem; border-radius: 10px;
473
+ display: grid; place-items: center; font-size: 1.25rem;
474
+ background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
475
+ box-shadow: none;
476
+ animation: none;
477
+ }
478
+ .gls-empty h3 { margin: 0; font-size: 1.05rem; font-weight: 600; letter-spacing: -0.02em; }
479
+ .gls-empty p {
480
+ margin: 0.45rem auto 0; max-width: 42ch; color: var(--muted);
481
+ font-size: 0.88rem; line-height: 1.5;
482
+ }
483
+
484
+ .verdict {
485
+ border-radius: 10px; padding: 0.95rem 1rem;
486
+ border: 1px solid var(--border);
487
+ display: flex; gap: 0.8rem; align-items: flex-start;
488
+ box-shadow: var(--shadow);
489
+ background: var(--surface-2);
490
+ animation: none;
491
+ }
492
+ .verdict.ok, .verdict.warn, .verdict.bad {
493
+ background: var(--surface-2);
494
+ border-color: var(--border);
495
+ }
496
+ .verdict-icon {
497
+ width: 36px; height: 36px; border-radius: 8px;
498
+ display: grid; place-items: center; flex-shrink: 0; font-size: 1.1rem;
499
+ background: var(--surface); border: 1px solid var(--border);
500
+ box-shadow: none;
501
+ }
502
+
503
+ .gls-stats {
504
+ display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem;
505
+ margin-top: 0.85rem; width: 100%;
506
+ }
507
+ @media (min-width: 700px) { .gls-stats { grid-template-columns: repeat(4, 1fr); } }
508
+
509
+ .gls-stat {
510
+ background: var(--surface);
511
+ border: 1px solid var(--border);
512
+ border-radius: 8px;
513
+ padding: 0.7rem 0.75rem;
514
+ box-shadow: var(--shadow);
515
+ transition: box-shadow .15s ease;
516
+ }
517
+ .gls-stat:hover {
518
+ transform: none;
519
+ box-shadow: var(--shadow-md);
520
+ border-color: var(--border);
521
+ }
522
+ .gls-stat .lbl {
523
+ font-size: 0.65rem; font-weight: 600; letter-spacing: 0.04em;
524
+ text-transform: uppercase; color: var(--muted);
525
+ }
526
+ .gls-stat .val {
527
+ margin-top: 0.2rem; font-size: 0.95rem; font-weight: 600;
528
+ letter-spacing: -0.02em; word-break: break-word;
529
+ }
530
+
531
+ .stat-pill {
532
+ display: inline-flex; align-items: center;
533
+ padding: 0.22rem 0.6rem; border-radius: 999px;
534
+ font-size: 0.74rem; font-weight: 600;
535
+ margin: 0 0.25rem 0.25rem 0;
536
+ border: 1px solid var(--border);
537
+ background: var(--surface);
538
+ color: var(--text);
539
+ }
540
+ .stat-pill:hover { transform: none; }
541
+ .pill-green, .pill-red, .pill-orange, .pill-grey {
542
+ background: var(--surface-2);
543
+ color: var(--text);
544
+ border-color: var(--border);
545
+ }
546
+
547
+ .gls-legend { font-size: 0.74rem; color: var(--muted); margin-top: 0.35rem; }
548
+
549
+ .deploy-grid {
550
+ display: grid; grid-template-columns: 1fr; gap: 0.6rem; margin-top: 0.5rem;
551
+ }
552
+ @media (min-width: 640px) { .deploy-grid { grid-template-columns: 1fr 1fr; } }
553
+ @media (min-width: 1100px) { .deploy-grid { grid-template-columns: 1fr 1fr 1fr; } }
554
+ @media (min-width: 1600px) { .deploy-grid { grid-template-columns: repeat(4, 1fr); } }
555
+
556
+ .deploy-card {
557
+ border: 1px solid var(--border);
558
+ border-radius: 10px;
559
+ padding: 0.85rem;
560
+ background: var(--surface);
561
+ transition: border-color .15s ease, box-shadow .15s ease;
562
+ height: 100%;
563
+ box-shadow: var(--shadow);
564
+ }
565
+ .deploy-card:hover {
566
+ transform: none;
567
+ box-shadow: var(--shadow-md);
568
+ border-color: var(--text);
569
+ background: var(--surface);
570
+ }
571
+
572
+ .risk-green, .risk-red, .risk-orange, .risk-grey {
573
+ border-left: 2px solid var(--text);
574
+ background: var(--surface);
575
+ }
576
+
577
+ .pkg-card {
578
+ border-radius: 8px; padding: 0.65rem 0.75rem; margin-bottom: 0.4rem;
579
+ border: 1px solid var(--border);
580
+ transition: none;
581
+ }
582
+ .pkg-card:hover {
583
+ transform: none;
584
+ border-color: var(--border);
585
+ }
586
+
587
+ .mono {
588
+ font-family: var(--mono) !important;
589
+ font-size: 0.8rem !important;
590
+ white-space: pre-wrap;
591
+ }
592
+
593
+ .gls-section { margin-top: 1.2rem; }
594
+ .gls-section h3 {
595
+ margin: 0 0 0.25rem; font-size: 0.98rem; font-weight: 600; letter-spacing: -0.02em;
596
+ }
597
+ .gls-muted { color: var(--muted); font-size: 0.84rem; line-height: 1.45; }
598
+
599
+ .gls-disclaimer {
600
+ display: flex; gap: 0.55rem; align-items: flex-start;
601
+ padding: 0.75rem 0.85rem; border-radius: 8px;
602
+ background: var(--surface-2);
603
+ border: 1px solid var(--border);
604
+ box-shadow: none;
605
+ font-size: 0.8rem; line-height: 1.45; color: var(--muted);
606
+ }
607
+
608
+ .gls-table-wrap {
609
+ width: 100%; overflow-x: auto; border-radius: 8px;
610
+ border: 1px solid var(--border); box-shadow: var(--shadow);
611
+ }
612
+ .gls-table-wrap .q-table { min-width: 560px; }
613
+
614
+ .gls-repo-link { font-family: var(--mono); font-size: 0.76rem; font-weight: 500; }
615
+
616
+ .gls-recent { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; }
617
+ .gls-recent-item {
618
+ display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
619
+ padding: 0.55rem 0.65rem; border-radius: 8px;
620
+ border: 1px solid var(--border); background: var(--surface);
621
+ cursor: pointer; box-shadow: var(--shadow);
622
+ transition: background .12s ease, box-shadow .12s ease;
623
+ }
624
+ .gls-recent-item:hover {
625
+ transform: none;
626
+ box-shadow: var(--shadow-md);
627
+ background: var(--surface-2);
628
+ }
629
+
630
+ .gls-footer {
631
+ margin-top: 1.5rem; text-align: center; color: var(--muted); font-size: 0.72rem;
632
+ font-weight: 500;
633
+ }
634
+
635
+ .gls-scan-bar {
636
+ height: 2px; background: transparent; overflow: hidden; position: relative; z-index: 101;
637
+ }
638
+ .gls-scan-bar .bar {
639
+ height: 100%; width: 28%;
640
+ background: var(--text);
641
+ animation: gls-slide 1s ease-in-out infinite;
642
+ }
643
+ @keyframes gls-slide {
644
+ 0% { transform: translateX(-120%); }
645
+ 100% { transform: translateX(400%); }
646
+ }
647
+
648
+ .fade-in { animation: fadeIn .25s ease both; }
649
+ @keyframes fadeIn {
650
+ from { opacity: 0; }
651
+ to { opacity: 1; }
652
+ }
653
+
654
+ body.gls-scanning .gls-mark {
655
+ animation: none;
656
+ opacity: 0.7;
657
+ }
658
+ body.gls-scanning .gls-card {
659
+ border-color: var(--border);
660
+ }
661
+ """
662
+
663
+ CANVAS_BOOT = r"""
664
+ (() => {
665
+ if (window.__glsCanvasBooted) return;
666
+ window.__glsCanvasBooted = true;
667
+
668
+ const canvas = document.getElementById('gls-bg-canvas');
669
+ if (!canvas) return;
670
+ const ctx = canvas.getContext('2d');
671
+ let w = 0, h = 0, dpr = 1;
672
+
673
+ function isDark() {
674
+ return document.body.classList.contains('body--dark');
675
+ }
676
+ function bg() { return isDark() ? '#0a0a0a' : '#fafafa'; }
677
+ function line() { return isDark() ? 'rgba(255,255,255,0.035)' : 'rgba(0,0,0,0.035)'; }
678
+ function dot() { return isDark() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'; }
679
+
680
+ function resize() {
681
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
682
+ w = window.innerWidth;
683
+ h = window.innerHeight;
684
+ canvas.width = Math.floor(w * dpr);
685
+ canvas.height = Math.floor(h * dpr);
686
+ canvas.style.width = w + 'px';
687
+ canvas.style.height = h + 'px';
688
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
689
+ draw();
690
+ }
691
+
692
+ function draw() {
693
+ ctx.clearRect(0, 0, w, h);
694
+ ctx.fillStyle = bg();
695
+ ctx.fillRect(0, 0, w, h);
696
+
697
+ // Quiet grid
698
+ const gap = 56;
699
+ ctx.strokeStyle = line();
700
+ ctx.lineWidth = 1;
701
+ for (let x = 0; x <= w; x += gap) {
702
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
703
+ }
704
+ for (let y = 0; y <= h; y += gap) {
705
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
706
+ }
707
+
708
+ // Sparse dots at intersections
709
+ ctx.fillStyle = dot();
710
+ for (let x = 0; x <= w; x += gap) {
711
+ for (let y = 0; y <= h; y += gap) {
712
+ if ((x + y) % (gap * 2) === 0) {
713
+ ctx.beginPath();
714
+ ctx.arc(x, y, 1.2, 0, Math.PI * 2);
715
+ ctx.fill();
716
+ }
717
+ }
718
+ }
719
+ }
720
+
721
+ window.addEventListener('resize', resize, { passive: true });
722
+ const mo = new MutationObserver(draw);
723
+ mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
724
+ resize();
725
+ })();
726
+ """
727
+
728
+
729
+
730
+
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # Storage helpers
734
+ # ---------------------------------------------------------------------------
735
+
736
+ def get_lang() -> str:
737
+ try:
738
+ return normalize_lang(app.storage.user.get("lang", DEFAULT_LANG))
739
+ except Exception: # noqa: BLE001
740
+ return DEFAULT_LANG
741
+
742
+
743
+ def set_lang(lang: str) -> None:
744
+ app.storage.user["lang"] = normalize_lang(lang)
745
+ ui.navigate.reload()
746
+
747
+
748
+ def get_dark() -> bool:
749
+ try:
750
+ return bool(app.storage.user.get("dark", True))
751
+ except Exception: # noqa: BLE001
752
+ return True
753
+
754
+
755
+ def set_dark(enabled: bool) -> None:
756
+ app.storage.user["dark"] = bool(enabled)
757
+
758
+
759
+ def get_username() -> str | None:
760
+ """Authenticated username, or None when auth is off / logged out."""
761
+ if not auth_enabled():
762
+ return None
763
+ try:
764
+ user = app.storage.user.get("username")
765
+ return str(user) if user else None
766
+ except Exception: # noqa: BLE001
767
+ return None
768
+
769
+
770
+ def history_user() -> str | None:
771
+ """Scope key for per-user history (None = shared local file)."""
772
+ return history_user_key(get_username()) if auth_enabled() else None
773
+
774
+
775
+ def require_login() -> bool:
776
+ """Return True if the current session may use the app."""
777
+ if not auth_enabled():
778
+ return True
779
+ return get_username() is not None
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Small helpers
784
+ # ---------------------------------------------------------------------------
785
+
786
+ def _risk_pill(risk: str, count: int, lang: str) -> str:
787
+ color = {
788
+ "permissive": "pill-green",
789
+ "strong_copyleft": "pill-red",
790
+ "weak_copyleft": "pill-orange",
791
+ "unknown": "pill-orange",
792
+ }.get(risk, "pill-grey")
793
+ labels = {
794
+ "permissive": t("risk_permissive", lang),
795
+ "strong_copyleft": t("risk_strong", lang),
796
+ "weak_copyleft": t("risk_weak", lang),
797
+ "unknown": t("risk_unknown", lang),
798
+ }
799
+ return f'<span class="stat-pill {color}">{labels.get(risk, risk)}: {count}</span>'
800
+
801
+
802
+ def _worst_risk(pkgs: list[PackageLicense]) -> str:
803
+ order = ["strong_copyleft", "weak_copyleft", "unknown", "permissive"]
804
+ risks = {p.risk for p in pkgs}
805
+ for r in order:
806
+ if r in risks:
807
+ return r
808
+ return "unknown"
809
+
810
+
811
+ def _yn(value: bool, lang: str) -> str:
812
+ return t("yes", lang) if value else t("no", lang)
813
+
814
+
815
+ def _badge_color(risk: str) -> str:
816
+ if risk == "permissive":
817
+ return "positive"
818
+ if risk == "strong_copyleft":
819
+ return "negative"
820
+ return "warning"
821
+
822
+
823
+ # ---------------------------------------------------------------------------
824
+ # Result rendering
825
+ # ---------------------------------------------------------------------------
826
+
827
+ def render_empty(container: ui.element, lang: str) -> None:
828
+ """Friendly empty state for the results column."""
829
+ container.clear()
830
+ with container:
831
+ with ui.element("div").classes("gls-empty fade-in"):
832
+ ui.label("⚖️").classes("gls-empty-icon")
833
+ ui.html(
834
+ f"<h3>{t('empty_title', lang)}</h3><p>{t('empty_body', lang)}</p>",
835
+ sanitize=False,
836
+ )
837
+
838
+
839
+ def render_result(container: ui.element, result: ScanResult, lang: str | None = None) -> None:
840
+ """Draw a full scan result with polished hierarchy."""
841
+ lang = normalize_lang(lang or get_lang())
842
+ container.clear()
843
+ with container:
844
+ if result.errors and not result.owner:
845
+ with ui.element("div").classes("gls-empty fade-in"):
846
+ ui.icon("error_outline", size="lg").classes("text-negative")
847
+ ui.label(t("error_prefix", lang, error=result.errors[0])).classes(
848
+ "text-negative q-mt-sm"
849
+ )
850
+ return
851
+
852
+ with ui.element("div").classes("gls-card fade-in"):
853
+ # Header
854
+ with ui.row().classes("w-full items-start justify-between flex-wrap gap-2"):
855
+ with ui.column().classes("gap-0"):
856
+ ui.label(f"{result.owner}/{result.repo}").classes(
857
+ "text-h5 text-weight-bolder"
858
+ ).style("letter-spacing:-0.03em;margin:0")
859
+ ui.link(
860
+ t("open_repo", lang),
861
+ result.url,
862
+ new_tab=True,
863
+ ).classes("gls-repo-link")
864
+ ui.badge(result.scanned_at).props("outline color=grey")
865
+
866
+ # Verdict — incomplete scan (API/rate-limit) is not a legal "no"
867
+ incomplete = not result.scan_complete or (
868
+ bool(result.errors) and not result.repo_license and not result.packages
869
+ )
870
+ if incomplete:
871
+ v_cls, title, sub, emoji = (
872
+ "warn",
873
+ t("verdict_incomplete_title", lang),
874
+ t("verdict_incomplete_sub", lang),
875
+ "⏳",
876
+ )
877
+ elif result.forces_open_source:
878
+ v_cls, title, sub, emoji = (
879
+ "bad",
880
+ t("verdict_bad_title", lang),
881
+ t("verdict_bad_sub", lang),
882
+ "🚫",
883
+ )
884
+ elif (
885
+ result.has_weak_copyleft
886
+ or result.has_unknown_licenses
887
+ or result.has_network_copyleft
888
+ ):
889
+ v_cls, title, sub, emoji = (
890
+ "warn",
891
+ t("verdict_warn_title", lang),
892
+ t("verdict_warn_sub", lang),
893
+ "⚠️",
894
+ )
895
+ else:
896
+ v_cls, title, sub, emoji = (
897
+ "ok",
898
+ t("verdict_ok_title", lang),
899
+ t("verdict_ok_sub", lang),
900
+ "✅",
901
+ )
902
+
903
+ with ui.element("div").classes(f"verdict {v_cls} q-mt-md"):
904
+ ui.label(emoji).classes("verdict-icon")
905
+ with ui.column().classes("gap-1"):
906
+ ui.label(title).classes("text-weight-bold").style("font-size:1.05rem")
907
+ ui.label(sub).classes("text-body2")
908
+ ui.label(result.verdict_summary).classes("text-caption opacity-90 q-mt-xs")
909
+
910
+ # Stats
911
+ counts = result.risk_counts()
912
+ score_label_key = f"risk_label_{result.risk_score_label}"
913
+ score_label = t(score_label_key, lang)
914
+ if score_label == score_label_key:
915
+ score_label = result.risk_score_label
916
+ with ui.element("div").classes("gls-stats"):
917
+ for label, value in (
918
+ (t("meta_repo_license", lang), result.repo_license or t("unknown", lang)),
919
+ (
920
+ t("meta_risk_score", lang),
921
+ f"{result.risk_score}/100 · {score_label}",
922
+ ),
923
+ (
924
+ t("meta_prod_dev", lang),
925
+ f"{result.prod_package_count} / {result.dev_package_count}",
926
+ ),
927
+ (
928
+ t("meta_forces_open", lang),
929
+ t("yes", lang).upper() if result.forces_open_source else t("no", lang).upper(),
930
+ ),
931
+ ):
932
+ with ui.element("div").classes("gls-stat"):
933
+ ui.label(label).classes("lbl")
934
+ ui.label(value).classes("val")
935
+
936
+ with ui.element("div").classes("q-mt-md"):
937
+ ui.html(
938
+ "".join(
939
+ _risk_pill(k, counts[k], lang)
940
+ for k in (
941
+ "permissive",
942
+ "weak_copyleft",
943
+ "strong_copyleft",
944
+ "unknown",
945
+ )
946
+ ),
947
+ sanitize=False,
948
+ )
949
+ ui.label(t("risk_legend", lang)).classes("gls-legend")
950
+
951
+ if result.errors:
952
+ with ui.expansion(t("notes_warnings", lang), icon="info").classes(
953
+ "w-full q-mt-sm"
954
+ ):
955
+ for err in result.errors:
956
+ ui.label(f"• {err}").classes("text-caption")
957
+
958
+ # Deploy
959
+ with ui.element("div").classes("gls-section"):
960
+ ui.html(f"<h3>{t('deploy_title', lang)}</h3>", sanitize=False)
961
+ ui.label(t("deploy_help", lang)).classes("gls-muted")
962
+ if result.deploy_advice:
963
+ with ui.element("div").classes("deploy-grid"):
964
+ for adv in result.deploy_advice:
965
+ with ui.element("div").classes("deploy-card"):
966
+ with ui.row().classes(
967
+ "items-center justify-between w-full no-wrap"
968
+ ):
969
+ ui.label(adv.platform).classes("text-weight-bold")
970
+ ui.badge(str(adv.score)).props("color=grey")
971
+ for reason in adv.reasons[:3]:
972
+ ui.label(f"• {reason}").classes("text-caption q-mt-xs")
973
+ ui.link(
974
+ t("deploy_docs", lang), adv.docs_url, new_tab=True
975
+ ).classes("text-caption q-mt-sm")
976
+ else:
977
+ ui.label(t("deploy_none", lang)).classes("gls-muted q-mt-sm")
978
+
979
+ # Export Markdown
980
+ md_report = render_markdown_report(result)
981
+ with ui.element("div").classes("gls-section"):
982
+ ui.html(f"<h3>{t('export_markdown', lang)}</h3>", sanitize=False)
983
+ with ui.row().classes("gap-2 flex-wrap q-mt-sm"):
984
+ async def copy_markdown() -> None:
985
+ payload = json.dumps(md_report)
986
+ try:
987
+ await ui.run_javascript(
988
+ f"navigator.clipboard.writeText({payload})",
989
+ timeout=3.0,
990
+ )
991
+ ui.notify(t("export_copied", lang), type="positive")
992
+ except Exception: # noqa: BLE001
993
+ ui.notify(t("copyright_copy_fail", lang), type="warning")
994
+
995
+ ui.button(
996
+ t("export_markdown", lang),
997
+ on_click=copy_markdown,
998
+ icon="content_copy",
999
+ ).props("outline rounded no-caps")
1000
+
1001
+ fname = (
1002
+ f"{result.owner or 'repo'}-{result.repo or 'scan'}-license-report.md"
1003
+ )
1004
+ ui.button(
1005
+ t("export_download", lang),
1006
+ icon="download",
1007
+ on_click=lambda: ui.download(
1008
+ md_report.encode("utf-8"),
1009
+ filename=fname,
1010
+ ),
1011
+ ).props("outline rounded no-caps")
1012
+
1013
+ # Export SBOM
1014
+ base = f"{result.owner or 'repo'}-{result.repo or 'scan'}"
1015
+ cdx = render_sbom(result, "cyclonedx")
1016
+ spdx_doc = render_sbom(result, "spdx")
1017
+ with ui.element("div").classes("gls-section"):
1018
+ ui.html(f"<h3>{t('export_sbom', lang)}</h3>", sanitize=False)
1019
+ ui.label(t("export_sbom_help", lang)).classes("gls-muted")
1020
+ with ui.row().classes("gap-2 flex-wrap q-mt-sm"):
1021
+ ui.button(
1022
+ t("export_cyclonedx", lang),
1023
+ icon="download",
1024
+ on_click=lambda: ui.download(
1025
+ cdx.encode("utf-8"),
1026
+ filename=f"{base}-bom.cdx.json",
1027
+ ),
1028
+ ).props("outline rounded no-caps")
1029
+ ui.button(
1030
+ t("export_spdx", lang),
1031
+ icon="download",
1032
+ on_click=lambda: ui.download(
1033
+ spdx_doc.encode("utf-8"),
1034
+ filename=f"{base}.spdx.json",
1035
+ ),
1036
+ ).props("outline rounded no-caps")
1037
+
1038
+ # Copyright
1039
+ with ui.element("div").classes("gls-section"):
1040
+ ui.html(f"<h3>{t('copyright_title', lang)}</h3>", sanitize=False)
1041
+ ui.textarea(value=result.copyright_notice).classes("w-full mono").props(
1042
+ "outlined readonly rows=5 input-class=mono"
1043
+ )
1044
+
1045
+ async def copy_notice() -> None:
1046
+ payload = json.dumps(result.copyright_notice)
1047
+ try:
1048
+ await ui.run_javascript(
1049
+ f"navigator.clipboard.writeText({payload})",
1050
+ timeout=3.0,
1051
+ )
1052
+ ui.notify(t("copyright_copied", lang), type="positive")
1053
+ except Exception: # noqa: BLE001
1054
+ ui.notify(t("copyright_copy_fail", lang), type="warning")
1055
+
1056
+ ui.button(
1057
+ t("copyright_copy", lang),
1058
+ on_click=copy_notice,
1059
+ icon="content_copy",
1060
+ ).props("color=primary unelevated rounded").classes("q-mt-sm")
1061
+
1062
+ # Replacements
1063
+ if result.replacements:
1064
+ with ui.element("div").classes("gls-section"):
1065
+ ui.html(f"<h3>{t('replacements_title', lang)}</h3>", sanitize=False)
1066
+ ui.label(t("replacements_help", lang)).classes("gls-muted")
1067
+ for rep in result.replacements:
1068
+ risk_badge = _badge_color(
1069
+ next(
1070
+ (
1071
+ p.risk
1072
+ for p in result.packages
1073
+ if p.name == rep.package
1074
+ ),
1075
+ "weak_copyleft",
1076
+ )
1077
+ )
1078
+ with ui.element("div").classes(
1079
+ "pkg-card risk-orange q-mt-sm"
1080
+ ):
1081
+ with ui.row().classes("items-center gap-2 flex-wrap"):
1082
+ ui.badge(rep.ecosystem).props("outline")
1083
+ ui.label(rep.package).classes("text-weight-bold")
1084
+ ui.badge(rep.license_id or "unknown").props(
1085
+ f"color={risk_badge}"
1086
+ )
1087
+ ui.label(rep.note).classes("text-caption text-grey q-mt-xs")
1088
+ for alt in rep.alternatives:
1089
+ ui.label(f"→ {alt}").classes("text-body2")
1090
+
1091
+ # Packages
1092
+ with ui.element("div").classes("gls-section"):
1093
+ ui.html(f"<h3>{t('packages_by_license', lang)}</h3>", sanitize=False)
1094
+ if not result.packages:
1095
+ ui.label(t("packages_none", lang)).classes("gls-muted")
1096
+ else:
1097
+ for lic, pkgs in result.grouped.items():
1098
+ worst = _worst_risk(pkgs)
1099
+ color = risk_color(worst)
1100
+ icon = {
1101
+ "green": "check_circle",
1102
+ "red": "dangerous",
1103
+ "orange": "warning",
1104
+ }.get(color, "help")
1105
+ with ui.expansion(
1106
+ t("packages_count", lang, license=lic, count=len(pkgs)),
1107
+ icon=icon,
1108
+ ).classes(f"w-full q-mt-xs risk-{color}"):
1109
+ for pkg in sorted(pkgs, key=lambda p: p.name.lower()):
1110
+ with ui.element("div").classes(
1111
+ f"pkg-card risk-{risk_color(pkg.risk)}"
1112
+ ):
1113
+ with ui.row().classes(
1114
+ "items-center justify-between w-full flex-wrap gap-2"
1115
+ ):
1116
+ with ui.column().classes("gap-0"):
1117
+ ui.label(pkg.name).classes(
1118
+ "text-weight-medium"
1119
+ )
1120
+ scope = (
1121
+ t("scope_dev", lang)
1122
+ if pkg.is_dev
1123
+ else t("scope_prod", lang)
1124
+ )
1125
+ ui.label(
1126
+ f"{pkg.ecosystem} · {scope} · {pkg.source_file}"
1127
+ + (
1128
+ f" · {pkg.version_spec}"
1129
+ if pkg.version_spec
1130
+ else ""
1131
+ )
1132
+ ).classes("text-caption text-grey")
1133
+ with ui.row().classes("items-center gap-2"):
1134
+ ui.badge(scope).props("outline")
1135
+ ui.badge(pkg.risk).props(
1136
+ f"color={_badge_color(pkg.risk)}"
1137
+ )
1138
+ if pkg.license_url:
1139
+ ui.link(
1140
+ t("registry_link", lang),
1141
+ pkg.license_url,
1142
+ new_tab=True,
1143
+ ).classes("text-caption")
1144
+
1145
+ if result.dependency_files:
1146
+ with ui.expansion(t("dep_files", lang), icon="folder").classes(
1147
+ "w-full q-mt-md"
1148
+ ):
1149
+ for path in result.dependency_files:
1150
+ ui.label(f"• {path}").classes("text-caption mono")
1151
+
1152
+
1153
+ def render_batch_summary(
1154
+ container: ui.element,
1155
+ results: list[ScanResult],
1156
+ lang: str | None = None,
1157
+ ) -> None:
1158
+ lang = normalize_lang(lang or get_lang())
1159
+ container.clear()
1160
+ with container:
1161
+ with ui.element("div").classes("gls-card fade-in"):
1162
+ ui.label(t("batch_results", lang)).classes("gls-title")
1163
+ columns = [
1164
+ {"name": "repo", "label": t("col_repo", lang), "field": "repo", "align": "left"},
1165
+ {
1166
+ "name": "license",
1167
+ "label": t("col_license", lang),
1168
+ "field": "license",
1169
+ "align": "left",
1170
+ },
1171
+ {"name": "pkgs", "label": t("col_pkgs", lang), "field": "pkgs", "align": "right"},
1172
+ {
1173
+ "name": "closed",
1174
+ "label": t("col_closed", lang),
1175
+ "field": "closed",
1176
+ "align": "center",
1177
+ },
1178
+ {
1179
+ "name": "force",
1180
+ "label": t("col_force", lang),
1181
+ "field": "force",
1182
+ "align": "center",
1183
+ },
1184
+ ]
1185
+ rows = [
1186
+ {
1187
+ "repo": f"{r.owner}/{r.repo}" if r.owner else r.url,
1188
+ "license": r.repo_license or "?",
1189
+ "pkgs": len(r.packages),
1190
+ "closed": _yn(r.can_sell_closed, lang),
1191
+ "force": _yn(r.forces_open_source, lang),
1192
+ }
1193
+ for r in results
1194
+ ]
1195
+ with ui.element("div").classes("gls-table-wrap q-mt-sm"):
1196
+ ui.table(columns=columns, rows=rows, row_key="repo").classes("w-full")
1197
+
1198
+ if results:
1199
+ ui.label(t("batch_last_detail", lang)).classes(
1200
+ "gls-kicker q-mt-md"
1201
+ ).style("display:block")
1202
+ detail = ui.column().classes("w-full")
1203
+ render_result(detail, results[-1], lang=lang)
1204
+
1205
+
1206
+ def render_history(container: ui.element, lang: str | None = None) -> None:
1207
+ lang = normalize_lang(lang or get_lang())
1208
+ container.clear()
1209
+ entries = load_history(user=history_user())
1210
+ with container:
1211
+ if not entries:
1212
+ with ui.element("div").classes("gls-empty"):
1213
+ ui.icon("history", size="md")
1214
+ ui.label(t("history_empty", lang)).classes("q-mt-sm")
1215
+ return
1216
+ columns = [
1217
+ {"name": "when", "label": t("col_when", lang), "field": "when", "align": "left"},
1218
+ {"name": "repo", "label": t("col_repo", lang), "field": "repo", "align": "left"},
1219
+ {
1220
+ "name": "license",
1221
+ "label": t("col_license", lang),
1222
+ "field": "license",
1223
+ "align": "left",
1224
+ },
1225
+ {"name": "pkgs", "label": t("col_pkgs", lang), "field": "pkgs", "align": "right"},
1226
+ {
1227
+ "name": "closed",
1228
+ "label": t("col_closed", lang),
1229
+ "field": "closed",
1230
+ "align": "center",
1231
+ },
1232
+ {
1233
+ "name": "force",
1234
+ "label": t("col_force", lang),
1235
+ "field": "force",
1236
+ "align": "center",
1237
+ },
1238
+ ]
1239
+ rows = [
1240
+ {
1241
+ "when": e.get("scanned_at", ""),
1242
+ "repo": f"{e.get('owner', '')}/{e.get('repo', '')}",
1243
+ "license": e.get("repo_license") or "?",
1244
+ "pkgs": e.get("package_count", 0),
1245
+ "closed": _yn(bool(e.get("can_sell_closed")), lang),
1246
+ "force": _yn(bool(e.get("forces_open_source")), lang),
1247
+ }
1248
+ for e in entries
1249
+ ]
1250
+ with ui.element("div").classes("gls-table-wrap"):
1251
+ ui.table(columns=columns, rows=rows, row_key="repo").classes("w-full")
1252
+ ui.label(t("history_count", lang, count=len(entries))).classes("gls-muted q-mt-sm")
1253
+
1254
+
1255
+ def _set_steps(step_els: list[ui.element], active: int) -> None:
1256
+ """Update visual state of progress steps (0..3 active, -1 = idle)."""
1257
+ for i, el in enumerate(step_els):
1258
+ el.classes(remove="on done")
1259
+ if active < 0:
1260
+ continue
1261
+ if i < active:
1262
+ el.classes(add="done")
1263
+ elif i == active:
1264
+ el.classes(add="on")
1265
+
1266
+
1267
+ # ---------------------------------------------------------------------------
1268
+ # Page
1269
+ # ---------------------------------------------------------------------------
1270
+
1271
+ @ui.page("/login")
1272
+ def login_page() -> None:
1273
+ """Login form when GLS_AUTH_ENABLED is set."""
1274
+ ui.add_css(CUSTOM_CSS)
1275
+ lang = get_lang()
1276
+ if not auth_enabled():
1277
+ ui.navigate.to("/")
1278
+ return
1279
+ if get_username():
1280
+ ui.navigate.to("/")
1281
+ return
1282
+
1283
+ with ui.element("div").classes("gls-app"):
1284
+ with ui.element("div").classes("gls-wrap").style("max-width:420px;margin:4rem auto"):
1285
+ with ui.element("div").classes("gls-card"):
1286
+ ui.label(t("login_title", lang)).classes("gls-title")
1287
+ ui.label(t("login_help", lang)).classes("gls-lead")
1288
+ user_in = ui.input(label=t("login_user", lang)).classes(
1289
+ "w-full gls-field q-mt-md"
1290
+ ).props("outlined dense")
1291
+ pass_in = ui.input(
1292
+ label=t("login_pass", lang), password=True, password_toggle_button=True
1293
+ ).classes("w-full gls-field").props("outlined dense")
1294
+
1295
+ def do_login() -> None:
1296
+ u = (user_in.value or "").strip()
1297
+ p = pass_in.value or ""
1298
+ if authenticate(u, p):
1299
+ app.storage.user["username"] = u
1300
+ ui.notify(t("login_ok", lang), type="positive")
1301
+ ui.navigate.to("/")
1302
+ else:
1303
+ ui.notify(t("login_fail", lang), type="negative")
1304
+
1305
+ ui.button(
1306
+ t("login_button", lang),
1307
+ on_click=do_login,
1308
+ icon="login",
1309
+ ).props("color=primary unelevated no-caps").classes("gls-cta q-mt-md")
1310
+ pass_in.on("keydown.enter", do_login)
1311
+
1312
+
1313
+ @ui.page("/")
1314
+ def index_page() -> None:
1315
+ """Main app page with split workspace UX."""
1316
+ ui.add_head_html(
1317
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
1318
+ '<meta name="theme-color" content="#111111">'
1319
+ )
1320
+ ui.add_css(CUSTOM_CSS)
1321
+
1322
+ if auth_enabled() and not require_login():
1323
+ ui.navigate.to("/login")
1324
+ return
1325
+
1326
+ lang = get_lang()
1327
+ dark_pref = get_dark()
1328
+ dark = ui.dark_mode(dark_pref)
1329
+ # Cross-tab UI refresh hooks (history clear → recent list, etc.)
1330
+ ui_refreshers: list[Callable[[], None]] = []
1331
+ current_user = get_username()
1332
+
1333
+ with ui.element("div").classes("gls-app"):
1334
+ # Top bar
1335
+ with ui.element("div").classes("gls-topbar"):
1336
+ with ui.element("div").classes("gls-topbar-inner"):
1337
+ with ui.element("div").classes("gls-brand"):
1338
+ ui.label("⚖️").classes("gls-mark")
1339
+ with ui.column().classes("gap-0"):
1340
+ ui.html(
1341
+ f"<h1>{t('app_title', lang)}</h1>"
1342
+ f"<span>{t('hero_badge', lang)}</span>",
1343
+ sanitize=False,
1344
+ )
1345
+ with ui.element("div").classes("gls-tools"):
1346
+ if current_user:
1347
+ ui.label(
1348
+ t("logged_in_as", lang, user=current_user)
1349
+ ).classes("text-caption text-grey q-mr-sm")
1350
+
1351
+ def do_logout() -> None:
1352
+ app.storage.user.pop("username", None)
1353
+ ui.navigate.to("/login")
1354
+
1355
+ ui.button(
1356
+ t("logout", lang),
1357
+ on_click=do_logout,
1358
+ icon="logout",
1359
+ ).props("flat dense no-caps").classes("gls-icon-btn")
1360
+
1361
+ with ui.element("div").classes("gls-seg"):
1362
+ ui.button("ES", on_click=lambda: set_lang("es")).props(
1363
+ "flat dense no-caps"
1364
+ ).classes("is-on" if lang == "es" else "")
1365
+ ui.button("EN", on_click=lambda: set_lang("en")).props(
1366
+ "flat dense no-caps"
1367
+ ).classes("is-on" if lang == "en" else "")
1368
+
1369
+ def toggle_theme() -> None:
1370
+ new_value = not bool(dark.value)
1371
+ dark.set_value(new_value)
1372
+ set_dark(new_value)
1373
+ theme_btn.props(
1374
+ f'icon={"light_mode" if new_value else "dark_mode"}'
1375
+ )
1376
+
1377
+ theme_btn = (
1378
+ ui.button(
1379
+ on_click=toggle_theme,
1380
+ icon="light_mode" if dark_pref else "dark_mode",
1381
+ )
1382
+ .props("flat dense")
1383
+ .classes("gls-icon-btn")
1384
+ .tooltip(
1385
+ t(
1386
+ "theme_toggle_to_light"
1387
+ if dark_pref
1388
+ else "theme_toggle_to_dark",
1389
+ lang,
1390
+ )
1391
+ )
1392
+ )
1393
+
1394
+ scan_bar = ui.element("div").classes("gls-scan-bar")
1395
+ with scan_bar:
1396
+ bar_inner = ui.element("div").classes("bar")
1397
+ scan_bar.set_visibility(False)
1398
+
1399
+ with ui.element("div").classes("gls-wrap"):
1400
+ with ui.element("div").classes("gls-grid"):
1401
+ # ========== LEFT: workspace ==========
1402
+ with ui.element("div").classes("gls-sidebar"):
1403
+ with ui.element("div").classes("gls-card"):
1404
+ ui.label(t("workspace_title", lang)).classes("gls-kicker")
1405
+ ui.label(t("scan_card_title", lang)).classes("gls-title")
1406
+ ui.label(t("scan_card_help", lang)).classes("gls-lead")
1407
+
1408
+ with ui.tabs().classes("w-full gls-tabs q-mt-md") as tabs:
1409
+ tab_scan = ui.tab(t("tab_scan", lang), icon="search")
1410
+ tab_batch = ui.tab(t("tab_batch", lang), icon="playlist_add")
1411
+ tab_history = ui.tab(t("tab_history", lang), icon="history")
1412
+
1413
+ with ui.tab_panels(tabs, value=tab_scan).classes("w-full").props(
1414
+ "animated"
1415
+ ):
1416
+ # --- Scan ---
1417
+ with ui.tab_panel(tab_scan):
1418
+ url_input = (
1419
+ ui.input(
1420
+ label=t("url_label", lang),
1421
+ placeholder=t("url_placeholder", lang),
1422
+ )
1423
+ .classes("w-full gls-field")
1424
+ .props(
1425
+ 'outlined clearable dense=false '
1426
+ 'prepend-inner-icon=link'
1427
+ )
1428
+ )
1429
+
1430
+ ui.label(t("try_example", lang)).classes(
1431
+ "gls-muted q-mt-sm"
1432
+ ).style("font-size:0.75rem;font-weight:700")
1433
+
1434
+ def fill_example(url: str) -> Callable[[], None]:
1435
+ def _inner() -> None:
1436
+ url_input.value = url
1437
+
1438
+ return _inner
1439
+
1440
+ with ui.element("div").classes("gls-examples"):
1441
+ for label, url in EXAMPLE_REPOS:
1442
+ ui.button(
1443
+ label,
1444
+ on_click=fill_example(url),
1445
+ ).props("flat dense no-caps").classes("gls-chip")
1446
+
1447
+ # Steps
1448
+ step_keys = (
1449
+ "step_fetch",
1450
+ "step_deps",
1451
+ "step_licenses",
1452
+ "step_verdict",
1453
+ )
1454
+ step_els: list[ui.element] = []
1455
+ with ui.element("div").classes("gls-steps"):
1456
+ for key in step_keys:
1457
+ el = ui.label(t(key, lang)).classes("gls-step")
1458
+ step_els.append(el)
1459
+
1460
+ status_label = ui.label("").classes(
1461
+ "gls-muted q-mt-sm text-center w-full"
1462
+ )
1463
+
1464
+ async def run_scan(url_override: str | None = None) -> None:
1465
+ url = (url_override or url_input.value or "").strip()
1466
+ if not url:
1467
+ ui.notify(
1468
+ t("notify_paste_url", lang), type="warning"
1469
+ )
1470
+ return
1471
+ limited = _check_scan_budget(1)
1472
+ if limited:
1473
+ ui.notify(limited, type="warning")
1474
+ return
1475
+ url_input.value = url
1476
+ scan_btn.disable()
1477
+ scan_bar.set_visibility(True)
1478
+ status_label.set_text(t("scanning_status", lang))
1479
+ await ui.run_javascript(
1480
+ "document.body.classList.add('gls-scanning')",
1481
+ timeout=2.0,
1482
+ )
1483
+ _set_steps(step_els, 0)
1484
+ try:
1485
+ # Animate steps while awaiting the full pipeline
1486
+ _set_steps(step_els, 1)
1487
+ result = await analyze_repository(url)
1488
+ _set_steps(step_els, 2)
1489
+ if result.owner and result.scan_complete:
1490
+ append_scan(result, user=history_user())
1491
+ _set_steps(step_els, 3)
1492
+ render_result(results_box, result, lang=lang)
1493
+ _set_steps(step_els, 4) # all done look
1494
+ for el in step_els:
1495
+ el.classes(remove="on")
1496
+ el.classes(add="done")
1497
+ if result.forces_open_source:
1498
+ ui.notify(
1499
+ t("notify_strong_copyleft", lang),
1500
+ type="negative",
1501
+ )
1502
+ elif result.errors and not result.packages:
1503
+ ui.notify(result.errors[0], type="warning")
1504
+ else:
1505
+ ui.notify(
1506
+ t("notify_done", lang), type="positive"
1507
+ )
1508
+ # Refresh recent list
1509
+ render_recent()
1510
+ except Exception as exc: # noqa: BLE001
1511
+ ui.notify(
1512
+ t("notify_unexpected", lang, error=exc),
1513
+ type="negative",
1514
+ )
1515
+ results_box.clear()
1516
+ with results_box:
1517
+ with ui.element("div").classes("gls-empty"):
1518
+ ui.label(str(exc)).classes(
1519
+ "text-negative"
1520
+ )
1521
+ _set_steps(step_els, -1)
1522
+ finally:
1523
+ scan_btn.enable()
1524
+ scan_bar.set_visibility(False)
1525
+ status_label.set_text("")
1526
+ try:
1527
+ await ui.run_javascript(
1528
+ "document.body.classList.remove('gls-scanning')",
1529
+ timeout=2.0,
1530
+ )
1531
+ except Exception:
1532
+ pass
1533
+
1534
+ # Enter key submits
1535
+ url_input.on(
1536
+ "keydown.enter",
1537
+ lambda: run_scan(),
1538
+ )
1539
+
1540
+ scan_btn = (
1541
+ ui.button(
1542
+ t("scan_button", lang),
1543
+ on_click=lambda: run_scan(),
1544
+ icon="policy",
1545
+ )
1546
+ .props("color=primary unelevated no-caps")
1547
+ .classes("gls-cta q-mt-md")
1548
+ )
1549
+
1550
+ # How it works
1551
+ with ui.expansion(
1552
+ t("how_it_works", lang), icon="auto_awesome"
1553
+ ).classes("w-full q-mt-md"):
1554
+ with ui.element("div").classes("gls-how"):
1555
+ for i, key in enumerate(
1556
+ ("how_1", "how_2", "how_3", "how_4"), 1
1557
+ ):
1558
+ with ui.element("div").classes(
1559
+ "gls-how-item"
1560
+ ):
1561
+ ui.label(str(i)).classes("gls-how-n")
1562
+ ui.label(t(key, lang))
1563
+
1564
+ # Recent scans quick rescan
1565
+ recent_box = ui.column().classes("w-full q-mt-md")
1566
+
1567
+ def render_recent() -> None:
1568
+ recent_box.clear()
1569
+ entries = load_history(user=history_user())[:5]
1570
+ with recent_box:
1571
+ if not entries:
1572
+ return
1573
+ ui.label(t("recent_scans", lang)).classes(
1574
+ "gls-kicker"
1575
+ )
1576
+ with ui.element("div").classes("gls-recent"):
1577
+ for e in entries:
1578
+ repo = f"{e.get('owner', '')}/{e.get('repo', '')}"
1579
+ url = e.get("url") or (
1580
+ f"https://github.com/{repo}"
1581
+ )
1582
+
1583
+ def make_handler(u: str) -> Callable[[], Any]:
1584
+ async def _h() -> None:
1585
+ await run_scan(u)
1586
+
1587
+ return _h
1588
+
1589
+ with ui.element("div").classes(
1590
+ "gls-recent-item"
1591
+ ).on("click", make_handler(url)):
1592
+ with ui.column().classes("gap-0"):
1593
+ ui.label(repo).classes(
1594
+ "text-weight-bold text-caption"
1595
+ )
1596
+ ui.label(
1597
+ e.get("repo_license") or "?"
1598
+ ).classes("text-caption text-grey")
1599
+ ui.icon("replay", size="xs").classes(
1600
+ "text-grey"
1601
+ )
1602
+
1603
+ render_recent()
1604
+ ui_refreshers.append(render_recent)
1605
+
1606
+ # --- Batch ---
1607
+ with ui.tab_panel(tab_batch):
1608
+ ui.label(t("batch_help", lang)).classes("gls-lead")
1609
+ batch_input = (
1610
+ ui.textarea(
1611
+ label=t("batch_urls_label", lang),
1612
+ placeholder=(
1613
+ "https://github.com/owner/repo1\n"
1614
+ "https://github.com/owner/repo2"
1615
+ ),
1616
+ )
1617
+ .classes("w-full gls-field q-mt-sm")
1618
+ .props("outlined rows=8")
1619
+ )
1620
+ batch_status = ui.label("").classes("gls-muted q-mt-sm")
1621
+
1622
+ async def run_batch() -> None:
1623
+ raw = batch_input.value or ""
1624
+ urls = [
1625
+ line.strip()
1626
+ for line in raw.splitlines()
1627
+ if line.strip()
1628
+ and not line.strip().startswith("#")
1629
+ ]
1630
+ if not urls:
1631
+ ui.notify(
1632
+ t("notify_batch_empty", lang), type="warning"
1633
+ )
1634
+ return
1635
+ if len(urls) > MAX_BATCH_URLS:
1636
+ ui.notify(
1637
+ t(
1638
+ "notify_batch_too_many",
1639
+ lang,
1640
+ max=MAX_BATCH_URLS,
1641
+ count=len(urls),
1642
+ ),
1643
+ type="warning",
1644
+ )
1645
+ urls = urls[:MAX_BATCH_URLS]
1646
+ limited = _check_scan_budget(len(urls))
1647
+ if limited:
1648
+ ui.notify(limited, type="warning")
1649
+ return
1650
+ batch_btn.disable()
1651
+ scan_bar.set_visibility(True)
1652
+ results: list[ScanResult] = []
1653
+ try:
1654
+ for i, url in enumerate(urls, 1):
1655
+ batch_status.set_text(
1656
+ t(
1657
+ "batch_status",
1658
+ lang,
1659
+ i=i,
1660
+ total=len(urls),
1661
+ url=url,
1662
+ )
1663
+ )
1664
+ result = await analyze_repository(url)
1665
+ if result.owner and result.scan_complete:
1666
+ append_scan(
1667
+ result, user=history_user()
1668
+ )
1669
+ results.append(result)
1670
+ render_batch_summary(
1671
+ results_box, results, lang=lang
1672
+ )
1673
+ ui.notify(
1674
+ t(
1675
+ "notify_batch_done",
1676
+ lang,
1677
+ count=len(results),
1678
+ ),
1679
+ type="positive",
1680
+ )
1681
+ except Exception as exc: # noqa: BLE001
1682
+ ui.notify(
1683
+ t("notify_batch_error", lang, error=exc),
1684
+ type="negative",
1685
+ )
1686
+ finally:
1687
+ batch_btn.enable()
1688
+ scan_bar.set_visibility(False)
1689
+ batch_status.set_text("")
1690
+
1691
+ batch_btn = (
1692
+ ui.button(
1693
+ t("batch_button", lang),
1694
+ on_click=run_batch,
1695
+ icon="play_arrow",
1696
+ )
1697
+ .props("color=secondary unelevated no-caps")
1698
+ .classes("gls-cta q-mt-md")
1699
+ )
1700
+
1701
+ # --- History ---
1702
+ with ui.tab_panel(tab_history):
1703
+ history_box = ui.column().classes("w-full")
1704
+
1705
+ def refresh_history() -> None:
1706
+ render_history(history_box, lang=lang)
1707
+
1708
+ def do_clear_history() -> None:
1709
+ clear_history(user=history_user())
1710
+ refresh_history()
1711
+ for fn in ui_refreshers:
1712
+ try:
1713
+ fn()
1714
+ except Exception: # noqa: BLE001
1715
+ pass
1716
+ ui.notify(t("history_cleared", lang), type="info")
1717
+
1718
+ with ui.row().classes("gap-2 flex-wrap"):
1719
+ ui.button(
1720
+ t("history_refresh", lang),
1721
+ on_click=refresh_history,
1722
+ icon="refresh",
1723
+ ).props("outline rounded no-caps dense")
1724
+ ui.button(
1725
+ t("history_clear", lang),
1726
+ on_click=do_clear_history,
1727
+ icon="delete_outline",
1728
+ ).props("outline rounded no-caps dense color=negative")
1729
+ ui.label(t("history_privacy_note", lang)).classes(
1730
+ "gls-muted q-mt-sm"
1731
+ ).style("font-size:0.75rem")
1732
+ history_box
1733
+ refresh_history()
1734
+
1735
+ # Disclaimer under sidebar
1736
+ with ui.element("div").classes("gls-disclaimer q-mt-md"):
1737
+ ui.icon("balance", size="sm")
1738
+ ui.label(t("disclaimer", lang))
1739
+ ui.label(t("token_hint", lang)).classes("gls-muted q-mt-sm").style(
1740
+ "font-size:0.75rem"
1741
+ )
1742
+ with ui.row().classes("gap-3 q-mt-sm flex-wrap"):
1743
+ ui.link(
1744
+ t("link_privacy", lang),
1745
+ "/docs/privacy",
1746
+ new_tab=True,
1747
+ ).classes("text-caption")
1748
+ ui.link(
1749
+ t("link_terms", lang),
1750
+ "/docs/terms",
1751
+ new_tab=True,
1752
+ ).classes("text-caption")
1753
+ ui.link(
1754
+ t("link_legal", lang),
1755
+ "/docs/legal",
1756
+ new_tab=True,
1757
+ ).classes("text-caption")
1758
+
1759
+ # ========== RIGHT: results ==========
1760
+ with ui.element("div").classes("gls-main"):
1761
+ with ui.element("div").classes("gls-results-head"):
1762
+ with ui.column().classes("gap-0"):
1763
+ ui.label(t("results_live", lang)).classes("gls-kicker")
1764
+ ui.label(t("results", lang)).classes("gls-title")
1765
+ results_box = ui.column().classes("w-full")
1766
+ render_empty(results_box, lang)
1767
+
1768
+ with ui.element("div").classes("gls-footer"):
1769
+ ui.label(
1770
+ f"{t('app_title', lang)} · ES/EN · "
1771
+ f"{t('theme_light', lang)}/{t('theme_dark', lang)}"
1772
+ )
1773
+
1774
+
1775
+ @ui.page("/docs/privacy")
1776
+ def privacy_page() -> None:
1777
+ """Serve privacy policy (local markdown)."""
1778
+ _render_doc_page("PRIVACY.md", "Privacy")
1779
+
1780
+
1781
+ @ui.page("/docs/terms")
1782
+ def terms_page() -> None:
1783
+ """Serve terms of use (local markdown)."""
1784
+ _render_doc_page("TERMS.md", "Terms")
1785
+
1786
+
1787
+ @ui.page("/docs/legal")
1788
+ def legal_page() -> None:
1789
+ """Serve legal disclaimer (local markdown)."""
1790
+ _render_doc_page("LEGAL_DISCLAIMER.md", "Legal disclaimer")
1791
+
1792
+
1793
+ def _render_doc_page(filename: str, title: str) -> None:
1794
+ from importlib.resources import files
1795
+
1796
+ lang = get_lang()
1797
+ # Read from package data — an installed wheel has no repo checkout around it.
1798
+ try:
1799
+ body = files("gls").joinpath("docs", filename).read_text(encoding="utf-8")
1800
+ except (FileNotFoundError, OSError):
1801
+ body = f"# {title}\n\nNot found."
1802
+ ui.add_css(CUSTOM_CSS)
1803
+ with ui.element("div").classes("gls-app"):
1804
+ with ui.element("div").classes("gls-wrap"):
1805
+ ui.link(t("back_home", lang), "/").classes("text-caption")
1806
+ ui.markdown(body).classes("gls-card q-mt-md")
1807
+
1808
+
1809
+ def run(
1810
+ host: str | None = None,
1811
+ port: int | None = None,
1812
+ show: bool | None = None,
1813
+ ) -> None:
1814
+ """Start the NiceGUI application server. Arguments override config defaults."""
1815
+ ui.run(
1816
+ title="GitHub License Scanner",
1817
+ reload=False,
1818
+ host=host or HOST,
1819
+ port=port or PORT,
1820
+ show=SHOW_BROWSER if show is None else show,
1821
+ favicon="⚖️",
1822
+ dark=None,
1823
+ storage_secret=STORAGE_SECRET,
1824
+ )
1825
+
1826
+
1827
+ if __name__ in {"__main__", "__mp_main__"}:
1828
+ run()