tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit 0cb84bfe692e681430b5d05b318530637bbeb3a4
parent 9eb95bc4ed15170a68b19420918f59709cbe827a
Author: James Eversole <james@eversole.co>
Date:   Mon, 19 Oct 2026 18:57:15 -0500

Add system-aware dark mode and persistent header theme toggle

Diffstat:
Mext/demo-site/README.md | 22++++++++++++++++++++++
Mext/demo-site/asset-release.py | 4++--
Mext/demo-site/index.html | 7+++++--
Mext/demo-site/release.nix | 4++--
Mext/demo-site/style.css | 26++++++++++++++++++++++----
Mext/demo-site/test/asset-release.py | 4++--
Mext/demo-site/test/release.py | 2+-
Aext/demo-site/test/theme.mjs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/theme.js | 39+++++++++++++++++++++++++++++++++++++++
9 files changed, 185 insertions(+), 13 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -30,6 +30,28 @@ changes, page departure, or deliberate wheel/touch/scroll-key browsing cancel a pending reveal; an open mobile settings sheet suppresses it. Movement is instant, including with reduced-motion enabled. +## Color scheme + +The site follows the browser/system light or dark preference, including live +changes without reloading until a preference is explicitly chosen. The header’s +sun/moon button switches modes and remembers the choice in local storage; if +storage is blocked, it still works for the current page. Clearing that stored +preference restores system following. The choice is applied before the first +paint, independently of the playground runtime. Controls, output, dialogs +and expression syntax colors follow the scheme; the source editor keeps its dark +surface in both. Native controls and supported browser chrome use the matching +scheme. Forced-colors mode retains native editor text. + +Run the presentation-only browser regression (no runtime assets needed): + +```sh +nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/theme.mjs +``` + +It checks light → dark → light changes, text contrast, browser theme metadata, +source/expression caret colors, keyboard toggling, persistence, mobile header +layout, blocked storage and the forced-colors fallback. + ## Syntax colors and bracket matching Editable source, reference source, and the expression prompt distinguish comments, diff --git a/ext/demo-site/asset-release.py b/ext/demo-site/asset-release.py @@ -8,7 +8,7 @@ from pathlib import Path def freeze(root): root = Path(root) - names = ['dist', 'runtime', 'images', 'reference', 'style.css', 'catalogue.json', 'compiler.json', + names = ['dist', 'runtime', 'images', 'reference', 'style.css', 'theme.js', 'catalogue.json', 'compiler.json', 'packages.json', 'environments.json'] files = sorted(p for name in names for p in ((root / name).rglob('*') if (root / name).is_dir() else [root / name]) @@ -20,7 +20,7 @@ def freeze(root): version = hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() prefix = 'assets/' + version html = (root / 'index.html').read_text() - for name in ['dist/app.js', 'style.css', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: + for name in ['dist/app.js', 'style.css', 'theme.js', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: old = './' + name if html.count('"' + old + '"') != 1: raise ValueError('Expected one HTML entrypoint: ' + old) diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -4,7 +4,9 @@ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="description" content="Explore Tree Calculus through tricu: familiar syntax, portable Arboricx packages, interactive IO and a self-hosting compiler."> <link rel="canonical" href="https://arbori.cx/"> - <meta name="theme-color" content="#f5f2ea"> + <meta name="color-scheme" content="light dark"> + <meta name="theme-color" content="#f5f2ea" media="(prefers-color-scheme: light)"> + <meta name="theme-color" content="#141e1a" media="(prefers-color-scheme: dark)"> <link rel="icon" href="./images/favicon.png" type="image/png" sizes="32x32"> <link rel="icon" href="./images/icon.svg" type="image/svg+xml"> <link rel="apple-touch-icon" href="./images/apple-touch-icon.png" sizes="180x180"> @@ -20,10 +22,11 @@ <meta property="og:image:alt" content="Arboricx: Portable programs. All trees. Leaf, Stem and Fork diagrams on a warm cream background."> <link rel="help" type="text/plain" href="./llms.txt" title="Documentation for AI assistants"> <title>Arboricx: tricu and portable tree programs</title> + <script src="./theme.js"></script> <link rel="stylesheet" href="./style.css"><script type="module" src="./dist/app.js"></script> </head> <body> - <header class="wrap" data-inspector-background><a class="wordmark" href="#">arbori.cx<span> / portable trees</span></a><nav aria-label="Main"><a href="https://git.eversole.co/James/tricu">Source</a></nav></header> + <header class="wrap" data-inspector-background><a class="wordmark" href="#">arbori.cx<span> / portable trees</span></a><nav aria-label="Main"><a href="https://git.eversole.co/James/tricu">Source</a><span id="theme-separator" aria-hidden="true" hidden>|</span><button id="theme-toggle" type="button" aria-label="Switch color theme" hidden><span data-theme-icon aria-hidden="true">☾</span></button></nav></header> <main> <section class="hero wrap" data-inspector-background> <p class="eyebrow">Arboricx / <span class="pronunciation" aria-label="arbor-icks">ˈɑːrbərɪks</span></p> diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -7,7 +7,7 @@ let relative = pkgs.lib.removePrefix "${toString ./.}/" path; top = builtins.head (pkgs.lib.splitString "/" relative); in builtins.elem top [ - "src" "dist" "index.html" "style.css" "catalogue.json" + "src" "dist" "index.html" "style.css" "theme.js" "catalogue.json" "images" "compiler.json" "packages.json" "environments.json" "tsconfig.json" ]; }; @@ -33,7 +33,7 @@ let tsc -p tsconfig.json --outDir "$TMPDIR/dist" diff -ru dist "$TMPDIR/dist" mkdir -p $out - cp index.html style.css catalogue.json compiler.json packages.json environments.json $out/ + cp index.html style.css theme.js catalogue.json compiler.json packages.json environments.json $out/ cp -r images $out/images for name in preview.png favicon.png apple-touch-icon.png; do cmp images/$name ${artwork}/$name diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -1,10 +1,10 @@ -:root{color-scheme:light;--paper:#f5f2ea;--ink:#202d29;--muted:#626d65;--green:#235a43;--line:#d8dcd0;--panel:#fffef9;--code:#172a24;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} -*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button,.button{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:10px 14px;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px}button:hover:not(:disabled),.button:hover{border-color:var(--green)}button:disabled{cursor:default;opacity:.45}button.primary,.button.primary{background:var(--green);color:white;border-color:var(--green)}:focus-visible{outline:3px solid #b65b28;outline-offset:3px}.wrap{max-width:1120px;margin:auto;padding-inline:28px}header{display:flex;justify-content:space-between;align-items:center;padding-block:28px;border-bottom:1px solid var(--line);gap:20px}.wordmark{font-weight:800;font-size:25px;text-decoration:none}.wordmark span{font-size:13px;font-weight:400;color:var(--muted)}nav{display:flex;gap:22px;font-size:14px}.hero{padding-block:64px;position:relative}.eyebrow{font:600 12px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:var(--green)}.pronunciation{text-transform:none;letter-spacing:normal}h1{font-size:clamp(48px,6vw,78px);line-height:1.03;letter-spacing:-.055em;font-weight:650;margin:22px 0}h1 span{color:var(--green)}.intro{max-width:570px;font-size:19px;line-height:1.7;color:var(--muted)}.shapes{display:flex;gap:35px;margin:28px 0 35px}.shapes div{width:100px;text-align:center}.shapes svg{width:100px;height:80px;display:block;fill:var(--green)}.shapes>div:first-child circle{transform:translateY(18.5px)}.shapes path{stroke:var(--green);stroke-width:2;fill:none}.shapes strong,.shapes code{display:block;font-size:13px;margin-top:5px}.shapes code{color:var(--muted)}h2{font-size:36px;letter-spacing:-.035em;font-weight:600;margin:8px 0 16px}h3{font-size:15px;font-weight:650;margin:0 0 12px}.hint{font-size:12px;line-height:1.7;color:var(--muted);overflow-wrap:anywhere}label{display:block;font-size:12px;font-weight:600;margin-bottom:6px}select,textarea,input:not([type=checkbox]){font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:9px;width:100%;min-width:0}select{min-height:44px}textarea{resize:vertical;font:14px/1.6 ui-monospace,monospace}pre{font:14px/1.65 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{margin-top:14px;font-size:13px}summary{cursor:pointer;color:var(--green);min-height:32px}button,input,select,textarea{touch-action:manipulation}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}.error{color:#9b3422}.input-row{display:flex;gap:8px;margin-bottom:12px}.input-row button{white-space:nowrap}.check{display:flex;align-items:center;gap:8px;font-weight:400;margin-block:14px}.check input{flex:none}.demo-picker{display:flex;flex-wrap:wrap;gap:8px}.demo-picker button{font-size:12px} +:root{color-scheme:light;--paper:#f5f2ea;--ink:#202d29;--muted:#626d65;--green:#235a43;--line:#d8dcd0;--panel:#fffef9;--code:#172a24;--toolbar:#eaf0e6;--dock:#eef2e8;--output:#f3f4ee;--error:#9b3422;--warning:#8b581b;--focus:#b65b28;--primary:var(--green);--on-primary:white;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button,.button{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:10px 14px;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px}button:hover:not(:disabled),.button:hover{border-color:var(--green)}button:disabled{cursor:default;opacity:.45}button.primary,.button.primary{background:var(--primary);color:var(--on-primary);border-color:var(--green)}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.wrap{max-width:1120px;margin:auto;padding-inline:28px}header{display:flex;justify-content:space-between;align-items:center;padding-block:28px;border-bottom:1px solid var(--line);gap:20px}.wordmark{font-weight:800;font-size:25px;text-decoration:none}.wordmark span{font-size:13px;font-weight:400;color:var(--muted)}nav{display:flex;gap:22px;font-size:14px}.hero{padding-block:64px;position:relative}.eyebrow{font:600 12px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:var(--green)}.pronunciation{text-transform:none;letter-spacing:normal}h1{font-size:clamp(48px,6vw,78px);line-height:1.03;letter-spacing:-.055em;font-weight:650;margin:22px 0}h1 span{color:var(--green)}.intro{max-width:570px;font-size:19px;line-height:1.7;color:var(--muted)}.shapes{display:flex;gap:35px;margin:28px 0 35px}.shapes div{width:100px;text-align:center}.shapes svg{width:100px;height:80px;display:block;fill:var(--green)}.shapes>div:first-child circle{transform:translateY(18.5px)}.shapes path{stroke:var(--green);stroke-width:2;fill:none}.shapes strong,.shapes code{display:block;font-size:13px;margin-top:5px}.shapes code{color:var(--muted)}h2{font-size:36px;letter-spacing:-.035em;font-weight:600;margin:8px 0 16px}h3{font-size:15px;font-weight:650;margin:0 0 12px}.hint{font-size:12px;line-height:1.7;color:var(--muted);overflow-wrap:anywhere}label{display:block;font-size:12px;font-weight:600;margin-bottom:6px}select,textarea,input:not([type=checkbox]){font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:9px;width:100%;min-width:0}select{min-height:44px}textarea{resize:vertical;font:14px/1.6 ui-monospace,monospace}pre{font:14px/1.65 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{margin-top:14px;font-size:13px}summary{cursor:pointer;color:var(--green);min-height:32px}button,input,select,textarea{touch-action:manipulation}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}.error{color:var(--error)}.input-row{display:flex;gap:8px;margin-bottom:12px}.input-row button{white-space:nowrap}.check{display:flex;align-items:center;gap:8px;font-weight:400;margin-block:14px}.check input{flex:none}.demo-picker{display:flex;flex-wrap:wrap;gap:8px}.demo-picker button{font-size:12px} /* One workspace, one output surface. No nested cards or inactive panels. */ -.workspace{max-width:1064px;margin:0 auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:clip;scroll-margin-top:16px}.editor-toolbar{position:sticky;top:0;z-index:5;display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--line);background:#eaf0e6}.toolbar-selection{flex:1 1 220px;min-width:0}.toolbar-runtime{flex:0 1 150px;min-width:115px}.toolbar-actions{display:flex;gap:8px;align-items:center}.editor-toolbar #status{flex-basis:100%;font-size:12px;color:var(--muted);min-height:18px}.editor-body{display:grid;grid-template-columns:minmax(0,1fr)}.editor-body.settings-open{grid-template-columns:minmax(0,1fr) 320px}.editor-main{min-width:0}.source-panel{background:var(--code);color:#e3edde;min-width:0}.source-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px 8px}.source-heading h2{font-size:14px;letter-spacing:0;margin:0}.source-heading span{font:11px ui-monospace,monospace;color:#b1c1b7}#authorship{font-size:12px;color:#b1c1b7;margin:0;padding:0 20px 12px}#authorship.modified{color:#ffda98}#source-editor{display:block;width:100%;height:460px;min-height:240px;max-height:none;border:0;border-radius:0;padding:18px 20px;margin:0;color:inherit;background:transparent;font:14px/24px ui-monospace,monospace;tab-size:2}#source-editor.modified{background:#273c32;box-shadow:inset 4px 0 #e5b96b}#source-editor:disabled{opacity:.75}#source-notes{margin:0;padding:18px 20px;max-height:460px;overflow:auto;white-space:pre;tab-size:2}#source-availability,#compile-diagnostic,#compile-activity{margin:0;padding:10px 20px;color:#e2d5af}#compile-diagnostic{color:#ffc5b9}.source-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #385045}.source-actions .hint{color:#b1c1b7;margin:0}.source-actions button{font-size:12px;padding:6px 10px;background:transparent;color:#e3edde;border-color:#385045}.expression-dock{padding:16px 20px;background:#eef2e8;border-top:1px solid var(--line)}#expression-inputs label{font-size:13px}#expression-inputs label span{font-weight:400;color:var(--muted)}#expression-editor{font:16px/26px ui-monospace,monospace;min-height:92px;background:var(--panel)}#expression-help{margin:6px 0 0}#draft-run-note{color:#8b581b}.argument-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.argument-heading h3{margin:0}#argument-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.argument-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 10px;margin-top:12px}.argument-row label{grid-column:1/-1}.argument-row textarea{font-size:16px}.argument-row button{font-size:12px;align-self:start} +.workspace{max-width:1064px;margin:0 auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:clip;scroll-margin-top:16px}.editor-toolbar{position:sticky;top:0;z-index:5;display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--line);background:var(--toolbar)}.toolbar-selection{flex:1 1 220px;min-width:0}.toolbar-runtime{flex:0 1 150px;min-width:115px}.toolbar-actions{display:flex;gap:8px;align-items:center}.editor-toolbar #status{flex-basis:100%;font-size:12px;color:var(--muted);min-height:18px}.editor-body{display:grid;grid-template-columns:minmax(0,1fr)}.editor-body.settings-open{grid-template-columns:minmax(0,1fr) 320px}.editor-main{min-width:0}.source-panel{background:var(--code);color:#e3edde;min-width:0}.source-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px 8px}.source-heading h2{font-size:14px;letter-spacing:0;margin:0}.source-heading span{font:11px ui-monospace,monospace;color:#b1c1b7}#authorship{font-size:12px;color:#b1c1b7;margin:0;padding:0 20px 12px}#authorship.modified{color:#ffda98}#source-editor{display:block;width:100%;height:460px;min-height:240px;max-height:none;border:0;border-radius:0;padding:18px 20px;margin:0;color:inherit;background:transparent;font:14px/24px ui-monospace,monospace;tab-size:2}#source-editor.modified{background:#273c32;box-shadow:inset 4px 0 #e5b96b}#source-editor:disabled{opacity:.75}#source-notes{margin:0;padding:18px 20px;max-height:460px;overflow:auto;white-space:pre;tab-size:2}#source-availability,#compile-diagnostic,#compile-activity{margin:0;padding:10px 20px;color:#e2d5af}#compile-diagnostic{color:#ffc5b9}.source-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #385045}.source-actions .hint{color:#b1c1b7;margin:0}.source-actions button{font-size:12px;padding:6px 10px;background:transparent;color:#e3edde;border-color:#385045}.expression-dock{padding:16px 20px;background:var(--dock);border-top:1px solid var(--line)}#expression-inputs label{font-size:13px}#expression-inputs label span{font-weight:400;color:var(--muted)}#expression-editor{font:16px/26px ui-monospace,monospace;min-height:92px;background:var(--panel)}#expression-help{margin:6px 0 0}#draft-run-note{color:var(--warning)}.argument-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.argument-heading h3{margin:0}#argument-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.argument-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 10px;margin-top:12px}.argument-row label{grid-column:1/-1}.argument-row textarea{font-size:16px}.argument-row button{font-size:12px;align-self:start} /* Docked settings; mobile turns the same inspector into a modal sheet. */ #settings-panel{min-width:0;padding:18px;background:var(--paper);border-left:1px solid var(--line);font-size:13px}#settings-panel section{padding-block:20px;border-bottom:1px solid var(--line)}#settings-panel section:last-child{border:0}.inspector-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.inspector-heading h2{font-size:16px;margin:0;letter-spacing:0}#settings-panel button{font-size:12px}#settings-panel .hint{margin-bottom:0}#settings-panel details>p{margin-top:8px}#package-info,#release-info,#compiler-stats{overflow-wrap:anywhere}#download-bundle{margin-top:12px;width:100%}#bundle-download-status:empty{display:none} -.output-panel{border-top:1px solid var(--line);padding:20px;min-width:0}.output-toolbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.output-toolbar>div{min-width:0}#output-provenance{margin-block:16px 8px}#result{min-height:64px;max-height:520px;overflow:auto;margin:0;padding:16px;background:#f3f4ee;border:1px solid var(--line);border-radius:4px}#result:empty::before{content:"Your output appears here.";color:var(--muted)}#result-stale{border-left:3px solid #b28132;padding-left:10px}.output-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}#io-console{margin-top:20px}#terminal{max-height:260px;overflow:auto;padding:12px;border-left:3px solid var(--green);background:#eef2e8}#terminal:empty{display:none}#trace{max-height:260px;overflow:auto;list-style:none;padding:0;font:12px/1.6 ui-monospace,monospace}#trace li{padding-block:8px;border-bottom:1px solid var(--line);overflow-wrap:anywhere;white-space:pre-wrap}#trace li.pending{color:var(--green)}.trace-panel{border-top:1px solid var(--line);padding-top:12px} +.output-panel{border-top:1px solid var(--line);padding:20px;min-width:0}.output-toolbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.output-toolbar>div{min-width:0}#output-provenance{margin-block:16px 8px}#result{min-height:64px;max-height:520px;overflow:auto;margin:0;padding:16px;background:var(--output);border:1px solid var(--line);border-radius:4px}#result:empty::before{content:"Your output appears here.";color:var(--muted)}#result-stale{border-left:3px solid #b28132;padding-left:10px}.output-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}#io-console{margin-top:20px}#terminal{max-height:260px;overflow:auto;padding:12px;border-left:3px solid var(--green);background:var(--dock)}#terminal:empty{display:none}#trace{max-height:260px;overflow:auto;list-style:none;padding:0;font:12px/1.6 ui-monospace,monospace}#trace li{padding-block:8px;border-bottom:1px solid var(--line);overflow-wrap:anywhere;white-space:pre-wrap}#trace li.pending{color:var(--green)}.trace-panel{border-top:1px solid var(--line);padding-top:12px} .explanation{padding-block:64px}.explanation-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:36px;margin-top:28px}.explanation-grid h3{font-size:17px}.explanation-grid p{font-size:14px;line-height:1.8;color:var(--muted)}footer{padding-block:25px 35px;border-top:1px solid var(--line);font-size:12px;line-height:1.8;color:var(--muted)} @media(min-width:950px){.shapes{position:absolute;right:38px;top:70px;flex-direction:column;gap:15px}} @media(max-width:1119px) and (min-width:741px){.workspace{margin-inline:28px}} @@ -41,3 +41,21 @@ #import-example,#import-usage{white-space:pre-wrap;overflow-wrap:anywhere}#import-dialog details{margin:.75rem 0} #import-dialog[open]{display:flex;flex-direction:column;overflow:hidden}.import-content{min-height:0;overflow:auto}.import-heading,.import-actions,#import-status{flex-shrink:0}#import-actions{padding-top:1rem}#import-status{max-height:8rem;overflow:auto;margin-bottom:0}#import-status:empty{display:none} + +/* System preference without JS; data-theme provides an explicit override. + The source surface stays dark in either scheme. */ +@media(prefers-color-scheme:dark){ + :root:not([data-theme="light"]){color-scheme:dark;--paper:#141e1a;--ink:#e3edde;--muted:#acbcb1;--green:#8bd3ad;--line:#40564a;--panel:#1c2b23;--code:#172a24;--toolbar:#25392d;--dock:#223329;--output:#18261f;--error:#ffb2a6;--warning:#ffda98;--focus:#f4bc8d;--primary:#8bd3ad;--on-primary:#14271e} + /* These tokens belong to the light expression editor, not the permanently + dark source editor. Keep both mirrors, carets and IME fallback legible. */ + :root:not([data-theme="light"]) .expression-dock .syntax-editor{--syn-comment:#9cafa4;--syn-string:#c0d9a3;--syn-escape:#f4bc8d;--syn-number:#9ecff5;--syn-keyword:#d6b5ef;--syn-definition:#ffd08a;--syn-tree:#73dbc0;--syn-contract:#f4bc8d;--syn-invalid:#ffb2a6;--syn-match:#435b43;--syn-operator:#aac6cc} +} + +:root[data-theme="dark"]{color-scheme:dark;--paper:#141e1a;--ink:#e3edde;--muted:#acbcb1;--green:#8bd3ad;--line:#40564a;--panel:#1c2b23;--code:#172a24;--toolbar:#25392d;--dock:#223329;--output:#18261f;--error:#ffb2a6;--warning:#ffda98;--focus:#f4bc8d;--primary:#8bd3ad;--on-primary:#14271e} + /* These tokens belong to the light expression editor, not the permanently + dark source editor. Keep both mirrors, carets and IME fallback legible. */ + :root[data-theme="dark"] .expression-dock .syntax-editor{--syn-comment:#9cafa4;--syn-string:#c0d9a3;--syn-escape:#f4bc8d;--syn-number:#9ecff5;--syn-keyword:#d6b5ef;--syn-definition:#ffd08a;--syn-tree:#73dbc0;--syn-contract:#f4bc8d;--syn-invalid:#ffb2a6;--syn-match:#435b43;--syn-operator:#aac6cc} + +header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} +#theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;background:transparent;color:var(--green);font-size:24px;line-height:1} +#theme-toggle:hover{background:var(--panel)} diff --git a/ext/demo-site/test/asset-release.py b/ext/demo-site/test/asset-release.py @@ -16,12 +16,12 @@ with tempfile.TemporaryDirectory() as temporary: 'runtime/haskell/shim/index.js', 'runtime/haskell/reactor.wasm', 'runtime/haskell/data/compiler.dag', 'runtime/zig/site-session.mjs', 'runtime/zig/arboricx.wasm', 'catalogue.json', 'packages.json', - 'compiler.json', 'environments.json', 'style.css', 'images/icon.svg', + 'compiler.json', 'environments.json', 'style.css', 'theme.js', 'images/icon.svg', 'reference/libraries/example.md', 'reference/libraries.md', 'reference/getting-started.md', 'reference/libraries.json', 'images/favicon.png', 'images/apple-touch-icon.png', 'images/preview.png'] def build(number, changed=None, reverse=False): root = parent / str(number) root.mkdir() - (root / 'index.html').write_text('<link href="./style.css"><script type="module" src="./dist/app.js"></script>' + ''.join('<link href="./images/'+n+'">' for n in ['icon.svg','favicon.png','apple-touch-icon.png']) + '<meta content="https://arbori.cx/images/preview.png">') + (root / 'index.html').write_text('<link href="./style.css"><script src="./theme.js"></script><script type="module" src="./dist/app.js"></script>' + ''.join('<link href="./images/'+n+'">' for n in ['icon.svg','favicon.png','apple-touch-icon.png']) + '<meta content="https://arbori.cx/images/preview.png">') for name in reversed(entries) if reverse else entries: file = root / name file.parent.mkdir(parents=True, exist_ok=True) diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -23,7 +23,7 @@ def main(): assert assets['version'] == hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() assert assets['base'] == 'assets/' + assets['version'] + '/' html = (args.site / 'index.html').read_text() - for entry in ['dist/app.js', 'style.css', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: + for entry in ['dist/app.js', 'style.css', 'theme.js', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: assert '"./' + assets['base'] + entry + '"' in html assert 'https://arbori.cx/' + assets['base'] + 'images/preview.png' in html for name, size in [('preview.png', (1200, 630)), ('favicon.png', (32, 32)), ('apple-touch-icon.png', (180, 180))]: diff --git a/ext/demo-site/test/theme.mjs b/ext/demo-site/test/theme.mjs @@ -0,0 +1,90 @@ +// Presentation-only browser regression; no compiler/runtime assets required. +// nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/theme.mjs +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {mkdtemp,readFile,writeFile,copyFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +const dir=await mkdtemp(join(tmpdir(),'arboricx-theme-')); +let browser,socket; +try{ + const root=new URL('../',import.meta.url); + const html=(await readFile(new URL('index.html',root),'utf8')).replace(/<script type="module"[^>]*><\/script>/,''); + await writeFile(join(dir,'index.html'),html); + await copyFile(new URL('style.css',root),join(dir,'style.css')); + await copyFile(new URL('theme.js',root),join(dir,'theme.js')); + browser=spawn(process.argv[2]||'chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${join(dir,'profile')}`,'about:blank'],{stdio:['ignore','ignore','pipe']}); + const endpoint=await new Promise((resolve,reject)=>{ + let log='';const timer=setTimeout(()=>reject(Error(log)),15000); + browser.on('error',reject); + browser.stderr.on('data',chunk=>{log+=chunk;const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}}); + }); + const tab=await(await fetch(`http://${endpoint.host}/json/new?about:blank`,{method:'PUT'})).json(); + socket=new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); + let id=0;const pending=new Map(); + socket.onmessage=({data})=>{const event=JSON.parse(data);if(event.id){const p=pending.get(event.id);pending.delete(event.id);event.error?p.reject(Error(JSON.stringify(event.error))):p.resolve(event.result);}}; + const send=(method,params={})=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));}); + const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; + await send('Page.navigate',{url:pathToFileURL(join(dir,'index.html')).href}); + for(let i=0;!await evaluate("document.readyState==='complete' && !!document.getElementById('expression-editor')");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));} + // Install the same presentation wrappers used by syntax.ts, without loading a runtime. + await evaluate(`(()=>{for(const id of ['source-editor','expression-editor']){const input=document.getElementById(id),wrapper=document.createElement('div');wrapper.className='syntax-editor';input.before(wrapper);wrapper.append(input);input.classList.add('syntax-input');}})()`); + for(const scheme of ['light','dark','light']){ + await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:scheme}]}); + await evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))'); + const result=await evaluate(`(()=>{ + const css=e=>getComputedStyle(e),body=css(document.body),root=css(document.documentElement); + const rgb=s=>s.match(/[\\d.]+/g).slice(0,3).map(Number); + const lum=s=>rgb(s).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4;}).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0); + const contrast=(a,b)=>(Math.max(lum(a),lum(b))+.05)/(Math.min(lum(a),lum(b))+.05); + const pairs=[[body.color,body.backgroundColor]]; + for(const selector of ['.intro','a','button.primary','#result','#expression-editor','#settings-panel','#import-dialog']){ + const e=document.querySelector(selector),s=css(e); + pairs.push([selector==='#expression-editor'?s.caretColor:s.color,s.backgroundColor==='rgba(0, 0, 0, 0)'?body.backgroundColor:s.backgroundColor]); + } + const editor=document.getElementById('expression-editor').parentElement,ec=css(editor); + for(const token of ['comment','string','escape','number','keyword','definition','tree','contract','invalid','operator']){ + const span=document.createElement('span');span.className='syntax-'+token;editor.append(span);pairs.push([css(span).color,ec.backgroundColor]);span.remove(); + } + return {scheme:root.colorScheme,paper:body.backgroundColor,contrast:pairs.map(([a,b])=>contrast(a,b)),source:css(document.getElementById('source-editor')).caretColor, + chrome:[...document.querySelectorAll('meta[name="theme-color"]')].filter(m=>matchMedia(m.media).matches).map(m=>m.content)}; + })()`); + assert.equal(result.scheme,scheme); + assert.equal(result.paper,scheme==='dark'?'rgb(20, 30, 26)':'rgb(245, 242, 234)'); + assert.deepEqual(result.chrome,[scheme==='dark'?'#141e1a':'#f5f2ea']); + assert.equal(result.source,'rgb(227, 237, 222)'); + for(const ratio of result.contrast)assert.ok(ratio>=4.5,`${scheme} text contrast ${ratio}`); + } + const scheme=()=>evaluate('getComputedStyle(document.documentElement).colorScheme'); + await evaluate("document.getElementById('theme-toggle').click()"); + assert.equal(await scheme(),'dark','Toggle overrides light system preference'); + assert.equal(await evaluate("document.getElementById('theme-toggle').getAttribute('aria-label')"),'Switch to light mode'); + await send('Page.reload'); + for(let i=0;!await evaluate("document.readyState==='complete' && !document.getElementById('theme-toggle')?.hidden");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));} + assert.equal(await scheme(),'dark','Explicit choice survives reload'); + await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'}]}); + await evaluate("document.getElementById('theme-toggle').focus()"); + await send('Input.dispatchKeyEvent',{type:'keyDown',key:' ',code:'Space',windowsVirtualKeyCode:32}); + await send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32}); + assert.equal(await scheme(),'light','Keyboard toggles to light even on a dark system'); + for(const width of [320,390,1440]){ + await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950}); + assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Header fits '+width); + assert.equal(await evaluate("document.getElementById('theme-toggle').getBoundingClientRect().height>=44"),true,'Touch target'); + } + await evaluate("localStorage.removeItem('arboricx-theme');window.dispatchEvent(new StorageEvent('storage',{key:'arboricx-theme',newValue:null}))"); + assert.equal(await scheme(),'dark','Clearing preference resumes system default'); + // Storage failure must not prevent page-local toggling. + await evaluate("Storage.prototype.setItem=()=>{throw Error('blocked')};document.getElementById('theme-toggle').click()"); + assert.equal(await scheme(),'light'); + await evaluate("document.getElementById('expression-editor').classList.add('syntax-input')"); + await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'},{name:'forced-colors',value:'active'}]}); + assert.equal(await evaluate("getComputedStyle(document.getElementById('expression-editor')).color===getComputedStyle(document.getElementById('expression-editor')).caretColor"),true); + console.log('Theme checks passed: system changes, contrast, toggle/keyboard, persistence, mobile header, blocked storage and forced colors.'); +}finally{ + socket?.close(); + if(browser&&browser.exitCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill();await exited;} + await rm(dir,{recursive:true,force:true}); +} diff --git a/ext/demo-site/theme.js b/ext/demo-site/theme.js @@ -0,0 +1,39 @@ +// Run before styles/paint, independently of playground/compiler initialization. +(() => { + const key = 'arboricx-theme'; + const system = matchMedia('(prefers-color-scheme: dark)'); + const root = document.documentElement; + let preference; + try { preference = localStorage.getItem(key); } catch { /* Storage may be blocked. */ } + if (preference !== 'light' && preference !== 'dark') preference = null; + function apply() { + const dark = (preference || (system.matches ? 'dark' : 'light')) === 'dark'; + root.dataset.theme = dark ? 'dark' : 'light'; + for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { + meta.content = dark ? '#141e1a' : '#f5f2ea'; + } + const button = document.getElementById('theme-toggle'); + if (button) { + button.hidden = false; + document.getElementById('theme-separator').hidden = false; + button.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); + button.title = button.getAttribute('aria-label'); + button.querySelector('[data-theme-icon]').textContent = dark ? '☀' : '☾'; + } + } + apply(); + system.addEventListener('change', apply); + window.addEventListener('storage', event => { + if (event.key !== key && event.key !== null) return; + preference = event.newValue === 'light' || event.newValue === 'dark' ? event.newValue : null; + apply(); + }); + document.addEventListener('DOMContentLoaded', () => { + apply(); + document.getElementById('theme-toggle').addEventListener('click', () => { + preference = root.dataset.theme === 'dark' ? 'light' : 'dark'; + try { localStorage.setItem(key, preference); } catch { /* Still works for this page. */ } + apply(); + }); + }); +})();