ananyajoshi commited on
Commit
c9d9189
Β·
1 Parent(s): 73ca3b4

UI: motion runtime + icons 1.0

Browse files
app/assets/design_system.css CHANGED
@@ -1,658 +1,941 @@
1
  /* ============================================================
2
- Upwork Proposal Strategist β€” "Spectrum" design system
3
- INJECTABLE INTO STREAMLIT via:
4
- st.markdown(f"<style>{Path('design_system.css').read_text()}</style>",
 
 
5
  unsafe_allow_html=True)
6
 
7
- REQUIRED FONTS β€” add this <link> (or @import) to the app head:
8
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
9
 
10
- Theme: tokens live under [data-theme="dark"] / [data-theme="light"].
11
- Set data-theme on <html>. Per-step accent: override --accent-h / --accent-c /
12
- --accent2-h on :root each render (see README + STEP_COLORS).
13
- Streamlit widget overrides are at the BOTTOM of this file.
14
- ============================================================ */
15
 
16
- /* ============================================================
17
- Upwork Proposal Strategist β€” "Aurora" redesign v2
18
- Premium, layered, atmospheric. Depth from ambient light,
19
- grain, inner highlights and glow β€” not flat fills.
20
  ============================================================ */
21
 
22
  :root {
23
- --accent-h: 156;
24
- --accent-c: 0.15;
25
- --accent2-h: 196; /* secondary hue for gradients */
 
 
 
 
26
  --r: 1;
27
  --pad: 1;
28
 
29
- --radius-xs: calc(7px * var(--r));
30
- --radius-sm: calc(10px * var(--r));
31
- --radius: calc(15px * var(--r));
32
- --radius-lg: calc(22px * var(--r));
33
- --radius-xl: calc(28px * var(--r));
34
- --radius-pill: 999px;
35
-
36
- --gap: calc(14px * var(--pad));
37
- --section-gap: calc(28px * var(--pad));
38
-
39
- --ff-display: "Space Grotesk", system-ui, sans-serif;
40
- --ff-body: "Hanken Grotesk", system-ui, sans-serif;
41
- --ff-mono: "JetBrains Mono", ui-monospace, monospace;
42
-
43
- /* grain texture (very subtle) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
45
  }
46
 
47
- /* ---------- DARK (default β€” shows depth best) ---------- */
48
  [data-theme="dark"] {
49
- --bg: oklch(0.158 0.016 270);
50
- --bg-2: oklch(0.182 0.018 268);
51
- --surface: oklch(0.214 0.018 268);
52
- --surface-hi: oklch(0.246 0.020 267); /* gradient top */
53
- --surface-2: oklch(0.236 0.019 268);
54
- --surface-3: oklch(0.282 0.020 267);
55
- --glass: oklch(0.214 0.018 268 / 0.66);
56
- --border: oklch(1 0 0 / 0.09);
57
- --border-2: oklch(1 0 0 / 0.16);
58
- --hairline: oklch(1 0 0 / 0.07); /* inner top highlight */
59
- --text: oklch(0.97 0.006 260);
60
- --text-muted: oklch(0.74 0.014 262);
61
- --text-faint: oklch(0.58 0.014 262);
62
-
63
- --accent: oklch(0.78 0.155 var(--accent-h));
64
- --accent-2: oklch(0.80 0.13 var(--accent2-h));
65
- --accent-press:oklch(0.72 0.155 var(--accent-h));
66
- --accent-fg: oklch(0.16 0.04 var(--accent-h));
67
- --accent-soft: oklch(0.32 0.075 var(--accent-h) / 0.55);
68
- --accent-line: oklch(0.6 0.12 var(--accent-h) / 0.5);
69
- --accent-glow: oklch(0.78 0.155 var(--accent-h) / 0.45);
70
- --on-soft: oklch(0.9 0.1 var(--accent-h));
71
-
72
- --go: oklch(0.82 0.16 152); --go-soft: oklch(0.4 0.09 152 / 0.4); --go-ink: oklch(0.9 0.13 152); --go-glow: oklch(0.8 0.16 152 / 0.4);
73
- --warn: oklch(0.85 0.15 84); --warn-soft: oklch(0.42 0.08 78 / 0.4); --warn-ink: oklch(0.9 0.12 86); --warn-glow: oklch(0.84 0.15 84 / 0.35);
74
- --stop: oklch(0.72 0.18 26); --stop-soft: oklch(0.4 0.1 26 / 0.42); --stop-ink: oklch(0.86 0.14 28); --stop-glow: oklch(0.72 0.18 26 / 0.4);
75
-
76
- --shadow-sm: 0 1px 2px oklch(0 0 0 / .4), 0 2px 6px oklch(0 0 0 / .3);
77
- --shadow: 0 4px 12px oklch(0 0 0 / .4), 0 18px 44px oklch(0 0 0 / .42);
78
- --shadow-lg: 0 8px 24px oklch(0 0 0 / .45), 0 40px 90px oklch(0 0 0 / .55);
79
- --ring: oklch(0.78 0.155 var(--accent-h) / .45);
80
- --grain-opacity: 0.05;
81
- --aura-1: oklch(0.6 0.18 var(--accent-h) / 0.18);
82
- --aura-2: oklch(0.6 0.16 var(--accent2-h) / 0.13);
83
- --card-grad: linear-gradient(180deg, var(--surface-hi), var(--surface));
84
- --inset-hi: inset 0 1px 0 var(--hairline);
85
- }
86
-
87
- /* ---------- LIGHT ---------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  [data-theme="light"] {
89
- --bg: oklch(0.975 0.006 260);
90
- --bg-2: oklch(0.955 0.008 262);
91
- --surface: oklch(1 0 0);
92
- --surface-hi: oklch(1 0 0);
93
- --surface-2: oklch(0.978 0.004 258);
94
- --surface-3: oklch(0.955 0.006 258);
95
- --glass: oklch(1 0 0 / 0.72);
96
- --border: oklch(0.27 0.02 262 / 0.1);
97
- --border-2: oklch(0.27 0.02 262 / 0.18);
98
- --hairline: oklch(1 0 0 / 0.9);
99
- --text: oklch(0.255 0.016 262);
100
- --text-muted: oklch(0.5 0.014 262);
101
- --text-faint: oklch(0.63 0.012 262);
102
-
103
- --accent: oklch(0.58 0.15 var(--accent-h));
104
- --accent-2: oklch(0.62 0.13 var(--accent2-h));
105
- --accent-press:oklch(0.52 0.15 var(--accent-h));
106
- --accent-fg: oklch(0.99 0.01 var(--accent-h));
107
- --accent-soft: oklch(0.95 0.04 var(--accent-h));
108
- --accent-line: oklch(0.82 0.08 var(--accent-h));
109
- --accent-glow: oklch(0.58 0.15 var(--accent-h) / 0.28);
110
- --on-soft: oklch(0.42 0.12 var(--accent-h));
111
-
112
- --go: oklch(0.62 0.16 152); --go-soft: oklch(0.95 0.05 152); --go-ink: oklch(0.38 0.1 152); --go-glow: oklch(0.62 0.16 152 / 0.22);
113
- --warn: oklch(0.72 0.14 78); --warn-soft: oklch(0.96 0.06 84); --warn-ink: oklch(0.48 0.11 64); --warn-glow: oklch(0.72 0.14 78 / 0.2);
114
- --stop: oklch(0.6 0.19 26); --stop-soft: oklch(0.96 0.04 26); --stop-ink: oklch(0.47 0.16 26); --stop-glow: oklch(0.6 0.19 26 / 0.2);
115
-
116
- --shadow-sm: 0 1px 2px oklch(0.4 0.02 262 / .06), 0 1px 3px oklch(0.4 0.02 262 / .05);
117
- --shadow: 0 2px 6px oklch(0.4 0.02 262 / .06), 0 12px 32px oklch(0.4 0.02 262 / .09);
118
- --shadow-lg: 0 6px 16px oklch(0.4 0.02 262 / .08), 0 30px 70px oklch(0.4 0.02 262 / .14);
119
- --ring: oklch(0.58 0.15 var(--accent-h) / .3);
120
- --grain-opacity: 0.035;
121
- --aura-1: oklch(0.7 0.16 var(--accent-h) / 0.16);
122
- --aura-2: oklch(0.7 0.14 var(--accent2-h) / 0.1);
123
- --card-grad: var(--surface);
124
- --inset-hi: inset 0 1px 0 oklch(1 0 0 / 0.6);
125
- }
126
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  * { box-sizing: border-box; }
128
- html, body { height: 100%; }
129
  body {
130
- margin: 0;
131
  font-family: var(--ff-body);
132
- background: var(--bg);
133
  color: var(--text);
134
  -webkit-font-smoothing: antialiased;
135
  text-rendering: optimizeLegibility;
136
- font-size: 15px;
137
  line-height: 1.5;
138
  }
139
- /* grain overlay across everything */
 
 
 
 
 
140
  body::after {
141
- content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
142
- background-image: var(--grain); background-size: 160px; opacity: var(--grain-opacity);
 
 
143
  mix-blend-mode: overlay;
144
  }
145
- ::selection { background: var(--accent-soft); color: var(--on-soft); }
146
- h1,h2,h3,h4 { font-family: var(--ff-display); font-weight: 600; letter-spacing: -0.015em; margin: 0; }
 
 
 
 
 
147
  button { font-family: inherit; }
148
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
149
- .mono { font-family: var(--ff-mono); }
150
 
151
- /* ============================================================ shell */
152
- .app { display: grid; grid-template-columns: 280px 1fr; height: 100vh; overflow: hidden; }
 
 
 
 
153
 
154
- /* ---------- Sidebar ---------- */
155
- .sidebar {
156
- position: relative;
157
- background:
158
- linear-gradient(180deg, var(--bg-2), var(--bg));
159
- border-right: 1px solid var(--border);
160
- display: flex; flex-direction: column; padding: 24px 18px; gap: 22px; overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  }
162
- .sidebar::before {
163
- content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0;
164
- background: radial-gradient(80% 30% at 30% 0%, var(--aura-1), transparent 70%);
 
 
165
  }
166
- .sidebar > * { position: relative; z-index: 1; }
167
 
168
- .brand { display: flex; align-items: center; gap: 12px; padding: 2px 6px 0; }
169
- .brand-mark {
170
- width: 38px; height: 38px; border-radius: 11px; flex: none;
171
- background: linear-gradient(150deg, oklch(0.7 0.21 286), oklch(0.66 0.22 352));
172
- display: grid; place-items: center; color: #fff;
173
- box-shadow: 0 4px 16px oklch(0.66 0.2 320 / .5), var(--inset-hi), inset 0 -2px 6px oklch(0 0 0 / .15);
174
  position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
- .brand-mark::after {
177
- content: ""; position: absolute; inset: -7px; border-radius: 16px; z-index: -1;
178
- background: conic-gradient(from 0deg, oklch(0.7 0.21 286 / .6), oklch(0.7 0.18 248 / .6), oklch(0.8 0.17 52 / .6), oklch(0.7 0.22 352 / .6), oklch(0.74 0.16 168 / .6), oklch(0.7 0.21 286 / .6));
179
- filter: blur(9px); opacity: .7;
180
- }
181
- .brand-mark svg { width: 20px; height: 20px; }
182
- .brand-name { font-family: var(--ff-display); font-weight: 600; font-size: 16px; letter-spacing: -.01em; line-height: 1.15; }
183
- .brand-name small { display: block; font-family: var(--ff-mono); font-weight: 500; font-size: 9.5px; letter-spacing: .15em; text-transform: uppercase; color: var(--text-faint); margin-top: 3px; }
184
-
185
- .rail-label { font-family: var(--ff-mono); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--text-faint); padding: 0 8px; margin-bottom: -4px; }
186
-
187
- .steprail { display: flex; flex-direction: column; position: relative; }
188
- .step {
189
- display: grid; grid-template-columns: 32px 1fr; align-items: center; gap: 13px;
190
- padding: 11px 11px; border-radius: var(--radius-sm); cursor: pointer; position: relative;
191
- border: 1px solid transparent; text-align: left; background: none; color: inherit; width: 100%;
192
- --sc: oklch(0.74 var(--sc-c, 0.15) var(--sc-h, 280));
193
- --sc-deep: oklch(0.66 var(--sc-c, 0.15) var(--sc-h2, 320));
194
- --sc-ink: oklch(0.2 0.05 var(--sc-h, 280));
195
- }
196
- .step + .step { margin-top: 4px; }
197
- .step:hover:not(.is-locked) { background: var(--surface-2); }
198
- .step:active:not(.is-locked) { transform: translateY(.5px); }
199
- .step.is-locked { cursor: not-allowed; opacity: .5; }
200
- .step.is-current {
201
- background: var(--card-grad); border-color: oklch(0.7 var(--sc-c) var(--sc-h) / .45);
202
- box-shadow: var(--shadow-sm), var(--inset-hi), inset 3px 0 0 var(--sc), inset 0 0 30px oklch(0.7 var(--sc-c) var(--sc-h) / .1);
203
- }
204
- .app[data-railfill="off"] .step.is-current { background: var(--surface-2); box-shadow: none; }
205
-
206
- .step-node {
207
- width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; flex: none;
208
- font-family: var(--ff-mono); font-size: 12.5px; font-weight: 600;
209
- border: 1.5px solid var(--border-2); color: var(--text-muted);
210
- background: var(--surface); position: relative; z-index: 1;
211
- }
212
- .step.is-current .step-node {
213
- border-color: transparent; color: var(--sc-ink);
214
- background: linear-gradient(150deg, var(--sc), var(--sc-deep));
215
- box-shadow: 0 3px 12px oklch(0.7 var(--sc-c) var(--sc-h) / .5), 0 0 0 4px oklch(0.7 var(--sc-c) var(--sc-h) / .25), var(--inset-hi);
216
- }
217
- .step.is-done .step-node {
218
- border-color: oklch(0.7 var(--sc-c) var(--sc-h) / .4); color: var(--sc);
219
- background: oklch(0.7 var(--sc-c) var(--sc-h) / .16);
220
- }
221
- .step.is-locked .step-node { background: var(--surface-2); }
222
- .step-node svg { width: 15px; height: 15px; }
223
-
224
- .step:not(:last-child)::before {
225
- content: ""; position: absolute; left: 25.5px; top: 41px; width: 2px; height: calc(100% - 28px);
226
- background: var(--border-2); z-index: 0; border-radius: 2px;
227
- }
228
- .step.is-done:not(:last-child)::before { background: linear-gradient(oklch(0.7 var(--sc-c) var(--sc-h) / .8), var(--border-2)); }
229
-
230
- .step-text { min-width: 0; display: flex; flex-direction: column; }
231
- .step-name { font-weight: 600; font-size: 14px; letter-spacing: -.01em; }
232
- .step-sub { font-size: 11.5px; color: var(--text-faint); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
233
-
234
- .sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 12px; }
235
- .provider-chip {
236
- display: flex; align-items: center; gap: 11px; padding: 12px 13px;
237
- border: 1px solid var(--border); border-radius: var(--radius-sm);
238
- background: var(--card-grad); box-shadow: var(--inset-hi); font-size: 12.5px;
239
- }
240
- .provider-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--go); box-shadow: 0 0 0 3px var(--go-soft), 0 0 8px var(--go-glow); flex: none; }
241
- .provider-chip b { font-weight: 600; }
242
- .provider-chip .pc-sub { color: var(--text-faint); font-size: 11px; }
243
- .provider-chip .pc-meta { margin-left: auto; font-family: var(--ff-mono); font-size: 10px; color: var(--text-faint); }
244
-
245
- .theme-toggle { display: flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 3px; gap: 2px; }
246
- .theme-toggle button {
247
- flex: 1; border: none; background: none; cursor: pointer; padding: 7px; border-radius: var(--radius-pill);
248
- color: var(--text-faint); font-size: 11.5px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px;
249
- }
250
- .theme-toggle button svg { width: 14px; height: 14px; }
251
- .theme-toggle button.on { background: var(--card-grad); color: var(--text); box-shadow: var(--shadow-sm), var(--inset-hi); }
252
-
253
- /* ============================================================ main */
254
- .main { overflow-y: auto; position: relative; }
255
- .main::before {
256
- content: ""; position: fixed; inset: 0 0 0 280px; pointer-events: none; z-index: 0;
257
- background:
258
- radial-gradient(60% 50% at 85% -5%, var(--aura-1), transparent 60%),
259
- radial-gradient(55% 45% at 8% 8%, var(--aura-2), transparent 55%);
260
- }
261
- .topbar {
262
- position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 14px;
263
- padding: 16px 44px; backdrop-filter: blur(14px) saturate(1.3);
264
- background: var(--glass); border-bottom: 1px solid var(--border);
265
- }
266
- .crumb { font-family: var(--ff-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-faint); white-space: nowrap; flex: none; }
267
- .crumb b { color: var(--text-muted); font-weight: 600; }
268
- .progress-wrap { display: flex; align-items: center; gap: 11px; margin-left: 18px; }
269
- .progress-track {
270
- width: 168px; height: 7px; border-radius: 99px; overflow: hidden; flex: none;
271
- background: var(--bg-2); border: 1px solid var(--border); box-shadow: inset 0 1px 2px oklch(0 0 0 / .2);
272
- }
273
- .progress-fill {
274
- height: 100%; border-radius: 99px; transition: width .5s cubic-bezier(.4,1.2,.4,1);
275
- background: linear-gradient(90deg, oklch(0.7 0.21 286), oklch(0.7 0.18 248), oklch(0.8 0.17 52), oklch(0.7 0.22 352), oklch(0.74 0.16 168));
276
- box-shadow: 0 0 12px oklch(0.7 0.18 320 / .5);
277
- }
278
- .progress-label { font-size: 10.5px; letter-spacing: .08em; color: var(--text-faint); white-space: nowrap; }
279
- .progress-label i { font-style: normal; opacity: .6; }
280
- .topbar-spacer { flex: 1; }
281
- .badge {
282
- display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
283
- padding: 7px 13px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600;
284
- border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--inset-hi);
285
- }
286
- .badge .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
287
- .badge.ok { color: var(--go-ink); border-color: var(--accent-line); }
288
- .badge.ok .dot { background: var(--go); box-shadow: 0 0 8px var(--go-glow); }
289
-
290
- .screen { position: relative; z-index: 1; max-width: 920px; margin: 0 auto; padding: 42px 44px 100px; }
291
- @media (prefers-reduced-motion: no-preference) { .screen { animation: rise .45s cubic-bezier(.2,.7,.3,1); } }
292
- @keyframes rise { from { transform: translateY(12px); } }
293
 
294
- .screen-head { margin-bottom: var(--section-gap); }
295
- .eyebrow {
296
- font-family: var(--ff-mono); font-size: 11px; letter-spacing: .16em; text-transform: uppercase;
297
- color: var(--accent); font-weight: 500; display: inline-flex; align-items: center; gap: 9px; margin-bottom: 14px;
298
- padding: 5px 11px 5px 9px; border-radius: var(--radius-pill);
299
- background: var(--accent-soft); border: 1px solid var(--accent-line);
300
- }
301
- .eyebrow::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
302
- .screen-title { font-size: 33px; line-height: 1.08; letter-spacing: -.025em; }
303
- .screen-sub { color: var(--text-muted); font-size: 15.5px; margin-top: 11px; max-width: 60ch; }
304
- .screen-sub b { color: var(--text); font-weight: 600; }
 
 
 
305
 
306
- /* ---------- Card ---------- */
307
- .card { background: var(--card-grad); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm), var(--inset-hi); transition: transform .18s cubic-bezier(.2,.7,.3,1), box-shadow .18s; }
308
- .card + .card { margin-top: 16px; }
309
- .card-pad { padding: calc(24px * var(--pad)); }
310
- .card-head { display: flex; align-items: center; gap: 10px; padding: 16px 22px; border-bottom: 1px solid var(--border); }
311
- .card-head .hint { margin-left: auto; color: var(--text-faint); font-size: 12px; display: flex; align-items: center; gap: 6px; }
312
- .label { font-family: var(--ff-mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-muted); font-weight: 500; }
313
-
314
- /* ---------- Buttons ---------- */
315
- .btn {
316
- display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 18px; height: 44px;
317
- border-radius: var(--radius-sm); font-family: var(--ff-body); font-size: 14px; font-weight: 600;
318
- cursor: pointer; border: 1px solid transparent; transition: transform .12s, filter .15s, background .15s; white-space: nowrap;
319
- }
320
- .btn svg { width: 16px; height: 16px; }
321
- .btn:active { transform: translateY(1px); }
322
- .btn-primary {
323
- color: var(--accent-fg); border: none;
324
- background: linear-gradient(150deg, var(--accent), var(--accent-2));
325
- box-shadow: 0 4px 16px var(--accent-glow), var(--inset-hi), inset 0 -2px 6px oklch(0 0 0 / .12);
326
- }
327
- .btn-primary:hover { filter: brightness(1.06); }
328
- .btn-secondary { background: var(--card-grad); color: var(--text); border-color: var(--border-2); box-shadow: var(--inset-hi); }
329
- .btn-secondary:hover { background: var(--surface-2); }
330
- .btn-ghost { background: transparent; color: var(--text-muted); }
331
- .btn-ghost:hover { background: var(--surface-2); color: var(--text); }
332
- .btn-block { width: 100%; }
333
- .btn:disabled { opacity: .45; cursor: not-allowed; transform: none; filter: none; }
334
- .btn-lg { height: 52px; font-size: 15px; padding: 0 26px; border-radius: var(--radius); }
335
-
336
- /* ---------- Fields ---------- */
337
- .field { display: flex; flex-direction: column; gap: 8px; }
338
- .field-label { font-size: 13px; font-weight: 600; }
339
- .field-label .opt { color: var(--text-faint); font-weight: 500; }
340
- .input-wrap { position: relative; display: flex; align-items: center; }
341
- .input {
342
- width: 100%; height: 48px; padding: 0 15px; background: var(--bg-2); border: 1px solid var(--border-2);
343
- border-radius: var(--radius-sm); color: var(--text); font-size: 14px; font-family: var(--ff-body);
344
- transition: border-color .15s, box-shadow .15s, background .15s; box-shadow: inset 0 1px 3px oklch(0 0 0 / .12);
345
- }
346
- .input.mono { font-family: var(--ff-mono); letter-spacing: .02em; }
347
- .input:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 4px var(--ring); }
348
- .input::placeholder { color: var(--text-faint); }
349
- .input-wrap .reveal { position: absolute; right: 9px; background: none; border: none; cursor: pointer; color: var(--text-faint); padding: 6px; border-radius: 7px; display: grid; place-items: center; }
350
- .input-wrap .reveal:hover { color: var(--text); background: var(--surface-3); }
351
- .input-wrap .reveal svg { width: 16px; height: 16px; }
352
- .field-hint { font-size: 12px; color: var(--text-faint); }
353
- .field-hint a { color: var(--accent); text-decoration: none; font-weight: 600; }
354
-
355
- /* ---------- Provider grid ---------- */
356
- .provider-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; }
357
- .provider-card {
358
- position: relative; text-align: left; cursor: pointer; overflow: hidden;
359
- background: var(--card-grad); border: 1.5px solid var(--border); border-radius: var(--radius);
360
- padding: 17px; display: flex; gap: 14px; align-items: flex-start;
361
- transition: border-color .15s, transform .12s; box-shadow: var(--inset-hi);
362
- }
363
- .provider-card:hover { border-color: var(--border-2); transform: translateY(-2px); }
364
- .provider-card.selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring), 0 8px 24px var(--accent-glow), var(--inset-hi); }
365
- .provider-card.selected::after {
366
- content: ""; position: absolute; inset: 0; pointer-events: none;
367
- background: radial-gradient(80% 60% at 100% 0%, var(--accent-soft), transparent 60%); opacity: .7;
368
- }
369
- .provider-logo {
370
- width: 40px; height: 40px; border-radius: 11px; flex: none; display: grid; place-items: center;
371
- font-family: var(--ff-display); font-weight: 700; font-size: 17px;
372
- background: var(--surface-3); color: var(--text); box-shadow: var(--inset-hi); position: relative; z-index: 1;
373
- }
374
- .provider-info { min-width: 0; position: relative; z-index: 1; }
375
- .provider-info b { font-size: 14.5px; font-weight: 600; display: block; }
376
- .provider-info span { font-size: 12px; color: var(--text-muted); }
377
- .provider-tags { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
378
- .tag { font-family: var(--ff-mono); font-size: 9.5px; letter-spacing: .04em; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-xs); background: var(--surface-3); color: var(--text-muted); font-weight: 500; }
379
- .tag.vision { background: var(--accent-soft); color: var(--on-soft); border: 1px solid var(--accent-line); }
380
- .tag.novision { background: var(--warn-soft); color: var(--warn-ink); }
381
- .provider-check { position: absolute; top: 13px; right: 13px; width: 22px; height: 22px; border-radius: 50%; background: linear-gradient(150deg, var(--accent), var(--accent-2)); color: var(--accent-fg); display: grid; place-items: center; opacity: 0; transform: scale(.6); transition: all .18s; z-index: 2; box-shadow: 0 2px 8px var(--accent-glow); }
382
- .provider-card.selected .provider-check { opacity: 1; transform: scale(1); }
383
- .provider-check svg { width: 12px; height: 12px; }
384
-
385
- /* ---------- Dropzone ---------- */
386
- .dropzone {
387
- position: relative; overflow: hidden; border: 1.5px dashed var(--border-2); border-radius: var(--radius);
388
- background: var(--bg-2); padding: 32px; display: flex; align-items: center; gap: 19px; transition: all .18s; cursor: pointer;
389
- }
390
- .dropzone:hover { border-color: var(--accent); }
391
- .dropzone:hover::before { content: ""; position: absolute; inset: 0; background: radial-gradient(60% 80% at 12% 50%, var(--accent-soft), transparent 60%); opacity: .6; }
392
- .dz-icon { width: 56px; height: 56px; border-radius: 15px; flex: none; background: var(--card-grad); border: 1px solid var(--border); display: grid; place-items: center; color: var(--accent); box-shadow: var(--shadow-sm), var(--inset-hi); position: relative; z-index: 1; }
393
- .dz-icon svg { width: 25px; height: 25px; }
394
- .dz-text { position: relative; z-index: 1; }
395
- .dz-text b { font-size: 15px; font-weight: 600; display: block; }
396
- .dz-text span { font-size: 12.5px; color: var(--text-faint); }
397
- .dz-spacer { flex: 1; }
398
- .divider-or { display: flex; align-items: center; gap: 14px; color: var(--text-faint); font-size: 12px; margin: 20px 0; }
399
- .divider-or::before, .divider-or::after { content: ""; height: 1px; background: var(--border); flex: 1; }
400
-
401
- .thumbs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
402
- .thumb { width: 96px; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); background: var(--surface-2); position: relative; box-shadow: var(--shadow-sm); }
403
- .thumb .ph { height: 66px; background-image: repeating-linear-gradient(135deg, var(--surface-3) 0 8px, var(--surface-2) 8px 16px); }
404
- .thumb .tn { font-family: var(--ff-mono); font-size: 9px; color: var(--text-faint); padding: 6px 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
405
- .thumb .x { position: absolute; top: 6px; right: 6px; width: 19px; height: 19px; border-radius: 50%; background: oklch(0 0 0 / .55); color: #fff; border: none; display: grid; place-items: center; cursor: pointer; backdrop-filter: blur(4px); }
406
- .thumb .x svg { width: 10px; height: 10px; }
407
-
408
- /* ---------- File list ---------- */
409
- .filelist { display: flex; flex-direction: column; }
410
- .filerow { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 14px; padding: 13px 4px; }
411
- .filerow + .filerow { border-top: 1px solid var(--border); }
412
- .fileicon { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; font-family: var(--ff-mono); font-size: 9.5px; font-weight: 600; box-shadow: var(--inset-hi); }
413
- .fileicon.pdf { background: var(--stop-soft); color: var(--stop-ink); }
414
- .fileicon.doc { background: var(--accent-soft); color: var(--on-soft); }
415
- .fileicon.txt { background: var(--surface-3); color: var(--text-muted); }
416
- .fr-name { font-size: 13.5px; font-weight: 500; }
417
- .fr-meta { font-size: 11.5px; color: var(--text-faint); }
418
- .fr-size { font-family: var(--ff-mono); font-size: 11px; color: var(--text-faint); }
419
-
420
- .stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
421
- .stat { padding: 18px 20px; border-radius: var(--radius); background: var(--bg-2); border: 1px solid var(--border); box-shadow: var(--inset-hi); position: relative; overflow: hidden; }
422
- .stat::before { content: ""; position: absolute; top: -20px; right: -20px; width: 70px; height: 70px; border-radius: 50%; background: radial-gradient(circle, var(--accent-soft), transparent 70%); opacity: .6; }
423
- .stat .n { font-family: var(--ff-display); font-size: 30px; font-weight: 600; letter-spacing: -.03em; position: relative; }
424
- .stat .l { font-size: 12px; color: var(--text-faint); margin-top: 3px; }
425
-
426
- /* ============================================================ verdict hero */
427
  .verdict {
428
- position: relative; border-radius: var(--radius-xl); padding: 32px 34px; overflow: hidden;
429
- display: grid; grid-template-columns: 1fr auto; gap: 28px; align-items: center; border: 1px solid var(--border);
430
- background: var(--card-grad); box-shadow: var(--shadow-lg), var(--inset-hi);
 
 
 
 
 
431
  }
432
  .verdict::before {
433
  content: ""; position: absolute; inset: 0; pointer-events: none;
434
- background: radial-gradient(70% 120% at 0% 0%, var(--g-aura), transparent 55%), radial-gradient(60% 100% at 100% 100%, var(--g-aura), transparent 60%);
435
- opacity: .9;
436
  }
437
  .verdict.go { --g-aura: var(--go-glow); }
438
  .verdict.warn { --g-aura: var(--warn-glow); }
439
  .verdict.stop { --g-aura: var(--stop-glow); }
440
- .verdict > * { position: relative; z-index: 1; }
 
441
  .verdict-badge {
442
- display: inline-flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: var(--radius-pill);
443
- font-family: var(--ff-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; font-weight: 600;
444
- background: var(--glass); border: 1px solid var(--border); backdrop-filter: blur(8px); width: fit-content; box-shadow: var(--inset-hi);
445
- }
446
- .verdict.go .verdict-badge { color: var(--go-ink); }
 
 
 
 
 
 
447
  .verdict.warn .verdict-badge { color: var(--warn-ink); }
448
  .verdict.stop .verdict-badge { color: var(--stop-ink); }
449
- .verdict-badge .dot { width: 9px; height: 9px; border-radius: 50%; }
450
- .verdict.go .dot { background: var(--go); box-shadow: 0 0 10px var(--go-glow); }
451
- .verdict.warn .dot { background: var(--warn); box-shadow: 0 0 10px var(--warn-glow); }
452
- .verdict.stop .dot { background: var(--stop); box-shadow: 0 0 10px var(--stop-glow); }
453
- .verdict h2 { font-size: 29px; margin: 16px 0 9px; letter-spacing: -.025em; line-height: 1.1; }
454
- .verdict p { color: var(--text-muted); font-size: 14.5px; max-width: 46ch; margin: 0; }
455
-
456
- /* score ring */
457
- .score { display: grid; place-items: center; gap: 10px; position: relative; }
458
- .ring { width: 132px; height: 132px; position: relative; display: grid; place-items: center; }
459
- .ring::before { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: radial-gradient(circle, var(--g-aura), transparent 72%); filter: blur(10px); }
460
- .ring svg { transform: rotate(-90deg); width: 132px; height: 132px; position: relative; filter: drop-shadow(0 0 8px var(--g-aura)); }
461
- .ring .track { fill: none; stroke: var(--border-2); stroke-width: 9; }
462
- .ring .bar { fill: none; stroke-width: 9; stroke-linecap: round; }
463
- @media (prefers-reduced-motion: no-preference) {
464
- .ring .bar { animation: ringDraw 1.1s cubic-bezier(.3,.8,.3,1); }
465
- }
466
- @keyframes ringDraw { from { stroke-dashoffset: var(--c-full); } }
467
- .ring .val { position: absolute; text-align: center; }
468
- @media (prefers-reduced-motion: no-preference) { .ring .val { animation: pop .6s .2s backwards cubic-bezier(.3,1.4,.4,1); } }
469
- @keyframes pop { from { transform: scale(.55); } }
470
- .ring .val b { font-family: var(--ff-display); font-size: 34px; font-weight: 600; letter-spacing: -.03em; display: block; line-height: 1; }
471
- .ring .val span { font-family: var(--ff-mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-faint); }
472
-
473
- .twocol { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
474
- .panel-title { display: flex; align-items: center; gap: 10px; font-family: var(--ff-display); font-weight: 600; font-size: 15.5px; margin-bottom: 6px; }
475
- .panel-title .pico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; box-shadow: var(--inset-hi); }
476
- .panel-title .pico svg { width: 14px; height: 14px; }
477
- .pico.pos { background: var(--go-soft); color: var(--go-ink); }
478
- .pico.neg { background: var(--warn-soft); color: var(--warn-ink); }
479
-
480
- .checkitem { display: grid; grid-template-columns: 18px 1fr; gap: 12px; padding: 12px 0; align-items: start; }
481
- .checkitem + .checkitem { border-top: 1px solid var(--border); }
482
- .checkitem .ci-ico { margin-top: 2px; }
483
- .checkitem .ci-ico svg { width: 16px; height: 16px; }
484
- .checkitem.pos .ci-ico { color: var(--go); }
485
- .checkitem.neg .ci-ico { color: var(--warn); }
486
- .checkitem b { font-size: 13.5px; font-weight: 600; }
487
- .checkitem p { font-size: 12.5px; color: var(--text-muted); margin: 3px 0 0; line-height: 1.5; }
488
- .checkitem .src { font-family: var(--ff-mono); font-size: 10px; color: var(--text-faint); margin-top: 6px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: var(--radius-xs); background: var(--bg-2); border: 1px solid var(--border); }
489
- .checkitem .src svg { width: 11px; height: 11px; }
490
-
491
- .jobmeta { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); }
492
- .metacell { background: var(--card-grad); padding: 15px 17px; }
493
- .metacell .mk { font-family: var(--ff-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-faint); }
494
- .metacell .mv { font-size: 14px; font-weight: 600; margin-top: 6px; }
495
- .metacell .mv.muted { color: var(--text-faint); font-weight: 500; font-style: italic; }
496
-
497
- /* ============================================================ proposal */
498
- .doc-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
499
- .seg { display: inline-flex; background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px; gap: 2px; box-shadow: var(--inset-hi); }
500
- .seg button { border: none; background: none; cursor: pointer; padding: 8px 14px; border-radius: calc(var(--radius-sm) - 3px); font-size: 12.5px; font-weight: 600; color: var(--text-muted); transition: all .14s; }
501
- .seg button.on { background: var(--card-grad); color: var(--text); box-shadow: var(--shadow-sm), var(--inset-hi); }
502
  .toolbar-spacer { flex: 1; }
503
- .wordcount { font-family: var(--ff-mono); font-size: 11px; color: var(--text-faint); }
504
-
505
- .proposal-doc { position: relative; background: var(--card-grad); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow), var(--inset-hi); padding: 40px 44px; overflow: hidden; }
506
- .proposal-doc::before { content: ""; position: absolute; left: 0; top: 26px; bottom: 26px; width: 3px; border-radius: 3px; background: linear-gradient(var(--accent), var(--accent-2)); box-shadow: 0 0 16px var(--accent-glow); }
507
- .proposal-doc::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(50% 40% at 100% 0%, var(--accent-soft), transparent 55%); opacity: .4; }
508
- .proposal-doc p { position: relative; margin: 0 0 16px; font-size: 14.5px; line-height: 1.75; color: var(--text); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  .proposal-doc p:last-child { margin-bottom: 0; }
510
- .proposal-doc .lead { font-size: 15.5px; }
511
- .proposal-doc mark { background: var(--accent-soft); color: var(--on-soft); padding: 1px 5px; border-radius: 5px; font-weight: 600; border: 1px solid var(--accent-line); }
512
- .evidence-note { position: relative; display: inline-flex; align-items: center; gap: 9px; margin-top: 22px; font-size: 12px; color: var(--text-faint); padding: 8px 13px; border-radius: var(--radius-pill); background: var(--bg-2); border: 1px solid var(--border); }
513
- .evidence-note svg { width: 14px; height: 14px; color: var(--accent); }
514
-
515
- /* helpers */
516
- .row { display: flex; align-items: center; gap: 12px; }
517
- .spread { justify-content: space-between; }
518
- .mt-s { margin-top: 12px; } .mt-m { margin-top: 20px; } .mt-l { margin-top: 28px; }
519
- .callout { display: flex; gap: 12px; padding: 15px 17px; border-radius: var(--radius); background: var(--accent-soft); border: 1px solid var(--accent-line); font-size: 13px; color: var(--on-soft); box-shadow: var(--inset-hi); }
520
- .callout svg { width: 17px; height: 17px; flex: none; margin-top: 1px; }
521
-
522
- @media (max-width: 1100px) {
523
- .app { grid-template-columns: 248px 1fr; }
524
- .main::before { inset: 0 0 0 248px; }
525
- .screen { padding: 34px 30px 84px; }
526
- .topbar { padding: 14px 30px; }
527
- }
528
-
529
- /* ============================================================ gamification */
530
- /* points pill */
531
- .points-pill {
532
- display: inline-flex; align-items: center; gap: 6px; padding: 6px 13px 6px 11px;
533
- border-radius: var(--radius-pill); font-size: 12.5px; font-weight: 600; color: var(--text);
534
- background: linear-gradient(135deg, oklch(0.8 0.17 52 / .18), oklch(0.7 0.2 320 / .16));
535
- border: 1px solid var(--accent-line); box-shadow: var(--inset-hi);
536
- }
537
- .points-pill svg { width: 14px; height: 14px; color: oklch(0.82 0.17 70); fill: oklch(0.82 0.17 70 / .25); }
538
- .points-pill b { font-size: 13px; }
539
-
540
- /* XP card */
541
- .xp-card { background: var(--card-grad); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow-sm), var(--inset-hi); position: relative; overflow: hidden; }
542
- .xp-card::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 90% at 0% 0%, var(--accent-soft), transparent 60%); opacity: .8; }
543
- .xp-top { display: flex; align-items: center; gap: 11px; position: relative; }
544
- .xp-badge {
545
- width: 38px; height: 38px; border-radius: 11px; flex: none; display: grid; place-items: center;
546
- background: linear-gradient(150deg, var(--accent), var(--accent-2)); color: var(--accent-fg);
547
- font-weight: 700; box-shadow: 0 3px 12px var(--accent-glow), var(--inset-hi);
548
- }
549
- .xp-badge .mono { font-size: 13px; letter-spacing: -.02em; }
550
- .xp-meta { min-width: 0; flex: 1; }
551
- .xp-name { font-family: var(--ff-display); font-weight: 600; font-size: 13.5px; letter-spacing: -.01em; }
552
- .xp-rank { font-size: 10px; color: var(--text-faint); margin-top: 2px; letter-spacing: .02em; }
553
- .streak { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 700; color: oklch(0.8 0.17 52); flex: none; }
554
- .streak svg { width: 15px; height: 15px; fill: oklch(0.8 0.17 52 / .25); }
555
- .xp-bar { height: 8px; border-radius: 99px; background: var(--bg-2); border: 1px solid var(--border); margin-top: 12px; overflow: hidden; box-shadow: inset 0 1px 2px oklch(0 0 0 / .2); position: relative; }
556
- .xp-bar-fill {
557
- height: 100%; border-radius: 99px; transition: width .6s cubic-bezier(.4,1.3,.4,1);
558
- background: linear-gradient(90deg, var(--accent), var(--accent-2));
559
- box-shadow: 0 0 10px var(--accent-glow);
560
- }
561
- .xp-foot { font-size: 9.5px; color: var(--text-faint); margin-top: 7px; letter-spacing: .03em; text-align: right; }
562
-
563
- /* confetti */
564
- .confetti-layer { position: absolute; inset: -10px 0 0; overflow: hidden; pointer-events: none; z-index: 30; }
565
- .confetti-pc { position: absolute; top: -14px; will-change: transform; }
566
- @media (prefers-reduced-motion: no-preference) {
567
- .confetti-pc { animation: confettiFall var(--d) cubic-bezier(.3,.5,.6,1) var(--delay) forwards; }
568
  }
