Add owl sign-in page for the G2 run screen

First UI for the ops team: a local sign-in page where an owl sits on
the card, watches the password field as you type, shakes its head on
a wrong password and hops on the right one. Uses a test password for
now; where the real G2 password lives is still undecided.

Also adds PRODUCT.md (product context for UI work) and the page's
design brief under .impeccable/surfaces/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-22 11:47:19 +05:30
1 parent 48781f35eb
commit 54fa7de9b0
4 files changed
+514

No files matched your search

+3
View File
@@ -16,3 +16,6 @@ __pycache__/
src/build/ src/build/
src/dist/ src/dist/
build_log.txt build_log.txt
# Impeccable review screenshots (regenerated each review)
.impeccable/review/
+24
View File
@@ -0,0 +1,24 @@
---
version: 1
slug: "src-ui-login-html"
primary_target: "src/ui/login.html"
related_targets: []
---
## Scope
Operate. G2 sign-in page for the ops team, first build only: owl, password field, show/hide, sign in, test password, placeholder "run screen coming soon" after sign-in. Real password storage, lockout, per-person usernames and the run screen are undecided and out of scope.
## Direction contract
THESIS: A sign-in the owl watches. Refuses the faceless centered form with a logo above it: the owl sits on the card and reacts to what you type.
OWN-WORLD: The user's pinned world, rendered natural, not game-like (user, 22 Oct: no pixel look). Deep navy night ground (#040b26) with small soft round stars (blue, teal, orange, white). A smooth, softly shaded orange owl like the user's reference: dome head with ear tufts, egg body, wide cream face disc, big brown eyes behind round black glasses, peach belly, darker wings, small feet. Dark outlined pill controls, like the user's HOOT button, for both the field and the button. Product name and all text in the platform UI face; standard stroke eye icon.
STORY: The ops user sees the owl, types the G2 password, and the owl's eyes follow the typing. A wrong password makes the owl shake its head no and the text says what to do. The right one makes it hop and lets them in.
FIRST VIEWPORT: Centered column. Product name "Charge Billed Automation", then the card with the owl perched on its top edge (feet on the border), password field with show/hide, full-width pill Sign in. The night sky fills the viewport.
FORM: Pinned by the user (owl screenshot); no roll was run. seed: none (user-pinned direction)
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
+66
View File
@@ -0,0 +1,66 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Stack
delegated: a local page served by the bot itself with Python's standard-library `http.server`, bound to `127.0.0.1` and opened in the default browser on the ops user's Windows PC. It ships inside the existing PyInstaller exe (`src/ChargeBilledAutomation.spec`). Chosen because the ops team already runs that exe, starting a run needs a live process (a static HTML file cannot do that), it adds no new dependencies (Flask/Streamlit would), and claim data never leaves the PC. The bot itself stays Python (Selenium, pdfplumber, pandas, openpyxl).
## Users
The client's operations team (process owner: Sulthan Shafiq N). Three FTEs do this work by hand today: open Waystar, go account by account, open each claim PDF and copy fields into the Excel report template. With the bot, they start the run themselves and watch it, without the RPA developer and without reading a console window.
Other people involved but not the primary users: the OTS RPA team (builds and maintains the bot), and managers (Vijayakumari G, Swaminathan) who follow report status.
## Product Purpose
Charge Billed Automation produces the **Charge Billed Report**: charges by procedure, pulled from Waystar CMS-1500 claim PDFs into Excel, one workbook per account. It exists because the client keeps asking for charge and payment reports by procedure, and capturing them by hand takes three people a long time.
Success means the ops team can start a run, see where it is, and know at the end which claims are saved, which need a person to check, and which failed, without asking the developer.
The UI to build first is **run and watch the bot**: start a run, see live progress per account and claim, and see which claims failed or need review and why.
## Positioning
The bot reads the PDF's text layer directly (`src/cms1500_text.py`) instead of OCR or manual copying, and it does not guess. Any claim with a missing or inconsistent field gets a plain review note (for example "No Insured ID (box 1a)" or "Line charges 120.00 != box 28 total 140.00") so a person checks only those claims, not every claim.
## Operating Context
- Runs on a Windows PC. The bot closes all Chrome windows, starts Chrome with a debug port (9222) and a dedicated profile, attaches Selenium, and logs in to Waystar (login.zirmed.com), including a security question.
- Settings come from `Input/input.xlsx` (proxy, portal URL, Waystar login, security answers). The Waystar password rotates monthly.
- Date range is fixed to the previous calendar month, so in practice it runs once a month. (Inferred from code; not confirmed as the schedule.)
- Steps today: close Chrome → read config → launch Chrome → attach → open Waystar → log in → process all accounts. Within an account it pages through the claims list, downloads each claim PDF, extracts it and saves rows in batches.
- Output per run date in `Output/<MM-DD-YYYY>/`: `PDF Folder/<Account (ID)>/<claim>.pdf`, `reports/<Account (ID)>.xlsx`, and a per-account log (Claim ID, Status, Remarks, Timestamp). Error screenshots go in `Screenshots/`.
- Runs resume: claims and accounts already in the log are skipped on the next run.
- Today all progress is console text tagged `[INFO]`, `[WARN]`, `[ERROR]`, `[SUCCESS]`, with numbered `[STEP n]` lines.
- `--selftest <pdfs>` checks a build without Chrome or Waystar and prints counts only.
## Capabilities and Constraints
- Confirmed report columns: Claim ID, Rendering Provider #, Signature of Physician, Date of Birth, Insurance Name, Insured ID Number, Date of Service, Diagnosis Codes, CPT, POS, Modifier, Units, Charges, Line Charges, Review Notes.
- Terms the ops team uses: account (a practice, shown as "Name (ID)"), claim, CMS-1500 box numbers, Charge Billed Report.
- **PHI.** Claim PDFs, reports, logs and screenshots hold patient data (names, DOB, insured IDs). The UI stays on localhost, never sends data to an outside service, and shows counts, claim IDs and review notes rather than patient details unless the task needs them.
- **Credentials.** Waystar login and security answers live in `Input/input.xlsx`. The UI must never display them.
- Undecided:
- Account scope: every Waystar account or only specific ones (asked 10 Mar 2026, no answer). The code currently processes every account in the dropdown.
- Payment Report and Charge Reconciliation Report: confirmed as future reports, not started. Optimantra access for them needs a token approved by Ankit Sanghi (pending).
- A screen for reviewing and fixing flagged fields: not chosen for now.
- Whether a run can be stopped or paused from the UI.
## Evidence on Hand
- Real run output from April 2026 in `Output/` (several accounts). It is PHI: never copy it into designs, mockups, demos or screenshots. Use clearly fake sample data instead.
- Project history: `../PROJECT_DETAILS.md`, `../Communication_Summary.md`.
- Not in the folder: the client's Charge and Payment Excel templates, the PDF field list, and any measure of time saved per report (only the 3-FTE figure is known). Do not invent time-saved numbers, accuracy rates or client quotes.
## Product Principles
1. The ops team runs it alone. Every state is readable without the developer or the console.
2. Nothing fails silently. Every claim ends as saved, needs review, or failed, with its reason.
3. Flag, don't guess. A claim the bot can't read cleanly goes to a person with a plain note.
4. Patient data stays on this PC.
5. Pick up where it stopped. A rerun continues; it doesn't start over.
+421
View File
@@ -0,0 +1,421 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Charge Billed Automation</title>
<style>
:root {
--night: #040b26;
--panel: #0c1638;
--panel-line: #26335e;
--field: #050c28;
--field-line: #56689c;
--pill-line: #c9d5f2;
--ink: #eef2ff;
--ink-2: #a9b6d8;
--owl: #ec7d33;
--owl-soft: #f5a25a;
--error: #ff9a9a;
--owl-w: 188px;
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
color-scheme: dark;
scrollbar-color: var(--panel-line) var(--night);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--night);
color: var(--ink);
font: 15px/1.5 var(--ui);
caret-color: var(--owl);
}
::selection { background: var(--owl); color: var(--night); }
.sky { position: fixed; inset: 0; overflow: hidden; pointer-events: none; }
.sky span {
position: absolute;
width: var(--s);
height: var(--s);
border-radius: 50%;
background: var(--c);
opacity: var(--o);
}
.sky .tw { animation: twinkle 4s ease-in-out infinite; animation-delay: var(--d); }
@keyframes twinkle { 50% { opacity: .15; } }
.stage {
position: relative;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px 48px;
text-align: center;
}
h1 {
margin: 0;
font-size: clamp(24px, 6vw, 30px);
font-weight: 650;
line-height: 1.2;
letter-spacing: -.01em;
text-wrap: balance;
}
.sub { margin: 8px 0 0; color: var(--ink-2); }
.card {
position: relative;
width: min(360px, 100%);
margin-top: calc(var(--owl-w) * 150 / 160 + 12px);
padding: 28px 24px 24px;
background: var(--panel);
border: 1px solid var(--panel-line);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 3, 15, .5);
text-align: left;
}
.owl {
position: absolute;
left: 50%;
bottom: calc(100% - 7px);
width: var(--owl-w);
height: auto;
margin-left: calc(var(--owl-w) / -2);
overflow: visible;
}
label { display: block; font-weight: 600; margin-bottom: 8px; }
.field { position: relative; }
input {
width: 100%;
height: 46px;
padding: 0 52px 0 20px;
background: var(--field);
color: var(--ink);
border: 1px solid var(--field-line);
border-radius: 999px;
font: 16px var(--ui);
letter-spacing: .04em;
}
input:focus-visible { outline: 2px solid var(--owl); outline-offset: 2px; border-color: var(--owl); }
input[aria-invalid="true"] { border-color: var(--error); }
.reveal {
position: absolute;
top: 4px;
right: 4px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
background: none;
border: 0;
border-radius: 50%;
color: var(--ink-2);
cursor: pointer;
}
.reveal:hover { color: var(--ink); background: rgba(201, 213, 242, .1); }
.reveal:focus-visible { outline: 2px solid var(--owl); outline-offset: 0; color: var(--ink); }
.reveal svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.note, .error { margin: 8px 0 0; font-size: 14px; }
.note { color: var(--owl-soft); }
.error { color: var(--error); }
.error:empty { margin: 0; }
.pill {
width: 100%;
height: 46px;
margin-top: 20px;
background: #0a1435;
color: #fff;
border: 1.5px solid var(--pill-line);
border-radius: 999px;
font: 600 15px var(--ui);
letter-spacing: .02em;
cursor: pointer;
transition: border-color .15s ease-out, background-color .15s ease-out;
}
.pill:hover { border-color: var(--owl); background: #101d45; }
.pill:active { transform: translateY(1px); }
.pill:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
.done h2 { margin: 0; font-size: 20px; }
.done h2:focus { outline: none; }
.done p { margin: 6px 0 0; color: var(--ink-2); }
/* Owl. Origins are in the SVG's own units (viewBox 0 0 160 150). */
#head, #glasses, #wing-l, #wing-r { transform-box: view-box; }
#head { transform-origin: 80px 96px; }
#glasses { transform-origin: 80px 56px; }
#wing-l { transform-origin: 40px 90px; }
#wing-r { transform-origin: 120px 90px; }
.iris { transition: transform .14s ease-out; }
.lid { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform .08s ease-out; }
.owl.blink .lid { transform: scaleY(1); }
.owl.happy .lid { transform: scaleY(.55); }
.owl.idle #bob { animation: breathe 3.2s ease-in-out infinite; }
@keyframes breathe { 50% { transform: translateY(1.5px); } }
.owl.shake #head { animation: no .65s ease-in-out; }
@keyframes no {
15% { transform: translateX(-5px) rotate(-5deg); }
32% { transform: translateX(5px) rotate(5deg); }
50% { transform: translateX(-4px) rotate(-4deg); }
68% { transform: translateX(3px) rotate(3deg); }
85% { transform: translateX(-1px) rotate(-1deg); }
}
.owl.shake #glasses { animation: wobble .65s ease-in-out .07s; }
@keyframes wobble {
20% { transform: rotate(4deg); } 40% { transform: rotate(-4deg); }
60% { transform: rotate(3deg); } 80% { transform: rotate(-1.5deg); }
}
.owl.tilt #head { animation: tilt .8s ease-in-out; }
@keyframes tilt { 25%, 70% { transform: rotate(-10deg); } }
.owl.happy { animation: hop .75s cubic-bezier(.3, .6, .35, 1); }
@keyframes hop { 40% { transform: translateY(-14px); } 70% { transform: none; } 82% { transform: translateY(-3px); } }
.owl.happy #wing-l { animation: flap-l .75s ease-in-out; }
.owl.happy #wing-r { animation: flap-r .75s ease-in-out; }
@keyframes flap-l { 20%, 60% { transform: rotate(32deg); } 40%, 80% { transform: rotate(8deg); } }
@keyframes flap-r { 20%, 60% { transform: rotate(-32deg); } 40%, 80% { transform: rotate(-8deg); } }
@media (max-width: 420px) {
:root { --owl-w: 164px; }
}
@media (prefers-reduced-motion: reduce) {
.sky .tw, .owl, .owl * { animation: none !important; transition: none !important; }
.pill { transition: none; }
}
</style>
</head>
<body>
<div class="sky" id="sky" aria-hidden="true"></div>
<main class="stage">
<h1>Charge Billed Automation</h1>
<p class="sub">Sign in to run the bot</p>
<section class="card">
<svg class="owl idle" id="owl" viewBox="0 0 160 150" aria-hidden="true">
<defs>
<radialGradient id="fur" cx="40%" cy="28%" r="78%">
<stop offset="0" stop-color="#f8ad66"/><stop offset=".45" stop-color="#ee7f36"/><stop offset="1" stop-color="#c55a1f"/>
</radialGradient>
<linearGradient id="wing" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#df6f2c"/><stop offset="1" stop-color="#b14e1a"/>
</linearGradient>
<radialGradient id="face" cx="50%" cy="40%" r="65%">
<stop offset="0" stop-color="#fff1df"/><stop offset="1" stop-color="#f2cba1"/>
</radialGradient>
<radialGradient id="belly" cx="50%" cy="35%" r="70%">
<stop offset="0" stop-color="#fbd6a9"/><stop offset="1" stop-color="#efb072"/>
</radialGradient>
<radialGradient id="iris" cx="40%" cy="35%" r="70%">
<stop offset="0" stop-color="#b8692c"/><stop offset="1" stop-color="#6a3211"/>
</radialGradient>
<linearGradient id="beak" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f4a142"/><stop offset="1" stop-color="#cf6620"/>
</linearGradient>
<clipPath id="eye-l"><circle cx="59" cy="59" r="16"/></clipPath>
<clipPath id="eye-r"><circle cx="101" cy="59" r="16"/></clipPath>
</defs>
<g id="body">
<path fill="url(#fur)" d="M80 66C114 66 136 90 134 116C132 136 110 146 80 146C50 146 28 136 26 116C24 90 46 66 80 66Z"/>
<ellipse cx="80" cy="116" rx="30" ry="24" fill="url(#belly)"/>
<path fill="none" stroke="#d88a4d" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity=".55"
d="M67 107L70 110L73 107M87 107L90 110L93 107M77 117L80 120L83 117M63 121L66 124L69 121M91 121L94 124L97 121M77 129L80 132L83 129"/>
</g>
<g id="wing-l">
<path fill="url(#wing)" d="M38 90C24 100 22 124 34 136C40 132 44 118 44 100C43 95 41 92 38 90Z"/>
<path fill="none" stroke="#f08f4b" stroke-width="1.4" stroke-linecap="round" opacity=".6" d="M36 104Q32 116 34 126"/>
</g>
<g id="wing-r">
<path fill="url(#wing)" d="M122 90C136 100 138 124 126 136C120 132 116 118 116 100C117 95 119 92 122 90Z"/>
<path fill="none" stroke="#f08f4b" stroke-width="1.4" stroke-linecap="round" opacity=".6" d="M124 104Q128 116 126 126"/>
</g>
<g fill="#f4a93f" stroke="#c97a22" stroke-width=".8">
<ellipse cx="64" cy="144" rx="3.2" ry="4.2"/><ellipse cx="69.5" cy="145.5" rx="3.2" ry="4.2"/><ellipse cx="75" cy="144" rx="3.2" ry="4.2"/>
<ellipse cx="85" cy="144" rx="3.2" ry="4.2"/><ellipse cx="90.5" cy="145.5" rx="3.2" ry="4.2"/><ellipse cx="96" cy="144" rx="3.2" ry="4.2"/>
</g>
<g id="bob"><g id="head">
<path fill="url(#fur)" d="M32 38C29 24 32 12 38 5C44 16 52 22 60 22ZM128 38C131 24 128 12 122 5C116 16 108 22 100 22Z"/>
<path fill="url(#fur)" d="M80 10C112 10 138 30 138 58C138 82 114 98 80 98C46 98 22 82 22 58C22 30 48 10 80 10Z"/>
<path fill="none" stroke="#f9b978" stroke-width="2" stroke-linecap="round" opacity=".7" d="M62 22q4-5 9-2M77 17q4-4 8 0M90 20q4-3 8 1"/>
<path fill="url(#face)" d="M80 34C96 26 124 30 128 54C132 76 112 92 94 88C88 87 84 90 80 94C76 90 72 87 66 88C48 92 28 76 32 54C36 30 64 26 80 34Z"/>
<g clip-path="url(#eye-l)">
<circle cx="59" cy="59" r="16" fill="#fffaf2"/>
<g class="iris"><circle cx="59" cy="59" r="10.5" fill="url(#iris)"/><circle cx="59" cy="59" r="5.5" fill="#1b0e08"/><circle cx="55" cy="55" r="3" fill="#fff"/><circle cx="62.5" cy="62.5" r="1.4" fill="#fff" opacity=".8"/></g>
<rect class="lid" x="43" y="43" width="32" height="32" fill="#eebc8d"/>
</g>
<g clip-path="url(#eye-r)">
<circle cx="101" cy="59" r="16" fill="#fffaf2"/>
<g class="iris"><circle cx="101" cy="59" r="10.5" fill="url(#iris)"/><circle cx="101" cy="59" r="5.5" fill="#1b0e08"/><circle cx="97" cy="55" r="3" fill="#fff"/><circle cx="104.5" cy="62.5" r="1.4" fill="#fff" opacity=".8"/></g>
<rect class="lid" x="85" y="43" width="32" height="32" fill="#eebc8d"/>
</g>
<path fill="url(#beak)" d="M75 72C77 68 83 68 85 72C84 78 82 83 80 86C78 83 76 78 75 72Z"/>
<g id="glasses" fill="none" stroke-linecap="round">
<path stroke="#fff" stroke-width="2.2" opacity=".5" d="M46 47Q52 41 60 41M88 47Q94 41 102 41"/>
<g stroke="#1d1b20">
<circle cx="59" cy="58" r="20" stroke-width="4.5"/>
<circle cx="101" cy="58" r="20" stroke-width="4.5"/>
<path stroke-width="4" d="M77.5 54Q80 50 82.5 54M39.5 52L28 48M120.5 52L132 48"/>
</g>
</g>
</g></g>
</svg>
<form id="login" novalidate>
<label for="password">Password</label>
<div class="field">
<input id="password" name="password" type="password" autocomplete="current-password"
aria-describedby="password-msg caps" required>
<button type="button" class="reveal" id="reveal" aria-label="Show password" aria-pressed="false"></button>
</div>
<p class="note" id="caps" aria-live="polite" hidden>Caps Lock is on</p>
<p class="error" id="password-msg" role="alert"></p>
<button type="submit" class="pill">Sign in</button>
</form>
<div class="done" id="done" hidden>
<h2 id="done-title" tabindex="-1">Run screen coming soon</h2>
<p>You're signed in. This is where you'll start and watch the bot.</p>
</div>
</section>
</main>
<script>
// ponytail: test password in page source for this demo only; where the real G2 password is kept is still undecided (PRODUCT.md).
const TEST_PASSWORD = "hoot2026";
const EYE = '<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>';
const EYE_OFF = '<path d="M4 4l16 16"/><path d="M9.9 5.7A10 10 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.7 7.2C4 9 2.5 12 2.5 12s3.5 6.5 9.5 6.5a9.6 9.6 0 0 0 4.7-1.2"/><path d="M10 10.1a2.8 2.8 0 0 0 3.9 3.9"/>';
const owl = document.getElementById("owl");
const form = document.getElementById("login");
const pw = document.getElementById("password");
const reveal = document.getElementById("reveal");
const caps = document.getElementById("caps");
const msg = document.getElementById("password-msg");
const done = document.getElementById("done");
const irises = owl.querySelectorAll(".iris");
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
(function drawSky(el) {
let seed = 7;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const colors = ["#6ea8ff", "#4fd1c5", "#f29c4b", "#dfe7ff"];
let html = "";
for (let i = 0; i < 60; i++) {
html += `<span class="${rand() < .35 ? "tw" : ""}" style="left:${(rand() * 100).toFixed(2)}%;top:${(rand() * 100).toFixed(2)}%;` +
`--s:${(1.5 + rand() * 1.5).toFixed(1)}px;--c:${colors[Math.floor(rand() * 4)]};--o:${(.45 + rand() * .45).toFixed(2)};--d:${(rand() * 4).toFixed(2)}s"></span>`;
}
el.innerHTML = html;
})(document.getElementById("sky"));
function look(dx, dy) {
irises.forEach(g => { g.style.transform = `translate(${dx}px, ${dy}px)`; });
}
// Pupils slide from the left end of the field to the right as the typed text grows.
const measure = document.createElement("canvas").getContext("2d");
function followCaret() {
if (document.activeElement !== pw) return look(0, 0);
const cs = getComputedStyle(pw);
measure.font = cs.font;
measure.letterSpacing = cs.letterSpacing;
const caret = pw.selectionStart ?? pw.value.length;
const shown = pw.type === "password" ? "•".repeat(caret) : pw.value.slice(0, caret);
const room = pw.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const ratio = Math.min(measure.measureText(shown).width / room, 1);
look(Math.round(-4 + 8 * ratio), 4);
}
const timers = {};
function play(name, ms, then) {
clearTimeout(timers[name]);
owl.classList.remove(name);
void owl.getBoundingClientRect();
owl.classList.add(name);
timers[name] = setTimeout(() => { owl.classList.remove(name); then?.(); }, ms);
}
// Cleared first so a repeated identical message is announced again.
function showError(text) {
msg.textContent = "";
pw.setAttribute("aria-invalid", text ? "true" : "false");
if (text) setTimeout(() => { msg.textContent = text; }, 50);
}
function setReveal(show) {
pw.type = show ? "text" : "password";
reveal.setAttribute("aria-pressed", String(show));
reveal.setAttribute("aria-label", show ? "Hide password" : "Show password");
reveal.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${show ? EYE_OFF : EYE}</svg>`;
}
setReveal(false);
for (const ev of ["focus", "blur", "input", "keyup", "click", "select"]) pw.addEventListener(ev, followCaret);
pw.addEventListener("input", () => { if (msg.textContent) showError(""); });
for (const ev of ["keydown", "keyup"]) {
pw.addEventListener(ev, e => { caps.hidden = !e.getModifierState?.("CapsLock"); });
}
pw.addEventListener("blur", () => { caps.hidden = true; });
// Keep focus (and the owl's gaze) in the field when the eye button is clicked.
reveal.addEventListener("mousedown", e => e.preventDefault());
reveal.addEventListener("click", () => { setReveal(pw.type === "password"); followCaret(); });
form.addEventListener("submit", e => {
e.preventDefault();
if (!pw.value) {
showError("Enter your password.");
play("tilt", 800);
pw.focus();
return;
}
if (pw.value !== TEST_PASSWORD) {
showError("That password isn't right. Try again.");
pw.focus();
pw.select();
play("shake", 720, followCaret);
return;
}
showError("");
pw.value = "";
setReveal(false);
const enter = () => {
form.hidden = true;
done.hidden = false;
document.getElementById("done-title").focus();
};
if (reducedMotion.matches) enter();
else play("happy", 750, enter);
});
(function blink() {
setTimeout(() => {
if (!reducedMotion.matches && !owl.classList.contains("happy")) play("blink", 160);
blink();
}, 2500 + Math.random() * 3500);
})();
</script>
</body>
</html>