<html lang="en">
<head>
<meta charset="UTF-8"></meta>
<meta content="width=device-width, initial-scale=1.0" name="viewport"></meta>
<title>Inkline — Smart Monthly Calendar</title>
<script>
/* apply saved theme before first paint to avoid a flash */
try{var _t=localStorage.getItem('inkline.theme');if(_t){document.documentElement.dataset.theme=_t;
document.documentElement.dataset.mode=({noir:'dark',forest:'dark',slate:'dark'})[_t]||'light';}}catch(e){}
</script>
<link href="https://fonts.googleapis.com" rel="preconnect"></link>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"></link>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400;1,9..144,600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet"></link>
<style>
/* ---------- themes (carousel: paper → snow → mist → sage → butter → kraft → noir → forest → slate) ---------- */
:root, :root[data-theme="paper"]{--paper:#F5F0E4;--ink:#25211A;--accent:#B94A1F;--on-accent:#FBF7EE;color-scheme:light}
:root[data-theme="snow"] {--paper:#F2F3F0;--ink:#22282A;--accent:#0E7268;--on-accent:#F2F7F5;color-scheme:light}
:root[data-theme="mist"] {--paper:#E4EBF0;--ink:#1E2E38;--accent:#2F7492;--on-accent:#EEF5F7;color-scheme:light}
:root[data-theme="sage"] {--paper:#E7EDDF;--ink:#253229;--accent:#4E7A46;--on-accent:#EFF4E9;color-scheme:light}
:root[data-theme="butter"]{--paper:#F4ECC8;--ink:#39321A;--accent:#A87B12;--on-accent:#FAF5E0;color-scheme:light}
:root[data-theme="kraft"] {--paper:#E9DFC8;--ink:#3A2D1C;--accent:#8A4B24;--on-accent:#F6EFDE;color-scheme:light}
:root[data-theme="noir"] {--paper:#1A1813;--ink:#ECE5D3;--accent:#E0902F;--on-accent:#211B12;color-scheme:dark}
:root[data-theme="forest"]{--paper:#141F16;--ink:#DDE7D0;--accent:#D2A63E;--on-accent:#1B2115;color-scheme:dark}
:root[data-theme="slate"] {--paper:#1B2024;--ink:#DEE4E6;--accent:#D97E4A;--on-accent:#241A12;color-scheme:dark}
:root{
--ink2:color-mix(in srgb,var(--ink) 60%,var(--paper));
--ink3:color-mix(in srgb,var(--ink) 34%,var(--paper));
--hair:color-mix(in srgb,var(--ink) 16%,var(--paper));
--wash:color-mix(in srgb,var(--ink) 4%,var(--paper));
--wash2:color-mix(in srgb,var(--ink) 7%,var(--paper));
--wash3:color-mix(in srgb,var(--ink) 13%,var(--paper));
--shadow:color-mix(in srgb,var(--ink) 22%,transparent);
--f-disp:'Fraunces',Georgia,serif;
--f-ui:'Space Grotesk','Helvetica Neue',sans-serif;
--cellh:clamp(108px,13.5vh,158px);
}
/* event colors: c1–c6 = basic, c7–c16 = soft */
:root[data-mode="light"]{--grain-o:.05;--grain-b:multiply;
--c1:#B23B3B;--c2:#C05F2C;--c3:#A98514;--c4:#4E7A46;--c5:#3F6079;--c6:#6E5480;
--c7:#B2496C;--c8:#C46A55;--c9:#7E9425;--c10:#2E6B45;--c11:#2E7A72;--c12:#6C93AC;
--c13:#2B3F63;--c14:#96627E;--c15:#8A6440;--c16:#7C7668}
:root[data-mode="dark"]{--grain-o:.07;--grain-b:overlay;
--c1:#E0645F;--c2:#E08543;--c3:#D9AE4A;--c4:#7FB069;--c5:#6FA3C7;--c6:#A98BC4;
--c7:#DE7FA4;--c8:#E8907A;--c9:#ACBE52;--c10:#5FA37B;--c11:#4FB3A9;--c12:#8FB4CC;
--c13:#7D96C4;--c14:#C48DB0;--c15:#C09A6E;--c16:#948E85}
html.theming *, html.theming *::before, html.theming *::after{
transition:background-color .35s ease,color .35s ease,border-color .35s ease,box-shadow .35s ease,fill .35s ease,stroke .35s ease,opacity .35s ease !important;
}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--f-ui);-webkit-font-smoothing:antialiased}
button{font:inherit;cursor:pointer}
label{cursor:pointer}
input{font:inherit}
input[type=radio]{accent-color:var(--accent)}
::selection{background:var(--accent);color:var(--on-accent)}
body::after{content:'';position:fixed;inset:0;z-index:80;pointer-events:none;mix-blend-mode:var(--grain-b);opacity:var(--grain-o);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");}
.wrap{max-width:1200px;margin:0 auto;padding:0 clamp(16px,3.5vw,40px) 140px}
/* month masthead */
.mast{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 0 22px;padding-top:24px;
border-top:2px solid var(--ink);flex-wrap:wrap}
.mtitle{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
.mlabel{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink3);margin-bottom:8px}
.mname{margin:0;font-family:var(--f-disp);font-style:italic;font-weight:600;font-size:clamp(44px,7.5vw,88px);line-height:.92;letter-spacing:-.025em}
.msub{display:flex;gap:10px;align-items:baseline;font-size:12.5px;color:var(--ink2);letter-spacing:.08em;text-transform:uppercase;padding-bottom:12px;white-space:nowrap}
.msub #myear{color:var(--ink);font-weight:500}
.msub .sep{color:var(--ink3)}
.mnav{display:flex;flex-direction:column;align-items:flex-end;gap:8px;padding-bottom:10px}
.navrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.tbtn{height:38px;padding:0 18px;border:1px solid var(--hair);border-radius:19px;background:none;font-weight:500;font-size:13px;color:var(--ink);transition:all .18s;display:inline-flex;align-items:center;gap:8px}
.tbtn:hover{border-color:var(--ink);background:var(--ink);color:var(--paper)}
.nbtn{width:38px;height:38px;border:1px solid var(--hair);border-radius:50%;background:none;color:var(--ink);display:grid;place-items:center;transition:all .18s;flex:none}
.nbtn:hover{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.nbtn svg{width:15px;height:15px}
#btnGear svg{width:16px;height:16px}
.khint{font-size:10.5px;color:var(--ink3);letter-spacing:.08em}
.tbtn:focus-visible,.nbtn:focus-visible,.addb:focus-visible,.cbt:focus-visible,.qc:focus-visible,.cw:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
.sw{width:15px;height:15px;border-radius:50%;flex:none;border:1px solid var(--hair);
background:conic-gradient(var(--accent) 0 25%,var(--ink) 25% 55%,var(--paper) 55% 100%)}
.sw.spin{animation:swspin .5s cubic-bezier(.3,.7,.3,1)}
@keyframes swspin{from{transform:rotate(-200deg) scale(.55)}to{transform:none}}
/* board — full width */
.board{border:1px solid var(--hair)}
.cal{min-width:0}
.week{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--hair)}
.week span{padding:11px 12px 9px;font-size:10.5px;font-weight:500;letter-spacing:.18em;text-transform:uppercase;color:var(--ink2);border-right:1px solid var(--hair)}
.week span:nth-child(7n){border-right:0}
.days{display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:var(--cellh)}
.cell{position:relative;display:flex;flex-direction:column;gap:5px;padding:7px 8px;text-align:left;min-width:0;overflow:hidden;
background:none;border:0;border-right:1px solid var(--hair);border-bottom:1px solid var(--hair);color:inherit;transition:background .15s}
.cell:nth-child(7n){border-right:0}
.cell:nth-last-child(-n+7){border-bottom:0}
.cell:hover{background:var(--wash)}
.cell:focus-visible{outline:2px solid var(--ink);outline-offset:-2px}
.cell.sel{box-shadow:inset 0 0 0 1.5px var(--ink);background:var(--wash)}
.num{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;font-size:12.5px;font-weight:500;color:var(--ink2);flex:none}
.cell.today .num{background:var(--accent);color:var(--on-accent);font-weight:600}
.cell.out .num{color:var(--ink3)}
.cell.out .chip{opacity:.5}
.chips{display:flex;flex-direction:column;gap:3px;min-width:0;max-width:100%;overflow:hidden}
.chip{display:inline-flex;align-items:center;gap:5px;max-width:100%;min-width:0;padding:2px 6px 2px 5px;border-radius:4px;
background:var(--wash2);font-size:11px;line-height:1.35;white-space:nowrap;color:var(--ink)}
.chip:hover{background:var(--wash3)}
.chip i{width:6px;height:6px;border-radius:50%;flex:none}
.chip b{font-weight:500;color:var(--ink2);font-variant-numeric:tabular-nums;letter-spacing:.02em;flex:none}
.chip .t{min-width:0;overflow:hidden;text-overflow:ellipsis}
.more{font-size:10.5px;color:var(--ink2);padding:1px 6px}
.days.anim .cell{animation:cin .32s cubic-bezier(.22,.7,.32,1) both;animation-delay:calc(var(--i)*7ms)}
@keyframes cin{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}
/* day popover */
.dpop{position:fixed;z-index:65;width:300px;max-width:calc(100vw - 16px);top:0;left:0;display:none;
background:var(--paper);border:1.5px solid var(--ink);border-radius:12px;box-shadow:0 14px 40px var(--shadow)}
.dpop.on{display:block;animation:dppop .18s cubic-bezier(.22,.9,.3,1.1)}
@keyframes dppop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}}
.dphead{display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:12px 16px 9px;
border-bottom:1.5px solid var(--ink);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink2)}
.dphead b{color:var(--ink);font-weight:600}
.dplist{list-style:none;margin:0;padding:2px 0;max-height:min(46vh,340px);overflow-y:auto}
.dplist::-webkit-scrollbar{width:8px}
.dplist::-webkit-scrollbar-thumb{background:var(--hair);border-radius:4px}
.ev{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:0 10px;align-items:flex-start;padding:11px 14px 10px}
.ev+.ev{border-top:1px solid var(--hair)}
.ev .dot{width:7px;height:7px;border-radius:50%;margin-top:6px}
.ev .meta{min-width:0}
.ev .when{font-size:11px;color:var(--ink2);letter-spacing:.04em;font-variant-numeric:tabular-nums;margin-bottom:3px}
.ev .what{font-size:14.5px;line-height:1.35;overflow-wrap:anywhere}
.ev .tools{display:flex;gap:2px;opacity:0;transition:opacity .15s}
.ev:hover .tools,.ev:focus-within .tools{opacity:1}
.tools button{width:27px;height:27px;border:0;background:none;color:var(--ink2);border-radius:6px;display:grid;place-items:center}
.tools button:hover{color:var(--ink);background:var(--wash3)}
.tools svg{width:14px;height:14px}
.empty{list-style:none;padding:20px 16px;font-family:var(--f-disp);font-style:italic;font-size:16px;line-height:1.6;color:var(--ink2)}
.empty span{display:block;margin-top:6px;font-family:var(--f-ui);font-style:normal;font-weight:400;font-size:12px;line-height:1.4;color:var(--ink3)}
/* full palette inside popover: basic + soft rows */
.cpal{grid-column:1/-1;display:flex;flex-direction:column;gap:7px;padding:6px 0 4px;min-width:0}
.crow{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-width:0}
.cw{width:22px;height:22px;border-radius:50%;border:1px solid color-mix(in srgb,var(--ink) 30%,var(--paper));padding:0;transition:transform .15s;flex:none}
.crow.soft .cw{width:16px;height:16px}
.cw:hover{transform:scale(1.22)}
.cw.cur{box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--ink)}
.cw.auto{width:auto;height:22px;padding:0 11px;border:1px dashed var(--ink2);border-radius:12px;background:none;color:var(--ink2);
font-size:9.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase}
.cw.auto:hover{transform:none;color:var(--ink);border-color:var(--ink)}
.clab{font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink3);margin-left:2px}
/* composer footer: [day info] [input] [5 colors] [icon buttons] */
.composer{position:fixed;left:0;right:0;bottom:0;z-index:40;background:var(--paper);border-top:1px solid var(--ink);
padding:14px clamp(16px,3.5vw,40px) calc(14px + env(safe-area-inset-bottom))}
.cinner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:14px;position:relative}
.dinfo{flex:none;display:inline-flex;align-items:center;gap:7px;min-width:0;max-width:36%;cursor:default;
font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--ink2);white-space:nowrap}
.dinfo .tdot{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
.dinfo .dtxt{min-width:0;overflow:hidden;text-overflow:ellipsis}
.dinfo .dy{color:var(--ink3)}
.dinfo .dc{color:var(--ink3);flex:none}
#q{flex:1;min-width:60px;background:none;border:0;border-bottom:1px solid var(--hair);padding:9px 2px;font-size:15px;color:var(--ink);outline:none;caret-color:var(--accent);transition:border-color .2s}
#q:focus{border-bottom-color:var(--ink)}
#q::placeholder{color:var(--ink3)}
.qcolors{flex:none;display:flex;align-items:center;gap:7px}
.qc{width:18px;height:18px;border-radius:50%;border:1px solid color-mix(in srgb,var(--ink) 26%,var(--paper));padding:0;transition:transform .15s;flex:none}
.qc:hover{transform:scale(1.2)}
.qc.cur{box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--ink)}
.composer.editing .dinfo .tdot{background:var(--accent)}
/* icon-only footer buttons: label lives in the tooltip */
.addb{width:42px;height:42px;padding:0;flex:none;border:0;border-radius:50%;background:var(--ink);color:var(--paper);
display:grid;place-items:center;transition:background .18s,transform .12s}
.addb svg{width:17px;height:17px}
.addb:hover{background:var(--accent);color:var(--on-accent)}
.addb:active{transform:scale(.94)}
.cbt{width:42px;height:42px;padding:0;flex:none;display:grid;place-items:center;background:none;border:1px solid var(--hair);
border-radius:50%;color:var(--ink2);transition:all .18s}
.cbt:hover{color:var(--accent);border-color:var(--accent)}
.cbt svg{width:15px;height:15px}
/* live parse chip */
.pv{position:absolute;bottom:calc(100% + 12px);right:0;display:flex;align-items:center;gap:8px;white-space:nowrap;
background:var(--paper);border:1px solid var(--ink);border-radius:8px;padding:9px 14px;font-size:12.5px;color:var(--ink2);
box-shadow:0 8px 24px var(--shadow);transition:opacity .16s,transform .16s}
.pv.off{opacity:0;transform:translateY(5px);pointer-events:none}
.pv svg{width:13px;height:13px}
.pv em{font-style:normal;color:var(--ink);border-bottom:1px solid var(--accent);padding-bottom:1px}
.pv em.mut{color:var(--ink2);border:0}
.pv .sep{color:var(--ink3)}
.pv .tt{font-variant-numeric:tabular-nums}
.pv .fb{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)}
/* toast */
.toast{position:fixed;left:50%;bottom:96px;transform:translate(-50%,14px);opacity:0;pointer-events:none;z-index:90;
transition:opacity .22s,transform .22s;background:var(--ink);color:var(--paper);padding:11px 12px 11px 18px;border-radius:8px;
display:flex;align-items:center;gap:16px;font-size:13px;box-shadow:0 10px 30px var(--shadow);max-width:min(92vw,560px)}
.toast.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
.toast button{background:none;border:0;color:color-mix(in srgb,var(--accent) 70%,var(--paper));font-weight:600;font-size:11px;line-height:1;letter-spacing:.12em;text-transform:uppercase;padding:6px 8px;border-radius:5px}
.toast button:hover{background:color-mix(in srgb,var(--paper) 14%,transparent)}
/* settings modal */
.scrim{position:fixed;inset:0;z-index:70;display:none;align-items:center;justify-content:center;padding:20px;
background:color-mix(in srgb,var(--ink) 45%,transparent)}
.scrim.on{display:flex;animation:sfade .2s ease}
@keyframes sfade{from{opacity:0}to{opacity:1}}
.modal{width:min(660px,94vw);max-height:86vh;overflow:auto;background:var(--paper);border:2px solid var(--ink);border-radius:14px;
box-shadow:0 24px 70px var(--shadow);animation:mpop .28s cubic-bezier(.22,.9,.3,1.1)}
@keyframes mpop{from{opacity:0;transform:translateY(14px) scale(.97)}to{opacity:1;transform:none}}
.mhead{display:flex;justify-content:space-between;align-items:center;padding:20px 24px 14px;border-bottom:2px solid var(--ink);position:sticky;top:0;background:var(--paper)}
.mhead h2{margin:0;font-family:var(--f-disp);font-style:italic;font-weight:600;font-size:26px;letter-spacing:-.01em}
.mbody{padding:6px 24px 24px}
.msec{padding:18px 0 6px;border-top:1px solid var(--hair)}
.msec:first-child{border-top:0}
.msec h3{margin:0 0 5px;font-family:var(--f-disp);font-style:italic;font-weight:600;font-size:17px}
.mnote{margin:0 0 12px;font-size:12.5px;line-height:1.5;color:var(--ink2)}
.mrow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px}
#ioArea{width:100%;min-height:150px;resize:vertical;background:var(--wash);border:1px solid var(--hair);border-radius:8px;
padding:10px 12px;color:var(--ink);font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;outline:none}
#ioArea:focus{border-color:var(--ink)}
.radios{gap:18px}
.radios label{display:inline-flex;gap:7px;align-items:center;font-size:13px;color:var(--ink)}
.tbtn.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.tbtn.primary:hover{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
.tbtn.danger{color:var(--c1)}
.tbtn.danger:hover{background:var(--c1);border-color:var(--c1);color:var(--paper)}
.tbtn.danger.armed{background:var(--c1);border-color:var(--c1);color:var(--paper)}
.mstat{margin:10px 0 0;font-size:12.5px;min-height:1em}
.mstat.ok{color:var(--c4)}
.mstat.err{color:var(--c1)}
.fnote{font-size:12px;color:var(--ink3)}
.thgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.thb{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:9px 11px;text-align:left;
background:none;border:1px solid var(--hair);border-radius:10px;color:var(--ink);transition:all .15s}
.thb:hover{border-color:var(--ink)}
.thb.cur{box-shadow:inset 0 0 0 1.5px var(--ink);border-color:transparent}
.thchip{width:44px;height:32px;border-radius:6px;border:1px solid rgba(128,128,128,.35);display:flex;flex-direction:column;gap:3px;padding:6px;flex:none}
.thchip i{display:block;height:5px;width:62%;border-radius:2px}
.thchip b{display:block;width:10px;height:10px;border-radius:50%}
.thnm{font-weight:500;font-size:13.5px}
.thmd{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3)}
@media (hover:none){.ev .tools{opacity:1}}
@media (max-width:640px){
.khint{display:none}
.mnav{flex-direction:row;align-items:center;padding-bottom:0}
.chips{flex-direction:row;flex-wrap:wrap;gap:3px}
.chip{padding:2px 3px}
.chip b,.chip .t{display:none}
.more{padding:0}
.cinner{gap:10px}
.dinfo{max-width:30%}
.dinfo .dy{display:none}
.qc{width:16px;height:16px}
.mhead,.mbody{padding-left:16px;padding-right:16px}
}
@media (max-width:480px){
.dinfo .dw,.dinfo .dc{display:none}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header class="mast">
<div class="mtitle">
<div>
<div class="mlabel" id="mlabel"></div>
<h1 class="mname" id="mname"></h1>
</div>
<div class="msub"><span id="myear"></span><span class="sep">·</span><span id="mcount"></span></div>
</div>
<nav class="mnav">
<div class="navrow">
<button class="tbtn" id="btnToday" type="button">Today</button>
<button aria-label="Cycle color theme" class="tbtn" id="btnTheme" title="Cycle theme (C)" type="button">
<span aria-hidden="true" class="sw" id="swDot"></span><span id="thName">Paper</span>
</button>
<button aria-label="Open settings" class="nbtn" id="btnGear" title="Settings (S)" type="button">
<svg fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" stroke="currentColor" viewbox="0 0 24 24"><circle cx="12" cy="12" r="3"><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></circle></svg>
</button>
<button aria-label="Previous month" class="nbtn" id="btnPrev" title="Previous month (←)" type="button">
<svg fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewbox="0 0 24 24"><path d="M15 18l-6-6 6-6"></path></svg>
</button>
<button aria-label="Next month" class="nbtn" id="btnNext" title="Next month (→)" type="button">
<svg fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 18l6-6-6-6"></path></svg>
</button>
</div>
<span class="khint">← → months · T today · / add · C theme · S settings</span>
</nav>
</header>
<section class="board">
<div class="cal">
<div class="week"><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span></div>
<div class="days" id="days"></div>
</div>
</section>
</div>
<form autocomplete="off" class="composer" id="composer">
<div class="cinner">
<span class="dinfo" id="dInfo"></span>
<input aria-label="Quick add event" id="q" placeholder="Add an event — try “dentist next friday 3pm”" spellcheck="false" type="text" />
<div aria-live="polite" class="pv off" id="pv" role="status"></div>
<div aria-label="Quick event color" class="qcolors" id="qcolors" role="group">
<button aria-label="Red" class="qc" data-ci="1" style="background: var(--c1);" title="Red — click again for auto" type="button"></button>
<button aria-label="Orange" class="qc" data-ci="2" style="background: var(--c2);" title="Orange — click again for auto" type="button"></button>
<button aria-label="Yellow" class="qc" data-ci="3" style="background: var(--c3);" title="Yellow — click again for auto" type="button"></button>
<button aria-label="Green" class="qc" data-ci="4" style="background: var(--c4);" title="Green — click again for auto" type="button"></button>
<button aria-label="Blue" class="qc" data-ci="5" style="background: var(--c5);" title="Blue — click again for auto" type="button"></button>
</div>
<button aria-label="Cancel edit" class="cbt" hidden="" id="btnCancel" title="Cancel" type="button">
<svg fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" viewbox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
<button aria-label="Add event" class="addb" id="btnAdd" title="Add event" type="submit">
<svg fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg>
</button>
</div>
</form>
<!-- day details popover -->
<div aria-label="Day details" class="dpop" id="dpop" role="dialog"></div>
<div class="toast" id="toast" role="status"></div>
<!-- settings / backup modal -->
<div class="scrim" id="scrim">
<div aria-labelledby="mTitle" aria-modal="true" class="modal" role="dialog">
<div class="mhead">
<h2 id="mTitle">Settings</h2>
<button aria-label="Close settings" class="nbtn" id="mClose" title="Close (Esc)" type="button">
<svg fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" viewbox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div class="mbody">
<section class="msec">
<h3>Theme</h3>
<p class="mnote">Nine ink & paper themes — or press <b>C</b> in the calendar to carousel through them.</p>
<div class="thgrid" id="thGrid"></div>
</section>
<section class="msec">
<h3>Backup & restore</h3>
<p class="mnote">Your events live only in this browser. Export a JSON copy, or paste / load one below to restore.</p>
<textarea aria-label="Backup JSON" id="ioArea" placeholder="Paste a backup JSON here, or load a file…" spellcheck="false"></textarea>
<div class="mrow">
<button class="tbtn" id="btnCopy" type="button">Copy JSON</button>
<button class="tbtn" id="btnDl" type="button">Download .json</button>
</div>
<div class="mrow">
<label class="tbtn" for="fileIn">
<svg fill="none" height="14" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" stroke="currentColor" viewbox="0 0 24 24" width="14"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"><path d="M14 2v6h6"></path></path></svg>
Load file…
</label>
<input accept=".json,application/json" hidden="" id="fileIn" type="file" />
<span class="fnote" id="fileName"></span>
</div>
<div class="mrow radios">
<label><input checked="" name="rmode" type="radio" value="merge" /> Merge with current events</label>
<label><input name="rmode" type="radio" value="replace" /> Replace everything</label>
</div>
<div class="mrow">
<button class="tbtn primary" id="btnRestore" type="button">Restore from text above</button>
</div>
<p class="mstat" id="ioStat" role="status"></p>
</section>
<section class="msec">
<h3>Data</h3>
<p class="mnote" id="statLine"></p>
<button class="tbtn danger" id="btnWipe" type="button">Erase all events</button>
</section>
</div>
</div>
</div>
<script>
/* ---------- helpers ---------- */
const $ = s => document.querySelector(s);
const pad = n => String(n).padStart(2,'0');
const iso = d => d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate());
const fromISO = s => { const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); };
const today0 = () => { const t=new Date(); t.setHours(0,0,0,0); return t; };
const addDays = (d,n) => { const x=new Date(d); x.setDate(x.getDate()+n); return x; };
const uid = () => 'e'+Date.now().toString(36)+Math.random().toString(36).slice(2,7);
const cap = s => /^[a-z]/.test(s) ? s[0].toUpperCase()+s.slice(1) : s;
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const WSHORT=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const WFULL=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const MSHORT=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const MFULL=['January','February','March','April','May','June','July','August','September','October','November','December'];
const fmtT = m => pad(Math.floor(m/60))+':'+pad(m%60);
const fmtD = s => { const d=fromISO(s); return WSHORT[d.getDay()]+' '+d.getDate()+' '+MSHORT[d.getMonth()]; };
const SV = {
pencil:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>',
trash:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
arrow:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
droplet:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7s6.5 6.7 6.5 11.1a6.5 6.5 0 0 1-13 0C5.5 9.4 12 2.7 12 2.7Z"/></svg>',
plus:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
check:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
};
/* ---------- themes ---------- */
const THEMES=[
{id:'paper', name:'Paper', mode:'light', p:'#F5F0E4', i:'#25211A', a:'#B94A1F'},
{id:'snow', name:'Snow', mode:'light', p:'#F2F3F0', i:'#22282A', a:'#0E7268'},
{id:'mist', name:'Mist', mode:'light', p:'#E4EBF0', i:'#1E2E38', a:'#2F7492'},
{id:'sage', name:'Sage', mode:'light', p:'#E7EDDF', i:'#253229', a:'#4E7A46'},
{id:'butter',name:'Butter',mode:'light', p:'#F4ECC8', i:'#39321A', a:'#A87B12'},
{id:'kraft', name:'Kraft', mode:'light', p:'#E9DFC8', i:'#3A2D1C', a:'#8A4B24'},
{id:'noir', name:'Noir', mode:'dark', p:'#1A1813', i:'#ECE5D3', a:'#E0902F'},
{id:'forest',name:'Forest',mode:'dark', p:'#141F16', i:'#DDE7D0', a:'#D2A63E'},
{id:'slate', name:'Slate', mode:'dark', p:'#1B2024', i:'#DEE4E6', a:'#D97E4A'},
];
const THEME_LS='inkline.theme';
let themeId = 'paper';
try{ const t=localStorage.getItem(THEME_LS); if(t && THEMES.some(x=>x.id===t)) themeId=t; }catch(e){}
/* ---------- event colors ---------- */
const BASIC_CI=[1,2,3,4,5,6], SOFT_CI=[7,8,9,10,11,12,13,14,15,16];
const CI_WORD=['','red','orange','yellow','green','blue','purple','rose','coral','lime','forest','teal','sky','navy','mauve','brown','gray'];
const CAT_CI={health:1,family:14,food:2,fitness:4,travel:12,work:13,social:7,birthday:3,study:11,other:16};
const COL_MAP={red:1,crimson:1,orange:2,yellow:3,green:4,blue:5,purple:6,violet:6,pink:7,magenta:7,rose:7,
coral:8,peach:8,lime:9,forest:10,teal:11,turquoise:11,sky:12,navy:13,mauve:14,lavender:14,brown:15,gray:16,grey:16};
const evCI = ev => ev.ci || CAT_CI[ev.cat] || 16;
function detectCat(s){
if(/\b(birthday|bday|anniversary)\b/.test(s)) return 'birthday';
if(/\b(doctor|dentist|dental|therap(?:y|ist)|check[- ]?up|vaccin\w*|clinic|hospital|physio|massage|pharmac\w*)\b/.test(s)) return 'health';
if(/\b(stand[- ]?up|meeting|deadline|call|calls|interview|review|demo|sprint|retro|sync|workshop|office|client|report|launch|deploy|shift|work)\b/.test(s)) return 'work';
if(/\b(flight|fl(?:y|ying)|trip|travel|vacation|holiday|hotel|hostel|airport|visa|cruise)\b/.test(s)) return 'travel';
if(/\b(gym|workout|run|running|training|yoga|swim\w*|tennis|football|soccer|cycl\w+|padel|golf|hike\w*|match)\b/.test(s)) return 'fitness';
if(/\b(lunch|dinner|brunch|breakfast|coffee|cafe|restaurant|reserv\w*|drinks|beer|pizza|bbq|supper)\b/.test(s)) return 'food';
if(/\b(mom|mum|dad|mother|father|sister|brother|grandma|grandpa|grand\w*|family|cousin|niece|nephew|aunt|uncle)\b/.test(s)) return 'family';
if(/\b(exam|test|class|lecture|school|college|university|homework|study\w*|webinar|course|lesson|tutorial|thesis)\b/.test(s)) return 'study';
if(/\b(party|cinema|movie|film|concert|gig|date|meetup|hangout|trivia|club|festival|game night)\b/.test(s)) return 'social';
return 'other';
}
/* ---------- data ---------- */
const LS_KEY='inkline.events.v1';
function save(){ try{ localStorage.setItem(LS_KEY, JSON.stringify(events)); }catch(e){} }
function load(){ try{ const s=localStorage.getItem(LS_KEY); if(s) return JSON.parse(s); }catch(e){} return null; }
function seed(){
const t=today0();
const mk=(title,off,time,dur)=>({id:uid(),title,date:iso(addDays(t,off)),time,dur,cat:detectCat(title.toLowerCase()),ci:null});
return [ mk('Team standup',1,570,30), mk('Dinner with Sam',1,1140,90), mk('Dentist check-up',3,900,60), mk("Anna's birthday",5,null,null) ];
}
let events = load(); if(!Array.isArray(events)){ events = seed(); save(); }
/* ---------- state ---------- */
const T0 = today0();
const state = { y:T0.getFullYear(), m:T0.getMonth(), selected:iso(T0) };
let editingId = null, toastTimer = null, openPal = null;
let popISO = null; // date of the open day popover
let qColor = null; // quick color picked in the footer (1–5, or null = auto)
const el = {
mname:$('#mname'), myear:$('#myear'), mcount:$('#mcount'), mlabel:$('#mlabel'),
days:$('#days'), dInfo:$('#dInfo'), dpop:$('#dpop'),
q:$('#q'), pv:$('#pv'), composer:$('#composer'), btnAdd:$('#btnAdd'), btnCancel:$('#btnCancel'),
qcolors:$('#qcolors'),
btnPrev:$('#btnPrev'), btnNext:$('#btnNext'), btnToday:$('#btnToday'), toast:$('#toast'),
btnTheme:$('#btnTheme'), thName:$('#thName'), swDot:$('#swDot'), btnGear:$('#btnGear'),
scrim:$('#scrim'), mClose:$('#mClose'), thGrid:$('#thGrid'), ioArea:$('#ioArea'),
btnCopy:$('#btnCopy'), btnDl:$('#btnDl'), fileIn:$('#fileIn'), fileName:$('#fileName'),
ioStat:$('#ioStat'), statLine:$('#statLine'), btnRestore:$('#btnRestore'), btnWipe:$('#btnWipe')
};
const qBtns=[...document.querySelectorAll('.qc')];
/* ---------- natural-language parser ---------- */
const MRX='jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sept?(?:ember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
const WRX='sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?';
const MI = s => MSHORT.indexOf(s.slice(0,3));
function resolveMD(yr,mo,day,T){
let y = yr ?? T.getFullYear();
const dim = yy => new Date(yy,mo+1,0).getDate();
let d = new Date(y,mo,Math.min(day,dim(y)));
if(!yr && d < T) d = new Date(++y,mo,Math.min(day,dim(y)));
return d;
}
function parseQuick(raw){
const out={date:null,time:null,dur:60,fb:null,cat:'other',ci:null,title:raw.trim()};
if(!out.title) return out;
const low=raw.toLowerCase();
const ranges=[];
const take=m=>{ const s=m.index,e=m.index+m[0].length;
if(ranges.some(r=>s<r.e&&e>r.s)) return false;
ranges.push({s,e}); return true; };
const T=today0(), add=n=>{ const d=new Date(T); d.setDate(d.getDate()+n); return d; };
let m;
if(m=low.match(/\b(?:day\s+after\s+tomorrow|tomorrow|tmr|today|tonight|yesterday)\b/)){
const w=m[0];
if(!out.date) out.date=add(w==='yesterday'?-1:(w==='today'||w==='tonight')?0:w.startsWith('day')?2:1);
if(w==='tonight') out.fb=20*60;
take(m);
}
if(m=low.match(/\bin\s+(\d+)\s*(days?|weeks?)\b/)){
if(!out.date) out.date=add(+m[1]*(m[2][0]==='w'?7:1));
take(m);
}
if(m=low.match(/\b(?:this|next|on|coming|over\s+the)?\s*weekend\b/)){
if(!out.date) out.date=add(((6-T.getDay())+7)%7);
take(m);
}
if(m=low.match(/\b(?:at\s+|from\s+)?(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)\b/)){
let h=+m[1], mm=m[2]?+m[2]:0;
if(h<=12) h = m[3]==='am' ? (h===12?0:h) : (h===12?12:h+12);
out.time=Math.min(h,23)*60+Math.min(mm,59);
take(m);
}
else if(m=low.match(/\b(?:at\s+|from\s+)?(\d{1,2})[:.](\d{2})\b/)){
let h=+m[1]; if(h>=1&&h<=6) h+=12;
out.time=Math.min(h,23)*60+Math.min(+m[2],59);
take(m);
}
else if(m=low.match(new RegExp('\\b(?:at|from)\\s+(\\d{1,2})\\b(?!\\s*(?:'+MRX+')\\b)'))){
let h=+m[1]; if(h>=1&&h<=6) h+=12;
out.time=Math.min(h,23)*60;
take(m);
}
if(m=low.match(/\b(noon|midnight|morning|afternoon|evening|night)\b/)){
const TW={noon:720,midnight:0,morning:540,afternoon:840,evening:1140,night:1260};
if(out.time==null && out.fb==null) out.fb=TW[m[1]];
take(m);
}
if(m=low.match(new RegExp('\\b(?:on\\s+)?('+MRX+')\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s*((?:19|20)\\d{2}))?\\b'))){
if(take(m)&&!out.date) out.date=resolveMD(m[3]?+m[3]:null, MI(m[1]), +m[2], T);
}
if(m=low.match(new RegExp('\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+(?:of\\s+)?('+MRX+')\\b(?:,?\\s*((?:19|20)\\d{2}))?'))){
if(take(m)&&!out.date) out.date=resolveMD(m[3]?+m[3]:null, MI(m[2]), +m[1], T);
}
if(m=low.match(/\b(?:the\s+)?(\d{1,2})(st|nd|rd|th)\b(?!\s*(?:birthday|bday|anniversary))/)){
if(take(m)&&!out.date){
let day=+m[1], mo=T.getMonth(), y=T.getFullYear();
const mk=()=>new Date(y,mo,Math.min(day,new Date(y,mo+1,0).getDate()));
let d=mk(); if(d<T){ mo++; if(mo>11){mo=0;y++;} d=mk(); }
out.date=d;
}
}
if(m=low.match(new RegExp('\\b(?:next|this|on|coming|by)?\\s*('+WRX+')\\b'))){
if(take(m)&&!out.date){
const wd=['sun','mon','tue','wed','thu','fri','sat'].indexOf(m[1].slice(0,3));
let diff=(wd-T.getDay()+7)%7;
if(diff===0 && m[0].startsWith('next')) diff=7;
out.date=add(diff);
}
}
if(m=low.match(/\bfor\s+(\d+)\s*h\s*(\d+)?\s*m?\b/)){ out.dur=+m[1]*60+(m[2]?+m[2]:0); take(m); }
else if(m=low.match(/\bfor\s+(\d+(?:[.,]\d+)?)\s*(?:h|hr|hrs|hours?)\b/)){ out.dur=Math.round(parseFloat(m[1].replace(',','.'))*60); take(m); }
else if(m=low.match(/\bfor\s+(\d+)\s*(?:m|min|mins|minutes?)\b/)){ out.dur=+m[1]; take(m); }
if(m=low.match(/\b(?:light\s+|pale\s+|dark\s+)?(red|crimson|orange|yellow|green|blue|navy|purple|violet|pink|magenta|rose|coral|peach|lime|forest|teal|turquoise|sky|mauve|lavender|brown|gray|grey)\b/)){
let ci=COL_MAP[m[1]]||null;
if(/^light\s/.test(m[0])){ if(m[1]==='blue')ci=12; else if(m[1]==='green')ci=9; }
out.ci=ci; take(m);
}
let title=raw;
if(low.length===raw.length){
ranges.sort((a,b)=>b.s-a.s);
for(const r of ranges) title=title.slice(0,r.s)+title.slice(r.e);
}
out.title=title.replace(/\s+/g,' ').trim()
.replace(/^[,;:.\-–—\s]+/,'').replace(/[,;:.\-–—\s]+$/,'')
.replace(/^(?:on|at|for)\s+/i,'').replace(/\s+(?:on|at|for)$/i,'');
out.cat=detectCat(low);
if(out.time==null && out.fb!=null) out.time=out.fb;
if(out.time==null) out.dur=null;
return out;
}
/* ---------- rendering ---------- */
function byTime(a,b){ return (a.time??-1)-(b.time??-1); }
function render(anim){
el.mname.textContent=MFULL[state.m];
el.myear.textContent=state.y;
const dim=new Date(state.y,state.m+1,0).getDate();
el.mlabel.textContent='MONTH '+pad(state.m+1)+' · '+dim+' DAYS';
const cnt=events.filter(e=>{const d=fromISO(e.date);return d.getFullYear()===state.y&&d.getMonth()===state.m;}).length;
el.mcount.textContent=cnt?cnt+(cnt===1?' event':' events'):'no events yet';
renderGrid(anim);
renderDInfo();
updatePreview();
}
function renderGrid(anim){
el.days.classList.toggle('anim',!!anim);
const first=new Date(state.y,state.m,1);
const lead=(first.getDay()+6)%7; // Monday-first
const start=addDays(first,-lead);
const dim=new Date(state.y,state.m+1,0).getDate();
const weeks=Math.ceil((lead+dim)/7);
const tISO=iso(today0());
const map=new Map();
for(const ev of events){ (map.get(ev.date)||map.set(ev.date,[]).get(ev.date)).push(ev); }
let html='';
for(let i=0;i<weeks*7;i++){
const d=addDays(start,i), dISO=iso(d);
const outM=d.getMonth()!==state.m||d.getFullYear()!==state.y;
const evs=(map.get(dISO)||[]).slice().sort(byTime);
const chips=evs.slice(0,3).map(ev=>
`<span class="chip"><i style="background:var(--c${evCI(ev)})"></i>${ev.time!=null?`<b>${fmtT(ev.time)}</b>`:''}<span class="t">${esc(ev.title)}</span></span>`
).join('');
const more=evs.length>3?`<span class="more">+${evs.length-3} more</span>`:'';
html+=`<button type="button" class="cell${outM?' out':''}${dISO===tISO?' today':''}${dISO===state.selected?' sel':''}"
data-iso="${dISO}" style="--i:${i}" aria-label="${d.toDateString()}">
<span class="num">${d.getDate()}</span>
${chips?`<span class="chips">${chips}${more}</span>`:''}</button>`;
}
el.days.innerHTML=html;
}
/* small day info line in the composer (count only when there are events) */
function renderDInfo(){
const d=fromISO(state.selected);
const isT=state.selected===iso(today0());
const n=events.filter(e=>e.date===state.selected).length;
el.dInfo.innerHTML=`${isT?'<i class="tdot"></i>':''}<span class="dtxt"><span class="dw">${WSHORT[d.getDay()]}</span> ${d.getDate()} ${MSHORT[d.getMonth()]} <span class="dy">${d.getFullYear()}</span></span>${n?`<span class="dc">· ${n} event${n===1?'':'s'}</span>`:''}`;
el.dInfo.title=`${WFULL[d.getDay()]}, ${d.getDate()} ${MFULL[d.getMonth()]} ${d.getFullYear()}`;
}
/* ---------- day popover ---------- */
function evRow(ev){
return `<li class="ev">
<i class="dot" style="background:var(--c${evCI(ev)})"></i>
<div class="meta">
<div class="when">${ev.time!=null?`${fmtT(ev.time)} – ${fmtT(ev.time+(ev.dur||60))}`:'All day'}</div>
<div class="what">${esc(ev.title)}</div>
</div>
<div class="tools">
<button type="button" data-act="color" data-id="${ev.id}" aria-label="Change color" title="Change color">${SV.droplet}</button>
<button type="button" data-act="edit" data-id="${ev.id}" aria-label="Edit event" title="Edit">${SV.pencil}</button>
<button type="button" data-act="del" data-id="${ev.id}" aria-label="Delete event" title="Delete">${SV.trash}</button>
</div>
${openPal===ev.id?`<div class="cpal">
<div class="crow">
<button type="button" class="cw auto${ev.ci==null?' cur':''}" data-act="setci" data-ci="">Auto</button>
${BASIC_CI.map(i=>`<button type="button" class="cw${ev.ci===i?' cur':''}" style="background:var(--c${i})" data-act="setci" data-ci="${i}" aria-label="${CI_WORD[i]}" title="${CI_WORD[i]}"></button>`).join('')}
<span class="clab">basic</span>
</div>
<div class="crow soft">
${SOFT_CI.map(i=>`<button type="button" class="cw${ev.ci===i?' cur':''}" style="background:var(--c${i})" data-act="setci" data-ci="${i}" aria-label="${CI_WORD[i]}" title="${CI_WORD[i]}"></button>`).join('')}
<span class="clab">soft</span>
</div>
</div>`:''}
</li>`;
}
function renderPop(){
if(!popISO) return;
const d=fromISO(popISO);
const evs=events.filter(e=>e.date===popISO).sort(byTime);
let html=`<div class="dphead"><b>${WSHORT[d.getDay()]} ${d.getDate()} ${MSHORT[d.getMonth()]}</b><span>${evs.length?evs.length+(evs.length===1?' event':' events'):'free'}</span></div>`;
html+= evs.length
? `<ul class="dplist">${evs.map(evRow).join('')}</ul>`
: `<ul class="dplist"><li class="empty">Nothing planned yet.<span>Type below — try “yoga saturday 9am”.</span></li></ul>`;
el.dpop.innerHTML=html;
}
function openPopFromCell(dISO){
popISO=dISO; openPal=null;
const cell=el.days.querySelector(`[data-iso="${dISO}"]`);
el.dpop.classList.add('on');
el.dpop.style.visibility='hidden';
renderPop();
const r=cell?cell.getBoundingClientRect():{left:innerWidth/2-150,right:innerWidth/2+150,bottom:innerHeight/2,top:innerHeight/2};
const w=el.dpop.offsetWidth, h=el.dpop.offsetHeight, pad=8;
let L=Math.min(Math.max(r.left+10,pad),innerWidth-w-pad);
let T=r.bottom+8;
if(T+h>innerHeight-pad) T=Math.max(pad,r.top-h-8);
el.dpop.style.left=L+'px';
el.dpop.style.top=T+'px';
el.dpop.style.visibility='';
}
function closePop(){ popISO=null; openPal=null; el.dpop.classList.remove('on'); }
/* ---------- live preview + quick colors ---------- */
function updatePreview(){
const raw=el.q.value.trim();
const p=raw?parseQuick(raw):{date:null,time:null,ci:null};
const eff=qColor||p.ci||null;
const cur=qColor||(p.ci!=null&&p.ci<=5?p.ci:null);
qBtns.forEach(b=>b.classList.toggle('cur',+b.dataset.ci===cur));
if(!raw){ el.pv.classList.add('off'); return; }
const dISO=p.date?iso(p.date):state.selected;
const t=p.time!=null?fmtT(p.time):'all-day';
el.pv.innerHTML=`${SV.arrow}<em class="${p.date?'':'mut'}">${esc(fmtD(dISO))}</em><span class="sep">·</span><span class="tt">${t}</span>${eff?`<i style="width:9px;height:9px;border-radius:50%;background:var(--c${eff});flex:none"></i>`:''}${p.date?'':'<span class="fb">selected day</span>'}`;
el.pv.classList.remove('off');
}
/* ---------- theme engine ---------- */
function applyTheme(id,anim=true){
const t=THEMES.find(x=>x.id===id)||THEMES[0];
themeId=t.id;
const root=document.documentElement;
root.dataset.theme=t.id;
root.dataset.mode=t.mode;
try{ localStorage.setItem(THEME_LS,t.id); }catch(e){}
el.thName.textContent=t.name;
if(anim){
root.classList.add('theming');
clearTimeout(applyTheme._t);
applyTheme._t=setTimeout(()=>root.classList.remove('theming'),420);
}
document.querySelectorAll('.thb').forEach(b=>b.classList.toggle('cur',b.dataset.th===t.id));
}
function cycleTheme(){
const i=THEMES.findIndex(t=>t.id===themeId);
applyTheme(THEMES[(i+1)%THEMES.length].id);
el.swDot.classList.remove('spin'); void el.swDot.offsetWidth; el.swDot.classList.add('spin');
}
/* ---------- actions ---------- */
function nav(delta){
closePop();
state.m+=delta;
if(state.m<0){state.m=11;state.y--;} else if(state.m>11){state.m=0;state.y++;}
render(true);
}
function goToday(){ closePop(); const t=today0(); state.y=t.getFullYear(); state.m=t.getMonth(); state.selected=iso(t); render(true); }
function toast(msg,undo){
el.toast.innerHTML=`<span>${msg}</span>`+(undo?`<button type="button" id="undoB">Undo</button>`:'');
el.toast.classList.add('on');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>el.toast.classList.remove('on'),3400);
if(undo) $('#undoB').onclick=()=>{ undo(); el.toast.classList.remove('on'); };
}
function delEvent(id){
const i=events.findIndex(e=>e.id===id); if(i<0) return;
const [ev]=events.splice(i,1);
if(editingId===id) cancelEdit();
save(); render(false); if(popISO) renderPop();
toast(`“${esc(ev.title)}” deleted`, ()=>{ events.push(ev); save(); render(false); if(popISO) renderPop(); });
}
function eventToText(ev){
const d=fromISO(ev.date), t=today0();
const diff=Math.round((d-t)/864e5);
const dayS = diff===0?'today':diff===1?'tomorrow':`${d.getDate()} ${MSHORT[d.getMonth()]}${d.getFullYear()!==t.getFullYear()?' '+d.getFullYear():''}`;
const timeS = ev.time!=null?fmtT(ev.time):'';
const durS = ev.time!=null&&ev.dur&&ev.dur!==60?` for ${ev.dur%60===0?ev.dur/60+'h':Math.floor(ev.dur/60)+'h'+ev.dur%60}`:'';
const colS = ev.ci?` in ${CI_WORD[ev.ci]}`:'';
return `${ev.title} ${dayS} ${timeS}${durS}${colS}`.replace(/\s+/g,' ').trim();
}
/* add/save button: icon + tooltip swap */
function setSaveMode(on){
el.btnAdd.innerHTML=on?SV.check:SV.plus;
el.btnAdd.title=on?'Save event':'Add event';
el.btnAdd.setAttribute('aria-label',on?'Save event':'Add event');
}
function startEdit(ev){
editingId=ev.id;
qColor=null;
el.q.value=eventToText(ev);
el.composer.classList.add('editing');
el.btnCancel.hidden=false;
setSaveMode(true);
el.q.focus(); updatePreview();
}
function cancelEdit(){
editingId=null; el.q.value=''; qColor=null;
el.composer.classList.remove('editing');
el.btnCancel.hidden=true; setSaveMode(false);
updatePreview();
}
/* ---------- settings modal ---------- */
let thBuilt=false;
function buildThGrid(){
if(!thBuilt){
thBuilt=true;
el.thGrid.innerHTML=THEMES.map(t=>`
<button type="button" class="thb${t.id===themeId?' cur':''}" data-th="${t.id}">
<span class="thchip" style="background:${t.p}"><i style="background:${t.i}"></i><b style="background:${t.a}"></b></span>
<span class="thnm">${t.name}</span><span class="thmd">${t.mode}</span>
</button>`).join('');
el.thGrid.addEventListener('click',e=>{
const b=e.target.closest('.thb'); if(b) applyTheme(b.dataset.th);
});
}
}
function stat(msg,ok){ el.ioStat.textContent=msg; el.ioStat.className='mstat '+(ok?'ok':'err'); }
function stats(){
const n=events.length;
if(!n){ el.statLine.textContent='No events stored yet.'; return; }
const ds=events.map(e=>e.date).sort();
const f=s=>{const d=fromISO(s);return d.getDate()+' '+MSHORT[d.getMonth()]+' '+d.getFullYear();};
el.statLine.textContent=`${n} event${n===1?'':'s'} stored · spanning ${f(ds[0])} → ${f(ds[ds.length-1])}`;
}
function exportJSON(){
return JSON.stringify({app:'inkline',version:1,exportedAt:new Date().toISOString(),theme:themeId,events},null,2);
}
function fillIO(){ el.ioArea.value=exportJSON(); }
function openSettings(){ closePop(); buildThGrid(); fillIO(); stats(); disarmWipe(); el.scrim.classList.add('on'); }
function closeSettings(){ el.scrim.classList.remove('on'); stat(''); el.fileName.textContent=''; disarmWipe(); }
function sanitizeEvents(list){
const out=[], ids=new Set(); let skipped=0;
if(!Array.isArray(list)) return {out,skipped};
for(const r of list){
if(!r||typeof r!=='object'){skipped++;continue;}
const title=String(r.title==null?'':r.title).trim().slice(0,120);
if(!title){skipped++;continue;}
if(!/^\d{4}-\d{2}-\d{2}$/.test(String(r.date||''))){skipped++;continue;}
const date=String(r.date);
let time=r.time==null?null:Math.round(Number(r.time));
if(time!=null&&(!Number.isFinite(time)||time<0||time>1439)) time=null;
let dur=time==null?null:(r.dur==null?60:Math.round(Number(r.dur)));
if(dur!=null&&(!Number.isFinite(dur)||dur<5||dur>1440)) dur=60;
const cat=Object.prototype.hasOwnProperty.call(CAT_CI,r.cat)?r.cat:'other';
let ci=r.ci==null?null:Math.round(Number(r.ci));
if(ci!=null&&(!Number.isFinite(ci)||ci<1||ci>16)) ci=null;
let id=(typeof r.id==='string'&&r.id)?r.id.slice(0,40):uid();
if(ids.has(id)) id=uid();
ids.add(id);
out.push({id,title:cap(title),date,time,dur,cat,ci});
}
return {out,skipped};
}
function doRestore(){
const txt=el.ioArea.value.trim();
if(!txt){ stat('Nothing to restore — paste or load a backup first.',false); return; }
let data;
try{ data=JSON.parse(txt); }
catch(e){ stat('That is not valid JSON — check the text and try again.',false); return; }
const list=Array.isArray(data)?data:(data&&Array.isArray(data.events)?data.events:null);
if(!list){ stat('No events array found in this backup.',false); return; }
const mode=document.querySelector('input[name=rmode]:checked').value;
const prev=events;
const san=sanitizeEvents(list);
let skipped=san.skipped, final, added;
if(mode==='replace'){
final=san.out; added=final.length;
if(data.theme&&THEMES.some(t=>t.id===data.theme)) applyTheme(data.theme);
}else{
const have=new Set(events.map(e=>e.id));
const add=san.out.filter(e=>!have.has(e.id));
skipped+=san.out.length-add.length;
final=events.concat(add); added=add.length;
}
events=final;
if(editingId&&!events.some(e=>e.id===editingId)) cancelEdit();
save(); render(false); fillIO(); stats();
stat(`Restored ${added} event${added===1?'':'s'}${skipped?` · ${skipped} skipped`:''}.`,true);
const undo=()=>{ events=prev; save(); render(false); fillIO(); stats(); stat('Restore undone.',true); };
toast(added?`Restored ${added} event${added===1?'':'s'}`:'Nothing new to import',undo);
}
function download(){
fillIO();
const blob=new Blob([exportJSON()],{type:'application/json'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='inkline-backup-'+iso(today0())+'.json';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(a.href),4000);
stat('Backup file downloaded.',true);
}
async function copyIO(){
fillIO();
const txt=exportJSON();
try{ await navigator.clipboard.writeText(txt); stat('JSON copied to clipboard.',true); }
catch(e){
el.ioArea.select();
try{ document.execCommand('copy'); stat('JSON copied to clipboard.',true); }
catch(e2){ stat('Copy failed — select the text and press Ctrl+C.',false); }
}
}
let wipeArm=false, wipeTimer=null;
function disarmWipe(){ wipeArm=false; clearTimeout(wipeTimer); el.btnWipe.classList.remove('armed'); el.btnWipe.textContent='Erase all events'; }
el.btnWipe.addEventListener('click',()=>{
if(!wipeArm){
wipeArm=true; el.btnWipe.classList.add('armed');
el.btnWipe.textContent='Really erase? Click again';
wipeTimer=setTimeout(disarmWipe,3000);
return;
}
const prev=events;
events=[]; cancelEdit(); disarmWipe(); closePop();
save(); render(false); fillIO(); stats();
toast('All events erased',()=>{ events=prev; save(); render(false); fillIO(); stats(); });
stat('All events erased.',true);
});
/* ---------- wiring ---------- */
el.btnPrev.onclick=()=>nav(-1);
el.btnNext.onclick=()=>nav(1);
el.btnToday.onclick=goToday;
el.btnCancel.onclick=cancelEdit;
el.btnTheme.onclick=cycleTheme;
el.btnGear.onclick=openSettings;
el.mClose.onclick=closeSettings;
el.scrim.addEventListener('click',e=>{ if(e.target===el.scrim) closeSettings(); });
el.btnCopy.onclick=copyIO;
el.btnDl.onclick=download;
el.btnRestore.onclick=doRestore;
el.q.addEventListener('input',updatePreview);
/* footer quick colors: click to set, click again for auto */
el.qcolors.addEventListener('click',e=>{
const b=e.target.closest('.qc'); if(!b) return;
const i=+b.dataset.ci;
const raw=el.q.value.trim();
const p=raw?parseQuick(raw):{ci:null};
const cur=qColor||(p.ci!=null&&p.ci<=5?p.ci:null);
qColor=(cur===i)?null:i;
updatePreview();
});
el.fileIn.addEventListener('change',()=>{
const f=el.fileIn.files[0];
if(!f) return;
el.fileName.textContent=f.name;
const rd=new FileReader();
rd.onload=()=>{ el.ioArea.value=rd.result; stat(`Loaded ${f.name} — press Restore.`,true); };
rd.onerror=()=>stat('Could not read that file.',false);
rd.readAsText(f);
el.fileIn.value='';
});
/* calendar: click selects a day; click a chip (or the selected day again) opens the day popover */
el.days.addEventListener('click',e=>{
const cell=e.target.closest('.cell'); if(!cell) return;
const dISO=cell.dataset.iso;
const d=fromISO(dISO);
const jumped=d.getMonth()!==state.m||d.getFullYear()!==state.y;
const wasSel=!jumped&&state.selected===dISO;
const onItem=!!e.target.closest('.chip,.more');
if(jumped){ state.y=d.getFullYear(); state.m=d.getMonth(); }
state.selected=dISO;
render(jumped);
if(onItem||wasSel){
openPopFromCell(dISO);
e.stopPropagation(); // keep the just-opened popover alive
}
});
/* popover actions */
el.dpop.addEventListener('click',e=>{
const b=e.target.closest('button[data-act]'); if(!b) return;
const ev=events.find(x=>x.id===b.dataset.id); if(!ev) return;
const act=b.dataset.act;
if(act==='del') delEvent(ev.id);
else if(act==='edit'){ startEdit(ev); closePop(); }
else if(act==='color'){ openPal=openPal===ev.id?null:ev.id; renderPop(); }
else if(act==='setci'){
ev.ci=b.dataset.ci===''?null:+b.dataset.ci;
openPal=null; save(); render(false); renderPop();
}
});
/* click outside closes the popover */
document.addEventListener('click',e=>{
if(!popISO) return;
if(e.target.closest('#dpop')) return;
closePop();
});
window.addEventListener('resize',()=>{ if(popISO) closePop(); });
el.composer.addEventListener('submit',e=>{
e.preventDefault();
const raw=el.q.value.trim();
if(!raw){ el.q.focus(); return; }
const p=parseQuick(raw);
const title=p.title.trim();
if(!title){ toast('Add a short name first — then a date and time'); el.q.focus(); return; }
const dISO=p.date?iso(p.date):state.selected;
const ev={id:editingId||uid(),title:cap(title),date:dISO,time:p.time,dur:p.time!=null?(p.dur||60):null,cat:p.cat,ci:qColor||p.ci||null};
let msg;
if(editingId){
events[events.findIndex(x=>x.id===editingId)]=ev;
msg=`“${esc(ev.title)}” updated — ${fmtD(dISO)}${ev.time!=null?' · '+fmtT(ev.time):''}`;
}else{
events.push(ev);
const clash=events.find(x=>x.id!==ev.id&&x.date===dISO&&x.time===ev.time&&ev.time!=null);
msg=clash?`“${esc(ev.title)}” added — also at ${fmtT(ev.time)}: “${esc(clash.title)}”`
:`“${esc(ev.title)}” added — ${fmtD(dISO)}${ev.time!=null?' · '+fmtT(ev.time):''}`;
}
save();
editingId=null; qColor=null;
el.composer.classList.remove('editing');
el.btnCancel.hidden=true; setSaveMode(false);
el.q.value='';
const d=fromISO(dISO);
state.y=d.getFullYear(); state.m=d.getMonth(); state.selected=dISO;
render(false);
if(popISO===dISO) renderPop();
toast(msg);
el.q.focus();
});
document.addEventListener('keydown',e=>{
const a=document.activeElement;
const inField=a&&(a.tagName==='INPUT'||a.tagName==='TEXTAREA');
if(el.scrim.classList.contains('on')){
if(e.key==='Escape') closeSettings();
return;
}
if(inField){
if(a===el.q&&e.key==='Escape'){
if(editingId) cancelEdit(); else { el.q.value=''; qColor=null; updatePreview(); }
el.q.blur();
}
return;
}
if(e.key==='Escape'&&popISO){ closePop(); return; }
if(e.key==='ArrowLeft') nav(-1);
else if(e.key==='ArrowRight') nav(1);
else if(e.key==='t'||e.key==='T') goToday();
else if(e.key==='c'||e.key==='C') cycleTheme();
else if(e.key==='s'||e.key==='S') openSettings();
else if(e.key==='/'){ e.preventDefault(); el.q.focus(); }
});
/* ---------- init ---------- */
applyTheme(themeId,false);
setSaveMode(false);
render(true);
</script>
</body>
</html>
calendar
1:58 μ.μ.
