6da6b1b023
Kalas (muted) design spec with final tokens, components and every screen S-1 to S-22 in all states; 80-frame screen catalogue with 155 PNGs in docs/ux/screens/; strings.csv with D-017 module prefixes (558 rows, en and sv); colour tokens renamed to the landed contract names slate and amber; fixed category ids; proposed-decisions.md with the final §9.7 hex values and UX-9. Supersedes tokens.md, components.md, screens.md and the U1 Kalas PNGs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U2TeboHB8AtnzTrt3bWFzY
1397 lines
166 KiB
HTML
1397 lines
166 KiB
HTML
<!doctype html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Kvitt — skärmar (U2)</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=block" rel="stylesheet">
|
||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400..700,0..1,0&display=block" rel="stylesheet">
|
||
<style id="tokens"></style>
|
||
<style id="type"></style>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
button{font:inherit;color:inherit;background:none;border:0;text-align:inherit;cursor:pointer}
|
||
/* ---------- review page ---------- */
|
||
body.review{background:#EFE8E2;color:#2A201E;font-family:Roboto,system-ui,sans-serif;font-size:15px;line-height:1.5;padding:40px 24px 80px}
|
||
.review .wrap{max-width:1240px;margin:0 auto}
|
||
.review header>h1{font-size:40px;line-height:1.1;font-weight:900;letter-spacing:-.5px}
|
||
.review header>h1 small{display:block;font-size:15px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#9C3B22;margin-bottom:6px}
|
||
.review .idea{font-size:20px;font-weight:700;margin:10px 0 8px;max-width:820px}
|
||
.review .wrap>p,.review header>p{max-width:860px;margin:8px 0}
|
||
.review .wrap>h2{font-size:26px;font-weight:900;margin:48px 0 12px;padding-top:16px;border-top:2px solid #DCCFC6}
|
||
.review .wrap>h3,.review .group>h3{font-size:18px;font-weight:900;margin:26px 0 8px}
|
||
.review table.spec{border-collapse:collapse;font-size:13px;margin:8px 0 12px;background:#FFFBF7;border-radius:12px;overflow:hidden}
|
||
.review table.spec th,.review table.spec td{padding:6px 10px;border-bottom:1px solid #EADFD7;text-align:left;vertical-align:middle}
|
||
.review table.spec th{background:#F5ECE5;font-weight:800}
|
||
.review td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||
.review .sw{display:inline-block;width:28px;height:20px;border-radius:6px;vertical-align:middle;border:1px solid rgba(0,0,0,.15);margin-right:6px}
|
||
.review .bad{color:#B3261E;font-weight:900}
|
||
.review .ok{color:#1B6B45;font-weight:800}
|
||
.review code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#F3E9E1;padding:1px 4px;border-radius:4px}
|
||
.review .wrap>ul{margin:6px 0 6px 22px;max-width:860px}
|
||
.review .states{display:flex;flex-wrap:wrap;gap:16px;margin:8px 0}
|
||
.review .states .tk{width:379px;padding:16px;border-radius:24px;background:var(--surface);font-family:Roboto,sans-serif;--fs:1}
|
||
.review .states .cap{font-size:12px;font-weight:800;margin-bottom:6px;color:var(--on-surface-variant)}
|
||
.review .group{margin:36px 0 0}
|
||
.review .group>h3{font-size:20px;margin-bottom:2px}
|
||
.review .pairs{display:flex;flex-wrap:wrap;gap:28px;margin-top:12px}
|
||
.review figure{display:flex;flex-direction:column;gap:8px}
|
||
.review figcaption{font-size:13px;max-width:411px}
|
||
.review figcaption b{display:block;font-size:14px}
|
||
.review .phone{border-radius:28px;box-shadow:0 10px 30px rgba(60,30,20,.18),0 0 0 1px rgba(0,0,0,.08)}
|
||
.review .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px 32px}
|
||
body.frame{margin:0;background:transparent}
|
||
#frame-list{font:12px/1.4 ui-monospace,monospace}
|
||
|
||
/* ---------- phone ---------- */
|
||
.phone{position:relative;width:411px;height:891px;overflow:hidden;background:var(--surface);color:var(--on-surface);
|
||
font-family:Roboto,system-ui,sans-serif;font-variant-numeric:tabular-nums;font-feature-settings:'tnum';font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*20px);font-weight:400;
|
||
-webkit-font-smoothing:antialiased;flex:none;text-align:left}
|
||
.ic{font-family:'Material Symbols Rounded';font-weight:normal;font-style:normal;font-size:24px;line-height:1;width:1em;height:1em;display:inline-block;
|
||
overflow:hidden;white-space:nowrap;direction:ltr;font-feature-settings:'liga';flex:none;text-align:center;
|
||
font-variation-settings:'FILL' 0,'wght' 500,'GRAD' 0,'opsz' 24;user-select:none}
|
||
.ic.f{font-variation-settings:'FILL' 1,'wght' 600,'GRAD' 0,'opsz' 24}
|
||
.sb{position:absolute;top:0;left:0;right:0;height:32px;display:flex;align-items:center;justify-content:space-between;padding:0 18px 0 24px;z-index:20;
|
||
font-size:calc(min(var(--fs),1.2)*14px);font-weight:500;color:var(--on-surface)}
|
||
.sb .sbi{display:flex;gap:4px;align-items:center}
|
||
.sb .ic{font-size:17px}
|
||
.app{position:absolute;top:32px;left:0;right:0;bottom:0}
|
||
.handle{position:absolute;bottom:9px;left:50%;width:108px;height:4px;margin-left:-54px;border-radius:2px;background:var(--on-surface);opacity:.45;z-index:30}
|
||
.scr{position:absolute;inset:0;display:flex;flex-direction:column}
|
||
.body{flex:1;min-height:0;overflow:hidden;position:relative}
|
||
.pad{padding:4px 16px 120px;display:flex;flex-direction:column;gap:12px}
|
||
.ins{height:24px;flex:none}
|
||
.ell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.muted{color:var(--on-surface-variant)}
|
||
.c-owed{color:var(--owed)} .c-owing{color:var(--owing)} .c-settled{color:var(--settled)} .c-err{color:var(--error)} .c-warn{color:var(--warning)} .c-pend{color:var(--pending)} .c-pri{color:var(--primary)}
|
||
.amt{font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
.grow{flex:1;min-width:0}
|
||
.hstack{display:flex;align-items:center;gap:8px}
|
||
.vstack{display:flex;flex-direction:column}
|
||
.shp{clip-path:var(--clip);flex:none}
|
||
|
||
/* top app bar */
|
||
.tab{height:64px;flex:none;display:flex;align-items:center;padding:0 4px;gap:2px;background:var(--surface)}
|
||
.tab-t{flex:1;min-width:0;padding:0 6px}
|
||
.tab-t.pl{padding-left:16px}
|
||
.tab-a{display:flex;align-items:center;gap:2px;padding-right:4px}
|
||
.ib{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;flex:none;color:var(--on-surface-variant)}
|
||
.ib.tonal{background:var(--secondary-container);color:var(--on-secondary-container)}
|
||
.wordmark{font-weight:700;font-size:calc(min(var(--fs),1.5)*24px);line-height:calc(min(var(--fs),1.5)*28px);color:var(--primary);letter-spacing:-.4px;display:flex;align-items:center;gap:8px}
|
||
.wordmark .dot{width:22px;height:22px;background:var(--primary);display:grid;place-items:center;color:var(--on-primary)}
|
||
.wordmark .dot .ic{font-size:15px}
|
||
|
||
/* buttons */
|
||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:8px 20px;border-radius:999px;font-weight:600;
|
||
font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*20px);flex:none;text-align:center}
|
||
.btn .ic{font-size:20px}
|
||
.btn.m{min-height:56px;padding:12px 26px;font-size:calc(var(--fs)*16px);line-height:calc(var(--fs)*24px)}
|
||
.btn.m .ic{font-size:24px}
|
||
.btn.full{width:100%}
|
||
.btn.filled{background:var(--primary);color:var(--on-primary)}
|
||
.btn.tonal{background:var(--secondary-container);color:var(--on-secondary-container)}
|
||
.btn.tert{background:var(--tertiary);color:var(--on-tertiary)}
|
||
.btn.lantern{background:var(--tertiary-container);color:var(--on-tertiary-container)}
|
||
.btn.outlined{box-shadow:inset 0 0 0 1px var(--outline-variant);color:var(--on-surface-variant)}
|
||
.btn.text{color:var(--primary);padding:8px 12px}
|
||
.btn.danger{color:var(--error);padding:8px 12px}
|
||
.btn.err-filled{background:var(--error);color:var(--on-error)}
|
||
.btn.dis{background:color-mix(in srgb,var(--on-surface) 12%,transparent);color:color-mix(in srgb,var(--on-surface) 38%,transparent);box-shadow:none}
|
||
.btn.text.dis,.btn.danger.dis{background:none}
|
||
|
||
/* chips */
|
||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||
.chip{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:4px 10px 4px 8px;border-radius:999px;background:var(--surface-container-high);
|
||
color:var(--on-surface);font-weight:500;font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*18px);max-width:100%}
|
||
.chip .ic{font-size:18px;color:var(--on-surface-variant)}
|
||
.chip .lead-dot{width:12px;height:12px;border-radius:50%;flex:none}
|
||
.chip.sel{background:var(--secondary-container);color:var(--on-secondary-container)}
|
||
.chip.sel .ic{color:var(--on-secondary-container)}
|
||
.chip.out{background:none;box-shadow:inset 0 0 0 1px var(--outline-variant)}
|
||
.chip .k{color:var(--on-surface-variant);font-weight:600}
|
||
.chip.sel .k{color:var(--on-secondary-container)}
|
||
|
||
/* segmented / connected button group */
|
||
.seg{display:flex;gap:2px}
|
||
.seg>span{flex:1;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:4px;min-height:40px;padding:6px 12px;background:var(--surface-container-high);
|
||
font-weight:600;font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*18px);border-radius:8px;text-align:center;color:var(--on-surface-variant)}
|
||
.seg>span:first-child{border-radius:999px 8px 8px 999px}
|
||
.seg>span:last-child{border-radius:8px 999px 999px 8px}
|
||
.seg>span.on{background:var(--secondary);color:var(--on-secondary);border-radius:999px}
|
||
.seg>span .ic{font-size:18px}
|
||
|
||
/* fields */
|
||
.fld{background:var(--surface-container-highest);border-radius:16px;padding:8px 16px;min-height:56px;display:flex;align-items:center;gap:12px;box-shadow:inset 0 0 0 0 transparent}
|
||
.fld .fv{flex:1;min-width:0;display:flex;flex-direction:column}
|
||
.fld .lab{font-size:calc(var(--fs)*12px);line-height:calc(var(--fs)*16px);color:var(--on-surface-variant);font-weight:500}
|
||
.fld .val{font-size:calc(var(--fs)*16px);line-height:calc(var(--fs)*24px);font-weight:400;overflow-wrap:anywhere}
|
||
.fld .ph{color:var(--on-surface-variant);font-weight:500}
|
||
.fld>.ic{color:var(--on-surface-variant)}
|
||
.fld.focus{box-shadow:inset 0 0 0 2px var(--primary)}
|
||
.fld.focus .lab{color:var(--primary)}
|
||
.fld.err{box-shadow:inset 0 0 0 2px var(--error)}
|
||
.fld.err .lab{color:var(--error)}
|
||
.fld.ro{background:transparent;box-shadow:inset 0 0 0 1px var(--outline-variant)}
|
||
.fld.dis{opacity:.45}
|
||
.caret{display:inline-block;width:2px;height:1.05em;background:var(--primary);vertical-align:-0.12em;margin-left:1px;border-radius:1px}
|
||
.sup{padding:4px 16px 0;font-size:calc(var(--fs)*12px);line-height:calc(var(--fs)*16px);color:var(--on-surface-variant);font-weight:600;display:flex;gap:6px;align-items:flex-start}
|
||
.sup .ic{font-size:16px}
|
||
.sup.err{color:var(--error)}
|
||
|
||
/* cards and lists */
|
||
.card{background:var(--card);border-radius:28px;box-shadow:0 1px 2px var(--shadow),0 2px 8px var(--shadow)}
|
||
.flat{background:var(--surface-container-low);border-radius:28px}
|
||
.grp{display:flex;flex-direction:column;gap:3px}
|
||
.grp>.it{background:var(--card);border-radius:8px}
|
||
.grp>.it:first-child{border-top-left-radius:24px;border-top-right-radius:24px}
|
||
.grp>.it:last-child{border-bottom-left-radius:24px;border-bottom-right-radius:24px}
|
||
.grp.soft>.it{background:var(--surface-container-low)}
|
||
.row{display:flex;align-items:center;gap:14px;padding:10px 16px;min-height:56px}
|
||
.row.r72{min-height:72px}
|
||
.rc{flex:1;min-width:0}
|
||
.rr{flex:none;text-align:right;display:flex;flex-direction:column;align-items:flex-end}
|
||
.rmeta{display:flex;align-items:center;gap:4px;justify-content:flex-end}
|
||
.rmeta .ic{font-size:15px}
|
||
.mh{display:flex;align-items:baseline;justify-content:space-between;padding:12px 8px 2px;gap:8px}
|
||
.sec-h{display:flex;align-items:center;justify-content:space-between;padding:8px 4px 0 8px;gap:8px;min-height:48px}
|
||
.badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;border-radius:999px;background:var(--surface-container-highest);color:var(--on-surface-variant);
|
||
font-weight:600;font-size:calc(var(--fs)*11px);line-height:calc(var(--fs)*16px);white-space:nowrap;vertical-align:middle}
|
||
.badge .ic{font-size:14px}
|
||
.badge.lantern{background:var(--tertiary-container);color:var(--on-tertiary-container)}
|
||
.badge.pend{background:color-mix(in srgb,var(--pending) 14%,transparent);color:var(--pending)}
|
||
.divider{height:1px;background:var(--outline-variant);margin:0 16px}
|
||
|
||
/* tiles and avatars */
|
||
.tile{display:grid;place-items:center;flex:none}
|
||
.tile .ic{font-variation-settings:'FILL' 1,'wght' 600,'GRAD' 0,'opsz' 24}
|
||
.av{display:inline-grid;place-items:center;flex:none;overflow:hidden;position:relative}
|
||
.av.ini{background:var(--avatar-2);color:var(--on-avatar-2);font-weight:700}
|
||
.av.ini.a1{background:var(--avatar-1);color:var(--on-avatar-1)}
|
||
.av.photo{background:linear-gradient(150deg,var(--photo-1),var(--photo-2))}
|
||
.av.photo svg{position:absolute;inset:0;width:100%;height:100%}
|
||
.av.circle{border-radius:50%}
|
||
|
||
/* FAB */
|
||
.fab{position:absolute;right:16px;display:flex;align-items:center;gap:10px;min-height:56px;padding:12px 20px 12px 16px;border-radius:20px;background:var(--primary);color:var(--on-primary);
|
||
font-weight:600;font-size:calc(var(--fs)*16px);line-height:calc(var(--fs)*22px);box-shadow:0 2px 4px var(--shadow),0 6px 14px var(--shadow);z-index:8;max-width:calc(100% - 32px)}
|
||
.fab .ic{font-size:24px}
|
||
|
||
/* navigation bar (Expressive short navigation bar, 64 dp) */
|
||
.nav{flex:none;background:var(--surface-container);padding-bottom:24px;display:flex;z-index:7}
|
||
.nav .ni{flex:1;min-height:64px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;color:var(--on-surface-variant);
|
||
font-weight:500;font-size:calc(min(var(--fs),1.6)*12px);line-height:calc(min(var(--fs),1.6)*16px);padding:6px 2px;text-align:center}
|
||
.nav .ind{width:56px;height:32px;border-radius:16px;display:grid;place-items:center}
|
||
.nav .ni.on{color:var(--on-surface);font-weight:700}
|
||
.nav .ni.on .ind{background:var(--primary-container);color:var(--on-primary-container)}
|
||
.nav .ni.on .ic{font-variation-settings:'FILL' 1,'wght' 600,'GRAD' 0,'opsz' 24}
|
||
|
||
/* balance hero: seesaw */
|
||
.hero{padding:16px 20px 20px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;overflow:hidden}
|
||
.see{position:relative;width:250px;height:92px;flex:none;margin-bottom:6px}
|
||
.see .plank{position:absolute;left:15px;top:66px;width:220px;height:8px;border-radius:4px;transform-origin:50% 50%}
|
||
.see .fulc{position:absolute;left:107px;top:70px;width:36px;height:22px;clip-path:polygon(50% 0,100% 100%,0 100%);background:var(--outline-variant)}
|
||
.see .seat{position:absolute;display:flex;flex-direction:column;align-items:center;gap:2px}
|
||
.see .nm{font-size:calc(min(var(--fs),1.15)*12px);line-height:calc(min(var(--fs),1.15)*16px);font-weight:600;color:var(--on-surface-variant)}
|
||
.hero .words{margin-top:6px}
|
||
.hero-amt{display:flex;flex-wrap:wrap;justify-content:center;align-items:baseline;column-gap:6px;margin-top:2px}
|
||
.hero-amt .u{font-weight:700;font-size:calc(min(var(--fs),1.5)*24px);line-height:calc(min(var(--fs),1.5)*30px)}
|
||
.hero .btn{margin-top:14px}
|
||
.bunting{position:absolute;left:0;right:0;top:0;height:30px}
|
||
.bunting svg{width:100%;height:100%}
|
||
|
||
/* account card */
|
||
.acard{display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px;padding:14px 16px;min-height:76px}
|
||
.acard .an{flex:1 1 150px;min-width:0}
|
||
.acard .ab{flex:0 0 auto;margin-left:auto;text-align:right;display:flex;flex-direction:column;align-items:flex-end}
|
||
.acard.arch{opacity:.92}
|
||
.atile{font-weight:700;color:var(--on-cat);font-size:calc(min(var(--fs),1.3)*18px)}
|
||
|
||
/* split bar (Expressive slider look) */
|
||
.split{position:relative;display:flex;align-items:center;gap:6px;height:16px}
|
||
.split .sa,.split .sbb{height:100%;border-radius:999px}
|
||
.split .sa{background:var(--avatar-1)}
|
||
.split .sbb{background:var(--avatar-2);flex:1}
|
||
.split.thin{height:8px;gap:3px}
|
||
.split .knob{width:4px;height:44px;border-radius:2px;background:var(--on-surface);flex:none}
|
||
.split.err .sa{background:var(--error)}
|
||
|
||
/* keypad */
|
||
.kp{flex:none;background:var(--surface-container);border-radius:28px 28px 0 0;padding:12px 12px 32px;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||
.kp .k{min-height:52px;border-radius:999px;background:var(--surface-container-highest);display:grid;place-items:center;font-weight:600;
|
||
font-size:calc(min(var(--fs),1.5)*24px);line-height:calc(min(var(--fs),1.5)*30px);color:var(--on-surface)}
|
||
.kp.sm .k{min-height:46px}
|
||
.kp .k.fn{background:var(--primary-container);color:var(--on-primary-container)}
|
||
|
||
/* category quick row */
|
||
.cq{display:grid;grid-template-columns:repeat(6,1fr);gap:4px}
|
||
.cqi{display:flex;flex-direction:column;align-items:center;gap:6px;padding:4px 0;min-width:0;position:relative}
|
||
.cqi .cql{font-size:calc(var(--fs)*11px);line-height:calc(var(--fs)*14px);font-weight:500;color:var(--on-surface-variant);text-align:center;overflow-wrap:normal;hyphens:manual;max-width:100%}
|
||
.cqi.on .cql{color:var(--on-surface);font-weight:700}
|
||
.cqi .chk{position:absolute;top:-6px;right:-8px;width:20px;height:20px;border-radius:50%;background:var(--on-surface);color:var(--surface);display:grid;place-items:center;box-shadow:0 0 0 2px var(--surface)}
|
||
.cqi .chk .ic{font-size:14px;font-variation-settings:'FILL' 1,'wght' 700,'GRAD' 0,'opsz' 20}
|
||
|
||
/* banners */
|
||
.banner{border-radius:24px;padding:14px 16px 10px;display:flex;flex-direction:column;gap:6px}
|
||
.banner.lantern{background:var(--tertiary-container);color:var(--on-tertiary-container)}
|
||
.banner.err{background:var(--error-container);color:var(--on-error-container)}
|
||
.banner.warn{background:var(--warning-container);color:var(--on-warning-container)}
|
||
.banner.neutral{background:var(--surface-container-high);color:var(--on-surface)}
|
||
.banner .bt{display:flex;gap:12px;align-items:flex-start}
|
||
.banner .ba{display:flex;justify-content:flex-end;gap:4px;flex-wrap:wrap}
|
||
.banner .btn.text{color:inherit}
|
||
|
||
/* sync line */
|
||
.syncl{align-self:center;display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:6px 10px 6px 14px;border-radius:999px;background:var(--surface-container-high);
|
||
font-weight:500;font-size:calc(var(--fs)*13px);line-height:calc(var(--fs)*18px);color:var(--on-surface-variant);max-width:100%}
|
||
.syncl .ic{font-size:18px}
|
||
.syncl.pend{background:color-mix(in srgb,var(--pending) 13%,var(--surface));color:var(--on-surface)}
|
||
|
||
/* dialog */
|
||
.scrim{position:absolute;inset:-32px 0 0 0;background:var(--scrim);opacity:.38;z-index:40}
|
||
.snack{position:absolute;left:16px;right:16px;z-index:35;background:var(--inverse-surface);color:var(--inverse-on-surface);border-radius:16px;padding:4px 6px 4px 16px;min-height:48px;display:flex;align-items:center;gap:8px;box-shadow:0 3px 10px var(--shadow)}
|
||
.snack .btn.text{color:var(--inverse-primary)}
|
||
.fab.dis{background:color-mix(in srgb,var(--on-surface) 12%,var(--surface));color:color-mix(in srgb,var(--on-surface) 38%,transparent);box-shadow:none}
|
||
.mi .sub{display:block;font-size:calc(var(--fs)*12px);line-height:calc(var(--fs)*16px);color:var(--on-surface-variant)}
|
||
.dlg{position:absolute;left:24px;right:24px;top:50%;transform:translateY(-50%);z-index:41;background:var(--surface-container-high);border-radius:28px;padding:24px;
|
||
display:flex;flex-direction:column;gap:14px;box-shadow:0 8px 24px var(--shadow)}
|
||
.dlg .di{align-self:center}
|
||
.dlg .da{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px}
|
||
|
||
/* menu */
|
||
.menu{position:absolute;right:12px;top:52px;z-index:30;background:var(--surface-container);border-radius:16px;padding:6px;min-width:244px;max-width:300px;
|
||
box-shadow:0 4px 12px var(--shadow),0 1px 3px var(--shadow);display:flex;flex-direction:column;gap:2px}
|
||
.menu .mi{display:flex;gap:12px;align-items:flex-start;padding:12px 12px;border-radius:12px;min-height:48px}
|
||
.menu .mi .ic{color:var(--on-surface-variant)}
|
||
.menu .mi.dis{opacity:.5}
|
||
.menu .mi.sel{background:var(--surface-container-highest)}
|
||
|
||
/* timeline */
|
||
.tl{display:flex;flex-direction:column;padding:4px 4px 0}
|
||
.tli{display:flex;gap:14px;position:relative;padding-bottom:14px}
|
||
.tli .dotc{width:24px;display:flex;flex-direction:column;align-items:center;flex:none}
|
||
.tli .dot{width:12px;height:12px;border-radius:50%;background:var(--secondary);margin-top:4px;flex:none}
|
||
.tli .line{position:absolute;left:11px;top:22px;bottom:2px;width:2px;background:var(--outline-variant);border-radius:1px}
|
||
.tli.pend .dot{background:transparent;box-shadow:inset 0 0 0 2px var(--pending)}
|
||
.tli:last-child{padding-bottom:0}
|
||
.tli:last-child .line{display:none}
|
||
|
||
/* charts */
|
||
.chart{position:relative;height:170px;margin:8px 0 0 calc(min(var(--fs),1.7)*44px)}
|
||
.chart .gl{position:absolute;left:0;right:0;height:1px;background:var(--chart-grid)}
|
||
.chart .yl{position:absolute;left:calc(min(var(--fs),1.7)*-46px);width:calc(min(var(--fs),1.7)*40px);text-align:right;transform:translateY(-50%);font-size:calc(var(--fs)*10px);line-height:calc(var(--fs)*12px);color:var(--chart-axis-label);font-weight:500}
|
||
.chart .cols{position:absolute;inset:0;display:flex;align-items:flex-end;gap:10px;padding:0 4px}
|
||
.chart .col{flex:1;display:flex;flex-direction:column-reverse;gap:2px;height:100%;justify-content:flex-start}
|
||
.chart .col .stk{display:flex;flex-direction:column-reverse;gap:2px;border-radius:10px 10px 4px 4px;overflow:hidden;flex:none}
|
||
.chart .col .stk>i{display:block;flex:none;min-height:3px}
|
||
.xl{display:flex;gap:10px;margin:6px 0 0 calc(min(var(--fs),1.7)*44px);padding:0 4px}
|
||
.xl span{flex:1;text-align:center;font-size:calc(var(--fs)*11px);line-height:calc(var(--fs)*14px);color:var(--chart-axis-label);font-weight:500}
|
||
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:10px}
|
||
.legend span{display:inline-flex;align-items:center;gap:6px;font-size:calc(var(--fs)*12px);line-height:calc(var(--fs)*16px);font-weight:500;color:var(--on-surface-variant)}
|
||
.legend i{width:10px;height:10px;border-radius:3px;display:inline-block;flex:none}
|
||
.brow{display:flex;flex-direction:column;gap:6px;padding:8px 0}
|
||
.brow .bh{display:flex;align-items:center;gap:12px}
|
||
.bar{height:10px;border-radius:999px;background:var(--surface-container-highest);overflow:hidden}
|
||
.bar>i{display:block;height:100%;border-radius:999px}
|
||
|
||
/* skeleton */
|
||
.sk{background:var(--surface-container-highest);border-radius:12px}
|
||
|
||
/* notification shade */
|
||
.shade{position:absolute;inset:-32px 0 0 0;background:linear-gradient(170deg,var(--sys-wall-1),var(--sys-wall-2))}
|
||
.ntf{background:var(--sys-card);color:var(--sys-on);border-radius:24px;padding:14px 16px;display:flex;gap:12px}
|
||
.ntf .ap{width:36px;height:36px;border-radius:50%;background:var(--primary);color:var(--on-primary);display:grid;place-items:center;flex:none}
|
||
.ntf .ap .ic{font-size:20px}
|
||
.ntf .h{font-size:calc(var(--fs)*12px);line-height:calc(var(--fs)*16px);color:var(--sys-on-variant);font-weight:600}
|
||
.ntf .t{font-size:calc(var(--fs)*15px);line-height:calc(var(--fs)*20px);font-weight:500;margin-top:2px}
|
||
.ntf .b{font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*20px);color:var(--sys-on-variant)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<script>
|
||
'use strict';
|
||
/* =====================================================================
|
||
Kvitt direction 3 "Kalas" — one file, frames generated from FRAMES.
|
||
===================================================================== */
|
||
|
||
/* ---------- money & colour helpers ---------- */
|
||
const NB = ' ';
|
||
const grp = (n, sep) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
||
const krn = ore => { ore = Math.abs(Math.round(ore)); return grp(Math.floor(ore / 100), NB) + ',' + String(ore % 100).padStart(2, '0'); };
|
||
const kr = ore => krn(ore) + NB + 'kr';
|
||
const sekn = ore => { ore = Math.abs(Math.round(ore)); return grp(Math.floor(ore / 100), ',') + '.' + String(ore % 100).padStart(2, '0'); };
|
||
const sek = ore => 'SEK' + NB + sekn(ore);
|
||
const kron = n => grp(n, NB); // whole kronor for axis labels
|
||
|
||
function hexToRgb(h){ return [1,3,5].map(i => parseInt(h.slice(i, i + 2), 16)); }
|
||
function lum(h){ return hexToRgb(h).map(v => v / 255).map(v => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((a, v, i) => a + v * [0.2126, 0.7152, 0.0722][i], 0); }
|
||
function contrast(a, b){ const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }
|
||
function mix(a, b, t){ const A = hexToRgb(a), B = hexToRgb(b); return '#' + A.map((v, i) => Math.round(v * t + B[i] * (1 - t)).toString(16).padStart(2, '0')).join('').toUpperCase(); }
|
||
|
||
/* ---------- tokens (single source: CSS + review tables + contrast). Muted revision 2026-10-18 ---------- */
|
||
const CAT_BASE = {
|
||
light: { green:'#4E7A55', orange:'#B26A3E', brown:'#6B5244', yellow:'#9C7C2A', blue:'#4A78A8', teal:'#367F82', purple:'#865891', red:'#A84B45', pink:'#AE5D7C', grey:'#6C7177', indigo:'#45508D', lime:'#73822F', slate:'#5E2D4E', amber:'#937D63' },
|
||
dark: { green:'#8FB896', orange:'#E8AA7E', brown:'#B8A090', yellow:'#E6C67A', blue:'#98C0E6', teal:'#8CCAC8', purple:'#C59CDA', red:'#DC8F86', pink:'#EFB0BE', grey:'#B0B2B3', indigo:'#969DD8', lime:'#B9CC86', slate:'#B0719A', amber:'#DFCFC0' }
|
||
};
|
||
const CAT_CONTAINER_MIX = { light: 0.18, dark: 0.24 };
|
||
const TOK = {
|
||
light: {
|
||
'primary':'#9C5238','on-primary':'#FFFFFF','primary-container':'#F2DED4','on-primary-container':'#3A1609',
|
||
'secondary':'#5A6D52','on-secondary':'#FFFFFF','secondary-container':'#DDE6D3','on-secondary-container':'#172410',
|
||
'tertiary':'#76612F','on-tertiary':'#FFFFFF','tertiary-container':'#EEE3C6','on-tertiary-container':'#261C04',
|
||
'error':'#B3261E','on-error':'#FFFFFF','error-container':'#F9DEDA','on-error-container':'#410E0B',
|
||
'surface':'#FAF7F2','on-surface':'#22201D','surface-variant':'#E8E2D9','on-surface-variant':'#4E4842',
|
||
'surface-container-lowest':'#FFFFFF','surface-container-low':'#F5F1EB','surface-container':'#F0EBE4','surface-container-high':'#EAE5DD','surface-container-highest':'#E4DED6',
|
||
'surface-dim':'#DCD6CE','surface-bright':'#FAF7F2','inverse-surface':'#36322E','inverse-on-surface':'#F6F1EA','inverse-primary':'#E4B39F',
|
||
'outline':'#7E766E','outline-variant':'#D2CAC1','scrim':'#000000',
|
||
'owed':'#3D6A4C','on-owed':'#FFFFFF','owed-container':'#D6E7D9','on-owed-container':'#0F2416',
|
||
'owing':'#78497A','on-owing':'#FFFFFF','owing-container':'#EEDDEC','on-owing-container':'#2D1430',
|
||
'settled':'#6A5A2B','settled-container':'#EEE3C6','on-settled-container':'#261C04',
|
||
'warning':'#8A5522','on-warning':'#FFFFFF','warning-container':'#F5E1CA','on-warning-container':'#2C1800',
|
||
'pending':'#3C6680',
|
||
'avatar-1':'#8A5A33','on-avatar-1':'#FFFFFF','avatar-2':'#4A6587','on-avatar-2':'#FFFFFF',
|
||
'chart-cat-1':'#A75B40','chart-cat-2':'#5E7A4E','chart-cat-3':'#8B6A2C','chart-cat-4':'#4C6E98','chart-cat-5':'#7C5A88','chart-cat-6':'#3F7774','chart-cat-7':'#A2566F','chart-cat-8':'#6A6F74',
|
||
'chart-seq-1':'#F2DFD6','chart-seq-2':'#E2B7A5','chart-seq-3':'#C08068','chart-seq-4':'#A75B40','chart-seq-5':'#763A26',
|
||
'chart-grid':'#E6E0D8','chart-axis-label':'#4E4842',
|
||
/* Kalas extras (not Material roles) */
|
||
'card':'#FFFFFF','on-cat':'#FFFFFF','shadow':'rgba(60,40,25,.08)',
|
||
'photo-1':'#E8C2A2','photo-2':'#C0806A','photo-fg':'#6A3622',
|
||
'sys-wall-1':'#D9D4DE','sys-wall-2':'#C9C3D0','sys-card':'#F7F2FA','sys-on':'#1C1B1F','sys-on-variant':'#49454F'
|
||
},
|
||
dark: {
|
||
'primary':'#E6B5A2','on-primary':'#4A1F10','primary-container':'#6A3826','on-primary-container':'#F6DCD0',
|
||
'secondary':'#BCCBB1','on-secondary':'#27341F','secondary-container':'#3D4A35','on-secondary-container':'#D9E6CD',
|
||
'tertiary':'#DCC690','on-tertiary':'#3A2E0B','tertiary-container':'#55432E','on-tertiary-container':'#F1E3C4',
|
||
'error':'#F2B8B1','on-error':'#5F1410','error-container':'#73302A','on-error-container':'#F9DEDA',
|
||
'surface':'#1B1917','on-surface':'#ECE6DF','surface-variant':'#4A4540','on-surface-variant':'#CDC5BC',
|
||
'surface-container-lowest':'#151311','surface-container-low':'#221F1D','surface-container':'#272422','surface-container-high':'#312E2B','surface-container-highest':'#3C3835',
|
||
'surface-dim':'#1B1917','surface-bright':'#423E3A','inverse-surface':'#ECE6DF','inverse-on-surface':'#312E2B','inverse-primary':'#9C5238',
|
||
'outline':'#978E86','outline-variant':'#4A4540','scrim':'#000000',
|
||
'owed':'#A2CCB0','on-owed':'#10331F','owed-container':'#2E4A37','on-owed-container':'#CBE7D3',
|
||
'owing':'#D8B5D6','on-owing':'#3C1F3C','owing-container':'#553A55','on-owing-container':'#F1DDEF',
|
||
'settled':'#E0CA94','settled-container':'#55432E','on-settled-container':'#F1E3C4',
|
||
'warning':'#EDBE8C','on-warning':'#452B0E','warning-container':'#5B3F24','on-warning-container':'#F6DFC6',
|
||
'pending':'#A3C7DE',
|
||
'avatar-1':'#DDB693','on-avatar-1':'#3A230F','avatar-2':'#ADC3DD','on-avatar-2':'#1C2F45',
|
||
'chart-cat-1':'#E3A68E','chart-cat-2':'#ABC79A','chart-cat-3':'#D8BD7E','chart-cat-4':'#9EB9DC','chart-cat-5':'#C8A6D2','chart-cat-6':'#8FC2BC','chart-cat-7':'#E3A6BA','chart-cat-8':'#B5B9BD',
|
||
'chart-seq-1':'#3E2C25','chart-seq-2':'#6A4234','chart-seq-3':'#9A6049','chart-seq-4':'#CC8A70','chart-seq-5':'#E6B5A2',
|
||
'chart-grid':'#36322F','chart-axis-label':'#CDC5BC',
|
||
'card':'#272422','on-cat':'#1B1917','shadow':'rgba(0,0,0,.25)',
|
||
'photo-1':'#C89A78','photo-2':'#8E5238','photo-fg':'#3E1E12',
|
||
'sys-wall-1':'#26242B','sys-wall-2':'#141318','sys-card':'#2B2930','sys-on':'#E6E1E5','sys-on-variant':'#CAC4D0'
|
||
}
|
||
};
|
||
/* ---------- previous bright palette (git 210ab5c), kept for reference only ---------- */
|
||
const CAT_BASE_OLD = {
|
||
light: { green:'#2E7D32', orange:'#B84A00', brown:'#795548', yellow:'#8A6A00', blue:'#1565C0', teal:'#00796B', purple:'#7B1FA2', red:'#C62828', pink:'#AD1457', grey:'#5F6B73', indigo:'#3949AB', lime:'#5B6B00' },
|
||
dark: { green:'#81C784', orange:'#FFB74D', brown:'#C8B4AC', yellow:'#F2D45C', blue:'#90CAF9', teal:'#80CBC4', purple:'#D1A0DB', red:'#EF9A9A', pink:'#F48FB1', grey:'#B0BEC5', indigo:'#A5AEE0', lime:'#C5D86D' }
|
||
};
|
||
const TOK_OLD = {
|
||
light: {
|
||
'primary':'#B23A1C','on-primary':'#FFFFFF','primary-container':'#FFDBD0','on-primary-container':'#3D0B00',
|
||
'secondary':'#4B6A2A','on-secondary':'#FFFFFF','secondary-container':'#D3EFAE','on-secondary-container':'#112000',
|
||
'tertiary':'#7A5900','on-tertiary':'#FFFFFF','tertiary-container':'#FFDF8E','on-tertiary-container':'#261A00',
|
||
'error':'#BA1A1A','on-error':'#FFFFFF','error-container':'#FFDAD6','on-error-container':'#410002',
|
||
'surface':'#FFF8F1','on-surface':'#231918','surface-variant':'#F5DED6','on-surface-variant':'#53433F',
|
||
'surface-container-lowest':'#FFFFFF','surface-container-low':'#FFF1E8','surface-container':'#FAEADF','surface-container-high':'#F4E4D8','surface-container-highest':'#EEDED2',
|
||
'surface-dim':'#E6D6CB','surface-bright':'#FFF8F1','inverse-surface':'#392E2C','inverse-on-surface':'#FFEDE7','inverse-primary':'#FFB59F',
|
||
'outline':'#85736E','outline-variant':'#D8C2BB','scrim':'#000000',
|
||
'owed':'#1B6B45','on-owed':'#FFFFFF','owed-container':'#C4EFD2','on-owed-container':'#00210F',
|
||
'owing':'#7E2F84','on-owing':'#FFFFFF','owing-container':'#F9D7F6','on-owing-container':'#33003A',
|
||
'settled':'#6E5500','settled-container':'#FFE38F','on-settled-container':'#241A00',
|
||
'warning':'#8A4D00','on-warning':'#FFFFFF','warning-container':'#FFDDBA','on-warning-container':'#2C1600',
|
||
'pending':'#00658F',
|
||
'avatar-1':'#9A4A00','on-avatar-1':'#FFFFFF','avatar-2':'#2C5F9E','on-avatar-2':'#FFFFFF',
|
||
'chart-cat-1':'#C2462A','chart-cat-2':'#4F7A2B','chart-cat-3':'#8C6200','chart-cat-4':'#2D63AE','chart-cat-5':'#8A3F92','chart-cat-6':'#00796F','chart-cat-7':'#B0306A','chart-cat-8':'#5B6670',
|
||
'chart-seq-1':'#FBD8CC','chart-seq-2':'#F4A68E','chart-seq-3':'#E3765A','chart-seq-4':'#C24A2E','chart-seq-5':'#8E2A14',
|
||
'chart-grid':'#EBDACF','chart-axis-label':'#53433F',
|
||
/* Kalas extras (not Material roles) */
|
||
'card':'#FFFFFF','on-cat':'#FFFFFF','shadow':'rgba(110,45,20,.10)',
|
||
'photo-1':'#FFC48F','photo-2':'#E0603F','photo-fg':'#7A2914',
|
||
'sys-wall-1':'#D9D4DE','sys-wall-2':'#C9C3D0','sys-card':'#F7F2FA','sys-on':'#1C1B1F','sys-on-variant':'#49454F'
|
||
},
|
||
dark: {
|
||
'primary':'#FFB59F','on-primary':'#5E1604','primary-container':'#8A2A10','on-primary-container':'#FFDBD0',
|
||
'secondary':'#B2D38A','on-secondary':'#1F3701','secondary-container':'#344F15','on-secondary-container':'#D3EFAE',
|
||
'tertiary':'#F2C14E','on-tertiary':'#402D00','tertiary-container':'#5A3F00','on-tertiary-container':'#FFDF8E',
|
||
'error':'#FFB4AB','on-error':'#690005','error-container':'#93000A','on-error-container':'#FFDAD6',
|
||
'surface':'#1D1415','on-surface':'#F1DFDB','surface-variant':'#53433F','on-surface-variant':'#D8C2BB',
|
||
'surface-container-lowest':'#170F10','surface-container-low':'#251B1C','surface-container':'#2A1F20','surface-container-high':'#352A2A','surface-container-highest':'#403434',
|
||
'surface-dim':'#1D1415','surface-bright':'#463A3A','inverse-surface':'#F1DFDB','inverse-on-surface':'#392E2C','inverse-primary':'#B23A1C',
|
||
'outline':'#A08C87','outline-variant':'#53433F','scrim':'#000000',
|
||
'owed':'#8BD8A8','on-owed':'#003920','owed-container':'#0F5233','on-owed-container':'#A7F5C3',
|
||
'owing':'#F2AEF0','on-owing':'#4C0F53','owing-container':'#66226C','on-owing-container':'#FFD6F8',
|
||
'settled':'#F2D45C','settled-container':'#5A3F00','on-settled-container':'#FFE38F',
|
||
'warning':'#FFB86E','on-warning':'#4A2800','warning-container':'#6A3B00','on-warning-container':'#FFDDBA',
|
||
'pending':'#86CFFF',
|
||
'avatar-1':'#FFB870','on-avatar-1':'#2E1500','avatar-2':'#A8C8FF','on-avatar-2':'#002F64',
|
||
'chart-cat-1':'#FF9F86','chart-cat-2':'#A9D37F','chart-cat-3':'#F2C14E','chart-cat-4':'#9CC2FF','chart-cat-5':'#E2A6EA','chart-cat-6':'#7FD3C8','chart-cat-7':'#FF9EC3','chart-cat-8':'#B7C3CC',
|
||
'chart-seq-1':'#4A2A23','chart-seq-2':'#7A3B2C','chart-seq-3':'#B5563C','chart-seq-4':'#E8826A','chart-seq-5':'#FFB59F',
|
||
'chart-grid':'#3E3233','chart-axis-label':'#D8C2BB',
|
||
'card':'#2A1F20','on-cat':'#1D1415','shadow':'rgba(0,0,0,.28)',
|
||
'photo-1':'#D98C5F','photo-2':'#9E3E25','photo-fg':'#4A170A',
|
||
'sys-wall-1':'#26242B','sys-wall-2':'#141318','sys-card':'#2B2930','sys-on':'#E6E1E5','sys-on-variant':'#CAC4D0'
|
||
}
|
||
};
|
||
for (const [T, B] of [[TOK, CAT_BASE], [TOK_OLD, CAT_BASE_OLD]]) {
|
||
for (const th of ['light', 'dark']) {
|
||
for (const [k, v] of Object.entries(B[th])) {
|
||
T[th]['cat-' + k] = v;
|
||
T[th]['cat-' + k + '-container'] = mix(v, T[th]['surface'], CAT_CONTAINER_MIX[th]);
|
||
}
|
||
}
|
||
}
|
||
document.getElementById('tokens').textContent = ['light', 'dark'].map(th =>
|
||
`.phone[data-theme="${th}"],.tk[data-theme="${th}"]{` + Object.entries(TOK[th]).map(([k, v]) => `--${k}:${v}`).join(';') + '}\n' +
|
||
`.phone.old[data-theme="${th}"]{` + Object.entries(TOK_OLD[th]).map(([k, v]) => `--${k}:${v}`).join(';') + '}').join('\n');
|
||
|
||
/* CIEDE2000 between category tile colours (review page) */
|
||
function toLab(h){
|
||
const [r, g, b] = hexToRgb(h).map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||
const X = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, Y = 0.2126 * r + 0.7152 * g + 0.0722 * b, Z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883;
|
||
const f = t => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
||
return [116 * f(Y) - 16, 500 * (f(X) - f(Y)), 200 * (f(Y) - f(Z))];
|
||
}
|
||
function de2000(A, B){
|
||
const rad = Math.PI / 180, [L1, a1, b1] = A, [L2, a2, b2] = B;
|
||
const Cb = (Math.hypot(a1, b1) + Math.hypot(a2, b2)) / 2, G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
|
||
const a1p = (1 + G) * a1, a2p = (1 + G) * a2, C1 = Math.hypot(a1p, b1), C2 = Math.hypot(a2p, b2);
|
||
const h1 = (Math.atan2(b1, a1p) / rad + 360) % 360, h2 = (Math.atan2(b2, a2p) / rad + 360) % 360;
|
||
let dh = C1 * C2 === 0 ? 0 : h2 - h1; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360;
|
||
const dL = L2 - L1, dC = C2 - C1, dH = 2 * Math.sqrt(C1 * C2) * Math.sin(dh * rad / 2);
|
||
const Lb = (L1 + L2) / 2, Cbp = (C1 + C2) / 2;
|
||
const hb = C1 * C2 === 0 ? h1 + h2 : Math.abs(h1 - h2) <= 180 ? (h1 + h2) / 2 : (h1 + h2 < 360 ? (h1 + h2 + 360) / 2 : (h1 + h2 - 360) / 2);
|
||
const T = 1 - 0.17 * Math.cos((hb - 30) * rad) + 0.24 * Math.cos(2 * hb * rad) + 0.32 * Math.cos((3 * hb + 6) * rad) - 0.2 * Math.cos((4 * hb - 63) * rad);
|
||
const dth = 30 * Math.exp(-(((hb - 275) / 25) ** 2)), Rc = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
|
||
const Sl = 1 + 0.015 * (Lb - 50) ** 2 / Math.sqrt(20 + (Lb - 50) ** 2), Sc = 1 + 0.045 * Cbp, Sh = 1 + 0.015 * Cbp * T, Rt = -Math.sin(2 * dth * rad) * Rc;
|
||
return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh));
|
||
}
|
||
function catPairs(base){
|
||
const ks = Object.keys(base), out = [];
|
||
for (let i = 0; i < ks.length; i++) for (let j = i + 1; j < ks.length; j++) out.push([de2000(toLab(base[ks[i]]), toLab(base[ks[j]])), ks[i], ks[j]]);
|
||
return out.sort((a, b) => a[0] - b[0]);
|
||
}
|
||
|
||
/* ---------- type scale ---------- */
|
||
const TYPE = [
|
||
['display-large',57,64,500,-0.25],['display-medium',45,52,500,0],['display-small',36,44,600,0],
|
||
['headline-large',32,40,600,0],['headline-medium',28,36,600,0],['headline-small',24,32,600,0],
|
||
['title-large',22,28,600,0],['title-medium',16,24,600,0.15],['title-small',14,20,600,0.1],
|
||
['body-large',16,24,400,0.5],['body-medium',14,20,400,0.25],['body-small',12,16,400,0.4],
|
||
['label-large',14,20,600,0.1],['label-medium',12,16,500,0.5],['label-small',11,16,500,0.5],
|
||
['amount-hero',44,52,700,-0.5],['amount-large',28,36,700,-0.25],['amount-medium',18,24,700,0],['amount-small',15,20,600,0]
|
||
];
|
||
// Android 14+ non-linear font scaling (Compose default): styles of 24 sp and larger grow less than the scale; approximated as at most 1.5x.
|
||
const CAP = Object.fromEntries(TYPE.filter(t => t[1] >= 24).map(t => [t[0], 1.5]));
|
||
document.getElementById('type').textContent = TYPE.map(([n, s, lh, w, ls]) => {
|
||
const f = CAP[n] ? `min(var(--fs),${CAP[n]})` : 'var(--fs)';
|
||
return `.t-${n}{font-size:calc(${f}*${s}px);line-height:calc(${f}*${lh}px);font-weight:${w};letter-spacing:${ls}px${n.startsWith('amount') ? ";font-variant-numeric:tabular-nums;font-feature-settings:'tnum';white-space:nowrap" : ''}}`;
|
||
}).join('\n');
|
||
|
||
/* ---------- Expressive MaterialShapes approximated as clip-path polygons ---------- */
|
||
function shapePoly(lobes, amp, rot){
|
||
const N = 180, pts = []; let mx = 0;
|
||
for (let i = 0; i < N; i++) { const t = i / N * 2 * Math.PI; const r = 1 + amp * Math.cos(lobes * (t - rot)); pts.push([t, r]); mx = Math.max(mx, r); }
|
||
return 'polygon(' + pts.map(([t, r]) => { const k = r / mx * 0.5; return (50 + 100 * k * Math.cos(t)).toFixed(2) + '% ' + (50 + 100 * k * Math.sin(t)).toFixed(2) + '%'; }).join(',') + ')';
|
||
}
|
||
const SHAPES = {
|
||
cookie4: shapePoly(4, 0.075, Math.PI / 4), // Cookie4Sided → fallback RoundedCornerShape(30%)
|
||
clover: shapePoly(4, 0.16, Math.PI / 4), // Clover4Leaf → fallback RoundedCornerShape(16.dp)
|
||
sunny: shapePoly(8, 0.045, -Math.PI / 2), // Sunny → fallback CircleShape
|
||
cookie9: shapePoly(9, 0.055, -Math.PI / 2), // Cookie9Sided → fallback CircleShape
|
||
circle: 'circle(50%)'
|
||
};
|
||
const shp = name => `--clip:${SHAPES[name]}`;
|
||
|
||
/* ---------- icons ---------- */
|
||
const ic = (name, o = {}) => `<span class="ic${o.f ? ' f' : ''}${o.cls ? ' ' + o.cls : ''}" aria-hidden="true" style="${o.size ? 'font-size:' + o.size + 'px;' : ''}${o.color ? 'color:var(--' + o.color + ');' : ''}${o.style || ''}">${name}</span>`;
|
||
const iconBtn = (name, label, o = {}) => `<button class="ib${o.tonal ? ' tonal' : ''}" aria-label="${label}" style="${o.style || ''}">${ic(name, { f: o.f, size: o.size })}</button>`;
|
||
|
||
/* ---------- data ---------- */
|
||
const CAT = {
|
||
groceries: { sv:'Mat & hushåll', en:'Groceries', icon:'shopping_cart', c:'green' },
|
||
restaurants: { sv:'Restaurang & café', en:'Restaurants', icon:'restaurant', c:'orange' },
|
||
housing: { sv:'Boende', en:'Housing', icon:'key', c:'indigo' },
|
||
home: { sv:'Hem', en:'Home', icon:'home', c:'brown' },
|
||
utilities: { sv:'Räkningar', en:'Utilities', icon:'bolt', c:'yellow' },
|
||
insurance: { sv:'Försäkringar', en:'Insurance', icon:'shield', c:'slate' },
|
||
transport: { sv:'Transport', en:'Transport', icon:'directions_car', c:'blue' },
|
||
travel: { sv:'Resor', en:'Travel', icon:'flight', c:'teal' },
|
||
entertainment: { sv:'Nöje', en:'Entertainment', icon:'movie', c:'purple' },
|
||
health: { sv:'Hälsa', en:'Health', icon:'medical_services', c:'red' },
|
||
children: { sv:'Barn', en:'Children', icon:'child_care', c:'lime' },
|
||
shopping: { sv:'Shopping', en:'Shopping', icon:'shopping_bag', c:'pink' },
|
||
fees: { sv:'Avgifter & skatter', en:'Fees & taxes', icon:'account_balance', c:'amber' },
|
||
other: { sv:'Övrigt', en:'Other', icon:'category', c:'grey' },
|
||
pant: { sv:'Pant', en:'Pant', icon:'water_drop', c:'teal', archived:true }
|
||
};
|
||
const DEFAULT_CATS = ['groceries','restaurants','housing','home','utilities','insurance','transport','travel','entertainment','health','children','shopping','fees','other'];
|
||
const ICONS30 = ['shopping_cart','restaurant','local_cafe','home','bolt','water_drop','wifi','directions_car','train','local_gas_station','flight','hotel','luggage','movie','music_note','sports_esports','fitness_center','medical_services','medication','shopping_bag','checkroom','pets','child_care','school','card_giftcard','celebration','build','chair','subscriptions','category','key','shield','account_balance'];
|
||
const COLOR_SV = { green:'Grön', orange:'Orange', brown:'Brun', yellow:'Gul', blue:'Blå', teal:'Blågrön', purple:'Lila', red:'Röd', pink:'Rosa', grey:'Grå', indigo:'Indigo', lime:'Lime', slate:'Plommon', amber:'Sand' }; // swatch TalkBack names (color_<token>)
|
||
const COLORS12 = ['green','orange','brown','yellow','blue','teal','purple','red','pink','grey','indigo','lime','slate','amber']; // 14 tokens (slate, amber added 2026-10-18)
|
||
|
||
const ACC = {
|
||
vardag: { name:'Vardag', net: 118450, split:[50,50], n:11, chart:1 },
|
||
huset: { name:'Huset', net:-308450, split:[60,40], n:4, chart:2 },
|
||
gotland: { name:'Gotland 2026', net:0, split:[50,50], n:0, chart:3, archived:true },
|
||
japan: { name:'Japan 2027', net:-135000, split:[50,50], n:2, chart:4 }
|
||
};
|
||
ACC.sommar = { name:'Sommarstugan', net:0, split:[60,40], n:0, chart:5 };
|
||
ACC.long = { name:'Sommarstugan i Österåker 2027', net:61204594, split:[50,50], n:3, chart:6 };
|
||
const GLOBAL = ['vardag','huset','gotland','japan'].reduce((a, k) => a + ACC[k].net, 0); // −325000
|
||
|
||
const ex = (date, desc, cat, amt, payer, eff, o = {}) => ({ t:'e', date, desc, cat, amt, payer, eff, ...o });
|
||
const VARDAG = [
|
||
{ period:'Oktober', rows:[
|
||
ex('16 okt.','ICA Maxi Lindhagen','groceries',124760,'anna',['borrowed',62380]),
|
||
ex('15 okt.','Systembolaget','entertainment',41200,'jacob',['lent',20600]),
|
||
ex('14 okt.','SL-kort 30 dagar, Anna','transport',106000,'jacob',['lent',106000],{pending:true}),
|
||
ex('12 okt.','Willys Hornstull','groceries',86435,'jacob',['lent',43217]),
|
||
ex('10 okt.','Babette','restaurants',118000,'anna',['borrowed',59000]),
|
||
ex('9 okt.','Apotek Hjärtat','health',18990,'jacob',['self']),
|
||
ex('7 okt.','ICA Nära Hornstull','groceries',78560,'anna',['borrowed',39280]),
|
||
{ t:'s', date:'6 okt.', from:'anna', to:'jacob', amt:80000, accounts:1 },
|
||
ex('4 okt.','Clas Ohlson','home',45900,'anna',['borrowed',22950]),
|
||
ex('3 okt.','Netflix','entertainment',17900,'jacob',['lent',8950]),
|
||
ex('2 okt.','Pressbyrån','restaurants',6800,'anna',['none']),
|
||
ex('1 okt.','Lidl Liljeholmen','groceries',101245,'jacob',['lent',50622])
|
||
]},
|
||
{ period:'September', total: 941240, rows:[
|
||
ex('28 sep.','Coop Södermalm','groceries',73420,'anna',['borrowed',36710]),
|
||
ex('26 sep.','Hemköp','groceries',51280,'jacob',['lent',25640])
|
||
]}
|
||
];
|
||
const GOTLAND = [
|
||
{ period:'Juli', rows:[
|
||
ex('28 jul.','',null,0,null,null,{}),
|
||
]}
|
||
];
|
||
GOTLAND[0].rows = [
|
||
{ t:'s', date:'28 jul.', from:'anna', to:'jacob', amt:19000, accounts:1 },
|
||
ex('16 jul.','Stuga i Tofta, 3 nätter','travel',200000,'anna',['borrowed',100000]),
|
||
ex('13 jul.','Destination Gotland, färja tur och retur','travel',238000,'jacob',['lent',119000])
|
||
];
|
||
const LONG = [{ period:'Oktober', rows:[
|
||
ex('12 okt.','Handpenning Sommarstugan i Österåker, köpekontrakt och lagfart','housing',123456789,'jacob',['lent',61728394]),
|
||
ex('10 okt.','Byggmax Åkersberga, virke och fönsterbleck till altanen','home',1845600,'anna',['borrowed',922800]),
|
||
ex('4 okt.','Villahemförsäkring första året, Länsförsäkringar Stockholm','insurance',798000,'jacob',['lent',399000])
|
||
]}];
|
||
const monthTotal = m => m.total != null ? m.total : m.rows.filter(r => r.t === 'e').reduce((a, r) => a + r.amt, 0);
|
||
|
||
/* analytics, kronor in öre */
|
||
const MONTHS = ['maj','jun','jul','aug','sep','okt'];
|
||
const AN = {
|
||
groceries:[642000,698000,587000,661000,694000,391000], restaurants:[189000,264000,398000,172000,211000,124800],
|
||
housing:[1240000,1240000,1240000,1240000,1240000,0], home:[75000,142000,59000,106000,98000,393350], utilities:[149100,140100,125100,133100,158500,118600], insurance:[28900,28900,28900,28900,28900,28900],
|
||
transport:[106000,106000,245000,106000,106000,106000], travel:[0,125000,438000,0,1486000,1216000],
|
||
entertainment:[41000,59000,64000,38000,52000,59100], health:[0,29000,0,45000,0,18990],
|
||
children:[0,0,0,0,0,0], shopping:[0,62000,0,136000,74000,0], fees:[0,0,0,0,0,0], other:[0,0,0,0,0,0]
|
||
};
|
||
const AN_ACC = [['vardag',6695140],['huset',7986000],['japan',2702000],['gotland',438000]];
|
||
const WEEKS = ['v. 35','v. 36','v. 37','v. 38','v. 39','v. 40','v. 41','v. 42'];
|
||
const WK = { vardag:[164000,198000,131000,224000,147000,186000,215000,126000], huset:[121000,86000,212000,64000,238000,52000,117000,47000], japan:[0,0,0,0,0,140000,98000,0] };
|
||
const pct1 = (part, total) => { const v = Math.floor(part * 1000 / total + 0.5) / 10; return v.toFixed(1).replace('.', ',') + NB + '%'; }; // half-up, one decimal
|
||
|
||
/* ---------- components ---------- */
|
||
function avatar(u, size = 40, shape = 'circle'){
|
||
const sh = shape === 'circle' ? 'circle' : 'shp';
|
||
const st = `width:${size}px;height:${size}px;${shape !== 'circle' ? shp(shape) : ''}`;
|
||
if (u === 'jacob') return `<span class="av photo ${sh}" style="${st}" role="img" aria-label="Jacob"><svg viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="16.5" r="7.2" style="fill:var(--photo-fg)"/><path d="M4.5 42c1.4-9.4 7.6-14 15.5-14s14.1 4.6 15.5 14z" style="fill:var(--photo-fg)"/></svg></span>`;
|
||
if (u === 'anna-photo') return `<span class="av photo ${sh}" style="${st};background:linear-gradient(150deg,color-mix(in srgb,var(--avatar-2) 45%,var(--surface)),var(--avatar-2))" role="img" aria-label="Anna"><svg viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="16.5" r="7.2" style="fill:var(--on-avatar-2);opacity:.7"/><path d="M4.5 42c1.4-9.4 7.6-14 15.5-14s14.1 4.6 15.5 14z" style="fill:var(--on-avatar-2);opacity:.7"/></svg></span>`;
|
||
if (u === 'empty') return `<span class="av circle" style="width:${size}px;height:${size}px;border:2px dashed var(--outline);background:var(--surface-container-high);color:var(--on-surface-variant)" aria-label="Partner saknas">${ic('person_add', { size: Math.round(size * 0.5) })}</span>`;
|
||
const ini = u === 'anna' ? 'A' : 'J';
|
||
return `<span class="av ini ${u === 'anna' ? '' : 'a1'} ${sh}" style="${st};font-size:calc(min(var(--fs),1.3)*${Math.round(size * 0.44)}px);line-height:1" role="img" aria-label="${u === 'anna' ? 'Anna' : 'Jacob'}">${ini}</span>`;
|
||
}
|
||
function catTile(key, size = 40, o = {}){
|
||
const c = CAT[key]; const solid = o.solid !== false;
|
||
const bg = solid ? `var(--cat-${c.c})` : `var(--cat-${c.c}-container)`;
|
||
const fg = solid ? 'var(--on-cat)' : `var(--cat-${c.c})`;
|
||
return `<span class="tile shp" style="width:${size}px;height:${size}px;${shp(o.shape || 'cookie4')};background:${bg};color:${fg};${o.muted ? 'filter:saturate(.25);opacity:.75;' : ''}">${ic(c.icon, { size: Math.round(size * (o.iconRatio || 0.52)) })}</span>`;
|
||
}
|
||
function accTile(key, size = 44, o = {}){
|
||
const a = ACC[key];
|
||
return `<span class="tile shp atile" style="width:${size}px;height:${size}px;${shp('clover')};background:var(--chart-cat-${a.chart});${o.muted ? 'opacity:.7;' : ''}" aria-hidden="true">${a.name[0]}</span>`;
|
||
}
|
||
const settleTile = (size = 40) => `<span class="tile" style="width:${size}px;height:${size}px;border-radius:999px;background:var(--primary-container);color:var(--on-primary-container)">${ic('handshake', { f: true, size: Math.round(size * 0.52) })}</span>`;
|
||
function btn(label, o = {}){
|
||
const k = o.kind || 'filled';
|
||
return `<button class="btn ${k}${o.m ? ' m' : ''}${o.full ? ' full' : ''}${o.dis ? ' dis' : ''}" ${o.dis ? 'disabled aria-disabled="true"' : ''} style="${o.style || ''}">${o.icon ? ic(o.icon, { f: o.iconF }) : ''}<span>${label}</span></button>`;
|
||
}
|
||
function chip(label, o = {}){
|
||
let lead = '';
|
||
if (o.icon) lead = ic(o.icon, { f: o.iconF });
|
||
if (o.dot) lead = `<span class="lead-dot" style="background:var(--${o.dot})"></span>`;
|
||
if (o.avatar) lead = avatar(o.avatar, 24);
|
||
if (o.sel && !o.icon && !o.dot && !o.avatar) lead = ic('check', { size: 18 });
|
||
return `<button class="chip${o.sel ? ' sel' : ''}${o.out ? ' out' : ''}" ${o.aria ? `aria-label="${o.aria}"` : ''} style="${o.style || ''}">${lead}<span>${o.k ? `<span class="k">${o.k} </span>` : ''}${label}</span>${o.trail ? ic(o.trail, { size: 18 }) : o.drop ? ic(o.drop === 'up' ? 'expand_less' : 'expand_more', { size: 18 }) : ''}</button>`;
|
||
}
|
||
function seg(items, on){ return `<div class="seg" role="radiogroup">${items.map((t, i) => `<span role="radio" aria-checked="${i === on}" class="${i === on ? 'on' : ''}">${t}</span>`).join('')}</div>`; }
|
||
function field(o){
|
||
const cls = ['fld', o.focus && 'focus', o.err && 'err', o.ro && 'ro', o.dis && 'dis'].filter(Boolean).join(' ');
|
||
const val = o.value != null && o.value !== '' ? `<span class="val"><span class="${o.valCls || ''}">${o.value}</span>${o.focus && o.caret !== false ? '<span class="caret"></span>' : ''}</span>` : `<span class="val ph">${o.focus ? '<span class="caret" style="margin:0 1px 0 0"></span>' : ''}${o.ph || ''}</span>`;
|
||
const sup = o.sup ? `<div class="sup${o.err ? ' err' : ''}">${o.err ? ic('error', { f: true }) : ''}<span>${o.sup}</span></div>` : '';
|
||
return `<div><div class="${cls}">${o.lead || ''}<div class="fv">${o.label ? `<span class="lab">${o.label}</span>` : ''}${val}</div>${o.trail || ''}</div>${sup}</div>`;
|
||
}
|
||
function topBar(o = {}){
|
||
const nav = o.nav === 'close' ? iconBtn('close', 'Avbryt') : o.nav === null ? '' : iconBtn('arrow_back', 'Tillbaka');
|
||
return `<header class="tab" style="${o.bg ? 'background:var(--' + o.bg + ')' : ''}">${nav}<div class="tab-t${o.nav === null ? ' pl' : ''}">${o.titleHtml || `<div class="t-title-large ell">${o.title || ''}</div>`}${o.sub ? `<div class="t-body-small muted ell">${o.sub}</div>` : ''}</div><div class="tab-a">${o.actions || ''}</div></header>`;
|
||
}
|
||
function navBar(active, en){
|
||
const items = en ? [['home','Home'],['bar_chart','Analytics'],['settings','Settings']] : [['home','Översikt'],['bar_chart','Statistik'],['settings','Inställningar']];
|
||
return `<nav class="nav" aria-label="${en ? 'Navigation' : 'Navigering'}">${items.map(([i, l], k) => `<a class="ni${k === active ? ' on' : ''}" ${k === active ? 'aria-current="page"' : ''}><span class="ind">${ic(i)}</span><span>${l}</span></a>`).join('')}</nav>`;
|
||
}
|
||
function fab(label, o = {}){ if (o.dis || o.bottom) { const b = o.bottom || (o.nav ? 64 + 24 + 16 : 24 + 16); return `<button class="fab${o.dis ? ' dis' : ''}" ${o.dis ? 'disabled aria-disabled="true"' : ''} style="bottom:${b}px">${ic(o.icon || 'add', { size: 24 })}<span>${label}</span></button>`; } return o.small ? `<button class="fab" aria-label="${label}" style="bottom:${o.nav ? 64 + 24 + 16 : 24 + 16}px;width:56px;padding:0;justify-content:center">${ic(o.icon || 'add', { size: 24 })}</button>` : `<button class="fab" style="bottom:${o.nav ? 64 + 24 + 16 : 24 + 16}px">${ic(o.icon || 'add', { size: 24 })}<span>${label}</span></button>`; }
|
||
function screen(o){
|
||
return `<div class="scr" style="${o.bg ? 'background:var(--' + o.bg + ')' : ''}">${o.top || ''}<div class="body">${o.raw ? o.body : `<div class="pad" style="${o.padStyle || ''}${o.scroll ? ';transform:translateY(-' + o.scroll + 'px)' : ''}">${o.body}</div>`}</div>${o.bottom || ''}${o.nav != null ? navBar(o.nav, o.en) : '<div class="ins"></div>'}${o.fab || ''}${o.overlay || ''}</div>`;
|
||
}
|
||
const splitBar = (p, o = {}) => `<div class="split${o.thin ? ' thin' : ''}${o.err ? ' err' : ''}" role="img" aria-label="${o.aria || ''}">${p > 0 ? `<span class="sa" style="flex:0 0 calc(${p}% - ${o.knob ? 8 : 3}px)"></span>` : ''}${o.knob ? '<span class="knob"></span>' : ''}${p < 100 ? '<span class="sbb"></span>' : ''}</div>`;
|
||
|
||
/* effect line */
|
||
function effect(e, en){
|
||
if (!e) return '';
|
||
if (e[0] === 'lent') return `<span class="c-owed">${en ? 'You lent ' + sek(e[1]) : 'Du lånade ut ' + kr(e[1])}</span>`;
|
||
if (e[0] === 'borrowed') return `<span class="c-owing">${en ? 'You borrowed ' + sek(e[1]) : 'Du lånade ' + kr(e[1])}</span>`;
|
||
if (e[0] === 'none') return `<span class="muted">Inte inblandad</span>`;
|
||
return `<span class="muted">Betalade för dig själv</span>`;
|
||
}
|
||
function ledgerRow(r){
|
||
if (r.t === 's') {
|
||
const who = r.from === 'anna' ? 'Anna betalade dig ' + kr(r.amt) : 'Du betalade Anna ' + kr(r.amt);
|
||
return `<div class="it row r72" style="background:color-mix(in srgb,var(--primary-container) 28%,var(--card))">${settleTile(40)}<div class="rc"><div class="t-title-medium">${who}${r.accounts > 1 ? ' · ' + r.accounts + ' konton' : ''}</div><div class="t-body-medium muted">Betalning</div></div><div class="rr"><span class="t-body-small muted">${r.date}</span></div></div>`;
|
||
}
|
||
return `<div class="it row r72">${catTile(r.cat, 40)}<div class="rc"><div class="t-title-medium ell">${r.desc}</div><div class="t-body-medium">${effect(r.eff)}</div></div><div class="rr"><span class="t-amount-small">${kr(r.amt)}</span><span class="rmeta t-body-small muted">${r.pending ? `<span class="c-pend" role="img" aria-label="Väntar på synk">${ic('cloud_upload', { size: 15 })}</span>` : ''}${r.date}</span></div></div>`;
|
||
}
|
||
function ledger(months){
|
||
return months.map(m => `<div class="mh"><span class="t-title-small">${m.period} · ${kr(monthTotal(m))}</span></div><div class="grp">${m.rows.map(ledgerRow).join('')}</div>`).join('');
|
||
}
|
||
|
||
/* balance hero (seesaw) */
|
||
function seesaw(state, o = {}){
|
||
// state: owing (viewer lower), owed (partner lower), settled (level), waiting (partner missing)
|
||
const tilt = state === 'owing' ? -9 : state === 'owed' ? 9 : state === 'waiting' ? -12 : 0;
|
||
const rad = tilt * Math.PI / 180, d = 88, av = o.av || 46;
|
||
const dyL = -d * Math.sin(rad), dyR = d * Math.sin(rad);
|
||
const plankColor = state === 'owing' ? 'var(--owing)' : state === 'owed' ? 'var(--owed)' : state === 'settled' ? 'var(--settled)' : 'var(--outline)';
|
||
const top = 66 - av - 4;
|
||
const seat = (u, nm, dx, dy) => `<div class="seat" style="left:${125 + dx - av / 2}px;top:${top + dy}px">${avatar(u, av, 'sunny')}</div>`;
|
||
const label = (nm, dx, dy) => `<span class="nm" style="position:absolute;${dx < 0 ? 'right:' + (250 - (125 + dx - av / 2) + 6) + 'px;text-align:right' : 'left:' + (125 + dx + av / 2 + 6) + 'px;text-align:left'};width:60px;top:${top + dy + av / 2 - 8}px">${nm}</span>`;
|
||
const right = o.partner === 'empty' ? 'empty' : 'anna';
|
||
return `<div class="see" aria-hidden="true"><div class="fulc"></div><div class="plank" style="background:${plankColor};transform:rotate(${tilt}deg)"></div>${seat('jacob', 'Du', -d, dyL)}${seat(right, 'Anna', d, dyR)}${label(o.en ? 'You' : 'Du', -d, dyL)}${label(o.partner === 'empty' ? 'Partner' : 'Anna', d, dyR)}</div>`;
|
||
}
|
||
function bunting(){
|
||
const cols = ['green','orange','yellow','blue','teal','purple','red','pink','lime','orange','green','blue','yellow','pink'];
|
||
const n = cols.length, w = 379 / n;
|
||
let s = `<svg viewBox="0 0 379 30" preserveAspectRatio="none" aria-hidden="true"><path d="M0 4 Q189 16 379 4" style="fill:none;stroke:var(--outline);stroke-width:1.2"/>`;
|
||
cols.forEach((c, i) => { const x = i * w + 3, y = 4 + 12 * Math.sin(Math.PI * (i + 0.5) / n) * 0.95; s += `<path d="M${x.toFixed(1)} ${y.toFixed(1)} L${(x + w - 6).toFixed(1)} ${y.toFixed(1)} L${(x + w / 2 - 3).toFixed(1)} ${(y + 15).toFixed(1)}Z" style="fill:var(--cat-${c})"/>`; });
|
||
return `<div class="bunting">${s}</svg></div>`;
|
||
}
|
||
function heroAmount(ore, en, color){
|
||
return en ? `<div class="hero-amt" style="color:var(--${color})"><span class="u">SEK</span><span class="t-amount-hero">${sekn(ore)}</span></div>`
|
||
: `<div class="hero-amt" style="color:var(--${color})"><span class="t-amount-hero">${krn(ore)}</span><span class="u">kr</span></div>`;
|
||
}
|
||
function balanceHero(net, o = {}){
|
||
const en = o.en;
|
||
if (net === 0) {
|
||
return `<section class="card hero" style="background:var(--settled-container);color:var(--on-settled-container);padding-top:28px" aria-label="${en ? 'All settled up' : 'Ni är kvitt'}">${bunting()}${seesaw('settled', o)}<div class="t-headline-medium" style="margin-top:8px">${en ? 'All settled up' : 'Ni är kvitt'}</div><div class="t-body-medium" style="margin-top:2px">${en ? 'Nobody owes anything right now.' : 'Ingen är skyldig något just nu.'}</div></section>`;
|
||
}
|
||
const owing = net < 0, col = owing ? 'owing' : 'owed';
|
||
const words = owing ? (en ? 'You owe Anna' : 'Du är skyldig Anna') : (en ? 'Anna owes you' : 'Anna är skyldig dig');
|
||
return `<section class="card hero" aria-label="${words} ${en ? sek(net) : kr(net)}">${seesaw(col, o)}<div class="words t-title-medium" style="color:var(--${col})">${words}</div>${heroAmount(net, en, col)}${o.noButton ? '' : btn(en ? 'Settle up' : 'Gör upp', { icon: 'handshake', iconF: true })}</section>`;
|
||
}
|
||
function accountCard(key, o = {}){
|
||
const a = ACC[key], en = o.en, net = o.settled ? 0 : a.net;
|
||
const n = o.count != null ? o.count : a.n;
|
||
const cnt = en ? `${n} expense${n === 1 ? '' : 's'} in October` : `${n} ${n === 1 ? 'utgift' : 'utgifter'} i oktober`;
|
||
let bal;
|
||
if (net === 0) bal = `<span class="t-title-medium c-settled hstack" style="gap:4px">${ic('check_circle', { f: true, size: 20 })}${en ? 'Settled up' : 'Kvitt'}</span>`;
|
||
else {
|
||
const col = net < 0 ? 'owing' : 'owed';
|
||
const w = net < 0 ? (en ? 'You owe Anna' : 'Du är skyldig Anna') : (en ? 'Anna owes you' : 'Anna är skyldig dig');
|
||
bal = `<span class="t-label-medium" style="color:var(--${col})">${w}</span><span class="t-amount-medium" style="color:var(--${col})">${en ? sek(net) : kr(net)}</span>`;
|
||
}
|
||
return `<div class="card acard${a.archived ? ' arch' : ''}" style="border-radius:24px">${accTile(key, 44, { muted: a.archived })}<div class="an"><div class="t-title-medium ell">${a.name}${a.archived ? ` <span class="badge">${ic('inventory_2', { size: 14 })}${en ? 'Archived' : 'Arkiverat'}</span>` : ''}</div><div class="t-body-small muted">${a.archived ? (en ? 'No expenses in October' : 'Inga utgifter i oktober') : cnt}</div></div><div class="ab">${bal}</div></div>`;
|
||
}
|
||
function syncLine(o = {}){
|
||
if (o.offline) return `<button class="syncl pend" aria-label="Synkstatus: Offline, 2 ändringar väntar på synk">${ic('cloud_off', { color: 'pending' })}<span>Offline · 2 ändringar väntar på synk</span>${ic('chevron_right', { size: 18 })}</button>`;
|
||
return `<button class="syncl">${ic('cloud_done', { color: 'owed' })}<span>${o.en ? 'Synced 3 min ago' : 'Synkad för 3 min sedan'}</span>${ic('chevron_right', { size: 18 })}</button>`;
|
||
}
|
||
function wavy(p, w = 300){
|
||
const end = w * p / 100; let d = 'M2 7';
|
||
for (let x = 2; x <= end; x += 2) d += ` L${x.toFixed(1)} ${(7 + 3 * Math.sin(x / 5)).toFixed(2)}`;
|
||
return `<svg width="100%" height="14" viewBox="0 0 ${w} 14" aria-hidden="true"><path d="${d}" style="fill:none;stroke:var(--tertiary);stroke-width:4;stroke-linecap:round"/><line x1="${(end + 8).toFixed(1)}" y1="7" x2="${w - 2}" y2="7" style="stroke:color-mix(in srgb,var(--tertiary) 28%,transparent);stroke-width:4;stroke-linecap:round"/><circle cx="${w - 2}" cy="7" r="2" style="fill:var(--tertiary)"/></svg>`;
|
||
}
|
||
function updateBanner(kind, off){
|
||
if (kind === 'failed') return `<div class="banner err" role="alert"><div class="bt">${ic('error', { f: true })}<div class="grow"><div class="t-title-small">Version 1.2 finns</div><div class="t-body-medium">Nedladdningen misslyckades. Försök igen.</div></div></div><div class="ba">${btn('Senare', { kind: 'text' })}${btn('Försök igen', { kind: 'err-filled', icon: 'refresh' })}</div></div>`;
|
||
if (kind === 'progress') return `<div class="banner lantern"><div class="bt">${ic('downloading', { f: true })}<div class="grow"><div class="t-title-small">Version 1.2 finns</div><div class="t-body-medium">Laddar ner… 64${NB}%</div></div></div><div style="padding:4px 0 8px" role="progressbar" aria-valuenow="64" aria-valuemin="0" aria-valuemax="100" aria-label="Laddar ner version 1.2">${wavy(64, 340)}</div></div>`;
|
||
return `<div class="banner lantern"><div class="bt">${ic('system_update', { f: true })}<div class="grow"><div class="t-title-small">Version 1.2 finns</div><div class="t-body-medium">${off ? 'Kräver anslutning' : 'Nyheter och rättningar · 8,4 MB'}</div></div></div><div class="ba">${btn('Senare', { kind: 'text' })}${btn('Uppdatera', { kind: 'tert', icon: 'download', dis: off })}</div></div>`;
|
||
}
|
||
function archivedRow(open, en){
|
||
return `<button class="hstack" style="padding:6px 8px;min-height:48px;gap:10px;color:var(--on-surface-variant)">${ic('inventory_2')}<span class="t-title-small grow" style="color:var(--on-surface)">${en ? 'Archived (1)' : 'Arkiverade (1)'}</span>${ic(open ? 'expand_less' : 'expand_more')}</button>`;
|
||
}
|
||
|
||
function snack(text, action, o = {}){ return `<div class="snack" role="status" style="bottom:${o.bottom || (o.nav ? 64 + 24 + 12 : 24 + 12)}px"><span class="grow t-body-medium">${text}</span>${action ? `<button class="btn text" style="padding:8px 12px">${action}</button>` : ''}</div>`; }
|
||
function spinner(size = 20){ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" aria-hidden="true" style="flex:none"><circle cx="12" cy="12" r="9" style="fill:none;stroke:currentColor;stroke-opacity:.25;stroke-width:3"/><path d="M12 3a9 9 0 0 1 9 9" style="fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round"/></svg>`; }
|
||
const needsHint = (text = 'Kräver anslutning') => `<div class="hstack t-body-small muted" style="gap:6px;padding:4px 16px;font-weight:600">${ic('cloud_off', { size: 16 })}<span>${text}</span></div>`;
|
||
function offlineBanner(text){ return `<div class="banner warn" role="status"><div class="bt">${ic('cloud_off')}<div class="grow t-body-medium">${text}</div></div></div>`; }
|
||
function emptyState(o){ return `<section class="flat" style="padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center">${o.art}<h2 class="t-headline-small">${o.title}</h2>${o.body ? `<p class="t-body-large muted">${o.body}</p>` : ''}${o.action || ''}</section>`; }
|
||
const bigIcon = (name, bg, fg, shape = 'cookie9') => `<span class="shp" style="width:96px;height:96px;${shp(shape)};background:var(--${bg});color:var(--${fg});display:grid;place-items:center">${ic(name, { size: 44 })}</span>`;
|
||
function loadingIndicator(){ return `<div class="shp" role="progressbar" style="width:56px;height:56px;background:var(--primary-container);${shp('cookie9')};display:grid;place-items:center"><span class="shp" style="width:30px;height:30px;background:var(--primary);${shp('sunny')};transform:rotate(20deg)"></span></div>`; }
|
||
|
||
/* ---------- screens ---------- */
|
||
function scrHome(o = {}){
|
||
const en = o.lang === 'en';
|
||
let b = '';
|
||
if (o.update) b += updateBanner(o.update, o.offline);
|
||
b += balanceHero(o.settled ? 0 : GLOBAL, { en });
|
||
b += syncLine({ offline: o.offline, en });
|
||
b += `<div class="sec-h"><h2 class="t-title-large">${en ? 'Accounts' : 'Konton'}</h2>${o.offline ? `<div class="vstack" style="align-items:flex-end">${btn('Nytt konto', { kind: 'text', icon: 'add', dis: true })}<span class="t-body-small muted" style="padding:0 12px;font-weight:600">Kräver anslutning</span></div>` : btn(en ? 'New account' : 'Nytt konto', { kind: 'text', icon: 'add' })}</div>`;
|
||
b += ['vardag', 'huset', 'japan'].map(k => accountCard(k, { en, settled: o.settled })).join('');
|
||
b += archivedRow(o.archOpen, en);
|
||
if (o.archOpen) b += accountCard('gotland', { en });
|
||
return screen({ top: topBar({ nav: null, bg: o.big ? 'surface-container' : '', titleHtml: `<div class="wordmark"><span class="dot shp" style="${shp('sunny')}">${ic('receipt_long', { f: true })}</span>Kvitt</div>`, actions: '' }), body: b, nav: 0, en, scroll: o.big ? 90 : 0, fab: fab(en ? 'Add expense' : 'Lägg till utgift', { nav: true, small: o.big }) });
|
||
}
|
||
function scrHomeEmpty(){
|
||
const pile = `<div style="position:relative;width:170px;height:110px;margin:6px auto 4px" aria-hidden="true">
|
||
<div style="position:absolute;left:8px;top:40px;transform:rotate(-14deg)">${catTile('groceries', 64, { solid: true })}</div>
|
||
<div style="position:absolute;left:56px;top:6px;transform:rotate(6deg)">${catTile('travel', 72, { solid: true })}</div>
|
||
<div style="position:absolute;left:104px;top:44px;transform:rotate(16deg)">${catTile('restaurants', 58, { solid: true })}</div></div>`;
|
||
const b = `<section class="card" style="padding:18px 20px;display:flex;align-items:center;gap:14px"><div style="display:flex">${avatar('jacob', 44, 'sunny')}<span style="margin-left:-10px">${avatar('anna', 44, 'sunny')}</span></div><div class="grow"><div class="t-title-medium">Du och Anna är med</div><div class="t-body-medium muted">Hushållet är klart. Nu behövs ett konto.</div></div></section>
|
||
<section class="flat" style="padding:28px 24px 28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:8px">${pile}<h2 class="t-headline-small">Skapa ert första konto</h2><p class="t-body-large muted">Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.</p>${btn('Nytt konto', { m: true, icon: 'add' })}</section>`;
|
||
return screen({ top: topBar({ nav: null, titleHtml: `<div class="wordmark"><span class="dot shp" style="${shp('sunny')}">${ic('receipt_long', { f: true })}</span>Kvitt</div>` }), body: b, nav: 0 });
|
||
}
|
||
function scrHomeLoading(){
|
||
const b = `<section class="card hero" style="gap:10px;padding:28px 20px"><div class="shp" role="progressbar" aria-label="Hämtar era utgifter" style="width:56px;height:56px;background:var(--primary-container);${shp('cookie9')};display:grid;place-items:center"><span class="shp" style="width:30px;height:30px;background:var(--primary);${shp('sunny')};transform:rotate(20deg)"></span></div><div class="t-title-medium">Hämtar era utgifter…</div><div class="t-body-medium muted" style="max-width:280px">Första synken hämtar allt som finns på servern. Det tar bara en stund.</div></section>
|
||
<div class="sk" style="height:40px;width:200px;align-self:center;border-radius:999px"></div>
|
||
<div class="sec-h"><h2 class="t-title-large">Konton</h2></div>
|
||
${[0, 1, 2].map(i => `<div class="card acard" style="border-radius:24px"><span class="sk" style="width:44px;height:44px;border-radius:14px"></span><div class="an vstack" style="gap:8px"><span class="sk" style="height:14px;width:${[110, 80, 120][i]}px"></span><span class="sk" style="height:10px;width:140px"></span></div><div class="ab vstack" style="gap:8px"><span class="sk" style="height:10px;width:90px"></span><span class="sk" style="height:14px;width:80px"></span></div></div>`).join('')}`;
|
||
return screen({ top: topBar({ nav: null, titleHtml: `<div class="wordmark"><span class="dot shp" style="${shp('sunny')}">${ic('receipt_long', { f: true })}</span>Kvitt</div>` }), body: b, nav: 0 });
|
||
}
|
||
function scrWaiting(off){
|
||
const b = `<section class="card hero" style="padding:28px 22px 24px;gap:6px">${seesaw('waiting', { partner: 'empty' })}<h2 class="t-headline-small" style="margin-top:12px">Väntar på din partner</h2><p class="t-body-large muted">Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk.</p>${btn('Bjud in partner', { m: true, icon: 'person_add', style: 'margin-top:14px', dis: off })}${off ? needsHint() : ''}</section>
|
||
${off ? '' : `<div class="grp"><div class="it row">${ic('schedule', { color: 'on-surface-variant' })}<div class="rc"><div class="t-title-small">Inbjudan skickad</div><div class="t-body-medium muted">Inbjudan gäller till mån 19 okt. 21:40</div></div></div></div>`}
|
||
<p class="t-body-medium muted" style="padding:4px 8px">Konton, utgifter och betalningar blir tillgängliga när din partner har gått med.</p>`;
|
||
return screen({ top: topBar({ nav: null, titleHtml: `<div class="wordmark"><span class="dot shp" style="${shp('sunny')}">${ic('receipt_long', { f: true })}</span>Kvitt</div>` }), body: b, nav: 0 });
|
||
}
|
||
function brand(){
|
||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px;padding:28px 0 12px"><span class="shp" style="width:88px;height:88px;${shp('sunny')};background:var(--primary);display:grid;place-items:center;color:var(--on-primary)">${ic('receipt_long', { f: true, size: 44 })}</span><div class="t-display-small" style="color:var(--primary)">Kvitt</div><div class="t-title-medium muted">Delade utgifter för två</div></div>`;
|
||
}
|
||
function scrPairing(mode){
|
||
const err = mode === 'error', load = mode === 'loading', again = mode === 'again';
|
||
const paste = load ? '' : `<button class="btn text" style="padding:8px 10px" aria-label="Klistra in">${ic('content_paste', { size: 20 })}<span>Klistra in</span></button>`;
|
||
const head = again ? `<div style="display:flex;flex-direction:column;align-items:center;gap:8px;padding:28px 0 12px;text-align:center"><span class="shp" style="width:88px;height:88px;${shp('sunny')};background:var(--primary);display:grid;place-items:center;color:var(--on-primary)">${ic('receipt_long', { f: true, size: 44 })}</span><div class="t-display-small" style="color:var(--primary)">Kvitt</div><h1 class="t-headline-small">Para ihop telefonen igen</h1><p class="t-body-large muted">Dina utgifter finns kvar på telefonen.</p></div>` : brand();
|
||
const code = again ? field({ label: 'Inbjudningskod', ph: 'XXXX-XXXX-XXXX', focus: true, trail: paste }) :
|
||
field({ label: 'Inbjudningskod', value: '7K3M-QX9D-2HPA', valCls: 't-title-large', trail: paste, err, ro: load, sup: err ? 'Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny.' : null });
|
||
const name = again ? '' : field({ label: 'Ditt namn', value: 'Anna', ro: load });
|
||
const help = again ? 'Koden får du från en länk, eller så skrivs den ut när servern sätts upp.' : 'Koden får du av Jacob: från en länk eller från Inställningar › Bjud in partner.';
|
||
const b = `${head}
|
||
${code}
|
||
${name}
|
||
<div class="hstack t-body-medium muted" style="align-items:flex-start;padding:0 8px;gap:10px">${ic('info', { size: 20 })}<span>${help}</span></div>
|
||
<button class="hstack" style="padding:8px 8px;min-height:56px;gap:12px;border-radius:16px;background:var(--surface-container-low);${load ? 'opacity:.6' : ''}">${ic('dns', { color: 'on-surface-variant' })}<span class="grow"><span class="t-label-medium muted" style="display:block">Serveradress</span><span class="t-body-medium" style="overflow-wrap:anywhere">https://icehack.asuscomm.com:8443</span></span>${ic('expand_more', { color: 'on-surface-variant' })}</button>`;
|
||
const button = load ? `<button class="btn filled m full" aria-busy="true" style="opacity:.85">${spinner(20)}<span>Parar ihop…</span></button>` : btn('Para ihop', { m: true, full: true, icon: 'link', dis: again });
|
||
return screen({ body: b, padStyle: 'gap:14px;padding-bottom:20px', bottom: `<div style="padding:12px 16px 0">${button}</div>` });
|
||
}
|
||
function uploadAvatar(u, size){
|
||
const r = size / 2 + 7, c = 2 * Math.PI * r;
|
||
return `<span style="position:relative;display:inline-grid;place-items:center;width:${size + 18}px;height:${size + 18}px"><span style="opacity:.6;line-height:0">${avatar(u, size, 'sunny')}</span><svg width="${size + 18}" height="${size + 18}" viewBox="0 0 ${size + 18} ${size + 18}" style="position:absolute;inset:0;transform:rotate(-90deg)" role="progressbar" aria-label="Laddar upp bilden" aria-valuenow="40"><circle cx="${size / 2 + 9}" cy="${size / 2 + 9}" r="${r}" style="fill:none;stroke:var(--outline-variant);stroke-width:4"/><circle cx="${size / 2 + 9}" cy="${size / 2 + 9}" r="${r}" style="fill:none;stroke:var(--primary);stroke-width:4;stroke-linecap:round;stroke-dasharray:${(c * 0.4).toFixed(1)} ${c.toFixed(1)}"/></svg></span>`;
|
||
}
|
||
function scrProfileStep(state){
|
||
const load = state === 'loading', err = state === 'error';
|
||
const av = load ? uploadAvatar('anna-photo', 112) : `<div style="position:relative">${avatar('anna', 112, 'sunny')}<span style="position:absolute;right:-2px;bottom:4px;width:40px;height:40px;border-radius:50%;background:var(--primary);color:var(--on-primary);display:grid;place-items:center;box-shadow:0 0 0 3px var(--card)">${ic('add_a_photo', { size: 20 })}</span></div>`;
|
||
const pic = load ? btn('Byt bild', { kind: 'tonal', icon: 'photo_library', dis: true }) : btn('Lägg till bild', { kind: 'tonal', icon: 'photo_library' });
|
||
const b = `<div style="padding:24px 4px 4px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center">${avatar('jacob', 72, 'sunny')}<h1 class="t-headline-small">Du går med i Jacobs hushåll</h1><p class="t-body-large muted">Så här ser Jacob dig i Kvitt. Du kan ändra det senare under Inställningar › Profil.</p></div>
|
||
<section class="card" style="padding:22px 20px;display:flex;flex-direction:column;align-items:center;gap:14px">${av}${pic}<div style="width:100%">${field({ label: 'Ditt namn', value: 'Anna', focus: !load && !err })}</div></section>`;
|
||
return screen({ top: topBar({ nav: null, title: '' }), body: b, bottom: `<div style="padding:12px 16px 0">${btn('Fortsätt', { m: true, full: true, dis: load })}</div>`, overlay: err ? snack('Bilden kunde inte användas', '', { bottom: 24 + 12 + 56 + 16 }) : '' });
|
||
}
|
||
|
||
/* account detail */
|
||
function scrAccount(o = {}){
|
||
const key = o.archived ? 'gotland' : o.empty ? 'sommar' : o.long ? 'long' : 'vardag', a = ACC[key];
|
||
const col = a.net < 0 ? 'owing' : 'owed';
|
||
const balWords = a.net === 0 ? `<div class="hstack t-headline-small c-settled" style="gap:6px">${ic('check_circle', { f: true, size: 28 })}Kvitt</div>` :
|
||
`<div class="t-title-medium c-${col}">${a.net < 0 ? 'Du är skyldig Anna' : 'Anna är skyldig dig'}</div><div class="t-amount-large c-${col}" style="white-space:normal">${kr(a.net)}</div>`;
|
||
const action = o.archived ? btn('Återställ från arkiv', { kind: 'tonal', icon: 'unarchive', style: 'align-self:flex-start' }) :
|
||
a.net === 0 ? '' : btn('Gör upp det här kontot', { kind: 'filled', icon: 'handshake', iconF: true, style: 'align-self:flex-start' });
|
||
const header = `<section class="card" style="padding:18px 20px 20px;display:flex;flex-direction:column;gap:10px;background:color-mix(in srgb,var(--chart-cat-${a.chart}) 12%,var(--card))">
|
||
<div class="hstack" style="gap:12px">${accTile(key, 40, { muted: a.archived })}<div class="grow"><div class="t-title-medium">${a.split[0]} / ${a.split[1]} · Jacob och Anna</div>${splitBar(a.split[0], { thin: true, aria: `Standarddelning ${a.split[0]} procent Jacob, ${a.split[1]} procent Anna` })}</div>${o.archived ? `<span class="badge">${ic('inventory_2', { size: 14 })}Arkiverat</span>` : ''}</div>
|
||
<div>${balWords}</div>${action}</section>`;
|
||
const blocked = `<span><span class="t-body-large" style="display:block">Ta bort konto</span><span class="sub">${o.offline ? 'Kräver anslutning' : 'Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter'}</span></span>`;
|
||
const off = o.offline ? '<span class="sub">Kräver anslutning</span>' : '';
|
||
const menu = `<div class="menu" role="menu"><div class="mi${o.offline ? ' dis' : ''}">${ic('edit')}<span><span class="t-body-large" style="display:block">Ändra konto</span>${off}</span></div><div class="mi${o.offline ? ' dis' : ''}">${ic(o.archived ? 'unarchive' : 'inventory_2')}<span><span class="t-body-large" style="display:block">${o.archived ? 'Återställ från arkiv' : 'Arkivera'}</span>${off}</span></div><div class="mi dis" aria-disabled="true">${ic('delete')}${blocked}</div></div>`;
|
||
let body;
|
||
if (o.empty) {
|
||
const pile = `<div style="position:relative;width:170px;height:100px;margin:4px auto" aria-hidden="true"><div style="position:absolute;left:10px;top:34px;transform:rotate(-14deg)">${catTile('home', 58)}</div><div style="position:absolute;left:56px;top:4px;transform:rotate(6deg)">${catTile('housing', 66)}</div><div style="position:absolute;left:104px;top:38px;transform:rotate(16deg)">${catTile('utilities', 54)}</div></div>`;
|
||
body = header + `<section class="flat" style="padding:28px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><h2 class="t-headline-small">Inga utgifter än</h2><p class="t-body-large muted">Lägg till den första utgiften i Sommarstugan.</p></section>`;
|
||
} else body = header + ledger(o.archived ? GOTLAND : o.long ? LONG : VARDAG);
|
||
const snackHtml = o.error ? snack('Kunde inte nå servern', 'Försök igen') : '';
|
||
return screen({ top: topBar({ title: a.name, actions: iconBtn('more_vert', 'Fler alternativ') }), body, fab: o.archived ? '' : fab('Lägg till utgift', o.error ? { bottom: 24 + 12 + 48 + 16 } : {}), overlay: (o.menu ? menu : '') + snackHtml });
|
||
}
|
||
|
||
/* account editor */
|
||
function scrAccountEditor(off, err){
|
||
const p = err ? 50 : 60;
|
||
const d = off ? ' dis' : '';
|
||
const b = `${off ? `<div class="banner warn" role="status"><div class="bt">${ic('cloud_off')}<div class="grow"><div class="t-title-small">Kräver anslutning</div><div class="t-body-medium">Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot.</div></div></div></div>` : ''}
|
||
${field({ label: 'Namn', value: err ? 'Vardag' : 'Sommarstugan', focus: !off, err, dis: off, caret: !off, sup: err ? 'Det finns redan ett konto som heter Vardag' : null })}
|
||
<section class="card" style="padding:18px 18px 20px;display:flex;flex-direction:column;gap:14px;${off ? 'opacity:.45' : ''}">
|
||
<div><div class="t-title-medium">Standarddelning</div><div class="t-body-medium muted">Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning.</div></div>
|
||
<div class="hstack" style="justify-content:space-between"><span class="hstack">${avatar('jacob', 32)}<span class="t-title-small">Jacob</span></span><span class="hstack"><span class="t-title-small">Anna</span>${avatar('anna', 32)}</span></div>
|
||
<div role="slider" aria-label="Jacobs andel" aria-valuenow="${p}" aria-valuetext="Jacob ${p} procent, Anna ${100 - p} procent">${splitBar(p, { knob: true })}</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">${field({ label: 'Jacob', value: String(p), trail: '<span class="t-title-medium muted">%</span>' })}${field({ label: 'Anna', value: String(100 - p), trail: '<span class="t-title-medium muted">%</span>' })}</div>
|
||
${seg(['40/60', '50/50', '60/40'], err ? 1 : 2)}
|
||
<div class="hstack t-body-medium" style="background:var(--surface-container-high);border-radius:16px;padding:12px 14px;gap:10px">${ic('calculate', { color: 'on-surface-variant' })}<span>Av ${kr(10000)}: Jacob ${kr(p * 100)}, Anna ${kr((100 - p) * 100)}</span></div>
|
||
</section>`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Nytt konto', actions: btn('Spara', { dis: off }) }), body: b, padStyle: 'gap:14px' });
|
||
}
|
||
|
||
/* expense editor */
|
||
const autoCap = () => `<div class="hstack t-label-medium muted" style="gap:6px;padding:0 4px;margin-top:-4px" role="note">${ic('auto_awesome', { size: 16, color: 'secondary' })}<span>Vald utifrån beskrivningen</span></div>`;
|
||
function catQuick(sel, keys, big){
|
||
keys = keys || ['groceries', 'restaurants', 'entertainment', 'home', 'transport'];
|
||
return `<div class="cq" role="radiogroup" aria-label="Kategori" style="${big ? 'grid-template-columns:repeat(3,1fr);row-gap:12px' : ''}">${keys.map(k => `<button class="cqi${k === sel ? ' on' : ''}" role="radio" aria-checked="${k === sel}"><span style="position:relative;display:inline-block;line-height:0">${catTile(k, 52, { solid: k === sel })}${k === sel ? `<span class="chk">${ic('check')}</span>` : ''}</span><span class="cql">${CAT[k].sv}</span></button>`).join('')}<button class="cqi" aria-label="Alla kategorier"><span class="tile shp" style="width:52px;height:52px;${shp('cookie4')};background:var(--surface-container-highest);color:var(--on-surface-variant)">${ic('apps', { size: 26 })}</span><span class="cql">Alla</span></button></div>`;
|
||
}
|
||
function keypad(sm){
|
||
const k = ['1','2','3','4','5','6','7','8','9',',','0'];
|
||
return `<div class="kp${sm ? ' sm' : ''}" role="group" aria-label="Sifferknappar">${k.map(x => `<button class="k">${x}</button>`).join('')}<button class="k fn" aria-label="Radera">${ic('backspace', { size: 26 })}</button></div>`;
|
||
}
|
||
function amountBlock(ore, txt, o = {}){
|
||
return `<div style="display:flex;flex-direction:column;align-items:center;padding:${o.pad || '10px 0 6px'}"><span class="t-label-medium ${o.err ? 'c-err' : 'muted'}">Belopp</span><div class="hstack" style="gap:6px;align-items:baseline;flex-wrap:wrap;justify-content:center"><span class="t-amount-hero">${txt}${o.caret ? '<span class="caret" style="height:.8em;vertical-align:-0.05em;width:3px"></span>' : ''}</span><span class="t-headline-small muted">kr</span></div></div>`;
|
||
}
|
||
function scrEditor(fsBig){
|
||
const b = `${amountBlock(41200, '412', { caret: true })}
|
||
${field({ label: 'Beskrivning', value: 'Systembolaget' })}
|
||
${catQuick('entertainment', null, fsBig)}
|
||
<div class="chips">${chip('Vardag', { dot: 'chart-cat-1', drop: true, aria: 'Konto: Vardag' })}${chip('Idag', { icon: 'event', drop: true, aria: 'Datum: Idag' })}${chip('Du', { k: 'Betalt av', avatar: 'jacob', trail: 'swap_horiz', aria: 'Betalt av: du. Dubbeltryck för att byta till Anna' })}${chip('Kontots standard 50/50', { k: 'Delning', icon: 'call_split', drop: true })}</div>
|
||
<div class="hstack" style="gap:12px;padding:2px 4px"><div style="width:72px">${splitBar(50, { thin: true, aria: 'Hälften var' })}</div><span class="t-body-medium">Du <b class="amt">${kr(20600)}</b> · Anna <b class="amt">${kr(20600)}</b></span></div>
|
||
${btn('Lägg till anteckning', { kind: 'text', icon: 'notes', style: 'align-self:flex-start;margin-left:-8px' })}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Ny utgift', actions: btn('Spara', { icon: 'check' }) }), body: b, padStyle: 'gap:12px;padding-bottom:16px', bottom: keypad(), raw: false, nav: null, fab: '' }).replace('<div class="ins"></div>', '');
|
||
}
|
||
function splitModes(on){
|
||
const m = ['Kontots standard 50/50', 'Egen fördelning', 'Exakta belopp', 'Allt på en person'];
|
||
return `<div class="chips" role="radiogroup" aria-label="Delning">${m.map((x, i) => chip(x, { sel: i === on })).join('')}</div>`;
|
||
}
|
||
function scrEditorSplit(){
|
||
const compact = `<div class="hstack" style="padding:6px 20px 10px;gap:12px;border-bottom:1px solid var(--outline-variant)">${catTile('shopping', 36)}<div class="grow"><div class="t-title-medium ell">Ullared</div><div class="t-body-small muted">Shopping</div></div><span class="t-amount-medium">${kr(106000)}</span></div>`;
|
||
const b = `<div class="chips">${chip('Exakta belopp', { k: 'Delning', icon: 'call_split', sel: true, drop: 'up' })}</div>
|
||
<section class="flat" style="padding:16px;display:flex;flex-direction:column;gap:12px">
|
||
${splitModes(2)}
|
||
<div class="hstack" style="gap:12px;align-items:flex-start">${avatar('jacob', 40)}<div class="grow">${field({ label: 'Jacob', value: '1' + NB + '200,00', valCls: 't-amount-medium', err: true, focus: true, trail: '<span class="t-title-medium muted">kr</span>', sup: 'Andelen är större än beloppet' })}</div></div>
|
||
<div class="hstack" style="gap:12px">${avatar('anna', 40)}<div class="grow">${field({ label: 'Anna', value: '—', ro: true, trail: '<span class="t-body-small muted">räknas ut</span>' })}</div></div>
|
||
<div style="background:var(--error-container);color:var(--on-error-container);border-radius:16px;padding:10px 14px" class="vstack"><span class="t-body-medium">Jacob ${kr(120000)} av ${kr(106000)}</span><span class="t-title-small">${kr(14000)} för mycket</span></div>
|
||
</section>`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Ny utgift', actions: btn('Spara', { icon: 'check', dis: true }) }) + compact, body: b, padStyle: 'gap:12px;padding-top:12px;padding-bottom:12px', bottom: keypad(true) }).replace('<div class="ins"></div>', '');
|
||
}
|
||
function scrEditorFull(){
|
||
const b = `${amountBlock(106000, '1' + NB + '060,00', { pad: '4px 0 2px' })}
|
||
${field({ label: 'Beskrivning', value: 'SL-kort 30 dagar, Anna' })}
|
||
${catQuick('transport')}
|
||
<div class="chips">${chip('Vardag', { dot: 'chart-cat-1', drop: true })}${chip('14 okt.', { icon: 'event', drop: true })}${chip('Du', { k: 'Betalt av', avatar: 'jacob', trail: 'swap_horiz', aria: 'Betalt av: du. Dubbeltryck för att byta till Anna' })}${chip('Allt på Anna', { k: 'Delning', icon: 'call_split', sel: true, drop: 'up' })}</div>
|
||
<section class="flat" style="padding:16px;display:flex;flex-direction:column;gap:12px">
|
||
<div class="hstack"><span class="t-title-small grow">Delning</span>${ic('expand_less', { color: 'on-surface-variant' })}</div>
|
||
${splitModes(3)}
|
||
<div class="t-label-large muted">Vem står för hela beloppet?</div>
|
||
<div class="seg" role="radiogroup"><span role="radio" aria-checked="false" style="min-height:52px">${avatar('jacob', 28)} Jacob</span><span role="radio" aria-checked="true" class="on" style="min-height:52px">${avatar('anna', 28)} Anna</span></div>
|
||
${splitBar(0, { aria: 'Allt på Anna' })}
|
||
<div class="t-body-medium">Jacob <b class="amt">${kr(0)}</b> · Anna <b class="amt">${kr(106000)}</b></div>
|
||
</section>
|
||
${field({ label: 'Anteckningar', value: 'Annas månadskort för oktober. Jag tog det på mitt kort.', focus: false })}
|
||
${btn('Ta bort utgift', { kind: 'danger', icon: 'delete', style: 'align-self:flex-start' })}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Ändra utgift', actions: btn('Spara', { icon: 'check' }) }), body: b, padStyle: 'gap:12px' });
|
||
}
|
||
|
||
function scrEditorEdit(){
|
||
const b = `${amountBlock(124760, '1' + NB + '247,60', { pad: '4px 0 2px' })}
|
||
${field({ label: 'Beskrivning', value: 'ICA Maxi Lindhagen' })}
|
||
${catQuick('groceries')}
|
||
<div class="chips">${chip('Vardag', { dot: 'chart-cat-1', drop: true, aria: 'Konto: Vardag' })}${chip('16 okt.', { icon: 'event', drop: true, aria: 'Datum: 16 okt.' })}${chip('Anna', { k: 'Betalt av', avatar: 'anna', trail: 'swap_horiz', aria: 'Betalt av: Anna. Dubbeltryck för att byta till dig' })}${chip('Kontots standard 50/50', { k: 'Delning', icon: 'call_split', drop: true })}</div>
|
||
<div class="hstack" style="gap:12px;padding:2px 4px"><div style="width:72px">${splitBar(50, { thin: true, aria: 'Hälften var' })}</div><span class="t-body-medium">Du <b class="amt">${kr(62380)}</b> · Anna <b class="amt">${kr(62380)}</b></span></div>
|
||
${field({ label: 'Anteckningar', value: 'Storhandling inför middagen med Lindqvists' })}
|
||
${btn('Ta bort utgift', { kind: 'danger', icon: 'delete', style: 'align-self:flex-start' })}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Ändra utgift', actions: btn('Spara', { icon: 'check' }) }), body: b, padStyle: 'gap:12px' });
|
||
}
|
||
|
||
/* expense detail */
|
||
function detailHero(o){
|
||
const c = CAT[o.cat];
|
||
return `<section class="card" style="padding:20px;background:var(--cat-${c.c}-container);display:flex;flex-direction:column;gap:12px;${o.muted ? 'opacity:.62;filter:saturate(.3)' : ''}">
|
||
<div class="hstack" style="gap:14px;align-items:flex-start">${catTile(o.cat, 60)}<div class="grow"><div class="t-title-large" style="overflow-wrap:anywhere">${o.desc}</div><div class="t-body-medium muted">${c.sv} · ${o.acc}</div></div></div>
|
||
<div class="hstack" style="gap:8px"><span class="t-amount-large">${kr(o.amt)}</span>${o.pending ? `<span class="c-pend" role="img" aria-label="Väntar på synk">${ic('cloud_upload', { size: 20 })}</span>` : ''}</div>
|
||
${o.rec ? `<span class="badge lantern" style="align-self:flex-start;font-size:calc(var(--fs)*12px);padding:4px 12px">${ic('event_repeat', { size: 16 })}Återkommande: ${o.rec}</span>` : ''}
|
||
</section>`;
|
||
}
|
||
function infoRows(rows, muted){
|
||
return `<div class="grp" style="${muted ? 'opacity:.62' : ''}">${rows.map(([icn, lab, val]) => `<div class="it row">${ic(icn, { color: 'on-surface-variant' })}<div class="rc"><div class="t-label-medium muted">${lab}</div><div class="t-body-large">${val}</div></div></div>`).join('')}</div>`;
|
||
}
|
||
function timeline(lines){
|
||
return `<div class="mh"><span class="t-title-small">Historik</span></div><section class="card" style="padding:16px 16px 16px 12px"><div class="tl">${lines.map(l => `<div class="tli${l.pend ? ' pend' : ''}"><div class="dotc"><span class="dot"></span><span class="line"></span></div><div class="t-body-medium${l.pend ? ' c-pend' : ''}" style="${l.pend ? 'font-weight:600' : ''}">${l.pend ? ic('cloud_upload', { size: 16, style: 'vertical-align:-3px;margin-right:4px' }) : ''}${l.t}</div></div>`).join('')}</div></section>`;
|
||
}
|
||
function sharesRow(a, b2, mode){
|
||
return `<div class="vstack" style="gap:6px"><span>${mode}</span>${splitBar(Math.round(a / (a + b2) * 100), { thin: true, aria: mode })}<span class="t-body-medium">Du <b class="amt">${kr(a)}</b> · Anna <b class="amt">${kr(b2)}</b></span></div>`;
|
||
}
|
||
function scrExpense(kind){
|
||
const acts = kind === 'deleted' ? '' : iconBtn('edit', 'Ändra utgift') + iconBtn('delete', 'Ta bort utgift');
|
||
let b = '';
|
||
if (kind === 'missing') {
|
||
b = `<div style="padding-top:60px">${emptyState({ art: bigIcon('sync_problem', 'secondary-container', 'on-secondary-container'), title: 'Utgiften finns inte på telefonen än', body: 'Den kan finnas på servern. Synka för att hämta den.', action: btn('Synka nu', { kind: 'tonal', m: true, icon: 'sync' }) })}</div>`;
|
||
return screen({ top: topBar({ title: '' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
if (kind === 'recurring') {
|
||
b = detailHero({ cat: 'housing', desc: 'Hyra september', acc: 'Huset', amt: 1240000, rec: 'Hyra' }) +
|
||
infoRows([['event', 'Datum', 'fredag 25 september 2026'], ['person', 'Betalt av', `<span class="hstack">${avatar('anna', 24)}Anna</span>`], ['call_split', 'Delning', sharesRow(744000, 496000, 'Kontots standard 60/40')]]) +
|
||
timeline([{ t: 'Skapad automatiskt (återkommande) · 25 sep. 06:00' }]);
|
||
} else if (kind === 'deleted') {
|
||
b = `<div class="banner neutral" role="status"><div class="bt">${ic('delete', { f: true, color: 'error' })}<div class="grow t-title-small">Anna tog bort den här utgiften · 17 okt. 09:12</div></div><div class="t-body-medium" style="padding:0 0 4px 36px">Den räknas inte längre i saldon eller statistik.</div></div>` +
|
||
detailHero({ cat: 'home', desc: 'Biltema Kungens kurva', acc: 'Vardag', amt: 34900, muted: true }) +
|
||
infoRows([['event', 'Datum', 'onsdag 14 oktober 2026'], ['person', 'Betalt av', `<span class="hstack">${avatar('anna', 24)}Anna</span>`], ['call_split', 'Delning', `Kontots standard 50/50<br><span class="t-body-medium">Du <b class="amt">${kr(17450)}</b> · Anna <b class="amt">${kr(17450)}</b></span>`]], true) +
|
||
timeline([{ t: 'Anna skapade den här · 14 okt. 18:20' }, { t: 'Anna tog bort den här · 17 okt. 09:12' }]);
|
||
} else {
|
||
b = detailHero({ cat: 'groceries', desc: 'ICA Maxi Lindhagen', acc: 'Vardag', amt: 124760, pending: true }) +
|
||
infoRows([['event', 'Datum', 'fredag 16 oktober 2026'], ['person', 'Betalt av', `<span class="hstack">${avatar('anna', 24)}Anna</span>`], ['call_split', 'Delning', sharesRow(62380, 62380, 'Kontots standard 50/50')], ['notes', 'Anteckning', 'Storhandling inför middagen med Lindqvists']]) +
|
||
timeline([{ t: 'Anna skapade den här · 16 okt. 14:02' }, { t: 'Du ändrade beloppet från 1' + NB + '147,60' + NB + 'kr till 1' + NB + '247,60' + NB + 'kr · 16 okt. 19:40' }, { t: 'Anna ändrade kategorin från Övrigt till Mat & hushåll · 16 okt. 20:05' }, { t: 'Din ändring väntar på synk', pend: true }]);
|
||
}
|
||
return screen({ top: topBar({ title: '', actions: acts }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
|
||
/* settle up */
|
||
function allocRow(key, debt, val, o = {}){
|
||
const a = ACC[key];
|
||
return `<div class="it" style="padding:12px 14px;display:flex;flex-direction:column;gap:8px"><div class="hstack" style="gap:12px;${o.big ? 'flex-wrap:wrap' : ''}">${accTile(key, 40)}<div class="grow"><div class="t-title-medium ell">${a.name}</div><div class="t-body-small muted">Skuld ${kr(debt)}</div></div>${o.fixed ? `<span class="t-amount-medium">${kr(val)}</span>` : `<div class="fld${o.warn ? ' err' : ''}" style="min-height:48px;padding:6px 12px;${o.big ? 'flex:1 0 100%;order:3' : 'width:148px'};border-radius:14px;${o.warn ? 'box-shadow:inset 0 0 0 2px var(--warning)' : ''}"><span class="t-amount-medium grow" style="text-align:right">${krn(val)}</span><span class="t-body-medium muted">kr</span></div>`}${o.fixed ? '' : iconBtn('close', `Ta bort ${a.name} från fördelningen`, { style: o.big ? 'order:2' : 'margin-right:-8px' })}</div>${o.warn ? `<div class="hstack t-body-medium" style="background:var(--warning-container);color:var(--on-warning-container);border-radius:12px;padding:8px 12px;gap:8px;align-items:flex-start">${ic('warning', { f: true, size: 20 })}<span>Det här reglerar mer än som är skyldigt i ${a.name}</span></div>` : ''}</div>`;
|
||
}
|
||
function scrSettle(v){
|
||
const big = v === 'big'; if (big) v = 'proposal';
|
||
const fixed = v === 'account';
|
||
if (v === 'empty') return scrSettleEmpty();
|
||
const rows = fixed ? [['huset', 308450, 308450]] :
|
||
v === 'adjusted' || v === 'dialog' ? [['huset', 308450, 175000], ['japan', 135000, 150000, true]] :
|
||
v === 'remaining' ? [['huset', 308450, 200000], ['japan', 135000, 100000]] : [['huset', 308450, 308450], ['japan', 135000, 16550]];
|
||
const amount = fixed ? 308450 : 325000;
|
||
const remaining = amount - rows.reduce((s, r) => s + r[2], 0);
|
||
const canConfirm = remaining === 0;
|
||
const dir = `<section class="card" style="padding:14px 14px 14px 18px;display:flex;align-items:center;gap:12px"><div class="hstack" style="gap:6px">${avatar('jacob', 40, 'sunny')}${ic('arrow_forward', { color: 'on-surface-variant' })}${avatar('anna', 40, 'sunny')}</div><div class="grow"><div class="t-label-medium muted">Riktning</div><div class="t-title-medium">Du betalar Anna</div></div>${iconBtn('swap_horiz', 'Byt riktning', { tonal: true })}</section>`;
|
||
const amt = field({ label: 'Belopp', value: krn(amount), valCls: 't-amount-large', trail: '<span class="t-title-large muted">kr</span>' });
|
||
const rem = `<div class="hstack" style="padding:10px 14px;border-radius:16px;gap:10px;background:${canConfirm ? 'var(--surface-container-high)' : 'var(--warning-container)'};color:${canConfirm ? 'var(--on-surface)' : 'var(--on-warning-container)'}">${ic(canConfirm ? 'check_circle' : 'error', { f: true, color: canConfirm ? 'owed' : null })}<span class="t-title-small grow">Kvar att fördela</span><span class="t-amount-medium">${kr(remaining)}</span></div>`;
|
||
const b = `${fixed ? `<section class="card" style="padding:14px 18px;display:flex;align-items:center;gap:12px"><div class="hstack" style="gap:6px">${avatar('jacob', 40, 'sunny')}${ic('arrow_forward', { color: 'on-surface-variant' })}${avatar('anna', 40, 'sunny')}</div><div class="grow"><div class="t-label-medium muted">Riktning</div><div class="t-title-medium">Du betalar Anna</div></div>${iconBtn('swap_horiz', 'Byt riktning', { tonal: true })}</section>` : dir}
|
||
${amt}
|
||
<div class="mh" style="padding-top:4px"><span class="t-title-small">Fördelning</span></div>
|
||
<div class="grp">${rows.map(r => allocRow(r[0], r[1], r[2], { warn: r[3], fixed, big })).join('')}</div>
|
||
${fixed ? '' : rem}
|
||
${fixed ? '' : `<div class="hstack" style="justify-content:space-between;flex-wrap:wrap">${btn('Lägg till konto', { kind: 'text', icon: 'add' })}${v === 'proposal' ? '' : btn('Föreslå igen', { kind: 'text', icon: 'refresh' })}</div><div class="hstack t-body-small muted" style="gap:8px;padding:0 8px;align-items:flex-start">${ic('info', { size: 18 })}<span>Vardag lämnas som det är, eftersom Anna är skyldig dig där.</span></div>`}
|
||
<div class="chips">${chip('Idag', { k: 'Datum', icon: 'event', drop: true })}</div>
|
||
${field({ label: 'Anteckning', ph: 'Till exempel Swish' })}`;
|
||
const dlg = v === 'dialog' ? `<div class="scrim"></div><div class="dlg" role="alertdialog" aria-label="Bekräfta betalningen"><span class="di shp" style="width:52px;height:52px;${shp('sunny')};background:var(--secondary-container);color:var(--on-secondary-container);display:grid;place-items:center">${ic('handshake', { f: true })}</span><div class="t-headline-small" style="text-align:center">Bekräfta betalningen</div><div class="t-body-medium muted">Du betalar Anna ${kr(325000)} idag, lördag 17 oktober 2026.</div><div class="vstack t-body-medium" style="gap:4px"><div class="hstack"><span class="grow">Huset</span><b class="amt">${kr(175000)}</b></div><div class="hstack"><span class="grow">Japan 2027</span><b class="amt">${kr(150000)}</b></div></div><div class="hstack t-body-medium" style="background:var(--warning-container);color:var(--on-warning-container);border-radius:12px;padding:10px 12px;gap:8px;align-items:flex-start">${ic('warning', { f: true, size: 20 })}<span>Det här reglerar mer än som är skyldigt i Japan 2027</span></div><div class="da">${btn('Avbryt', { kind: 'text' })}${btn('Bekräfta', { icon: 'check' })}</div></div>` : '';
|
||
return screen({ top: topBar({ nav: 'close', title: fixed ? 'Gör upp Huset' : 'Gör upp' }), body: b, padStyle: 'gap:10px;padding-bottom:20px', bottom: `<div style="padding:10px 16px 0;background:var(--surface);box-shadow:0 -1px 0 var(--outline-variant)">${btn('Bekräfta', { m: true, full: true, dis: !canConfirm, icon: 'check' })}</div>`, overlay: dlg });
|
||
}
|
||
function scrSettleEmpty(){
|
||
const b = `<section class="card" style="padding:14px 14px 14px 18px;display:flex;align-items:center;gap:12px"><div class="hstack" style="gap:6px">${avatar('jacob', 40, 'sunny')}${ic('arrow_forward', { color: 'on-surface-variant' })}${avatar('anna', 40, 'sunny')}</div><div class="grow"><div class="t-label-medium muted">Riktning</div><div class="t-title-medium">Du betalar Anna</div></div>${iconBtn('swap_horiz', 'Byt riktning', { tonal: true })}</section>
|
||
${field({ label: 'Belopp', ph: '0,00', valCls: 't-amount-large', focus: true, trail: '<span class="t-title-large muted">kr</span>' })}
|
||
<div class="mh" style="padding-top:4px"><span class="t-title-small">Fördelning</span></div>
|
||
<p class="t-body-medium muted" style="padding:0 8px">Skriv ett belopp så föreslår Kvitt en fördelning.</p>
|
||
<div class="chips">${chip('Idag', { k: 'Datum', icon: 'event', drop: true })}</div>
|
||
${field({ label: 'Anteckning', ph: 'Till exempel Swish' })}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Gör upp' }), body: b, padStyle: 'gap:10px;padding-bottom:20px', bottom: `<div style="padding:10px 16px 0;background:var(--surface);box-shadow:0 -1px 0 var(--outline-variant)">${btn('Bekräfta', { m: true, full: true, dis: true, icon: 'check' })}</div>` });
|
||
}
|
||
function scrSettlement(deleted){
|
||
const b = `${deleted ? `<div class="banner neutral" role="status"><div class="bt">${ic('delete', { f: true, color: 'error' })}<div class="grow t-title-small">Anna tog bort den här betalningen · 17 okt. 09:12</div></div><div class="t-body-medium" style="padding:0 0 4px 36px">Den räknas inte längre i saldona.</div></div>` : ''}<section class="card" style="padding:22px 20px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;background:color-mix(in srgb,var(--primary-container) 50%,var(--card));${deleted ? 'opacity:.62;filter:saturate(.3)' : ''}"><div class="hstack" style="gap:8px">${avatar('anna', 52, 'sunny')}${ic('arrow_forward', { color: 'on-surface-variant', size: 28 })}${avatar('jacob', 52, 'sunny')}</div><div class="t-title-medium" style="margin-top:6px">Anna betalade dig</div><div class="t-amount-large">${kr(80000)}</div></section>
|
||
${infoRows([['event', 'Datum', 'tisdag 6 oktober 2026'], ['notes', 'Anteckning', 'Swish']], deleted)}
|
||
<div class="mh"><span class="t-title-small">Fördelning</span></div>
|
||
<div class="grp" style="${deleted ? 'opacity:.62' : ''}"><div class="it row">${accTile('vardag', 36)}<span class="t-title-medium grow">Vardag</span><span class="t-amount-small">${kr(80000)}</span></div></div>
|
||
${timeline([{ t: 'Anna skapade den här · 6 okt. 21:15' }].concat(deleted ? [{ t: 'Anna tog bort den här · 17 okt. 09:12' }] : []))}`;
|
||
return screen({ top: topBar({ title: 'Betalning', actions: deleted ? '' : iconBtn('edit', 'Ändra betalning') + iconBtn('delete', 'Ta bort betalning') }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
|
||
/* analytics */
|
||
function stackChart(buckets, series, labels, o = {}){
|
||
const max = o.max, ticks = o.ticks;
|
||
const grid = ticks.map(t => `<span class="gl" style="bottom:${t / max * 100}%"></span><span class="yl" style="bottom:${t / max * 100}%;transform:translateY(50%)">${kron(t / 100)}</span>`).join('');
|
||
const H = 170; // chart height in dp
|
||
const cols = buckets.map((b, i) => { const tot = series.reduce((a, s) => a + (b[s.key] || 0), 0); return `<div class="col"><div class="stk" style="height:${(tot / max * H).toFixed(1)}px">${series.map(s => b[s.key] > 0 ? `<i style="flex:${b[s.key]} 1 0;background:var(--${s.color})"></i>` : '').join('')}</div></div>`; }).join('');
|
||
return `<div class="chart" role="img" aria-label="${o.aria || ''}">${grid}<div class="cols">${cols}</div></div><div class="xl">${labels.map(l => `<span>${l}</span>`).join('')}</div>`;
|
||
}
|
||
function filters(o){
|
||
return `<div class="chips">${chip(o.period, { icon: 'date_range', drop: true, aria: 'Period: ' + o.period })}<div class="grow" style="min-width:170px">${seg(['Månad', 'Vecka'], o.week ? 1 : 0)}</div></div><div class="hstack" style="gap:8px;flex-wrap:wrap"><div style="flex:1 1 auto">${seg(['Hushållet', 'Min del'], o.mine ? 1 : 0)}</div><div style="flex:1 1 auto">${seg(['Kategori', 'Konto'], o.byAcc ? 1 : 0)}</div></div>`;
|
||
}
|
||
function catBreakdown(limit){
|
||
const tot = DEFAULT_CATS.reduce((s, k) => s + AN[k].reduce((a, v) => a + v, 0), 0);
|
||
const rows = DEFAULT_CATS.map(k => [k, AN[k].reduce((a, v) => a + v, 0)]).filter(r => r[1] > 0).sort((a, b) => b[1] - a[1]);
|
||
const max = rows[0][1];
|
||
return rows.slice(0, limit || 99).map(([k, v]) => `<div class="brow"><div class="bh">${catTile(k, 32)}<span class="t-title-small grow ell">${CAT[k].sv}</span><span class="vstack" style="align-items:flex-end"><span class="t-amount-small">${kr(v)}</span><span class="t-body-small muted">${pct1(v, tot)}</span></span></div><div class="bar" style="margin-left:44px"><i style="width:${(v / max * 100).toFixed(1)}%;background:var(--cat-${CAT[k].c})"></i></div></div>`).join('');
|
||
}
|
||
function accBreakdown(){
|
||
const tot = AN_ACC.reduce((s, r) => s + r[1], 0), max = Math.max(...AN_ACC.map(r => r[1]));
|
||
return AN_ACC.slice().sort((a, b) => b[1] - a[1]).map(([k, v]) => `<div class="brow"><div class="bh">${accTile(k, 32)}<span class="t-title-small grow ell">${ACC[k].name}${ACC[k].archived ? ' <span class="badge">Arkiverat</span>' : ''}</span><span class="vstack" style="align-items:flex-end"><span class="t-amount-small">${kr(v)}</span><span class="t-body-small muted">${pct1(v, tot)}</span></span></div><div class="bar" style="margin-left:44px"><i style="width:${(v / max * 100).toFixed(1)}%;background:var(--chart-cat-${ACC[k].chart})"></i></div></div>`).join('');
|
||
}
|
||
const RANGE_TOTAL = DEFAULT_CATS.reduce((s, k) => s + AN[k].reduce((a, v) => a + v, 0), 0);
|
||
const MONTH_TOTALS = MONTHS.map((m, i) => DEFAULT_CATS.reduce((s, k) => s + AN[k][i], 0));
|
||
function scrAnalytics(v){
|
||
let b;
|
||
if (v === 'empty') {
|
||
b = filters({ period: 'Anpassad · 1–30 nov.' }) + `<section class="card" style="padding:18px 20px"><div class="t-label-large muted">Totalt</div><div class="t-amount-large">${kr(0)}</div><div class="t-body-small muted">1–30 november 2026 · Hushållet</div></section>
|
||
<section class="flat" style="padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center"><div style="position:relative;width:150px;height:90px" aria-hidden="true"><span style="position:absolute;left:10px;top:30px;transform:rotate(-12deg)">${catTile('other', 56, { solid: false })}</span><span style="position:absolute;left:52px;top:4px;transform:rotate(8deg)">${catTile('other', 64, { solid: false })}</span><span style="position:absolute;left:96px;top:34px;transform:rotate(18deg)">${catTile('other', 50, { solid: false })}</span></div><h2 class="t-headline-small">Inga utgifter i den här perioden</h2><p class="t-body-large muted">Välj en annan period, eller lägg till en utgift med ett datum i perioden.</p>${btn('Byt period', { kind: 'tonal', icon: 'date_range' })}</section>`;
|
||
} else if (v === 'week') {
|
||
const series = [{ key: 'vardag', color: 'chart-cat-1' }, { key: 'huset', color: 'chart-cat-2' }, { key: 'japan', color: 'chart-cat-4' }];
|
||
const buckets = WEEKS.map((w, i) => ({ vardag: WK.vardag[i], huset: WK.huset[i], japan: WK.japan[i] }));
|
||
const tot = buckets.reduce((s, x) => s + x.vardag + x.huset + x.japan, 0);
|
||
const accTot = { vardag: WK.vardag.reduce((a, x) => a + x, 0), huset: WK.huset.reduce((a, x) => a + x, 0), japan: WK.japan.reduce((a, x) => a + x, 0) };
|
||
b = filters({ period: 'Anpassad · 24 aug.–17 okt.', week: true, mine: true, byAcc: true }) + `<section class="card" style="padding:18px 20px"><div class="t-label-large muted">Totalt · Min del</div><div class="t-amount-large">${kr(tot)}</div><div class="t-body-small muted">24 augusti–17 oktober 2026 · 8 veckor</div></section>
|
||
<section class="card" style="padding:18px 16px 16px"><div class="t-title-medium">Utgifter över tid</div>${stackChart(buckets, series, WEEKS.map(w => w.replace('v. ', '')), { max: 500000, ticks: [0, 250000, 500000], aria: 'Min del per vecka och konto' })}<div class="t-body-small muted" style="text-align:center;margin-top:2px">Vecka</div><div class="legend">${series.map(s => `<span><i style="background:var(--${s.color})"></i>${ACC[s.key].name}</span>`).join('')}</div></section>
|
||
<section class="card" style="padding:14px 16px"><div class="t-title-medium" style="margin-bottom:4px">Per konto</div>${['vardag', 'huset', 'japan'].map(k => `<div class="brow"><div class="bh">${accTile(k, 32)}<span class="t-title-small grow">${ACC[k].name}</span><span class="vstack" style="align-items:flex-end"><span class="t-amount-small">${kr(accTot[k])}</span><span class="t-body-small muted">${pct1(accTot[k], tot)}</span></span></div><div class="bar" style="margin-left:44px"><i style="width:${(accTot[k] / accTot.vardag * 100).toFixed(1)}%;background:var(--chart-cat-${ACC[k].chart})"></i></div></div>`).join('')}</section>`;
|
||
} else if (v === 'scrolled') {
|
||
b = `<section class="card" style="padding:14px 16px"><div class="t-title-medium" style="margin-bottom:4px">Per kategori</div>${catBreakdown()}</section><section class="card" style="padding:14px 16px"><div class="t-title-medium" style="margin-bottom:4px">Per konto</div>${accBreakdown()}</section>`;
|
||
} else {
|
||
const series = DEFAULT_CATS.filter(k => AN[k].some(x => x > 0)).map(k => ({ key: k, color: 'cat-' + CAT[k].c }));
|
||
const buckets = MONTHS.map((m, i) => Object.fromEntries(DEFAULT_CATS.map(k => [k, AN[k][i]])));
|
||
b = filters({ period: 'Senaste 6 mån' }) + `<section class="card" style="padding:16px 20px;display:flex;align-items:center;gap:12px"><div class="grow"><div class="t-label-large muted">Totalt</div><div class="t-amount-large">${kr(RANGE_TOTAL)}</div><div class="t-body-small muted">maj–okt 2026 · Hushållet</div></div></section>
|
||
<section class="card" style="padding:18px 16px 16px"><div class="t-title-medium">Utgifter över tid</div>${stackChart(buckets, series, MONTHS, { max: 4500000, ticks: [0, 1500000, 3000000, 4500000], hl: 5, aria: 'Utgifter över tid' })}<div class="legend">${series.map(s => `<span><i style="background:var(--${s.color})"></i>${CAT[s.key].sv}</span>`).join('')}</div></section>
|
||
<section class="card" style="padding:14px 16px"><div class="t-title-medium" style="margin-bottom:4px">Per kategori</div>${catBreakdown(3)}</section>`;
|
||
}
|
||
return screen({ top: topBar({ nav: null, title: 'Statistik', bg: v === 'scrolled' ? 'surface-container' : '' }), body: b, nav: 1, padStyle: 'gap:10px', scroll: v === 'scrolled' ? 470 : 0 });
|
||
}
|
||
function scrDrill(){
|
||
const rows = VARDAG[0].rows.filter(r => r.cat === 'groceries');
|
||
const tot = rows.reduce((s, r) => s + r.amt, 0);
|
||
const b = `<section class="card" style="padding:18px 20px;background:var(--cat-green-container);display:flex;align-items:center;gap:14px">${catTile('groceries', 56)}<div class="grow"><div class="t-label-large muted">Totalt · Hushållet</div><div class="t-amount-large">${kr(tot)}</div><div class="t-body-small muted">${rows.length} utgifter</div></div></section>
|
||
<div class="grp" style="margin-top:4px">${rows.map(ledgerRow).join('')}</div>`;
|
||
return screen({ top: topBar({ title: 'Mat & hushåll, oktober 2026' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
|
||
/* settings */
|
||
const tinted = (icn, color) => `<span class="tile shp" style="width:40px;height:40px;${shp('cookie4')};background:var(--${color}-container);color:var(--${color})">${ic(icn, { size: 22 })}</span>`;
|
||
function settingsRow(icn, color, title, sub){ return `<div class="it row">${tinted(icn, color)}<div class="rc"><div class="t-title-medium">${title}</div>${sub ? `<div class="t-body-small muted">${sub}</div>` : ''}</div>${ic('chevron_right', { color: 'on-surface-variant' })}</div>`; }
|
||
function scrSettings(member){
|
||
const me = member ? 'anna' : 'jacob', other = member ? 'jacob' : 'anna';
|
||
const b = `<section class="card" style="padding:16px 18px;display:flex;flex-direction:column;gap:12px"><div class="hstack" style="gap:14px">${avatar(me, 52, 'sunny')}<div class="grow"><div class="t-title-medium">${member ? 'Anna' : 'Jacob'}</div><div class="t-body-small muted">${member ? 'Du' : 'Du · Administratör'}</div></div></div><div class="divider" style="margin:0"></div><div class="hstack" style="gap:14px">${avatar(other, 52, 'sunny')}<div class="grow"><div class="t-title-medium">${member ? 'Jacob' : 'Anna'}</div><div class="t-body-small muted">${member ? 'Admin' : 'Partner'}</div></div></div></section>
|
||
<div class="grp">${settingsRow('person', 'cat-orange', 'Profil', 'Namn och bild')}${settingsRow('category', 'cat-green', 'Kategorier', '14 aktiva')}${settingsRow('event_repeat', 'cat-brown', 'Återkommande utgifter', '3 aktiva, 1 pausad')}</div>
|
||
<div class="grp">${settingsRow('devices', 'cat-blue', 'Enheter')}${member ? '' : settingsRow('add_link', 'cat-teal', 'Para ihop en enhet')}${settingsRow('sync', 'cat-purple', 'Synkstatus', 'Synkad för 3 min sedan')}${settingsRow('info', 'cat-grey', 'Om Kvitt', 'Version 1.1')}</div>`;
|
||
return screen({ top: topBar({ nav: null, title: 'Inställningar' }), body: b, nav: 2, padStyle: 'gap:12px' });
|
||
}
|
||
function scrProfile(state){
|
||
const load = state === 'loading', off = state === 'offline';
|
||
const av = load ? uploadAvatar('jacob', 128) : avatar('jacob', 128, 'sunny');
|
||
const b = `${off ? offlineBanner('Kräver anslutning. Profilen kan bara ändras när telefonen är online.') : ''}<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 0 10px">${av}<div class="hstack" style="gap:8px;flex-wrap:wrap;justify-content:center">${btn('Byt bild', { kind: 'tonal', icon: 'photo_library', dis: off || load })}${btn('Ta bort bild', { kind: 'text', icon: 'delete', dis: off || load })}</div></div>
|
||
${field({ label: 'Namn', value: 'Jacob', focus: !off && !load, dis: off, sup: 'Så här ser Anna dig i Kvitt.' })}`;
|
||
return screen({ top: topBar({ title: 'Profil', actions: btn('Spara', { dis: off || load }) }), body: b, padStyle: 'gap:14px' });
|
||
}
|
||
function scrCategories(scrolled, off){
|
||
const row = (k, o = {}) => `<div class="it row" style="min-height:56px;padding:4px ${off ? 16 : 12}px 4px ${off ? 16 : 8}px;gap:12px">${off ? '' : o.noDrag ? '<span style="width:32px"></span>' : `<span class="ib" role="button" aria-label="Flytta ${CAT[k].sv}" style="width:32px;height:48px">${ic('drag_indicator')}</span>`}${catTile(k, 36, { muted: o.muted })}<span class="t-title-medium grow">${CAT[k].sv}</span>${off ? '' : ic('chevron_right', { color: 'on-surface-variant' })}</div>`;
|
||
const b = `${off ? needsHint('Kräver anslutning för att ändra') : ''}<div class="grp">${DEFAULT_CATS.map(k => row(k)).join('')}</div>${archivedRow(true)}<div class="grp">${row('pant', { noDrag: true, muted: true })}</div>`;
|
||
return screen({ top: topBar({ title: 'Kategorier', actions: iconBtn('add', 'Ny kategori', { style: off ? 'opacity:.38' : '' }), bg: scrolled ? 'surface-container' : '' }), body: b, padStyle: 'gap:4px', scroll: scrolled ? 330 : 0 });
|
||
}
|
||
function scrCategoryEditor(custom){
|
||
const key = custom ? 'pant' : 'groceries', selIcon = CAT[key].icon, selCol = CAT[key].c;
|
||
const nameBlock = custom ? `${field({ label: 'Namn', value: 'Pant' })}<div style="padding:6px 0 0 4px"><span class="badge">${ic('inventory_2', { size: 14 })}Arkiverad</span></div>` :
|
||
`${field({ label: 'Namn', value: 'Matvaror', focus: true })}<div class="hstack" style="justify-content:space-between;padding:2px 0 0 16px"><span class="t-body-small muted">Standardnamn:<br>Mat & hushåll</span>${btn('Återställ namn', { kind: 'text', icon: 'restart_alt' })}</div>`;
|
||
const actions = custom ? `<div class="vstack" style="gap:8px;padding-top:6px;align-items:flex-start">${btn('Återställ från arkiv', { kind: 'outlined', icon: 'unarchive' })}${btn('Ta bort kategori', { kind: 'danger', icon: 'delete', dis: true })}<span class="hstack t-body-small muted" style="gap:6px;align-items:flex-start;padding:0 4px">${ic('lock', { size: 16 })}<span>Kategorin används av utgifter eller återkommande utgifter och kan inte tas bort.</span></span></div>` :
|
||
`<div class="hstack" style="gap:12px;flex-wrap:wrap;padding-top:6px">${btn('Arkivera', { kind: 'outlined', icon: 'inventory_2' })}<span class="hstack t-body-small muted" style="gap:6px">${ic('lock', { size: 16 })}Kan inte tas bort: standardkategori</span></div>`;
|
||
const b = `<div class="hstack" style="gap:14px;align-items:flex-start">${catTile(key, 64)}<div class="grow">${nameBlock}</div></div>
|
||
<div class="t-title-small" style="padding:0 4px">Ikon</div>
|
||
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:5px" role="radiogroup" aria-label="Ikon">${ICONS30.map(n => `<span role="radio" aria-checked="${n === selIcon}" aria-label="${n}" style="height:48px;display:grid;place-items:center;border-radius:16px;${n === selIcon ? `background:var(--cat-${selCol});color:var(--on-cat)` : 'background:var(--surface-container-high);color:var(--on-surface-variant)'}">${ic(n, { f: n === selIcon })}</span>`).join('')}</div>
|
||
<div class="t-title-small" style="padding:4px 4px 0">Färg</div>
|
||
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:8px;justify-items:center" role="radiogroup" aria-label="Färg">${COLORS12.map(c => `<span role="radio" aria-checked="${c === selCol}" aria-label="${COLOR_SV[c]}" style="width:44px;height:44px;border-radius:50%;background:var(--cat-${c});display:grid;place-items:center;color:var(--on-cat);${c === selCol ? 'box-shadow:0 0 0 3px var(--surface),0 0 0 5px var(--on-surface)' : ''}">${c === selCol ? ic('check', { size: 22 }) : ''}</span>`).join('')}</div>
|
||
${actions}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Ändra kategori', actions: btn('Spara') }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
function scrSync(clock){
|
||
if (clock === 'empty') {
|
||
const b = `<section class="card" style="padding:20px;display:flex;align-items:center;gap:14px"><span class="tile shp" style="width:52px;height:52px;${shp('sunny')};background:var(--owed-container);color:var(--on-owed-container)">${ic('cloud_done', { f: true })}</span><div class="grow"><div class="t-title-medium">Allt är synkat</div><div class="t-body-small muted">Senast lyckade synk 13:59</div></div></section>${btn('Synka nu', { m: true, icon: 'sync', style: 'align-self:stretch' })}`;
|
||
return screen({ top: topBar({ title: 'Synkstatus' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
const b = `${clock ? `<div class="banner err" role="alert"><div class="bt">${ic('schedule', { f: true })}<div class="grow"><div class="t-title-small">Telefonens klocka går fel. Rätta den och synka igen.</div><div class="t-body-medium">Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.</div></div></div><div class="ba">${btn('Datum och tid', { kind: 'text', icon: 'open_in_new' })}</div></div>` : ''}
|
||
<section class="card" style="padding:20px;display:flex;align-items:center;gap:14px"><span class="tile shp" style="width:52px;height:52px;${shp('sunny')};background:${clock ? 'var(--surface-container-highest)' : 'var(--owed-container)'};color:${clock ? 'var(--on-surface-variant)' : 'var(--on-owed-container)'}">${ic(clock ? 'sync_problem' : 'cloud_done', { f: true })}</span><div class="grow"><div class="t-title-medium">${clock ? 'Synkad för 2 h sedan' : 'Synkad för 3 min sedan'}</div><div class="t-body-small muted">${clock ? 'Senaste försöket nekades: klockan' : 'Senast lyckade synk 13:59'}</div></div></section>
|
||
${btn('Synka nu', { m: true, icon: 'sync', style: 'align-self:stretch' })}
|
||
<div class="mh"><span class="t-title-small">Väntande ändringar (2)</span></div>
|
||
<div class="grp">${[['transport', 'SL-kort 30 dagar, Anna', 'Ny', 106000], ['groceries', 'Willys Hornstull', 'Ändrad', 86435]].map(([k, d, s, a]) => `<div class="it row">${catTile(k, 36)}<div class="rc"><div class="t-title-medium ell">${d}</div><div class="t-body-small c-pend hstack" style="gap:4px">${ic('cloud_upload', { size: 15 })}Väntar på synk</div></div><span class="t-amount-small">${kr(a)}</span></div>`).join('')}</div>
|
||
${clock ? '' : `<div class="mh"><span class="t-title-small">Ändringar som inte synkades</span></div>
|
||
<section class="card" style="padding:16px;display:flex;flex-direction:column;gap:10px;box-shadow:inset 0 0 0 1px var(--outline-variant)"><div class="hstack" style="gap:12px;align-items:flex-start"><span class="tile shp" style="width:36px;height:36px;${shp('cookie4')};background:var(--surface-container-highest);color:var(--on-surface-variant)">${ic('receipt_long', { size: 20 })}</span><div class="grow"><div class="t-title-medium">Babette</div><div class="t-body-medium">Din ändring ersattes av en nyare ändring från Anna · 17 okt. 08:31</div><div class="t-body-small muted" style="margin-top:4px">Annas version gäller. Öppna utgiften om du vill göra din ändring igen.</div></div></div><div class="hstack" style="justify-content:flex-end;gap:4px">${btn('Öppna', { kind: 'text' })}${btn('Ta bort från listan', { kind: 'tonal' })}</div></section>`}`;
|
||
return screen({ top: topBar({ title: 'Synkstatus' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
function scrUpdateDialog(){
|
||
const home = scrHome({ update: 'available' });
|
||
const dlg = `<div class="scrim"></div><div class="dlg" role="alertdialog" aria-label="Tillåt installation"><span class="di shp" style="width:56px;height:56px;${shp('sunny')};background:var(--tertiary-container);color:var(--on-tertiary-container);display:grid;place-items:center">${ic('install_mobile', { f: true })}</span><div class="t-headline-small" style="text-align:center">Tillåt installation</div><div class="t-body-medium muted">Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får <b>Installera okända appar</b>.</div><div class="t-body-medium muted">Slå på <b>Tillåt från den här källan</b> och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv.</div><div class="da">${btn('Senare', { kind: 'text' })}${btn('Fortsätt', { icon: 'arrow_forward' })}</div></div>`;
|
||
return home.replace(/<\/div>$/, dlg + '</div>');
|
||
}
|
||
function scrRemoved(){
|
||
const b = `<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;padding:80px 12px 0"><span class="shp" style="width:112px;height:112px;${shp('cookie9')};background:var(--error-container);color:var(--on-error-container);display:grid;place-items:center">${ic('phonelink_erase', { size: 52 })}</span><h1 class="t-headline-medium">Den här telefonen har tagits bort</h1><p class="t-body-large muted">Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats.</p><p class="t-body-medium muted">Vill du använda Kvitt igen behöver du en ny inbjudningskod.</p></div>`;
|
||
return screen({ body: b, padStyle: 'gap:0;min-height:100%', bottom: `<div style="padding:12px 16px 0">${btn('Para ihop igen', { m: true, full: true, icon: 'link' })}</div>` });
|
||
}
|
||
function scrRecurring(mode){
|
||
if (mode === 'empty') {
|
||
const art = `<div style="position:relative;width:170px;height:100px" aria-hidden="true"><div style="position:absolute;left:10px;top:34px;transform:rotate(-14deg)">${catTile('utilities', 56)}</div><div style="position:absolute;left:56px;top:4px;transform:rotate(6deg)">${catTile('housing', 66)}</div><div style="position:absolute;left:104px;top:38px;transform:rotate(16deg)">${catTile('entertainment', 54)}</div></div>`;
|
||
return screen({ top: topBar({ title: 'Återkommande utgifter' }), body: `<div style="padding-top:24px">${emptyState({ art, title: 'Inga återkommande utgifter', body: 'Hyra, el och streaming kan läggas in automatiskt varje månad.', action: btn('Ny återkommande utgift', { m: true, icon: 'add' }) })}</div>`, padStyle: 'gap:10px' });
|
||
}
|
||
const T = [['housing', 'Hyra', 1240000, 'Huset · Anna betalar', 'Varje månad den 25:e<br>Nästa 25\u00A0okt.'], ['utilities', 'Bahnhof bredband', 39900, 'Huset · Du betalar', 'Varje månad den 28:e<br>Nästa 28\u00A0okt.'], ['entertainment', 'Netflix', 17900, 'Vardag · Du betalar', 'Varje månad den 3:e<br>Nästa 3\u00A0nov.'], ['entertainment', 'Spotify Family', 25900, 'Vardag · Anna betalar', 'Varje månad den 12:e', true]];
|
||
const b = `${mode === 'offline' ? offlineBanner('Kräver anslutning. Återkommande utgifter kan bara ändras när telefonen är online.') : ''}<div class="grp">${T.map(([k, d, a, w, s, p]) => `<div class="it row r72" style="align-items:flex-start;padding-top:14px;padding-bottom:14px">${catTile(k, 40, { muted: p })}<div class="rc"><div class="t-title-medium ell" style="${p ? 'opacity:.7' : ''}">${d}</div><div class="t-body-medium muted">${w}</div><div class="t-body-small ${p ? '' : 'muted'}" style="margin-top:2px">${p ? `<span class="badge lantern">${ic('pause', { size: 14 })}Pausad</span> Varje månad den 12:e` : s}</div></div><span class="t-amount-small" style="${p ? 'opacity:.7' : ''}">${kr(a)}</span></div>`).join('')}</div>`;
|
||
return screen({ top: topBar({ title: 'Återkommande utgifter' }), body: b, padStyle: 'gap:10px', fab: fab('Ny återkommande', { dis: mode === 'offline' }) });
|
||
}
|
||
function scrRecurringEditor(){
|
||
const b = `${amountBlock(1240000, '12' + NB + '400,00', { pad: '2px 0 0' })}
|
||
${field({ label: 'Beskrivning', value: 'Hyra' })}
|
||
${catQuick('housing', ['groceries', 'housing', 'home', 'utilities', 'transport'])}
|
||
<div class="chips">${chip('Huset', { dot: 'chart-cat-2', drop: true })}${chip('Anna', { k: 'Betalt av', avatar: 'anna', trail: 'swap_horiz', aria: 'Betalt av: Anna. Dubbeltryck för att byta till dig' })}${chip('Kontots standard 60/40', { k: 'Delning', icon: 'call_split', drop: true })}</div>
|
||
<section class="flat" style="padding:16px;display:flex;flex-direction:column;gap:10px"><div class="t-title-small">Schema</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1.5fr;gap:10px">${field({ label: 'Dag i månaden', value: '25' })}${field({ label: 'Första månad', value: 'september 2026', trail: ic('expand_more') })}</div>
|
||
${field({ label: 'Sista månad (valfritt)', ph: 'Ingen slutmånad', trail: ic('expand_more') })}
|
||
<div class="t-body-small muted" style="padding:0 4px">Ändringen gäller kommande utgifter.</div>
|
||
<div class="hstack" style="gap:12px;padding:4px 4px 0"><div class="grow"><div class="t-title-medium">Pausad</div><div class="t-body-small muted">Skapas kl. 06:00 den 25:e varje månad</div></div><span role="switch" aria-checked="false" aria-label="Pausad" style="width:52px;height:32px;border-radius:16px;box-shadow:inset 0 0 0 2px var(--outline);background:var(--surface-container-highest);position:relative;flex:none"><span style="position:absolute;left:8px;top:8px;width:16px;height:16px;border-radius:50%;background:var(--outline)"></span></span></div>
|
||
</section>
|
||
${btn('Ta bort', { kind: 'danger', icon: 'delete', style: 'align-self:flex-start' })}`;
|
||
return screen({ top: topBar({ nav: 'close', title: 'Återkommande utgift', actions: btn('Spara') }), body: b, padStyle: 'gap:12px' });
|
||
}
|
||
function scrDevices(revoke, mode){
|
||
if (mode === 'loading') {
|
||
const sk = w => `<div class="it row r72" style="padding:16px"><span class="sk" style="width:40px;height:40px;border-radius:14px"></span><div class="rc vstack" style="gap:8px"><span class="sk" style="height:14px;width:${w}px"></span><span class="sk" style="height:10px;width:180px"></span><span class="sk" style="height:10px;width:220px"></span></div></div>`;
|
||
return screen({ top: topBar({ title: 'Enheter' }), body: `<div class="grp" role="progressbar" aria-label="Hämtar enheter">${sk(110)}${sk(130)}</div>`, padStyle: 'gap:10px' });
|
||
}
|
||
if (mode === 'offline') return screen({ top: topBar({ title: 'Enheter' }), body: `<div class="vstack" style="align-items:center;gap:12px;padding-top:220px;text-align:center">${ic('cloud_off', { size: 48, color: 'on-surface-variant' })}<div class="t-title-medium">Kräver anslutning för att visa enheter</div></div>`, padStyle: 'gap:10px' });
|
||
const b = `<div class="grp"><div class="it row r72" style="align-items:flex-start;padding:16px">${tinted('smartphone', 'cat-blue')}<div class="rc"><div class="hstack" style="flex-wrap:wrap;gap:6px"><span class="t-title-medium">Galaxy S26</span><span class="badge lantern">Den här enheten</span></div><div class="hstack t-body-medium muted" style="gap:6px;margin-top:2px">${avatar('jacob', 20)}Jacob</div><div class="t-body-small muted" style="margin-top:4px">Parad 3 okt. · Senast sedd nyss · Kvitt 1.1</div></div></div>
|
||
<div class="it row r72" style="align-items:flex-start;padding:16px">${tinted('smartphone', 'cat-blue')}<div class="rc"><div class="t-title-medium">Galaxy S26+</div><div class="hstack t-body-medium muted" style="gap:6px;margin-top:2px">${avatar('anna', 20)}Anna</div><div class="t-body-small muted" style="margin-top:4px">Parad 3 okt. · Senast sedd 17 okt. 13:47 · Kvitt 1.1</div><div style="margin:8px 0 0 -12px">${btn('Ta bort åtkomst', { kind: 'danger', icon: 'block' })}</div></div></div></div>
|
||
<p class="t-body-small muted" style="padding:4px 8px">Du kan inte ta bort åtkomsten för den telefon du använder just nu.</p>`;
|
||
const dlg = revoke ? `<div class="scrim"></div><div class="dlg" role="alertdialog" aria-label="Ta bort åtkomst"><span class="di shp" style="width:52px;height:52px;${shp('sunny')};background:var(--error-container);color:var(--on-error-container);display:grid;place-items:center">${ic('phonelink_erase')}</span><div class="t-headline-small" style="text-align:center">Ta bort åtkomst för Galaxy S26+?</div><div class="t-body-medium muted">Annas telefon loggas ut och allt Kvitt-data på den raderas. Anna kan para ihop den igen med en ny inbjudningskod.</div><div class="da">${btn('Avbryt', { kind: 'text' })}${btn('Ta bort åtkomst', { kind: 'err-filled' })}</div></div>` : '';
|
||
return screen({ top: topBar({ title: 'Enheter' }), body: b, padStyle: 'gap:10px', overlay: dlg });
|
||
}
|
||
function scrInvites(mode){
|
||
if (mode === 'offline' || mode === 'error') {
|
||
const off = mode === 'offline';
|
||
const card = `<section class="card" style="padding:20px;display:flex;flex-direction:column;gap:12px"><p class="t-body-medium muted">Skapa en engångskod för att para ihop en telefon till. Koden gäller i 72 timmar.</p><div class="hstack" style="gap:8px;flex-wrap:wrap">${btn('För mig', { kind: 'tonal', icon: 'add_link', dis: off })}${btn('För Anna', { kind: 'tonal', icon: 'add_link', dis: off })}</div></section>`;
|
||
const pending = off ? '' : `<div class="mh"><span class="t-title-small">Väntande inbjudningar</span></div><p class="t-body-medium muted" style="padding:0 8px">Inga väntande inbjudningar</p>`;
|
||
const b = `${off ? offlineBanner('Kräver anslutning. Inbjudningar kan bara skapas och visas när telefonen är online.') : ''}${card}${pending}`;
|
||
return screen({ top: topBar({ title: 'Para ihop en enhet' }), body: b, padStyle: 'gap:10px', overlay: off ? '' : snack('Kunde inte nå servern', 'Försök igen') });
|
||
}
|
||
const b = `<section class="card" style="padding:20px;background:var(--tertiary-container);color:var(--on-tertiary-container);display:flex;flex-direction:column;gap:12px"><div class="hstack" style="gap:10px">${ic('key', { f: true })}<span class="t-title-medium grow">Ny enhet för Jacob</span></div>
|
||
<div class="t-label-medium">Inbjudningskod</div>
|
||
<div class="hstack" style="gap:8px;justify-content:center;flex-wrap:wrap" aria-label="7K3M QX9D 2HPA">${['7K3M', 'QX9D', '2HPA'].map(g => `<span style="background:var(--surface);color:var(--on-surface);border-radius:14px;padding:6px 10px;font-weight:700;font-size:calc(var(--fs)*24px);line-height:calc(var(--fs)*32px);letter-spacing:1.5px">${g}</span>`).join('')}</div>
|
||
<div class="t-body-small" style="overflow-wrap:anywhere;text-align:center">https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA</div>
|
||
<div class="hstack t-body-medium" style="gap:6px;justify-content:center">${ic('schedule', { size: 18 })}Gäller till tis 20 okt. 14:02</div>
|
||
<div class="hstack" style="gap:8px;justify-content:center;flex-wrap:wrap">${btn('Dela', { kind: 'tert', icon: 'share' })}${btn('Kopiera kod', { kind: 'outlined', icon: 'content_copy', style: 'color:var(--on-tertiary-container);box-shadow:inset 0 0 0 1px var(--on-tertiary-container)' })}</div></section>
|
||
<p class="t-body-medium muted" style="padding:0 8px">Öppna länken på den nya telefonen, eller skriv in koden i Kvitt. Koden fungerar en gång.</p>
|
||
<div class="mh"><span class="t-title-small">Väntande inbjudningar</span></div>
|
||
<div class="grp"><div class="it row">${tinted('key', 'cat-yellow')}<div class="rc"><div class="t-title-medium">Enhet · Jacob</div><div class="t-body-small muted">Skapad idag 14:02 · Gäller till tis 20 okt. 14:02</div></div>${btn('Avbryt', { kind: 'text' })}</div></div>`;
|
||
return screen({ top: topBar({ title: 'Para ihop en enhet' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
function scrAbout(){
|
||
const b = `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;padding:20px 0 12px"><span class="shp" style="width:96px;height:96px;${shp('sunny')};background:var(--primary);color:var(--on-primary);display:grid;place-items:center">${ic('receipt_long', { f: true, size: 48 })}</span><div class="t-headline-medium" style="margin-top:6px">Kvitt 1.1 (11)</div><div class="t-body-medium muted">Delade utgifter för två</div></div>
|
||
<div class="grp"><div class="it row">${ic('dns', { color: 'on-surface-variant' })}<div class="rc"><div class="t-label-medium muted">Server</div><div class="t-body-large" style="overflow-wrap:anywhere">https://icehack.asuscomm.com:8443</div></div></div><div class="it row">${ic('memory', { color: 'on-surface-variant' })}<div class="rc"><div class="t-label-medium muted">Serverversion</div><div class="t-body-large">1.1.0</div></div></div><div class="it row">${ic('description', { color: 'on-surface-variant' })}<div class="rc t-title-medium">Licenser för öppen källkod</div>${ic('chevron_right', { color: 'on-surface-variant' })}</div></div>
|
||
${btn('Sök efter uppdatering', { kind: 'tonal', m: true, icon: 'system_update', style: 'align-self:center;margin-top:8px' })}`;
|
||
return screen({ top: topBar({ title: 'Om Kvitt' }), body: b, padStyle: 'gap:10px' });
|
||
}
|
||
function scrNotifications(){
|
||
return `<div class="shade"></div><div style="position:absolute;inset:0;padding:20px 12px;display:flex;flex-direction:column;gap:6px;color:var(--sys-on)">
|
||
<div style="padding:6px 12px 14px"><div style="font-size:calc(var(--fs)*40px);line-height:calc(var(--fs)*48px);font-weight:500">14:02</div><div style="font-size:calc(var(--fs)*14px);color:var(--sys-on-variant)">lör 17 okt.</div></div>
|
||
${[['Anna lade till en utgift', 'ICA Maxi Lindhagen · 1' + NB + '247,60' + NB + 'kr · Vardag', 'nu'], ['Anna registrerade en betalning', 'Anna betalade dig 800,00' + NB + 'kr', '5 min']].map(([t, bd, w], i) => `<div class="ntf" style="border-radius:${i === 0 ? '24px 24px 6px 6px' : '6px 6px 24px 24px'}"><span class="ap">${ic('receipt_long', { f: true })}</span><div class="grow"><div class="h">Kvitt · ${w}</div><div class="t">${t}</div><div class="b">${bd}</div></div>${ic('expand_more', { size: 20, style: 'color:var(--sys-on-variant)' })}</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- FRAMES ---------- */
|
||
const LD = ['light', 'dark'], L = ['light'];
|
||
const FRAMES = [
|
||
{ id:'S-1-pairing', title:'S-1 Para ihop', note:'Anna går med (partnerinbjudan). Fält ifyllda från en länk. Koden först (Klistra in), sedan Ditt namn; serveradressen är en hopfälld sekundär rad. Para ihop är en 56 dp-knapp längst ner.', themes:LD, render:() => scrPairing('ok') },
|
||
{ id:'S-1-pairing-loading', title:'S-1 Para ihop · laddar', note:'Parar ihop…: fälten är skrivskyddade och knappen visar en förloppssnurra.', themes:LD, render:() => scrPairing('loading') },
|
||
{ id:'S-1-pairing-error', title:'S-1 Para ihop · fel', note:'invite_invalid: felet sitter under kodfältet med ikon och text; knappen är aktiv igen.', themes:LD, render:() => scrPairing('error') },
|
||
{ id:'S-1-pairing-again', title:'S-1 Para ihop igen (Jacob)', note:'Unrecognised (401): telefonen behåller sina data. Inget namnfält; koden skrivs in igen.', themes:LD, render:() => scrPairing('again') },
|
||
{ id:'S-2-profile-step', title:'S-2 Profilsteg (Anna)', note:'Annas telefon: Jacobs avatar (bild) och Annas initial på avatar-2 i Sunny-form.', themes:LD, render:() => scrProfileStep() },
|
||
{ id:'S-2-profile-step-loading', title:'S-2 Profilsteg · bild laddas upp', note:'Förloppsring runt avataren; Fortsätt väntar tills bilden är uppladdad.', themes:LD, render:() => scrProfileStep('loading') },
|
||
{ id:'S-2-profile-step-error', title:'S-2 Profilsteg · bildfel', note:'Snackbar "Bilden kunde inte användas"; Fortsätt går ändå (bilden är valfri).', themes:LD, render:() => scrProfileStep('error') },
|
||
{ id:'S-3-waiting', title:'S-3 Väntar på partner', note:'Gungbrädan med en tom plats: Jacob väger ner sin sida tills partnern sätter sig. Inställningar nås via navigeringen.', themes:LD, render:() => scrWaiting(false) },
|
||
{ id:'S-3-waiting-offline', title:'S-3 Väntar på partner · offline', note:'Bjud in partner är avstängd med "Kräver anslutning".', themes:LD, render:() => scrWaiting(true) },
|
||
{ id:'S-4-home', title:'S-4 Översikt', note:'Saldot som gungbräda: den som är skyldig sitter lägre, plankan har skuldens färg och orden säger riktningen. Varje konto har sin diagramfärg (samma som i Statistik).', themes:LD, render:() => scrHome({}) },
|
||
{ id:'S-4-home-settled', title:'S-4 Översikt · kvitt', note:'Kalas: när ni är kvitt hängs vimpelgirlangen upp och kortet blir havregult. Ingen Gör upp-knapp. Arkiverade är utfällt.', themes:LD, render:() => scrHome({ settled:true, archOpen:true }) },
|
||
{ id:'S-4-home-empty', title:'S-4 Översikt · inga konton', note:'Tom-läge med en hög kategoriplattor. Ingen FAB, det finns inget konto att lägga till i.', themes:LD, render:scrHomeEmpty },
|
||
{ id:'S-4-home-loading', title:'S-4 Översikt · första synken', note:'Expressive LoadingIndicator (formskiftande cookie/sunny; reserv: CircularProgressIndicator) plus skelettkort.', themes:LD, render:scrHomeLoading },
|
||
{ id:'S-4-home-offline', title:'S-4 Översikt · offline + uppdatering', note:'Synkraden blir en tonad pill med ikonen cloud_off; tryck öppnar S-16. Uppdateringsbannern är havregul (tertiary container).', themes:LD, render:() => scrHome({ offline:true, update:'available' }) },
|
||
{ id:'S-4-home-200', title:'S-4 Översikt · 200 %', note:'fontScale 2, listan scrollad 90 dp (appfältet får surface-container som scrollat) så att första kontot syns helt; FAB:en fälls ihop till en ikon-FAB (TalkBack: Lägg till utgift). Text ≥ 24 sp följer Androids icke-linjära skalning (ca 1,5× i stället för 2×), "kr" bryts till egen rad vid behov; kontobeloppen flyttar till en egen rad under namnet i stället för att kortas.', themes:L, fontScale:2, render:() => scrHome({ big:true }) },
|
||
{ id:'S-4-home-en', title:'S-4 Home · English', note:'Same data in English: SEK 3,250.00; unit before the number.', themes:L, render:() => scrHome({ lang:'en' }) },
|
||
{ id:'S-5-account', title:'S-5 Konto Vardag', note:'Huvudet: namn, 50 / 50 · Jacob och Anna, saldot och Gör upp det här kontot. Menyn visas öppen i S-5-account-menu. Raderna är segmenterade kort; betalningsraden har lerröd ton och handslagsikon. SL-kort har synkmarkören (moln med pil) vid datumet.', themes:LD, render:() => scrAccount({}) },
|
||
{ id:'S-5-account-menu', title:'S-5 Konto Vardag · meny', note:'Överflödesmenyn öppen. Ta bort konto är avstängt och skälet står i menyraden.', themes:LD, render:() => scrAccount({ menu:true }) },
|
||
{ id:'S-5-account-archived', title:'S-5 Konto Gotland 2026 · arkiverat', note:'Arkiverat-märke, Kvitt, Återställ från arkiv. Ingen FAB: arkiverade konton erbjuds inte för nya utgifter (AC-6.4).', themes:LD, render:() => scrAccount({ archived:true }) },
|
||
{ id:'S-5-account-empty', title:'S-5 Konto Sommarstugan · tomt', note:'Nytt konto utan utgifter: saldot är Kvitt och Gör upp visas inte. FAB:en finns.', themes:LD, render:() => scrAccount({ empty:true }) },
|
||
{ id:'S-5-account-offline', title:'S-5 Konto Vardag · offline', note:'Menyn: Ändra konto och Arkivera är avstängda med "Kräver anslutning". Listan och FAB:en fungerar offline.', themes:LD, render:() => scrAccount({ menu:true, offline:true }) },
|
||
{ id:'S-5-account-error', title:'S-5 Konto Vardag · fel', note:'Arkivera misslyckades (servern nås inte): snackbar med Försök igen; ingenting ändrades. FAB:en flyttar upp.', themes:LD, render:() => scrAccount({ error:true }) },
|
||
{ id:'S-5-account-long', title:'S-5 Konto · långt innehåll', note:'Långt kontonamn kortas i appfältet; långa beskrivningar kortas med ellips; beloppet 1 234 567,89 kr kortas aldrig.', themes:LD, render:() => scrAccount({ long:true }) },
|
||
{ id:'S-6-account-editor', title:'S-6 Nytt konto', note:'Delningen som Expressive-reglage (tjock delad bana, smalt handtag) + två procentfält som alltid summerar till 100 + snabbval i en ButtonGroup.', themes:LD, render:() => scrAccountEditor(false) },
|
||
{ id:'S-6-account-editor-offline', title:'S-6 Nytt konto · offline', note:'Kräver anslutning: fälten och Spara är avstängda.', themes:LD, render:() => scrAccountEditor(true) },
|
||
{ id:'S-6-account-editor-error', title:'S-6 Nytt konto · namnet finns', note:'name_taken visas under namnfältet.', themes:LD, render:() => scrAccountEditor(false, true) },
|
||
{ id:'S-7-editor', title:'S-7 Ny utgift · 10 sekunder', note:'Egen siffertangentbordspanel i appen (beskrivningen använder systemtangentbordet). Kategoriraden visar de mest använda; Nöje är vald automatiskt utifrån beskrivningen (Systembolaget), utan markör; användaren kan alltid byta. Delning-chippet öppnar delningen med ett tryck. Spara i appfältet.', themes:LD, render:() => scrEditor(false) },
|
||
{ id:'S-7-editor-split', title:'S-7 Ny utgift · exakta belopp med fel', note:'Beloppet fälls ihop till en kompakt rad när man scrollar; raden visar också den valda kategorin. Alla fyra lägen syns som valchips. Annas andel räknas ut och visas skrivskyddad; Spara är avstängd.', themes:LD, render:scrEditorSplit },
|
||
{ id:'S-7-editor-full', title:'S-7 Ändra utgift · allt på en person', note:'Transport är vald utifrån beskrivningen (SL-kort). Allt på Anna: personväljare som ButtonGroup, delningsstapeln helt i Annas färg, anteckningen utfälld, Ta bort i ändringsläget.', themes:LD, render:scrEditorFull },
|
||
{ id:'S-7-editor-edit', title:'S-7 Ändra utgift ICA Maxi Lindhagen', note:'Ändringsläge: inget tangentbord förrän ett fält får fokus; anteckningen syns; Ta bort utgift längst ner.', themes:LD, render:scrEditorEdit },
|
||
{ id:'S-7-editor-200', title:'S-7 Ny utgift · 200 %', note:'fontScale 2: beloppet och Spara syns, mellandelen scrollar, tangentbordet växer.', themes:L, fontScale:2, render:() => scrEditor(true) },
|
||
{ id:'S-8-expense', title:'S-8 Utgift ICA Maxi Lindhagen', note:'Kategorifärgen bär kortet. Historiken har sista raden "Din ändring väntar på synk" med ihålig punkt i pending-färg.', themes:LD, render:() => scrExpense('normal') },
|
||
{ id:'S-8-expense-recurring', title:'S-8 Utgift · återkommande', note:'Återkommande: Hyra som havregul etikett.', themes:LD, render:() => scrExpense('recurring') },
|
||
{ id:'S-8-expense-deleted', title:'S-8 Utgift · borttagen', note:'Öppnad från en notis: banner överst, innehållet avfärgat, inga Ändra/Ta bort.', themes:LD, render:() => scrExpense('deleted') },
|
||
{ id:'S-8-expense-missing', title:'S-8 Utgift · finns inte än', note:'Öppnad från en notis innan synken hämtat utgiften.', themes:LD, render:() => scrExpense('missing') },
|
||
{ id:'S-9-settle', title:'S-9 Gör upp · förslag', note:'Förslaget enligt §9.4: äldsta skulden först. Kvar att fördela 0,00 kr. Vardag-raden förklaras i en tyst info-rad.', themes:LD, render:() => scrSettle('proposal') },
|
||
{ id:'S-9-settle-adjusted', title:'S-9 Gör upp · justerat', note:'Raden som reglerar mer än skulden får varningsram och varningstext.', themes:LD, render:() => scrSettle('adjusted') },
|
||
{ id:'S-9-settle-remaining', title:'S-9 Gör upp · summan stämmer inte', note:'Kvar att fördela 250,00 kr i varningsfärg; Bekräfta avstängd.', themes:LD, render:() => scrSettle('remaining') },
|
||
{ id:'S-9-settle-account', title:'S-9 Gör upp · ett konto (Huset)', note:'Beloppet går att ändra (förifyllt med kontots skuld); raden är fast och följer beloppet. Ingen Lägg till konto.', themes:LD, render:() => scrSettle('account') },
|
||
{ id:'S-9-confirm-dialog', title:'S-9 Bekräfta-dialog', note:'Dialogen upprepar varningen (AC-11.5).', themes:LD, render:() => scrSettle('dialog') },
|
||
{ id:'S-9-settle-empty', title:'S-9 Gör upp · ni är kvitt', note:'Globalt saldo noll: beloppet är tomt och inga rader visas förrän ett belopp skrivs. Bekräfta är avstängd.', themes:LD, render:() => scrSettle('empty') },
|
||
{ id:'S-9-settle-200', title:'S-9 Gör upp · 200 %', note:'fontScale 2: beloppsfälten på raderna flyttar till en egen rad.', themes:L, fontScale:2, render:() => scrSettle('big') },
|
||
{ id:'S-10-settlement', title:'S-10 Betalning', note:'Avatar → avatar visar riktningen; orden säger den.', themes:LD, render:() => scrSettlement(false) },
|
||
{ id:'S-10-settlement-deleted', title:'S-10 Betalning · borttagen', note:'Banner överst, innehållet avfärgat, inga Ändra/Ta bort, sista historikraden är borttagningen.', themes:LD, render:() => scrSettlement(true) },
|
||
{ id:'S-11-analytics', title:'S-11 Statistik', note:'TalkBack: "Utgifter över tid: högst i september, 41 484 kr; lägst i maj, 24 710 kr. Boende är störst i maj till augusti, Resor i september och oktober." Per kategori: "Boende 34,8 %, Mat & hushåll 20,6 %, Resor 18,3 %."', themes:LD, render:() => scrAnalytics('default') },
|
||
{ id:'S-11-analytics-scrolled', title:'S-11 Statistik · scrollad', note:'Scrollad: slutet av diagram 2 och hela diagram 3. TalkBack per konto: "Huset 44,8 %, Vardag 37,6 %, Japan 2027 15,2 %, Gotland 2026 2,5 %."', themes:LD, render:() => scrAnalytics('scrolled') },
|
||
{ id:'S-11-analytics-week', title:'S-11 Statistik · vecka, min del, per konto', note:'TalkBack: "Min del per vecka: högst vecka 41, 4 300 kr; lägst vecka 42, 1 730 kr." Kontofärgerna är desamma som på Översikt.', themes:LD, render:() => scrAnalytics('week') },
|
||
{ id:'S-11-analytics-empty', title:'S-11 Statistik · tom period', note:'Anpassad period utan utgifter.', themes:LD, render:() => scrAnalytics('empty') },
|
||
{ id:'S-11-analytics-200', title:'S-11 Statistik · 200 %', note:'fontScale 2: filtren bryts på flera rader, diagrammet behåller sin höjd.', themes:L, fontScale:2, render:() => scrAnalytics('default') },
|
||
{ id:'S-12-drilldown', title:'S-12 Mat & hushåll, oktober 2026', note:'Summa och rader som i S-5.', themes:LD, render:scrDrill },
|
||
{ id:'S-13-settings', title:'S-13 Inställningar', note:'Varje rad har en egen tonad ikonplatta, grupperade i segmenterade kort.', themes:LD, render:() => scrSettings(false) },
|
||
{ id:'S-13-settings-member', title:'S-13 Inställningar (Anna, medlem)', note:'Medlem: ingen Para ihop en enhet.', themes:LD, render:() => scrSettings(true) },
|
||
{ id:'S-14-profile', title:'S-14 Profil', note:'', themes:LD, render:() => scrProfile() },
|
||
{ id:'S-14-profile-loading', title:'S-14 Profil · bild laddas upp', note:'Förloppsring runt avataren; knapparna är dolda tills uppladdningen är klar.', themes:LD, render:() => scrProfile('loading') },
|
||
{ id:'S-14-profile-offline', title:'S-14 Profil · offline', note:'Fält och knappar avstängda.', themes:LD, render:() => scrProfile('offline') },
|
||
{ id:'S-15-categories', title:'S-15 Kategorier', note:'14 standardkategorier i sorteringsordning. Dra-handtag med TalkBack-etikett "Flytta <namn>". Listan scrollar; slutet med Arkiverade syns i nästa ram.', themes:LD, render:() => scrCategories(false) },
|
||
{ id:'S-15-categories-scrolled', title:'S-15 Kategorier · scrollad', note:'Slutet av listan med Arkiverade (1) och den arkiverade egna kategorin Pant.', themes:LD, render:() => scrCategories(true) },
|
||
{ id:'S-15-categories-offline', title:'S-15 Kategorier · offline', note:'Inga draghandtag, ingen pil, Ny kategori avstängd.', themes:LD, render:() => scrCategories(false, true) },
|
||
{ id:'S-15-category-editor', title:'S-15 Ändra kategori', note:'Alla 33 ikoner (nya: key, shield, account_balance) och 14 färger (nya: slate, amber). Standardkategorier kan arkiveras men inte tas bort.', themes:LD, render:() => scrCategoryEditor(false) },
|
||
{ id:'S-15-category-editor-custom', title:'S-15 Ändra kategori · egen, arkiverad (Pant)', note:'Återställ från arkiv; Ta bort kategori avstängd med skälet.', themes:LD, render:() => scrCategoryEditor(true) },
|
||
{ id:'S-16-sync', title:'S-16 Synkstatus', note:'', themes:LD, render:() => scrSync(false) },
|
||
{ id:'S-16-sync-clock', title:'S-16 Synkstatus · klockan går fel', note:'', themes:LD, render:() => scrSync(true) },
|
||
{ id:'S-16-sync-empty', title:'S-16 Synkstatus · allt synkat', note:'Inga väntande ändringar och inga avvisade.', themes:LD, render:() => scrSync('empty') },
|
||
{ id:'S-17-update', title:'S-17 Uppdatering · tillåt installation', note:'Visas första gången, före systemets inställning.', themes:LD, render:scrUpdateDialog },
|
||
{ id:'S-17-update-progress', title:'S-17 Uppdatering · laddar ner', note:'Expressive vågig linjär förlopp (LinearWavyProgressIndicator; reserv: LinearProgressIndicator).', themes:LD, render:() => scrHome({ update:'progress' }) },
|
||
{ id:'S-17-update-failed', title:'S-17 Uppdatering · misslyckades', note:'', themes:LD, render:() => scrHome({ update:'failed' }) },
|
||
{ id:'S-18-removed', title:'S-18 Enheten borttagen', note:'', themes:LD, render:scrRemoved },
|
||
{ id:'S-19-recurring', title:'S-19 Återkommande utgifter', note:'', themes:LD, render:() => scrRecurring() },
|
||
{ id:'S-19-recurring-empty', title:'S-19 Återkommande · tom', note:'Ingen FAB; knappen sitter i tom-läget.', themes:LD, render:() => scrRecurring('empty') },
|
||
{ id:'S-19-recurring-offline', title:'S-19 Återkommande · offline', note:'Listan går att läsa; Ny återkommande är avstängd.', themes:LD, render:() => scrRecurring('offline') },
|
||
{ id:'S-19-recurring-editor', title:'S-19 Återkommande · Hyra', note:'Samma fält som S-7 utom datum, plus schema.', themes:LD, render:scrRecurringEditor },
|
||
{ id:'S-20-devices', title:'S-20 Enheter', note:'', themes:LD, render:() => scrDevices(false) },
|
||
{ id:'S-20-devices-revoke', title:'S-20 Enheter · bekräfta', note:'', themes:LD, render:() => scrDevices(true) },
|
||
{ id:'S-20-devices-loading', title:'S-20 Enheter · hämtar', note:'Listan hämtas från servern (online).', themes:LD, render:() => scrDevices(false, 'loading') },
|
||
{ id:'S-20-devices-offline', title:'S-20 Enheter · offline', note:'', themes:LD, render:() => scrDevices(false, 'offline') },
|
||
{ id:'S-21-invites', title:'S-21 Para ihop en enhet', note:'Koden i tre grupper om fyra på havregult kort.', themes:LD, render:() => scrInvites() },
|
||
{ id:'S-21-invites-offline', title:'S-21 Para ihop en enhet · offline', note:'Skapa inbjudan avstängd; väntande inbjudningar kan inte hämtas.', themes:LD, render:() => scrInvites('offline') },
|
||
{ id:'S-21-invites-error', title:'S-21 Para ihop en enhet · fel', note:'Skapa misslyckades: snackbar med Försök igen.', themes:LD, render:() => scrInvites('error') },
|
||
{ id:'S-22-about', title:'S-22 Om Kvitt', note:'', themes:LD, render:scrAbout },
|
||
{ id:'N-notifications', title:'Notiser', note:'Ritade neutralt som Android 16-notiser (inte One UI); appikonen i Kvitts lerröda primärfärg.', themes:LD, render:scrNotifications },
|
||
];
|
||
|
||
/* ---------- phone shell ---------- */
|
||
function phone(f, theme){
|
||
const offline = /offline/.test(f.id);
|
||
const sb = `<div class="sb"><span>14:02</span><span class="sbi">${offline ? ic('wifi_off', { f: true }) + ic('signal_cellular_off', { f: true }) : ic('signal_cellular_alt', { f: true }) + ic('wifi', { f: true })}${ic('battery_5_bar', { f: true })}</span></div>`;
|
||
return `<div class="phone" data-frame-id="${f.id}-${theme}" data-theme="${theme}" style="--fs:${f.fontScale || 1}">${sb}<div class="app">${f.render()}</div><div class="handle"></div></div>`;
|
||
}
|
||
|
||
/* ---------- review page ---------- */
|
||
function contrastCell(fg, bg, kind){
|
||
const r = contrast(fg, bg), min = kind === 'text' ? 4.5 : 3;
|
||
return `<span class="${r < min ? 'bad' : 'ok'}">${r.toFixed(2)}</span>`;
|
||
}
|
||
function paletteTable(){
|
||
const sw = h => `<span class="sw" style="background:${h}"></span><code>${h}</code>`;
|
||
const P = [
|
||
['primary','surface','text'],['on-primary','primary','text'],['primary-container','—'],['on-primary-container','primary-container','text'],
|
||
['secondary','surface','text'],['on-secondary','secondary','text'],['secondary-container','—'],['on-secondary-container','secondary-container','text'],
|
||
['tertiary','surface','text'],['on-tertiary','tertiary','text'],['tertiary-container','—'],['on-tertiary-container','tertiary-container','text'],
|
||
['error','surface','text'],['on-error','error','text'],['error-container','—'],['on-error-container','error-container','text'],
|
||
['surface','—'],['on-surface','surface','text'],['surface-variant','—'],['on-surface-variant','surface','text'],
|
||
['surface-container-lowest','—'],['surface-container-low','—'],['surface-container','—'],['surface-container-high','—'],['surface-container-highest','—'],['surface-dim','—'],['surface-bright','—'],
|
||
['inverse-surface','—'],['inverse-on-surface','inverse-surface','text'],['inverse-primary','inverse-surface','text'],['outline','surface','graphic'],['outline-variant','surface','decor'],['scrim','—'],
|
||
['owed','surface','text'],['on-owed','owed','text'],['owed-container','—'],['on-owed-container','owed-container','text'],
|
||
['owing','surface','text'],['on-owing','owing','text'],['owing-container','—'],['on-owing-container','owing-container','text'],
|
||
['settled','surface','text'],['settled-container','—'],['on-settled-container','settled-container','text'],
|
||
['warning','surface','text'],['on-warning','warning','text'],['warning-container','—'],['on-warning-container','warning-container','text'],
|
||
['pending','surface','text'],
|
||
['avatar-1','—'],['on-avatar-1','avatar-1','text'],['avatar-2','—'],['on-avatar-2','avatar-2','text'],
|
||
['card','—'],['on-cat','—']
|
||
];
|
||
const row = ([k, bg, kind]) => `<tr><td><code>--${k}</code></td>${['light','dark'].map(th => `<td>${sw(TOK[th][k])}</td><td class="num">${bg === '—' || kind === 'decor' ? (kind === 'decor' ? '<span class="muted">decor</span>' : '') : contrastCell(TOK[th][k], TOK[th][bg], kind) + ` <small>vs ${bg}</small>`}${(k === 'owed' || k === 'owing' || k === 'pending' || k === 'settled' || k === 'warning' || k === 'on-surface-variant' || k === 'primary') ? ' · ' + contrastCell(TOK[th][k], TOK[th]['card'], 'text') + ' <small>vs card</small>' : ''}</td>`).join('')}</tr>`;
|
||
return `<table class="spec"><tr><th>Role</th><th>Light</th><th>Contrast</th><th>Dark</th><th>Contrast</th></tr>${P.map(row).join('')}</table>`;
|
||
}
|
||
function catTable(){
|
||
const rows = COLORS12.map(c => `<tr><td><code>--cat-${c}</code></td>${['light','dark'].map(th => { const v = TOK[th]['cat-' + c], ct = TOK[th]['cat-' + c + '-container']; return `<td><span class="sw" style="background:${v}"></span><code>${v}</code></td><td><span class="sw" style="background:${ct}"></span><code>${ct}</code></td><td class="num">${contrastCell(v, TOK[th].surface, 'graphic')} / ${contrastCell(v, TOK[th]['surface-container-high'], 'graphic')} / ${contrastCell(v, ct, 'graphic')} / ${contrastCell(TOK[th]['on-cat'], v, 'graphic')}</td>`; }).join('')}</tr>`).join('');
|
||
return `<table class="spec"><tr><th>Token</th><th>Light</th><th>Light container</th><th>vs surface / s-c-high / container / on-cat icon on it (all graphics, ≥ 3:1)</th><th>Dark</th><th>Dark container</th><th>vs surface / s-c-high / container / on-cat icon on it (all graphics, ≥ 3:1)</th></tr>${rows}</table>`;
|
||
}
|
||
function chartTable(){
|
||
const keys = [...Array(8)].map((_, i) => 'chart-cat-' + (i + 1)).concat([...Array(5)].map((_, i) => 'chart-seq-' + (i + 1)), ['chart-grid', 'chart-axis-label']);
|
||
return `<table class="spec"><tr><th>Token</th><th>Light</th><th>vs card (graphics) · on-cat on it (text)</th><th>Dark</th><th>vs card · on-cat on it</th></tr>${keys.map(k => `<tr><td><code>--${k}</code></td>${['light','dark'].map(th => `<td><span class="sw" style="background:${TOK[th][k]}"></span><code>${TOK[th][k]}</code></td><td class="num">${k.startsWith('chart-cat') ? contrastCell(TOK[th][k], TOK[th].card, 'graphic') + ' · ' + contrastCell(TOK[th]['on-cat'], TOK[th][k], 'text') : k === 'chart-axis-label' ? contrastCell(TOK[th][k], TOK[th].card, 'text') : k.startsWith('chart-seq') ? contrast(TOK[th][k], TOK[th].card).toFixed(2) + ' <small>(sequential ramp: the light end is a background fill that always carries a label; marks use seq-3 to seq-5, all ≥ 3:1)</small>' : '<small>decor</small>'}</td>`).join('')}</tr>`).join('')}</table>`;
|
||
}
|
||
function deTable(){
|
||
const cell = th => catPairs(CAT_BASE[th]).slice(0, 5).map(([d, a, b]) => `${a}–${b} <b>${d.toFixed(1)}</b>`).join('<br>');
|
||
return `<table class="spec"><tr><th></th><th>Closest pairs of the 14 tile colours (CIEDE2000)</th></tr><tr><td>Light tiles</td><td>${cell('light')}</td></tr><tr><td>Dark tiles</td><td>${cell('dark')}</td></tr></table><p>A ΔE00 above about 10 is clearly distinguishable side by side.</p>`;
|
||
}
|
||
function beforeAfter(){
|
||
const pick = ['S-4-home', 'S-5-account', 'S-11-analytics'].map(id => FRAMES.find(f => f.id === id));
|
||
const ph = (f, th, old) => `<div class="phone${old ? ' old' : ''}" data-theme="${th}" style="--fs:1">${`<div class="sb"><span>14:02</span><span class="sbi">${ic('signal_cellular_alt', { f: true }) + ic('wifi', { f: true }) + ic('battery_5_bar', { f: true })}</span></div>`}<div class="app">${f.render()}</div><div class="handle"></div></div>`;
|
||
return pick.map(f => `<section class="group"><h3>${f.title}</h3>${['light', 'dark'].map(th => `<div class="pairs">${[true, false].map(old => `<figure>${ph(f, th, old)}<figcaption><b>${old ? 'Före (ljus, 210ab5c)' : 'Efter (dämpad)'} · ${th}</b></figcaption></figure>`).join('')}</div>`).join('')}</section>`).join('');
|
||
}
|
||
function typeTable(){
|
||
return `<table class="spec"><tr><th>Style</th><th>Size (sp)</th><th>Line height</th><th>Weight</th><th>Tracking</th><th>Sample</th></tr>${TYPE.map(([n, s, lh, w, ls]) => `<tr><td><code>${n}</code></td><td class="num">${s}</td><td class="num">${lh}</td><td class="num">${w}</td><td class="num">${ls}</td><td><div class="tk" data-theme="light" style="--fs:1;background:none;font-family:Roboto"><span class="t-${n}" style="white-space:nowrap">${n.startsWith('amount') ? '3' + NB + '250,00' + NB + 'kr' : n.startsWith('display') ? 'Kvitt' : 'Gör upp'}</span></div></td></tr>`).join('')}</table>`;
|
||
}
|
||
function reviewPage(){
|
||
const groups = [];
|
||
FRAMES.forEach(f => { const g = f.id.split('-').slice(0, 2).join('-'); let G = groups.find(x => x.g === g); if (!G) { G = { g, items: [] }; groups.push(G); } G.items.push(f); });
|
||
const states = th => [['owed', 118450], ['owing', -325000], ['settled', 0]].map(([n, v]) => `<div class="tk" data-theme="${th}"><div class="cap">${th} · ${n}</div>${balanceHero(v, {})}</div>`).join('');
|
||
document.body.className = 'review';
|
||
document.body.innerHTML = `<div class="wrap">
|
||
<header><h1><small>Kvitt · U2 · bindande skärmkatalog</small>Kvitt — skärmar (U2)</h1>
|
||
<p class="idea">Every screen of SPEC §13.3 in every state of the U2 state matrix, light and dark, Swedish, in the chosen direction Kalas (muted palette, Roboto, 14 default categories, 33 icons).</p>
|
||
<p>Kalas is card-based and calm: off-white (light) or warm brown-black (dark) surfaces, white or tonal cards with very round corners, and one solid colour per thing — the dusty category colour on every expense tile, the account's chart colour on every account card, clay for the one primary action. The balance is a two-person seesaw: whoever owes sits lower, the plank takes the colour of the debt, and the words always say who owes whom. When you are quits, bunting goes up and the card turns oat. This page is the visual source for <code>docs/ux/design-spec.md</code>; the frame ids are the ids of its state lists, and every PNG in <code>docs/ux/screens/</code> is one frame of this page.</p></header>
|
||
|
||
<h2>Direction spec</h2>
|
||
<h3>Colour roles (Material 3 + Kvitt semantic)</h3>
|
||
<p>Contrast is computed in JavaScript from the same token object that themes the phones. Red marks text below 4.5:1 or graphics below 3:1. <code>owed</code> (sage green, partner owes you) and <code>owing</code> (dusty plum, you owe) are a purple/green pair, distinguishable in deuteranopia, protanopia and tritanopia, and always come with words. <code>settled</code> uses the straw/oat family. Extras beyond the brief's names: <code>--card</code> (card fill: white on off-white in light, surface-container in dark), <code>--on-cat</code> (icon colour on a solid category/chart tile), <code>--shadow</code>, <code>--photo-*</code> (Jacob's picture stand-in), <code>--sys-*</code> (neutral Android notification shade). Dark containers for straw/oat and warning are pushed toward brown (hue ≈ 32°) so they never read olive.</p>
|
||
${paletteTable()}
|
||
<h3>Category colours (§9.7 tokens)</h3>
|
||
<p>Muted, dusty versions of the twelve §9.7 tokens plus two new tokens, <code>slate</code> (Försäkringar; looks plum, TalkBack "Plommon") and <code>amber</code> (Avgifter & skatter; looks sand, TalkBack "Sand"), so each of the 14 default categories (owner decision 2026-10-18: new Boende/key/indigo, Försäkringar/shield/slate, Barn/child_care/lime, Avgifter & skatter/account_balance/amber) has its own colour. The icon set grows to 33 with <code>key</code>, <code>shield</code> and <code>account_balance</code>. Light <code>brown</code> was darkened from #7A5F50 to #6B5244 to keep it apart from <code>amber</code>; the archived example category Pant moved from lime to teal so it no longer shares Barn's colour. In Kalas the category colour is used <b>solid</b> as the tile fill with the icon in <code>--on-cat</code> (white in light, brown-black in dark), so every row still starts with a colour block, just a quieter one. The container (<code>--cat-*-container</code>) is the colour at 18 % over surface in light and 24 % in dark; it fills unselected picker tiles (icon in the category colour) and the expense-detail hero card. Category colours are never used as text. To keep muted tiles apart, lightness is spread on purpose (indigo, brown and purple darker; yellow, orange and lime lighter) and every pair is checked with CIEDE2000 below.</p>
|
||
${deTable()}
|
||
${catTable()}
|
||
<h3>Chart colours</h3>
|
||
<p>Accounts take <code>chart-cat-n</code> in created order (Vardag 1, Huset 2, Gotland 2026 3, Japan 2027 4): the same colour is the account's clover tile on Home, its card tint on the account screen and its series in Statistik. Categories use their own category colour in charts. Stacked bar segments are separated by 2 dp gaps so adjacent colours never touch.</p>
|
||
${chartTable()}
|
||
|
||
<h3>Typography</h3>
|
||
<p><b>Roboto</b>, the Material 3 default typeface. SPEC v0.5 decision <b>D-020</b>: the app bundles no font file and uses the platform default, so there is no licence step and no font resource; this page previews it with Google Fonts Roboto so the owner sees what will ship. Roboto's figures are tabular by default; amounts also set <code>tnum</code> (Compose: <code>fontFeatureSettings = "tnum"</code>), and the columns of amounts in the ledger and breakdown frames align. The friendliness Nunito gave now comes from shape and colour: very round cards and buttons, MaterialShapes tiles and avatars, the seesaw and the bunting. Weights: 600 for titles and labels, 700 for amounts, 400 for body. Note for M6: <code>FontFamily.Default</code> resolves to the system sans-serif, which on a Galaxy with a custom One UI font style may not be Roboto; check on the S26 that amounts stay tabular. All sizes are sp. The 200 % frames follow Android 14+ <b>non-linear font scaling</b>, which Compose applies by default: text of 24 sp and larger grows less than the system scale (approximated here as at most 1.5×, so the 44 sp hero amount becomes about 66 dp, not 88 dp); text below 24 sp scales fully. The hero unit "kr"/"SEK" may wrap to its own line, and nav-bar labels are capped at 1.6× so "Inställningar" fits.</p>
|
||
${typeTable()}
|
||
<h3>Spacing</h3>
|
||
<table class="spec"><tr><th>Token</th>${[1,2,3,4,5,6,8,10,12].map(n => `<th>--space-${n}</th>`).join('')}</tr><tr><td>dp</td>${[4,8,12,16,20,24,32,40,48].map(v => `<td class="num">${v}</td>`).join('')}</tr></table>
|
||
<p>Screen gutter 16; card padding 16–20; gap between cards 10–12; segmented list items 3 dp apart; touch targets ≥ 48.</p>
|
||
<h3>Shape</h3>
|
||
<table class="spec"><tr><th>Token</th><th>dp</th><th>Used for</th></tr>
|
||
${[['--radius-xs','4','progress track ends, chart segments (3)'],['--radius-sm','8','segmented list middle items, connected-button inner corners, menu items (12 → md)'],['--radius-md','12','menu items, warning strips inside cards'],['--radius-lg','16','text fields (all four corners, no indicator line), menus, inner info boxes'],['--radius-lg-inc','20','FAB (56 dp)'],['--radius-xl','28','cards, balance hero, dialogs, keypad sheet top'],['--radius-xl-inc','32','—'],['--radius-xxl','48','—'],['--radius-full','999','buttons, chips, keypad keys, sync pill, badges, nav indicator']].map(r => `<tr><td><code>${r[0]}</code></td><td class="num">${r[1]}</td><td>${r[2]}</td></tr>`).join('')}</table>
|
||
<p>Account cards and segmented groups use 24 outer corners (between lg-inc and xl). Expressive <b>MaterialShapes</b>: <b>Cookie4Sided</b> for category tiles (fallback <code>RoundedCornerShape(30%)</code>), <b>Clover4Leaf</b> for account tiles (fallback <code>RoundedCornerShape(16.dp)</code>), <b>Sunny</b> for large avatars, app mark and dialog icons (fallback <code>CircleShape</code>), <b>Cookie9Sided</b> for the empty/removed illustrations and the loading indicator. Avatars in rows stay circles.</p>
|
||
<h3>Elevation and motion</h3>
|
||
<ul><li>Elevation is tonal first: in light, white cards (<code>--card</code>) sit on off-white with a soft warm shadow (0 1 2 and 0 2 8 at 8 % brown); in dark there are no visible shadows, cards are surface-container on surface. FAB and menus at level 3, dialogs at level 3 over a 38 % scrim.</li>
|
||
<li>Motion uses <code>MotionScheme.expressive()</code>: spatial springs bounce a little, effect springs never do. Spatial default: damping 0.8, stiffness 380 (≈ 350 ms). Fast spatial: damping 0.6, stiffness 800 (chips, tile selection). Effects: damping 1.0, stiffness 1600 (colour and opacity, ≈ 150 ms).</li>
|
||
<li>Seesaw: when the balance changes the plank re-tilts on a bouncy spring (damping 0.55, stiffness 300) and the amount counts to the new value over 400 ms (emphasized decelerate). Reaching zero levels the plank, then the bunting drops in one pennant at a time (40 ms stagger) and the card cross-fades to oat.</li>
|
||
<li>Category tile selection: the tile morphs from container tint to solid and the check badge pops (scale 0 → 1, fast spatial). Saving an expense: the new row slides into its month group with the FAB morphing back.</li>
|
||
<li>Reduced motion (system "Remove animations"): every spring becomes critically damped, staggered bunting appears at once, counting is skipped.</li></ul>
|
||
<h3>Iconography</h3>
|
||
<p>Material Symbols <b>Rounded</b>, weight 500 (UI) and 600 (filled), grade 0, optical size 24. Category icons, account glyphs, nav-bar active icon, status icons and banners are <b>filled</b> (FILL 1); actions in app bars and outline affordances are unfilled (FILL 0). Icons are dp, not sp. Every icon-only control has a Swedish TalkBack label.</p>
|
||
<h3>Balance header, three states (real component)</h3>
|
||
<div class="states">${states('light')}</div><div class="states">${states('dark')}</div>
|
||
<h3>Dynamic colour</h3>
|
||
<p><b>Not offered.</b> Kalas's identity is that colour means something: category colours identify expenses, account colours tie Home to Statistik, owed/owing are a checked colour-blind-safe pair, and settled is oat. A wallpaper-derived scheme would recolour the primary and containers but could not touch those semantic colours, so a Material You variant would end up with a random primary fighting fixed category colours. The cost of offering it is high and the value low for two users.</p>
|
||
<h3>Material 3 Expressive components and stable fallbacks</h3>
|
||
<table class="spec"><tr><th>Expressive</th><th>Where</th><th>Stable Material 3 fallback</th></tr>
|
||
${[['MaterialShapes (Cookie4Sided, Clover4Leaf, Sunny, Cookie9Sided) via RoundedPolygon.toShape()','category, account and avatar tiles, illustrations','RoundedCornerShape / CircleShape'],['ShortNavigationBar (64 dp)','bottom navigation','NavigationBar (80 dp)'],['ButtonGroup (connected)','Månad/Vecka, Hushållet/Min del, Kategori/Konto, split quick picks, "all on" person picker','SingleChoiceSegmentedButtonRow'],['Expressive Slider (thick track, thin handle)','default split on S-6 (split bar with handle)','Slider with custom track colours'],['LoadingIndicator (shape morph)','first sync on Home','CircularProgressIndicator'],['LinearWavyProgressIndicator','update download','LinearProgressIndicator'],['ExtendedFloatingActionButton, medium size (56 dp, 20 radius) in primary','Lägg till utgift','ExtendedFloatingActionButton'],['Button sizes (small 40 / medium 56) with full shape','all buttons','Button with heightIn'],['Segmented list items (ListItem with grouped shapes)','ledger, settings, devices','Card + ListItem with custom shape']].map(r => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td></tr>`).join('')}</table>
|
||
<h3>Decisions worth knowing</h3>
|
||
<ul><li><b>Keypad:</b> the amount uses an in-app keypad (3 × 4 rounded keys on a sheet) so the amount field never waits for the IME and can enforce §9.6 input rules key by key; the description switches to the Swedish system keyboard. Spara lives in the top app bar so it is always reachable.</li>
|
||
<li><b>Navigation:</b> the binding model of §13.2 unchanged: three destinations, FAB on Home and Account detail, pushed screens with a back arrow.</li>
|
||
<li><b>200 %:</b> see S-4-home-200, S-7-editor-200, S-9-settle-200 and S-11-analytics-200. Account amounts drop below the name instead of truncating; category labels and filter groups wrap; allocation fields move to their own line; text of 24 sp and larger follows non-linear scaling.</li>
|
||
<li><b>Scrolled frames:</b> <code>S-11-analytics-scrolled</code> and <code>S-15-categories-scrolled</code> show the lower part of screens that do not fit in 891 dp.</li>
|
||
<li><b>Errors from online actions</b> (§9.9) are snackbars with "Försök igen"; nothing changes locally. Offline states disable online-only actions with "Kräver anslutning" and keep everything local readable.</li></ul>
|
||
|
||
|
||
<h2>Frames</h2>
|
||
${groups.map(G => `<section class="group"><h3>${G.g}</h3>${G.items.map(f => `<div class="pairs">${f.themes.map(th => `<figure>${phone(f, th)}<figcaption><b>${f.title} · ${th}</b>${f.note || ''}</figcaption></figure>`).join('')}</div>`).join('')}</section>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- modes ---------- */
|
||
const Q = new URLSearchParams(location.search);
|
||
if (Q.get('list')) {
|
||
document.body.innerHTML = '<pre id="frame-list">' + FRAMES.flatMap(f => f.themes.map(t => f.id + '-' + t)).join('\n') + '</pre>';
|
||
} else if (Q.get('frame')) {
|
||
const id = Q.get('frame'); const m = id.match(/^(.*)-(light|dark)$/);
|
||
const f = m && FRAMES.find(x => x.id === m[1]);
|
||
document.body.className = 'frame';
|
||
document.body.innerHTML = f ? phone(f, m[2]) : '<p>unknown frame ' + id + '</p>';
|
||
} else {
|
||
reviewPage();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|