569
- @keyframes confettiFall {
570
- 0% { transform: translate3d(0, -5vh, 0) rotate(0); opacity: 1; }
571
- 100% { transform: translate3d(var(--drift), 105vh, 0) rotate(var(--r)); opacity: 0; }
 
 
 
572
  }
573
 
574
- /* achievement toast */
575
- .achv-toast {
576
- position: fixed; top: 78px; right: 36px; z-index: 60; display: flex; align-items: center; gap: 13px;
577
- padding: 13px 17px 13px 13px; border-radius: var(--radius); max-width: 320px;
578
- background: var(--card-grad); border: 1px solid var(--accent-line);
579
- box-shadow: var(--shadow-lg), 0 0 0 1px var(--accent-glow), var(--inset-hi);
580
- transform: translateX(130%); opacity: 0; transition: transform .55s cubic-bezier(.3,1.3,.4,1), opacity .4s;
 
 
 
 
 
 
 
 
581
  }
582
- .achv-toast.in { transform: translateX(0); opacity: 1; }
583
- .achv-ico {
584
- width: 42px; height: 42px; border-radius: 11px; flex: none; display: grid; place-items: center;
585
- background: linear-gradient(150deg, oklch(0.82 0.17 70), oklch(0.74 0.2 38)); color: oklch(0.25 0.08 50);
586
- box-shadow: 0 3px 12px oklch(0.78 0.17 60 / .5), var(--inset-hi);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  }
588
- .achv-ico svg { width: 22px; height: 22px; }
589
- .achv-kicker { font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
590
- .achv-title { font-family: var(--ff-display); font-weight: 600; font-size: 15px; margin-top: 2px; letter-spacing: -.01em; }
591
- .achv-sub { font-size: 11.5px; color: var(--text-muted); margin-top: 2px; }
592
 
593
  /* ============================================================
594
  === STREAMLIT WIDGET OVERRIDES ===
595
- Make Streamlit's own widgets inherit the design system.
596
- These target Streamlit's internal DOM (selectors are stable
597
- as of Streamlit 1.3x but verify against your version).
598
  ============================================================ */
599
 
600
- /* App background + remove default top padding */
601
- [data-testid="stAppViewContainer"] { background: var(--bg); }
 
 
 
 
 
 
602
  [data-testid="stHeader"] { background: transparent; }
603
  .block-container { padding-top: 2.2rem; max-width: 960px; }
604
 
605
- /* Sidebar surface */
606
  section[data-testid="stSidebar"] {
607
- background: linear-gradient(180deg, var(--bg-2), var(--bg));
608
- border-right: 1px solid var(--border);
609
  }
610
  section[data-testid="stSidebar"] .block-container { padding-top: 1.4rem; }
611
 
612
- /* Primary buttons β†’ gradient accent with glow (matches .btn-primary) */
613
  .stButton > button {
614
- font-family: var(--ff-body); font-weight: 600; font-size: 14px;
615
- border-radius: var(--radius-sm); height: 44px; border: none; color: var(--accent-fg);
616
- background: linear-gradient(150deg, var(--accent), var(--accent-2));
617
- box-shadow: 0 4px 16px var(--accent-glow), var(--inset-hi);
618
- transition: filter .15s, transform .12s;
619
- }
620
- .stButton > button:hover { filter: brightness(1.06); }
621
- .stButton > button:active { transform: translateY(1px); }
622
- /* Secondary button variant: add `type="secondary"` in st.button(...) */
 
 
 
 
623
  .stButton > button[kind="secondary"] {
624
- background: var(--card-grad); color: var(--text);
625
- border: 1px solid var(--border-2); box-shadow: var(--inset-hi);
 
 
626
  }
627
 
628
- /* Text / password inputs (matches .input) */
629
  .stTextInput input, .stTextArea textarea {
630
- background: var(--bg-2); border: 1px solid var(--border-2);
631
- border-radius: var(--radius-sm); color: var(--text); font-family: var(--ff-body);
 
 
 
632
  }
633
  .stTextInput input:focus, .stTextArea textarea:focus {
634
- border-color: var(--accent); box-shadow: 0 0 0 4px var(--ring);
 
635
  }
636
 
637
- /* File uploader β†’ dropzone look (matches .dropzone) */
638
  [data-testid="stFileUploaderDropzone"] {
639
- background: var(--bg-2); border: 1.5px dashed var(--border-2);
640
- border-radius: var(--radius); color: var(--text-muted);
 
 
 
 
 
 
641
  }
642
- [data-testid="stFileUploaderDropzone"]:hover { border-color: var(--accent); }
643
 
644
- /* Headings use the display font */
645
- h1, h2, h3 { font-family: var(--ff-display) !important; letter-spacing: -.02em; }
646
 
647
- /* Tabs / radios picked accent color */
648
- .stTabs [aria-selected="true"] { color: var(--accent) !important; }
649
- [data-baseweb="radio"] [aria-checked="true"] div:first-child { border-color: var(--accent) !important; background: var(--accent) !important; }
 
 
650
 
651
- /* Progress bar (st.progress) β†’ accent gradient */
652
  .stProgress > div > div > div > div {
653
  background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important;
654
  }
655
 
656
- /* Hide Streamlit chrome you don't want (optional) */
657
  #MainMenu { visibility: hidden; }
658
  footer { visibility: hidden; }
 
1
  /* ============================================================
2
+ Upwork Proposal Strategist β€” "Aurora Glass" design system
3
+ Dark luxury glassmorphism. Luxury through restraint.
4
+
5
+ INJECTABLE INTO STREAMLIT via app/ui/theme.py:
6
+ st.markdown(f"[style]{...design_system.css...}[/style]",
7
  unsafe_allow_html=True)
8
 
9
+ REQUIRED FONT β€” theme.py injects:
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
11
 
12
+ THEME: tokens live under [data-theme="dark"] / [data-theme="light"].
13
+ theme.py::apply_step_accent sets data-theme on <html> and writes
14
+ --accent-h / --accent-c / --accent2-h inline, so the accent stays a
15
+ single violet family with only a whisper of per-step drift.
 
16
 
17
+ Every custom-property name and every class name in this file is part
18
+ of the contract with app/ui/theme.py and the screen modules. Values
19
+ changed for Aurora; names deliberately unchanged.
 
20
  ============================================================ */
21
 
22
  :root {
23
+ /* Accent defaults. apply_step_accent() overwrites these inline on <html>.
24
+ At these defaults --accent resolves to #8B5CF6 (Aurora primary). */
25
+ --accent-h: 293.5;
26
+ --accent-c: 0.213;
27
+ --accent2-h: 304;
28
+
29
+ /* Legacy density multipliers kept so any existing calc() still resolves. */
30
  --r: 1;
31
  --pad: 1;
32
 
33
+ /* radius scale β€” 8 / 10 / 12 / 16 / 20 / 24 / pill */
34
+ --radius-xs: 8px; /* small */
35
+ --radius-input: 10px; /* control */
36
+ --radius-btn: 12px; /* button */
37
+ --radius-sm: 12px; /* alias: button */
38
+ --radius: 16px; /* card */
39
+ --radius-lg: 20px; /* large card */
40
+ --radius-xl: 24px; /* shell */
41
+ --radius-pill: 999px;
42
+
43
+ /* spacing β€” 8px base unit */
44
+ --space-xs: 4px;
45
+ --space-sm: 8px;
46
+ --space-md: 12px;
47
+ --gap: 16px;
48
+ --section-gap: 24px;
49
+ --space-xl: 32px;
50
+
51
+ /* typography β€” one family */
52
+ --ff-display: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
53
+ --ff-body: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
54
+ --ff-mono: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
55
+
56
+ /* motion */
57
+ --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
58
+ --t-fast: 120ms;
59
+ --t-base: 180ms;
60
+ --t-slow: 240ms;
61
+
62
+ /* rare premium accent β€” used in exactly one place (.evidence-note) */
63
+ --gold: #C8A96B;
64
+
65
+ /* very fine noise so large dark areas do not read as flat */
66
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
67
  }
68
 
69
+ /* ---------- DARK β€” the Aurora reference ---------- */
70
  [data-theme="dark"] {
71
+ --bg: #081020;
72
+ --bg-deep: #050A14;
73
+ --bg-2: #0B1323;
74
+
75
+ --surface: rgba(255, 255, 255, 0.04); /* canvas --glass-lo */
76
+ --surface-hi: rgba(255, 255, 255, 0.10); /* canvas --glass-hover */
77
+ --surface-2: rgba(255, 255, 255, 0.06); /* canvas --glass */
78
+ --surface-3: rgba(255, 255, 255, 0.080);
79
+ --surface-hover: rgba(255, 255, 255, 0.090);
80
+ --glass: rgba(11, 19, 35, 0.72);
81
+
82
+ --border-soft: rgba(255, 255, 255, 0.07); /* canvas --stroke-soft */
83
+ --border: rgba(255, 255, 255, 0.12); /* canvas --stroke */
84
+ --border-2: rgba(255, 255, 255, 0.160);
85
+ --hairline: rgba(255, 255, 255, 0.07); /* canvas --stroke-soft */
86
+
87
+ --text: #F8FAFC;
88
+ --text-muted: #94A3B8;
89
+ --text-faint: #64748B;
90
+ --text-disabled: #475569;
91
+
92
+ --accent: oklch(0.627 var(--accent-c) var(--accent-h));
93
+ --accent-2: oklch(0.656 0.225 var(--accent2-h));
94
+ --accent-press: oklch(0.542 var(--accent-c) var(--accent-h));
95
+ --accent-light: oklch(0.745 0.175 var(--accent2-h));
96
+ --on-soft: oklch(0.745 0.175 var(--accent2-h));
97
+ --accent-fg: #FFFFFF;
98
+ --accent-soft: oklch(0.627 var(--accent-c) var(--accent-h) / 0.14);
99
+ --accent-line: oklch(0.720 0.160 var(--accent2-h) / 0.22);
100
+ --accent-glow: oklch(0.627 var(--accent-c) var(--accent-h) / 0.22);
101
+ --ring: oklch(0.627 var(--accent-c) var(--accent-h) / 0.10);
102
+ --focus-ring: oklch(0.627 var(--accent-c) var(--accent-h) / 0.78);
103
+ --accent-grad: linear-gradient(135deg,
104
+ oklch(0.542 var(--accent-c) var(--accent-h)),
105
+ oklch(0.600 0.240 var(--accent-h)) 55%,
106
+ oklch(0.656 0.225 var(--accent2-h)));
107
+
108
+ --go: #22C55E; --go-soft: rgba(34, 197, 94, 0.12); --go-ink: #4ADE80; --go-glow: rgba(34, 197, 94, 0.20);
109
+ --warn: #F59E0B; --warn-soft: rgba(245, 158, 11, 0.12); --warn-ink: #FBBF24; --warn-glow: rgba(245, 158, 11, 0.20);
110
+ --stop: #EF4444; --stop-soft: rgba(239, 68, 68, 0.12); --stop-ink: #F87171; --stop-glow: rgba(239, 68, 68, 0.20);
111
+ --info: #3B82F6;
112
+
113
+ --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.28);
114
+ --shadow: 0 8px 32px rgba(0, 0, 0, 0.34);
115
+ --shadow-lg: 0 18px 50px rgba(0, 0, 0, 0.42);
116
+
117
+ --grain-opacity: 0.012;
118
+ --aura-1: rgba(139, 92, 246, 0.14);
119
+ --mote: rgba(196, 181, 253, 0.50);
120
+ --aura-2: rgba(91, 33, 182, 0.09);
121
+ --aura-3: rgba(37, 99, 235, 0.05);
122
+
123
+ --card-grad: linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06));
124
+ --card-grad-hi: linear-gradient(rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.10));
125
+ --inset-hi: inset 0 1px 0 rgba(255, 255, 255, 0.10);
126
+
127
+ /* widget layer β€” consumed by theme.py::_BRIDGE_CSS */
128
+ --input-border: rgba(255, 255, 255, 0.09);
129
+ --input-focus: rgba(139, 92, 246, 0.58);
130
+ --menu-bg: rgba(9, 17, 31, 0.94);
131
+ --menu-border: rgba(255, 255, 255, 0.11);
132
+ --menu-shadow: 0 18px 50px rgba(0, 0, 0, 0.48);
133
+ --sidebar-bg: rgba(5, 10, 20, 0.40);
134
+ --nav-hover: rgba(255, 255, 255, 0.055);
135
+ --nav-active-text: #FFFFFF;
136
+
137
+ /* File drop target β€” deliberately stronger than --surface so the area is
138
+ unmistakable on the dark ground, and a clear violet state on drag-over. */
139
+ --dropzone-bg: rgba(255, 255, 255, 0.055);
140
+ --dropzone-border: rgba(255, 255, 255, 0.240);
141
+ --dropzone-bg-hover: rgba(139, 92, 246, 0.10);
142
+
143
+ /* ---- Artboard primitives -------------------------------------------
144
+ Lifted verbatim from the Aurora Glass design canvas (build-g.mjs) so
145
+ the components below match the artboards 1:1 rather than approximately. */
146
+ --gl-glass: rgba(255, 255, 255, 0.06);
147
+ --gl-glass-hi: rgba(255, 255, 255, 0.10);
148
+ --gl-glass-lo: rgba(255, 255, 255, 0.04);
149
+ --gl-stroke: rgba(255, 255, 255, 0.12);
150
+ --gl-stroke-soft: rgba(255, 255, 255, 0.07);
151
+ --gl-track: rgba(255, 255, 255, 0.08);
152
+ --gl-inner: inset 0 1px 0 rgba(255, 255, 255, 0.10);
153
+ --gl-lift: 0 14px 44px rgba(0, 0, 0, 0.48);
154
+ --gl-glow: 0 0 20px rgba(139, 92, 246, 0.25);
155
+ --gl-violet: #C4B5FD;
156
+ --gl-well: rgba(0, 0, 0, 0.24);
157
+ --gl-pad-card: 24px;
158
+ --gl-gutter: 24px;
159
+ }
160
+
161
+ /* ---------- LIGHT β€” the same language on paper ---------- */
162
  [data-theme="light"] {
163
+ --bg: #F3F5FA;
164
+ --bg-deep: #E9EDF5;
165
+ --bg-2: #FFFFFF;
166
+
167
+ --surface: rgba(255, 255, 255, 0.70);
168
+ --surface-hi: rgba(255, 255, 255, 0.92);
169
+ --surface-2: rgba(255, 255, 255, 0.86);
170
+ --surface-3: rgba(15, 23, 42, 0.045);
171
+ --surface-hover: rgba(15, 23, 42, 0.055);
172
+ --glass: rgba(255, 255, 255, 0.78);
173
+
174
+ --border-soft: rgba(15, 23, 42, 0.07);
175
+ --border: rgba(15, 23, 42, 0.12);
176
+ --border-2: rgba(15, 23, 42, 0.170);
177
+ --hairline: rgba(255, 255, 255, 0.90);
178
+
179
+ --text: #0F172A;
180
+ --text-muted: #475569;
181
+ --text-faint: #64748B;
182
+ --text-disabled: #94A3B8;
183
+
184
+ --accent: oklch(0.542 var(--accent-c) var(--accent-h));
185
+ --accent-2: oklch(0.580 0.225 var(--accent2-h));
186
+ --accent-press: oklch(0.470 var(--accent-c) var(--accent-h));
187
+ --accent-light: oklch(0.627 var(--accent-c) var(--accent-h));
188
+ --on-soft: oklch(0.470 var(--accent-c) var(--accent-h));
189
+ --accent-fg: #FFFFFF;
190
+ --accent-soft: oklch(0.627 var(--accent-c) var(--accent-h) / 0.10);
191
+ --accent-line: oklch(0.627 var(--accent-c) var(--accent-h) / 0.26);
192
+ --accent-glow: oklch(0.627 var(--accent-c) var(--accent-h) / 0.18);
193
+ --ring: oklch(0.627 var(--accent-c) var(--accent-h) / 0.12);
194
+ --focus-ring: oklch(0.542 var(--accent-c) var(--accent-h) / 0.78);
195
+ --accent-grad: linear-gradient(135deg,
196
+ oklch(0.542 var(--accent-c) var(--accent-h)),
197
+ oklch(0.580 0.240 var(--accent-h)) 55%,
198
+ oklch(0.620 0.225 var(--accent2-h)));
199
+
200
+ --go: #15803D; --go-soft: rgba(34, 197, 94, 0.13); --go-ink: #166534; --go-glow: rgba(34, 197, 94, 0.18);
201
+ --warn: #B45309; --warn-soft: rgba(245, 158, 11, 0.15); --warn-ink: #92400E; --warn-glow: rgba(245, 158, 11, 0.18);
202
+ --stop: #B91C1C; --stop-soft: rgba(239, 68, 68, 0.12); --stop-ink: #991B1B; --stop-glow: rgba(239, 68, 68, 0.16);
203
+ --info: #1D4ED8;
204
+
205
+ --shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.07);
206
+ --shadow: 0 8px 32px rgba(15, 23, 42, 0.10);
207
+ --shadow-lg: 0 18px 50px rgba(15, 23, 42, 0.14);
208
+
209
+ --grain-opacity: 0.010;
210
+ --aura-1: rgba(139, 92, 246, 0.10);
211
+ --mote: rgba(109, 40, 217, 0.22);
212
+ --aura-2: rgba(91, 33, 182, 0.05);
213
+ --aura-3: rgba(37, 99, 235, 0.04);
214
+
215
+ --card-grad: linear-gradient(rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.74));
216
+ --card-grad-hi: linear-gradient(rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.94));
217
+ --inset-hi: inset 0 1px 0 rgba(255, 255, 255, 0.90);
218
+
219
+ /* widget layer β€” consumed by theme.py::_BRIDGE_CSS */
220
+ --input-border: rgba(15, 23, 42, 0.10);
221
+ --input-focus: rgba(124, 58, 237, 0.55);
222
+ --menu-bg: rgba(255, 255, 255, 0.96);
223
+ --menu-border: rgba(15, 23, 42, 0.10);
224
+ --menu-shadow: 0 18px 50px rgba(15, 23, 42, 0.16);
225
+ --sidebar-bg: rgba(255, 255, 255, 0.62);
226
+ --nav-hover: rgba(15, 23, 42, 0.045);
227
+ --nav-active-text: #4C1D95;
228
+
229
+ /* File drop target β€” see the dark block. */
230
+ --dropzone-bg: rgba(15, 23, 42, 0.030);
231
+ --dropzone-border: rgba(15, 23, 42, 0.230);
232
+ --dropzone-bg-hover: rgba(124, 58, 237, 0.07);
233
+
234
+ /* ---- Artboard primitives β€” the same language on paper ---------------- */
235
+ --gl-glass: rgba(255, 255, 255, 0.74);
236
+ --gl-glass-hi: rgba(255, 255, 255, 0.94);
237
+ --gl-glass-lo: rgba(255, 255, 255, 0.52);
238
+ --gl-stroke: rgba(15, 23, 42, 0.12);
239
+ --gl-stroke-soft: rgba(15, 23, 42, 0.07);
240
+ --gl-track: rgba(15, 23, 42, 0.08);
241
+ --gl-inner: inset 0 1px 0 rgba(255, 255, 255, 0.90);
242
+ --gl-lift: 0 14px 44px rgba(15, 23, 42, 0.14);
243
+ --gl-glow: 0 0 20px rgba(124, 58, 237, 0.18);
244
+ --gl-violet: #6D28D9;
245
+ --gl-well: rgba(15, 23, 42, 0.04);
246
+ --gl-pad-card: 24px;
247
+ --gl-gutter: 24px;
248
+ }
249
+
250
+ /* ============================================================ base */
251
  * { box-sizing: border-box; }
252
+
253
  body {
 
254
  font-family: var(--ff-body);
 
255
  color: var(--text);
256
  -webkit-font-smoothing: antialiased;
257
  text-rendering: optimizeLegibility;
258
+ font-size: 14px;
259
  line-height: 1.5;
260
  }
261
+
262
+ /* Static atmospheric lighting lives on Streamlit's own app container (see the
263
+ widget-override section below) because that element paints an opaque
264
+ background of its own. Deliberately NOT animated β€” the glow stays soft and
265
+ stationary so content remains the dominant visual element.
266
+ The grain rides above as a fixed, click-through film at ~1% opacity. */
267
  body::after {
268
+ content: "";
269
+ position: fixed; inset: 0; z-index: 9999; pointer-events: none;
270
+ background-image: var(--grain); background-size: 160px;
271
+ opacity: var(--grain-opacity);
272
  mix-blend-mode: overlay;
273
  }
274
+
275
+ h1, h2, h3, h4 {
276
+ font-family: var(--ff-display);
277
+ font-weight: 600;
278
+ letter-spacing: -0.025em;
279
+ margin: 0;
280
+ }
281
  button { font-family: inherit; }
282
+ ::selection { background: var(--accent-soft); color: var(--on-soft); }
 
283
 
284
+ /* Visible focus everywhere β€” WCAG AA, never removed for glass. */
285
+ :focus-visible {
286
+ outline: 2px solid var(--focus-ring);
287
+ outline-offset: 2px;
288
+ border-radius: var(--radius-xs);
289
+ }
290
 
