UX: Kalas muted palette and Roboto, owner answers and proposed decisions

Owner picked direction 3 Kalas with calmer colours. The palette, category and chart
colours are muted (closest category pair CIEDE2000 13.7 light, 12.3 dark), the page
uses the Material 3 default typeface (D-020), S-7 shows the category chosen from the
description, and a before/after section compares bright and muted. Owner answers,
findings and proposed decision and spec text are in docs/ux/proposed-decisions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 07:37:04 +02:00
parent b72ac5bb9e
commit 01b36e77b2
4 changed files with 391 additions and 73 deletions
+174 -73
View File
@@ -6,7 +6,7 @@
<title>Kvitt · 3 Kalas</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=Nunito:wght@400..900&display=block" rel="stylesheet">
<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>
@@ -14,14 +14,14 @@
*{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:Nunito,system-ui,sans-serif;font-size:15px;line-height:1.5;padding:40px 24px 80px}
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 h1{font-size:40px;line-height:1.1;font-weight:900;letter-spacing:-.5px}
.review h1 small{display:block;font-size:15px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#9C3B22;margin-bottom:6px}
.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 p{max-width:860px;margin:8px 0}
.review h2{font-size:26px;font-weight:900;margin:48px 0 12px;padding-top:16px;border-top:2px solid #DCCFC6}
.review h3{font-size:18px;font-weight:900;margin:26px 0 8px}
.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}
@@ -30,12 +30,12 @@ body.review{background:#EFE8E2;color:#2A201E;font-family:Nunito,system-ui,sans-s
.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 ul{margin:6px 0 6px 22px;max-width:860px}
.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:Nunito,sans-serif;--fs:1}
.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 .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}
@@ -47,14 +47,14 @@ body.frame{margin:0;background:transparent}
/* ---------- phone ---------- */
.phone{position:relative;width:411px;height:891px;overflow:hidden;background:var(--surface);color:var(--on-surface);
font-family:Nunito,system-ui,sans-serif;font-variant-numeric:tabular-nums;font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*20px);font-weight:500;
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:700;color:var(--on-surface)}
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}
@@ -79,12 +79,12 @@ body.frame{margin:0;background:transparent}
.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:900;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{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:800;
.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)}
@@ -104,7 +104,7 @@ body.frame{margin:0;background:transparent}
/* 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:700;font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*18px);max-width:100%}
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)}
@@ -116,7 +116,7 @@ body.frame{margin:0;background:transparent}
/* segmented / connected button group */
.seg{display:flex;gap:2px}
.seg>span{flex:1;display:flex;align-items:center;justify-content:center;gap:4px;min-height:40px;padding:6px 12px;background:var(--surface-container-high);
font-weight:800;font-size:calc(var(--fs)*14px);line-height:calc(var(--fs)*18px);border-radius:8px;text-align:center;color:var(--on-surface-variant)}
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}
@@ -125,8 +125,8 @@ body.frame{margin:0;background:transparent}
/* 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:700}
.fld .val{font-size:calc(var(--fs)*16px);line-height:calc(var(--fs)*24px);font-weight:600;overflow-wrap:anywhere}
.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)}
@@ -157,7 +157,7 @@ body.frame{margin:0;background:transparent}
.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:800;font-size:calc(var(--fs)*11px);line-height:calc(var(--fs)*16px);white-space:nowrap;vertical-align:middle}
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)}
@@ -167,7 +167,7 @@ body.frame{margin:0;background:transparent}
.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:900}
.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%}
@@ -175,15 +175,15 @@ body.frame{margin:0;background:transparent}
/* 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:800;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)}
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:700;font-size:calc(min(var(--fs),1.6)*12px);line-height:calc(min(var(--fs),1.6)*16px);padding:6px 2px;text-align:center}
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:900}
.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}
@@ -193,10 +193,10 @@ body.frame{margin:0;background:transparent}
.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:800;color:var(--on-surface-variant)}
.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:900;font-size:calc(min(var(--fs),1.5)*24px);line-height:calc(min(var(--fs),1.5)*30px)}
.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%}
@@ -206,7 +206,7 @@ body.frame{margin:0;background:transparent}
.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:900;color:var(--on-cat);font-size:calc(min(var(--fs),1.3)*18px)}
.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}
@@ -219,7 +219,7 @@ body.frame{margin:0;background:transparent}
/* 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:800;
.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)}
@@ -227,8 +227,8 @@ body.frame{margin:0;background:transparent}
/* 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:700;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:900}
.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}
@@ -244,7 +244,7 @@ body.frame{margin:0;background:transparent}
/* 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:700;font-size:calc(var(--fs)*13px);line-height:calc(var(--fs)*18px);color:var(--on-surface-variant);max-width:100%}
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)}
@@ -276,15 +276,15 @@ body.frame{margin:0;background:transparent}
/* charts */
.chart{position:relative;height:170px;margin:8px 0 0 44px}
.chart .gl{position:absolute;left:0;right:0;height:1px;background:var(--chart-grid)}
.chart .yl{position:absolute;left:-46px;width: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:700}
.chart .yl{position:absolute;left:-46px;width: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 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:700}
.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:700;color:var(--on-surface-variant)}
.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}
@@ -300,7 +300,7 @@ body.frame{margin:0;background:transparent}
.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:700;margin-top:2px}
.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>
@@ -325,13 +325,65 @@ function lum(h){ return hexToRgb(h).map(v => v / 255).map(v => v <= 0.03928 ? v
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) ---------- */
/* ---------- tokens (single source: CSS + review tables + contrast). Muted revision 2026-10-18 ---------- */
const CAT_BASE = {
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' }
light: { green:'#4E7A55', orange:'#B26A3E', brown:'#7A5F50', yellow:'#9C7C2A', blue:'#4A78A8', teal:'#367F82', purple:'#865891', red:'#A84B45', pink:'#AE5D7C', grey:'#6C7177', indigo:'#45508D', lime:'#73822F' },
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' }
};
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 only for the Före / efter section ---------- */
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',
@@ -378,29 +430,59 @@ const TOK = {
'sys-wall-1':'#26242B','sys-wall-2':'#141318','sys-card':'#2B2930','sys-on':'#E6E1E5','sys-on-variant':'#CAC4D0'
}
};
for (const th of ['light', 'dark']) {
for (const [k, v] of Object.entries(CAT_BASE[th])) {
TOK[th]['cat-' + k] = v;
TOK[th]['cat-' + k + '-container'] = mix(v, TOK[th]['surface'], CAT_CONTAINER_MIX[th]);
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(';') + '}').join('\n');
`.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,800,-0.25],['display-medium',45,52,800,0],['display-small',36,44,800,0],
['headline-large',32,40,800,0],['headline-medium',28,36,800,0],['headline-small',24,32,800,0],
['title-large',22,28,800,0],['title-medium',16,24,700,0.1],['title-small',14,20,800,0.1],
['body-large',16,24,500,0.2],['body-medium',14,20,500,0.2],['body-small',12,16,600,0.3],
['label-large',14,20,800,0.1],['label-medium',12,16,700,0.4],['label-small',11,16,700,0.4],
['amount-hero',44,52,900,-0.5],['amount-large',28,36,900,-0.25],['amount-medium',18,24,800,0],['amount-small',15,20,800,0]
['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;white-space:nowrap' : ''}}`;
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 ---------- */
@@ -717,6 +799,7 @@ function scrAccountEditor(off){
}
/* 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>`;
@@ -732,6 +815,7 @@ function scrEditor(fsBig){
const b = `${amountBlock(41200, '412', { caret: true })}
${field({ label: 'Beskrivning', value: 'Systembolaget' })}
${catQuick('entertainment', null, fsBig)}
${autoCap()}
<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', drop: true })}${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' })}`;
@@ -742,7 +826,7 @@ function splitModes(on){
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">Vardag · Idag · Betalt av: Du</div></div><span class="t-amount-medium">${kr(106000)}</span></div>`;
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 hstack" style="gap:4px">${ic('auto_awesome', { size: 14, color: 'secondary' })}Shopping, vald utifrån beskrivningen</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)}
@@ -755,7 +839,9 @@ function scrEditorSplit(){
function scrEditorFull(){
const b = `${amountBlock(106000, '1' + NB + '060,00', { pad: '4px 0 2px' })}
${field({ label: 'Beskrivning', value: 'SL-kort 30 dagar, Anna' })}
<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', drop: true })}${chip('Transport', { k: 'Kategori', icon: 'directions_car', drop: true })}</div>
${catQuick('transport')}
${autoCap()}
<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', drop: true })}</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)}
@@ -782,7 +868,7 @@ 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:800' : ''}">${l.pend ? ic('cloud_upload', { size: 16, style: 'vertical-align:-3px;margin-right:4px' }) : ''}${l.t}</div></div>`).join('')}</div></section>`;
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>`;
@@ -972,7 +1058,7 @@ function scrDevices(revoke){
function scrInvites(){
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:900;font-size:calc(var(--fs)*24px);line-height:calc(var(--fs)*32px);letter-spacing:1.5px">${g}</span>`).join('')}</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>
@@ -1002,24 +1088,24 @@ const FRAMES = [
{ 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-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 },
{ 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-offline', title:'S-4 Översikt · offline + uppdatering', note:'Synkraden blir en tonad pill med ikonen cloud_off; tryck öppnar S-16. Uppdateringsbannern är lyktgul (tertiary container).', themes:LD, render:() => scrHome({ offline:true, update:'available' }) },
{ id:'S-4-home-settled', title:'S-4 Översikt · kvitt', note:'Kalas: när ni är kvitt hängs vimpelgirlangen upp och kortet blir lyktgult. Ingen Gör upp-knapp. Arkiverade är utfällt.', themes:LD, render:() => scrHome({ settled:true, archOpen:true }) },
{ 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-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-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-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-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 korall ton och handslagsikon. SL-kort har synkmarkören (moln med pil) vid datumet.', themes:LD, render:() => scrAccount({}) },
{ 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 (extra)', note:'Extra ram: ö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-archived-menu', title:'S-5 Gotland 2026 · meny (extra)', note:'Extra ram: Ta bort konto är avstängt eftersom kontot har utgifter och en betalning.', themes:LD, render:() => scrAccount({ archived:true, menu: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-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 förvald (senast använd i Vardag, AC-8.6). 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. 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:'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', title:'S-7 Ny utgift · 10 sekunder', note:'Egen siffertangentbordspanel i appen (beskrivningen använder systemtangentbordet). Kategoriraden visar de mest använda; Nöje valdes automatiskt utifrån beskrivningen (Systembolaget) och bildtexten "Vald utifrån beskrivningen" försvinner så fort man väljer kategori själv. 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 automatiskt 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 valdes 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-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 lyktgul etikett.', themes:LD, render:() => scrExpense('recurring') },
{ 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-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') },
@@ -1046,9 +1132,9 @@ const FRAMES = [
{ 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-21-invites', title:'S-21 Para ihop en enhet', note:'Koden i tre grupper om fyra på lyktgult kort.', themes:LD, render:scrInvites },
{ 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-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 korall.', themes:LD, render:scrNotifications }
{ 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 ---------- */
@@ -1085,15 +1171,24 @@ function paletteTable(){
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, 'text')}</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 on it</th><th>Dark</th><th>Dark container</th><th>vs surface / s-c-high / container / on-cat on it</th></tr>${rows}</table>`;
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 => { const pr = catPairs(CAT_BASE[th]), po = catPairs(CAT_BASE_OLD[th]); return `<td>${pr.slice(0, 5).map(([d, a, b]) => `${a}–${b} <b>${d.toFixed(1)}</b>`).join('<br>')}</td><td>${po[0][1]}–${po[0][2]} <b>${po[0][0].toFixed(1)}</b></td>`; };
return `<table class="spec"><tr><th></th><th>Closest pairs now (CIEDE2000)</th><th>Closest pair in the bright version</th></tr><tr><td>Light tiles</td>${cell('light')}</tr><tr><td>Dark tiles</td>${cell('dark')}</tr></table><p>A ΔE00 above about 10 is clearly distinguishable side by side; the muted set's closest pairs are further apart than the bright set's, because lightness now differs between neighbouring hues.</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:Nunito"><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>`;
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 = [];
@@ -1102,22 +1197,24 @@ function reviewPage(){
document.body.className = 'review';
document.body.innerHTML = `<div class="wrap">
<header><h1><small>Kvitt · visual direction 3</small>Kalas</h1>
<p class="idea">A Swedish crayfish party: warm, cheerful cards where category colour carries the screen. Every expense is recognisable by its colour and icon before you read it.</p>
<p>Kalas is card-based and colourful without being noisy: cream (light) or warm plum-brown (dark) surfaces, white or tonal cards with very round corners, and one bold colour per thing — the category colour on every expense tile, the account's chart colour on every account card, coral for the one primary action. The balance is a playful 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 lantern yellow. Hierarchy stays strict: one hero, one primary button, numbers in heavy tabular Nunito.</p></header>
<p class="idea">A Swedish crayfish party, late in the evening: warm, calm cards where category colour still carries the screen. Every expense is recognisable by its colour and icon before you read it.</p>
<p><b>Revision 2026-10-18 (muted).</b> The owner chose Kalas but asked for colours he will not tire of over years of daily use. The structure is unchanged (cards, very round shapes, MaterialShapes tiles, the seesaw, bunting, one solid colour tile per expense); every colour has lower chroma: clay/terracotta primary instead of coral, sage instead of dill, straw/oat instead of lantern yellow, a quieter warm off-white in light and a warm near-neutral brown-black in dark. The typeface is now Roboto (D-020, see Typography). The bright version is in git at <code>210ab5c</code> and is compared below under <b>Före / efter</b>.</p>
<p>Kalas is card-based and colourful without being noisy: 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 playful 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. Hierarchy stays strict: one hero, one primary button, amounts in bold tabular figures.</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> (green, partner owes you) and <code>owing</code> (plum, you owe) are a purple/green pair, distinguishable in deuteranopia, protanopia and tritanopia, and always come with words. <code>settled</code> uses the lantern yellow family. Extras beyond the brief's names: <code>--card</code> (card fill: white on cream 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).</p>
<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>Baseline values from the brief kept as is: they already pass on the cream and plum surfaces. 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, plum-black in dark), so every row starts with a bold colour block. 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 for text in light (several are below 4.5:1 on surface-container-high); labels stay on-surface.</p>
<p>Muted, dusty versions of all twelve §9.7 tokens (token names unchanged). 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>Nunito</b> (SIL Open Font License 1.1, variable weight 200–1000, bundleable as an Android font resource). A rounded terminal sans that reads friendly at heavy weights, has å/ä/ö with open counters, and whose default figures are <b>tabular</b> (verified: columns of amounts align in the rendered PNGs; Figtree and Plus Jakarta Sans were tested and do not). Amounts use weights 800–900 with <code>font-variant-numeric: tabular-nums</code> (Compose: <code>fontFeatureSettings = "tnum"</code>). 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>
<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>
@@ -1127,9 +1224,9 @@ function reviewPage(){
${[['--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 cream with a soft warm shadow (0 1 2 and 0 2 8 at 10 % 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>
<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 lantern yellow.</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>
@@ -1137,7 +1234,7 @@ function reviewPage(){
<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 lantern yellow. 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>
<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>
@@ -1147,6 +1244,10 @@ function reviewPage(){
<li><b>200 %:</b> see S-4-home-200 and S-7-editor-200. Account amounts drop below the name instead of truncating; category labels wrap; the hero amount follows non-linear scaling.</li>
<li><b>Extra frame:</b> <code>S-11-analytics-scrolled</code> shows charts 2 and 3 whole, because one 891 dp screen cannot hold three charts.</li></ul>
<h2>Före / efter</h2>
<p>The same markup rendered with the previous bright token set (git <code>210ab5c</code>) and the new muted set. Only colour differs; both use Roboto. These comparison phones are review-only and are not in the frame list or the PNG export.</p>
${beforeAfter()}
<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>`;