calendar

(full-width)

<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&amp;family=Space+Grotesk:wght@400;500;600&amp;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 &amp; 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 &amp; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>


News and Tweets...

#buttons=(Accept !) #days=(20)

Our website uses cookies to enhance your experience. Learn More
Accept !