bitmax/inbox.html
monster 59fd95b707 Initial commit: BORING SOFTWARE — an anthology of incremental games
Five incremental games disguised as boring 1998-era desktop utilities, launched from a Win98 shell (ENTROPY OS):

- Vol I  qBitTorrz       (index.html)        — torrent-client idler; seed, climb hardware tiers, migrate
- Vol II  DEFRAG.EXE     (defrag.html)       — steer a disk defragmenter; drag-select; outrun entropy
- Vol III MACRO_VIRUS.XLS(spreadsheet.html)  — build a compounding economy by drag-filling formulas
- Vol IV  UPLINK         (terminal.html)     — terminal + live htop; manage load/heat; escalate to root
- Vol V   INBOX ZERO     (inbox.html)        — keyboard-triage an exponential inbox; write regex rules
- ENTROPY OS             (desktop.html)      — the Win98 launcher that holds them all

Each is a single self-contained HTML file (vanilla JS, no deps, no build), with idle/offline
progress, scientific-scale numbers, diegetic upgrades, and a prestige that re-frames the fiction.

Also includes MANIFESTO.md (design thesis) and docs/ — the Collector's Edition companion:
a Dev Handbook and a Lore Bible (lore complete; 5 handbook chapters land in the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:44:36 +10:00

1764 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inbox - Microsoft Outlook</title>
<style>
/* ============================================================
INBOX ZERO — an incremental game disguised as Outlook 97.
Volume V of "Boring Software". Self-contained, no externals.
============================================================ */
:root{
--bg:#d4d0c8; /* classic Win95/97 face gray */
--panel:#ece9d8;
--white:#ffffff;
--line:#808080;
--line-lt:#ffffff;
--line-dk:#404040;
--text:#000000;
--muted:#555555;
--navy:#0a246a;
--navy2:#a6caf0;
--sel:#08246b; /* selection bar (Outlook navy) */
--sel-txt:#ffffff;
--titlebar1:#0a246a;
--titlebar2:#3a6ea5;
--unread:#000080;
--warn:#a50000;
--green:#0a640a;
--amber:#9a6a00;
--tbl-line:#d8d4cc;
--mono:"Lucida Console","DejaVu Sans Mono",Consolas,monospace;
--ui:"MS Sans Serif","Segoe UI",Tahoma,Geneva,sans-serif;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
font-family:var(--ui); font-size:12px; color:var(--text);
background:var(--bg); overflow:hidden; user-select:none; -webkit-user-select:none;
}
button{font-family:inherit;font-size:inherit;color:inherit;cursor:default;}
/* ---- raised / sunken bevels (the Win97 language) ---- */
.raised{border:1px solid;border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);}
.sunken{border:1px solid;border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);background:var(--white);}
.groove{border:1px solid;border-color:var(--line) var(--line-lt) var(--line-lt) var(--line);}
#app{display:flex;flex-direction:column;height:100vh;width:100vw;background:var(--bg);}
/* ---- Title bar ---- */
#titlebar{flex:0 0 auto;height:20px;display:flex;align-items:center;gap:5px;
background:linear-gradient(90deg,var(--titlebar1),var(--titlebar2));color:#fff;
font-weight:bold;padding:0 3px 0 4px;font-size:11px;}
#titlebar .tb-ic{width:14px;height:14px;background:#fff;color:var(--navy);border-radius:1px;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:bold;}
#titlebar .tb-title{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#titlebar .tb-btns{display:flex;gap:2px;}
.tb-sq{width:16px;height:14px;background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
display:flex;align-items:center;justify-content:center;font-size:9px;color:#000;line-height:1;}
/* ---- Menu bar ---- */
#menubar{flex:0 0 auto;display:flex;align-items:center;background:var(--bg);
border-bottom:1px solid var(--line);height:20px;padding:0 1px;}
.menu{padding:2px 8px;position:relative;font-size:12px;}
.menu:hover{background:var(--sel);color:#fff;}
.menu .dropdown{display:none;position:absolute;top:19px;left:0;background:var(--bg);
border:1px solid;border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
box-shadow:2px 2px 0 rgba(0,0,0,.35);min-width:208px;z-index:400;padding:2px;color:#000;}
.menu:hover .dropdown{display:block;}
.menu .dropdown div{padding:3px 22px 3px 22px;display:flex;justify-content:space-between;gap:22px;white-space:nowrap;}
.menu .dropdown div:hover{background:var(--sel);color:#fff;}
.menu .dropdown .sep{height:1px;background:var(--line);margin:3px 2px;padding:0;border-bottom:1px solid var(--line-lt);}
.menu .dropdown .kbd{color:var(--muted);}
.menu .dropdown div:hover .kbd{color:#dfe7ff;}
/* ---- Toolbar ---- */
#toolbar{flex:0 0 auto;display:flex;align-items:center;gap:1px;height:40px;
background:var(--bg);border-bottom:1px solid var(--line);padding:2px 4px;}
.tbtn{display:flex;flex-direction:column;align-items:center;justify-content:center;
min-width:48px;height:36px;border:1px solid transparent;background:transparent;padding:1px 5px;gap:1px;}
.tbtn:hover{border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);}
.tbtn:active,.tbtn.on{border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);background:#c8c4bc;}
.tbtn[disabled]{opacity:.4;pointer-events:none;}
.tbtn .ic{font-size:17px;line-height:18px;filter:saturate(.55);}
.tbtn .lb{font-size:10px;white-space:nowrap;}
.tsep{width:2px;height:28px;border-left:1px solid var(--line);border-right:1px solid var(--line-lt);margin:0 4px;}
#tb-stats{margin-left:auto;display:flex;gap:2px;align-items:stretch;padding-right:2px;}
.statbox{display:flex;flex-direction:column;align-items:flex-end;justify-content:center;
padding:1px 8px;min-width:74px;}
.statbox .k{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;}
.statbox .v{font-size:13px;font-weight:bold;font-family:var(--mono);line-height:1.1;}
.statbox .v.pp{color:var(--green);} .statbox .v.enl{color:#5a1d8a;} .statbox .v.foc{color:var(--navy);}
/* ---- Body 3-pane ---- */
#body{flex:1 1 auto;display:flex;min-height:0;padding:2px;gap:2px;}
#folders{width:172px;flex:0 0 auto;display:flex;flex-direction:column;min-height:0;}
.pane{background:var(--white);overflow:auto;}
.folders-head{flex:0 0 auto;background:var(--bg);padding:3px 6px;font-weight:bold;font-size:11px;
border-bottom:1px solid var(--line);}
#folder-tree{flex:1 1 auto;overflow:auto;background:var(--white);padding:3px 0;}
.fold{display:flex;align-items:center;gap:6px;padding:3px 8px;white-space:nowrap;}
.fold:hover{background:#e8e8e8;}
.fold.active{background:var(--sel);color:#fff;}
.fold .f-ic{width:16px;text-align:center;}
.fold .f-lb{flex:1;overflow:hidden;text-overflow:ellipsis;}
.fold .f-ct{font-family:var(--mono);font-size:11px;color:var(--muted);}
.fold.active .f-ct{color:#cfe0ff;}
.fold.locked{opacity:.45;}
.fold-section{padding:5px 8px 2px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
#vsplit{width:3px;cursor:col-resize;flex:0 0 auto;background:var(--bg);}
#vsplit:hover{background:var(--navy2);}
#center{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;gap:2px;}
/* ---- Message list ---- */
#list-wrap{flex:1 1 auto;min-height:80px;overflow:auto;background:var(--white);}
table.mlist{border-collapse:collapse;width:100%;font-size:12px;table-layout:fixed;}
table.mlist thead th{position:sticky;top:0;z-index:2;background:var(--bg);
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
padding:3px 6px;text-align:left;font-weight:normal;white-space:nowrap;
border-top:1px solid var(--line-lt);}
table.mlist thead th .sortcaret{float:right;color:var(--muted);font-size:9px;}
table.mlist tbody td{border-bottom:1px solid var(--tbl-line);padding:3px 6px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;}
table.mlist tbody tr:hover{background:#eef3fb;}
table.mlist tbody tr.sel{background:var(--sel);color:#fff;}
table.mlist tbody tr.sel .m-sub,table.mlist tbody tr.sel .m-from{color:#fff;}
table.mlist tbody tr.unread .m-from,table.mlist tbody tr.unread .m-sub{font-weight:bold;color:var(--unread);}
table.mlist tbody tr.unread.sel .m-from,table.mlist tbody tr.unread.sel .m-sub{color:#fff;}
.m-num{text-align:right;font-family:var(--mono);}
.m-flag{text-align:center;width:20px;}
.m-att{text-align:center;width:22px;color:var(--muted);}
.bang{color:var(--warn);font-weight:bold;}
.vip-tag{color:#8a5a00;}
.type-spam{color:#909090;}
.type-phish{color:#a50000;}
#empty-list{padding:50px 20px;text-align:center;color:var(--muted);}
#empty-list .big{font-size:15px;color:var(--green);font-weight:bold;margin-bottom:6px;}
#hsplit{height:3px;cursor:row-resize;flex:0 0 auto;background:var(--bg);}
#hsplit:hover{background:var(--navy2);}
/* ---- Reading pane ---- */
#reading{height:210px;flex:0 0 auto;display:flex;flex-direction:column;min-height:0;background:var(--white);}
#read-head{flex:0 0 auto;background:var(--panel);border-bottom:1px solid var(--line);padding:6px 10px;}
#read-head .rh-sub{font-weight:bold;font-size:13px;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#read-head .rh-row{display:flex;gap:6px;font-size:11px;color:#222;}
#read-head .rh-row .lbl{color:var(--muted);width:48px;flex:0 0 auto;}
#read-head .rh-row .val{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#read-body{flex:1 1 auto;overflow:auto;padding:10px 12px;font-size:12px;line-height:1.5;
font-family:"Times New Roman",Georgia,serif;}
#read-body .sig{color:var(--muted);font-style:italic;margin-top:10px;border-top:1px solid #ddd;padding-top:6px;font-size:11px;}
#read-empty{padding:36px;text-align:center;color:var(--muted);}
/* ---- Status bar ---- */
#statusbar{flex:0 0 auto;height:22px;display:flex;align-items:stretch;background:var(--bg);
border-top:1px solid var(--line-lt);font-size:11px;}
.sb{display:flex;align-items:center;gap:5px;padding:0 9px;font-family:var(--ui);
border:1px solid;border-color:var(--line) var(--line-lt) var(--line-lt) var(--line);margin:1px 0;}
.sb:first-child{margin-left:2px;} .sb:last-child{margin-right:2px;}
.sb.grow{flex:1;justify-content:flex-start;}
.sb b{font-family:var(--mono);}
.sb .gauge{width:120px;height:11px;background:#fff;border:1px solid var(--line-dk);position:relative;}
.sb .gauge .fill{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(#5fa4e0,#2f6db5);}
.sb .gauge .fill.warn{background:linear-gradient(#e0b85f,#b5862f);}
.sb .gauge .fill.over{background:linear-gradient(#e07a5f,#b5402f);}
.sb .gauge .gt{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:9px;font-family:var(--mono);color:#000;}
.over-flag{color:var(--warn);font-weight:bold;}
.foc-gauge{width:80px;}
.foc-gauge .fill{background:linear-gradient(#7fc46a,#3f8a2f);}
/* ---- Modals (dialog boxes) ---- */
#modal-back{position:fixed;inset:0;background:rgba(0,0,0,.18);display:none;z-index:600;
align-items:center;justify-content:center;}
#modal-back.show{display:flex;}
.dialog{background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
box-shadow:3px 3px 0 rgba(0,0,0,.4);width:560px;max-width:94vw;max-height:88vh;
display:flex;flex-direction:column;overflow:hidden;}
.dialog.wide{width:680px;}
.dialog.sm{width:400px;}
.dlg-title{flex:0 0 auto;height:20px;display:flex;align-items:center;gap:5px;
background:linear-gradient(90deg,var(--titlebar1),var(--titlebar2));color:#fff;
font-weight:bold;padding:0 3px 0 6px;font-size:11px;}
.dlg-title .x{margin-left:auto;width:16px;height:14px;background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
display:flex;align-items:center;justify-content:center;color:#000;font-size:9px;}
.dlg-body{flex:1 1 auto;overflow:auto;padding:0;}
.dlg-foot{flex:0 0 auto;padding:8px 10px;display:flex;gap:6px;justify-content:flex-end;align-items:center;
border-top:1px solid var(--line-lt);}
.dlg-foot .spacer{flex:1;}
.btn{border:1px solid;border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
background:var(--bg);padding:4px 14px;min-width:74px;text-align:center;}
.btn:active{border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);}
.btn:focus{outline:1px dotted #000;outline-offset:-4px;}
.btn[disabled]{opacity:.45;color:#777;pointer-events:none;}
.btn.primary{font-weight:bold;}
/* dialog tabs */
.dtabs{display:flex;background:var(--bg);padding:4px 4px 0;gap:1px;border-bottom:1px solid var(--line);
position:sticky;top:0;z-index:3;}
.dtab{padding:4px 12px;border:1px solid var(--line);border-bottom:none;background:#c8c4bc;
border-color:var(--line-lt) var(--line) var(--line) var(--line-lt);position:relative;top:1px;}
.dtab.active{background:var(--bg);border-bottom:1px solid var(--bg);font-weight:bold;top:0;padding-bottom:5px;}
.dtab.locked{opacity:.5;}
/* options / upgrade list */
.opt-list{display:flex;flex-direction:column;}
.opt-row{display:flex;align-items:center;gap:9px;padding:7px 11px;border-bottom:1px solid var(--line);}
.opt-row:nth-child(even){background:#e6e3dc;}
.opt-row .o-ic{width:18px;text-align:center;font-size:14px;filter:grayscale(1);opacity:.7;flex:0 0 auto;}
.opt-row .o-main{flex:1;min-width:0;}
.opt-row .o-name{font-weight:bold;}
.opt-row .o-name .lvl{font-weight:normal;color:var(--muted);font-family:var(--mono);margin-left:8px;font-size:11px;}
.opt-row .o-desc{color:#333;font-size:11px;margin-top:1px;}
.opt-row .o-eff{font-size:11px;font-family:var(--mono);color:var(--navy);margin-top:1px;}
.opt-row .o-buy{display:flex;align-items:center;gap:10px;min-width:158px;justify-content:flex-end;}
.opt-cost{font-family:var(--mono);font-weight:bold;min-width:58px;text-align:right;}
.opt-cost.ok{color:var(--green);} .opt-cost.no{color:var(--warn);}
.opt-row.maxed .o-buy{opacity:.6;}
.opt-row.locked{opacity:.5;}
.opt-row .btn{min-width:62px;padding:3px 8px;}
.tab-note{padding:6px 11px;background:#e6e3dc;border-bottom:1px solid var(--line);font-size:11px;color:#333;}
/* ---- Rules manager ---- */
.rules-wrap{padding:0;}
.rule-row{display:flex;align-items:center;gap:6px;padding:5px 10px;border-bottom:1px solid var(--line);font-size:11px;}
.rule-row:nth-child(even){background:#e6e3dc;}
.rule-row .r-num{width:18px;color:var(--muted);font-family:var(--mono);}
.rule-cond{flex:1;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.rule-cond .kw{color:var(--navy);font-weight:bold;}
.rule-cond .act-archive{color:var(--green);font-weight:bold;}
.rule-cond .act-delete{color:var(--warn);font-weight:bold;}
.rule-cond .act-reply{color:#5a1d8a;font-weight:bold;}
.rule-cond .act-flag{color:var(--amber);font-weight:bold;}
.rule-cond .pat{background:#fff;border:1px solid var(--line);padding:0 3px;}
.rule-row .r-hits{font-family:var(--mono);color:var(--muted);min-width:62px;text-align:right;}
.rule-row .r-del{width:20px;text-align:center;color:var(--warn);cursor:default;
border:1px solid var(--line);background:var(--bg);}
.rule-row .r-del:active{border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);}
.rule-row .r-toggle{width:14px;height:14px;}
.rule-empty{padding:24px;text-align:center;color:var(--muted);}
.rule-builder{padding:10px 11px;background:#e6e3dc;border-bottom:2px solid var(--line);}
.rb-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:7px;}
.rb-line .kw{font-weight:bold;width:30px;}
.rb-line select,.rb-line input{font-family:var(--ui);font-size:12px;padding:2px 3px;
border:1px solid;border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);background:#fff;}
.rb-line input.pat{flex:1;min-width:120px;font-family:var(--mono);}
.rb-hint{font-size:10px;color:var(--muted);margin-top:2px;}
.rb-foot{display:flex;align-items:center;gap:8px;margin-top:4px;}
/* generic dialog content */
.dlg-pad{padding:12px 14px;}
.dlg-pad p{margin:0 0 9px;line-height:1.45;}
.kv{display:grid;grid-template-columns:max-content 1fr;gap:3px 16px;font-size:12px;}
.kv .k{color:var(--muted);} .kv .v{font-family:var(--mono);}
.big-num{font-size:24px;font-family:var(--mono);font-weight:bold;color:#5a1d8a;}
.note-box{background:#fffbe6;border:1px solid var(--amber);padding:8px 10px;margin:9px 0;font-size:11px;}
.warn-box{background:#fff0ee;border:1px solid var(--warn);padding:8px 10px;margin:9px 0;font-size:11px;color:#6a1410;}
.field{display:flex;flex-direction:column;gap:3px;margin-bottom:9px;}
.field label{font-size:11px;color:#333;}
.field textarea,.field input{font-family:var(--mono);font-size:11px;padding:4px;
border:1px solid;border-color:var(--line-dk) var(--line-lt) var(--line-lt) var(--line-dk);background:#fff;}
.field textarea{height:120px;resize:none;}
/* context menu */
#ctx{position:fixed;z-index:700;background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
box-shadow:2px 2px 0 rgba(0,0,0,.35);padding:2px;display:none;min-width:172px;}
#ctx div{padding:3px 20px;display:flex;justify-content:space-between;gap:18px;}
#ctx div:hover{background:var(--sel);color:#fff;}
#ctx div.dis{opacity:.4;pointer-events:none;}
#ctx .sep{height:1px;background:var(--line);margin:2px;padding:0;border-bottom:1px solid var(--line-lt);}
#ctx .kbd{color:var(--muted);} #ctx div:hover .kbd{color:#dfe7ff;}
/* toasts */
#toasts{position:fixed;right:8px;bottom:30px;z-index:800;display:flex;flex-direction:column;gap:6px;align-items:flex-end;}
.toast{background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);
box-shadow:2px 2px 0 rgba(0,0,0,.3);padding:0;max-width:320px;font-size:11px;
animation:tin .18s ease;}
.toast .t-bar{height:18px;display:flex;align-items:center;gap:4px;padding:0 6px;font-weight:bold;
background:linear-gradient(90deg,var(--titlebar1),var(--titlebar2));color:#fff;font-size:10px;}
.toast.warn .t-bar{background:linear-gradient(90deg,#7a5a00,#b5862f);}
.toast.bad .t-bar{background:linear-gradient(90deg,#7a0000,#b5402f);}
.toast.enl .t-bar{background:linear-gradient(90deg,#3a0a6a,#7a3aa5);}
.toast .t-body{padding:6px 8px;line-height:1.35;}
@keyframes tin{from{transform:translateX(30px);opacity:0;}to{transform:none;opacity:1;}}
/* overwhelmed flash */
#body.overwhelmed #list-wrap{box-shadow:inset 0 0 0 2px var(--warn);}
::-webkit-scrollbar{width:16px;height:16px;background:var(--bg);}
::-webkit-scrollbar-thumb{background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);}
::-webkit-scrollbar-button{background:var(--bg);border:1px solid;
border-color:var(--line-lt) var(--line-dk) var(--line-dk) var(--line-lt);height:16px;width:16px;}
.blink{animation:blink 1s step-start infinite;}
@keyframes blink{50%{opacity:0;}}
</style>
</head>
<body>
<div id="app">
<!-- TITLE BAR -->
<div id="titlebar">
<div class="tb-ic">@</div>
<div class="tb-title" id="tb-title">Inbox - Microsoft Outlook</div>
<div class="tb-btns">
<div class="tb-sq">_</div><div class="tb-sq"></div><div class="tb-sq"></div>
</div>
</div>
<!-- MENU BAR -->
<div id="menubar">
<div class="menu">File
<div class="dropdown">
<div data-act="new-rule">New Rule…</div>
<div class="sep"></div>
<div data-act="save-now">Save Session<span class="kbd">Ctrl+S</span></div>
<div data-act="export-save">Export Save…</div>
<div data-act="import-save">Import Save…</div>
<div class="sep"></div>
<div data-act="hard-reset">Empty Account &amp; Reset…</div>
</div>
</div>
<div class="menu">Edit
<div class="dropdown">
<div data-act="archive">Archive<span class="kbd">E</span></div>
<div data-act="reply">Reply<span class="kbd">R</span></div>
<div data-act="delete">Delete<span class="kbd">Del / #</span></div>
<div class="sep"></div>
<div data-act="archive-all-read">Archive All Read</div>
</div>
</div>
<div class="menu">View
<div class="dropdown">
<div data-act="toggle-sci">Number Format (SI / Scientific)</div>
<div data-act="manage-rules">Rules &amp; Filters…</div>
</div>
</div>
<div class="menu">Tools
<div class="dropdown">
<div data-act="manage-rules">Rules &amp; Alerts…</div>
<div data-act="open-options">Options…</div>
<div class="sep"></div>
<div data-act="open-ascend">Attain Inbox Zero (Ascend)…</div>
</div>
</div>
<div class="menu">Help
<div class="dropdown">
<div data-act="open-help">How To Reach Zero…</div>
<div data-act="open-about">About Inbox Zero</div>
</div>
</div>
</div>
<!-- TOOLBAR -->
<div id="toolbar">
<button class="tbtn" data-act="archive" title="Archive selected (E)"><span class="ic">🗄️</span><span class="lb">Archive</span></button>
<button class="tbtn" data-act="reply" title="Reply (R)"><span class="ic">↩️</span><span class="lb">Reply</span></button>
<button class="tbtn" data-act="delete" title="Delete (Del / #)"><span class="ic">🗑️</span><span class="lb">Delete</span></button>
<div class="tsep"></div>
<button class="tbtn" data-act="new-rule" title="Create a rule"><span class="ic">⚙️</span><span class="lb">New Rule</span></button>
<button class="tbtn" data-act="manage-rules" title="Manage rules"><span class="ic">📋</span><span class="lb">Rules</span></button>
<button class="tbtn" data-act="open-options" title="Options"><span class="ic">🔧</span><span class="lb">Options</span></button>
<div class="tsep"></div>
<button class="tbtn" id="tb-ascend" data-act="open-ascend" title="Ascend"><span class="ic">🧘</span><span class="lb">Ascend</span></button>
<div id="tb-stats">
<div class="statbox"><span class="k">Productivity</span><span class="v pp" id="hud-pp">0</span></div>
<div class="statbox"><span class="k">Focus</span><span class="v foc" id="hud-focus">0</span></div>
<div class="statbox" id="hud-enl-box" style="display:none"><span class="k">Enlightenment</span><span class="v enl" id="hud-enl">0</span></div>
</div>
</div>
<!-- BODY -->
<div id="body">
<div id="folders">
<div class="folders-head" id="acct-name">Outlook Today - [sysadmin@boringsoft]</div>
<div id="folder-tree" class="sunken"></div>
</div>
<div id="vsplit"></div>
<div id="center">
<div id="list-wrap" class="sunken">
<table class="mlist">
<colgroup>
<col style="width:22px"><col style="width:22px"><col><col style="width:300px">
<col style="width:118px"><col style="width:64px">
</colgroup>
<thead><tr id="ml-head"></tr></thead>
<tbody id="ml-body"></tbody>
</table>
<div id="empty-list" style="display:none"></div>
</div>
<div id="hsplit"></div>
<div id="reading" class="sunken">
<div id="read-head"></div>
<div id="read-body"></div>
</div>
</div>
</div>
<!-- STATUS BAR -->
<div id="statusbar">
<div class="sb" id="sb-folder">Inbox</div>
<div class="sb" id="sb-unread">Unread: <b>0</b></div>
<div class="sb grow" id="sb-status">Connected to mail.boringsoft.net</div>
<div class="sb" id="sb-rate"><span></span> <b id="sb-rate-v">0</b>/min</div>
<div class="sb"><span>Focus</span>
<div class="gauge foc-gauge"><div class="fill" id="foc-fill"></div><div class="gt" id="foc-gt"></div></div>
</div>
<div class="sb"><span id="cap-lbl">Inbox</span>
<div class="gauge"><div class="fill" id="cap-fill"></div><div class="gt" id="cap-gt">0 / 0</div></div>
</div>
</div>
</div>
<!-- MODAL / DIALOG -->
<div id="modal-back"><div class="dialog" id="dialog"></div></div>
<!-- CONTEXT MENU -->
<div id="ctx"></div>
<!-- TOASTS -->
<div id="toasts"></div>
<script>
"use strict";
/* ===========================================================================
INBOX ZERO — Volume V of "Boring Software"
=========================================================================== */
const SAVE_KEY = "boringsoft_inbox_v1"; // UNIQUE per volume — do not collide.
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600; // cap offline catch-up at 8h
const HARD_RENDER_CAP = 220; // max rows actually painted to the DOM
/* ---- number formatting (re-implemented per spec) -------------------------- */
const SI = ['','k','M','B','T','Qa','Qi','Sx','Sp','Oc','No','Dc'];
function sciStr(n){ const e=Math.floor(Math.log10(n)); return (n/Math.pow(10,e)).toFixed(2)+'e'+e; }
function fmt(n){
if(n===Infinity) return '∞';
if(!isFinite(n)||isNaN(n)) return '0';
if(n<0) n=0;
if(n<1000) return (Math.abs(n%1)<1e-9?n.toFixed(0):n.toFixed(1));
if(G&&G.sci) return sciStr(n);
let i=0,x=n;
while(x>=1000 && i<SI.length-1){x/=1000;i++;}
if(i===SI.length-1 && x>=1000) return sciStr(n); // past Dc -> scientific
return x.toFixed(x<100?2:1)+' '+SI[i];
}
function fmtInt(n){
if(!isFinite(n)) return '∞';
if(n<1000) return Math.floor(n).toString();
return fmt(n);
}
function fmtTime(s){
if(!isFinite(s)||s<0) return '∞';
if(s<1) return '< 1s';
s=Math.floor(s);
const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),ss=s%60;
if(d>99) return '∞';
if(d>0) return d+'d '+h+'h';
if(h>0) return h+'h '+m+'m';
if(m>0) return m+'m '+ss+'s';
return ss+'s';
}
function now(){ return Date.now(); }
function rand(a,b){ return a + Math.random()*(b-a); }
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function clamp(x,a,b){ return x<a?a:(x>b?b:x); }
function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
/* ===========================================================================
PLANES (prestige tiers) — arrival rate multiplies, power multiplies.
=========================================================================== */
const PLANES = [
{name:'Startup', rate:1, ppMult:1, domain:'boringsoft.net'},
{name:'Enterprise', rate:6, ppMult:7, domain:'enterprise-corp.com'},
{name:'Government', rate:32, ppMult:55, domain:'agency.gov'},
{name:'The Whole Internet',rate:180, ppMult:520, domain:'all-of.net'},
{name:'The Void', rate:1100, ppMult:6400, domain:'∅.void'},
];
function plane(){ return PLANES[Math.min(G.plane||0, PLANES.length-1)]; }
/* ===========================================================================
MAIL GENERATION — procedural plausible email.
type: work | newsletter | spam | vip | phishing
=========================================================================== */
const MAIL_TYPES = {
work: {weight:34, baseVal:14, flag:false, vip:false},
newsletter: {weight:24, baseVal:5, flag:false, vip:false},
spam: {weight:24, baseVal:0, flag:false, vip:false},
phishing: {weight:10, baseVal:0, flag:true, vip:false},
vip: {weight:8, baseVal:90, flag:true, vip:true},
};
const TYPE_ORDER = ['work','newsletter','spam','phishing','vip'];
const FIRST = ['James','Linda','Robert','Patricia','Karen','Sandeep','Wei','Olga','Hans','Marco',
'Aisha','Diego','Priya','Tomasz','Yuki','Ahmed','Fatima','Bjorn','Chen','Raj','Greg','Megan',
'Dwight','Pam','Stanley','Angela','Kevin','Oscar','Toby','Holly'];
const LAST = ['Smith','Johnson','Patel','Nguyen','Garcia','Mueller','Rossi','Kim','Okonkwo','Larsson',
'Schrute','Beesly','Hudson','Martin','Halpert','Bratton','Malone','Flenderson','Kapoor','Bernard'];
const WORK_DOMAINS = ['boringsoft.net','accounting.corp','it-helpdesk.internal','hr.boringsoft.net',
'vendor-relations.com','procurement.corp','legal.boringsoft.net','facilities.local'];
const NEWS_DOMAINS = ['newsletter.devweekly.io','digest.medium.com','updates.linkedin.com','news.producthunt.com',
'mailing.hackernews.club','promo.bestbuy.com','offers.expedia.com','weekly.substack.com'];
const SPAM_DOMAINS = ['win-prize.biz','hot-singles.ru','crypto-double.io','rolex-replica.cn',
'deals4u.click','enlarge.now','nigerian-prince.org','free-iphone.xyz','meds-cheap.pharma'];
const PHISH_DOMAINS = ['secure-paypa1.com','microsoft-account-verify.net','irs-refund.gov-tax.co',
'apple-id-locked.support','bank0famerica.net','docusign-secure.cc','fedex-redelivery.info'];
const VIP_DOMAINS = ['ceo','board','investor-relations','general-counsel','the-client'];
const WORK_SUBJ = ['Re: Q{n} forecast numbers','FW: Action required — {proj} deck','Quick sync re: {proj}',
'Approval needed: PO #{n}','Re: Re: Re: meeting notes','{proj} status update','Can you review this by EOD?',
'Updated spreadsheet attached','URGENT: server room AC is out','Timesheet reminder — week {n}',
'New ticket assigned: #{n}','FW: FW: please advise','Lunch &amp; learn next Thursday','Office 365 password expires'];
const NEWS_SUBJ = ['Your weekly digest is here','{n} articles you missed','🔥 Trending in tech this week',
'The newsletter you forgot to unsubscribe','Top {n} productivity hacks','Limited time: {n}% off everything',
'We miss you! Come back','Your {proj} subscription renews soon','This week in JavaScript'];
const SPAM_SUBJ = ['You have WON $1,000,000!!!','Hot deals just 4 U','Re: your unclaimed package',
'Make $5000/week from home','CONGRATULATIONS valued customer','Cheap m3ds no prescription',
'She will not believe this trick','Final notice: claim your prize','💊💊 Special offer inside 💊💊'];
const PHISH_SUBJ = ['Your account has been suspended','Verify your identity now','Unusual sign-in detected',
'Action required: payment failed','Your package could not be delivered','Tax refund of $842.19 pending',
'Document shared with you — sign now','Security alert: confirm your password'];
const VIP_SUBJ = ['Need this on my desk in 5','Call me when you see this','Re: the thing we discussed',
'board meeting moved up','can you handle this personally?','Quick favor — confidential','URGENT and personal'];
const PROJ = ['Phoenix','Atlas','Synergy','Q3','Falcon','Helios','Northstar','Titan','Orion','Cascade'];
const BODIES = {
work:['Hi,\n\nJust circling back on this. Can you take a look when you get a chance? No rush, but ideally before the standup.\n\nThanks,','Per my last email — see attached. Let me know if anything looks off.\n\nBest,','Looping in the team. We need to align on this before Friday. Thoughts?','Sorry to be a pain but this is now blocking the release. Any update?'],
newsletter:['You\'re receiving this because you signed up (or someone did for you). Here are this week\'s top stories...','Don\'t miss out! Our biggest sale of the season ends soon. Shop now and save big.','Thanks for being a subscriber. We\'ve curated some content we think you\'ll love.'],
spam:['Dear Friend, I am contacting you regarding an urgent business proposal involving $47,000,000 USD...','CONGRATULATIONS! Your email address has been selected in our international lottery!!!','Click here NOW to claim your reward before it expires. Limited spots available!!!'],
phishing:['Dear Customer, We detected unusual activity on your account. Please verify your information immediately by clicking the link below to avoid suspension.','Your password will expire in 24 hours. Update it now to keep your account secure.','We were unable to deliver your package. Please confirm your address and pay the redelivery fee.'],
vip:['I need this handled today. Don\'t cc anyone. Just get it done and report back to me directly.','Walk me through the numbers. I have the board in an hour and I cannot look unprepared.','This is important. Drop whatever you\'re doing.'],
};
let _mid = 1;
function genMail(forceType){
const type = forceType || rollType();
const meta = MAIL_TYPES[type];
const n = Math.floor(rand(1,9999));
const proj = pick(PROJ);
const subT = ({work:WORK_SUBJ,newsletter:NEWS_SUBJ,spam:SPAM_SUBJ,phishing:PHISH_SUBJ,vip:VIP_SUBJ})[type];
let subject = pick(subT).replace(/\{n\}/g,n).replace(/\{proj\}/g,proj);
let fromName, fromAddr;
if(type==='work'){ fromName=pick(FIRST)+' '+pick(LAST); fromAddr=fromName.toLowerCase().replace(/[^a-z]/g,'.')+'@'+pick(WORK_DOMAINS); }
else if(type==='newsletter'){ const d=pick(NEWS_DOMAINS); fromName=d.split('.')[1]||d.split('.')[0]; fromName=fromName[0].toUpperCase()+fromName.slice(1); fromAddr='noreply@'+d; }
else if(type==='spam'){ const d=pick(SPAM_DOMAINS); fromName=pick(['Prize Dept','Sales Team','Customer Care','Mr. Goodwill','Lucky Winner']); fromAddr=pick(['offers','promo','win','deal','no-reply'])+'@'+d; }
else if(type==='phishing'){ const d=pick(PHISH_DOMAINS); fromName=pick(['Account Security','Support Team','Billing','IT Admin','Verification']); fromAddr=pick(['security','support','no-reply','alert','verify'])+'@'+d; }
else { fromName=pick(['The CEO','Board Chair','General Counsel','The Investor','Big Client']); fromAddr=pick(VIP_DOMAINS)+'@'+plane().domain; }
// value scales with plane productivity multiplier
const baseVal = meta.baseVal * (0.7+Math.random()*0.6);
return {
id:_mid++, type, from:fromName, addr:fromAddr, subject,
value: Math.max(0, baseVal),
unread:true, flagged:meta.flag, vip:meta.vip,
att: type==='work'&&Math.random()<0.45 || type==='phishing'&&Math.random()<0.6,
t: now(), body: pick(BODIES[type]),
};
}
function rollType(){
let total=0; for(const k of TYPE_ORDER) total+=MAIL_TYPES[k].weight;
let r=Math.random()*total;
for(const k of TYPE_ORDER){ r-=MAIL_TYPES[k].weight; if(r<=0) return k; }
return 'work';
}
/* ===========================================================================
UPGRADES — diegetic, bought through Options / Rules dialogs.
kind: 'mult' (PP per action), 'cap', 'focusmax', 'focusregen', 'ruleslots',
'rulespeed', 'autopp', 'unlock'
=========================================================================== */
const UPGRADES = [
{id:'hotkeys', tab:'triage', ic:'⌨️', name:'Triage Hotkey Training', kind:'mult',
desc:'Muscle memory. ×1.45 Productivity per manual triage action.', base:50, growth:1.55, mult:1.45, max:60},
{id:'reading', tab:'triage', ic:'👓', name:'Speed-Reading Course', kind:'readmult',
desc:'Skim faster. Archiving an UNREAD mail no longer loses value (+30% on reads).', base:140, growth:1.7, mult:1.30, max:40},
{id:'cap', tab:'triage', ic:'🧠', name:'Sanity Reserves (Inbox Cap)', kind:'cap',
desc:'Raise the overflow threshold. +14 Inbox capacity before Overwhelmed.', base:80, growth:1.6, add:14, max:200},
{id:'focusmax', tab:'focus', ic:'🔋', name:'Focus Pool', kind:'focusmax',
desc:'Deeper concentration. +20 maximum Focus (Replies cost Focus).', base:120, growth:1.7, add:20, max:120},
{id:'focusregen', tab:'focus', ic:'☕', name:'Coffee Machine (Focus Regen)', kind:'focusregen',
desc:'+0.6 Focus/sec regeneration. Replies & rule edits cost Focus.', base:200, growth:1.7, add:0.6, max:120},
{id:'replyval', tab:'focus', ic:'✍️', name:'Canned Responses', kind:'replymult',
desc:'Templated replies. ×1.6 Productivity from every Reply.', base:260, growth:1.75, mult:1.6, max:60},
{id:'ruleslots', tab:'rules', ic:'📑', name:'Additional Rule Slots', kind:'ruleslots',
desc:'+1 rule you can have active at once.', base:300, growth:2.0, add:1, max:40},
{id:'rulespeed', tab:'rules', ic:'⚡', name:'Rule Engine Throughput', kind:'rulespeed',
desc:'Rules already auto-triage incoming mail. ×1.5 Productivity banked by rules.', base:500, growth:1.8, mult:1.5, max:60},
{id:'regex', tab:'rules', ic:'🔣', name:'Regex Pattern Matching', kind:'unlock',
desc:'Unlock full regular-expression patterns in rules (matches operator).', base:900},
{id:'autoresponder', tab:'rules', ic:'🤖', name:'Auto-Responder Daemon', kind:'autopp',
desc:'Passively banks Productivity from auto-handled mail. +12% of rule income as idle PP.', base:1500, growth:1.85, mult:0.12, max:30, reqUp:'rulespeed'},
];
const UP_BY_ID = Object.fromEntries(UPGRADES.map(u=>[u.id,u]));
/* ===========================================================================
PRESTIGE PERKS (Serenity) — permanent, bought with Enlightenment.
=========================================================================== */
const PERKS = [
{id:'clarity', ic:'🔆', name:'Clarity of Mind', desc:'+30% global Productivity per level.', base:1, growth:1.7, mult:1.30, max:50},
{id:'serenity', ic:'🕉️', name:'Serenity (Base Cap)', desc:'+10 permanent Inbox capacity per level.', base:1, growth:1.6, add:10, max:50},
{id:'flow', ic:'🌊', name:'Flow State (Focus)', desc:'+0.4 permanent Focus regen per level.', base:2, growth:1.7, add:0.4, max:40},
{id:'preset', ic:'📜', name:'Inherited Filters', desc:'Start each plane with +1 carried-over rule slot per level.', base:3, growth:1.9, add:1, max:20},
{id:'enlightened', ic:'🧘', name:'Enlightened Throughput', desc:'+25% Productivity from RULES per level (compounds with rules).', base:2, growth:1.8, mult:1.25, max:40},
];
const PERK_BY_ID = Object.fromEntries(PERKS.map(p=>[p.id,p]));
/* ===========================================================================
FOLDERS
=========================================================================== */
const FOLDERS = [
{id:'inbox', ic:'📥', name:'Inbox'},
{id:'archive', ic:'🗄️', name:'Archive'},
{id:'spam', ic:'🗑️', name:'Deleted Items'},
{id:'sent', ic:'📤', name:'Sent Items'},
];
/* ===========================================================================
STATE
=========================================================================== */
let G = null;
function freshState(){
return {
v:1,
pp:0, focus:30, focusMax:30,
enlightenment:0, peakThroughput:0, runPeakRate:0,
plane:0, ascensions:0,
inbox:[], sel:null, folder:'inbox',
folderCounts:{archive:0,spam:0,sent:0}, // lifetime processed (for folder counts)
upgrades:{}, perks:{},
rules:[], // {field,op,pattern,action,enabled,hits}
overwhelmedUntil:0,
spawnAccum:0,
sci:false,
lastSave:now(), started:now(),
sidebarW:172, readingH:210,
seen:{welcome:false},
rateEMA:0, // emails/min smoothed for status bar
};
}
/* ---- derived ---- */
const lvl = id => G.upgrades[id]||0;
const plvl = id => G.perks[id]||0;
const hasUp = id => (G.upgrades[id]||0)>0;
function perkMult(id){ const p=PERK_BY_ID[id]; return Math.pow(p.mult, plvl(id)); }
function perkAdd(id){ const p=PERK_BY_ID[id]; return p.add*plvl(id); }
function globalMult(){
let m = plane().ppMult;
m *= Math.pow(UP_BY_ID.hotkeys.mult, lvl('hotkeys')); // applies to manual; folded for clarity below
m *= perkMult('clarity');
return m;
}
// Overwhelmed penalty: while over Cap (or in the sticky window) ALL income is halved.
// This is the core pressure mechanic the UI/help promise ("income halved until you dig out").
function overwhelmPenalty(){ return isOverwhelmed() ? 0.5 : 1; }
// Manual triage PP (per action). hotkeys boosts manual; clarity boosts everything.
function manualMult(){
return plane().ppMult * Math.pow(UP_BY_ID.hotkeys.mult, lvl('hotkeys')) * perkMult('clarity') * overwhelmPenalty();
}
function readBonus(){ return 1 + lvl('reading')*(UP_BY_ID.reading.mult-1); }
function replyMult(){ return Math.pow(UP_BY_ID.replyval.mult, lvl('replyval')); }
// Rule PP multiplier (auto-triage income). rulespeed + enlightened perk.
function ruleMult(){
return plane().ppMult * Math.pow(UP_BY_ID.rulespeed.mult, lvl('rulespeed'))
* perkMult('clarity') * perkMult('enlightened') * overwhelmPenalty();
}
function inboxCap(){
// base buffer gives a new player ~20s of grace before the flood overwhelms them;
// the 'cap' upgrade and 'serenity' perk extend it as volume ramps each plane.
return 18 + lvl('cap')*UP_BY_ID.cap.add + perkAdd('serenity') + G.ascensions*0;
}
function focusMaxV(){ return 30 + lvl('focusmax')*UP_BY_ID.focusmax.add; }
function focusRegen(){ return 1.0 + lvl('focusregen')*UP_BY_ID.focusregen.add + perkAdd('flow'); }
function replyCost(){ return Math.max(3, 8 - lvl('focusregen')*0.0); }
function ruleSlots(){ return 1 + lvl('ruleslots')*UP_BY_ID.ruleslots.add + perkAdd('preset'); }
function arrivalRate(){
// emails per second, grows with plane and a slow time-pressure ramp within a run.
const base = 0.8 * plane().rate;
const ramp = 1 + Math.min(8, (G.peakThroughput>0? Math.log10(1+G.peakThroughput)*0.18 : 0));
return base * ramp;
}
function isOverwhelmed(){ return now() < G.overwhelmedUntil || G.inbox.length > inboxCap(); }
function enlightenmentFor(){
// prestige currency from peak throughput; sqrt-scaled, only meaningful past a floor.
const p = G.peakThroughput;
if(p < 2000) return 0;
return Math.floor(Math.pow(p/2000, 0.42) * (G.plane+1));
}
/* ===========================================================================
RULES ENGINE
=========================================================================== */
const RULE_FIELDS = ['from','subject','address','type'];
const RULE_OPS = ['contains','matches','is'];
const RULE_ACTIONS = ['archive','delete','reply','flag'];
function compileRule(r){
try{
if(r.op==='matches'){
r._re = new RegExp(r.pattern, 'i');
} else if(r.op==='contains'){
r._re = null; r._needle = String(r.pattern).toLowerCase();
} else { // is
r._re = null; r._needle = String(r.pattern).toLowerCase();
}
r._bad = false;
}catch(e){ r._bad = true; r._re=null; }
}
function fieldVal(m, field){
if(field==='from') return m.from;
if(field==='subject') return m.subject;
if(field==='address') return m.addr;
if(field==='type') return m.type;
return '';
}
function ruleMatches(r, m){
if(!r.enabled || r._bad) return false;
const v = String(fieldVal(m, r.field)).toLowerCase();
if(r.op==='matches'){ return r._re ? r._re.test(fieldVal(m,r.field)) : false; }
if(r.op==='is'){ return v === r._needle; }
return v.includes(r._needle); // contains
}
// Run rules against a mail the instant it arrives. Returns true if auto-handled.
function applyRules(m){
const slots = ruleSlots();
const active = G.rules.filter(r=>r.enabled).slice(0, slots);
for(const r of active){
if(ruleMatches(r, m)){
r.hits = (r.hits||0) + 1;
autoTriage(m, r.action);
return true;
}
}
return false;
}
function autoTriage(m, action){
let gain = 0;
if(action==='archive'){ gain = m.value; folderInc('archive'); }
else if(action==='delete'){ gain = m.value*0.1; folderInc('spam'); }
else if(action==='reply'){ gain = m.value*1.4; folderInc('sent'); }
else if(action==='flag'){ m.flagged=true; m._ruleHandled=false; G.inbox.push(m); return; } // flag keeps it
// bank PP via rule multiplier; autoresponder adds passive bonus
const banked = gain * ruleMult();
G.pp += banked;
if(hasUp('autoresponder')) G.pp += banked * lvl('autoresponder')*UP_BY_ID.autoresponder.mult;
trackThroughput(banked);
}
/* ===========================================================================
THROUGHPUT TRACKING (drives prestige currency)
=========================================================================== */
let ppRateWindow = []; // recent banked PP for "throughput" estimate
function trackThroughput(banked){
G._ppThisSec = (G._ppThisSec||0) + banked;
}
/* ===========================================================================
CORE ACTIONS — triage
=========================================================================== */
function selIdx(){
if(G.sel==null) return -1;
return G.inbox.findIndex(m=>m.id===G.sel);
}
function selectMail(id){
G.sel = id;
const m = G.inbox.find(x=>x.id===id);
if(m && m.unread){ m.unread=false; }
renderList(); renderReading(); renderStatus();
}
function moveSel(dir){
const i = selIdx();
if(G.inbox.length===0){ return; }
let ni;
if(i<0) ni = dir>0?0:G.inbox.length-1;
else ni = clamp(i+dir, 0, G.inbox.length-1);
selectMail(G.inbox[ni].id);
scrollSelIntoView();
}
function scrollSelIntoView(){
const row = document.querySelector('tr.sel');
if(row && row.scrollIntoView) row.scrollIntoView({block:'nearest'});
}
function removeAt(i){
G.inbox.splice(i,1);
// keep selection sensible
if(i < G.inbox.length) G.sel = G.inbox[i].id;
else if(G.inbox.length) G.sel = G.inbox[G.inbox.length-1].id;
else G.sel = null;
}
function folderInc(f){ G.folderCounts[f]=(G.folderCounts[f]||0)+1; }
function archiveSel(){
const i = selIdx(); if(i<0) return;
const m = G.inbox[i];
let v = m.value * manualMult();
if(!m.unread) v *= readBonus(); // already-read mail archives cleaner (speed-read bonus)
else if(lvl('reading')===0) v *= 0.85; // archiving unread sight-unseen = small penalty until trained
G.pp += v; trackThroughput(v);
folderInc('archive'); removeAt(i);
afterTriage();
}
function deleteSel(){
const i = selIdx(); if(i<0) return;
const m = G.inbox[i];
// Deleting a VIP/high-value mail wastes it (flat penalty). Deleting spam/phishing
// is the *correct* move and clears clutter for a tiny "tidied up" reward.
if(m.vip){
const penalty = m.value * 0.5 * manualMult();
G.pp = Math.max(0, G.pp - penalty);
toast('warn','Deleted a VIP','You just trashed a message from <b>'+esc(m.from)+'</b>. That will be noticed. ('+fmt(penalty)+' PP)');
} else {
const v = (m.value*0.1 + 0.5) * manualMult(); // clutter cleared
G.pp += v; trackThroughput(v);
}
folderInc('spam'); removeAt(i);
afterTriage();
}
function replySel(){
const i = selIdx(); if(i<0) return;
const m = G.inbox[i];
const cost = replyCost();
if(G.focus < cost){ toast('warn','Not enough Focus','Replying costs '+cost.toFixed(0)+' Focus. Let the coffee kick in (Focus regenerates).'); return; }
G.focus -= cost;
let v = m.value * 1.5 * manualMult() * replyMult();
G.pp += v; trackThroughput(v);
folderInc('sent'); removeAt(i);
toast('','Reply sent','Re: '+esc(m.subject.slice(0,40))+'<br>+'+fmt(v)+' PP · '+cost.toFixed(0)+' Focus');
afterTriage();
}
function afterTriage(){
renderList(); renderReading(); renderHud(); renderStatus(); renderFolders();
checkAscendReady();
}
function archiveAllRead(){
let banked=0, count=0;
for(let i=G.inbox.length-1;i>=0;i--){
const m=G.inbox[i];
if(!m.unread && !m.vip){
banked += m.value*manualMult()*readBonus(); count++;
folderInc('archive'); G.inbox.splice(i,1);
}
}
if(count){ G.pp+=banked; trackThroughput(banked); G.sel = G.inbox.length?G.inbox[0].id:null;
toast('','Bulk archive','Archived '+count+' read message'+(count>1?'s':'')+' · +'+fmt(banked)+' PP');
afterTriage();
} else toast('warn','Nothing to archive','Open some messages first (j/k to move, they mark as read).');
}
/* ===========================================================================
TICK
=========================================================================== */
function spawnMail(n){
for(let k=0;k<n;k++){
const m = genMail();
if(!applyRules(m)){
// not auto-handled -> lands in inbox
G.inbox.push(m);
}
}
if(G.inbox.length && G.sel==null) G.sel = G.inbox[0].id;
}
function step(dt){
// focus regen
G.focus = Math.min(focusMaxV(), G.focus + focusRegen()*dt);
if(G.focusMax!==focusMaxV()) G.focusMax=focusMaxV();
// arrival
let rate = arrivalRate();
if(isOverwhelmed()) rate *= 1.0; // arrivals don't slow; YOUR income tanks instead (below)
G.spawnAccum += rate*dt;
let toSpawn = Math.floor(G.spawnAccum);
if(toSpawn>0){ G.spawnAccum -= toSpawn; if(toSpawn>400) toSpawn=400; spawnMail(toSpawn); }
// overwhelmed penalty: if over cap, manual income halves and an Overwhelmed status sticks
if(G.inbox.length > inboxCap()){
G.overwhelmedUntil = now()+1500;
}
// throughput bookkeeping every ~1s
G._ppSecAccum = (G._ppSecAccum||0) + dt;
if(G._ppSecAccum>=1){
const persec = (G._ppThisSec||0)/G._ppSecAccum;
G._ppThisSec=0; G._ppSecAccum=0;
G.peakThroughput = Math.max(G.peakThroughput, persec);
G.rateEMA = G.rateEMA*0.7 + arrivalRate()*60*0.3;
}
}
/* ===========================================================================
OFFLINE CATCH-UP
=========================================================================== */
function simulateAway(dt){
if(!(dt>0)) return {banked:0, arrived:0};
const steps = Math.min(240, Math.max(20, Math.ceil(dt/30))), sdt=dt/steps;
let banked=0, arrived=0;
const beforePP = G.pp;
for(let i=0;i<steps;i++){
G.focus = Math.min(focusMaxV(), G.focus + focusRegen()*sdt);
const n = arrivalRate()*sdt;
G.spawnAccum += n;
let toSpawn = Math.floor(G.spawnAccum);
if(toSpawn>0){
G.spawnAccum -= toSpawn; arrived += toSpawn;
if(toSpawn>2000) toSpawn=2000;
// offline, only rule-handled mail banks PP; the rest piles into inbox (capped)
for(let k=0;k<toSpawn;k++){
const m = genMail();
if(!applyRules(m)){
if(G.inbox.length < inboxCap()*3) G.inbox.push(m); // let it overflow a bit, but cap memory
}
}
}
}
banked = G.pp - beforePP;
if(G.inbox.length && G.sel==null) G.sel=G.inbox[0].id;
return {banked, arrived};
}
function applyOffline(){
const dt = Math.min(MAX_OFFLINE_S, Math.max(0,(now()-(G.lastSave||now()))/1000));
if(dt<3) return;
const r = simulateAway(dt);
if(r.banked>0 || r.arrived>0){
setTimeout(()=>toast('info','Welcome back','You were away '+fmtTime(dt)+'.<br>'+
fmtInt(r.arrived)+' mails arrived; your Rules auto-banked <b>'+fmt(r.banked)+' PP</b>.'+
(G.inbox.length>inboxCap()?'<br><span style="color:#a50000">Your inbox overflowed while you were gone.</span>':'')),500);
}
}
/* ===========================================================================
PRESTIGE — Inbox Zero -> Ascend
=========================================================================== */
let _ascendToastShown=false;
function checkAscendReady(){
if(G.inbox.length===0 && enlightenmentFor()>=1 && !_ascendToastShown && G.plane<PLANES.length-1){
_ascendToastShown=true;
toast('enl','✦ INBOX ZERO ✦','Enlightenment within reach. Open <b>Tools ▸ Ascend</b> to bank <b>'+fmtInt(enlightenmentFor())+'</b> Enlightenment and rise a plane.');
}
if(G.inbox.length>0) _ascendToastShown=false;
updateAscendBtn();
}
function canAscend(){ return G.inbox.length===0 && enlightenmentFor()>=1; }
function doAscend(){
if(!canAscend()){ toast('warn','Not at Zero','You must genuinely reach Inbox Zero (0 messages) to ascend.'); return; }
const gained = enlightenmentFor();
const keepSlots = perkAdd('preset');
G.enlightenment += gained;
G.ascensions += 1;
G.plane = Math.min(G.plane+1, PLANES.length-1);
// reset run state, KEEP perks & enlightenment
G.pp = 0; G.focus = focusMaxV(); G.inbox=[]; G.sel=null;
G.peakThroughput = 0; G.spawnAccum=0; G.overwhelmedUntil=0;
G.folderCounts={archive:0,spam:0,sent:0};
// keep N carried-over rules (Inherited Filters perk)
G.rules = G.rules.slice(0, keepSlots).map(r=>({...r, hits:0}));
G.rules.forEach(compileRule);
_ascendToastShown=false;
closeModal();
toast('enl','✦ Ascended to '+plane().name+' ✦','+'+fmtInt(gained)+' Enlightenment. Mail volume ×'+(plane().rate)+', power ×'+plane().ppMult+'. Reach Zero again to rise higher.');
renderAll(); save();
}
/* ===========================================================================
BUY
=========================================================================== */
function upgradeMaxed(u){ return u.max && lvl(u.id)>=u.max; }
function upgradeReqMet(u){ return !u.reqUp || hasUp(u.reqUp); }
function upgradeCost(u){ if(u.kind==='unlock') return u.base; return Math.floor(u.base*Math.pow(u.growth, lvl(u.id))); }
function buyUpgrade(id){
const u = UP_BY_ID[id]; if(!u) return;
if(u.kind==='unlock' && hasUp(id)) return;
if(upgradeMaxed(u)||!upgradeReqMet(u)) return;
const cost = upgradeCost(u);
if(G.pp < cost){ toast('warn','Insufficient Productivity','That costs '+fmt(cost)+' PP.'); return; }
G.pp -= cost;
G.upgrades[id] = (G.upgrades[id]||0)+1;
if(id==='focusmax') G.focusMax=focusMaxV();
renderHud(); renderStatus();
if(dlgKind==='options') renderOptions();
if(dlgKind==='rules') renderRulesDialog();
}
function perkMaxed(p){ return p.max && plvl(p.id)>=p.max; }
function perkCost(p){ return Math.floor(p.base*Math.pow(p.growth, plvl(p.id))); }
function buyPerk(id){
const p = PERK_BY_ID[id]; if(!p) return;
if(perkMaxed(p)) return;
const cost = perkCost(p);
if(G.enlightenment < cost){ toast('warn','Not enough Enlightenment','That perk costs '+fmtInt(cost)+' Enlightenment.'); return; }
G.enlightenment -= cost;
G.perks[id] = (G.perks[id]||0)+1;
renderHud();
if(dlgKind==='ascend') renderAscend();
}
/* ===========================================================================
RULES UI helpers
=========================================================================== */
function addRule(field, op, pattern, action){
if(!pattern || !pattern.trim()){ toast('warn','Empty pattern','Enter a pattern to match.'); return false; }
if(G.rules.length >= ruleSlots()){
toast('warn','No free rule slots','You have '+ruleSlots()+' rule slot'+(ruleSlots()>1?'s':'')+'. Buy "Additional Rule Slots" in Options ▸ Rules.');
return false;
}
if(op==='matches' && !hasUp('regex')){
toast('warn','Regex locked','The "matches" operator needs the <b>Regex Pattern Matching</b> upgrade (Options ▸ Rules). Use "contains" for now.');
return false;
}
// focus cost to author a rule (rule edits cost Focus, per spec)
const fcost = 5;
if(G.focus < fcost){ toast('warn','Need Focus','Authoring a rule costs '+fcost+' Focus.'); return false; }
G.focus -= fcost;
const r = {field, op, pattern:pattern.trim(), action, enabled:true, hits:0};
compileRule(r);
if(r._bad){ G.focus+=fcost; toast('bad','Invalid regex','That pattern isn\'t a valid regular expression.'); return false; }
G.rules.push(r);
toast('','Rule created','IF '+field+' '+op+' "'+esc(pattern.trim().slice(0,24))+'" THEN '+action+'. It now auto-triages incoming mail.');
return true;
}
function deleteRule(idx){ G.rules.splice(idx,1); renderRulesDialog(); }
function toggleRule(idx){ const r=G.rules[idx]; if(r){ r.enabled=!r.enabled; renderRulesDialog(); } }
/* ===========================================================================
RENDER
=========================================================================== */
function renderAll(){ renderHud(); renderFolders(); renderList(); renderReading(); renderStatus(); }
function renderHud(){
document.getElementById('hud-pp').textContent = fmt(G.pp);
document.getElementById('hud-focus').textContent = Math.floor(G.focus)+'/'+focusMaxV();
const eb=document.getElementById('hud-enl-box');
if(G.enlightenment>0||G.ascensions>0){ eb.style.display='flex'; document.getElementById('hud-enl').textContent=fmtInt(G.enlightenment); }
else eb.style.display='none';
document.getElementById('tb-title').textContent =
(G.folder==='inbox'?'Inbox':FOLDERS.find(f=>f.id===G.folder).name)+' - Microsoft Outlook'+(G.ascensions?' ['+plane().name+']':'');
updateAscendBtn();
}
function updateAscendBtn(){
const b=document.getElementById('tb-ascend');
const ready=canAscend();
b.classList.toggle('on', ready);
b.title = ready? 'Inbox Zero reached — Ascend to '+(PLANES[Math.min(G.plane+1,PLANES.length-1)].name)+' for '+fmtInt(enlightenmentFor())+' Enlightenment'
: 'Ascend: reach Inbox Zero (0 messages) to bank Enlightenment and rise a plane.';
}
function renderFolders(){
const tree=document.getElementById('folder-tree');
let h='';
h+=`<div class="fold ${G.folder==='inbox'?'active':''}" data-folder="inbox">
<span class="f-ic">📥</span><span class="f-lb">Inbox</span>
<span class="f-ct">${G.inbox.filter(m=>m.unread).length||''}</span></div>`;
h+=`<div class="fold ${G.folder==='archive'?'active':''}" data-folder="archive">
<span class="f-ic">🗄️</span><span class="f-lb">Archive</span>
<span class="f-ct">${G.folderCounts.archive?fmtInt(G.folderCounts.archive):''}</span></div>`;
h+=`<div class="fold ${G.folder==='spam'?'active':''}" data-folder="spam">
<span class="f-ic">🗑️</span><span class="f-lb">Deleted Items</span>
<span class="f-ct">${G.folderCounts.spam?fmtInt(G.folderCounts.spam):''}</span></div>`;
h+=`<div class="fold ${G.folder==='sent'?'active':''}" data-folder="sent">
<span class="f-ic">📤</span><span class="f-lb">Sent Items</span>
<span class="f-ct">${G.folderCounts.sent?fmtInt(G.folderCounts.sent):''}</span></div>`;
h+=`<div class="fold-section">Filters &amp; Rules</div>`;
h+=`<div class="fold" data-act="manage-rules"><span class="f-ic">📋</span>
<span class="f-lb">Rules (${G.rules.length}/${ruleSlots()})</span>
<span class="f-ct">${G.rules.reduce((a,r)=>a+(r.hits||0),0)?fmtInt(G.rules.reduce((a,r)=>a+(r.hits||0),0)):''}</span></div>`;
h+=`<div class="fold-section">Plane</div>`;
h+=`<div class="fold" style="pointer-events:none"><span class="f-ic">${G.ascensions?'🧘':'🏢'}</span>
<span class="f-lb">${plane().name}</span></div>`;
tree.innerHTML=h;
document.getElementById('acct-name').textContent='Outlook Today - [sysadmin@'+plane().domain+']';
}
const ML_HEAD = ['!','📎','From','Subject','Received','Size'];
function renderListHead(){
const tr=document.getElementById('ml-head');
tr.innerHTML = ML_HEAD.map(h=>`<th>${h}</th>`).join('');
}
function relTime(t){
const s=(now()-t)/1000;
if(s<60) return Math.floor(s)+'s ago';
if(s<3600) return Math.floor(s/60)+'m ago';
if(s<86400) return Math.floor(s/3600)+'h ago';
return Math.floor(s/86400)+'d ago';
}
function mailSize(m){ return (m.att?fmt(rand(40,900)*1000)+'B':(Math.floor(rand(2,48))+' KB')); }
function renderList(){
const body=document.getElementById('ml-body');
const empty=document.getElementById('empty-list');
if(G.folder!=='inbox'){
// other folders are processed-mail counters, not live lists
body.innerHTML='';
empty.style.display='block';
const fc=FOLDERS.find(f=>f.id===G.folder);
const cnt=G.folderCounts[G.folder]||0;
empty.innerHTML=`<div class="big">${fc.ic} ${esc(fc.name)}</div>
<div>${fmtInt(cnt)} message${cnt!==1?'s':''} processed this plane.</div>
<div style="margin-top:8px;color:#888">Outlook compacts processed mail. Switch back to <b>Inbox</b> to keep triaging.</div>`;
return;
}
if(G.inbox.length===0){
body.innerHTML='';
empty.style.display='block';
empty.innerHTML=`<div class="big">✦ Inbox Zero ✦</div>
<div>No unread items. There are no messages to show in this view.</div>
<div style="margin-top:10px">${canAscend()
? 'You may now <b>Ascend</b> (Tools ▸ Ascend) for <b>'+fmtInt(enlightenmentFor())+'</b> Enlightenment.'
: 'Build Productivity to unlock Ascension. New mail is on its way…'}</div>`;
return;
}
empty.style.display='none';
// virtualization: render a window of rows around the selection, capped.
const n=G.inbox.length;
let win = HARD_RENDER_CAP;
let start=0, end=n;
if(n>win){
const si=Math.max(0,selIdx());
start=clamp(si-Math.floor(win/2), 0, Math.max(0,n-win));
end=start+win;
}
let rows='';
if(start>0) rows+=`<tr><td colspan="6" style="text-align:center;color:#999;background:#f4f4f4;font-style:italic">▲ ${fmtInt(start)} earlier message${start>1?'s':''} (scroll / triage to reach)</td></tr>`;
for(let i=start;i<end;i++){
const m=G.inbox[i];
const cls=(m.unread?'unread ':'')+(m.id===G.sel?'sel':'');
const bang=m.flagged?(m.type==='phishing'?'<span class="bang" title="suspicious">⚑</span>':(m.vip?'<span class="bang" title="VIP — reply!">!</span>':'<span class="bang">!</span>')):'';
const typeCls = m.type==='spam'?'type-spam':(m.type==='phishing'?'type-phish':(m.vip?'vip-tag':''));
rows+=`<tr class="${cls}" data-id="${m.id}">
<td class="m-flag">${bang}</td>
<td class="m-att">${m.att?'📎':''}</td>
<td class="m-from ${typeCls}">${esc(m.from)}</td>
<td class="m-sub ${typeCls}">${esc(m.subject)}</td>
<td class="m-num" style="color:#666">${relTime(m.t)}</td>
<td class="m-num" style="color:#666">${mailSize(m)}</td>
</tr>`;
}
if(end<n) rows+=`<tr><td colspan="6" style="text-align:center;color:#999;background:#f4f4f4;font-style:italic">▼ ${fmtInt(n-end)} more message${(n-end)>1?'s':''}</td></tr>`;
body.innerHTML=rows;
}
function renderReading(){
const head=document.getElementById('read-head');
const rbody=document.getElementById('read-body');
if(G.folder!=='inbox' || G.sel==null){
head.innerHTML='';
rbody.innerHTML=`<div id="read-empty">Select an item to read it.<br><br>
<b>j / k</b> move · <b>e</b> archive · <b>r</b> reply · <b>#</b> or <b>Del</b> delete</div>`;
return;
}
const m=G.inbox.find(x=>x.id===G.sel);
if(!m){ head.innerHTML=''; rbody.innerHTML='<div id="read-empty">Select an item to read it.</div>'; return; }
const vipTag = m.vip?' <span style="color:#8a5a00">[VIP — Reply for full value]</span>':'';
const phishTag = m.type==='phishing'?' <span style="color:#a50000">[⚑ Looks like phishing — Delete it]</span>':'';
head.innerHTML=`
<div class="rh-sub">${esc(m.subject)}${vipTag}${phishTag}</div>
<div class="rh-row"><span class="lbl">From:</span><span class="val">${esc(m.from)} &lt;${esc(m.addr)}&gt;</span></div>
<div class="rh-row"><span class="lbl">To:</span><span class="val">sysadmin@${esc(plane().domain)}</span></div>
<div class="rh-row"><span class="lbl">Value:</span><span class="val" style="font-family:var(--mono)">${m.value>0?'+'+fmt(m.value*manualMult())+' PP if archived':'~0 PP (clutter — delete it)'}</span></div>`;
let bodyHtml = esc(m.body).replace(/\n/g,'<br>');
if(m.att) bodyHtml += '<br><br>📎 <span style="font-family:var(--mono);font-size:11px">'+(m.type==='phishing'?'invoice_overdue.pdf.exe':'attachment_'+(m.id%97)+'.xlsx')+'</span>';
bodyHtml += `<div class="sig">— ${esc(m.from)}<br>${esc(m.addr)}</div>`;
rbody.innerHTML=bodyHtml;
}
function renderStatus(){
document.getElementById('sb-folder').textContent = G.folder==='inbox'?'Inbox':FOLDERS.find(f=>f.id===G.folder).name;
document.getElementById('sb-unread').innerHTML='Unread: <b>'+fmtInt(G.inbox.filter(m=>m.unread).length)+'</b>';
// rate
document.getElementById('sb-rate-v').textContent = fmt(arrivalRate()*60);
// status text
const st=document.getElementById('sb-status');
if(G.inbox.length>inboxCap()){ st.innerHTML='<span class="over-flag">⚠ OVERWHELMED — income halved until you dig out</span>'; }
else if(canAscend()){ st.innerHTML='<span style="color:#0a640a">✦ Inbox Zero — ready to Ascend (Tools ▸ Ascend)</span>'; }
else if(G.inbox.length>inboxCap()*0.8){ st.innerHTML='<span style="color:#9a6a00">Inbox filling up — build Rules to auto-triage</span>'; }
else st.textContent='Connected to mail.'+plane().domain+' · Send/Receive complete';
// focus gauge
const fpct=clamp(G.focus/focusMaxV(),0,1)*100;
document.getElementById('foc-fill').style.width=fpct+'%';
document.getElementById('foc-gt').textContent=Math.floor(G.focus);
// cap gauge
const cap=inboxCap(), cnt=G.inbox.length;
const fill=document.getElementById('cap-fill');
const pct=clamp(cnt/cap,0,1)*100;
fill.style.width=pct+'%';
fill.className='fill'+(cnt>cap?' over':(cnt>cap*0.8?' warn':''));
document.getElementById('cap-gt').textContent=fmtInt(cnt)+' / '+fmtInt(cap);
document.getElementById('body').classList.toggle('overwhelmed', cnt>cap);
}
/* ===========================================================================
DIALOGS
=========================================================================== */
let dlgKind=null;
let optTab='triage';
function openDialog(html){
dlgKind='generic';
document.getElementById('dialog').innerHTML=html;
document.getElementById('modal-back').classList.add('show');
}
function setDlg(kind, html, cls){
dlgKind=kind;
const d=document.getElementById('dialog');
d.className='dialog'+(cls?' '+cls:'');
d.innerHTML=html;
document.getElementById('modal-back').classList.add('show');
}
function closeModal(){
document.getElementById('modal-back').classList.remove('show');
dlgKind=null;
document.getElementById('dialog').className='dialog';
}
/* ---- OPTIONS (upgrades) ---- */
const OPT_TABS=[
{id:'triage', name:'Triage'},
{id:'focus', name:'Focus'},
{id:'rules', name:'Rules'},
];
function openOptions(tab){ if(tab) optTab=tab; renderOptions(); }
function renderOptions(){
const tabs=OPT_TABS.map(t=>`<div class="dtab ${optTab===t.id?'active':''}" data-opttab="${t.id}">${t.name}</div>`).join('');
const list=UPGRADES.filter(u=>u.tab===optTab).map(u=>upgradeRowHtml(u)).join('');
const note=({
triage:'Faster hands. These boost the Productivity you bank from manual triage and raise your Inbox capacity.',
focus:'Focus is spent on Replies and authoring Rules. It regenerates over time.',
rules:'Your automation. More slots, faster banking, regex power. A good rule-set empties the inbox for you.',
})[optTab];
setDlg('options', `
<div class="dlg-title">🔧 Options<span class="x" data-act="close-modal">✕</span></div>
<div class="dtabs">${tabs}</div>
<div class="dlg-body">
<div class="tab-note">${note} &nbsp;·&nbsp; Productivity: <b id="opt-pp" style="font-family:var(--mono);color:#0a640a">${fmt(G.pp)}</b></div>
<div class="opt-list">${list}</div>
</div>
<div class="dlg-foot"><button class="btn primary" data-act="close-modal">Close</button></div>`, 'wide');
}
function upgradeRowHtml(u){
const max=upgradeMaxed(u), req=upgradeReqMet(u), unlocked=(u.kind==='unlock'&&hasUp(u.id));
const cost=upgradeCost(u);
const can=G.pp>=cost && !max && req && !unlocked;
let eff='';
if(u.kind==='mult') eff='Current: ×'+Math.pow(u.mult,lvl(u.id)).toFixed(2)+' → ×'+Math.pow(u.mult,lvl(u.id)+1).toFixed(2);
else if(u.kind==='cap') eff='Cap: '+fmtInt(inboxCap())+' → '+fmtInt(inboxCap()+u.add);
else if(u.kind==='focusmax') eff='Max Focus: '+focusMaxV()+' → '+(focusMaxV()+u.add);
else if(u.kind==='focusregen') eff='Regen: +'+focusRegen().toFixed(1)+'/s → +'+(focusRegen()+u.add).toFixed(1)+'/s';
else if(u.kind==='replymult') eff='Reply PP: ×'+replyMult().toFixed(2)+' → ×'+(replyMult()*u.mult).toFixed(2);
else if(u.kind==='readmult') eff='Read bonus: ×'+readBonus().toFixed(2)+' → ×'+(readBonus()+(u.mult-1)).toFixed(2);
else if(u.kind==='ruleslots') eff='Rule slots: '+ruleSlots()+' → '+(ruleSlots()+u.add);
else if(u.kind==='rulespeed') eff='Rule PP: ×'+Math.pow(u.mult,lvl(u.id)).toFixed(2)+' → ×'+Math.pow(u.mult,lvl(u.id)+1).toFixed(2);
else if(u.kind==='autopp') eff='Idle PP: +'+(lvl(u.id)*u.mult*100).toFixed(0)+'% → +'+((lvl(u.id)+1)*u.mult*100).toFixed(0)+'% of rule income';
else if(u.kind==='unlock') eff=hasUp(u.id)?'✓ Enabled':'One-time unlock';
const lvlStr = u.kind==='unlock'? (hasUp(u.id)?'[ON]':'') : ('Lv '+lvl(u.id)+(u.max?(' / '+u.max):''));
let btnHtml;
if(unlocked) btnHtml=`<span class="opt-cost" style="color:#0a640a">Owned</span>`;
else if(max) btnHtml=`<span class="opt-cost">MAX</span>`;
else if(!req) btnHtml=`<span class="opt-cost no">needs ${esc(UP_BY_ID[u.reqUp].name)}</span>`;
else btnHtml=`<span class="opt-cost ${can?'ok':'no'}" id="optc-${u.id}">${fmt(cost)}</span>
<button class="btn ${can?'primary':''}" data-act="buy-up" data-id="${u.id}" ${can?'':'disabled'} id="optb-${u.id}">Buy</button>`;
return `<div class="opt-row ${max?'maxed':''} ${!req?'locked':''}" id="optrow-${u.id}">
<span class="o-ic">${u.ic}</span>
<div class="o-main">
<div class="o-name">${esc(u.name)}<span class="lvl">${lvlStr}</span></div>
<div class="o-desc">${u.desc}</div>
<div class="o-eff">${eff}</div>
</div>
<div class="o-buy">${btnHtml}</div>
</div>`;
}
// live affordability refresh while Options open
function refreshOptionsAfford(){
if(dlgKind!=='options') return;
const pp=document.getElementById('opt-pp'); if(pp) pp.textContent=fmt(G.pp);
UPGRADES.filter(u=>u.tab===optTab).forEach(u=>{
if(upgradeMaxed(u)||!upgradeReqMet(u)||(u.kind==='unlock'&&hasUp(u.id))) return;
const cost=upgradeCost(u), can=G.pp>=cost;
const c=document.getElementById('optc-'+u.id); if(c) c.className='opt-cost '+(can?'ok':'no');
const b=document.getElementById('optb-'+u.id); if(b){ b.toggleAttribute('disabled',!can); b.classList.toggle('primary',can); }
});
}
/* ---- RULES manager ---- */
function openRulesDialog(){ renderRulesDialog(); }
function renderRulesDialog(){
const slots=ruleSlots();
const regexOn=hasUp('regex');
const ruleRows = G.rules.length? G.rules.map((r,i)=>{
const actCls='act-'+r.action;
const opTxt = r.op==='matches'?'matches /':(r.op==='is'?'is':'contains');
const opEnd = r.op==='matches'?'/':'';
return `<div class="rule-row">
<input type="checkbox" class="r-toggle" data-rtoggle="${i}" ${r.enabled?'checked':''}>
<span class="r-num">${i+1}</span>
<span class="rule-cond">
<span class="kw">IF</span> ${r.field} <span class="kw">${opTxt}</span>
<span class="pat">${esc(r.pattern)}</span>${opEnd}
<span class="kw">THEN</span> <span class="${actCls}">${r.action.toUpperCase()}</span>
${r._bad?'<span style="color:#a50000"> [invalid]</span>':''}
${!r.enabled?'<span style="color:#999"> (off)</span>':''}
</span>
<span class="r-hits">${fmtInt(r.hits||0)} hits</span>
<div class="r-del" data-rdel="${i}">✕</div>
</div>`;
}).join('') : `<div class="rule-empty">No rules yet. Rules auto-triage incoming mail the instant it arrives.<br>
The flood will out-pace your hands — encode your judgment here.</div>`;
const opOptions = RULE_OPS.map(o=>{
const disabled = (o==='matches' && !regexOn);
return `<option value="${o}" ${disabled?'disabled':''}>${o}${disabled?' (locked)':''}</option>`;
}).join('');
setDlg('rules', `
<div class="dlg-title">📋 Rules and Alerts<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body">
<div class="rule-builder">
<div style="font-weight:bold;margin-bottom:6px">New Rule &nbsp;<span style="font-weight:normal;color:#555">(${G.rules.length}/${slots} slots used · Focus: ${Math.floor(G.focus)})</span></div>
<div class="rb-line">
<span class="kw">IF</span>
<select id="rb-field">${RULE_FIELDS.map(f=>`<option value="${f}">${f}</option>`).join('')}</select>
<select id="rb-op">${opOptions}</select>
<input class="pat" id="rb-pattern" placeholder="${regexOn?'pattern or /regex/':'text to look for'}" spellcheck="false">
</div>
<div class="rb-line">
<span class="kw">THEN</span>
<select id="rb-action">
<option value="archive">archive (bank value)</option>
<option value="delete">delete (clear clutter)</option>
<option value="reply">reply (×1.4 value)</option>
<option value="flag">flag (keep, highlight)</option>
</select>
<button class="btn primary" data-act="commit-rule">Add Rule (5 Focus)</button>
</div>
<div class="rb-hint">Tip: <b>address contains</b> <span class="pat">spam.</span> → delete, or <b>from contains</b> <span class="pat">CEO</span> → reply.
${regexOn?'Regex unlocked: try <span class="pat">^(prize|winner|won)</span>.':'Buy <b>Regex Pattern Matching</b> (Rules tab in Options) for the "matches" operator.'}</div>
</div>
<div class="rules-wrap">${ruleRows}</div>
</div>
<div class="dlg-foot">
<span class="spacer" style="font-size:11px;color:#555">Active rules run top-to-bottom; first match wins. Buy more slots & regex in Options ▸ Rules.</span>
<button class="btn" data-act="open-options-rules">Options…</button>
<button class="btn primary" data-act="close-modal">Close</button>
</div>`, 'wide');
}
/* ---- NEW RULE quick dialog (prefilled from selection) ---- */
function openNewRule(){
// prefill from current selection if any
const m = G.inbox.find(x=>x.id===G.sel);
renderRulesDialog();
if(m){
const fp=document.getElementById('rb-pattern');
const ff=document.getElementById('rb-field');
if(fp&&ff){
// suggest a domain-based rule
const dom = m.addr.split('@')[1]||m.addr;
ff.value='address'; fp.value=dom;
const act=document.getElementById('rb-action');
if(act) act.value = (m.type==='spam'||m.type==='phishing')?'delete':(m.vip?'reply':'archive');
fp.focus();
}
} else {
const fp=document.getElementById('rb-pattern'); if(fp) fp.focus();
}
}
function commitRuleFromBuilder(){
const field=document.getElementById('rb-field').value;
const op=document.getElementById('rb-op').value;
const pattern=document.getElementById('rb-pattern').value;
const action=document.getElementById('rb-action').value;
if(addRule(field,op,pattern,action)){ renderRulesDialog(); renderFolders(); renderStatus(); }
}
/* ---- ASCEND (prestige) ---- */
function openAscend(){ renderAscend(); }
function renderAscend(){
const gain=enlightenmentFor();
const ready=canAscend();
const nextPlane=PLANES[Math.min(G.plane+1,PLANES.length-1)];
const perkRows=PERKS.map(p=>{
const max=perkMaxed(p), cost=perkCost(p), can=G.enlightenment>=cost && !max;
let eff='';
if(p.id==='clarity') eff='Now ×'+perkMult('clarity').toFixed(2)+' → ×'+(perkMult('clarity')*p.mult).toFixed(2);
else if(p.id==='serenity') eff='Base cap +'+perkAdd('serenity')+' → +'+(perkAdd('serenity')+p.add);
else if(p.id==='flow') eff='Focus regen +'+perkAdd('flow').toFixed(1)+' → +'+(perkAdd('flow')+p.add).toFixed(1);
else if(p.id==='preset') eff='Carried rules: '+perkAdd('preset')+' → '+(perkAdd('preset')+p.add);
else if(p.id==='enlightened') eff='Rule PP ×'+perkMult('enlightened').toFixed(2)+' → ×'+(perkMult('enlightened')*p.mult).toFixed(2);
return `<div class="opt-row ${max?'maxed':''}">
<span class="o-ic">${p.ic}</span>
<div class="o-main"><div class="o-name">${esc(p.name)}<span class="lvl">Lv ${plvl(p.id)}/${p.max}</span></div>
<div class="o-desc">${p.desc}</div><div class="o-eff">${eff}</div></div>
<div class="o-buy">${max?'<span class="opt-cost">MAX</span>':
`<span class="opt-cost ${can?'ok':'no'}">${fmtInt(cost)}</span>
<button class="btn ${can?'primary':''}" data-act="buy-perk" data-id="${p.id}" ${can?'':'disabled'}>Buy</button>`}</div>
</div>`;
}).join('');
setDlg('ascend', `
<div class="dlg-title">🧘 Attain Inbox Zero — Ascension<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body">
<div class="dlg-pad">
<p>"The mystics say <b>Inbox Zero</b> is enlightenment. Every time you truly reach Zero, you may
<b>Ascend</b> a plane — the volume of mail multiplies, but so does your power."</p>
<div class="kv">
<span class="k">Current plane</span><span class="v">${esc(plane().name)} (${G.plane+1}/${PLANES.length})</span>
<span class="k">Inbox count</span><span class="v" style="color:${G.inbox.length===0?'#0a640a':'#a50000'}">${fmtInt(G.inbox.length)} ${G.inbox.length===0?'— ZERO ✦':'(must be 0)'}</span>
<span class="k">Peak throughput</span><span class="v">${fmt(G.peakThroughput)} PP/s</span>
<span class="k">Enlightenment banked</span><span class="v">${fmtInt(G.enlightenment)}</span>
</div>
<div class="note-box">
Ascending grants <span class="big-num" style="font-size:18px">${fmtInt(gain)}</span> Enlightenment,
then <b>resets</b> Productivity, Inbox and Rules (you keep <b>${perkAdd('preset')}</b> carried rule${perkAdd('preset')!==1?'s':''}),
and raises you to <b>${esc(nextPlane.name)}</b> (mail ×${nextPlane.rate}, power ×${nextPlane.ppMult}).
${G.plane>=PLANES.length-1?'<br><b>You are on The Void — the highest plane. Reaching Zero here is the secret ending.</b>':''}
</div>
${!ready?`<div class="warn-box">You can only Ascend from <b>genuine Inbox Zero</b> with at least 1 Enlightenment to gain. ${G.inbox.length>0?'Clear your inbox to 0 first.':'Build more peak throughput first.'}</div>`:''}
</div>
<div class="tab-note" style="border-top:1px solid #808080"><b>Permanent Serenity Perks</b> — spend Enlightenment. These persist across every Ascension.</div>
<div class="opt-list">${perkRows}</div>
</div>
<div class="dlg-foot">
<span class="spacer"></span>
<button class="btn" data-act="close-modal">Cancel</button>
<button class="btn primary" data-act="do-ascend" ${ready?'':'disabled'}>${G.plane>=PLANES.length-1?'Reach Zero in the Void':'Ascend to '+esc(nextPlane.name)}</button>
</div>`, 'wide');
}
/* ---- HELP / ABOUT ---- */
function openHelp(){
setDlg('help', `
<div class="dlg-title">❔ How To Reach Zero<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body"><div class="dlg-pad">
<p><b>You are the last sysadmin.</b> Everyone else quit. Mail floods in — a trickle, then a botnet, then the whole internet. Reach <b>Inbox Zero</b> to <b>Ascend</b> to a higher, faster plane.</p>
<p><b>Triage by keyboard (fast):</b></p>
<div class="kv" style="margin-bottom:10px">
<span class="k"><b>j / k</b> or ↓/↑</span><span class="v">move selection (marks read)</span>
<span class="k"><b>e</b></span><span class="v">Archive — bank the mail's Productivity</span>
<span class="k"><b>r</b></span><span class="v">Reply — ×1.5 value, costs Focus (VIPs want this)</span>
<span class="k"><b>#</b> or <b>Del</b></span><span class="v">Delete — for spam/phishing; tiny value</span>
</div>
<p><b>Build Rules (automation):</b> Manual triage cannot keep up with exponential mail. Open <b>Rules</b> and compose
<i>IF field contains/matches pattern THEN archive/delete/reply/flag</i>. Rules auto-triage incoming mail and bank Productivity for you — even while you're away.</p>
<p><b>The pressure:</b> watch <b>Inbox vs Cap</b> in the status bar. Overflow = <b>Overwhelmed</b> (income halved until you dig out). Raise the Cap, and let Rules do the digging.</p>
<p><b>Win condition:</b> at genuine <b>Inbox Zero</b>, Ascend for <b>Enlightenment</b> → buy permanent Serenity perks → push the volume higher. Plane order: Startup ▸ Enterprise ▸ Government ▸ The Whole Internet ▸ The Void.</p>
</div></div>
<div class="dlg-foot"><button class="btn primary" data-act="close-modal">OK</button></div>`);
}
function openAbout(){
setDlg('about', `
<div class="dlg-title">About Inbox Zero<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body"><div class="dlg-pad" style="text-align:center">
<div style="font-size:30px;margin:6px 0">@</div>
<p style="font-weight:bold;font-size:14px;margin-bottom:2px">Microsoft Outlook — Inbox Zero Edition</p>
<p style="color:#555">Boring Software · Volume V · "[ZERO]"</p>
<p style="font-size:11px;color:#777">An incremental game disguised as a mail client.<br>
Triage under pressure; encode your judgment into rules; ascend to infinite throughput.</p>
<div class="kv" style="justify-content:center;margin:10px auto;width:max-content;text-align:left">
<span class="k">Plane</span><span class="v">${esc(plane().name)}</span>
<span class="k">Ascensions</span><span class="v">${fmtInt(G.ascensions)}</span>
<span class="k">Enlightenment</span><span class="v">${fmtInt(G.enlightenment)}</span>
<span class="k">Rules running</span><span class="v">${fmtInt(G.rules.filter(r=>r.enabled).length)}</span>
<span class="k">Peak throughput</span><span class="v">${fmt(G.peakThroughput)} PP/s</span>
</div>
</div></div>
<div class="dlg-foot"><button class="btn primary" data-act="close-modal">OK</button></div>`, 'sm');
}
/* ---- Export / Import / Reset ---- */
function exportSave(){
let str='';
try{ str=btoa(unescape(encodeURIComponent(JSON.stringify(G)))); }catch(e){ str='(export failed)'; }
setDlg('io', `
<div class="dlg-title">Export Save<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body"><div class="dlg-pad">
<div class="field"><label>Copy this string to back up your account:</label>
<textarea id="io-text" readonly>${esc(str)}</textarea></div>
</div></div>
<div class="dlg-foot"><button class="btn" data-act="copy-save">Copy</button>
<button class="btn primary" data-act="close-modal">Close</button></div>`, 'sm');
}
function importSave(){
setDlg('io', `
<div class="dlg-title">Import Save<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body"><div class="dlg-pad">
<div class="warn-box">Importing overwrites your current account.</div>
<div class="field"><label>Paste a save string:</label>
<textarea id="io-text" spellcheck="false"></textarea></div>
</div></div>
<div class="dlg-foot"><button class="btn" data-act="close-modal">Cancel</button>
<button class="btn primary" data-act="do-import">Import</button></div>`, 'sm');
}
function doImport(){
const ta=document.getElementById('io-text'); if(!ta) return;
try{
const raw=decodeURIComponent(escape(atob(ta.value.trim())));
JSON.parse(raw);
localStorage.setItem(SAVE_KEY, raw);
location.reload();
}catch(e){ toast('bad','Import failed','That doesn\'t look like a valid save string.'); }
}
function hardReset(){
setDlg('confirm', `
<div class="dlg-title">⚠️ Empty Account &amp; Reset<span class="x" data-act="close-modal">✕</span></div>
<div class="dlg-body"><div class="dlg-pad">
<div class="warn-box">This permanently deletes <b>everything</b> — Productivity, Rules, every Ascension, all Enlightenment and perks. This cannot be undone.</div>
<p>Are you sure you want to declare bankruptcy on your entire mailbox?</p>
</div></div>
<div class="dlg-foot"><button class="btn" data-act="close-modal">Cancel</button>
<button class="btn primary" data-act="confirm-reset">Delete everything</button></div>`, 'sm');
}
/* ===========================================================================
TOASTS
=========================================================================== */
function toast(kind,title,body){
const wrap=document.getElementById('toasts');
const el=document.createElement('div');
el.className='toast'+(kind?(' '+kind):'');
el.innerHTML='<div class="t-bar">'+title+'</div>'+(body?('<div class="t-body">'+body+'</div>'):'');
wrap.appendChild(el);
setTimeout(()=>{el.style.transition='opacity .35s';el.style.opacity='0';setTimeout(()=>el.remove(),360);}, kind==='enl'?6500:3800);
while(wrap.children.length>5) wrap.firstChild.remove();
}
/* ===========================================================================
SAVE / LOAD
=========================================================================== */
let saveFailed=false;
function save(){
G.lastSave=now();
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(G)); return true; }
catch(e){
if(!saveFailed){ saveFailed=true;
toast('bad','Could not save','Browser storage is unavailable (private mode / quota). Progress will be lost on reload.'); }
return false;
}
}
function sanitizeState(){
const num=(v,d)=> (typeof v==='number'&&isFinite(v))?v:d;
const numpos=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.pp=numpos(G.pp,0); G.focus=numpos(G.focus,30); G.focusMax=numpos(G.focusMax,30);
G.enlightenment=numpos(G.enlightenment,0); G.peakThroughput=numpos(G.peakThroughput,0);
G.plane=Math.max(0,Math.min(PLANES.length-1,Math.floor(numpos(G.plane,0))));
G.ascensions=Math.floor(numpos(G.ascensions,0));
G.folder = FOLDERS.some(f=>f.id===G.folder)?G.folder:'inbox';
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={};
if(!G.perks||typeof G.perks!=='object'||Array.isArray(G.perks)) G.perks={};
// sanitize upgrade/perk levels to safe numbers
for(const k in G.upgrades){ if(!UP_BY_ID[k]) delete G.upgrades[k]; else G.upgrades[k]=Math.max(0,Math.floor(numpos(G.upgrades[k],0))); }
for(const k in G.perks){ if(!PERK_BY_ID[k]) delete G.perks[k]; else G.perks[k]=Math.max(0,Math.floor(numpos(G.perks[k],0))); }
if(!G.folderCounts||typeof G.folderCounts!=='object'||Array.isArray(G.folderCounts)) G.folderCounts={archive:0,spam:0,sent:0};
G.folderCounts.archive=Math.floor(numpos(G.folderCounts.archive,0));
G.folderCounts.spam=Math.floor(numpos(G.folderCounts.spam,0));
G.folderCounts.sent=Math.floor(numpos(G.folderCounts.sent,0));
if(!Array.isArray(G.inbox)) G.inbox=[];
let maxId=0;
G.inbox=G.inbox.filter(m=>m&&typeof m==='object').map(m=>{
const id=Math.floor(numpos(m.id,0))||(++_mid);
if(id>maxId)maxId=id;
return {
id, type: MAIL_TYPES[m.type]?m.type:'work',
from:String(m.from||'Unknown'), addr:String(m.addr||'unknown@unknown'),
subject:String(m.subject||'(no subject)'),
value:numpos(m.value,0), unread:m.unread!==false, flagged:!!m.flagged, vip:!!m.vip,
att:!!m.att, t:num(m.t,now()), body:String(m.body||''),
};
});
_mid=Math.max(_mid, maxId+1);
if(!Array.isArray(G.rules)) G.rules=[];
G.rules=G.rules.filter(r=>r&&typeof r==='object'&&RULE_FIELDS.includes(r.field)&&RULE_OPS.includes(r.op)&&RULE_ACTIONS.includes(r.action)&&typeof r.pattern==='string')
.map(r=>({field:r.field,op:r.op,pattern:r.pattern,action:r.action,enabled:r.enabled!==false,hits:Math.floor(numpos(r.hits,0))}));
G.rules.forEach(compileRule);
if(G.sel!=null && !G.inbox.some(m=>m.id===G.sel)) G.sel = G.inbox.length?G.inbox[0].id:null;
G.sci=!!G.sci;
G.spawnAccum=numpos(G.spawnAccum,0);
G.overwhelmedUntil=num(G.overwhelmedUntil,0);
G.rateEMA=numpos(G.rateEMA,0);
if(!G.seen||typeof G.seen!=='object') G.seen={};
if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=now();
if(G.lastSave>now()) G.lastSave=now();
}
function load(){
let raw=null;
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
if(!raw){ G=freshState(); return false; }
try{
const s=JSON.parse(raw);
if(!s||typeof s!=='object'||Array.isArray(s)) throw new Error('bad shape');
G=Object.assign(freshState(), s);
sanitizeState();
return true;
}catch(e){ G=freshState(); return false; }
}
/* ===========================================================================
EVENT WIRING
=========================================================================== */
function handleAct(act,el){
switch(act){
case 'archive': archiveSel(); break;
case 'delete': deleteSel(); break;
case 'reply': replySel(); break;
case 'archive-all-read': archiveAllRead(); break;
case 'new-rule': openNewRule(); break;
case 'manage-rules': openRulesDialog(); break;
case 'open-options': openOptions(); break;
case 'open-options-rules': openOptions('rules'); break;
case 'open-ascend': openAscend(); break;
case 'commit-rule': commitRuleFromBuilder(); break;
case 'buy-up': buyUpgrade(el.dataset.id); break;
case 'buy-perk': buyPerk(el.dataset.id); break;
case 'do-ascend': doAscend(); break;
case 'open-help': openHelp(); break;
case 'open-about': openAbout(); break;
case 'close-modal': closeModal(); break;
case 'toggle-sci': G.sci=!G.sci; renderAll(); toast('','Number format',G.sci?'Scientific notation':'SI suffix notation'); break;
case 'save-now': if(save()) toast('','Saved','Session written to local storage.'); break;
case 'export-save': exportSave(); break;
case 'import-save': importSave(); break;
case 'copy-save': { const ta=document.getElementById('io-text'); if(ta){ta.select(); try{document.execCommand('copy');}catch(e){} toast('','Copied','Save string copied to clipboard.'); } break; }
case 'do-import': doImport(); break;
case 'hard-reset': hardReset(); break;
case 'confirm-reset': try{localStorage.removeItem(SAVE_KEY);}catch(e){} location.reload(); break;
}
}
document.addEventListener('click',e=>{
hideCtx();
const actEl=e.target.closest('[data-act]');
const folderEl=e.target.closest('[data-folder]');
const optTabEl=e.target.closest('[data-opttab]');
const rdelEl=e.target.closest('[data-rdel]');
const rtoggleEl=e.target.closest('[data-rtoggle]');
const rowEl=e.target.closest('tr[data-id]');
if(actEl){ handleAct(actEl.dataset.act, actEl); return; }
if(folderEl && folderEl.dataset.folder){ G.folder=folderEl.dataset.folder; G.sel = G.folder==='inbox'&&G.inbox.length?G.sel:G.sel; renderFolders(); renderList(); renderReading(); renderStatus(); renderHud(); return; }
if(optTabEl){ optTab=optTabEl.dataset.opttab; renderOptions(); return; }
if(rdelEl){ deleteRule(parseInt(rdelEl.dataset.rdel)); renderFolders(); renderStatus(); return; }
if(rtoggleEl){ toggleRule(parseInt(rtoggleEl.dataset.rtoggle)); renderStatus(); return; }
if(rowEl){ selectMail(parseInt(rowEl.dataset.id)); return; }
});
document.addEventListener('dblclick',e=>{
const rowEl=e.target.closest('tr[data-id]');
if(rowEl){ selectMail(parseInt(rowEl.dataset.id)); archiveSel(); }
});
/* context menu on message rows */
function showCtx(x,y,id){
const m=G.inbox.find(mm=>mm.id===id); if(!m) return;
const ctx=document.getElementById('ctx');
ctx.innerHTML=`
<div data-ctx="archive" data-id="${id}">Archive<span class="kbd">E</span></div>
<div data-ctx="reply" data-id="${id}">Reply<span class="kbd">R</span></div>
<div data-ctx="delete" data-id="${id}">Delete<span class="kbd">Del</span></div>
<div class="sep"></div>
<div data-ctx="rule-from" data-id="${id}">Create Rule from Sender…</div>`;
ctx.style.left=Math.min(x,window.innerWidth-190)+'px';
ctx.style.top=Math.min(y,window.innerHeight-140)+'px';
ctx.style.display='block';
}
function hideCtx(){ document.getElementById('ctx').style.display='none'; }
document.addEventListener('contextmenu',e=>{
const row=e.target.closest('tr[data-id]');
if(row){ e.preventDefault(); const id=parseInt(row.dataset.id); selectMail(id); showCtx(e.clientX,e.clientY,id); }
});
document.addEventListener('click',e=>{
const ctxEl=e.target.closest('[data-ctx]');
if(ctxEl){
const id=parseInt(ctxEl.dataset.id); const ctx=ctxEl.dataset.ctx;
selectMail(id);
if(ctx==='archive') archiveSel();
else if(ctx==='reply') replySel();
else if(ctx==='delete') deleteSel();
else if(ctx==='rule-from') openNewRule();
hideCtx();
}
});
/* keyboard — the HOOK's tactile layer */
document.addEventListener('keydown',e=>{
if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT'||e.target.tagName==='SELECT'){
if(e.key==='Enter' && e.target.id==='rb-pattern'){ e.preventDefault(); commitRuleFromBuilder(); }
return;
}
const modalOpen=document.getElementById('modal-back').classList.contains('show');
if(e.key==='Escape'){ closeModal(); hideCtx(); return; }
if(modalOpen) return;
if((e.ctrlKey||e.metaKey) && (e.key==='s'||e.key==='S')){ e.preventDefault(); if(save()) toast('','Saved','Session saved.'); return; }
if(e.ctrlKey||e.metaKey||e.altKey) return;
if(G.folder!=='inbox' && (e.key==='j'||e.key==='k'||e.key==='e'||e.key==='r'||e.key==='#'||e.key==='Delete')){
G.folder='inbox'; renderFolders(); renderList(); renderReading(); renderStatus();
}
switch(e.key){
case 'j': case 'ArrowDown': e.preventDefault(); moveSel(1); break;
case 'k': case 'ArrowUp': e.preventDefault(); moveSel(-1); break;
case 'e': e.preventDefault(); archiveSel(); break;
case 'r': e.preventDefault(); replySel(); break;
case '#': e.preventDefault(); deleteSel(); break;
case 'Delete': case 'Backspace': e.preventDefault(); deleteSel(); break;
}
});
document.getElementById('modal-back').addEventListener('mousedown',e=>{ if(e.target.id==='modal-back') closeModal(); });
/* splitters */
(function(){
const vs=document.getElementById('vsplit'), hs=document.getElementById('hsplit');
const folders=document.getElementById('folders'), reading=document.getElementById('reading');
let drag=null;
vs.addEventListener('mousedown',e=>{drag={type:'v',x:e.clientX,w:folders.offsetWidth};e.preventDefault();});
hs.addEventListener('mousedown',e=>{drag={type:'h',y:e.clientY,h:reading.offsetHeight};e.preventDefault();});
window.addEventListener('mousemove',e=>{
if(!drag) return;
if(drag.type==='v'){ let w=clamp(drag.w+(e.clientX-drag.x),130,320); folders.style.width=w+'px'; G.sidebarW=w; }
else { let h=clamp(drag.h-(e.clientY-drag.y),100,window.innerHeight-260); reading.style.height=h+'px'; G.readingH=h; }
});
window.addEventListener('mouseup',()=>{drag=null;});
})();
/* ===========================================================================
MAIN LOOP
=========================================================================== */
let lastTick=now(), saveAccum=0, renderAccum=0;
function tick(){
const t=now();
let dt=(t-lastTick)/1000; lastTick=t;
if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps
try{ step(dt); }catch(err){ /* never let the tick die */ console&&console.warn&&console.warn(err); }
// render: HUD/status every tick; list less often (it's heavier)
renderHud(); renderStatus();
renderAccum+=dt;
if(renderAccum>=0.35){ renderAccum=0; renderList(); if(G.sel!=null) renderReading(); renderFolders(); }
// live dialog numbers
if(dlgKind==='options') refreshOptionsAfford();
if(dlgKind==='rules'){ /* rules dialog: refresh focus readout passively via reopen-free; cheap skip */ }
// autosave
saveAccum+=dt;
if(saveAccum>=10){ saveAccum=0; save(); }
}
/* ===========================================================================
BOOT
=========================================================================== */
function boot(){
load();
renderListHead();
applyOffline();
if(G.inbox.length===0 && G.sel==null){
// seed a first wave so a brand-new player has something to triage
if(G.ascensions===0 && (G.peakThroughput||0)===0 && Object.keys(G.upgrades).length===0){
spawnMail(5);
}
}
renderAll();
setInterval(tick, TICK_MS);
window.addEventListener('beforeunload', save);
// backgrounded-tab catch-up
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState!=='visible') return;
const gap=(now()-lastTick)/1000;
if(gap>10){ simulateAway(Math.min(MAX_OFFLINE_S,gap)); save(); renderAll(); }
lastTick=now();
});
if(!G.seen.welcome && G.ascensions===0){
G.seen.welcome=true;
setTimeout(()=>toast('info','You are the last sysadmin','Mail is flooding in. Triage with <b>j/k</b> to move, <b>e</b> archive, <b>r</b> reply, <b>#</b> delete. Then build <b>Rules</b> to automate. Reach <b>Inbox Zero</b> to Ascend.'),700);
}
}
boot();
</script>
</body>
</html>