291
+ .mono { font-family: var(--ff-mono); font-variant-numeric: tabular-nums; }
292
+
293
+ /* ============================================================ screen header
294
+ Emitted by theme.py::screen_head β€” .screen-head / .eyebrow /
295
+ .screen-title / .screen-sub */
296
+ .screen-head { margin-bottom: var(--section-gap); }
297
+
298
+ .eyebrow {
299
+ display: inline-flex; align-items: center; gap: 8px;
300
+ font-family: var(--ff-mono);
301
+ font-size: 12px; font-weight: 500;
302
+ letter-spacing: 0.14em; text-transform: uppercase;
303
+ color: var(--accent-light);
304
+ padding: 5px 12px 5px 10px;
305
+ border-radius: var(--radius-pill);
306
+ border: 1px solid var(--accent-line);
307
+ background: var(--accent-soft);
308
+ margin-bottom: 14px;
309
+ }
310
+ .eyebrow::before {
311
+ content: ""; width: 6px; height: 6px; border-radius: 50%;
312
+ background: var(--accent);
313
+ }
314
+
315
+ .screen-title {
316
+ font-size: 40px; font-weight: 600; line-height: 1.2;
317
+ letter-spacing: -0.021em; color: var(--text);
318
+ margin: 0;
319
  }
320
+ .screen-sub {
321
+ color: var(--text-muted);
322
+ font-size: 14px; line-height: 1.5;
323
+ margin: 10px 0 0; max-width: 64ch;
324
+ text-wrap: pretty;
325
  }
326
+ .screen-sub b { color: var(--text); font-weight: 600; }
327
 
328
+ /* ============================================================ glass surfaces
329
+ .card / .card-pad are emitted by the screens; Streamlit's own
330
+ st.container(border=True) is matched to these in theme.py. */
331
+ .card {
 
 
332
  position: relative;
333
+ background: var(--card-grad);
334
+ border: 1px solid var(--border);
335
+ border-radius: var(--radius);
336
+ box-shadow: var(--shadow), var(--inset-hi);
337
+ backdrop-filter: blur(20px);
338
+ -webkit-backdrop-filter: blur(20px);
339
+ transition: transform var(--t-base) var(--ease),
340
+ border-color var(--t-base) var(--ease),
341
+ background var(--t-base) var(--ease);
342
+ }
343
+ .card + .card { margin-top: var(--gap); }
344
+ .card-pad { padding: 20px; }
345
+
346
+ .twocol {
347
+ display: grid;
348
+ grid-template-columns: repeat(2, minmax(0, 1fr));
349
+ gap: var(--gap);
350
+ align-items: start;
351
+ }
352
+
353
+ /* ============================================================ evidence lists
354
+ .panel-title / .pico / .checkitem / .ci-ico β€” analysis screen */
355
+ .panel-title {
356
+ display: flex; align-items: center; gap: 10px;
357
+ font-family: var(--ff-display);
358
+ font-size: 16px; font-weight: 600; line-height: 1.35;
359
+ color: var(--text);
360
+ margin-bottom: 6px;
361
  }
362
+ .panel-title .pico {
363
+ width: 26px; height: 26px; flex: none;
364
+ border-radius: var(--radius-xs);
365
+ display: inline-grid; place-items: center;
366
+ font-size: 13px; line-height: 1;
367
+ }
368
+ .pico.pos { background: var(--go-soft); color: var(--go-ink); }
369
+ .pico.neg { background: var(--warn-soft); color: var(--warn-ink); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
+ .checkitem {
372
+ display: grid; grid-template-columns: 18px 1fr;
373
+ gap: 12px; padding: 12px 0; align-items: start;
374
+ }
375
+ .checkitem + .checkitem { border-top: 1px solid var(--border-soft); }
376
+ .checkitem .ci-ico { margin-top: 1px; font-size: 13px; line-height: 1.3; }
377
+ .checkitem.pos .ci-ico { color: var(--go); }
378
+ .checkitem.neg .ci-ico { color: var(--warn); }
379
+ .checkitem b { font-size: 14px; font-weight: 600; color: var(--text); }
380
+ .checkitem p {
381
+ font-size: 13px; line-height: 1.5;
382
+ color: var(--text-muted); margin: 4px 0 0;
383
+ text-wrap: pretty;
384
+ }
385
 
386
+ /* ============================================================ verdict banner
387
+ Emitted by theme.py::verdict_banner β€” .verdict.go / .warn / .stop */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  .verdict {
389
+ position: relative; overflow: hidden;
390
+ border-radius: var(--radius-lg);
391
+ padding: 24px 26px;
392
+ border: 1px solid var(--border);
393
+ background: var(--card-grad);
394
+ box-shadow: var(--shadow-lg), var(--inset-hi);
395
+ backdrop-filter: blur(20px);
396
+ -webkit-backdrop-filter: blur(20px);
397
  }
398
  .verdict::before {
399
  content: ""; position: absolute; inset: 0; pointer-events: none;
400
+ background: radial-gradient(80% 130% at 0% 0%, var(--g-aura), transparent 58%);
 
401
  }
402
  .verdict.go { --g-aura: var(--go-glow); }
403
  .verdict.warn { --g-aura: var(--warn-glow); }
404
  .verdict.stop { --g-aura: var(--stop-glow); }
405
+ .verdict > * { position: relative; }
406
+
407
  .verdict-badge {
408
+ display: inline-flex; align-items: center; gap: 8px;
409
+ padding: 6px 13px;
410
+ border-radius: var(--radius-pill);
411
+ font-family: var(--ff-mono);
412
+ font-size: 12px; font-weight: 500;
413
+ letter-spacing: 0.10em; text-transform: uppercase;
414
+ border: 1px solid var(--border);
415
+ background: var(--surface-2);
416
+ width: fit-content;
417
+ }
418
+ .verdict.go .verdict-badge { color: var(--go-ink); }
419
  .verdict.warn .verdict-badge { color: var(--warn-ink); }
420
  .verdict.stop .verdict-badge { color: var(--stop-ink); }
421
+ .verdict-badge .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
422
+ .verdict.go .dot { background: var(--go); }
423
+ .verdict.warn .dot { background: var(--warn); }
424
+ .verdict.stop .dot { background: var(--stop); }
425
+
426
+ .verdict h2 {
427
+ font-size: 26px; font-weight: 600; line-height: 1.25;
428
+ letter-spacing: -0.02em; color: var(--text);
429
+ margin: 14px 0 8px;
430
+ }
431
+ .verdict p {
432
+ color: var(--text-muted);
433
+ font-size: 14px; line-height: 1.5;
434
+ max-width: 60ch; margin: 0;
435
+ text-wrap: pretty;
436
+ }
437
+
438
+ /* ============================================================ proposal
439
+ Emitted by theme.py::proposal_doc β€” .doc-toolbar / .wordcount /
440
+ .toolbar-spacer / .proposal-doc / .evidence-note */
441
+ .doc-toolbar {
442
+ display: flex; align-items: center; gap: 10px;
443
+ flex-wrap: wrap; margin-bottom: 12px;
444
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  .toolbar-spacer { flex: 1; }
446
+ .wordcount {
447
+ display: inline-flex; align-items: center; gap: 6px;
448
+ height: 26px; padding: 0 10px;
449
+ border-radius: var(--radius-pill);
450
+ border: 1px solid var(--gl-stroke);
451
+ background: var(--gl-glass-lo);
452
+ font-size: 12px; font-weight: 500;
453
+ color: var(--text-muted);
454
+ font-variant-numeric: tabular-nums;
455
+ white-space: nowrap;
456
+ }
457
+
458
+ .proposal-doc {
459
+ position: relative; overflow: hidden;
460
+ background: var(--card-grad);
461
+ border: 1px solid var(--border);
462
+ border-radius: var(--radius);
463
+ box-shadow: var(--shadow), var(--inset-hi);
464
+ backdrop-filter: blur(20px);
465
+ -webkit-backdrop-filter: blur(20px);
466
+ padding: 32px 36px;
467
+ }
468
+ .proposal-doc::before {
469
+ content: ""; position: absolute;
470
+ left: 0; top: 20px; bottom: 20px; width: 2px;
471
+ border-radius: 2px;
472
+ background: linear-gradient(var(--accent), var(--accent-2));
473
+ }
474
+ .proposal-doc p {
475
+ position: relative;
476
+ margin: 0 0 14px;
477
+ font-size: 14px; line-height: 1.5;
478
+ color: var(--text);
479
+ text-wrap: pretty;
480
+ }
481
  .proposal-doc p:last-child { margin-bottom: 0; }
482
+
483
+ /* The one place gold appears β€” the grounding guarantee. */
484
+ .evidence-note {
485
+ display: inline-flex; align-items: center; gap: 8px;
486
+ margin-top: 18px;
487
+ font-size: 12px; line-height: 1.4;
488
+ color: var(--gold);
489
+ padding: 6px 12px;
490
+ border-radius: var(--radius-pill);
491
+ background: rgba(200, 169, 107, 0.08);
492
+ border: 1px solid rgba(200, 169, 107, 0.24);
493
+ }
494
+ .evidence-note::before {
495
+ content: ""; width: 5px; height: 5px; border-radius: 50%;
496
+ background: var(--gold); flex: none;
497
+ }
498
+
499
+ /* ============================================================ legacy chips
500
+ .ups-chip / .ups-section / .ups-side-title are emitted by theme.py.
501
+ Definitions also exist in the bridge CSS; kept here so the sheet is
502
+ self-contained if the bridge is ever loaded on its own. */
503
+ .ups-chip {
504
+ display: inline-flex; align-items: center;
505
+ padding: 5px 12px;
506
+ border-radius: var(--radius-pill);
507
+ font-family: var(--ff-mono);
508
+ font-size: 12px; font-weight: 500;
509
+ letter-spacing: 0.02em;
510
+ white-space: nowrap;
511
+ }
512
+ .ups-chip-ready { background: var(--go-soft); color: var(--go-ink); border: 1px solid rgba(34, 197, 94, 0.28); }
513
+ .ups-chip-info { background: var(--accent-soft); color: var(--on-soft); border: 1px solid var(--accent-line); }
514
+ .ups-chip-missing { background: var(--stop-soft); color: var(--stop-ink); border: 1px solid rgba(239, 68, 68, 0.28); }
515
+ .ups-chip-neutral { background: var(--warn-soft); color: var(--warn-ink); border: 1px solid rgba(245, 158, 11, 0.28); }
516
+
517
+ /* The panel title. On the canvas every panel is headed by .ct β€” 18px, semi
518
+ bold, sentence case β€” not by a small tracked label, so .ups-section (which
519
+ 21 call sites already use) becomes that. */
520
+ .ups-section {
521
+ font-family: var(--ff-display);
522
+ font-size: 18px; font-weight: 600;
523
+ letter-spacing: -0.008em; line-height: 1.3;
524
+ color: var(--text);
525
+ margin: 0 0 18px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  }
527
+ .ups-side-title {
528
+ font-family: var(--ff-mono);
529
+ font-size: 12px; font-weight: 500;
530
+ letter-spacing: 0.14em; text-transform: uppercase;
531
+ color: var(--text-faint);
532
+ margin: 0 0 10px;
533
  }
534
 
535
+ /* ============================================================ Aurora components
536
+ The design canvas's own component vocabulary, ported one-for-one. Sizes,
537
+ weights, radii, strokes and shadows are lifted verbatim from the artboard
538
+ build (build-g.mjs) and expressed against the theme tokens above, so the
539
+ same components hold in light mode.
540
+
541
+ Everything here is presentational and rendered by theme.py helpers into
542
+ markdown blocks. No rule in this section styles, wraps or positions a
543
+ Streamlit control β€” the widget layer lives in theme.py::_BRIDGE_CSS. */
544
+
545
+ /* icons β€” 24px view box, 2px stroke, currentColor, sized by CSS only */
546
+ .ups-ico { display: inline-flex; align-items: center; justify-content: center; flex: none; }
547
+ .ups-ico svg {
548
+ width: var(--ico, 18px); height: var(--ico, 18px); display: block;
549
+ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
550
  }
551
+
552
+ /* the glass primitive every surface is built from */
553
+ .ups-glass {
554
+ position: relative;
555
+ background: var(--gl-glass);
556
+ backdrop-filter: blur(20px) saturate(1.3);
557
+ -webkit-backdrop-filter: blur(20px) saturate(1.3);
558
+ border: 1px solid var(--gl-stroke);
559
+ border-radius: var(--radius);
560
+ box-shadow: var(--shadow), var(--gl-inner);
561
+ }
562
+
563
+ /* type scale */
564
+ .ups-h1 { font-size: 40px; line-height: 1.2; font-weight: 600; letter-spacing: -0.021em; margin: 0; }
565
+ .ups-h2 { font-size: 28px; line-height: 1.25; font-weight: 600; letter-spacing: -0.016em; margin: 0; }
566
+ .ups-ct { font-size: 18px; line-height: 1.3; font-weight: 600; letter-spacing: -0.008em; }
567
+ .ups-cap { font-size: 12px; line-height: 1.4; color: var(--text-muted); }
568
+ .ups-t2 { color: var(--text-muted); }
569
+ .ups-num { font-variant-numeric: tabular-nums; letter-spacing: -0.015em; }
570
+ .ups-sp { flex: 1; }
571
+ .ups-flow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
572
+ .ups-grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gl-gutter); align-items: start; }
573
+ .ups-grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gl-gutter); align-items: start; }
574
+
575
+ /* ---- top bar: breadcrumb, segmented step rail, step caption ---------- */
576
+ .ups-top {
577
+ display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
578
+ min-height: 56px; padding: 0 2px 16px; margin: 0 0 20px;
579
+ border-bottom: 1px solid var(--gl-stroke-soft);
580
+ }
581
+ .ups-crumb {
582
+ font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase;
583
+ color: var(--text-muted); white-space: nowrap;
584
+ }
585
+ .ups-crumb b { color: var(--text); font-weight: 600; }
586
+ .ups-steps { display: flex; align-items: center; gap: 6px; }
587
+ .ups-seg-i {
588
+ width: 28px; height: 4px; border-radius: 2px; background: var(--gl-track);
589
+ transition: background var(--t-slow) ease-out, box-shadow var(--t-slow) ease-out;
590
+ }
591
+ .ups-seg-i.is-done { background: oklch(0.627 var(--accent-c) var(--accent-h) / 0.5); }
592
+ .ups-seg-i.is-now { background: var(--accent); box-shadow: var(--gl-glow); }
593
+
594
+ /* ---- sidebar chrome: brand, rail cap, engine card -------------------- */
595
+ .ups-brand { display: flex; align-items: center; gap: 11px; padding: 2px 4px 18px; }
596
+ .ups-brand-mark {
597
+ width: 32px; height: 32px; border-radius: var(--radius-input); flex: none;
598
+ display: grid; place-items: center; color: #fff;
599
+ background: var(--accent-grad); box-shadow: var(--gl-glow), var(--gl-inner);
600
+ }
601
+ .ups-brand-mark .ups-ico svg { width: 17px; height: 17px; }
602
+ .ups-brand-t { font-weight: 700; font-size: 13px; letter-spacing: 0.09em; text-transform: uppercase; line-height: 1.35; color: var(--text); }
603
+ .ups-brand-tag {
604
+ font-size: 11px; line-height: 1.45; color: var(--text-muted);
605
+ margin: -10px 0 16px; padding: 0 4px; text-wrap: pretty;
606
+ }
607
+ .ups-brand-s { font-size: 9.5px; letter-spacing: 0.15em; color: var(--text-muted); margin-top: 3px; display: block; }
608
+ .ups-rail-cap {
609
+ font-size: 10.5px; letter-spacing: 0.13em; color: var(--text-muted);
610
+ padding: 0 6px 6px; text-transform: uppercase; margin: 0;
611
+ }
612
+ .ups-engine {
613
+ padding: 16px; border-radius: var(--radius); background: var(--gl-glass);
614
+ border: 1px solid var(--gl-stroke); box-shadow: var(--gl-inner); margin: 10px 0 0;
615
+ }
616
+ .ups-engine .k { font-size: 10.5px; letter-spacing: 0.12em; color: var(--text-muted); text-transform: uppercase; }
617
+ .ups-engine .v { font-weight: 600; font-size: 13.5px; margin-top: 8px; overflow-wrap: anywhere; color: var(--text); }
618
+ .ups-engine .s { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); margin-top: 8px; }
619
+ .ups-dot {
620
+ width: 7px; height: 7px; border-radius: 50%; flex: none; display: inline-block; position: relative;
621
+ background: var(--go); box-shadow: 0 0 0 3px var(--go-glow);
622
+ }
623
+ .ups-dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-glow); }
624
+ .ups-dot.stop { background: var(--stop); box-shadow: 0 0 0 3px var(--stop-glow); }
625
+
626
+ /* ---- panel ----------------------------------------------------------- */
627
+ .ups-panel { padding: var(--gl-pad-card); border-radius: var(--radius); overflow: hidden; }
628
+ .ups-panel-h { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
629
+
630
+ /* ---- KPI tiles ------------------------------------------------------- */
631
+ .ups-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--gl-gutter); }
632
+ .ups-kpis.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
633
+ .ups-kpis.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
634
+ .ups-kpi {
635
+ padding: 20px; border-radius: var(--radius); overflow: hidden; text-align: left;
636
+ transition: transform var(--t-fast) ease-out, box-shadow var(--t-fast) ease-out,
637
+ background var(--t-fast) ease-out, border-color var(--t-fast) ease-out;
638
+ }
639
+ .ups-kpi:hover { background: var(--gl-glass-hi); transform: translateY(-3px); box-shadow: var(--gl-lift), var(--gl-inner); }
640
+ .ups-kpi.on { border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.5); box-shadow: var(--gl-glow), var(--shadow), var(--gl-inner); }
641
+ .ups-kpi-t { display: flex; align-items: flex-start; gap: 12px; }
642
+ .ups-kpi .lab { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
643
+ .ups-kpi .val { font-weight: 600; font-size: 28px; line-height: 1.25; letter-spacing: -0.02em; margin-top: 6px; display: block; color: var(--text); }
644
+ .ups-kpi .delta { font-size: 12px; margin-top: 8px; color: var(--go); display: block; }
645
+ .ups-kpi .delta.warn { color: var(--warn); }
646
+ .ups-kpi .delta.t2 { color: var(--text-muted); }
647
+ .ups-kpi .delta.stop { color: var(--stop); }
648
+ .ups-kpi .ic {
649
+ width: 36px; height: 36px; border-radius: var(--radius-input); flex: none; margin-left: auto;
650
+ background: var(--gl-glass-hi); border: 1px solid var(--gl-stroke);
651
+ display: grid; place-items: center; color: var(--gl-violet);
652
+ box-shadow: var(--gl-inner); font-size: 13px; font-weight: 600;
653
+ }
654
+ .ups-kpi .ic .ups-ico svg { width: 17px; height: 17px; }
655
+
656
+ /* ---- data rows ------------------------------------------------------- */
657
+ .ups-thead {
658
+ display: grid; gap: 14px; padding: 0 14px 10px;
659
+ font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
660
+ color: var(--text-muted); border-bottom: 1px solid var(--gl-stroke-soft);
661
+ }
662
+ .ups-row {
663
+ position: relative; display: grid; align-items: center; gap: 14px;
664
+ padding: 12px 14px; border-radius: var(--radius-input);
665
+ border: 1px solid transparent; width: 100%; text-align: left;
666
+ transition: background var(--t-fast) ease-out, border-color var(--t-fast) ease-out;
667
+ }
668
+ .ups-row::before {
669
+ content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 2px;
670
+ border-radius: 2px; background: var(--accent); pointer-events: none;
671
+ transform: scaleY(0); opacity: 0;
672
+ transition: transform 240ms var(--ease), opacity 240ms var(--ease);
673
+ }
674
+ .ups-row:hover::before, .ups-row.on::before { transform: scaleY(1); opacity: 1; }
675
+ .ups-row:hover { background: var(--gl-glass-hi); }
676
+ .ups-row.on { background: oklch(0.627 var(--accent-c) var(--accent-h) / 0.12); border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.3); }
677
+ .ups-row b { font-weight: 600; font-size: 14px; }
678
+ .ups-row .sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; display: block; }
679
+ .ups-tile {
680
+ width: 34px; height: 34px; border-radius: var(--radius-xs); flex: none;
681
+ background: var(--gl-glass-hi); border: 1px solid var(--gl-stroke);
682
+ display: grid; place-items: center; font-size: 10px; font-weight: 600;
683
+ color: var(--gl-violet); box-shadow: var(--gl-inner);
684
+ }
685
+ .ups-tile .ups-ico svg { width: 16px; height: 16px; }
686
+ .ups-hr { height: 1px; background: var(--gl-stroke-soft); }
687
+
688
+ /* ---- read-only field (mirrors the widget layer's input styling) ------ */
689
+ .ups-field {
690
+ min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 0 14px;
691
+ border-radius: var(--radius); background: var(--gl-glass-lo);
692
+ border: 1px solid var(--gl-stroke-soft); font-size: 14px;
693
+ transition: border-color var(--t-fast) ease-out, box-shadow var(--t-fast) ease-out, background var(--t-fast) ease-out;
694
+ }
695
+ .ups-field:hover { background: var(--gl-glass-hi); }
696
+ .ups-field.on { border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.5); box-shadow: var(--gl-glow); background: var(--gl-glass); }
697
+ .ups-field .ph { color: var(--text-muted); }
698
+ .ups-field .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
699
+
700
+ /* ---- chips ----------------------------------------------------------- */
701
+ .ups-tag {
702
+ display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px;
703
+ border-radius: var(--radius-pill); font-size: 12px; font-weight: 500;
704
+ border: 1px solid var(--gl-stroke); background: var(--gl-glass-lo);
705
+ color: var(--text-muted); white-space: nowrap;
706
+ }
707
+ .ups-tag .ups-ico svg { width: 12px; height: 12px; }
708
+ .ups-tag.ok { color: var(--go); border-color: rgba(34, 197, 94, 0.32); background: var(--go-soft); }
709
+ .ups-tag.warn { color: var(--warn); border-color: rgba(245, 158, 11, 0.32); background: var(--warn-soft); }
710
+ .ups-tag.stop { color: var(--stop); border-color: rgba(239, 68, 68, 0.32); background: var(--stop-soft); }
711
+ .ups-tag.violet { color: var(--gl-violet); border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.38); background: oklch(0.627 var(--accent-c) var(--accent-h) / 0.14); }
712
+
713
+ /* ---- meters ---------------------------------------------------------- */
714
+ .ups-meter { height: 6px; border-radius: 3px; background: var(--gl-track); overflow: hidden; }
715
+ .ups-meter i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent), var(--accent-light)); box-shadow: var(--gl-glow); }
716
+ .ups-meter.ok i { background: linear-gradient(90deg, #22C55E, #4ADE80); box-shadow: 0 0 20px rgba(34, 197, 94, 0.25); }
717
+ .ups-meter.warn i { background: linear-gradient(90deg, #E0A34A, #F0C078); box-shadow: 0 0 20px rgba(224, 163, 74, 0.25); }
718
+ .ups-meter.stop i { background: linear-gradient(90deg, #F1566E, #FB7A8E); box-shadow: 0 0 20px rgba(241, 86, 110, 0.25); }
719
+
720
+ /* ---- score ring ------------------------------------------------------ */
721
+ .ups-ring { position: relative; display: grid; place-items: center; width: 168px; height: 168px; }
722
+ .ups-ring svg { width: 168px; height: 168px; transform: rotate(-90deg); overflow: visible; }
723
+ .ups-ring .tr { fill: none; stroke: var(--gl-stroke-soft); stroke-width: 10; }
724
+ .ups-ring .bar { fill: none; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 400ms ease-out; }
725
+ .ups-ring .comet { transform-box: view-box; transform-origin: 84px 84px; }
726
+ .ups-ring .comet circle { filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.95)) drop-shadow(0 0 16px rgba(139, 92, 246, 0.9)); }
727
+ .ups-ring .val { position: absolute; text-align: center; }
728
+ .ups-ring .val b { display: block; font-weight: 600; font-size: 44px; line-height: 1; letter-spacing: -0.03em; color: var(--text); }
729
+ .ups-ring .val span { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); }
730
+
731
+ /* ---- key/value grid --------------------------------------------------- */
732
+ .ups-kv {
733
+ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
734
+ background: var(--gl-stroke-soft); border: 1px solid var(--gl-stroke-soft);
735
+ border-radius: var(--radius); overflow: hidden;
736
+ }
737
+ .ups-kv.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
738
+ .ups-kvc { background: var(--gl-glass-lo); padding: 14px 16px; }
739
+ .ups-kvc .k { font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
740
+ .ups-kvc .v { font-size: 13.5px; font-weight: 500; margin-top: 6px; color: var(--text); overflow-wrap: anywhere; }
741
+ .ups-kvc .v.dim { color: var(--text-muted); font-style: italic; font-weight: 400; }
742
+
743
+ /* ---- console log ------------------------------------------------------ */
744
+ .ups-log {
745
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.9;
746
+ color: var(--text-muted); background: var(--gl-well);
747
+ border: 1px solid var(--gl-stroke-soft); border-radius: var(--radius-input); padding: 14px 16px;
748
+ }
749
+ .ups-log .ok { color: var(--go); }
750
+ .ups-log .ac { color: var(--gl-violet); }
751
+
752
+ /* ---- evidence item + citation ----------------------------------------- */
753
+ .ups-ev { padding: 14px 0; border-bottom: 1px solid var(--gl-stroke-soft); }
754
+ .ups-ev:last-child { border-bottom: none; }
755
+ .ups-ev b { font-size: 14px; font-weight: 600; display: block; color: var(--text); }
756
+ .ups-ev p { margin: 5px 0 0; font-size: 13px; line-height: 1.5; color: var(--text-muted); text-wrap: pretty; }
757
+ .ups-cite {
758
+ display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted);
759
+ margin-top: 8px; padding: 4px 9px; border-radius: var(--radius-pill);
760
+ background: var(--gl-glass-lo); border: 1px solid var(--gl-stroke-soft);
761
+ }
762
+ .ups-cite .ups-ico svg { width: 11px; height: 11px; color: var(--gl-violet); }
763
+
764
+ /* Narrow viewports: the artboard is drawn at 1440. Collapse the multi-column
765
+ grids rather than letting them squeeze below a readable measure. */
766
+ @media (max-width: 900px) {
767
+ .ups-kpis, .ups-kpis.three, .ups-kpis.two,
768
+ .ups-grid2, .ups-grid3 { grid-template-columns: minmax(0, 1fr); }
769
+ .ups-kv, .ups-kv.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
770
+ .ups-h1 { font-size: 30px; }
771
+ }
772
+
773
+ /* ============================================================ motion
774
+ The app should feel alive without ever getting in the way. Ambient loops
775
+ are slow (3s+) and never interactive; interaction feedback is fast
776
+ (120-240ms). Everything is transform / opacity / background only β€” no
777
+ rule here changes layout, hit-testing or stacking on anything a person
778
+ can click. The whole section is off under prefers-reduced-motion, and
779
+ entrance delays are kept short so a stalled animation can never leave
780
+ content stranded invisible. */
781
+ @media (prefers-reduced-motion: no-preference) {
782
+ @keyframes auroraIn { from { opacity: 0; transform: translateY(12px) scale(0.994); } }
783
+ @keyframes softIn { from { opacity: 0; transform: translateY(10px); filter: blur(6px); } }
784
+ @keyframes railDraw { from { transform: scaleY(0); } }
785
+ @keyframes lightPass { 0% { transform: translateX(-130%) skewX(-14deg); }
786
+ 100% { transform: translateX(260%) skewX(-14deg); } }
787
+ @keyframes breathe { 0%, 100% { opacity: 0.5; transform: scale(1); }
788
+ 50% { opacity: 1; transform: scale(1.28); } }
789
+
790
+ /* Content arrives rather than appearing. */
791
+ .screen-head, .card, .verdict, .proposal-doc { animation: auroraIn 460ms var(--ease) backwards; }
792
+ .card:nth-of-type(2) { animation-delay: 60ms; }
793
+ .card:nth-of-type(3) { animation-delay: 120ms; }
794
+
795
+ /* The screen title resolves out of a soft blur β€” the one filmic moment. */
796
+ .screen-title { animation: softIn 620ms var(--ease) 50ms backwards; }
797
+ .screen-sub { animation: softIn 620ms var(--ease) 120ms backwards; }
798
+
799
+ /* Evidence rows cascade. Delays stay under 260ms on purpose. */
800
+ .checkitem { animation: auroraIn 420ms var(--ease) backwards; }
801
+ .checkitem:nth-child(2) { animation-delay: 150ms; }
802
+ .checkitem:nth-child(3) { animation-delay: 190ms; }
803
+ .checkitem:nth-child(4) { animation-delay: 225ms; }
804
+ .checkitem:nth-child(n+5) { animation-delay: 260ms; }
805
+
806
+ /* The eyebrow dot is the app's quiet heartbeat β€” present on every screen. */
807
+ .eyebrow::before { animation: breathe 3.2s var(--ease) infinite; }
808
+
809
+ /* The accent rail on the proposal draws itself down the page. */
810
+ .proposal-doc::before { transform-origin: top; animation: railDraw 760ms var(--ease) 200ms backwards; }
811
+
812
+ /* The verdict is the moment this whole product exists for. It gets one
813
+ slow pass of light, and it is the only surface that does. */
814
+ .verdict > * { z-index: 1; }
815
+ .verdict::after {
816
+ content: ""; position: absolute; z-index: 0; pointer-events: none;
817
+ top: 0; bottom: 0; left: 0; width: 42%;
818
+ background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.07) 48%, transparent);
819
+ animation: lightPass 7.5s var(--ease) 700ms infinite;
820
+ }
821
+
822
+ /* ── Ambient loops ───────────────────────────────────────────────────
823
+ An entrance plays once. Half a second after a screen loads, everything
824
+ above has finished and the page is a photograph. Everything below runs
825
+ forever, so every screen still moves while it is being read. All of it
826
+ is paint only β€” transform and opacity on pointer-events:none pseudo
827
+ elements β€” so none of it touches layout, hit-testing or stacking on
828
+ anything a person clicks, types into or drops a file onto. */
829
+ @keyframes cardPass { 0% { transform: translateX(-140%) skewX(-14deg); }
830
+ 44% { transform: translateX(300%) skewX(-14deg); }
831
+ 100% { transform: translateX(300%) skewX(-14deg); } }
832
+ @keyframes railGlow { 0%, 100% { opacity: 0.6; }
833
+ 50% { opacity: 1; } }
834
+ @keyframes noteGlow { 0%, 100% { opacity: 0.55; }
835
+ 50% { opacity: 0.95; } }
836
+
837
+ /* Every card takes the same slow pass of light the verdict does, at a
838
+ third of the strength and out of phase, so no two catch it together. */
839
+ .card { overflow: hidden; }
840
+ .card > * { position: relative; z-index: 1; }
841
+ .card::after {
842
+ content: ""; position: absolute; z-index: 0; pointer-events: none;
843
+ top: 0; bottom: 0; left: 0; width: 38%;
844
+ background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.05) 50%, transparent);
845
+ animation: cardPass 9s var(--ease) infinite;
846
+ }
847
+ .card:nth-of-type(2)::after { animation-delay: 2.3s; }
848
+ .card:nth-of-type(3)::after { animation-delay: 4.6s; }
849
+ .card:nth-of-type(n+4)::after { animation-delay: 6.4s; }
850
+
851
+ /* The rail draws itself in, then keeps a slow pulse so the proposal page
852
+ is never completely still either. */
853
+ .proposal-doc::before { animation: railDraw 760ms var(--ease) 200ms backwards,
854
+ railGlow 5.2s var(--ease) 960ms infinite; }
855
+ .evidence-note::before { animation: noteGlow 4.6s var(--ease) infinite; }
856
+
857
+ .card:hover { transform: translateY(-2px); border-color: var(--border-2); background: var(--card-grad-hi); }
858
  }
 
 
 
 
