Spaces:
Sleeping
Sleeping
Commit Β·
c9d9189
1
Parent(s): 73ca3b4
UI: motion runtime + icons 1.0
Browse files- app/assets/design_system.css +854 -571
- app/assets/vendor/README.md +17 -0
- app/assets/vendor/motion-LICENSE.md +21 -0
- app/assets/vendor/motion.umd.js +0 -0
- app/main.py +66 -20
- app/ui/analysis_screen.py +101 -49
- app/ui/confirmation_screen.py +6 -1
- app/ui/dossier_screen.py +37 -10
- app/ui/icons.py +51 -0
- app/ui/motion_runtime.py +322 -0
- app/ui/output_screen.py +1 -1
- app/ui/proposal_screen.py +35 -14
- app/ui/screenshot_screen.py +8 -6
- app/ui/setup_screen.py +3 -3
- app/ui/theme.py +1098 -182
app/assets/design_system.css
CHANGED
|
@@ -1,658 +1,941 @@
|
|
| 1 |
/* ============================================================
|
| 2 |
-
Upwork Proposal Strategist β "
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
unsafe_allow_html=True)
|
| 6 |
|
| 7 |
-
REQUIRED
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
--
|
| 13 |
-
|
| 14 |
-
============================================================ */
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
grain, inner highlights and glow β not flat fills.
|
| 20 |
============================================================ */
|
| 21 |
|
| 22 |
:root {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
--r: 1;
|
| 27 |
--pad: 1;
|
| 28 |
|
| 29 |
-
|
| 30 |
-
--radius-
|
| 31 |
-
--radius:
|
| 32 |
-
--radius-
|
| 33 |
-
--radius-
|
| 34 |
-
--radius
|
| 35 |
-
|
| 36 |
-
--
|
| 37 |
-
--
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
--
|
| 41 |
-
--
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 48 |
[data-theme="dark"] {
|
| 49 |
-
--bg:
|
| 50 |
-
--bg-
|
| 51 |
-
--
|
| 52 |
-
|
| 53 |
-
--surface
|
| 54 |
-
--surface-
|
| 55 |
-
--
|
| 56 |
-
--
|
| 57 |
-
--
|
| 58 |
-
--
|
| 59 |
-
|
| 60 |
-
--
|
| 61 |
-
--
|
| 62 |
-
|
| 63 |
-
--
|
| 64 |
-
|
| 65 |
-
--
|
| 66 |
-
--
|
| 67 |
-
--
|
| 68 |
-
--
|
| 69 |
-
|
| 70 |
-
--
|
| 71 |
-
|
| 72 |
-
--
|
| 73 |
-
--
|
| 74 |
-
--
|
| 75 |
-
|
| 76 |
-
--
|
| 77 |
-
--
|
| 78 |
-
--
|
| 79 |
-
--ring:
|
| 80 |
-
--
|
| 81 |
-
--
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
[data-theme="light"] {
|
| 89 |
-
--bg:
|
| 90 |
-
--bg-
|
| 91 |
-
--
|
| 92 |
-
|
| 93 |
-
--surface
|
| 94 |
-
--surface-
|
| 95 |
-
--
|
| 96 |
-
--
|
| 97 |
-
--
|
| 98 |
-
--
|
| 99 |
-
|
| 100 |
-
--
|
| 101 |
-
--
|
| 102 |
-
|
| 103 |
-
--
|
| 104 |
-
|
| 105 |
-
--
|
| 106 |
-
--
|
| 107 |
-
--
|
| 108 |
-
--
|
| 109 |
-
|
| 110 |
-
--
|
| 111 |
-
|
| 112 |
-
--
|
| 113 |
-
--
|
| 114 |
-
--
|
| 115 |
-
|
| 116 |
-
--
|
| 117 |
-
--
|
| 118 |
-
--
|
| 119 |
-
--ring:
|
| 120 |
-
--
|
| 121 |
-
--
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
* { box-sizing: border-box; }
|
| 128 |
-
|
| 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:
|
| 137 |
line-height: 1.5;
|
| 138 |
}
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
body::after {
|
| 141 |
-
content: "";
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
mix-blend-mode: overlay;
|
| 144 |
}
|
| 145 |
-
|
| 146 |
-
h1,h2,h3,h4 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
button { font-family: inherit; }
|
| 148 |
-
:
|
| 149 |
-
.mono { font-family: var(--ff-mono); }
|
| 150 |
|
| 151 |
-
/*
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
-
.
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
-
.
|
| 167 |
|
| 168 |
-
|
| 169 |
-
.
|
| 170 |
-
|
| 171 |
-
|
| 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 |
-
.
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
.
|
| 183 |
-
.
|
| 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 |
-
.
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
}
|
| 301 |
-
.
|
| 302 |
-
.
|
| 303 |
-
.
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
-
/*
|
| 307 |
-
|
| 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;
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
.verdict::before {
|
| 433 |
content: ""; position: absolute; inset: 0; pointer-events: none;
|
| 434 |
-
background: radial-gradient(
|
| 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;
|
|
|
|
| 441 |
.verdict-badge {
|
| 442 |
-
display: inline-flex; align-items: center; gap:
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
.verdict.warn .verdict-badge { color: var(--warn-ink); }
|
| 448 |
.verdict.stop .verdict-badge { color: var(--stop-ink); }
|
| 449 |
-
.verdict-badge .dot { width:
|
| 450 |
-
.verdict.go
|
| 451 |
-
.verdict.warn .dot { background: var(--warn);
|
| 452 |
-
.verdict.stop .dot { background: var(--stop);
|
| 453 |
-
|
| 454 |
-
.verdict
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
.
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 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 {
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
.proposal-doc p:last-child { margin-bottom: 0; }
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
.evidence-note {
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
.
|
| 532 |
-
display: inline-flex; align-items: center;
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
.
|
| 542 |
-
.
|
| 543 |
-
.
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 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 |
-
|
| 570 |
-
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
| 572 |
}
|
| 573 |
|
| 574 |
-
/*
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
}
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
============================================================ */
|
| 599 |
|
| 600 |
-
|
| 601 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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);
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
.stButton > button[kind="secondary"] {
|
| 624 |
-
background: var(--
|
| 625 |
-
|
|
|
|
|
|
|
| 626 |
}
|
| 627 |
|
| 628 |
-
/* Text / password inputs (matches .input) */
|
| 629 |
.stTextInput input, .stTextArea textarea {
|
| 630 |
-
background: var(--
|
| 631 |
-
border
|
|
|
|
|
|
|
|
|
|
| 632 |
}
|
| 633 |
.stTextInput input:focus, .stTextArea textarea:focus {
|
| 634 |
-
border-color:
|
|
|
|
| 635 |
}
|
| 636 |
|
| 637 |
-
/* File uploader β dropzone look (matches .dropzone) */
|
| 638 |
[data-testid="stFileUploaderDropzone"] {
|
| 639 |
-
background: var(--
|
| 640 |
-
border
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
}
|
| 642 |
-
[data-testid="stFileUploaderDropzone"]:hover { border-color: var(--accent); }
|
| 643 |
|
| 644 |
-
|
| 645 |
-
h1, h2, h3 { font-family: var(--ff-display) !important; letter-spacing: -.02em; }
|
| 646 |
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
current = st.session_state.current_step
|
| 197 |
|
| 198 |
-
for
|
| 199 |
status = _step_status(key, current)
|
| 200 |
unlocked = status != "locked"
|
| 201 |
glyph = _STATUS_GLYPH.get(status, "β")
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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 |
-
"
|
| 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 |
-
"
|
| 237 |
-
value=bool(st.session_state.get("theme_dark",
|
| 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",
|
| 399 |
current_step = st.session_state.current_step
|
| 400 |
theme.apply_step_accent(current_step, dark=dark)
|
| 401 |
|
| 402 |
-
#
|
|
|
|
| 403 |
visible = _visible_steps(show_debug)
|
| 404 |
-
|
| 405 |
for idx, (key, _label) in enumerate(visible, start=1):
|
| 406 |
if key == current_step:
|
| 407 |
-
|
| 408 |
break
|
| 409 |
|
| 410 |
chip_label, chip_kind = _header_chip()
|
| 411 |
-
theme.
|
| 412 |
-
APP_TITLE,
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 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 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 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 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 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 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
else:
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
font-size:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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("
|
| 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",
|
| 165 |
if _dark:
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
else:
|
| 168 |
-
_btn_bg, _btn_hover, _btn_fg = "#
|
| 169 |
try:
|
| 170 |
paste_result = _paste_image_button(
|
| 171 |
-
"
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
"
|
| 37 |
-
|
| 38 |
-
"
|
| 39 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
/* ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
#MainMenu, footer, [data-testid="stHeader"] { display: none !important; }
|
| 66 |
-
[data-testid="stAppViewContainer"] {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
[data-testid="stMain"] { background: transparent !important; }
|
| 68 |
.block-container {
|
| 69 |
-
padding:
|
| 70 |
-
max-width:
|
| 71 |
}
|
| 72 |
|
| 73 |
-
/* ββ
|
| 74 |
-
Streamlit
|
| 75 |
-
|
| 76 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
[data-baseweb="select"] > div {
|
| 110 |
-
background: var(--
|
| 111 |
-
border
|
|
|
|
| 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(--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 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
|
| 144 |
-
section[data-testid="stSidebar"] .stButton
|
| 145 |
background: none !important;
|
| 146 |
border: 1px solid transparent !important;
|
| 147 |
-
border-radius:
|
| 148 |
text-align: left !important;
|
| 149 |
justify-content: flex-start !important;
|
| 150 |
font-family: var(--ff-body) !important;
|
| 151 |
-
font-size:
|
| 152 |
font-weight: 500 !important;
|
| 153 |
color: var(--text-muted) !important;
|
| 154 |
-
padding:
|
| 155 |
-
height:
|
|
|
|
| 156 |
width: 100% !important;
|
| 157 |
box-shadow: none !important;
|
| 158 |
-
transition:
|
|
|
|
| 159 |
}
|
| 160 |
-
section[data-testid="stSidebar"] .stButton
|
| 161 |
-
background: var(--
|
| 162 |
color: var(--text) !important;
|
|
|
|
| 163 |
}
|
| 164 |
-
section[data-testid="stSidebar"] .stButton
|
| 165 |
-
background:
|
| 166 |
-
border
|
| 167 |
-
color: var(--text) !important;
|
| 168 |
-
font-weight:
|
| 169 |
-
box-shadow: inset
|
|
|
|
| 170 |
}
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
border: 1px solid var(--border-2) !important;
|
| 176 |
color: var(--text) !important;
|
| 177 |
-
font-weight:
|
| 178 |
}
|
| 179 |
-
section[data-testid="stSidebar"] .stButton
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
color: var(--text) !important;
|
| 182 |
}
|
| 183 |
-
section[data-testid="stSidebar"] .stButton
|
| 184 |
opacity: 0.45 !important;
|
| 185 |
cursor: not-allowed !important;
|
|
|
|
| 186 |
}
|
| 187 |
|
| 188 |
/* ββ Main action buttons ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 189 |
-
.stButton
|
| 190 |
font-family: var(--ff-body) !important;
|
| 191 |
-
font-weight:
|
| 192 |
font-size: 14px !important;
|
| 193 |
height: 44px !important;
|
| 194 |
-
border-radius: var(--radius-
|
| 195 |
-
transition:
|
|
|
|
| 196 |
}
|
| 197 |
-
.stButton
|
| 198 |
color: var(--accent-fg) !important;
|
| 199 |
border: none !important;
|
| 200 |
-
background:
|
| 201 |
-
box-shadow: 0
|
|
|
|
| 202 |
}
|
| 203 |
-
.stButton
|
| 204 |
-
|
| 205 |
-
|
| 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 |
-
|
| 213 |
-
|
| 214 |
-
|
| 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
|
| 219 |
-
background: var(--
|
| 220 |
color: var(--text) !important;
|
| 221 |
-
border: 1px solid var(--border
|
|
|
|
| 222 |
}
|
|
|
|
| 223 |
[data-testid="stBaseButton-secondary"]:hover,
|
| 224 |
-
.stDownloadButton
|
| 225 |
-
background: var(--surface-
|
| 226 |
-
color: var(--
|
| 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 |
-
|
| 239 |
}
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
.stButton
|
| 244 |
-
.stButton > button:not([kind="primary"]):hover *,
|
| 245 |
[data-testid="stBaseButton-secondary"]:hover,
|
| 246 |
[data-testid="stBaseButton-secondary"]:hover *,
|
| 247 |
-
.stDownloadButton
|
| 248 |
-
.stDownloadButton
|
| 249 |
-
|
| 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 |
-
|
| 259 |
-
|
| 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(--
|
| 266 |
-
border-color: var(--
|
| 267 |
-
border-radius: var(--radius-
|
| 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
|
| 274 |
-
|
| 275 |
-
|
| 276 |
}
|
| 277 |
-
|
| 278 |
-
.stTextInput
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
-
/* ββ File uploader β dropzone βββββββββββββββββββββββββββββββββββ
|
| 281 |
[data-testid="stFileUploaderDropzone"] {
|
| 282 |
-
background: var(--
|
| 283 |
-
border: 1.5px dashed var(--
|
| 284 |
border-radius: var(--radius) !important;
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
border-color: var(--accent) !important;
|
|
|
|
|
|
|
| 288 |
}
|
| 289 |
-
/*
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 314 |
-
|
| 315 |
-
|
|
|
|
| 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-
|
| 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="
|
| 333 |
background: var(--card-grad) !important;
|
| 334 |
border: 1px solid var(--border) !important;
|
| 335 |
border-radius: var(--radius) !important;
|
| 336 |
-
box-shadow:
|
|
|
|
|
|
|
| 337 |
}
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
[data-testid="
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
font-weight: 600 !important;
|
| 344 |
}
|
| 345 |
|
| 346 |
-
/* ββ Progress
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 355 |
color: var(--text) !important;
|
| 356 |
}
|
| 357 |
|
| 358 |
/* ββ Dividers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 359 |
-
hr { border-color: var(--border) !important; margin:
|
| 360 |
|
| 361 |
-
/* ββ
|
| 362 |
.ups-chip {
|
| 363 |
-
display: inline-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 369 |
-
.ups-chip-neutral { background: var(--warn-soft); color: var(--warn-ink); border: 1px solid
|
| 370 |
-
|
| 371 |
-
/* ββ Section labels βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 372 |
.ups-section {
|
| 373 |
-
font-family: var(--ff-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
| 376 |
}
|
| 377 |
.ups-side-title {
|
| 378 |
-
font-family: var(--ff-mono); font-size:
|
| 379 |
letter-spacing: 0.14em; text-transform: uppercase;
|
| 380 |
-
color: var(--text-faint); margin: 0 0
|
| 381 |
}
|
| 382 |
|
| 383 |
/* ββ Toggle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 384 |
-
.stToggle label
|
|
|
|
|
|
|
| 385 |
|
| 386 |
-
/* ββ
|
| 387 |
[data-testid="stAlert"] {
|
| 388 |
-
|
| 389 |
border: 1px solid var(--border) !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
}
|
| 391 |
|
| 392 |
/* ββ Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 393 |
-
.stTabs [aria-selected="true"] {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 590 |
-
f'letter-spacing:.
|
| 591 |
f'border:1px solid var(--accent-line);background:var(--accent-soft);'
|
| 592 |
-
f'padding:
|
| 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:
|
| 606 |
-
color:var(--text);margin:0;letter-spacing:-.
|
| 607 |
-
<p style="color:var(--text-muted);font-size:.
|
| 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)
|