859
 
860
  /* ============================================================
861
  === STREAMLIT WIDGET OVERRIDES ===
862
+ The authoritative widget layer lives in app/ui/theme.py::_BRIDGE_CSS
863
+ (injected after this sheet, with !important). These rules mirror it so
864
+ the sheet stands alone and the two never disagree.
865
  ============================================================ */
866
 
867
+ [data-testid="stAppViewContainer"] {
868
+ background:
869
+ radial-gradient(circle at 63% 18%, var(--aura-1), transparent 30%),
870
+ radial-gradient(circle at 82% 42%, var(--aura-2), transparent 34%),
871
+ radial-gradient(circle at 35% 72%, var(--aura-3), transparent 28%),
872
+ var(--bg);
873
+ background-attachment: fixed;
874
+ }
875
  [data-testid="stHeader"] { background: transparent; }
876
  .block-container { padding-top: 2.2rem; max-width: 960px; }
877
 
 
878
  section[data-testid="stSidebar"] {
879
+ background: rgba(5, 10, 20, 0.40);
880
+ border-right: 1px solid var(--border-soft);
881
  }
882
  section[data-testid="stSidebar"] .block-container { padding-top: 1.4rem; }
883
 
 
884
  .stButton > button {
885
+ font-family: var(--ff-body);
886
+ font-weight: 500;
887
+ font-size: 14px;
888
+ border-radius: var(--radius-btn);
889
+ height: 44px;
890
+ border: none;
891
+ color: var(--accent-fg);
892
+ background: var(--accent-grad);
893
+ box-shadow: 0 8px 24px var(--accent-glow);
894
+ transition: transform var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
895
+ }
896
+ .stButton > button:hover { transform: translateY(-1px); }
897
+ .stButton > button:active { transform: scale(0.98); }
898
  .stButton > button[kind="secondary"] {
899
+ background: var(--surface);
900
+ color: var(--text);
901
+ border: 1px solid var(--border);
902
+ box-shadow: none;
903
  }
904
 
 
905
  .stTextInput input, .stTextArea textarea {
906
+ background: var(--surface);
907
+ border: 1px solid rgba(255, 255, 255, 0.09);
908
+ border-radius: var(--radius-input);
909
+ color: var(--text);
910
+ font-family: var(--ff-body);
911
  }
912
  .stTextInput input:focus, .stTextArea textarea:focus {
913
+ border-color: rgba(139, 92, 246, 0.58);
914
+ box-shadow: 0 0 0 3px var(--ring);
915
  }
916
 
 
917
  [data-testid="stFileUploaderDropzone"] {
918
+ background: var(--dropzone-bg);
919
+ border: 1.5px dashed var(--dropzone-border);
920
+ border-radius: var(--radius);
921
+ color: var(--text-muted);
922
+ }
923
+ [data-testid="stFileUploaderDropzone"]:hover {
924
+ border-color: var(--accent);
925
+ background: var(--dropzone-bg-hover);
926
  }
 
927
 
928
+ h1, h2, h3 { font-family: var(--ff-display) !important; letter-spacing: -0.025em; }
 
929
 
930
+ .stTabs [aria-selected="true"] { color: var(--accent-light) !important; }
931
+ [data-baseweb="radio"] [aria-checked="true"] div:first-child {
932
+ border-color: var(--accent) !important;
933
+ background: var(--accent) !important;
934
+ }
935
 
 
936
  .stProgress > div > div > div > div {
937
  background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important;
938
  }
939
 
 
940
  #MainMenu { visibility: hidden; }
941
  footer { visibility: hidden; }
app/assets/vendor/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored third-party assets
2
+
3
+ ## motion.umd.js
4
+
5
+ Motion β€” 13.1.1, MIT. https://github.com/motiondivision/motion
6
+
7
+ The package's own standalone browser build (`dist/motion.js` from the npm
8
+ tarball `motion-13.1.1.tgz`), copied verbatim. It is a UMD bundle that
9
+ defines `window.Motion` and has no imports, so it runs from a plain <script>
10
+ with no bundler, no CDN and no network β€” which matters because this app also
11
+ ships as an offline desktop launcher.
12
+
13
+ Loaded by app/ui/motion_runtime.py, which injects it into the parent document
14
+ once per page load. Do not edit it; to upgrade, replace it with a newer
15
+ `dist/motion.js` and update the version above.
16
+
17
+ The licence is beside it in motion-LICENSE.md.
app/assets/vendor/motion-LICENSE.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 [Motion](https://motion.dev) B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/assets/vendor/motion.umd.js ADDED
The diff for this file is too large to render. See raw diff
 
app/main.py CHANGED
@@ -31,7 +31,7 @@ from app.ui import (
31
  analysis_screen,
32
  proposal_screen,
33
  )
34
- from app.ui import api_usage_panel, theme
35
 
36
 
37
  # (step key, sidebar label) in flow order.
@@ -72,7 +72,7 @@ SESSION_DEFAULTS: dict[str, object] = {
72
  "api_status": None,
73
  "api_ok": False,
74
  # Theme (light default)
75
- "theme_dark": False,
76
  # Navigation
77
  "current_step": "setup",
78
  # Dossier flow
@@ -177,10 +177,14 @@ _STATUS_GLYPH = {
177
  "completed": "βœ“",
178
  "current": "●",
179
  "available": "β—‹",
180
- "locked": "πŸ”’",
181
  }
182
 
183
 
 
 
 
 
184
  def _header_chip() -> tuple[str, str]:
185
  if st.session_state.get("verified_proposal"):
186
  return "Proposal Ready", "ready"
@@ -192,14 +196,22 @@ def _header_chip() -> tuple[str, str]:
192
 
193
 
194
  def _render_sidebar(show_debug: bool) -> None:
195
- theme.sidebar_title("Steps")
 
 
 
 
 
196
  current = st.session_state.current_step
197
 
198
- for index, (key, label) in enumerate(_visible_steps(show_debug), start=1):
199
  status = _step_status(key, current)
200
  unlocked = status != "locked"
201
  glyph = _STATUS_GLYPH.get(status, "β—‹")
202
- btn_label = f"{glyph} {index}. {label}"
 
 
 
203
  clicked = st.button(
204
  btn_label,
205
  key=f"nav_btn_{key}",
@@ -215,14 +227,14 @@ def _render_sidebar(show_debug: bool) -> None:
215
  # API status warning β€” shown any time the key is no longer working so the
216
  # user knows why things are failing without hunting through the screens.
217
  if st.session_state.get("api_ok") is False and st.session_state.get("api_status") not in (None,):
218
- st.warning("⚠️ AI service issue β€” go to Setup to fix it.")
219
 
220
  # ── Always-visible settings link ───────────────────────────────────
221
  # The Setup screen is auto-skipped once an API key is saved, so users
222
  # need an explicit way back to change it.
223
  st.divider()
224
  if st.button(
225
- "βš™οΈ Change API Settings",
226
  key="goto_setup_btn",
227
  use_container_width=True,
228
  type="secondary",
@@ -233,8 +245,8 @@ def _render_sidebar(show_debug: bool) -> None:
233
 
234
  # Dark / light theme toggle.
235
  dark_on = st.toggle(
236
- "πŸŒ™ Dark mode",
237
- value=bool(st.session_state.get("theme_dark", False)),
238
  key="theme_toggle",
239
  )
240
  if dark_on != st.session_state.get("theme_dark"):
@@ -244,6 +256,32 @@ def _render_sidebar(show_debug: bool) -> None:
244
  # Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true.
245
  api_usage_panel.render(key_suffix="sidebar")
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
  def _try_restore_saved_config() -> None:
249
  """Load this browser's saved (encrypted) Setup config from Supabase.
@@ -395,25 +433,26 @@ def main() -> None:
395
  st.session_state.current_step = "setup"
396
 
397
  # Re-tint the whole screen to the current step's signature color.
398
- dark = bool(st.session_state.get("theme_dark", False))
399
  current_step = st.session_state.current_step
400
  theme.apply_step_accent(current_step, dark=dark)
401
 
402
- # "Step X of N" indicator over the visible steps.
 
403
  visible = _visible_steps(show_debug)
404
- step_label = None
405
  for idx, (key, _label) in enumerate(visible, start=1):
406
  if key == current_step:
407
- step_label = f"Step {idx} of {len(visible)}"
408
  break
409
 
410
  chip_label, chip_kind = _header_chip()
411
- theme.render_app_header(
412
- APP_TITLE,
413
- APP_TAGLINE,
414
- chip_label=chip_label,
415
- chip_kind=chip_kind,
416
- step_label=step_label,
417
  )
418
 
419
  with st.sidebar:
@@ -421,6 +460,13 @@ def main() -> None:
421
 
422
  RENDERERS[st.session_state.current_step]()
423
 
 
 
 
 
 
 
 
424
 
425
  if __name__ == "__main__":
426
  main()
 
31
  analysis_screen,
32
  proposal_screen,
33
  )
34
+ from app.ui import api_usage_panel, motion_runtime, theme
35
 
36
 
37
  # (step key, sidebar label) in flow order.
 
72
  "api_status": None,
73
  "api_ok": False,
74
  # Theme (light default)
75
+ "theme_dark": True,
76
  # Navigation
77
  "current_step": "setup",
78
  # Dossier flow
 
177
  "completed": "βœ“",
178
  "current": "●",
179
  "available": "β—‹",
180
+ "locked": "β—¦",
181
  }
182
 
183
 
184
+ # Session status pill -> the design canvas's pill tones.
185
+ _CHIP_TONE = {"ready": "ok", "info": "violet", "missing": "stop"}
186
+
187
+
188
  def _header_chip() -> tuple[str, str]:
189
  if st.session_state.get("verified_proposal"):
190
  return "Proposal Ready", "ready"
 
196
 
197
 
198
  def _render_sidebar(show_debug: bool) -> None:
199
+ # The rail from the design canvas: brand lockup, a caption, one row per
200
+ # step, and the engine card pinned to the foot. The rows are still real
201
+ # st.button controls β€” the widget layer gives each one its step icon and
202
+ # its on / done / locked state.
203
+ theme.sidebar_brand(APP_TITLE, APP_TAGLINE)
204
+ theme.rail_cap("Workflow")
205
  current = st.session_state.current_step
206
 
207
+ for key, label in _visible_steps(show_debug):
208
  status = _step_status(key, current)
209
  unlocked = status != "locked"
210
  glyph = _STATUS_GLYPH.get(status, "β—‹")
211
+ # The ordinal moved to the top bar, which now shows the live segmented
212
+ # rail and "Step N of M". The glyph stays: it is the only per-row state
213
+ # signal Streamlit does not already express through type= / disabled.
214
+ btn_label = f"{glyph} {label}"
215
  clicked = st.button(
216
  btn_label,
217
  key=f"nav_btn_{key}",
 
227
  # API status warning β€” shown any time the key is no longer working so the
228
  # user knows why things are failing without hunting through the screens.
229
  if st.session_state.get("api_ok") is False and st.session_state.get("api_status") not in (None,):
230
+ st.warning("AI service issue β€” go to Setup to fix it.")
231
 
232
  # ── Always-visible settings link ───────────────────────────────────
233
  # The Setup screen is auto-skipped once an API key is saved, so users
234
  # need an explicit way back to change it.
235
  st.divider()
236
  if st.button(
237
+ "Change API Settings",
238
  key="goto_setup_btn",
239
  use_container_width=True,
240
  type="secondary",
 
245
 
246
  # Dark / light theme toggle.
247
  dark_on = st.toggle(
248
+ "Dark mode",
249
+ value=bool(st.session_state.get("theme_dark", True)),
250
  key="theme_toggle",
251
  )
252
  if dark_on != st.session_state.get("theme_dark"):
 
256
  # Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true.
257
  api_usage_panel.render(key_suffix="sidebar")
258
 
259
+ # The engine card closes the rail, as on the design canvas. The provider
260
+ # and model names are debug-gated exactly as everywhere else in this app
261
+ # (Settings.show_debug_panel), so a normal user sees the state of their AI
262
+ # service and nothing about which one it is.
263
+ _render_engine_card(show_debug)
264
+
265
+
266
+ def _render_engine_card(show_debug: bool) -> None:
267
+ settings = get_settings()
268
+ ok = bool(st.session_state.get("api_ok"))
269
+ if show_debug:
270
+ theme.engine_card(
271
+ "Engine",
272
+ settings.active_model or "Not configured",
273
+ f"{settings.active_provider or 'no provider'} Β· "
274
+ + ("connected" if ok else "not connected"),
275
+ "ok" if ok else "stop",
276
+ )
277
+ return
278
+ theme.engine_card(
279
+ "AI service",
280
+ "Connected" if ok else "Not connected",
281
+ "Ready for analysis" if ok else "Set your key on Setup",
282
+ "ok" if ok else "warn",
283
+ )
284
+
285
 
286
  def _try_restore_saved_config() -> None:
287
  """Load this browser's saved (encrypted) Setup config from Supabase.
 
433
  st.session_state.current_step = "setup"
434
 
435
  # Re-tint the whole screen to the current step's signature color.
436
+ dark = bool(st.session_state.get("theme_dark", True))
437
  current_step = st.session_state.current_step
438
  theme.apply_step_accent(current_step, dark=dark)
439
 
440
+ # The top bar from the design canvas: breadcrumb, a segmented rail that
441
+ # lights the step you are on, "Step N of M", and the session status pill.
442
  visible = _visible_steps(show_debug)
443
+ step_index = None
444
  for idx, (key, _label) in enumerate(visible, start=1):
445
  if key == current_step:
446
+ step_index = idx
447
  break
448
 
449
  chip_label, chip_kind = _header_chip()
450
+ theme.top_bar(
451
+ crumb_a=APP_TITLE,
452
+ crumb_b=dict(STEPS).get(current_step, current_step),
453
+ step_index=step_index,
454
+ step_total=len(visible),
455
+ right_html=theme.tag(chip_label, _CHIP_TONE.get(chip_kind, "")),
456
  )
457
 
458
  with st.sidebar:
 
460
 
461
  RENDERERS[st.session_state.current_step]()
462
 
463
+ # Motion drives the animation the CSS layer cannot express: panels that
464
+ # arrive as they scroll into view, springs on hover and press, the fit ring
465
+ # drawing to its score, and numbers counting up to the value just computed.
466
+ # Started last so its first pass sees a fully rendered page; after that a
467
+ # MutationObserver keeps up with Streamlit's reruns on its own.
468
+ motion_runtime.inject()
469
+
470
 
471
  if __name__ == "__main__":
472
  main()
app/ui/analysis_screen.py CHANGED
@@ -61,6 +61,14 @@ _BEGINNER_VERDICT_CHIP = {
61
  "Do Not Proceed": "missing",
62
  }
63
 
 
 
 
 
 
 
 
 
64
 
65
  # Raw screenshot/critical field names β†’ plain language, so a beginner never
66
  # sees an internal token like "client_need" in a reason or missing-field card.
@@ -95,6 +103,8 @@ _PLAIN_FIELD_LABELS = {
95
  # Status token β†’ coloured pill for the 10-signal evaluation table.
96
  _SIGNAL_STATUS_EMOJI = {"GO": "🟒", "CAUTION": "🟑", "NO GO": "πŸ”΄"}
97
  _SIGNAL_STATUS_CHIP = {"GO": "ready", "CAUTION": "neutral", "NO GO": "missing"}
 
 
98
 
99
 
100
  def _plainify(text: str) -> str:
@@ -290,23 +300,29 @@ def _render_signal_table(beginner_eval: dict | None) -> None:
290
  rows = be.get("signals") or []
291
  if not rows:
292
  return
293
- with st.container(border=True):
294
- theme.section_label("Job evaluation")
295
- header = st.columns([3, 4, 2])
296
- header[0].markdown("**Signal**")
297
- header[1].markdown("**Detail**")
298
- header[2].markdown("**Status**")
299
- for row in rows:
300
- cols = st.columns([3, 4, 2])
301
- cols[0].write(row.get("label", ""))
302
- cols[1].write(_plainify(str(row.get("data") or "β€”")))
303
- status = row.get("status", "")
304
- emoji = _SIGNAL_STATUS_EMOJI.get(status, "")
305
- cols[2].markdown(
306
- theme.status_chip(f"{emoji} {status}".strip(),
307
- _SIGNAL_STATUS_CHIP.get(status, "neutral")),
308
- unsafe_allow_html=True,
309
- )
 
 
 
 
 
 
310
 
311
 
312
  def _render_score_summary(beginner_eval: dict | None) -> None:
@@ -314,15 +330,21 @@ def _render_score_summary(beginner_eval: dict | None) -> None:
314
  be = beginner_eval or {}
315
  if not be.get("signals"):
316
  return
317
- with st.container(border=True):
318
- theme.section_label("Score summary")
319
- col_go, col_caution, col_nogo = st.columns(3)
320
- col_go.metric("🟒 GO", be.get("go_count", 0))
321
- col_caution.metric("🟑 CAUTION", be.get("caution_count", 0))
322
- col_nogo.metric("πŸ”΄ NO GO", be.get("nogo_count", 0))
323
- line = be.get("recommendation_line")
324
- if line:
325
- st.write(f"**{_plainify(line)}**")
 
 
 
 
 
 
326
 
327
 
328
  def _render_niche_note(beginner_eval: dict | None) -> None:
@@ -331,7 +353,7 @@ def _render_niche_note(beginner_eval: dict | None) -> None:
331
  status = niche.get("status")
332
  note = niche.get("note")
333
  if status in ("PARTIAL", "NONE") and note:
334
- st.warning("⚠️ " + _plainify(note))
335
 
336
 
337
  def _render_strengths_concerns(recommendation: dict) -> None:
@@ -404,7 +426,7 @@ def _render_heads_up(
404
  beginner_note = (beginner_eval or {}).get("missing_info_note")
405
  if not (missing_labels or beginner_note or dossier_strength < 40):
406
  return
407
- with st.container(border=True):
408
  theme.section_label("Heads up")
409
  if missing_labels:
410
  st.write("Some details weren't visible, so this is less certain:")
@@ -492,28 +514,55 @@ def _analysis_is_stale(fingerprint: str) -> bool:
492
  return False
493
 
494
 
495
- def _render_score_card(score_result) -> None:
 
 
 
 
 
 
 
 
 
 
 
496
  components = getattr(score_result, "components", {}) or {}
497
- with st.container(border=True):
498
- theme.section_label("Fit score")
499
- col_score, col_conf = st.columns([3, 1])
500
- with col_score:
501
- st.progress(
502
- min(max(score_result.total, 0), 100),
503
- text=f"{score_result.total} / 100",
504
- )
505
- with col_conf:
506
- st.metric(
507
- "Confidence", output_screen.confidence_badge(score_result.confidence)
508
- )
 
 
509
  for key, weight in WEIGHTS.items():
510
  value = score_result.sub_scores.get(key, 0)
511
  comp = components.get(key)
512
  reason = getattr(comp, "short_reason", "") if comp else ""
513
- line = f"- {output_screen.SUB_SCORE_LABELS[key]}: **{value}/{weight}**"
514
- if reason:
515
- line += f" β€” {reason}"
516
- st.write(line)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
 
519
  def render() -> None:
@@ -590,7 +639,7 @@ def render() -> None:
590
  if st.session_state.get("analysis_running"):
591
  tstate = bg.status(_ANALYSIS_TASK)
592
  if tstate["status"] == "running":
593
- with st.container(border=True):
594
  theme.section_label("Analyzing")
595
  st.info(
596
  "πŸ”Ž Analyzing this opportunity… this keeps running even if "
@@ -654,7 +703,7 @@ def render() -> None:
654
  verdict = recommendation.get("verdict", "β€”")
655
  why = recommendation.get("why") or recommendation.get("reasoning") or ""
656
  short_verdict = recommendation.get("short_verdict") or ""
657
- with st.container(border=True):
658
  theme.section_label("Recommendation (debug)")
659
  _render_verdict_chip(verdict)
660
  if short_verdict and short_verdict != verdict:
@@ -675,7 +724,10 @@ def render() -> None:
675
  if connects:
676
  st.markdown(f"**Connects** β€” {connects}")
677
  output_screen.render_beginner_check_card(beginner_eval, show_debug=True)
678
- _render_score_card(score_result)
 
 
 
679
 
680
  # ---- Continue -----------------------------------------------------
681
  st.write("")
@@ -686,7 +738,7 @@ def render() -> None:
686
  st.session_state.current_step = "proposal"
687
  st.rerun()
688
  with col_new:
689
- if st.button("πŸ”„ Analyze another job", key="new_job_from_analysis_btn",
690
  use_container_width=True,
691
  help="Clear this job and upload a new screenshot."):
692
  from app.ui.screenshot_screen import _clear_screenshots_for_new_job
 
61
  "Do Not Proceed": "missing",
62
  }
63
 
64
+ # The same three verdicts in the design canvas's band. The ring, the meters
65
+ # and the pill all read from this β€” a band is never derived from the number.
66
+ _BEGINNER_VERDICT_TONE = {
67
+ "Apply Confidently": "ok",
68
+ "Proceed With Caution": "warn",
69
+ "Do Not Proceed": "stop",
70
+ }
71
+
72
 
73
  # Raw screenshot/critical field names β†’ plain language, so a beginner never
74
  # sees an internal token like "client_need" in a reason or missing-field card.
 
103
  # Status token β†’ coloured pill for the 10-signal evaluation table.
104
  _SIGNAL_STATUS_EMOJI = {"GO": "🟒", "CAUTION": "🟑", "NO GO": "πŸ”΄"}
105
  _SIGNAL_STATUS_CHIP = {"GO": "ready", "CAUTION": "neutral", "NO GO": "missing"}
106
+ # The same three states in the design canvas's pill tones.
107
+ _SIGNAL_STATUS_TONE = {"GO": "ok", "CAUTION": "warn", "NO GO": "stop"}
108
 
109
 
110
  def _plainify(text: str) -> str:
 
300
  rows = be.get("signals") or []
301
  if not rows:
302
  return
303
+ # The canvas renders this as a headed table of rows: a numbered tile, the
304
+ # signal and its detail on two lines, and the verdict pill on the right.
305
+ with st.container(border=True, key="ups_panel_analysis_1"):
306
+ theme.panel_header("Job evaluation", theme.tag(f"{len(rows)} signals"))
307
+ widths = "34px 1fr 132px"
308
+ theme.rows_table(
309
+ ["", "Signal", "Status"],
310
+ widths,
311
+ [
312
+ [
313
+ theme.tile(str(i)),
314
+ theme.cell(
315
+ row.get("label", ""),
316
+ _plainify(str(row.get("data") or "β€”")),
317
+ ),
318
+ theme.tag(
319
+ row.get("status", ""),
320
+ _SIGNAL_STATUS_TONE.get(row.get("status", ""), ""),
321
+ ),
322
+ ]
323
+ for i, row in enumerate(rows, start=1)
324
+ ],
325
+ )
326
 
327
 
328
  def _render_score_summary(beginner_eval: dict | None) -> None:
 
330
  be = beginner_eval or {}
331
  if not be.get("signals"):
332
  return
333
+ # Three counts, as the canvas's KPI tiles. Same numbers, same source.
334
+ theme.kpis(
335
+ [
336
+ {"lab": "GO", "val": be.get("go_count", 0), "icon": "check"},
337
+ {"lab": "CAUTION", "val": be.get("caution_count", 0), "icon": "alert"},
338
+ {"lab": "NO GO", "val": be.get("nogo_count", 0), "icon": "x"},
339
+ ],
340
+ columns=3,
341
+ )
342
+ line = be.get("recommendation_line")
343
+ if line:
344
+ theme.html(
345
+ f'<p class="ups-cap" style="margin:14px 0 0;font-size:13px;color:var(--text)">'
346
+ f"<b>{theme._e(_plainify(line))}</b></p>"
347
+ )
348
 
349
 
350
  def _render_niche_note(beginner_eval: dict | None) -> None:
 
353
  status = niche.get("status")
354
  note = niche.get("note")
355
  if status in ("PARTIAL", "NONE") and note:
356
+ st.warning(_plainify(note))
357
 
358
 
359
  def _render_strengths_concerns(recommendation: dict) -> None:
 
426
  beginner_note = (beginner_eval or {}).get("missing_info_note")
427
  if not (missing_labels or beginner_note or dossier_strength < 40):
428
  return
429
+ with st.container(border=True, key="ups_panel_analysis_2"):
430
  theme.section_label("Heads up")
431
  if missing_labels:
432
  st.write("Some details weren't visible, so this is less certain:")
 
514
  return False
515
 
516
 
517
+ def _render_score_card(score_result, band: str = "") -> None:
518
+ """The fit score as the design canvas draws it: a ring, then one weighted
519
+ meter per component with the reason underneath.
520
+
521
+ Developer-only, exactly as before β€” this is only ever reached from the
522
+ ``show_debug`` branch, so the score, the confidence and the sub-scores stay
523
+ out of the normal user's screen.
524
+
525
+ ``band`` ties the ring and the meters to the verdict, never to a threshold
526
+ invented here: on the canvas the ring, the meters and the pill always read
527
+ from the same band, and accent violet never carries a verdict.
528
+ """
529
  components = getattr(score_result, "components", {}) or {}
530
+ total = min(max(score_result.total, 0), 100)
531
+ with st.container(border=True, key="ups_panel_analysis_3"):
532
+ theme.panel_header(
533
+ "Fit score",
534
+ theme.tag(
535
+ output_screen.confidence_badge(score_result.confidence), "violet"
536
+ ),
537
+ )
538
+ theme.html(
539
+ '<div class="ups-flow" style="gap:32px;align-items:center">'
540
+ + theme.score_ring(total, band=band)
541
+ + '<div class="ups-sp"></div></div>'
542
+ )
543
+ rows = []
544
  for key, weight in WEIGHTS.items():
545
  value = score_result.sub_scores.get(key, 0)
546
  comp = components.get(key)
547
  reason = getattr(comp, "short_reason", "") if comp else ""
548
+ pct = (value / weight * 100) if weight else 0
549
+ rows.append(
550
+ '<div style="padding:13px 0;border-bottom:1px solid var(--gl-stroke-soft)">'
551
+ '<div class="ups-flow" style="gap:10px">'
552
+ f'<b style="font-size:14px;font-weight:600">'
553
+ f"{theme._e(output_screen.SUB_SCORE_LABELS[key])}</b>"
554
+ '<span class="ups-sp"></span>'
555
+ f'<span class="ups-num ups-cap" style="font-size:13px">'
556
+ f"{theme._e(value)} / {theme._e(weight)}</span></div>"
557
+ f'<div style="margin-top:9px">{theme.meter(pct, band)}</div>'
558
+ + (
559
+ f'<div class="ups-cap" style="margin-top:7px">{theme._e(reason)}</div>'
560
+ if reason
561
+ else ""
562
+ )
563
+ + "</div>"
564
+ )
565
+ theme.html("".join(rows))
566
 
567
 
568
  def render() -> None:
 
639
  if st.session_state.get("analysis_running"):
640
  tstate = bg.status(_ANALYSIS_TASK)
641
  if tstate["status"] == "running":
642
+ with st.container(border=True, key="ups_panel_analysis_4"):
643
  theme.section_label("Analyzing")
644
  st.info(
645
  "πŸ”Ž Analyzing this opportunity… this keeps running even if "
 
703
  verdict = recommendation.get("verdict", "β€”")
704
  why = recommendation.get("why") or recommendation.get("reasoning") or ""
705
  short_verdict = recommendation.get("short_verdict") or ""
706
+ with st.container(border=True, key="ups_panel_analysis_5"):
707
  theme.section_label("Recommendation (debug)")
708
  _render_verdict_chip(verdict)
709
  if short_verdict and short_verdict != verdict:
 
724
  if connects:
725
  st.markdown(f"**Connects** β€” {connects}")
726
  output_screen.render_beginner_check_card(beginner_eval, show_debug=True)
727
+ _render_score_card(
728
+ score_result,
729
+ _BEGINNER_VERDICT_TONE.get((beginner_eval or {}).get("result", ""), ""),
730
+ )
731
 
732
  # ---- Continue -----------------------------------------------------
733
  st.write("")
 
738
  st.session_state.current_step = "proposal"
739
  st.rerun()
740
  with col_new:
741
+ if st.button("Analyze another job", key="new_job_from_analysis_btn",
742
  use_container_width=True,
743
  help="Clear this job and upload a new screenshot."):
744
  from app.ui.screenshot_screen import _clear_screenshots_for_new_job
app/ui/confirmation_screen.py CHANGED
@@ -11,6 +11,8 @@ grouped into four simple sections to make review quick.
11
 
12
  from __future__ import annotations
13
 
 
 
14
  import streamlit as st
15
 
16
  from app.config import get_settings
@@ -181,7 +183,10 @@ def render() -> None:
181
  )
182
 
183
  for title, keys in FIELD_GROUPS:
184
- with st.container(border=True):
 
 
 
185
  theme.section_label(title)
186
  for key in keys:
187
  _render_field(key, extracted, debug)
 
11
 
12
  from __future__ import annotations
13
 
14
+ import re
15
+
16
  import streamlit as st
17
 
18
  from app.config import get_settings
 
183
  )
184
 
185
  for title, keys in FIELD_GROUPS:
186
+ # Keyed per group: the key is what the widget layer styles as a panel,
187
+ # and inside a loop it has to differ on every iteration.
188
+ slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
189
+ with st.container(border=True, key=f"ups_panel_fields_{slug}"):
190
  theme.section_label(title)
191
  for key in keys:
192
  _render_field(key, extracted, debug)
app/ui/dossier_screen.py CHANGED
@@ -73,6 +73,21 @@ def _reset_downstream() -> None:
73
  clear_opportunity_state(st.session_state)
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def _render_validation_clean(result: FolderValidationResult, *, debug: bool) -> None:
77
  if result.issues and not result.exists:
78
  for issue in result.issues:
@@ -83,14 +98,26 @@ def _render_validation_clean(result: FolderValidationResult, *, debug: bool) ->
83
  # its label, the "to strengthen your dossier consider adding…" line, and
84
  # the "evidence collection looks solid" banner mislead non-technical
85
  # users, so they are gated behind SHOW_DEBUG_PANEL=true.
 
86
  if debug:
87
- col_files, col_strength = st.columns(2)
88
- col_files.metric("Dossier Files", result.total_files)
89
- col_strength.metric("Dossier strength", f"{result.strength_score}/100")
90
- color = _strength_color(result.strength_score)
91
- st.markdown(f"**Strength:** :{color}[{result.strength_label}]")
 
 
 
 
 
 
 
 
92
  else:
93
- st.metric("Dossier Files", result.total_files)
 
 
 
94
 
95
  for issue in result.issues:
96
  st.error(issue)
@@ -316,7 +343,7 @@ def _card_upload_dossier(debug: bool) -> None:
316
  :func:`_continue_chain`. In debug mode the granular read/index cards still
317
  render against the uploaded folder, exactly as on desktop.
318
  """
319
- with st.container(border=True):
320
  theme.section_label(
321
  "Step 1 Β· Upload dossier files" if debug else "Upload dossier files"
322
  )
@@ -374,7 +401,7 @@ def _card_upload_dossier(debug: bool) -> None:
374
 
375
 
376
  def _card_select_folder(debug: bool) -> None:
377
- with st.container(border=True):
378
  theme.section_label("Step 1 Β· Select dossier folder")
379
  st.text_input(
380
  "Dossier folder path",
@@ -447,7 +474,7 @@ def _render_read_summary(summary: DossierReadSummary) -> None:
447
 
448
 
449
  def _card_read_evidence() -> None:
450
- with st.container(border=True):
451
  theme.section_label("Step 2 Β· Read profile evidence")
452
  result: FolderValidationResult | None = st.session_state.get(
453
  "dossier_validation"
@@ -577,7 +604,7 @@ def _card_continue_normal() -> None:
577
  if saved:
578
  st.session_state["dossier_folder_path"] = saved
579
 
580
- with st.container(border=True):
581
  theme.section_label("Select dossier folder")
582
  st.text_input(
583
  "Dossier folder path",
 
73
  clear_opportunity_state(st.session_state)
74
 
75
 
76
+ def _strength_tone(score: int) -> str:
77
+ """The strength label's tone on the canvas's KPI tile.
78
+
79
+ The thresholds are _strength_color's, unchanged β€” this only picks which of
80
+ the design's bands paints the label.
81
+ """
82
+ if score >= 80:
83
+ return "ok"
84
+ if score >= 60:
85
+ return "t2"
86
+ if score >= 40:
87
+ return "warn"
88
+ return "stop"
89
+
90
+
91
  def _render_validation_clean(result: FolderValidationResult, *, debug: bool) -> None:
92
  if result.issues and not result.exists:
93
  for issue in result.issues:
 
98
  # its label, the "to strengthen your dossier consider adding…" line, and
99
  # the "evidence collection looks solid" banner mislead non-technical
100
  # users, so they are gated behind SHOW_DEBUG_PANEL=true.
101
+ # Same numbers, same gating β€” rendered as the design canvas's KPI tiles.
102
  if debug:
103
+ theme.kpis(
104
+ [
105
+ {"lab": "Dossier files", "val": result.total_files, "icon": "folder"},
106
+ {
107
+ "lab": "Dossier strength",
108
+ "val": f"{result.strength_score}/100",
109
+ "icon": "spark",
110
+ "delta": result.strength_label,
111
+ "delta_kind": _strength_tone(result.strength_score),
112
+ },
113
+ ],
114
+ columns=2,
115
+ )
116
  else:
117
+ theme.kpis(
118
+ [{"lab": "Dossier files", "val": result.total_files, "icon": "folder"}],
119
+ columns=1,
120
+ )
121
 
122
  for issue in result.issues:
123
  st.error(issue)
 
343
  :func:`_continue_chain`. In debug mode the granular read/index cards still
344
  render against the uploaded folder, exactly as on desktop.
345
  """
346
+ with st.container(border=True, key="ups_panel_dossier_1"):
347
  theme.section_label(
348
  "Step 1 Β· Upload dossier files" if debug else "Upload dossier files"
349
  )
 
401
 
402
 
403
  def _card_select_folder(debug: bool) -> None:
404
+ with st.container(border=True, key="ups_panel_dossier_2"):
405
  theme.section_label("Step 1 Β· Select dossier folder")
406
  st.text_input(
407
  "Dossier folder path",
 
474
 
475
 
476
  def _card_read_evidence() -> None:
477
+ with st.container(border=True, key="ups_panel_dossier_3"):
478
  theme.section_label("Step 2 Β· Read profile evidence")
479
  result: FolderValidationResult | None = st.session_state.get(
480
  "dossier_validation"
 
604
  if saved:
605
  st.session_state["dossier_folder_path"] = saved
606
 
607
+ with st.container(border=True, key="ups_panel_dossier_4"):
608
  theme.section_label("Select dossier folder")
609
  st.text_input(
610
  "Dossier folder path",
app/ui/icons.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Line icons, lifted verbatim from the Aurora Glass design canvas.
2
+
3
+ Stroke-based, 24x24 view box, ``currentColor`` throughout, so every icon
4
+ inherits the colour of whatever it sits in and recolours with the step
5
+ accent for free. Sized by CSS (``.ups-ico svg``), never by an attribute.
6
+
7
+ These are markup constants only β€” nothing here reads state or renders.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ ICONS: dict[str, str] = {
13
+ "alert": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 8.2v5M12 16.6v.1\"/><circle cx=\"12\" cy=\"12\" r=\"9\"/></svg>",
14
+ "arrow": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h13M12.5 6l6 6-6 6\"/></svg>",
15
+ "check": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m4 12.5 5.2 5.2L20 6.8\"/></svg>",
16
+ "clock": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"8.6\"/><path d=\"M12 7.4V12l3 1.9\"/></svg>",
17
+ "compass": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5z\"/></svg>",
18
+ "copy": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2.2\"/><path d=\"M15 6.5V5.5a2 2 0 0 0-2-2H5.5a2 2 0 0 0-2 2V13a2 2 0 0 0 2 2h1\"/></svg>",
19
+ "doc": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 3.5H7.5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8z\"/><path d=\"M14 3.5V8h4.5M9 13h6M9 16.5h4\"/></svg>",
20
+ "download": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 4v10.5M8 11l4 4 4-4M4.5 19.5h15\"/></svg>",
21
+ "edit": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4.5 19.5h4l10-10a2.1 2.1 0 0 0-3-3l-10 10z\"/></svg>",
22
+ "eye": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.8\"/></svg>",
23
+ "eyeoff": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 4.5 20 20.5\"/><path d=\"M9.6 6.2A9.6 9.6 0 0 1 12 5.8c6 0 9.5 6.2 9.5 6.2a17 17 0 0 1-3.2 3.9M6.6 8.2A17.4 17.4 0 0 0 2.5 12S6 18.2 12 18.2a9.5 9.5 0 0 0 3.1-.5\"/><path d=\"M10.2 10.4a2.8 2.8 0 0 0 3.7 3.9\"/></svg>",
24
+ "folder": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3.5 7.5A2 2 0 0 1 5.5 5.5h3.3a2 2 0 0 1 1.5.7l1 1.2h7.2a2 2 0 0 1 2 2v7.1a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z\"/></svg>",
25
+ "image": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2.5\"/><circle cx=\"9\" cy=\"10\" r=\"1.7\"/><path d=\"m4.5 17.5 4.6-4.4a1.8 1.8 0 0 1 2.5 0l6.1 5.5\"/></svg>",
26
+ "key": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"12\" r=\"4\"/><path d=\"M12 12h9M18 12v3.5M15.5 12v2.5\"/></svg>",
27
+ "lock": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"4.5\" y=\"10.5\" width=\"15\" height=\"10\" rx=\"2.5\"/><path d=\"M8 10.5V7.4a4 4 0 0 1 8 0v3.1\"/></svg>",
28
+ "minus": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M7 12h10\"/></svg>",
29
+ "moon": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 14.2A8.4 8.4 0 0 1 9.8 4 8.5 8.5 0 1 0 20 14.2Z\"/></svg>",
30
+ "plus": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M12 7v10M7 12h10\"/></svg>",
31
+ "quote": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 6.5C6.6 8 5 10.4 5 13.6V17h4.8v-4.8H7.4c.1-1.7.8-3 2.1-3.9zM19.5 6.5c-2.9 1.5-4.5 3.9-4.5 7.1V17h4.8v-4.8h-2.4c.1-1.7.8-3 2.1-3.9z\"/></svg>",
32
+ "refresh": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 12a8 8 0 1 1-2.4-5.7\"/><path d=\"M20 4.5V9h-4.5\"/></svg>",
33
+ "shield": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3.2 5 6v5.6c0 4.3 2.9 7.6 7 9.2 4.1-1.6 7-4.9 7-9.2V6z\"/><path d=\"m9.2 12.2 2 2 3.6-3.9\"/></svg>",
34
+ "spark": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3.5 13.7 9l5.5 1.7-5.5 1.7L12 18l-1.7-5.6L4.8 10.7 10.3 9z\"/><path d=\"M18.5 15.5l.7 2.2 2.2.7-2.2.7-.7 2.2-.7-2.2-2.2-.7 2.2-.7z\"/></svg>",
35
+ "sun": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2.8v2.1M12 19.1v2.1M4.4 4.4l1.5 1.5M18.1 18.1l1.5 1.5M2.8 12h2.1M19.1 12h2.1M4.4 19.6l1.5-1.5M18.1 5.9l1.5-1.5\"/></svg>",
36
+ "target": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"4.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.1\" fill=\"currentColor\"/></svg>",
37
+ "x": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\"/></svg>",
38
+ }
39
+
40
+ def icon(name: str, *, size: int | None = None, cls: str = "") -> str:
41
+ """Return one icon as inline SVG, wrapped so CSS can size and colour it.
42
+
43
+ Unknown names return an empty string rather than raising: an icon is
44
+ decoration, and a missing one must never take a screen down.
45
+ """
46
+ svg = ICONS.get(name, "")
47
+ if not svg:
48
+ return ""
49
+ style = f' style="--ico:{size}px"' if size else ""
50
+ classes = ("ups-ico " + cls).strip()
51
+ return f'<span class="{classes}"{style}>{svg}</span>'
app/ui/motion_runtime.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Motion β€” real animation on the live Streamlit DOM.
2
+
3
+ Why a runtime at all, when there is already a CSS motion layer: CSS can only
4
+ animate what it can select, from states it can express. It cannot start an
5
+ animation when a panel scrolls into view, it cannot run a spring, and it cannot
6
+ count a number up to the value the app just computed. Motion does all three.
7
+
8
+ How it reaches the page. ``st.markdown`` strips <script>, so nothing can be
9
+ executed from the markup the screens emit. ``components.html`` runs scripts,
10
+ but only inside its own sandboxed iframe. That iframe is same-origin with the
11
+ app (Streamlit sets it via srcdoc), so it can reach ``window.parent`` β€” the
12
+ same bridge :func:`app.ui.theme.apply_step_accent` already uses for the theme.
13
+
14
+ So the iframe is only a bootstrapper: it injects two <script> elements into the
15
+ PARENT document β€” the library, then the driver β€” and both then run in the
16
+ parent's own realm. That matters because Streamlit may discard the component
17
+ iframe on a rerun; anything still living in the iframe's realm would die with
18
+ it. Both injections are idempotent, and the driver installs a MutationObserver,
19
+ so it keeps animating the nodes Streamlit swaps in on every rerun without the
20
+ iframe having to run again.
21
+
22
+ Two rules the animations here must never break:
23
+
24
+ * **Nothing that is, or contains, a file uploader may be transformed.** A
25
+ transform makes an element the containing block for its ``position: fixed``
26
+ descendants, which is what broke dossier drag-and-drop once already. Those
27
+ surfaces are animated by opacity alone (see ``canMove``).
28
+ * **No element is ever left hidden.** Every "from" value lives in a keyframe
29
+ array, never in CSS and never written to the element up front, so a failure
30
+ to animate leaves content visible and correct rather than stranded at
31
+ ``opacity: 0``.
32
+
33
+ The library is vendored (``app/assets/vendor/motion.umd.js``, MIT) rather than
34
+ loaded from a CDN, because this app also ships as an offline desktop launcher.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from functools import lru_cache
40
+
41
+ import streamlit as st
42
+ import streamlit.components.v1 as components
43
+
44
+ from app.paths import resource_path
45
+
46
+
47
+ @lru_cache(maxsize=1)
48
+ def _library() -> str:
49
+ """The vendored Motion UMD bundle. Empty string if it is missing."""
50
+ try:
51
+ return resource_path("app", "assets", "vendor", "motion.umd.js").read_text(
52
+ encoding="utf-8"
53
+ )
54
+ except Exception:
55
+ return ""
56
+
57
+
58
+ # The driver. Runs in the parent realm; `Motion` is the UMD global.
59
+ _DRIVER = r"""
60
+ (function () {
61
+ if (window.__upsMotion) { window.__upsMotion.rescan(); return; }
62
+ var M = window.Motion;
63
+ if (!M || !M.animate) return;
64
+
65
+ var EASE = [0.22, 0.61, 0.36, 1];
66
+ var SPRING = { type: 'spring', stiffness: 320, damping: 30, mass: 0.9 };
67
+ var reduce = false;
68
+ try {
69
+ reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
70
+ } catch (e) {}
71
+
72
+ // Transforms are always written as Motion's discrete properties (y, scale,
73
+ // scaleX) and never as a transform STRING. Motion decomposes a string into
74
+ // its components, and a "none" end value resolves the scale component to 0,
75
+ // not 1 β€” which leaves the element occupying its space and painting nothing.
76
+ //
77
+ // And every entrance clears the inline transform once it has finished, so no
78
+ // surface is left permanently transformed: a transform makes an element the
79
+ // containing block for its fixed-position descendants.
80
+ function settle(anim, el) {
81
+ if (!anim || !anim.then) return;
82
+ anim.then(function () { el.style.transform = ''; }, function () {});
83
+ }
84
+
85
+ // A transform on one of these would become the containing block for the
86
+ // uploader's fixed-position drop overlay. Opacity only, always.
87
+ function canMove(el) {
88
+ return !el.querySelector('[data-testid="stFileUploader"]') &&
89
+ !el.closest('[data-testid="stFileUploader"]');
90
+ }
91
+
92
+ function once(el) {
93
+ if (el.dataset.upsM) return false;
94
+ el.dataset.upsM = '1';
95
+ return true;
96
+ }
97
+
98
+ // Panels, cards and tiles arrive as they scroll into view β€” not all at once
99
+ // on load, which is what made the page read as a still photograph.
100
+ var SURFACES = [
101
+ '[class*="st-key-ups_panel"]',
102
+ '.card', '.verdict', '.proposal-doc',
103
+ '.ups-panel', '.ups-kpi', '.ups-kv', '.ups-log',
104
+ '[data-testid="stAlert"]'
105
+ ].join(',');
106
+
107
+ // Their contents cascade behind them.
108
+ var ROWS = '.ups-row, .ups-ev, .checkitem, .ups-kvc, .ups-thead';
109
+
110
+ function enter(el) {
111
+ var move = canMove(el);
112
+ M.inView(el, function () {
113
+ var anim = M.animate(
114
+ el,
115
+ move ? { opacity: [0, 1], y: [16, 0], scale: [0.995, 1] }
116
+ : { opacity: [0, 1] },
117
+ { duration: 0.52, ease: EASE }
118
+ );
119
+ if (move) settle(anim, el);
120
+ }, { amount: 0.12 });
121
+ }
122
+
123
+ function cascade(container) {
124
+ var items = container.querySelectorAll(ROWS);
125
+ if (!items.length) return;
126
+ M.inView(container, function () {
127
+ var anim = M.animate(
128
+ items,
129
+ { opacity: [0, 1], y: [10, 0] },
130
+ { duration: 0.42, ease: EASE, delay: M.stagger(0.045) }
131
+ );
132
+ if (anim && anim.then) {
133
+ anim.then(function () {
134
+ items.forEach(function (it) { it.style.transform = ''; });
135
+ }, function () {});
136
+ }
137
+ }, { amount: 0.1 });
138
+ }
139
+
140
+ // Numbers arrive at their value rather than appearing at it. Only the
141
+ // leading number is animated, so "78/100" and "$2,000" keep their shape.
142
+ function countUp(el) {
143
+ var raw = (el.textContent || '').trim();
144
+ var m = raw.match(/^(-?\d[\d,]*)(.*)$/s);
145
+ if (!m) return;
146
+ var target = parseFloat(m[1].replace(/,/g, ''));
147
+ if (!isFinite(target) || target === 0) return;
148
+ var tail = m[2], grouped = m[1].indexOf(',') !== -1;
149
+ M.inView(el, function () {
150
+ M.animate(0, target, {
151
+ duration: 0.9,
152
+ ease: EASE,
153
+ onUpdate: function (v) {
154
+ var n = Math.round(v);
155
+ el.textContent = (grouped ? n.toLocaleString() : String(n)) + tail;
156
+ }
157
+ });
158
+ }, { amount: 0.5 });
159
+ }
160
+
161
+ // The fit ring draws itself to the score, comet head and all.
162
+ function drawRing(svgRoot) {
163
+ var bar = svgRoot.querySelector('.bar');
164
+ var comet = svgRoot.querySelector('.comet');
165
+ if (!bar) return;
166
+ var total = parseFloat(bar.getAttribute('stroke-dasharray')) || 433.5;
167
+ var target = parseFloat(bar.getAttribute('stroke-dashoffset'));
168
+ if (!isFinite(target)) return;
169
+ var deg = 360 * (1 - target / total);
170
+ M.inView(svgRoot, function () {
171
+ M.animate(bar, { strokeDashoffset: [total, target] },
172
+ { duration: 1.1, ease: EASE });
173
+ if (comet) M.animate(comet, { rotate: [0, deg] }, { duration: 1.1, ease: EASE });
174
+ }, { amount: 0.4 });
175
+ }
176
+
177
+ // Meters fill from empty to the width the app computed.
178
+ function fillMeter(fill) {
179
+ var target = fill.style.width;
180
+ if (!target) return;
181
+ M.inView(fill, function () {
182
+ M.animate(fill, { width: ['0%', target] }, { duration: 0.8, ease: EASE });
183
+ }, { amount: 0.6 });
184
+ }
185
+
186
+ // Hover and press are springs, so the whole surface feels physical. The
187
+ // inline transform is cleared on the way out, never left behind.
188
+ function springy(el, lift) {
189
+ M.hover(el, function () {
190
+ M.animate(el, { y: lift }, SPRING);
191
+ return function () { settle(M.animate(el, { y: 0 }, SPRING), el); };
192
+ });
193
+ M.press(el, function () {
194
+ M.animate(el, { scale: 0.985 }, { duration: 0.08, ease: 'easeOut' });
195
+ return function () { settle(M.animate(el, { scale: 1 }, SPRING), el); };
196
+ });
197
+ }
198
+
199
+ function rescan() {
200
+ var root = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
201
+ if (!root) return;
202
+
203
+ root.querySelectorAll(SURFACES).forEach(function (el) {
204
+ if (!once(el)) return;
205
+ enter(el);
206
+ cascade(el);
207
+ });
208
+
209
+ root.querySelectorAll('.ups-kpi .val, .ups-ring .val b').forEach(function (el) {
210
+ if (once(el)) countUp(el);
211
+ });
212
+
213
+ root.querySelectorAll('.ups-ring svg').forEach(function (el) {
214
+ if (once(el)) drawRing(el);
215
+ });
216
+
217
+ root.querySelectorAll('.ups-meter i').forEach(function (el) {
218
+ if (once(el)) fillMeter(el);
219
+ });
220
+
221
+ // The tiles lift; buttons only press, because a button that moves under
222
+ // the cursor is a button that gets mis-clicked.
223
+ root.querySelectorAll('.ups-kpi').forEach(function (el) {
224
+ if (el.dataset.upsH) return;
225
+ el.dataset.upsH = '1';
226
+ if (canMove(el)) springy(el, -4);
227
+ });
228
+ root.querySelectorAll('.stButton button, .stDownloadButton button').forEach(function (el) {
229
+ if (el.dataset.upsH) return;
230
+ el.dataset.upsH = '1';
231
+ M.press(el, function () {
232
+ M.animate(el, { scale: 0.97 }, { duration: 0.08, ease: 'easeOut' });
233
+ return function () { settle(M.animate(el, { scale: 1 }, SPRING), el); };
234
+ });
235
+ });
236
+
237
+ // The step you are on pulses into place when it changes.
238
+ root.querySelectorAll('.ups-seg-i.is-now').forEach(function (el) {
239
+ if (!once(el)) return;
240
+ settle(M.animate(el, { scaleX: [0.2, 1] },
241
+ { type: 'spring', stiffness: 400, damping: 22 }), el);
242
+ });
243
+ }
244
+
245
+ if (reduce) {
246
+ // Nothing is hidden up front, so honouring the setting means simply not
247
+ // starting anything. The page is already in its final state.
248
+ window.__upsMotion = { rescan: function () {}, reduced: true };
249
+ return;
250
+ }
251
+
252
+ var pending = 0;
253
+ function schedule() {
254
+ if (pending) return;
255
+ pending = requestAnimationFrame(function () { pending = 0; rescan(); });
256
+ }
257
+
258
+ // Streamlit replaces DOM nodes on every rerun, so the driver watches for
259
+ // them rather than running once and assuming the page stays put.
260
+ var target = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
261
+ var observer = new MutationObserver(schedule);
262
+ observer.observe(target, { childList: true, subtree: true });
263
+
264
+ window.__upsMotion = { rescan: rescan, observer: observer, reduced: false };
265
+ rescan();
266
+ })();
267
+ """
268
+
269
+
270
+ def inject() -> None:
271
+ """Load Motion into the page and start the driver. Safe to call every run.
272
+
273
+ Fails silently and completely: if the bundle is missing or the parent
274
+ document is unreachable, the app simply keeps the CSS motion layer.
275
+ """
276
+ lib = _library()
277
+ if not lib:
278
+ return
279
+ # Both payloads ride inside <script> elements, so nothing in them may
280
+ # contain a closing tag. Neither does today; this keeps that true after an
281
+ # upgrade. In JavaScript "<\\/script" and "</script" are the same string.
282
+ lib = lib.replace("</script", "<\\/script")
283
+ driver = _DRIVER.replace("</script", "<\\/script")
284
+ components.html(
285
+ # The payloads come first: the bootstrap below reads them by id, and a
286
+ # <script> only sees the document that exists above it.
287
+ '<script type="text/plain" id="ups-motion-lib">' + lib + "</script>\n"
288
+ '<script type="text/plain" id="ups-motion-driver">' + driver + "</script>\n"
289
+ "<script>\n"
290
+ "(function () {\n"
291
+ " var libEl = document.getElementById('ups-motion-lib');\n"
292
+ " var drvEl = document.getElementById('ups-motion-driver');\n"
293
+ " if (!libEl || !drvEl) return;\n"
294
+ " var LIB = libEl.textContent, DRIVER = drvEl.textContent;\n"
295
+ " function boot() {\n"
296
+ " try {\n"
297
+ " var w = window.parent, d = w.document;\n"
298
+ " if (!d || !d.head) return false;\n"
299
+ " if (!w.Motion) {\n"
300
+ " var s = d.createElement('script');\n"
301
+ " s.id = 'ups-motion-lib';\n"
302
+ " s.textContent = LIB;\n"
303
+ " d.head.appendChild(s);\n"
304
+ " }\n"
305
+ " if (!w.Motion) return false;\n"
306
+ " var t = d.createElement('script');\n"
307
+ " t.textContent = DRIVER;\n"
308
+ " d.head.appendChild(t);\n"
309
+ " t.remove();\n"
310
+ " return true;\n"
311
+ " } catch (e) { return false; }\n"
312
+ " }\n"
313
+ " if (!boot()) {\n"
314
+ " var n = 0;\n"
315
+ " var iv = setInterval(function () {\n"
316
+ " if (boot() || ++n > 20) clearInterval(iv);\n"
317
+ " }, 50);\n"
318
+ " }\n"
319
+ "})();\n"
320
+ "</script>",
321
+ height=0,
322
+ )
app/ui/output_screen.py CHANGED
@@ -162,7 +162,7 @@ def render_beginner_check_card(beginner_eval: dict, *, show_debug: bool = False)
162
  return
163
  result = beginner_eval.get("result") or "β€”"
164
  reasons = (beginner_eval.get("reasons") or [])[:2]
165
- with st.container(border=True):
166
  theme.section_label("Beginner Job Check")
167
  st.markdown(
168
  theme.status_chip(result, _BEGINNER_RESULT_CHIP.get(result, "neutral")),
 
162
  return
163
  result = beginner_eval.get("result") or "β€”"
164
  reasons = (beginner_eval.get("reasons") or [])[:2]
165
+ with st.container(border=True, key="ups_panel_output_1"):
166
  theme.section_label("Beginner Job Check")
167
  st.markdown(
168
  theme.status_chip(result, _BEGINNER_RESULT_CHIP.get(result, "neutral")),
app/ui/proposal_screen.py CHANGED
@@ -31,15 +31,36 @@ _PROPOSAL_TASK = "proposal_generation"
31
  def _copy_button(text: str) -> None:
32
  """A real one-click copy button (works in deployed Streamlit)."""
33
  safe = json.dumps(text).replace("</", "<\\/")
 
 
 
34
  html = f"""
35
- <button id="ups-copy"
36
- style="border:none;border-radius:10px;background:#2563EB;color:#fff;
37
- font-weight:600;font-size:0.9rem;padding:0.55rem 1.1rem;
38
- cursor:pointer;font-family:inherit;">
39
- Copy Proposal
40
- </button>
41
- <span id="ups-copy-msg" style="margin-left:10px;color:#137333;
42
- font-size:0.85rem;font-family:inherit;"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  <script>
44
  const btn = document.getElementById('ups-copy');
45
  const msg = document.getElementById('ups-copy-msg');
@@ -53,7 +74,7 @@ def _copy_button(text: str) -> None:
53
  }});
54
  </script>
55
  """
56
- components.html(html, height=48)
57
 
58
 
59
  def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> None:
@@ -80,7 +101,7 @@ def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> No
80
  proposal_result.get("verification_status") == "failed"
81
  )
82
  if verification_failed:
83
- with st.container(border=True):
84
  theme.section_label("Proposal not verified")
85
  st.error(
86
  "The grounding check could not confirm this draft against "
@@ -111,7 +132,7 @@ def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> No
111
 
112
  screening_answers = proposal_result.get("screening_answers") or []
113
  if screening_answers:
114
- with st.container(border=True):
115
  theme.section_label("Screening answers")
116
  for i, qa in enumerate(screening_answers, start=1):
117
  question = str(qa.get("question") or "").strip()
@@ -122,7 +143,7 @@ def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> No
122
 
123
  # Start a fresh job without restarting the app.
124
  st.write("")
125
- if st.button("πŸ”„ Analyze another job", key="new_job_from_proposal_btn",
126
  help="Clear this job and upload a new screenshot."):
127
  from app.ui.screenshot_screen import _clear_screenshots_for_new_job
128
  _clear_screenshots_for_new_job()
@@ -202,7 +223,7 @@ def render() -> None:
202
 
203
  do_not_proceed = recommendation.get("verdict") == "Do Not Proceed"
204
 
205
- with st.container(border=True):
206
  theme.section_label("Generate")
207
 
208
  override_ok = True
@@ -267,7 +288,7 @@ def render() -> None:
267
  if st.session_state.get("proposal_generating"):
268
  tstate = bg.status(_PROPOSAL_TASK)
269
  if tstate["status"] == "running":
270
- with st.container(border=True):
271
  theme.section_label("Generating")
272
  st.info(
273
  "✍️ Writing your proposal… this keeps running even if you "
 
31
  def _copy_button(text: str) -> None:
32
  """A real one-click copy button (works in deployed Streamlit)."""
33
  safe = json.dumps(text).replace("</", "<\\/")
34
+ # The button lives in its own component iframe, so it cannot inherit the
35
+ # Aurora stylesheet β€” the tokens are written inline here instead. Purely
36
+ # presentational; the clipboard behaviour below is unchanged.
37
  html = f"""
38
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
39
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
40
+ <style>
41
+ html, body {{ margin: 0; background: transparent; }}
42
+ #ups-copy {{
43
+ border: none; border-radius: 12px; padding: 10px 18px;
44
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
45
+ font-size: 14px; font-weight: 500; color: #FFFFFF; cursor: pointer;
46
+ background: linear-gradient(135deg, #7C3AED, #9333EA 55%, #A855F7);
47
+ box-shadow: 0 8px 24px rgba(139, 92, 246, 0.22);
48
+ transition: transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
49
+ box-shadow 120ms cubic-bezier(0.2, 0.8, 0.2, 1);
50
+ }}
51
+ #ups-copy:hover {{ transform: translateY(-1px); box-shadow: 0 10px 28px rgba(139, 92, 246, 0.30); }}
52
+ #ups-copy:active {{ transform: scale(0.98); transition-duration: 80ms; }}
53
+ #ups-copy:focus-visible {{ outline: 2px solid rgba(139, 92, 246, 0.78); outline-offset: 2px; }}
54
+ #ups-copy-msg {{
55
+ margin-left: 12px; color: #22C55E; font-size: 12px;
56
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
57
+ }}
58
+ @media (prefers-reduced-motion: reduce) {{
59
+ #ups-copy {{ transition: none; }}
60
+ }}
61
+ </style>
62
+ <button id="ups-copy">Copy Proposal</button>
63
+ <span id="ups-copy-msg"></span>
64
  <script>
65
  const btn = document.getElementById('ups-copy');
66
  const msg = document.getElementById('ups-copy-msg');
 
74
  }});
75
  </script>
76
  """
77
+ components.html(html, height=56)
78
 
79
 
80
  def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> None:
 
101
  proposal_result.get("verification_status") == "failed"
102
  )
103
  if verification_failed:
104
+ with st.container(border=True, key="ups_panel_proposal_1"):
105
  theme.section_label("Proposal not verified")
106
  st.error(
107
  "The grounding check could not confirm this draft against "
 
132
 
133
  screening_answers = proposal_result.get("screening_answers") or []
134
  if screening_answers:
135
+ with st.container(border=True, key="ups_panel_proposal_2"):
136
  theme.section_label("Screening answers")
137
  for i, qa in enumerate(screening_answers, start=1):
138
  question = str(qa.get("question") or "").strip()
 
143
 
144
  # Start a fresh job without restarting the app.
145
  st.write("")
146
+ if st.button("Analyze another job", key="new_job_from_proposal_btn",
147
  help="Clear this job and upload a new screenshot."):
148
  from app.ui.screenshot_screen import _clear_screenshots_for_new_job
149
  _clear_screenshots_for_new_job()
 
223
 
224
  do_not_proceed = recommendation.get("verdict") == "Do Not Proceed"
225
 
226
+ with st.container(border=True, key="ups_panel_proposal_3"):
227
  theme.section_label("Generate")
228
 
229
  override_ok = True
 
288
  if st.session_state.get("proposal_generating"):
289
  tstate = bg.status(_PROPOSAL_TASK)
290
  if tstate["status"] == "running":
291
+ with st.container(border=True, key="ups_panel_proposal_4"):
292
  theme.section_label("Generating")
293
  st.info(
294
  "✍️ Writing your proposal… this keeps running even if you "
app/ui/screenshot_screen.py CHANGED
@@ -161,14 +161,16 @@ def _capture_pasted_screenshot() -> list[dict]:
161
 
162
  # Colour the paste button to match the active theme (the component renders
163
  # in its own iframe, so it can't inherit our CSS β€” we pass colours in).
164
- _dark = bool(st.session_state.get("theme_dark", False))
165
  if _dark:
166
- _btn_bg, _btn_hover, _btn_fg = "#2c313d", "#3a4150", "#f4f5f7"
 
 
167
  else:
168
- _btn_bg, _btn_hover, _btn_fg = "#7c3aed", "#6d28d9", "#ffffff"
169
  try:
170
  paste_result = _paste_image_button(
171
- "πŸ“‹ Paste screenshot from clipboard",
172
  # Key carries the same reset counter as the uploader, so "Clear
173
  # screenshots" mounts a FRESH paste widget with no retained image β€”
174
  # otherwise the component keeps re-adding the old pasted screenshot.
@@ -285,7 +287,7 @@ def _render_debug_panel(meta: dict) -> None:
285
 
286
  def _render_debug_handoff(extracted: dict) -> None:
287
  """Developer/admin handoff: review the fields on the Confirm Details page."""
288
- with st.container(border=True):
289
  theme.section_label("Detected details")
290
  visible = sum(
291
  1
@@ -326,7 +328,7 @@ def render() -> None:
326
  "The tool reads the details for you. Anything it can't see is marked <b>Not visible</b> β€” it never guesses.",
327
  )
328
 
329
- with st.container(border=True):
330
  theme.section_label("Add screenshot")
331
  # Native Streamlit uploader β€” no custom axios/fetch, no external
332
  # endpoint. The bytes stay on this server in memory only.
 
161
 
162
  # Colour the paste button to match the active theme (the component renders
163
  # in its own iframe, so it can't inherit our CSS β€” we pass colours in).
164
+ _dark = bool(st.session_state.get("theme_dark", True))
165
  if _dark:
166
+ # Aurora secondary surface, solid because the component takes a
167
+ # single colour rather than the glass gradient.
168
+ _btn_bg, _btn_hover, _btn_fg = "#151D30", "#1D2740", "#F8FAFC"
169
  else:
170
+ _btn_bg, _btn_hover, _btn_fg = "#E7EAF3", "#DCE1EE", "#0F172A"
171
  try:
172
  paste_result = _paste_image_button(
173
+ "Paste screenshot from clipboard",
174
  # Key carries the same reset counter as the uploader, so "Clear
175
  # screenshots" mounts a FRESH paste widget with no retained image β€”
176
  # otherwise the component keeps re-adding the old pasted screenshot.
 
287
 
288
  def _render_debug_handoff(extracted: dict) -> None:
289
  """Developer/admin handoff: review the fields on the Confirm Details page."""
290
+ with st.container(border=True, key="ups_panel_screenshot_1"):
291
  theme.section_label("Detected details")
292
  visible = sum(
293
  1
 
328
  "The tool reads the details for you. Anything it can't see is marked <b>Not visible</b> β€” it never guesses.",
329
  )
330
 
331
+ with st.container(border=True, key="ups_panel_screenshot_2"):
332
  theme.section_label("Add screenshot")
333
  # Native Streamlit uploader β€” no custom axios/fetch, no external
334
  # endpoint. The bytes stay on this server in memory only.
app/ui/setup_screen.py CHANGED
@@ -365,7 +365,7 @@ def _render_config_form(settings) -> None:
365
 
366
  _seed_form_defaults(settings)
367
 
368
- with st.container(border=True):
369
  theme.section_label("Configure AI Service")
370
  st.write(
371
  "Enter your AI service details and click Save Configuration. "
@@ -565,7 +565,7 @@ def _render_refresh_button() -> None:
565
 
566
 
567
  def _render_api_check_card(settings, *, debug: bool) -> None:
568
- with st.container(border=True):
569
  theme.section_label("AI service")
570
  col_status, col_action = st.columns([2, 1])
571
  with col_status:
@@ -656,7 +656,7 @@ def render() -> None:
656
  _render_api_check_card(settings, debug=debug)
657
 
658
  if st.session_state.get("api_ok"):
659
- with st.container(border=True):
660
  theme.section_label("Next step")
661
  st.write("Your AI service is connected. You're ready to add your dossier.")
662
  if st.button(
 
365
 
366
  _seed_form_defaults(settings)
367
 
368
+ with st.container(border=True, key="ups_panel_setup_1"):
369
  theme.section_label("Configure AI Service")
370
  st.write(
371
  "Enter your AI service details and click Save Configuration. "
 
565
 
566
 
567
  def _render_api_check_card(settings, *, debug: bool) -> None:
568
+ with st.container(border=True, key="ups_panel_setup_2"):
569
  theme.section_label("AI service")
570
  col_status, col_action = st.columns([2, 1])
571
  with col_status:
 
656
  _render_api_check_card(settings, debug=debug)
657
 
658
  if st.session_state.get("api_ok"):
659
+ with st.container(border=True, key="ups_panel_setup_3"):
660
  theme.section_label("Next step")
661
  st.write("Your AI service is connected. You're ready to add your dossier.")
662
  if st.button(
app/ui/theme.py CHANGED
@@ -31,12 +31,16 @@ _CHIP_KINDS = {
31
 
32
  # Per-step signature color: (H, C, H2) in oklch. Light default.
33
  STEP_COLORS: dict[str, tuple[float, float, float]] = {
34
- "setup": (286, 0.21, 312),
35
- "dossier": (248, 0.18, 226),
36
- "screenshot": (52, 0.17, 32),
37
- "confirmation": (352, 0.22, 330),
38
- "analysis": (352, 0.22, 330),
39
- "proposal": (168, 0.16, 150),
 
 
 
 
40
  }
41
  _DEFAULT_ACCENT = STEP_COLORS["setup"]
42
 
@@ -52,29 +56,41 @@ _FONTS_LINK = (
52
  '<link rel="preconnect" href="https://fonts.googleapis.com">'
53
  '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
54
  '<link href="https://fonts.googleapis.com/css2?'
55
- 'family=Space+Grotesk:wght@500;600;700'
56
- '&family=Hanken+Grotesk:wght@400;500;600;700'
57
- '&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">'
58
  )
59
 
 
60
  # Extra CSS layered on top of design_system.css to bridge Streamlit's DOM
61
  # with the design's component classes.
62
  _BRIDGE_CSS = """
63
  <style>
64
- /* ── Global resets for Streamlit chrome ─────────────────────────────── */
 
 
 
 
 
65
  #MainMenu, footer, [data-testid="stHeader"] { display: none !important; }
66
- [data-testid="stAppViewContainer"] { background: var(--bg) !important; }
 
 
 
 
 
 
 
 
 
67
  [data-testid="stMain"] { background: transparent !important; }
68
  .block-container {
69
- padding: 28px 32px 80px !important;
70
- max-width: 980px !important;
71
  }
72
 
73
- /* ── ALL TEXT FOLLOWS THE THEME (critical for dark-mode contrast) ─────────
74
- Streamlit's own theme paints text a fixed dark colour; on a dark background
75
- that becomes invisible. We force every text surface to the design tokens
76
- (var(--text) / var(--text-muted)), which flip automatically with the active
77
- data-theme, so contrast is correct in BOTH light and dark mode. */
78
  [data-testid="stAppViewContainer"],
79
  [data-testid="stSidebar"],
80
  .stApp, .stMarkdown, .stText,
@@ -94,303 +110,834 @@ _BRIDGE_CSS = """
94
  [data-testid="stWidgetLabel"] label,
95
  label, p, li {
96
  color: var(--text) !important;
 
97
  }
98
- /* Muted/secondary text */
 
99
  [data-testid="stCaptionContainer"],
100
  [data-testid="stCaptionContainer"] p,
101
  small, .stCaption {
102
  color: var(--text-muted) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
- /* Metrics */
105
- [data-testid="stMetricValue"] { color: var(--text) !important; }
106
- [data-testid="stMetricLabel"], [data-testid="stMetricLabel"] p { color: var(--text-muted) !important; }
107
 
108
- /* Select boxes / dropdowns β€” closed control + the open menu */
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  [data-baseweb="select"] > div {
110
- background: var(--bg-2) !important;
111
- border-color: var(--border-2) !important;
 
112
  color: var(--text) !important;
 
 
113
  }
114
  [data-baseweb="select"] div, [data-baseweb="select"] span, [data-baseweb="select"] input {
115
  color: var(--text) !important;
116
  }
 
117
  [data-baseweb="popover"], [data-baseweb="menu"], [role="listbox"] {
118
- background: var(--surface) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  }
120
- [role="option"] { background: var(--surface) !important; color: var(--text) !important; }
121
- [role="option"]:hover { background: var(--surface-2) !important; }
122
-
123
- /* Expander header + body text */
124
  [data-testid="stExpander"] summary,
125
  [data-testid="stExpander"] summary span,
126
  [data-testid="stExpander"] p { color: var(--text) !important; }
127
 
128
- /* Radio / checkbox option labels */
129
  [data-baseweb="radio"] div, [data-testid="stCheckbox"] label, [data-testid="stRadio"] label {
130
  color: var(--text) !important;
131
  }
 
 
 
132
 
133
  /* ── Sidebar ────────────────────────────────────────────────────────── */
134
  section[data-testid="stSidebar"] {
135
- background: linear-gradient(180deg, var(--bg-2), var(--bg)) !important;
136
- border-right: 1px solid var(--border) !important;
 
 
137
  min-width: 240px !important; max-width: 260px !important;
138
  }
139
- section[data-testid="stSidebar"] .block-container {
140
- padding: 20px 14px 24px !important;
141
- }
142
 
143
- /* ── Sidebar nav buttons β†’ step rail look ──────────────────────────── */
144
- section[data-testid="stSidebar"] .stButton > button {
145
  background: none !important;
146
  border: 1px solid transparent !important;
147
- border-radius: 10px !important;
148
  text-align: left !important;
149
  justify-content: flex-start !important;
150
  font-family: var(--ff-body) !important;
151
- font-size: 13.5px !important;
152
  font-weight: 500 !important;
153
  color: var(--text-muted) !important;
154
- padding: 9px 11px !important;
155
- height: auto !important;
 
156
  width: 100% !important;
157
  box-shadow: none !important;
158
- transition: all 0.15s !important;
 
159
  }
160
- section[data-testid="stSidebar"] .stButton > button:hover {
161
- background: var(--surface-2) !important;
162
  color: var(--text) !important;
 
163
  }
164
- section[data-testid="stSidebar"] .stButton > button[kind="primary"] {
165
- background: var(--card-grad) !important;
166
- border-color: oklch(0.7 var(--accent-c, 0.15) var(--accent-h, 286) / .45) !important;
167
- color: var(--text) !important;
168
- font-weight: 600 !important;
169
- box-shadow: inset 3px 0 0 var(--accent), 0 2px 8px oklch(0 0 0 / .12) !important;
 
170
  }
171
- /* Sidebar secondary buttons (e.g. "Change API Settings") β€” clear surface +
172
- high-contrast text so they don't disappear into the sidebar in dark mode. */
173
- section[data-testid="stSidebar"] .stButton > button[kind="secondary"] {
174
- background: var(--surface-2) !important;
175
- border: 1px solid var(--border-2) !important;
176
  color: var(--text) !important;
177
- font-weight: 600 !important;
178
  }
179
- section[data-testid="stSidebar"] .stButton > button[kind="secondary"]:hover {
180
- background: var(--surface-3) !important;
 
 
181
  color: var(--text) !important;
182
  }
183
- section[data-testid="stSidebar"] .stButton > button:disabled {
184
  opacity: 0.45 !important;
185
  cursor: not-allowed !important;
 
186
  }
187
 
188
  /* ── Main action buttons ────────────────────────────────────────────── */
189
- .stButton > button {
190
  font-family: var(--ff-body) !important;
191
- font-weight: 600 !important;
192
  font-size: 14px !important;
193
  height: 44px !important;
194
- border-radius: var(--radius-sm) !important;
195
- transition: filter .15s, transform .12s !important;
 
196
  }
197
- .stButton > button[kind="primary"] {
198
  color: var(--accent-fg) !important;
199
  border: none !important;
200
- background: linear-gradient(150deg, var(--accent), var(--accent-2)) !important;
201
- box-shadow: 0 4px 16px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,.18) !important;
 
202
  }
203
- .stButton > button[kind="primary"]:hover { filter: brightness(1.06) !important; }
204
- .stButton > button[kind="primary"]:active { transform: translateY(1px) !important; }
205
- .stButton > button[kind="secondary"] {
206
- background: var(--card-grad) !important;
207
- color: var(--text) !important;
208
- border: 1px solid var(--border-2) !important;
209
- box-shadow: inset 0 1px 0 var(--hairline) !important;
210
  }
211
-
212
- /* Robust button targeting via Streamlit's stable test-ids. Newer Streamlit
213
- uses data-testid="stBaseButton-secondary/primary" with a hard-coded white
214
- background that the kind="..." selectors above don't always beat β€” these
215
- rules guarantee secondary buttons (Clear screenshots, Analyze another job,
216
- Change API Settings, download, etc.) follow the theme in dark mode. */
217
  [data-testid="stBaseButton-secondary"],
218
- .stDownloadButton > button {
219
- background: var(--card-grad) !important;
220
  color: var(--text) !important;
221
- border: 1px solid var(--border-2) !important;
 
222
  }
 
223
  [data-testid="stBaseButton-secondary"]:hover,
224
- .stDownloadButton > button:hover {
225
- background: var(--surface-2) !important;
226
- color: var(--text) !important;
227
- border-color: var(--accent) !important;
228
- }
229
- section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] {
230
- background: var(--surface-2) !important;
231
- color: var(--text) !important;
232
- border: 1px solid var(--border-2) !important;
233
- font-weight: 600 !important;
234
- }
235
- section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover {
236
- background: var(--surface-3) !important;
237
  color: var(--text) !important;
238
- border-color: var(--accent) !important;
239
  }
240
- /* Catch-all: NO button may lose its text colour on hover (Streamlit's default
241
- hover recolours the label, which can vanish on a dark hover background).
242
- Primary buttons are excluded β€” they keep their accent-foreground colour. */
243
- .stButton > button:not([kind="primary"]):hover,
244
- .stButton > button:not([kind="primary"]):hover *,
245
  [data-testid="stBaseButton-secondary"]:hover,
246
  [data-testid="stBaseButton-secondary"]:hover *,
247
- .stDownloadButton > button:hover,
248
- .stDownloadButton > button:hover * {
249
- color: var(--text) !important;
250
- }
251
- [data-testid="stBaseButton-primary"] {
252
- color: var(--accent-fg) !important;
253
- border: none !important;
254
- background: linear-gradient(150deg, var(--accent), var(--accent-2)) !important;
255
  }
256
 
257
- /* ── Inputs ─────────────────────────────────────────────────────────── */
258
- /* Target the baseweb input WRAPPER too, otherwise the field shows a white
259
- box in dark mode (the wrapper, not the <input>, carries the background). */
260
  .stTextInput input, .stTextArea textarea, .stNumberInput input,
261
  .stTextInput div[data-baseweb="input"],
262
  .stTextInput div[data-baseweb="base-input"],
263
  .stNumberInput div[data-baseweb="input"],
264
  .stNumberInput div[data-baseweb="base-input"] {
265
- background: var(--bg-2) !important;
266
- border-color: var(--border-2) !important;
267
- border-radius: var(--radius-sm) !important;
268
  color: var(--text) !important;
269
  font-family: var(--ff-body) !important;
270
  font-size: 14px !important;
271
  -webkit-text-fill-color: var(--text) !important;
 
272
  }
273
- .stTextInput input:focus, .stTextArea textarea:focus {
274
- border-color: var(--accent) !important;
275
- box-shadow: 0 0 0 4px var(--ring) !important;
276
  }
277
- /* Password reveal (eye) button next to the API key field */
278
- .stTextInput button { background: var(--bg-2) !important; color: var(--text-muted) !important; }
 
 
 
 
 
 
279
 
280
- /* ── File uploader β†’ dropzone ───────────────────────────────────────── */
281
  [data-testid="stFileUploaderDropzone"] {
282
- background: var(--bg-2) !important;
283
- border: 1.5px dashed var(--border-2) !important;
284
  border-radius: var(--radius) !important;
 
 
285
  }
286
- [data-testid="stFileUploaderDropzone"]:hover {
 
 
287
  border-color: var(--accent) !important;
 
 
288
  }
289
- /* Dropzone text + instructions ("Drag and drop", limits) must follow theme,
290
- otherwise it's dark-on-dark and unreadable in dark mode. */
 
 
 
291
  [data-testid="stFileUploaderDropzone"],
292
  [data-testid="stFileUploaderDropzone"] span,
293
  [data-testid="stFileUploaderDropzone"] div,
294
  [data-testid="stFileUploaderDropzoneInstructions"],
295
  [data-testid="stFileUploaderDropzoneInstructions"] span,
296
- [data-testid="stFileUploaderDropzoneInstructions"] div {
297
- color: var(--text) !important;
298
- }
299
  [data-testid="stFileUploaderDropzoneInstructions"] small,
300
  [data-testid="stFileUploader"] small { color: var(--text-muted) !important; }
301
  [data-testid="stFileUploaderDropzone"] button {
302
  background: var(--surface-2) !important;
303
  color: var(--text) !important;
304
- border: 1px solid var(--border-2) !important;
 
305
  }
306
  [data-testid="stFileUploaderDropzone"] svg { color: var(--text-muted) !important; }
307
- /* Uploaded-file rows (name + size) */
308
  [data-testid="stFileUploaderFile"], [data-testid="stFileUploaderFile"] *,
309
  [data-testid="stFileUploaderFileName"] { color: var(--text) !important; }
310
- /* (Alert boxes keep Streamlit's default light pastel background + dark text,
311
- which stays readable in both light and dark β€” so we don't override them.) */
312
 
313
- /* ── Paste-screenshot component iframe β€” blend its frame into the page so
314
- there's no white bar around it in dark mode (the button colours come from
315
- Python args; this just removes the surrounding white iframe background). */
 
316
  iframe[title*="paste_button"], iframe[title*="streamlit_paste_button"] {
317
  background: transparent !important;
318
  color-scheme: light dark;
319
- /* Shrink the iframe to hug the button so its (white) inner document doesn't
320
- show as a full-width bar in dark mode β€” we can't style across the iframe. */
321
  width: 252px !important;
322
  max-width: 252px !important;
323
- border-radius: var(--radius-sm);
324
  overflow: hidden;
325
  }
326
- [data-testid="stCustomComponentV1"] {
327
- background: transparent !important;
328
- width: 252px !important;
329
- }
330
 
331
- /* ── Bordered containers β†’ cards ────────────────────────────────────── */
332
- [data-testid="stVerticalBlockBorderWrapper"] {
333
  background: var(--card-grad) !important;
334
  border: 1px solid var(--border) !important;
335
  border-radius: var(--radius) !important;
336
- box-shadow: 0 2px 8px oklch(0 0 0 / .08), inset 0 1px 0 var(--hairline) !important;
 
 
337
  }
338
-
339
- /* ── Metrics ────────────────────────────────────────────────────────── */
340
- [data-testid="stMetricValue"] {
341
- font-family: var(--ff-display) !important;
342
- font-size: 28px !important;
343
- font-weight: 600 !important;
344
  }
345
 
346
- /* ── Progress bar ───────────────────────────────────────────────────── */
 
 
 
 
347
  .stProgress > div > div > div > div {
348
  background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important;
 
349
  }
350
 
351
  /* ── Headings ───────────────────────────────────────────────────────── */
352
  h1, h2, h3 {
353
  font-family: var(--ff-display) !important;
354
- letter-spacing: -0.02em !important;
 
355
  color: var(--text) !important;
356
  }
357
 
358
  /* ── Dividers ───────────────────────────────────────────────────────── */
359
- hr { border-color: var(--border) !important; margin: 14px 0 !important; }
360
 
361
- /* ── Legacy ups-chip classes (kept for backward compat) ─────────────── */
362
  .ups-chip {
363
- display: inline-block; padding: 5px 13px; border-radius: var(--radius-pill);
364
- font-family: var(--ff-mono); font-size: 0.78rem; font-weight: 600; white-space: nowrap;
 
 
 
 
 
 
 
365
  }
366
- .ups-chip-ready { background: var(--go-soft); color: var(--go-ink); border: 1px solid var(--go); }
367
  .ups-chip-info { background: var(--accent-soft); color: var(--on-soft); border: 1px solid var(--accent-line); }
368
- .ups-chip-missing { background: var(--stop-soft); color: var(--stop-ink); border: 1px solid var(--stop); }
369
- .ups-chip-neutral { background: var(--warn-soft); color: var(--warn-ink); border: 1px solid var(--warn); }
370
-
371
- /* ── Section labels ─────────────────────────────────────────────────── */
372
  .ups-section {
373
- font-family: var(--ff-mono); font-size: 0.68rem; font-weight: 600;
374
- letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-faint);
375
- margin: 0 0 0.5rem;
 
376
  }
377
  .ups-side-title {
378
- font-family: var(--ff-mono); font-size: 0.7rem; font-weight: 700;
379
  letter-spacing: 0.14em; text-transform: uppercase;
380
- color: var(--text-faint); margin: 0 0 10px;
381
  }
382
 
383
  /* ── Toggle ─────────────────────────────────────────────────────────── */
384
- .stToggle label { font-family: var(--ff-body) !important; font-size: 13px !important; color: var(--text-muted) !important; }
 
 
385
 
386
- /* ── Alert / warning boxes ──────────────────────────────────────────── */
387
  [data-testid="stAlert"] {
388
- border-radius: var(--radius) !important;
389
  border: 1px solid var(--border) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  }
391
 
392
  /* ── Tabs ───────────────────────────────────────────────────────────── */
393
- .stTabs [aria-selected="true"] { color: var(--accent) !important; border-bottom-color: var(--accent) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  </style>
395
  """
396
 
@@ -406,6 +953,53 @@ def _design_css() -> str:
406
  return css.replace("</style>", "").replace("<style>", "")
407
 
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  def inject_css() -> None:
410
  """Inject fonts + the Spectrum design system + Streamlit bridge CSS."""
411
  st.markdown(_FONTS_LINK, unsafe_allow_html=True)
@@ -413,6 +1007,7 @@ def inject_css() -> None:
413
  if css:
414
  st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
415
  st.markdown(_BRIDGE_CSS, unsafe_allow_html=True)
 
416
 
417
 
418
  def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
@@ -461,11 +1056,19 @@ def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
461
 
462
 
463
  def screen_head(step_key: str, title: str, subtitle: str) -> None:
464
- """Render the eyebrow + title + subtitle header matching the design prototype."""
465
- eyebrow = STEP_EYEBROWS.get(step_key, "")
 
 
 
 
 
 
 
 
 
466
  st.markdown(
467
  f"""<div class="screen-head">
468
- <div class="eyebrow">{eyebrow}</div>
469
  <h1 class="screen-title">{title}</h1>
470
  <p class="screen-sub">{subtitle}</p>
471
  </div>""",
@@ -586,10 +1189,10 @@ def render_app_header(
586
  right_bits = []
587
  if step_label:
588
  right_bits.append(
589
- f'<span style="font-family:var(--ff-mono);font-size:11px;'
590
- f'letter-spacing:.12em;text-transform:uppercase;color:var(--accent);'
591
  f'border:1px solid var(--accent-line);background:var(--accent-soft);'
592
- f'padding:4px 11px;border-radius:999px;">{step_label}</span>'
593
  )
594
  if chip_label:
595
  right_bits.append(status_chip(chip_label, chip_kind))
@@ -602,9 +1205,9 @@ def render_app_header(
602
  f"""<div style="display:flex;align-items:flex-start;justify-content:space-between;
603
  gap:1rem;flex-wrap:wrap;margin-bottom:.3rem;">
604
  <div>
605
- <p style="font-family:var(--ff-display);font-size:1.7rem;font-weight:700;
606
- color:var(--text);margin:0;letter-spacing:-.02em;">{title}</p>
607
- <p style="color:var(--text-muted);font-size:.9rem;margin:.2rem 0 0;">{subtitle}</p>
608
  </div>
609
  {right_html}
610
  </div>""",
@@ -619,3 +1222,316 @@ def section_label(text: str) -> None:
619
 
620
  def sidebar_title(text: str) -> None:
621
  st.markdown(f'<p class="ups-side-title">{text}</p>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  # Per-step signature color: (H, C, H2) in oklch. Light default.
33
  STEP_COLORS: dict[str, tuple[float, float, float]] = {
34
+ # Aurora keeps ONE dominant violet, so the six steps sit inside a narrow
35
+ # band: the room warms or cools very slightly as the user moves forward,
36
+ # it never changes colour. At the "setup" values --accent resolves to
37
+ # #8B5CF6 β€” the Aurora primary.
38
+ "setup": (293.5, 0.213, 304.0),
39
+ "dossier": (290.0, 0.213, 301.0),
40
+ "screenshot": (297.0, 0.207, 308.0),
41
+ "confirmation": (287.0, 0.216, 299.0),
42
+ "analysis": (287.0, 0.216, 299.0),
43
+ "proposal": (300.0, 0.200, 310.0),
44
  }
45
  _DEFAULT_ACCENT = STEP_COLORS["setup"]
46
 
 
56
  '<link rel="preconnect" href="https://fonts.googleapis.com">'
57
  '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
58
  '<link href="https://fonts.googleapis.com/css2?'
59
+ 'family=Inter:wght@400;500;600&display=swap" rel="stylesheet">'
 
 
60
  )
61
 
62
+
63
  # Extra CSS layered on top of design_system.css to bridge Streamlit's DOM
64
  # with the design's component classes.
65
  _BRIDGE_CSS = """
66
  <style>
67
+ /* ── Aurora Glass Β· Streamlit bridge ────────────────────────────────────
68
+ Makes Streamlit's own widgets speak the Aurora design language. Every
69
+ value here comes from app/assets/design_system.css tokens, so the two
70
+ layers can never drift. Presentation only β€” no behaviour is changed. */
71
+
72
+ /* ── Global chrome ──────────────────────────────────────────────────── */
73
  #MainMenu, footer, [data-testid="stHeader"] { display: none !important; }
74
+ [data-testid="stAppViewContainer"] {
75
+ background:
76
+ radial-gradient(circle at 63% 18%, var(--aura-1), transparent 30%),
77
+ radial-gradient(circle at 82% 42%, var(--aura-2), transparent 34%),
78
+ radial-gradient(circle at 35% 72%, var(--aura-3), transparent 28%),
79
+ var(--bg) !important;
80
+ background-attachment: fixed !important;
81
+ background-size: 150% 150%, 165% 165%, 140% 140%, auto !important;
82
+ background-position: 24% 20%, 58% 42%, 30% 70%, 0 0 !important;
83
+ }
84
  [data-testid="stMain"] { background: transparent !important; }
85
  .block-container {
86
+ padding: 32px 32px 80px !important;
87
+ max-width: 960px !important;
88
  }
89
 
90
+ /* ── All text follows the theme (critical for dark-mode contrast) ──────
91
+ Streamlit paints text a fixed colour; on a dark ground that vanishes.
92
+ Force every text surface onto the tokens so contrast is correct in
93
+ BOTH themes. */
 
94
  [data-testid="stAppViewContainer"],
95
  [data-testid="stSidebar"],
96
  .stApp, .stMarkdown, .stText,
 
110
  [data-testid="stWidgetLabel"] label,
111
  label, p, li {
112
  color: var(--text) !important;
113
+ font-family: var(--ff-body) !important;
114
  }
115
+ [data-testid="stMarkdownContainer"] p,
116
+ [data-testid="stMarkdownContainer"] li { font-size: 14px !important; line-height: 1.5 !important; }
117
  [data-testid="stCaptionContainer"],
118
  [data-testid="stCaptionContainer"] p,
119
  small, .stCaption {
120
  color: var(--text-muted) !important;
121
+ font-size: 12px !important;
122
+ line-height: 1.4 !important;
123
+ }
124
+ [data-testid="stWidgetLabel"] p, [data-testid="stWidgetLabel"] label {
125
+ font-size: 13px !important; font-weight: 500 !important;
126
+ }
127
+ [data-testid="stMarkdownContainer"] code {
128
+ background: var(--surface-2) !important;
129
+ border: 1px solid var(--border-soft) !important;
130
+ border-radius: var(--radius-xs) !important;
131
+ color: var(--accent-light) !important;
132
+ padding: 1px 6px !important;
133
+ font-size: 12.5px !important;
134
+ }
135
+ [data-testid="stMetricValue"] {
136
+ color: var(--text) !important;
137
+ font-family: var(--ff-display) !important;
138
+ font-size: 26px !important;
139
+ font-weight: 600 !important;
140
+ letter-spacing: -0.02em !important;
141
+ font-variant-numeric: tabular-nums !important;
142
+ }
143
+ [data-testid="stMetricLabel"], [data-testid="stMetricLabel"] p {
144
+ color: var(--text-muted) !important; font-size: 12px !important;
145
  }
 
 
 
146
 
147
+ /* ── Visible focus on everything interactive (WCAG AA) ─────────────── */
148
+ button:focus-visible,
149
+ [role="button"]:focus-visible,
150
+ a:focus-visible,
151
+ input:focus-visible,
152
+ textarea:focus-visible,
153
+ select:focus-visible,
154
+ summary:focus-visible,
155
+ [data-baseweb="select"]:focus-within {
156
+ outline: 2px solid var(--focus-ring) !important;
157
+ outline-offset: 2px !important;
158
+ }
159
+
160
+ /* ── Select boxes and their menus ───────────────────────────────────── */
161
  [data-baseweb="select"] > div {
162
+ background: var(--surface) !important;
163
+ border: 1px solid var(--input-border) !important;
164
+ border-radius: var(--radius-input) !important;
165
  color: var(--text) !important;
166
+ min-height: 42px !important;
167
+ transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease) !important;
168
  }
169
  [data-baseweb="select"] div, [data-baseweb="select"] span, [data-baseweb="select"] input {
170
  color: var(--text) !important;
171
  }
172
+ [data-baseweb="select"] svg { color: var(--text-muted) !important; }
173
  [data-baseweb="popover"], [data-baseweb="menu"], [role="listbox"] {
174
+ background: var(--menu-bg) !important;
175
+ backdrop-filter: blur(24px) !important;
176
+ -webkit-backdrop-filter: blur(24px) !important;
177
+ border: 1px solid var(--menu-border) !important;
178
+ border-radius: var(--radius-btn) !important;
179
+ box-shadow: var(--menu-shadow) !important;
180
+ }
181
+ [role="option"] { background: transparent !important; color: var(--text-muted) !important; font-size: 13.5px !important; }
182
+ [role="option"]:hover { background: var(--surface-hover) !important; color: var(--text) !important; }
183
+ [role="option"][aria-selected="true"] { background: var(--accent-soft) !important; color: var(--text) !important; }
184
+
185
+ /* ── Tooltips ───────────────────────────────────────────────────────────
186
+ Every `help=` string lands here: the locked step rows, the settings
187
+ button, the disabled Continue button, the dossier path field. Streamlit
188
+ paints the tooltip on its own light surface and does not theme it, while
189
+ the text rule above puts near-white type on top β€” so the help text was
190
+ invisible in dark mode. Both surface and text are set here, and the inner
191
+ markdown container is overridden explicitly because the global rule
192
+ targets it by test id and would otherwise win. */
193
+ /* The surface. Painted on the two elements that own it, never on the ones
194
+ in between. */
195
+ [data-testid="stTooltipContent"],
196
+ div[data-baseweb="tooltip"],
197
+ [role="tooltip"] {
198
+ background: var(--menu-bg) !important;
199
+ border: 1px solid var(--menu-border) !important;
200
+ border-radius: var(--radius-input) !important;
201
+ box-shadow: var(--menu-shadow) !important;
202
+ color: var(--text) !important;
203
+ font-family: var(--ff-body) !important;
204
+ max-width: 320px !important;
205
+ }
206
+ [data-testid="stTooltipContent"] { padding: 10px 12px !important; }
207
+ /* When one is nested inside the other, only the inner one keeps the chrome,
208
+ so the border and shadow are never drawn twice. */
209
+ div[data-baseweb="tooltip"]:has([data-testid="stTooltipContent"]),
210
+ [role="tooltip"]:has([data-testid="stTooltipContent"]) {
211
+ background: transparent !important;
212
+ border: none !important;
213
+ box-shadow: none !important;
214
+ padding: 0 !important;
215
+ }
216
+ /* Nothing inside may repaint the surface β€” baseweb's own wrappers are what
217
+ put a white plate under near-white text. `code` restores its own fill
218
+ below, because it comes after this. */
219
+ div[data-baseweb="tooltip"] *,
220
+ [role="tooltip"] *,
221
+ [data-testid="stTooltipContent"] * {
222
+ background: transparent !important;
223
+ background-color: transparent !important;
224
+ border-color: transparent !important;
225
+ box-shadow: none !important;
226
+ }
227
+ /* The text. The global "all text follows the theme" rule above targets
228
+ [data-testid="stMarkdownContainer"] p by test id, so each of these is
229
+ named explicitly to out-specify it. */
230
+ [data-testid="stTooltipContent"],
231
+ [data-testid="stTooltipContent"] *,
232
+ div[data-baseweb="tooltip"] *,
233
+ [role="tooltip"] *,
234
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"],
235
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"] p,
236
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"] li,
237
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"] strong,
238
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"] code {
239
+ color: var(--text) !important;
240
+ -webkit-text-fill-color: var(--text) !important;
241
+ font-size: 13px !important;
242
+ line-height: 1.45 !important;
243
+ }
244
+ [data-testid="stTooltipContent"] [data-testid="stMarkdownContainer"] code {
245
+ background: var(--surface-2) !important;
246
+ background-color: var(--surface-2) !important;
247
+ border: 1px solid var(--border-soft) !important;
248
+ }
249
+ /* The little "?" affordance beside a labelled widget. */
250
+ [data-testid="stTooltipHoverTarget"],
251
+ [data-testid="stTooltipIcon"] { color: var(--text-faint) !important; }
252
+ [data-testid="stTooltipHoverTarget"]:hover,
253
+ [data-testid="stTooltipIcon"]:hover { color: var(--text-muted) !important; }
254
+
255
+ /* ── Expander ───────────────────────────────────────────────────────── */
256
+ [data-testid="stExpander"] details {
257
+ background: var(--card-grad) !important;
258
+ border: 1px solid var(--border) !important;
259
+ border-radius: var(--radius) !important;
260
  }
 
 
 
 
261
  [data-testid="stExpander"] summary,
262
  [data-testid="stExpander"] summary span,
263
  [data-testid="stExpander"] p { color: var(--text) !important; }
264
 
265
+ /* ── Radio / checkbox ───────────────────────────────────────────────── */
266
  [data-baseweb="radio"] div, [data-testid="stCheckbox"] label, [data-testid="stRadio"] label {
267
  color: var(--text) !important;
268
  }
269
+ [data-baseweb="radio"] [aria-checked="true"] div:first-child {
270
+ border-color: var(--accent) !important; background: var(--accent) !important;
271
+ }
272
 
273
  /* ── Sidebar ────────────────────────────────────────────────────────── */
274
  section[data-testid="stSidebar"] {
275
+ background: var(--sidebar-bg) !important;
276
+ border-right: 1px solid var(--border-soft) !important;
277
+ backdrop-filter: blur(20px) !important;
278
+ -webkit-backdrop-filter: blur(20px) !important;
279
  min-width: 240px !important; max-width: 260px !important;
280
  }
281
+ section[data-testid="stSidebar"] .block-container { padding: 22px 16px 24px !important; }
 
 
282
 
283
+ /* ── Sidebar navigation β€” the step rail ─────────────────────────────── */
284
+ section[data-testid="stSidebar"] .stButton button {
285
  background: none !important;
286
  border: 1px solid transparent !important;
287
+ border-radius: var(--radius-btn) !important;
288
  text-align: left !important;
289
  justify-content: flex-start !important;
290
  font-family: var(--ff-body) !important;
291
+ font-size: 14px !important;
292
  font-weight: 500 !important;
293
  color: var(--text-muted) !important;
294
+ padding: 0 13px !important;
295
+ height: 44px !important;
296
+ min-height: 44px !important;
297
  width: 100% !important;
298
  box-shadow: none !important;
299
+ transition: background var(--t-base) var(--ease), color var(--t-base) var(--ease),
300
+ border-color var(--t-base) var(--ease), transform var(--t-base) var(--ease) !important;
301
  }
302
+ section[data-testid="stSidebar"] .stButton button:hover {
303
+ background: var(--nav-hover) !important;
304
  color: var(--text) !important;
305
+ transform: translateX(2px) !important;
306
  }
307
+ section[data-testid="stSidebar"] .stButton button[kind="primary"] {
308
+ background: linear-gradient(90deg, rgba(139, 92, 246, 0.25), rgba(139, 92, 246, 0.085)) !important;
309
+ border: 1px solid rgba(167, 139, 250, 0.22) !important;
310
+ color: var(--nav-active-text) !important;
311
+ font-weight: 500 !important;
312
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
313
+ transform: none !important;
314
  }
315
+ section[data-testid="stSidebar"] .stButton button[kind="secondary"],
316
+ section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] {
317
+ background: var(--surface) !important;
318
+ border: 1px solid var(--border) !important;
 
319
  color: var(--text) !important;
320
+ font-weight: 500 !important;
321
  }
322
+ section[data-testid="stSidebar"] .stButton button[kind="secondary"]:hover,
323
+ section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover {
324
+ background: var(--surface-hover) !important;
325
+ border-color: var(--border-2) !important;
326
  color: var(--text) !important;
327
  }
328
+ section[data-testid="stSidebar"] .stButton button:disabled {
329
  opacity: 0.45 !important;
330
  cursor: not-allowed !important;
331
+ transform: none !important;
332
  }
333
 
334
  /* ── Main action buttons ────────────────────────────────────────────── */
335
+ .stButton button {
336
  font-family: var(--ff-body) !important;
337
+ font-weight: 500 !important;
338
  font-size: 14px !important;
339
  height: 44px !important;
340
+ border-radius: var(--radius-btn) !important;
341
+ transition: transform var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease),
342
+ background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease) !important;
343
  }
344
+ .stButton button[kind="primary"], [data-testid="stBaseButton-primary"] {
345
  color: var(--accent-fg) !important;
346
  border: none !important;
347
+ background: var(--accent-grad) !important;
348
+ box-shadow: 0 8px 24px var(--accent-glow) !important;
349
+ padding: 0 18px !important;
350
  }
351
+ .stButton button[kind="primary"]:hover, [data-testid="stBaseButton-primary"]:hover {
352
+ transform: translateY(-1px) !important;
353
+ box-shadow: 0 10px 28px var(--accent-glow) !important;
 
 
 
 
354
  }
355
+ .stButton button[kind="primary"]:active, [data-testid="stBaseButton-primary"]:active {
356
+ transform: scale(0.98) !important;
357
+ }
358
+ .stButton button[kind="secondary"],
 
 
359
  [data-testid="stBaseButton-secondary"],
360
+ .stDownloadButton button {
361
+ background: var(--surface) !important;
362
  color: var(--text) !important;
363
+ border: 1px solid var(--border) !important;
364
+ box-shadow: none !important;
365
  }
366
+ .stButton button[kind="secondary"]:hover,
367
  [data-testid="stBaseButton-secondary"]:hover,
368
+ .stDownloadButton button:hover {
369
+ background: var(--surface-hover) !important;
370
+ border-color: var(--border-2) !important;
 
 
 
 
 
 
 
 
 
 
371
  color: var(--text) !important;
372
+ transform: translateY(-1px) !important;
373
  }
374
+ .stButton button:active, .stDownloadButton button:active { transform: scale(0.98) !important; }
375
+ /* No button may lose its label colour on hover. Primary keeps its own. */
376
+ .stButton button:not([kind="primary"]):hover,
377
+ .stButton button:not([kind="primary"]):hover *,
 
378
  [data-testid="stBaseButton-secondary"]:hover,
379
  [data-testid="stBaseButton-secondary"]:hover *,
380
+ .stDownloadButton button:hover,
381
+ .stDownloadButton button:hover * { color: var(--text) !important; }
382
+ .stButton button:disabled, .stDownloadButton button:disabled {
383
+ opacity: 0.45 !important; box-shadow: none !important; transform: none !important;
 
 
 
 
384
  }
385
 
386
+ /* ── Inputs ─────────────────────────────────────────────────────────────
387
+ The baseweb WRAPPER carries the background, so it must be targeted too
388
+ or the field shows as a pale box on the dark ground. */
389
  .stTextInput input, .stTextArea textarea, .stNumberInput input,
390
  .stTextInput div[data-baseweb="input"],
391
  .stTextInput div[data-baseweb="base-input"],
392
  .stNumberInput div[data-baseweb="input"],
393
  .stNumberInput div[data-baseweb="base-input"] {
394
+ background: var(--surface) !important;
395
+ border-color: var(--input-border) !important;
396
+ border-radius: var(--radius-input) !important;
397
  color: var(--text) !important;
398
  font-family: var(--ff-body) !important;
399
  font-size: 14px !important;
400
  -webkit-text-fill-color: var(--text) !important;
401
+ transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease) !important;
402
  }
403
+ .stTextInput input, .stNumberInput input { min-height: 42px !important; }
404
+ .stTextInput input::placeholder, .stTextArea textarea::placeholder {
405
+ color: var(--text-faint) !important; -webkit-text-fill-color: var(--text-faint) !important;
406
  }
407
+ .stTextInput input:focus, .stTextArea textarea:focus,
408
+ .stTextInput div[data-baseweb="base-input"]:focus-within,
409
+ .stNumberInput div[data-baseweb="base-input"]:focus-within {
410
+ border-color: var(--input-focus) !important;
411
+ box-shadow: 0 0 0 3px var(--ring) !important;
412
+ }
413
+ .stTextInput button { background: transparent !important; color: var(--text-muted) !important; }
414
+ .stTextInput button:hover { color: var(--text) !important; }
415
 
416
+ /* ── File uploader β†’ glass dropzone ─────────────────────────────────── */
417
  [data-testid="stFileUploaderDropzone"] {
418
+ background: var(--dropzone-bg) !important;
419
+ border: 1.5px dashed var(--dropzone-border) !important;
420
  border-radius: var(--radius) !important;
421
+ transition: border-color var(--t-base) var(--ease), background var(--t-base) var(--ease),
422
+ box-shadow var(--t-base) var(--ease) !important;
423
  }
424
+ /* Drag-over / hover / keyboard focus all give the same unmistakable state. */
425
+ [data-testid="stFileUploaderDropzone"]:hover,
426
+ [data-testid="stFileUploaderDropzone"]:focus-within {
427
  border-color: var(--accent) !important;
428
+ background: var(--dropzone-bg-hover) !important;
429
+ box-shadow: 0 0 0 3px var(--ring) !important;
430
  }
431
+ /* Nothing decorative may ever swallow a drop. */
432
+ [data-testid="stFileUploader"],
433
+ [data-testid="stFileUploader"] *,
434
+ [data-testid="stFileUploaderDropzone"],
435
+ [data-testid="stFileUploaderDropzone"] * { pointer-events: auto !important; }
436
  [data-testid="stFileUploaderDropzone"],
437
  [data-testid="stFileUploaderDropzone"] span,
438
  [data-testid="stFileUploaderDropzone"] div,
439
  [data-testid="stFileUploaderDropzoneInstructions"],
440
  [data-testid="stFileUploaderDropzoneInstructions"] span,
441
+ [data-testid="stFileUploaderDropzoneInstructions"] div { color: var(--text) !important; }
 
 
442
  [data-testid="stFileUploaderDropzoneInstructions"] small,
443
  [data-testid="stFileUploader"] small { color: var(--text-muted) !important; }
444
  [data-testid="stFileUploaderDropzone"] button {
445
  background: var(--surface-2) !important;
446
  color: var(--text) !important;
447
+ border: 1px solid var(--border) !important;
448
+ border-radius: var(--radius-btn) !important;
449
  }
450
  [data-testid="stFileUploaderDropzone"] svg { color: var(--text-muted) !important; }
 
451
  [data-testid="stFileUploaderFile"], [data-testid="stFileUploaderFile"] *,
452
  [data-testid="stFileUploaderFileName"] { color: var(--text) !important; }
 
 
453
 
454
+ /* ── Paste-screenshot component iframe ──────────────────────────────────
455
+ Its inner document belongs to a third-party component, so it cannot be
456
+ styled across the frame β€” shrink it to hug its button so no pale bar
457
+ shows on the dark ground. */
458
  iframe[title*="paste_button"], iframe[title*="streamlit_paste_button"] {
459
  background: transparent !important;
460
  color-scheme: light dark;
 
 
461
  width: 252px !important;
462
  max-width: 252px !important;
463
+ border-radius: var(--radius-btn);
464
  overflow: hidden;
465
  }
466
+ [data-testid="stCustomComponentV1"] { background: transparent !important; width: 252px !important; }
 
 
 
467
 
468
+ /* ── Bordered containers β†’ glass cards ──────────────────────────────── */
469
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"] {
470
  background: var(--card-grad) !important;
471
  border: 1px solid var(--border) !important;
472
  border-radius: var(--radius) !important;
473
+ box-shadow: var(--shadow), var(--inset-hi) !important;
474
+ padding: 20px !important;
475
+ transition: border-color var(--t-base) var(--ease), background var(--t-base) var(--ease) !important;
476
  }
477
+ /* Border and surface only β€” never a lift, so a panel you are typing in
478
+ does not move under the cursor. */
479
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:hover {
480
+ border-color: var(--border-2) !important;
481
+ background: var(--card-grad-hi) !important;
 
482
  }
483
 
484
+ /* ── Progress ───────────────────────────────────────────────────────── */
485
+ .stProgress > div > div > div {
486
+ background: var(--surface-3) !important;
487
+ border-radius: var(--radius-pill) !important;
488
+ }
489
  .stProgress > div > div > div > div {
490
  background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important;
491
+ border-radius: var(--radius-pill) !important;
492
  }
493
 
494
  /* ── Headings ───────────────────────────────────────────────────────── */
495
  h1, h2, h3 {
496
  font-family: var(--ff-display) !important;
497
+ font-weight: 600 !important;
498
+ letter-spacing: -0.025em !important;
499
  color: var(--text) !important;
500
  }
501
 
502
  /* ── Dividers ───────────────────────────────────────────────────────── */
503
+ hr { border-color: var(--border-soft) !important; margin: 16px 0 !important; }
504
 
505
+ /* ── Chips and micro-labels (emitted by theme.py) ───────────────────── */
506
  .ups-chip {
507
+ display: inline-flex; align-items: center;
508
+ padding: 5px 12px; border-radius: var(--radius-pill);
509
+ font-family: var(--ff-mono); font-size: 12px; font-weight: 500;
510
+ letter-spacing: 0.02em; white-space: nowrap;
511
+ }
512
+ .ups-chip-ready { background: var(--go-soft); color: var(--go-ink); border: 1px solid rgba(34, 197, 94, 0.28); }
513
+ .ups-chip-ready::before {
514
+ content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%;
515
+ background: currentColor; margin-right: 7px; flex: none;
516
  }
 
517
  .ups-chip-info { background: var(--accent-soft); color: var(--on-soft); border: 1px solid var(--accent-line); }
518
+ .ups-chip-missing { background: var(--stop-soft); color: var(--stop-ink); border: 1px solid rgba(239, 68, 68, 0.28); }
519
+ .ups-chip-neutral { background: var(--warn-soft); color: var(--warn-ink); border: 1px solid rgba(245, 158, 11, 0.28); }
 
 
520
  .ups-section {
521
+ font-family: var(--ff-display) !important; font-size: 18px !important;
522
+ font-weight: 600 !important; letter-spacing: -0.008em !important;
523
+ line-height: 1.3 !important; text-transform: none !important;
524
+ color: var(--text) !important; margin: 0 0 18px !important;
525
  }
526
  .ups-side-title {
527
+ font-family: var(--ff-mono); font-size: 12px; font-weight: 500;
528
  letter-spacing: 0.14em; text-transform: uppercase;
529
+ color: var(--text-faint); margin: 0 0 8px;
530
  }
531
 
532
  /* ── Toggle ─────────────────────────────────────────────────────────── */
533
+ .stToggle label, [data-testid="stWidgetLabel"] label {
534
+ font-family: var(--ff-body) !important; font-size: 13px !important; color: var(--text-muted) !important;
535
+ }
536
 
537
+ /* ── Alerts β€” one design system, semantic tint kept ─────────────────── */
538
  [data-testid="stAlert"] {
539
+ background: var(--card-grad) !important;
540
  border: 1px solid var(--border) !important;
541
+ border-radius: var(--radius) !important;
542
+ box-shadow: var(--shadow-sm) !important;
543
+ backdrop-filter: blur(20px) !important;
544
+ -webkit-backdrop-filter: blur(20px) !important;
545
+ }
546
+ [data-testid="stAlert"] p, [data-testid="stAlert"] span, [data-testid="stAlert"] div {
547
+ color: var(--text) !important;
548
+ }
549
+ [data-testid="stAlert"]:has([data-testid="stAlertContentSuccess"]) {
550
+ border-color: rgba(34, 197, 94, 0.28) !important;
551
+ background: linear-gradient(145deg, rgba(34, 197, 94, 0.10), rgba(34, 197, 94, 0.035)) !important;
552
+ }
553
+ [data-testid="stAlert"]:has([data-testid="stAlertContentWarning"]) {
554
+ border-color: rgba(245, 158, 11, 0.28) !important;
555
+ background: linear-gradient(145deg, rgba(245, 158, 11, 0.10), rgba(245, 158, 11, 0.035)) !important;
556
+ }
557
+ [data-testid="stAlert"]:has([data-testid="stAlertContentError"]) {
558
+ border-color: rgba(239, 68, 68, 0.28) !important;
559
+ background: linear-gradient(145deg, rgba(239, 68, 68, 0.10), rgba(239, 68, 68, 0.035)) !important;
560
+ }
561
+ [data-testid="stAlert"]:has([data-testid="stAlertContentInfo"]) {
562
+ border-color: var(--accent-line) !important;
563
+ background: linear-gradient(145deg, rgba(139, 92, 246, 0.10), rgba(139, 92, 246, 0.035)) !important;
564
  }
565
 
566
  /* ── Tabs ───────────────────────────────────────────────────────────── */
567
+ .stTabs [aria-selected="true"] {
568
+ color: var(--accent-light) !important;
569
+ border-bottom-color: var(--accent) !important;
570
+ }
571
+
572
+ /* ── Spinner ────────────────────────────────────────────────────────── */
573
+ [data-testid="stSpinner"] i, .stSpinner > div { border-top-color: var(--accent) !important; }
574
+ [data-testid="stSpinner"] p { color: var(--text-muted) !important; }
575
+
576
+ /* ── Artboard parity ────────────────────────────────────────────────────
577
+ Pulls each Streamlit control onto the exact geometry of its counterpart on
578
+ the design canvas: same height, radius, stroke, fill and glow. Controls
579
+ stay controls β€” this is geometry and fill only. Nothing here puts a
580
+ containing-block property (transform / filter / backdrop-filter / contain)
581
+ on an ancestor of the file uploader. */
582
+
583
+ /* The canvas stage: 32px page padding on a column the width of a 1440 shell
584
+ minus its 240px rail. */
585
+ .block-container { padding: 24px 32px 80px !important; max-width: 1120px !important; }
586
+
587
+ /* Panels β€” st.container(border=True) is the canvas's .panel.glass. */
588
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"] { padding: 24px !important; }
589
+
590
+ /* Primary action = .cta β€” 44px, 10px radius, violet gradient, violet glow. */
591
+ .stButton button[kind="primary"],
592
+ [data-testid="stBaseButton-primary"] {
593
+ height: 44px !important; min-height: 44px !important;
594
+ padding: 0 20px !important;
595
+ border-radius: var(--radius-input) !important;
596
+ font-weight: 600 !important;
597
+ box-shadow: var(--gl-glow), inset 0 1px 0 rgba(255, 255, 255, 0.24) !important;
598
+ }
599
+ .stButton button[kind="primary"]:hover,
600
+ [data-testid="stBaseButton-primary"]:hover {
601
+ transform: translateY(-2px) !important;
602
+ filter: brightness(1.08) !important;
603
+ box-shadow: 0 0 28px var(--accent-glow), 0 10px 26px rgba(0, 0, 0, 0.40),
604
+ inset 0 1px 0 rgba(255, 255, 255, 0.26) !important;
605
+ }
606
+
607
+ /* Secondary action = .btn β€” glass fill, canvas stroke, inner highlight. */
608
+ .stButton button[kind="secondary"],
609
+ [data-testid="stBaseButton-secondary"],
610
+ .stDownloadButton button {
611
+ height: 44px !important; min-height: 44px !important;
612
+ padding: 0 18px !important;
613
+ border-radius: var(--radius-input) !important;
614
+ background: var(--gl-glass) !important;
615
+ border: 1px solid var(--gl-stroke) !important;
616
+ box-shadow: var(--gl-inner) !important;
617
+ font-weight: 500 !important;
618
+ }
619
+ .stButton button[kind="secondary"]:hover,
620
+ [data-testid="stBaseButton-secondary"]:hover,
621
+ .stDownloadButton button:hover {
622
+ background: var(--gl-glass-hi) !important;
623
+ border-color: var(--gl-stroke) !important;
624
+ transform: translateY(-1px) !important;
625
+ }
626
+
627
+ /* Text entry = .field β€” 44px, 16px radius, glass-lo, violet focus + glow. */
628
+ .stTextInput div[data-baseweb="base-input"],
629
+ .stTextInput div[data-baseweb="input"],
630
+ .stNumberInput div[data-baseweb="base-input"],
631
+ .stNumberInput div[data-baseweb="input"],
632
+ .stTextArea div[data-baseweb="base-input"],
633
+ .stTextArea textarea,
634
+ div[data-baseweb="select"] > div {
635
+ background: var(--gl-glass-lo) !important;
636
+ border-color: var(--gl-stroke-soft) !important;
637
+ border-radius: var(--radius) !important;
638
+ }
639
+ .stTextInput input, .stNumberInput input {
640
+ height: 42px !important; min-height: 42px !important;
641
+ background: transparent !important;
642
+ }
643
+ div[data-baseweb="select"] > div { min-height: 44px !important; }
644
+ .stTextInput div[data-baseweb="base-input"]:hover,
645
+ .stNumberInput div[data-baseweb="base-input"]:hover,
646
+ div[data-baseweb="select"] > div:hover { background: var(--gl-glass-hi) !important; }
647
+ .stTextInput div[data-baseweb="base-input"]:focus-within,
648
+ .stNumberInput div[data-baseweb="base-input"]:focus-within,
649
+ .stTextArea div[data-baseweb="base-input"]:focus-within,
650
+ div[data-baseweb="select"] > div:focus-within {
651
+ border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.5) !important;
652
+ box-shadow: var(--gl-glow) !important;
653
+ background: var(--gl-glass) !important;
654
+ }
655
+
656
+ /* Tabs = .seg β€” a padded track with a lit pill, not an underline. */
657
+ .stTabs [data-baseweb="tab-list"] {
658
+ display: inline-flex !important; gap: 3px !important; padding: 4px !important;
659
+ border-radius: var(--radius-input) !important;
660
+ background: var(--gl-glass-lo) !important;
661
+ border: 1px solid var(--gl-stroke-soft) !important;
662
+ border-bottom: 1px solid var(--gl-stroke-soft) !important;
663
+ }
664
+ .stTabs [data-baseweb="tab"] {
665
+ height: 32px !important; padding: 0 14px !important;
666
+ border-radius: 7px !important; border: none !important;
667
+ font-size: 13px !important; font-weight: 500 !important;
668
+ color: var(--text-muted) !important;
669
+ transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease) !important;
670
+ }
671
+ .stTabs [data-baseweb="tab"]:hover { background: var(--gl-glass-hi) !important; color: var(--text) !important; }
672
+ .stTabs [aria-selected="true"] {
673
+ background: var(--gl-glass-hi) !important;
674
+ color: var(--text) !important;
675
+ box-shadow: var(--gl-inner) !important;
676
+ border-bottom-color: transparent !important;
677
+ }
678
+ .stTabs [data-baseweb="tab-highlight"], .stTabs [data-baseweb="tab-border"] { display: none !important; }
679
+
680
+ /* Progress = .meter β€” 6px track, violet gradient fill, violet glow. */
681
+ .stProgress > div > div > div {
682
+ height: 6px !important; border-radius: 3px !important;
683
+ background: var(--gl-track) !important;
684
+ }
685
+ .stProgress > div > div > div > div { border-radius: 3px !important; box-shadow: var(--gl-glow) !important; }
686
+
687
+ /* Drop target = .dz β€” the canvas radius and padding. Fill and stroke keep the
688
+ stronger values the drop target needs to stay unmistakable; the canvas's
689
+ own 1px hairline disappears on this ground. */
690
+ [data-testid="stFileUploaderDropzone"] { border-radius: var(--radius) !important; padding: 26px !important; }
691
+
692
+ /* ── The rail's rows = .nav ──────────────────────────────────────────────
693
+ Each row is still a real st.button. Streamlit stamps the element key onto
694
+ the wrapper as `st-key-<key>`, which is what lets each step carry its own
695
+ icon: the SVG rides as a mask so it takes the row's colour and changes with
696
+ its state. `type="primary"` is the current step, `disabled` is a locked one. */
697
+ /* Both kinds are named so these out-rank the generic sidebar button rules
698
+ above, which paint a secondary button as a filled surface β€” a nav row is
699
+ transparent until you are on it or over it. */
700
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button,
701
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="secondary"],
702
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] [data-testid="stBaseButton-secondary"] {
703
+ background: none !important;
704
+ border: 1px solid transparent !important;
705
+ border-radius: var(--radius-input) !important;
706
+ height: 44px !important; min-height: 44px !important;
707
+ padding: 0 12px !important;
708
+ gap: 12px !important;
709
+ justify-content: flex-start !important;
710
+ color: var(--text-muted) !important;
711
+ font-size: 14px !important; font-weight: 500 !important;
712
+ box-shadow: none !important;
713
+ transform: none !important;
714
+ }
715
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button::before {
716
+ content: ""; flex: none; width: 18px; height: 18px;
717
+ background: currentColor;
718
+ -webkit-mask: var(--nav-ico) center / 18px 18px no-repeat;
719
+ mask: var(--nav-ico) center / 18px 18px no-repeat;
720
+ transition: background var(--t-fast) var(--ease);
721
+ }
722
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button:hover,
723
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="secondary"]:hover {
724
+ background: var(--gl-glass-hi) !important;
725
+ color: var(--text) !important;
726
+ transform: none !important;
727
+ }
728
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="primary"] {
729
+ background: var(--gl-glass) !important;
730
+ border-color: oklch(0.627 var(--accent-c) var(--accent-h) / 0.45) !important;
731
+ color: var(--text) !important;
732
+ box-shadow: var(--gl-glow), var(--gl-inner) !important;
733
+ }
734
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="primary"]::before {
735
+ background: var(--accent) !important;
736
+ }
737
+ /* the chevron that marks the row you are on */
738
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="primary"]::after {
739
+ content: ""; margin-left: auto; flex: none; width: 14px; height: 14px; opacity: 0.5;
740
+ background: currentColor;
741
+ -webkit-mask: var(--ico-arrow) center / 14px 14px no-repeat;
742
+ mask: var(--ico-arrow) center / 14px 14px no-repeat;
743
+ }
744
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button:disabled {
745
+ opacity: 0.4 !important; background: none !important; border-color: transparent !important;
746
+ }
747
+
748
+ /* ── Motion ─────────────────────────────────────────────────────────────
749
+ What makes this feel alive: light that moves slowly behind the glass, a
750
+ specular pass across the one primary action, the fit score flowing while
751
+ it is on screen, and the step rail drawing itself in. Nothing here is
752
+ position/z-index/backdrop-filter on an interactive element, so none of it
753
+ can affect clicking, typing or dropping a file. */
754
+ @media (prefers-reduced-motion: no-preference) {
755
+ @keyframes auroraDrift {
756
+ 0% { background-position: 20% 14%, 62% 38%, 26% 74%, 0 0; }
757
+ 50% { background-position: 38% 30%, 46% 54%, 44% 58%, 0 0; }
758
+ 100% { background-position: 20% 14%, 62% 38%, 26% 74%, 0 0; }
759
+ }
760
+ /* Dust in the light. One repeating tile scrolled by exactly its own
761
+ height, so the loop has no seam. Fixed, click-through, and painted
762
+ under the grain β€” it can never take a click or swallow a dropped file. */
763
+ @keyframes upsMotes { from { background-position: 0 0; }
764
+ to { background-position: 0 -480px; } }
765
+ @keyframes upsGlint { 0% { transform: translateX(-190%) skewX(-16deg); }
766
+ 38% { transform: translateX(430%) skewX(-16deg); }
767
+ 100% { transform: translateX(430%) skewX(-16deg); } }
768
+ @keyframes upsSheen { 0% { background-position: -45% 0, 0 0; }
769
+ 58% { background-position: 155% 0, 0 0; }
770
+ 100% { background-position: 155% 0, 0 0; } }
771
+ @keyframes upsBarLit { 0%, 100% { opacity: 0.65; }
772
+ 50% { opacity: 1; } }
773
+ @keyframes stRise { from { opacity: 0; transform: translateY(10px); } }
774
+ @keyframes stSweep { 0% { transform: translateX(-130%) skewX(-14deg); }
775
+ 100% { transform: translateX(260%) skewX(-14deg); } }
776
+ @keyframes stFlow { 0% { background-position: 0% 50%; }
777
+ 100% { background-position: 200% 50%; } }
778
+ @keyframes stGrow { from { transform: scaleX(0); } }
779
+ @keyframes stBarDraw { from { transform: scaleY(0); } }
780
+ @keyframes stPulse { 0%, 100% { opacity: 0.45; transform: scale(1); }
781
+ 50% { opacity: 1; transform: scale(1.3); } }
782
+
783
+ /* The room breathes. One slow loop, behind everything, click-through by
784
+ nature because it is a background, not an element. */
785
+ [data-testid="stAppViewContainer"] {
786
+ animation: auroraDrift 34s ease-in-out infinite !important;
787
+ }
788
+
789
+ /* Panels arrive. Delays stay short so a stalled animation can never leave
790
+ a card stranded at opacity 0. */
791
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"],
792
+ [data-testid="stAlert"],
793
+ [data-testid="stExpander"],
794
+ [data-testid="stMetric"] { animation: stRise 420ms var(--ease) backwards !important; }
795
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:nth-of-type(2) { animation-delay: 70ms !important; }
796
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:nth-of-type(3) { animation-delay: 130ms !important; }
797
+ /* A transformed element is a containing block for fixed descendants, even
798
+ for the 420ms an entrance lasts. The card holding a file uploader fades
799
+ only β€” never transforms β€” so a drop can never be affected by chrome. */
800
+ @keyframes stFade { from { opacity: 0; } }
801
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:has([data-testid="stFileUploader"]) {
802
+ animation: stFade 420ms var(--ease) backwards !important;
803
+ }
804
+
805
+ /* One pass of light across the primary action when the cursor arrives. */
806
+ .stButton button[kind="primary"],
807
+ [data-testid="stBaseButton-primary"] { position: relative !important; overflow: hidden !important; }
808
+ .stButton button[kind="primary"]::after,
809
+ [data-testid="stBaseButton-primary"]::after {
810
+ content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 45%;
811
+ pointer-events: none;
812
+ background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.22) 50%, transparent);
813
+ transform: translateX(-130%) skewX(-14deg);
814
+ }
815
+ .stButton button[kind="primary"]:hover::after,
816
+ [data-testid="stBaseButton-primary"]:hover::after { animation: stSweep 820ms var(--ease); }
817
+
818
+ /* The fit score fills in, then keeps flowing while it is on screen β€” the
819
+ one number this product is built to produce. No fill-mode, so if the
820
+ animation never runs the bar still rests at its correct width. */
821
+ .stProgress > div > div > div { overflow: hidden !important; }
822
+ .stProgress > div > div > div > div {
823
+ background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent)) !important;
824
+ background-size: 200% 100% !important;
825
+ transform-origin: left center;
826
+ animation: stFlow 3.4s linear infinite, stGrow 900ms var(--ease) !important;
827
+ }
828
+
829
+ /* The rail's own ::before carries the step icon (see "Artboard parity"),
830
+ so the old accent bar that used to live here is gone: it claimed the same
831
+ pseudo-element and squeezed the icon to 2px. The active row is already
832
+ unmistakable without it β€” glass fill, violet border, violet icon, glow
833
+ and a chevron. */
834
+
835
+ /* Dust drifting up through the room, on every screen. */
836
+ body::before {
837
+ content: ""; position: fixed; inset: 0; z-index: 9998;
838
+ pointer-events: none;
839
+ background-image:
840
+ radial-gradient(circle 1.6px at 11% 14%, var(--mote), transparent),
841
+ radial-gradient(circle 1.3px at 38% 61%, var(--mote), transparent),
842
+ radial-gradient(circle 1.8px at 62% 26%, var(--mote), transparent),
843
+ radial-gradient(circle 1.2px at 82% 78%, var(--mote), transparent),
844
+ radial-gradient(circle 1.5px at 24% 88%, var(--mote), transparent),
845
+ radial-gradient(circle 1.3px at 91% 42%, var(--mote), transparent);
846
+ background-size: 380px 480px;
847
+ background-repeat: repeat;
848
+ animation: upsMotes 26s linear infinite;
849
+ opacity: 0.7;
850
+ }
851
+
852
+ /* Every panel catches a slow sheen. Paint only: no position, no z-index,
853
+ no filter β€” the card holding the file uploader is styled by exactly the
854
+ same rule and stays a plain, undisturbed drop target. */
855
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"] {
856
+ background-image:
857
+ linear-gradient(100deg, transparent 36%, rgba(255, 255, 255, 0.055) 50%, transparent 64%),
858
+ var(--card-grad) !important;
859
+ background-size: 44% 100%, 100% 100% !important;
860
+ background-repeat: no-repeat, no-repeat !important;
861
+ background-position: -45% 0, 0 0 !important;
862
+ animation: stRise 420ms var(--ease) backwards,
863
+ upsSheen 11s var(--ease) 700ms infinite !important;
864
+ }
865
+ /* The hover rule above is a `background` shorthand and outranks the layered
866
+ background this sheen needs, so the hover state restates both layers. */
867
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:hover {
868
+ background-image:
869
+ linear-gradient(100deg, transparent 36%, rgba(255, 255, 255, 0.055) 50%, transparent 64%),
870
+ var(--card-grad-hi) !important;
871
+ background-size: 44% 100%, 100% 100% !important;
872
+ background-repeat: no-repeat, no-repeat !important;
873
+ }
874
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:nth-of-type(2) { animation-delay: 70ms, 3.4s !important; }
875
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:nth-of-type(3) { animation-delay: 130ms, 6.1s !important; }
876
+ [data-testid="stVerticalBlock"][class*="st-key-ups_panel"]:has([data-testid="stFileUploader"]) {
877
+ animation: stFade 420ms var(--ease) backwards,
878
+ upsSheen 11s var(--ease) 900ms infinite !important;
879
+ }
880
+
881
+ /* The primary action is never asleep: a glint crosses it on a slow loop,
882
+ and the hover sweep above still fires on top of it. */
883
+ .stButton button[kind="primary"]::before,
884
+ [data-testid="stBaseButton-primary"]::before {
885
+ content: ""; position: absolute; top: -25%; bottom: -25%; left: 0; width: 32%;
886
+ pointer-events: none;
887
+ background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.30), transparent);
888
+ transform: translateX(-190%) skewX(-16deg);
889
+ animation: upsGlint 6.4s var(--ease) infinite;
890
+ }
891
+
892
+ /* Ready means ready. */
893
+ .ups-chip-ready::before { animation: stPulse 3s var(--ease) infinite; }
894
+
895
+ /* Waiting on the model should look expensive, not broken. */
896
+ [data-testid="stSpinner"] i, [data-testid="stSpinner"] svg,
897
+ .stSpinner i, .stSpinner svg {
898
+ filter: drop-shadow(0 0 8px var(--accent-glow)) !important;
899
+ }
900
+ }
901
+
902
+ /* ── The rail wins its own pseudo-elements ───────────────────────────────
903
+ The main area decorates a primary button with ::before (the looping glint)
904
+ and ::after (the hover sweep). Those selectors are not area-scoped, so on
905
+ the rail β€” where the current step is also a primary button β€” they were
906
+ landing on the two pseudo-elements the nav row uses for its icon and its
907
+ chevron. Restated here, last, so the rail keeps them. */
908
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button::before {
909
+ position: static !important;
910
+ top: auto !important; bottom: auto !important; left: auto !important;
911
+ width: 18px !important; height: 18px !important;
912
+ transform: none !important; animation: none !important;
913
+ background: currentColor !important;
914
+ -webkit-mask: var(--nav-ico) center / 18px 18px no-repeat !important;
915
+ mask: var(--nav-ico) center / 18px 18px no-repeat !important;
916
+ }
917
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="primary"]::before {
918
+ background: var(--accent) !important;
919
+ }
920
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button::after {
921
+ content: none !important;
922
+ }
923
+ section[data-testid="stSidebar"] [class*="st-key-nav_btn_"] .stButton button[kind="primary"]::after {
924
+ content: "" !important;
925
+ position: static !important; margin-left: auto !important;
926
+ width: 14px !important; height: 14px !important; opacity: 0.5 !important;
927
+ transform: none !important; animation: none !important;
928
+ background: currentColor !important;
929
+ -webkit-mask: var(--ico-arrow) center / 14px 14px no-repeat !important;
930
+ mask: var(--ico-arrow) center / 14px 14px no-repeat !important;
931
+ }
932
+
933
+ /* ── Motion respects the user's setting ─────────────────────────────── */
934
+ @media (prefers-reduced-motion: reduce) {
935
+ *, *::before, *::after {
936
+ animation-duration: 0.001ms !important;
937
+ animation-iteration-count: 1 !important;
938
+ transition-duration: 0.001ms !important;
939
+ }
940
+ }
941
  </style>
942
  """
943
 
 
953
  return css.replace("</style>", "").replace("<style>", "")
954
 
955
 
956
+ # Which canvas icon marks each step in the rail. Keys match main.py's STEPS.
957
+ _NAV_ICONS = {
958
+ "setup": "key",
959
+ "dossier": "folder",
960
+ "screenshot": "image",
961
+ "confirmation": "edit",
962
+ "analysis": "target",
963
+ "proposal": "doc",
964
+ }
965
+
966
+
967
+ def _mask_url(icon_name: str) -> str:
968
+ """One icon as a CSS mask URL.
969
+
970
+ A mask takes its colour from whatever paints through it, so an icon set
971
+ this way follows the row's own colour into every state β€” muted when
972
+ idle, violet on the step you are on, dimmed when the step is locked.
973
+ """
974
+ from urllib.parse import quote
975
+
976
+ from app.ui.icons import ICONS
977
+
978
+ svg = ICONS.get(icon_name, "")
979
+ if not svg:
980
+ return "none"
981
+ # A mask needs an opaque paint; currentColor would resolve to nothing here.
982
+ svg = svg.replace('stroke="currentColor"', 'stroke="#000"')
983
+ svg = svg.replace('fill="currentColor"', 'fill="#000"')
984
+ if "xmlns=" not in svg:
985
+ svg = svg.replace("<svg ", '<svg xmlns="http://www.w3.org/2000/svg" ', 1)
986
+ return 'url("data:image/svg+xml,%s")' % quote(svg, safe="")
987
+
988
+
989
+ @lru_cache(maxsize=1)
990
+ def _nav_icon_css() -> str:
991
+ """Per-step icon variables for the rail, plus the current-row chevron."""
992
+ rows = "".join(
993
+ 'section[data-testid="stSidebar"] [class*="st-key-nav_btn_%s"] '
994
+ "{ --nav-ico: %s; }\n" % (key, _mask_url(name))
995
+ for key, name in _NAV_ICONS.items()
996
+ )
997
+ return "<style>\n%ssection[data-testid=\"stSidebar\"] { --ico-arrow: %s; }\n</style>" % (
998
+ rows,
999
+ _mask_url("arrow"),
1000
+ )
1001
+
1002
+
1003
  def inject_css() -> None:
1004
  """Inject fonts + the Spectrum design system + Streamlit bridge CSS."""
1005
  st.markdown(_FONTS_LINK, unsafe_allow_html=True)
 
1007
  if css:
1008
  st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
1009
  st.markdown(_BRIDGE_CSS, unsafe_allow_html=True)
1010
+ st.markdown(_nav_icon_css(), unsafe_allow_html=True)
1011
 
1012
 
1013
  def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
 
1056
 
1057
 
1058
  def screen_head(step_key: str, title: str, subtitle: str) -> None:
1059
+ """Render the screen's title and standfirst, as on the design canvas.
1060
+
1061
+ The artboards put the step indicator in the top bar rather than above the
1062
+ headline, so this no longer emits the eyebrow pill β€” ``top_bar`` carries
1063
+ the breadcrumb, the segmented rail and "Step N of M". ``step_key`` stays in
1064
+ the signature (every screen passes it) and still selects the eyebrow text
1065
+ for callers that want it via :data:`STEP_EYEBROWS`.
1066
+
1067
+ ``subtitle`` is trusted markup written in the screen modules β€” it carries
1068
+ <b> emphasis. Never pass model output or file contents through it.
1069
+ """
1070
  st.markdown(
1071
  f"""<div class="screen-head">
 
1072
  <h1 class="screen-title">{title}</h1>
1073
  <p class="screen-sub">{subtitle}</p>
1074
  </div>""",
 
1189
  right_bits = []
1190
  if step_label:
1191
  right_bits.append(
1192
+ f'<span style="font-family:var(--ff-mono);font-size:12px;'
1193
+ f'letter-spacing:.14em;text-transform:uppercase;color:var(--accent-light);'
1194
  f'border:1px solid var(--accent-line);background:var(--accent-soft);'
1195
+ f'padding:5px 12px;border-radius:999px;">{step_label}</span>'
1196
  )
1197
  if chip_label:
1198
  right_bits.append(status_chip(chip_label, chip_kind))
 
1205
  f"""<div style="display:flex;align-items:flex-start;justify-content:space-between;
1206
  gap:1rem;flex-wrap:wrap;margin-bottom:.3rem;">
1207
  <div>
1208
+ <p style="font-family:var(--ff-display);font-size:22px;font-weight:600;
1209
+ color:var(--text);margin:0;letter-spacing:-.025em;">{title}</p>
1210
+ <p style="color:var(--text-muted);font-size:13px;line-height:1.4;margin:.35rem 0 0;">{subtitle}</p>
1211
  </div>
1212
  {right_html}
1213
  </div>""",
 
1222
 
1223
  def sidebar_title(text: str) -> None:
1224
  st.markdown(f'<p class="ups-side-title">{text}</p>', unsafe_allow_html=True)
1225
+
1226
+
1227
+ # ══════════════════════════════════════════════════════════════════════════
1228
+ # Aurora components
1229
+ #
1230
+ # One Python helper per component on the design canvas, emitting exactly the
1231
+ # markup the artboards use (classes defined under "Aurora components" in
1232
+ # design_system.css). Everything is escaped on the way in β€” these render app
1233
+ # data, and none of them accept markup from a model or a file.
1234
+ #
1235
+ # Every helper here is presentational. None of them wrap, replace or stand in
1236
+ # for a Streamlit control: real controls stay real controls and are matched to
1237
+ # this same language by the widget layer in _BRIDGE_CSS.
1238
+ # ══════════════════════════════════════════════════════════════════════════
1239
+
1240
+ import html as _html_mod
1241
+
1242
+ from app.ui.icons import ICONS, icon # noqa: F401 (re-exported for screens)
1243
+
1244
+
1245
+ def _e(value: object) -> str:
1246
+ """Escape anything on its way into markup."""
1247
+ return _html_mod.escape("" if value is None else str(value))
1248
+
1249
+
1250
+ def _ico(name: str | None, size: int | None = None) -> str:
1251
+ return icon(name, size=size) if name else ""
1252
+
1253
+
1254
+ # ── chrome ────────────────────────────────────────────────────────────────
1255
+
1256
+
1257
+ def top_bar(
1258
+ *,
1259
+ crumb_a: str,
1260
+ crumb_b: str,
1261
+ step_index: int | None,
1262
+ step_total: int,
1263
+ right_html: str = "",
1264
+ ) -> None:
1265
+ """The artboard's top bar: breadcrumb, segmented step rail, step caption.
1266
+
1267
+ ``step_index`` is 1-based; ``None`` draws the rail with nothing lit (used
1268
+ when the current screen is not one of the numbered steps).
1269
+ """
1270
+ now = step_index or 0
1271
+ segs = "".join(
1272
+ f'<span class="ups-seg-i {"is-done" if i < now - 1 else "is-now" if i == now - 1 else ""}"></span>'
1273
+ for i in range(step_total)
1274
+ )
1275
+ cap = (
1276
+ f'<span class="ups-cap">Step {now} of {step_total}</span>'
1277
+ if step_index
1278
+ else ""
1279
+ )
1280
+ st.markdown(
1281
+ f'<div class="ups-top">'
1282
+ f'<span class="ups-crumb">{_e(crumb_a)} <b>/ {_e(crumb_b)}</b></span>'
1283
+ f'<span class="ups-steps">{segs}</span>'
1284
+ f"{cap}"
1285
+ f'<span class="ups-sp"></span>'
1286
+ f"{right_html}"
1287
+ f"</div>",
1288
+ unsafe_allow_html=True,
1289
+ )
1290
+
1291
+
1292
+ def sidebar_brand(title: str, tagline: str = "") -> None:
1293
+ """Brand lockup at the head of the rail: mark, wordmark, and the tagline.
1294
+
1295
+ The canvas puts a short tracked descriptor under the wordmark. This app's
1296
+ descriptor is a real two-sentence tagline, so it is set as wrapping copy
1297
+ below the lockup rather than squeezed onto one tracked line β€” the words
1298
+ are the product's own and none of them are dropped.
1299
+ """
1300
+ tag_html = (
1301
+ f'<p class="ups-brand-tag">{_e(tagline)}</p>' if tagline else ""
1302
+ )
1303
+ st.markdown(
1304
+ f'<div class="ups-brand">'
1305
+ f'<span class="ups-brand-mark">{_ico("compass")}</span>'
1306
+ f'<span class="ups-brand-t">{_e(title)}</span>'
1307
+ f"</div>{tag_html}",
1308
+ unsafe_allow_html=True,
1309
+ )
1310
+
1311
+
1312
+ def rail_cap(text: str) -> None:
1313
+ """The small uppercase caption that labels a group in the rail."""
1314
+ st.markdown(f'<p class="ups-rail-cap">{_e(text)}</p>', unsafe_allow_html=True)
1315
+
1316
+
1317
+ def engine_card(label: str, value: str, status: str, kind: str = "ok") -> None:
1318
+ """The rail's footer card β€” which AI service this session is running on."""
1319
+ dot = "ups-dot" if kind == "ok" else f"ups-dot {_e(kind)}"
1320
+ st.markdown(
1321
+ f'<div class="ups-engine">'
1322
+ f'<div class="k">{_e(label)}</div>'
1323
+ f'<div class="v">{_e(value)}</div>'
1324
+ f'<div class="s"><span class="{dot}"></span>{_e(status)}</div>'
1325
+ f"</div>",
1326
+ unsafe_allow_html=True,
1327
+ )
1328
+
1329
+
1330
+ # ── surfaces ──────────────────────────────────────────────────────────────
1331
+
1332
+
1333
+ def panel(body_html: str, *, title: str = "", right_html: str = "") -> None:
1334
+ """A glass panel built from markup only.
1335
+
1336
+ For a panel that must CONTAIN Streamlit widgets, use
1337
+ ``st.container(border=True)`` instead β€” the widget layer styles it to
1338
+ match this exactly. Wrapping widgets in raw HTML does not work.
1339
+ """
1340
+ head = (
1341
+ f'<div class="ups-panel-h"><span class="ups-ct">{_e(title)}</span>'
1342
+ f'<span class="ups-sp"></span>{right_html}</div>'
1343
+ if title or right_html
1344
+ else ""
1345
+ )
1346
+ st.markdown(
1347
+ f'<div class="ups-panel ups-glass">{head}{body_html}</div>',
1348
+ unsafe_allow_html=True,
1349
+ )
1350
+
1351
+
1352
+ def panel_header(title: str, right_html: str = "") -> None:
1353
+ """The panel head on its own, for use inside a st.container(border=True)."""
1354
+ st.markdown(
1355
+ f'<div class="ups-panel-h"><span class="ups-ct">{_e(title)}</span>'
1356
+ f'<span class="ups-sp"></span>{right_html}</div>',
1357
+ unsafe_allow_html=True,
1358
+ )
1359
+
1360
+
1361
+ def kpis(items: list[dict], *, columns: int | None = None) -> None:
1362
+ """A row of KPI tiles.
1363
+
1364
+ Each item: ``{"lab", "val", "icon" | "initial", "delta", "delta_kind"}``.
1365
+ Only ever call this with numbers the app actually has β€” a KPI that has to
1366
+ be invented is a KPI that should not be on the screen.
1367
+ """
1368
+ if not items:
1369
+ return
1370
+ n = columns or len(items)
1371
+ cls = {2: "ups-kpis two", 3: "ups-kpis three"}.get(n, "ups-kpis")
1372
+ tiles = []
1373
+ for it in items:
1374
+ badge = _ico(it["icon"]) if it.get("icon") else _e(it.get("initial", ""))
1375
+ delta = (
1376
+ f'<span class="delta {_e(it.get("delta_kind", "t2"))}">{_e(it["delta"])}</span>'
1377
+ if it.get("delta")
1378
+ else ""
1379
+ )
1380
+ tiles.append(
1381
+ f'<div class="ups-kpi ups-glass{" on" if it.get("on") else ""}">'
1382
+ f'<span class="ups-kpi-t"><span>'
1383
+ f'<span class="lab">{_e(it["lab"])}</span>'
1384
+ f'<span class="val">{_e(it["val"])}</span></span>'
1385
+ f'<span class="ic">{badge}</span></span>'
1386
+ f"{delta}</div>"
1387
+ )
1388
+ st.markdown(f'<div class="{cls}">{"".join(tiles)}</div>', unsafe_allow_html=True)
1389
+
1390
+
1391
+ def rows_table(columns: list[str], widths: str, rows: list[list[str]]) -> None:
1392
+ """A header row plus data rows on the artboard's grid.
1393
+
1394
+ ``widths`` is a ``grid-template-columns`` value; every row uses it, so the
1395
+ header and the body always line up. Cells are pre-built HTML fragments
1396
+ from the caller (use ``cell`` / ``tile`` / ``tag`` below) β€” escape any raw
1397
+ text with the helpers rather than passing it through.
1398
+ """
1399
+ head = "".join(f"<span>{_e(c)}</span>" for c in columns)
1400
+ body = "".join(
1401
+ f'<div class="ups-row" style="grid-template-columns:{widths}">'
1402
+ + "".join(cells)
1403
+ + "</div>"
1404
+ for cells in rows
1405
+ )
1406
+ st.markdown(
1407
+ f'<div class="ups-thead" style="grid-template-columns:{widths}">{head}</div>'
1408
+ f"{body}",
1409
+ unsafe_allow_html=True,
1410
+ )
1411
+
1412
+
1413
+ def cell(main: str, sub: str = "") -> str:
1414
+ """One two-line row cell: a bold primary line over a muted second line."""
1415
+ sub_html = f'<span class="sub">{_e(sub)}</span>' if sub else ""
1416
+ return f"<span><b>{_e(main)}</b>{sub_html}</span>"
1417
+
1418
+
1419
+ def plain(text: str, cls: str = "ups-cap") -> str:
1420
+ """One single-line row cell."""
1421
+ return f'<span class="{_e(cls)}">{_e(text)}</span>'
1422
+
1423
+
1424
+ def tile(label: str = "", icon_name: str | None = None) -> str:
1425
+ """The small square badge at the head of a row."""
1426
+ return f'<span class="ups-tile">{_ico(icon_name) if icon_name else _e(label)}</span>'
1427
+
1428
+
1429
+ def kv_grid(pairs: list[tuple[str, str]], *, columns: int = 4) -> None:
1430
+ """The extracted-facts grid: a label over a value, in equal cells."""
1431
+ if not pairs:
1432
+ return
1433
+ cells = "".join(
1434
+ f'<div class="ups-kvc"><div class="k">{_e(k)}</div>'
1435
+ f'<div class="v{"" if v else " dim"}">{_e(v) if v else "not stated"}</div></div>'
1436
+ for k, v in pairs
1437
+ )
1438
+ cls = "ups-kv two" if columns == 2 else "ups-kv"
1439
+ st.markdown(f'<div class="{cls}">{cells}</div>', unsafe_allow_html=True)
1440
+
1441
+
1442
+ def log_block(lines: list[tuple[str, str]]) -> None:
1443
+ """A console transcript: ``(text, kind)`` where kind is ``""``/``ac``/``ok``."""
1444
+ body = "".join(f'<div class="{_e(k)}">{_e(t)}</div>' for t, k in lines)
1445
+ st.markdown(f'<div class="ups-log">{body}</div>', unsafe_allow_html=True)
1446
+
1447
+
1448
+ def evidence_list(items: list[dict]) -> None:
1449
+ """Evidence entries: a claim, the sentence behind it, and its source file."""
1450
+ body = "".join(
1451
+ f'<div class="ups-ev"><b>{_e(it["title"])}</b><p>{_e(it["body"])}</p>'
1452
+ + (
1453
+ f'<span class="ups-cite">{_ico("doc")}{_e(it["source"])}</span>'
1454
+ if it.get("source")
1455
+ else ""
1456
+ )
1457
+ + "</div>"
1458
+ for it in items
1459
+ )
1460
+ st.markdown(body, unsafe_allow_html=True)
1461
+
1462
+
1463
+ # ── inline fragments ──────────────────────────────────────────────────────
1464
+
1465
+
1466
+ _TAG_KINDS = {"ok", "warn", "stop", "violet"}
1467
+
1468
+
1469
+ def tag(label: str, kind: str = "", icon_name: str | None = None) -> str:
1470
+ """A pill. Returns markup β€” compose several inside a ``ups-flow`` row."""
1471
+ cls = f"ups-tag {kind}" if kind in _TAG_KINDS else "ups-tag"
1472
+ return f'<span class="{cls}">{_ico(icon_name)}{_e(label)}</span>'
1473
+
1474
+
1475
+ def field(text: str, *, icon_name: str | None = None, mono: bool = False,
1476
+ on: bool = False, right_html: str = "") -> str:
1477
+ """A read-only value shown in the same shape as an input.
1478
+
1479
+ Read-only only: anything the user types stays a real Streamlit widget.
1480
+ """
1481
+ body = f'<span class="{"mono" if mono else ""}">{_e(text)}</span>'
1482
+ return (
1483
+ f'<div class="ups-field{" on" if on else ""}">'
1484
+ f'{_ico(icon_name)}{body}<span class="ups-sp"></span>{right_html}</div>'
1485
+ )
1486
+
1487
+
1488
+ def meter(pct: float, band: str = "") -> str:
1489
+ """A progress meter. ``band`` is ``""``/``ok``/``warn``/``stop``."""
1490
+ width = max(0.0, min(100.0, float(pct)))
1491
+ cls = f"ups-meter {band}" if band in {"ok", "warn", "stop"} else "ups-meter"
1492
+ return f'<div class="{cls}"><i style="width:{width:.4g}%"></i></div>'
1493
+
1494
+
1495
+ _RING_BANDS = {
1496
+ "ok": ("#22C55E", "#4ADE80"),
1497
+ "warn": ("#E0A34A", "#F0C078"),
1498
+ "stop": ("#F1566E", "#FB7A8E"),
1499
+ }
1500
+ _RING_CIRCUMFERENCE = 433.5 # 2 * pi * r, r = 69 β€” the artboard's geometry
1501
+
1502
+
1503
+ def score_ring(score: float, *, band: str = "", label: str = "Fit score",
1504
+ out_of: float = 100.0) -> str:
1505
+ """The fit-score ring: track, gradient arc, comet head and centred value."""
1506
+ pct = 0.0 if out_of <= 0 else max(0.0, min(1.0, float(score) / float(out_of)))
1507
+ c1, c2 = _RING_BANDS.get(band, ("#8B5CF6", "#A78BFA"))
1508
+ offset = _RING_CIRCUMFERENCE * (1 - pct)
1509
+ deg = 360 * pct
1510
+ uid = f"rg{abs(hash((c1, c2, round(pct, 4)))) % 100000}"
1511
+ return (
1512
+ f'<div class="ups-ring"><svg viewBox="0 0 168 168" aria-hidden="true">'
1513
+ f'<defs><linearGradient id="{uid}" x1="0" y1="0" x2="1" y2="1">'
1514
+ f'<stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/>'
1515
+ f"</linearGradient></defs>"
1516
+ f'<circle class="tr" cx="84" cy="84" r="69"></circle>'
1517
+ f'<circle class="bar" cx="84" cy="84" r="69" stroke="url(#{uid})" '
1518
+ f'stroke-dasharray="{_RING_CIRCUMFERENCE}" stroke-dashoffset="{offset:.2f}"></circle>'
1519
+ f'<g class="comet" style="transform:rotate({deg:.2f}deg)">'
1520
+ f'<circle cx="153" cy="84" r="4.5" fill="#FFFFFF"></circle></g>'
1521
+ f"</svg>"
1522
+ f'<div class="val"><b>{_e(round(score))}</b><span>{_e(label)}</span></div></div>'
1523
+ )
1524
+
1525
+
1526
+ def flow(*fragments: str, gap: int = 12, top: int = 0) -> None:
1527
+ """Lay inline fragments (tags, captions) out in a row."""
1528
+ style = f"gap:{gap}px" + (f";margin-top:{top}px" if top else "")
1529
+ st.markdown(
1530
+ f'<div class="ups-flow" style="{style}">{"".join(f for f in fragments if f)}</div>',
1531
+ unsafe_allow_html=True,
1532
+ )
1533
+
1534
+
1535
+ def html(markup: str) -> None:
1536
+ """Emit a composed fragment. The caller owns escaping."""
1537
+ st.markdown(markup, unsafe_allow_html=True)