aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/2026-07-23-world-clock-horizontal.html190
-rw-r--r--docs/prototypes/2026-07-23-world-clock-vertical-centered.html191
-rw-r--r--docs/prototypes/2026-07-23-world-clock-vertical-even.html128
-rw-r--r--docs/prototypes/2026-07-23-world-clock-vertical-longitude.html177
-rw-r--r--docs/prototypes/dupre-kit-additions.js30
-rw-r--r--docs/prototypes/gallery-widget.el29
-rw-r--r--docs/prototypes/gen_tokens.py21
-rw-r--r--docs/prototypes/panel-widget-gallery.html14
-rw-r--r--docs/prototypes/widgets.js160
9 files changed, 874 insertions, 66 deletions
diff --git a/docs/prototypes/2026-07-23-world-clock-horizontal.html b/docs/prototypes/2026-07-23-world-clock-horizontal.html
new file mode 100644
index 0000000..ef45657
--- /dev/null
+++ b/docs/prototypes/2026-07-23-world-clock-horizontal.html
@@ -0,0 +1,190 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Dupre World — horizontal, true longitude</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin:0; height:100%; }
+ body {
+ background:var(--ground); font-family:var(--mono); color:var(--silver);
+ cursor:none; overflow:hidden; position:relative;
+ }
+ .masthead {
+ position:absolute; top:30px; left:50%; transform:translateX(-50%);
+ color:var(--steel); font-size:12px; letter-spacing:0.35em;
+ text-transform:uppercase; z-index:3;
+ }
+ .masthead b { color:var(--gold); font-weight:400; }
+ .mode { position:absolute; top:30px; right:34px; color:var(--dim);
+ font-size:11px; letter-spacing:0.2em; z-index:3; }
+ svg { position:absolute; inset:0; width:100%; height:100%; z-index:1; }
+ /* Each label anchors at the leader's end; above blocks sit up, below sit down. */
+ .lbl { position:absolute; z-index:2; text-align:center;
+ transform:translateX(-50%); white-space:nowrap; }
+ .lbl.up { transform:translate(-50%,-100%); }
+ .lbl .city { color:var(--steel); font-size:11px; letter-spacing:0.18em;
+ text-transform:uppercase; }
+ .lbl .time { color:var(--cream); font-size:24px; letter-spacing:0.04em;
+ line-height:1.1; }
+ .lbl .meta { color:var(--dim); font-size:11px; letter-spacing:0.1em; }
+ .lbl .meta .tz { color:var(--steel); }
+ .lbl.night .time { color:var(--slate); }
+ .lbl.night .city::after { content:" ☾"; color:var(--night); }
+ .lbl.home .time { color:var(--gold-hi); }
+ .lbl.home .city { color:var(--gold); }
+</style>
+</head>
+<body>
+ <div class="masthead"><b>DUPRE</b> · WORLD WATCH &nbsp;·&nbsp; WEST → EAST</div>
+ <div class="mode" id="mode">12H · click to toggle</div>
+ <svg id="wires"></svg>
+ <div id="labels"></div>
+<script>
+const ROSTER = [
+ { tz:"Pacific/Auckland", label:"Wellington", lon:174.78 },
+ { tz:"Australia/Sydney", label:"Sydney", lon:151.21 },
+ { tz:"Asia/Tokyo", label:"Tokyo", lon:139.69 },
+ { tz:"Asia/Shanghai", label:"Shanghai", lon:121.47 },
+ { tz:"Asia/Kolkata", label:"Delhi", lon:77.21 },
+ { tz:"Asia/Yerevan", label:"Yerevan", lon:44.51 },
+ { tz:"Europe/Istanbul", label:"Istanbul", lon:28.98 },
+ { tz:"Europe/Athens", label:"Athens", lon:23.73 },
+ { tz:"Europe/Paris", label:"Paris", lon:2.35 },
+ { tz:"Europe/London", label:"London", lon:-0.13 },
+ { tz:"America/New_York", label:"New York", lon:-74.01 },
+ { tz:"America/Chicago", label:"New Orleans", lon:-90.07 },
+ { tz:"America/Los_Angeles", label:"Berkeley", lon:-122.27},
+ { tz:"America/Anchorage", label:"Anchorage", lon:-149.90},
+ { tz:"Pacific/Honolulu", label:"Honolulu", lon:-157.86},
+];
+const TZNAME = {
+ "Pacific/Honolulu":"US Hawaii", "America/Anchorage":"US Alaska",
+ "America/Los_Angeles":"US Pacific", "America/Chicago":"US Central",
+ "America/New_York":"US Eastern", "Europe/London":"UK",
+ "Europe/Paris":"Central Europe", "Europe/Athens":"Eastern Europe",
+ "Europe/Istanbul":"Türkiye", "Asia/Yerevan":"Armenia",
+ "Asia/Kolkata":"India", "Asia/Shanghai":"China", "Asia/Tokyo":"Japan",
+ "Australia/Sydney":"Australia East", "Pacific/Auckland":"New Zealand",
+};
+function tzName(tz) {
+ if (TZNAME[tz]) return TZNAME[tz];
+ try {
+ const p = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, timeZoneName: "longGeneric" }).formatToParts(new Date())
+ .find((x) => x.type === "timeZoneName");
+ return p ? p.value : tz.split("/").pop().replace(/_/g, " ");
+ } catch (e) { return tz.split("/").pop().replace(/_/g, " "); }
+}
+const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
+let hour12 = true;
+
+/* Spine across the centre; longitude maps to x (west left, east right). Labels
+ alternate above and below so near-longitude cities split across the line. */
+const SIDE = 150, MIN_LEAD = 7, ROW_H = 92, COL_W = 176;
+const lons = ROSTER.map((c) => c.lon);
+const maxLon = Math.max(...lons), minLon = Math.min(...lons);
+const svgNS = "http://www.w3.org/2000/svg";
+const wires = document.getElementById("wires");
+const labels = document.getElementById("labels");
+let nodes = [];
+
+function layout() {
+ const H = window.innerHeight, W = window.innerWidth, cy = Math.round(H / 2);
+ const drawW = W - SIDE * 2;
+ wires.innerHTML = ""; labels.innerHTML = ""; nodes = [];
+
+ const spine = document.createElementNS(svgNS, "line");
+ spine.setAttribute("y1", cy); spine.setAttribute("y2", cy);
+ spine.setAttribute("x1", SIDE - 24); spine.setAttribute("x2", W - SIDE + 24);
+ spine.setAttribute("stroke", "var(--line)"); spine.setAttribute("stroke-width", "2");
+ wires.appendChild(spine);
+
+ const lastX = {}; // slot "side:row" -> last x placed
+ // west->east order (left to right) for stable slotting
+ const ordered = [...ROSTER].sort((a, b) => a.lon - b.lon);
+ ordered.forEach((c, i) => {
+ const x = SIDE + (c.lon - minLon) / (maxLon - minLon) * drawW;
+ const order = [];
+ for (let row = 0; row < 4; row++) {
+ const first = (i % 2 === 0) ? "up" : "dn";
+ const second = first === "up" ? "dn" : "up";
+ order.push(first + ":" + row, second + ":" + row);
+ }
+ let slot = order.find((s) => lastX[s] === undefined || x - lastX[s] >= COL_W)
+ || order[order.length - 1];
+ lastX[slot] = x;
+ const [side, rowStr] = slot.split(":");
+ const row = parseInt(rowStr, 10);
+ const labelY = side === "up"
+ ? cy - MIN_LEAD - row * ROW_H
+ : cy + MIN_LEAD + row * ROW_H;
+
+ const lead = document.createElementNS(svgNS, "line");
+ lead.setAttribute("x1", x); lead.setAttribute("x2", x);
+ lead.setAttribute("y1", cy); lead.setAttribute("y2", labelY);
+ lead.setAttribute("stroke", "var(--line)"); lead.setAttribute("stroke-width", "1.4");
+ wires.appendChild(lead);
+ const dot = document.createElementNS(svgNS, "circle");
+ dot.setAttribute("cx", x); dot.setAttribute("cy", cy);
+ dot.setAttribute("r", "3.5");
+ dot.setAttribute("fill", c.tz === home ? "var(--gold)" : "var(--steel)");
+ wires.appendChild(dot);
+
+ const lbl = document.createElement("div");
+ lbl.className = "lbl " + side;
+ lbl.style.left = x + "px";
+ lbl.style.top = (side === "up" ? labelY - 6 : labelY + 6) + "px";
+ lbl.innerHTML = "<div class='city'>" + c.label + "</div>"
+ + "<div class='time'></div><div class='meta'></div>";
+ labels.appendChild(lbl);
+ nodes.push({ c, lbl,
+ time: lbl.querySelector(".time"), meta: lbl.querySelector(".meta") });
+ });
+}
+function fmt(tz) {
+ const now = new Date();
+ const p = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz, hour12, weekday: "short",
+ day: "numeric", hour: hour12 ? "numeric" : "2-digit", minute: "2-digit",
+ }).formatToParts(now);
+ const g = (t) => (p.find((x) => x.type === t) || {}).value || "";
+ const h24 = parseInt(new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, hour12: false, hour: "2-digit" }).formatToParts(now)
+ .find((x) => x.type === "hour").value, 10) % 24;
+ const time = hour12
+ ? g("hour") + ":" + g("minute") + " " + g("dayPeriod")
+ : g("hour") + ":" + g("minute");
+ return { time, date: g("weekday") + " " + g("day"), night: h24 >= 19 || h24 < 7 };
+}
+function tick() {
+ for (const e of nodes) {
+ const f = fmt(e.c.tz);
+ e.time.textContent = f.time;
+ e.meta.innerHTML = f.date + " &nbsp;·&nbsp; <span class='tz'>"
+ + tzName(e.c.tz) + "</span>";
+ const isHome = e.c.tz === home;
+ e.lbl.classList.toggle("home", isHome);
+ e.lbl.classList.toggle("night", f.night && !isHome);
+ }
+}
+document.body.addEventListener("click", () => {
+ hour12 = !hour12;
+ document.getElementById("mode").textContent =
+ (hour12 ? "12H" : "24H") + " · click to toggle";
+ tick();
+});
+layout(); tick();
+setInterval(tick, 5000);
+let rt; window.addEventListener("resize", () => {
+ clearTimeout(rt); rt = setTimeout(() => { layout(); tick(); }, 150);
+});
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-23-world-clock-vertical-centered.html b/docs/prototypes/2026-07-23-world-clock-vertical-centered.html
new file mode 100644
index 0000000..114c4b8
--- /dev/null
+++ b/docs/prototypes/2026-07-23-world-clock-vertical-centered.html
@@ -0,0 +1,191 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Dupre World — vertical, centered spine, both sides</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin:0; height:100%; }
+ body {
+ background:var(--ground); font-family:var(--mono); color:var(--silver);
+ cursor:none; overflow:hidden; position:relative;
+ }
+ .masthead {
+ position:absolute; top:30px; left:50%; transform:translateX(-50%);
+ color:var(--steel); font-size:12px; letter-spacing:0.35em;
+ text-transform:uppercase; z-index:3; text-align:center;
+ }
+ .masthead b { color:var(--gold); font-weight:400; }
+ .mode { position:absolute; top:30px; right:34px; color:var(--dim);
+ font-size:11px; letter-spacing:0.2em; z-index:3; }
+ svg { position:absolute; inset:0; width:100%; height:100%; z-index:1; }
+ .lbl { position:absolute; z-index:2; transform:translateY(-50%);
+ white-space:nowrap; }
+ .lbl.r { text-align:left; }
+ .lbl.l { text-align:right; transform:translate(-100%,-50%); }
+ .lbl .city { color:var(--steel); font-size:12px; letter-spacing:0.2em;
+ text-transform:uppercase; }
+ .lbl .time { color:var(--cream); font-size:26px; letter-spacing:0.04em;
+ line-height:1.05; }
+ .lbl .meta { color:var(--dim); font-size:11px; letter-spacing:0.11em; }
+ .lbl .meta .tz { color:var(--steel); }
+ .lbl.night .time { color:var(--slate); }
+ .lbl.night .city::after { content:" ☾"; color:var(--night); }
+ .lbl.home .time { color:var(--gold-hi); }
+ .lbl.home .city { color:var(--gold); }
+</style>
+</head>
+<body>
+ <div class="masthead"><b>DUPRE</b> · WORLD WATCH &nbsp;·&nbsp; EAST → WEST</div>
+ <div class="mode" id="mode">12H · click to toggle</div>
+ <svg id="wires"></svg>
+ <div id="labels"></div>
+<script>
+const ROSTER = [
+ { tz:"Pacific/Auckland", label:"Wellington", lon:174.78 },
+ { tz:"Australia/Sydney", label:"Sydney", lon:151.21 },
+ { tz:"Asia/Tokyo", label:"Tokyo", lon:139.69 },
+ { tz:"Asia/Shanghai", label:"Shanghai", lon:121.47 },
+ { tz:"Asia/Kolkata", label:"Delhi", lon:77.21 },
+ { tz:"Asia/Yerevan", label:"Yerevan", lon:44.51 },
+ { tz:"Europe/Istanbul", label:"Istanbul", lon:28.98 },
+ { tz:"Europe/Athens", label:"Athens", lon:23.73 },
+ { tz:"Europe/Paris", label:"Paris", lon:2.35 },
+ { tz:"Europe/London", label:"London", lon:-0.13 },
+ { tz:"America/New_York", label:"New York", lon:-74.01 },
+ { tz:"America/Chicago", label:"New Orleans", lon:-90.07 },
+ { tz:"America/Los_Angeles", label:"Berkeley", lon:-122.27},
+ { tz:"America/Anchorage", label:"Anchorage", lon:-149.90},
+ { tz:"Pacific/Honolulu", label:"Honolulu", lon:-157.86},
+];
+const TZNAME = {
+ "Pacific/Honolulu":"US Hawaii", "America/Anchorage":"US Alaska",
+ "America/Los_Angeles":"US Pacific", "America/Chicago":"US Central",
+ "America/New_York":"US Eastern", "Europe/London":"UK",
+ "Europe/Paris":"Central Europe", "Europe/Athens":"Eastern Europe",
+ "Europe/Istanbul":"Türkiye", "Asia/Yerevan":"Armenia",
+ "Asia/Kolkata":"India", "Asia/Shanghai":"China", "Asia/Tokyo":"Japan",
+ "Australia/Sydney":"Australia East", "Pacific/Auckland":"New Zealand",
+};
+function tzName(tz) {
+ if (TZNAME[tz]) return TZNAME[tz];
+ try {
+ const p = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, timeZoneName: "longGeneric" }).formatToParts(new Date())
+ .find((x) => x.type === "timeZoneName");
+ return p ? p.value : tz.split("/").pop().replace(/_/g, " ");
+ } catch (e) { return tz.split("/").pop().replace(/_/g, " "); }
+}
+const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
+let hour12 = true;
+
+/* Spine down the centre; labels alternate to left and right so near-longitude
+ cities split across the line instead of piling into one far column. */
+const TOP = 104, BOT = 104, MIN_LEAD = 7, COL_W = 224, ROW_H = 68;
+const lons = ROSTER.map((c) => c.lon);
+const maxLon = Math.max(...lons), minLon = Math.min(...lons);
+const svgNS = "http://www.w3.org/2000/svg";
+const wires = document.getElementById("wires");
+const labels = document.getElementById("labels");
+let nodes = [];
+
+function layout() {
+ const H = window.innerHeight, W = window.innerWidth, cx = Math.round(W / 2);
+ const drawH = H - TOP - BOT;
+ wires.innerHTML = ""; labels.innerHTML = ""; nodes = [];
+
+ const spine = document.createElementNS(svgNS, "line");
+ spine.setAttribute("x1", cx); spine.setAttribute("x2", cx);
+ spine.setAttribute("y1", TOP - 18); spine.setAttribute("y2", H - BOT + 18);
+ spine.setAttribute("stroke", "var(--line)"); spine.setAttribute("stroke-width", "2");
+ wires.appendChild(spine);
+
+ // slots keyed "side:col" -> last y placed there
+ const lastY = {};
+ ROSTER.forEach((c, i) => {
+ const y = TOP + (maxLon - c.lon) / (maxLon - minLon) * drawH;
+ // try sides in an order that alternates by index, then widen columns
+ const order = [];
+ for (let col = 0; col < 6; col++) {
+ const first = (i % 2 === 0) ? "r" : "l";
+ const second = first === "r" ? "l" : "r";
+ order.push(first + ":" + col, second + ":" + col);
+ }
+ let slot = order.find((s) => lastY[s] === undefined || y - lastY[s] >= ROW_H)
+ || order[order.length - 1];
+ lastY[slot] = y;
+ const [side, colStr] = slot.split(":");
+ const col = parseInt(colStr, 10);
+
+ const labelX = side === "r"
+ ? cx + MIN_LEAD + col * COL_W
+ : cx - MIN_LEAD - col * COL_W;
+
+ const lead = document.createElementNS(svgNS, "line");
+ lead.setAttribute("x1", cx); lead.setAttribute("x2", labelX);
+ lead.setAttribute("y1", y); lead.setAttribute("y2", y);
+ lead.setAttribute("stroke", "var(--line)"); lead.setAttribute("stroke-width", "1.4");
+ wires.appendChild(lead);
+ const dot = document.createElementNS(svgNS, "circle");
+ dot.setAttribute("cx", cx); dot.setAttribute("cy", y);
+ dot.setAttribute("r", "3.5");
+ dot.setAttribute("fill", c.tz === home ? "var(--gold)" : "var(--steel)");
+ wires.appendChild(dot);
+
+ const lbl = document.createElement("div");
+ lbl.className = "lbl " + side;
+ lbl.style.left = (side === "r" ? labelX + 8 : labelX - 8) + "px";
+ lbl.style.top = y + "px";
+ lbl.innerHTML = "<div class='city'>" + c.label + "</div>"
+ + "<div class='time'></div><div class='meta'></div>";
+ labels.appendChild(lbl);
+ nodes.push({ c, lbl,
+ time: lbl.querySelector(".time"), meta: lbl.querySelector(".meta") });
+ });
+}
+function fmt(tz) {
+ const now = new Date();
+ const p = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz, hour12, weekday: "short",
+ day: "numeric", hour: hour12 ? "numeric" : "2-digit", minute: "2-digit",
+ }).formatToParts(now);
+ const g = (t) => (p.find((x) => x.type === t) || {}).value || "";
+ const h24 = parseInt(new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, hour12: false, hour: "2-digit" }).formatToParts(now)
+ .find((x) => x.type === "hour").value, 10) % 24;
+ const time = hour12
+ ? g("hour") + ":" + g("minute") + " " + g("dayPeriod")
+ : g("hour") + ":" + g("minute");
+ return { time, date: g("weekday") + " " + g("day"), night: h24 >= 19 || h24 < 7 };
+}
+function tick() {
+ for (const e of nodes) {
+ const f = fmt(e.c.tz);
+ e.time.textContent = f.time;
+ e.meta.innerHTML = f.date + " &nbsp;·&nbsp; <span class='tz'>"
+ + tzName(e.c.tz) + "</span>";
+ const isHome = e.c.tz === home;
+ e.lbl.classList.toggle("home", isHome);
+ e.lbl.classList.toggle("night", f.night && !isHome);
+ }
+}
+document.body.addEventListener("click", () => {
+ hour12 = !hour12;
+ document.getElementById("mode").textContent =
+ (hour12 ? "12H" : "24H") + " · click to toggle";
+ tick();
+});
+layout(); tick();
+setInterval(tick, 5000);
+let rt; window.addEventListener("resize", () => {
+ clearTimeout(rt); rt = setTimeout(() => { layout(); tick(); }, 150);
+});
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-23-world-clock-vertical-even.html b/docs/prototypes/2026-07-23-world-clock-vertical-even.html
new file mode 100644
index 0000000..58d24a1
--- /dev/null
+++ b/docs/prototypes/2026-07-23-world-clock-vertical-even.html
@@ -0,0 +1,128 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Dupre World — vertical, even spacing</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ * { box-sizing: border-box; }
+ body {
+ margin:0; min-height:100vh; background:var(--ground);
+ display:flex; flex-direction:column; justify-content:center;
+ padding:48px 64px; gap:22px;
+ font-family:var(--mono); color:var(--silver);
+ cursor:none; overflow:hidden;
+ }
+ .masthead {
+ color:var(--steel); font-size:12px; letter-spacing:0.35em;
+ text-transform:uppercase; margin-bottom:8px;
+ }
+ .masthead b { color:var(--gold); font-weight:400; }
+ .row { display:flex; align-items:baseline; gap:16px; }
+ .city { color:var(--steel); font-size:13px; letter-spacing:0.22em;
+ text-transform:uppercase; min-width:150px; }
+ .time { color:var(--cream); font-size:30px; letter-spacing:0.05em;
+ min-width:150px; }
+ .meta { color:var(--dim); font-size:12px; letter-spacing:0.12em; }
+ .meta .tz { color:var(--steel); }
+ .row.night .time { color:var(--slate); }
+ .row.night .city::after { content:" ☾"; color:var(--night); }
+ .row.home .time { color:var(--gold-hi); }
+ .row.home .city { color:var(--gold); }
+</style>
+</head>
+<body>
+ <div class="masthead"><b>DUPRE</b> · WORLD WATCH</div>
+ <div id="stack"></div>
+<script>
+/* Roster mirrors ~/.config/waybar/worldclock.conf (tz|label|lat|lon). The
+ real face receives this as ?cities=<json>; embedded here for the prototype. */
+const ROSTER = [
+ { tz:"Pacific/Auckland", label:"Wellington", lon:174.78 },
+ { tz:"Australia/Sydney", label:"Sydney", lon:151.21 },
+ { tz:"Asia/Tokyo", label:"Tokyo", lon:139.69 },
+ { tz:"Asia/Shanghai", label:"Shanghai", lon:121.47 },
+ { tz:"Asia/Kolkata", label:"Delhi", lon:77.21 },
+ { tz:"Asia/Yerevan", label:"Yerevan", lon:44.51 },
+ { tz:"Europe/Istanbul", label:"Istanbul", lon:28.98 },
+ { tz:"Europe/Athens", label:"Athens", lon:23.73 },
+ { tz:"Europe/Paris", label:"Paris", lon:2.35 },
+ { tz:"Europe/London", label:"London", lon:-0.13 },
+ { tz:"America/New_York", label:"New York", lon:-74.01 },
+ { tz:"America/Chicago", label:"New Orleans", lon:-90.07 },
+ { tz:"America/Los_Angeles", label:"Berkeley", lon:-122.27},
+ { tz:"America/Anchorage", label:"Anchorage", lon:-149.90},
+ { tz:"Pacific/Honolulu", label:"Honolulu", lon:-157.86},
+];
+/* Region names in Craig's "US Central" style, not the tz's major city.
+ Intl longGeneric is the fallback for a zone not in the map. */
+const TZNAME = {
+ "Pacific/Honolulu":"US Hawaii", "America/Anchorage":"US Alaska",
+ "America/Los_Angeles":"US Pacific", "America/Chicago":"US Central",
+ "America/New_York":"US Eastern", "Europe/London":"UK",
+ "Europe/Paris":"Central Europe", "Europe/Athens":"Eastern Europe",
+ "Europe/Istanbul":"Türkiye", "Asia/Yerevan":"Armenia",
+ "Asia/Kolkata":"India", "Asia/Shanghai":"China", "Asia/Tokyo":"Japan",
+ "Australia/Sydney":"Australia East", "Pacific/Auckland":"New Zealand",
+};
+function tzName(tz) {
+ if (TZNAME[tz]) return TZNAME[tz];
+ try {
+ const parts = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, timeZoneName: "longGeneric" }).formatToParts(new Date());
+ const p = parts.find((x) => x.type === "timeZoneName");
+ return p ? p.value : tz.split("/").pop().replace(/_/g, " ");
+ } catch (e) { return tz.split("/").pop().replace(/_/g, " "); }
+}
+const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
+const stack = document.getElementById("stack");
+stack.className = "";
+const rows = ROSTER.map((c) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.style.marginBottom = "18px";
+ const city = document.createElement("div"); city.className = "city";
+ city.textContent = c.label;
+ const time = document.createElement("div"); time.className = "time";
+ const meta = document.createElement("div"); meta.className = "meta";
+ row.append(city, time, meta);
+ stack.appendChild(row);
+ return { c, row, time, meta };
+});
+function fmt(tz) {
+ const now = new Date();
+ const p = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz, hour12: true, weekday: "short",
+ day: "numeric", hour: "numeric", minute: "2-digit",
+ }).formatToParts(now);
+ const g = (t) => (p.find((x) => x.type === t) || {}).value || "";
+ const h24 = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, hour12: false, hour: "2-digit" }).formatToParts(now)
+ .find((x) => x.type === "hour").value;
+ return {
+ time: g("hour") + ":" + g("minute") + " " + g("dayPeriod"),
+ date: g("weekday") + " " + g("day"),
+ night: (parseInt(h24, 10) % 24) >= 19 || (parseInt(h24, 10) % 24) < 7,
+ };
+}
+function tick() {
+ for (const e of rows) {
+ const f = fmt(e.c.tz);
+ e.time.textContent = f.time;
+ e.meta.innerHTML = f.date + " &nbsp;·&nbsp; <span class='tz'>"
+ + tzName(e.c.tz) + "</span>";
+ const isHome = e.c.tz === home;
+ e.row.classList.toggle("home", isHome);
+ e.row.classList.toggle("night", f.night && !isHome);
+ }
+}
+tick();
+setInterval(tick, 5000);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-23-world-clock-vertical-longitude.html b/docs/prototypes/2026-07-23-world-clock-vertical-longitude.html
new file mode 100644
index 0000000..a897755
--- /dev/null
+++ b/docs/prototypes/2026-07-23-world-clock-vertical-longitude.html
@@ -0,0 +1,177 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Dupre World — vertical, true longitude</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin:0; height:100%; }
+ body {
+ background:var(--ground); font-family:var(--mono); color:var(--silver);
+ cursor:none; overflow:hidden; position:relative;
+ }
+ .masthead {
+ position:absolute; top:34px; left:64px;
+ color:var(--steel); font-size:12px; letter-spacing:0.35em;
+ text-transform:uppercase; z-index:3;
+ }
+ .masthead b { color:var(--gold); font-weight:400; }
+ svg { position:absolute; inset:0; width:100%; height:100%; z-index:1; }
+ .lbl {
+ position:absolute; z-index:2; transform:translateY(-50%);
+ white-space:nowrap;
+ }
+ .lbl .city { color:var(--steel); font-size:12px; letter-spacing:0.2em;
+ text-transform:uppercase; }
+ .lbl .time { color:var(--cream); font-size:26px; letter-spacing:0.04em;
+ line-height:1.05; }
+ .lbl .meta { color:var(--dim); font-size:11px; letter-spacing:0.11em; }
+ .lbl .meta .tz { color:var(--steel); }
+ .lbl.night .time { color:var(--slate); }
+ .lbl.night .city::after { content:" ☾"; color:var(--night); }
+ .lbl.home .time { color:var(--gold-hi); }
+ .lbl.home .city { color:var(--gold); }
+</style>
+</head>
+<body>
+ <div class="masthead"><b>DUPRE</b> · WORLD WATCH &nbsp;·&nbsp; EAST → WEST</div>
+ <svg id="wires"></svg>
+ <div id="labels"></div>
+<script>
+const ROSTER = [
+ { tz:"Pacific/Auckland", label:"Wellington", lon:174.78 },
+ { tz:"Australia/Sydney", label:"Sydney", lon:151.21 },
+ { tz:"Asia/Tokyo", label:"Tokyo", lon:139.69 },
+ { tz:"Asia/Shanghai", label:"Shanghai", lon:121.47 },
+ { tz:"Asia/Kolkata", label:"Delhi", lon:77.21 },
+ { tz:"Asia/Yerevan", label:"Yerevan", lon:44.51 },
+ { tz:"Europe/Istanbul", label:"Istanbul", lon:28.98 },
+ { tz:"Europe/Athens", label:"Athens", lon:23.73 },
+ { tz:"Europe/Paris", label:"Paris", lon:2.35 },
+ { tz:"Europe/London", label:"London", lon:-0.13 },
+ { tz:"America/New_York", label:"New York", lon:-74.01 },
+ { tz:"America/Chicago", label:"New Orleans", lon:-90.07 },
+ { tz:"America/Los_Angeles", label:"Berkeley", lon:-122.27},
+ { tz:"America/Anchorage", label:"Anchorage", lon:-149.90},
+ { tz:"Pacific/Honolulu", label:"Honolulu", lon:-157.86},
+];
+const TZNAME = {
+ "Pacific/Honolulu":"US Hawaii", "America/Anchorage":"US Alaska",
+ "America/Los_Angeles":"US Pacific", "America/Chicago":"US Central",
+ "America/New_York":"US Eastern", "Europe/London":"UK",
+ "Europe/Paris":"Central Europe", "Europe/Athens":"Eastern Europe",
+ "Europe/Istanbul":"Türkiye", "Asia/Yerevan":"Armenia",
+ "Asia/Kolkata":"India", "Asia/Shanghai":"China", "Asia/Tokyo":"Japan",
+ "Australia/Sydney":"Australia East", "Pacific/Auckland":"New Zealand",
+};
+function tzName(tz) {
+ if (TZNAME[tz]) return TZNAME[tz];
+ try {
+ const parts = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, timeZoneName: "longGeneric" }).formatToParts(new Date());
+ const p = parts.find((x) => x.type === "timeZoneName");
+ return p ? p.value : tz.split("/").pop().replace(/_/g, " ");
+ } catch (e) { return tz.split("/").pop().replace(/_/g, " "); }
+}
+const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/* Geometry: longitude maps to y (east/high-lon at top, west/low-lon at bottom).
+ Dots sit at the true longitude position on a vertical line. Labels are
+ staggered into columns so near-longitude cities don't overwrite each other;
+ a horizontal leader (>= MIN_LEAD px) runs from each dot to its label. */
+const LINE_X = 84, TOP = 104, BOT = 104, MIN_LEAD = 7;
+const COL_W = 216, ROW_H = 68; // label footprint (3 lines + breathing room)
+const lons = ROSTER.map((c) => c.lon);
+const maxLon = Math.max(...lons), minLon = Math.min(...lons);
+const wires = document.getElementById("wires");
+const labels = document.getElementById("labels");
+let nodes = [];
+
+function layout() {
+ const H = window.innerHeight, W = window.innerWidth;
+ const drawH = H - TOP - BOT;
+ wires.innerHTML = ""; labels.innerHTML = ""; nodes = [];
+ const svgNS = "http://www.w3.org/2000/svg";
+
+ // the spine
+ const spine = document.createElementNS(svgNS, "line");
+ spine.setAttribute("x1", LINE_X); spine.setAttribute("x2", LINE_X);
+ spine.setAttribute("y1", TOP - 18); spine.setAttribute("y2", H - BOT + 18);
+ spine.setAttribute("stroke", "var(--line)"); spine.setAttribute("stroke-width", "2");
+ wires.appendChild(spine);
+
+ // true y per city, sorted east->west (already the roster order)
+ const placed = []; // per column: last y used
+ ROSTER.forEach((c) => {
+ const y = TOP + (maxLon - c.lon) / (maxLon - minLon) * drawH;
+ // leftmost column whose last label is >= ROW_H away
+ let col = 0;
+ while (placed[col] !== undefined && y - placed[col] < ROW_H) col++;
+ placed[col] = y;
+ const x = LINE_X + MIN_LEAD + col * COL_W;
+
+ // leader (horizontal from dot to label)
+ const lead = document.createElementNS(svgNS, "line");
+ lead.setAttribute("x1", LINE_X); lead.setAttribute("x2", x);
+ lead.setAttribute("y1", y); lead.setAttribute("y2", y);
+ lead.setAttribute("stroke", "var(--line)"); lead.setAttribute("stroke-width", "1.4");
+ wires.appendChild(lead);
+ // dot on the spine
+ const dot = document.createElementNS(svgNS, "circle");
+ dot.setAttribute("cx", LINE_X); dot.setAttribute("cy", y);
+ dot.setAttribute("r", "3.5");
+ dot.setAttribute("fill", c.tz === home ? "var(--gold)" : "var(--steel)");
+ wires.appendChild(dot);
+
+ const lbl = document.createElement("div");
+ lbl.className = "lbl";
+ lbl.style.left = (x + 8) + "px"; lbl.style.top = y + "px";
+ lbl.innerHTML = "<div class='city'>" + c.label + "</div>"
+ + "<div class='time'></div>"
+ + "<div class='meta'></div>";
+ labels.appendChild(lbl);
+ nodes.push({ c, lbl,
+ time: lbl.querySelector(".time"), meta: lbl.querySelector(".meta") });
+ });
+}
+function fmt(tz) {
+ const now = new Date();
+ const p = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz, hour12: true, weekday: "short",
+ day: "numeric", hour: "numeric", minute: "2-digit",
+ }).formatToParts(now);
+ const g = (t) => (p.find((x) => x.type === t) || {}).value || "";
+ const h24 = new Intl.DateTimeFormat("en-US",
+ { timeZone: tz, hour12: false, hour: "2-digit" }).formatToParts(now)
+ .find((x) => x.type === "hour").value;
+ return {
+ time: g("hour") + ":" + g("minute") + " " + g("dayPeriod"),
+ date: g("weekday") + " " + g("day"),
+ night: (parseInt(h24, 10) % 24) >= 19 || (parseInt(h24, 10) % 24) < 7,
+ };
+}
+function tick() {
+ for (const e of nodes) {
+ const f = fmt(e.c.tz);
+ e.time.textContent = f.time;
+ e.meta.innerHTML = f.date + " &nbsp;·&nbsp; <span class='tz'>"
+ + tzName(e.c.tz) + "</span>";
+ const isHome = e.c.tz === home;
+ e.lbl.classList.toggle("home", isHome);
+ e.lbl.classList.toggle("night", f.night && !isHome);
+ }
+}
+layout(); tick();
+setInterval(tick, 5000);
+let rt; window.addEventListener("resize", () => {
+ clearTimeout(rt); rt = setTimeout(() => { layout(); tick(); }, 150);
+});
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/dupre-kit-additions.js b/docs/prototypes/dupre-kit-additions.js
index 005a577..b1846a2 100644
--- a/docs/prototypes/dupre-kit-additions.js
+++ b/docs/prototypes/dupre-kit-additions.js
@@ -5,12 +5,9 @@
* so they can merge back into the kit with gallery cards.
*
* Load AFTER widgets.js. Contents:
- * - DUPRE.detentFader (NEW) — multi-detent slide attenuator with
- * speedbump drag physics.
- * - DUPRE.drumRoller (UPGRADE) — backward-compatible redefinition:
- * 1..N channels (stock hardcodes exactly two), configurable min/max
- * range and stage height. Defaults reproduce the stock instrument.
- * - DUPRE.guardedToggle (UPGRADE) — rotateX throw (no planar spin).
+ * - detentFader, drumRoller and guardedToggle have merged into widgets.js;
+ * their retired implementation references remain local below while the
+ * remaining additions continue to load in older casting prototypes.
* - DUPRE.batToggle (UPGRADE) — same throw fix.
* - DUPRE.slideRule (UPGRADE) — width option; the printed scale
* spreads its stops proportionally. Default keeps stock 180px.
@@ -101,21 +98,6 @@
const st = document.createElement('style');
st.id = 'dupre-additions-css';
st.textContent = `
-.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
-.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
- background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
-.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
- background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
-.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
- background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
-.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
- box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
-.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
- background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
- box-shadow:0 2px 4px #000a;pointer-events:none}
-.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
- transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
-.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
.dupre-slsel{display:block;width:220px;cursor:pointer;touch-action:none;user-select:none}
.dupre-slsel .sl-cols{display:grid}
.dupre-slsel .sl-col{display:flex;flex-direction:column;align-items:center;gap:3px;padding-bottom:5px}
@@ -177,7 +159,7 @@
(the detent value or null).
CSS lives in the additions block (.dupre-dfader); no other styles
involved. */
- DUPRE.detentFader = function (host, opts = {}) {
+ const mergedDetentFaderReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
@@ -255,7 +237,7 @@
each drum drags vertically on its own hit strip.
SVG-built: styling is inline attributes plus the shared .rsvg stage
block of DUPRE_CSS; gradients are additions-scoped until merge. */
- DUPRE.drumRoller = function (host, opts = {}) {
+ const mergedDrumRollerReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MIN = opts.min !== undefined ? opts.min : 1;
const MAX = opts.max !== undefined ? opts.max : 10;
@@ -313,7 +295,7 @@
foreshortens through the pivot and re-extends downward, never sweeping
sideways. Contract unchanged from stock: opts onLabel/offLabel/on/
onChange(on, 'ON'|'OFF'); handle {el, get, set}. */
- DUPRE.guardedToggle = function (host, opts = {}) {
+ const mergedGuardedToggleReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
grad('dkaDiscRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el
index 5a80e59..543affe 100644
--- a/docs/prototypes/gallery-widget.el
+++ b/docs/prototypes/gallery-widget.el
@@ -49,6 +49,12 @@ a typo'd token must fail loudly, not render black."
"Needle angle at value 0, degrees from vertical (negative = left).")
(defconst gallery-widget--gauge-max-deg 60.0
"Needle angle at value 100.")
+(defconst gallery-widget--gauge-cx 48
+ "Horizontal center of the needle gauge.")
+(defconst gallery-widget--gauge-cy 48
+ "Vertical center and baseline of the needle gauge.")
+(defconst gallery-widget--gauge-radius 47
+ "Outer radius of the needle gauge.")
(defun gallery-widget--clamp-value (value)
"Validate VALUE is a number and clamp it to the gauge's 0-100 range.
@@ -76,27 +82,36 @@ Positive angles swing right, matching the gauge's needle travel."
"Format coordinate N with two decimals for stable, readable SVG output."
(format "%.2f" n))
+(defun gallery-widget--semicircle-path (cx cy radius &optional close)
+ "Return an upper semicircle path centered at (CX, CY) with RADIUS.
+Append a closing segment when CLOSE is non-nil."
+ (format "M %g %g A %g %g 0 0 1 %g %g%s"
+ (- cx radius) cy radius radius (+ cx radius) cy
+ (if close " Z" "")))
+
(defun gallery-widget--node (svg tag &rest attrs)
"Append a TAG element with ATTRS plist to SVG and return it."
(let ((node (dom-node tag
(cl-loop for (k v) on attrs by #'cddr
collect (cons (intern (substring (symbol-name k) 1))
v)))))
- (svg--append svg node)
+ (dom-append-child svg node)
node))
(defun gallery-widget-needle-gauge (value)
"Render the gallery's needle gauge at VALUE (0-100) as an svg.el object.
Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
bottom center, 40px needle sweeping -60..+60 degrees, readout below."
- (let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
+ (let* ((w 96) (h 64)
+ (cx gallery-widget--gauge-cx)
+ (cy gallery-widget--gauge-cy)
;; clamp once; the angle and the readout render the same value
(value (gallery-widget--clamp-value value))
(angle (gallery-widget--needle-angle value))
(tip (gallery-widget--polar cx cy 40.0 angle))
(svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
;; glow filter: the SVG equivalent of the web card's box-shadow bloom
- (svg--append
+ (dom-append-child
svg
(dom-node 'defs nil
(dom-node 'filter
@@ -107,13 +122,15 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below."
(stdDeviation . "2"))))))
;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
(gallery-widget--node svg 'path
- :d "M 1 48 A 47 47 0 0 1 95 48"
+ :d (gallery-widget--semicircle-path
+ cx cy gallery-widget--gauge-radius)
:fill "none"
:stroke (gallery-widget-token 'wash)
:stroke-width "2")
;; ticks: 8px steel radials at -60 / 0 / +60
(dolist (deg '(-60.0 0.0 60.0))
- (let ((outer (gallery-widget--polar cx cy 47.0 deg))
+ (let ((outer (gallery-widget--polar
+ cx cy gallery-widget--gauge-radius deg))
(inner (gallery-widget--polar cx cy 39.0 deg)))
(gallery-widget--node svg 'line
:class "tick"
@@ -148,7 +165,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below."
;; lower half of its 8px circle; here the dome is drawn directly)
(gallery-widget--node svg 'path
:class "hub"
- :d "M 44 48 A 4 4 0 0 1 52 48 Z"
+ :d (gallery-widget--semicircle-path cx cy 4 t)
:fill (gallery-widget-token 'gold))
;; value readout, matching the web card's cream bold figure
(svg-text svg (format "%d%%" (round value))
diff --git a/docs/prototypes/gen_tokens.py b/docs/prototypes/gen_tokens.py
index 9f011e9..7c80985 100644
--- a/docs/prototypes/gen_tokens.py
+++ b/docs/prototypes/gen_tokens.py
@@ -70,6 +70,12 @@ def resolve_color(tokens, key):
raise KeyError(key)
+def iter_section_items(tokens, sections):
+ """Yield (name, value) pairs from SECTIONS in their declared order."""
+ for section in sections:
+ yield from tokens.get(section, {}).items()
+
+
def _el_str(v):
"""Quote a value as an elisp string literal, escaping backslashes/quotes."""
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
@@ -78,9 +84,8 @@ def _el_str(v):
def emit_web_css(tokens):
"""The inner :root block: hyphenated --vars, glows as rgb triples."""
lines = []
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- lines.append(f"--{k}:{v};")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ lines.append(f"--{k}:{v};")
for name, source in tokens.get("glow", {}).items():
lines.append(f"--{name}:{hex_to_triple(resolve_color(tokens, source))};")
for k, v in tokens.get("font", {}).items():
@@ -100,9 +105,8 @@ def emit_waybar_gtk(tokens):
" GTK CSS has no custom properties; reference these as @name, and use",
" alpha(@name, 0.5) where the web build uses rgba(var(--name),.5). */",
]
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- lines.append(f"@define-color {gtk(k)} {v};")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ lines.append(f"@define-color {gtk(k)} {v};")
for name, source in tokens.get("glow", {}).items():
lines.append(f"@define-color {gtk(name)} {resolve_color(tokens, source)};")
return "\n".join(lines)
@@ -111,9 +115,8 @@ def emit_waybar_gtk(tokens):
def emit_elisp(tokens):
"""An alist of (name . hex) plus timing, for the future svg.el renderer."""
pairs = []
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- pairs.append(f"({k} . {_el_str(v)})")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ pairs.append(f"({k} . {_el_str(v)})")
for name, source in tokens.get("glow", {}).items():
pairs.append(f"({name} . {_el_str(resolve_color(tokens, source))})")
for k, v in tokens.get("font", {}).items():
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 9ff492d..f53b823 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -581,6 +581,13 @@ const INFO={
origin:'Hardware samplers.',difficulty:'Expert.',
prefer:'The region IS the value and the data must stay visible.',
period:'1980s sampler styling.'},
+'A1':{input:'Drag horizontally; the thumb catches at each marked detent and needs an extra push to break free. Drag-only.',
+ solves:'A continuous value with a few mechanically meaningful resting points.',
+ use:'Specialty. Shines for balances and thresholds where common values should be felt without losing the continuum.',
+ limits:'Detents need enough spacing to remain distinct; a dense set should be a stepped selector instead.',
+ origin:'Console faders with center and calibrated detents.',difficulty:'Easy.',
+ prefer:'Use when the stops should slow a sweep, not quantize it.',
+ period:'1960s onward studio and control-console hardware.'},
'R20':{input:'Drag each drum vertically to roll it. Drag-only.',
solves:'Stepped selection with a satisfying mechanical roll.',
use:'Specialty. Shines for paired coarse selections (EQ turnovers).',
@@ -1394,9 +1401,12 @@ card(C,'R18','Thumb-slide attenuator pair',
card(C,'R19','Waveform region editor',
(st,rd)=>DUPRE.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}),
'<b>pick a region on the wave.</b> The monochrome LCD shows the sample; drag near either flag to move START or END, and the selection draws bright. After a sampler edit screen.');
+card(C,'A1','Detent fader',
+ (st,rd)=>DUPRE.detentFader(st,{value:50,detents:[25,50,75],onChange:(v,t)=>rd(t)}),
+ '<b>continuous travel with three speedbumps.</b> The cap catches at each calibrated tick and needs an extra push to leave it. Drag through the rail; the readout names a parked detent.');
card(C,'R20','Drum roller selector',
- (st,rd)=>DUPRE.drumRoller(st,{onChange:(v,t)=>rd(t)}),
- '<b>roll to the number.</b> The numbered drum shows through a tall window and the center value sits on an inverted chip. Drag either drum. After an equalizer tone selector.');
+ (st,rd)=>DUPRE.drumRoller(st,{min:0,max:12,channels:[{name:'HIGH',v:6},{name:'MID',v:4},{name:'LOW',v:8}],onChange:(v,t)=>rd(t)}),
+ '<b>roll to the number.</b> Three independently sized numbered drums demonstrate the generalized channel bank and range; each center value sits on an inverted chip. Drag any drum. After an equalizer tone selector.');
card(C,'R21','LED program row',
(st,rd)=>DUPRE.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}),
'<b>identical keys, one lit dot.</b> The LED above the button carries the selection, not the key itself — the classic program-select row. Click a key; readout names the program. After a digital reverb’s program bank.');
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 57ad33e..508ec3c 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1910,56 +1910,138 @@ DUPRE.waveRegion = function (host, opts = {}) {
return { el: s, get: () => ({ s: S, e: E }), set };
};
-/* R20 drum roller selector — numbered paper drum in a window, center chip reads it.
+/* A1 multi-detent fader — a horizontal attenuator with speedbump detents.
+
+ Contract:
+ opts: value (0-100, default 68); detents (default [50]); magnet (capture
+ distance, default 3); escape (extra distance needed to leave a
+ detent, default 3); onChange(value, label).
+ handle: el, get(), set(value), parked() (detent value or null). */
+DUPRE.detentFader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
+ const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
+ const ESCAPE = opts.escape !== undefined ? opts.escape : 3;
+ const el = document.createElement('span'); el.className = 'dupre-dfader';
+ el.innerHTML = '<span class="df-slot"></span><span class="df-fill"></span>';
+ const ticks = detents.map(d => {
+ const t = document.createElement('span'); t.className = 'df-tick';
+ t.style.left = `calc(8px + ${d / 100} * (100% - 16px))`;
+ el.appendChild(t); return t;
+ });
+ const cap = document.createElement('span'); cap.className = 'df-cap';
+ el.appendChild(cap); host.appendChild(el);
+
+ let v, park = null;
+ const paint = () => {
+ const left = `calc(8px + ${v / 100} * (100% - 16px))`;
+ cap.style.left = left;
+ el.querySelector('.df-fill').style.width =
+ `calc(${v / 100} * (100% - 18px))`;
+ el.classList.toggle('df-parked', park !== null);
+ ticks.forEach((t, i) =>
+ t.classList.toggle('df-park', detents[i] === park));
+ };
+ const apply = raw => {
+ raw = Math.max(0, Math.min(100, raw));
+ if (park !== null) {
+ if (Math.abs(raw - park) <= MAGNET + ESCAPE) raw = park;
+ else park = null;
+ }
+ if (park === null) {
+ const near = detents.find(d => Math.abs(raw - d) <= MAGNET);
+ if (near !== undefined) { park = near; raw = near; }
+ }
+ const nv = Math.round(raw);
+ if (nv === v && el.dataset.painted) return;
+ v = nv; el.dataset.painted = '1'; paint();
+ onChange(v, park !== null ? `${v} · detent` : String(v));
+ };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ const pct = m => {
+ const r = el.getBoundingClientRect();
+ return ((m.clientX - r.left - 8) / (r.width - 16)) * 100;
+ };
+ apply(pct(e));
+ const move = m => apply(pct(m));
+ const up = () => {
+ el.removeEventListener('pointermove', move);
+ el.removeEventListener('pointerup', up);
+ el.removeEventListener('pointercancel', up);
+ };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointercancel', up);
+ e.preventDefault();
+ });
+ v = -1; apply(opts.value !== undefined ? opts.value : 68);
+ return { el, get: () => v, set: apply, parked: () => park };
+};
+
+/* R20 drum roller selector — numbered paper drums in a window.
Contract (everything a consumer needs; no page globals touched):
opts: title (engraved header, default 'EQUALIZER'); channels (array of
- { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is
- sized for two); onChange(values array, 'NAME v · NAME v') fires on
- every set, including the initial paint.
- handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum
- drags vertically on its own hit strip.
+ { name, v } drums, any count >= 1, default HIGH/LOW pair);
+ min/max (default 1/10); height (default 140); onChange(values,
+ 'NAME v · ...') fires on every set, including initial paint.
+ handle: el, get() (values array), set(i, v); each drum drags vertically.
SVG-built: styling is inline attributes plus the shared .rsvg stage block
of DUPRE_CSS; gradients register in the shared defs plate. */
DUPRE.drumRoller = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v }));
- const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17;
+ const MIN = opts.min !== undefined ? opts.min : 1;
+ const MAX = opts.max !== undefined ? opts.max : 10;
+ const chans = (opts.channels ||
+ [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }])
+ .map(c => ({ name: c.name, v: c.v }));
+ const N = chans.length;
+ const vw = Math.max(130, N * 50 + 40);
+ const vh = Math.max(100, opts.height !== undefined ? opts.height : 140);
+ const railY = 24, railH = vh - 36, winY = railY + 4, winH = railH - 8;
+ const s = stageSvg(host, 'rsvg', vw, vh);
+ const cy = winY + winH / 2, step = 17;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
const localDefs = svgEl(s, 'defs', {});
- svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
+ if (opts.title !== '')
+ svgEl(s, 'text', { x: vw / 2, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
const grps = [];
+ const label = () =>
+ chans.map(c => `${c.name} ${Math.round(c.v)}`).join(' · ');
const set = (i, v) => {
- v = Math.max(1, Math.min(10, v)); chans[i].v = v;
+ v = Math.max(MIN, Math.min(MAX, v)); chans[i].v = v;
const p = grps[i];
p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
p.chip.textContent = Math.round(v);
- onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`);
+ onChange(chans.map(c => c.v), label());
};
+ const x0 = (vw - (N - 1) * 50) / 2;
chans.forEach((ch, i) => {
- const x = 45 + i * 50, cid = uid('drClip');
- svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
- svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
- svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ const x = x0 + i * 50, cid = uid('drClip');
+ svgEl(s, 'text', { x: x + 18, y: winY + 8, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
+ svgEl(s, 'text', { x: x + 18, y: winY + winH - 2, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
+ svgEl(s, 'rect', { x: x - 12, y: railY, width: 24, height: railH, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
const clip = svgEl(localDefs, 'clipPath', { id: cid });
- svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 });
- svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' });
+ svgEl(clip, 'rect', { x: x - 9, y: winY, width: 18, height: winH });
+ svgEl(s, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drPaper)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
- for (let n = 1; n <= 10; n++)
+ for (let n = MIN; n <= MAX; n++)
svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
- svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' });
+ svgEl(g, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drCurve)', 'pointer-events': 'none' });
svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
- svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
- const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' });
+ svgEl(s, 'text', { x, y: vh - 4, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 14, y: railY, width: 28, height: railH, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
grps.push({ nums, chip, base: ch.v });
- dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 });
+ dragDelta(hit, () => chans[i].v, v => set(i, v),
+ { min: MIN, max: MAX, sens: (MAX - MIN) / (winH * 1.1) });
});
- set(0, chans[0].v); set(1, chans[1].v);
+ chans.forEach((c, i) => set(i, c.v));
return { el: s, get: () => chans.map(c => c.v), set };
};
@@ -2251,11 +2333,13 @@ DUPRE.guardedToggle = function (host, opts = {}) {
svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
- lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ lever.style.transformBox = 'view-box';
+ lever.style.transformOrigin = `${cx}px ${cy}px`;
+ lever.style.transition = 'transform .16s ease-in-out';
let on;
const set = v => {
on = !!v;
- lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
+ lever.style.transform = on ? 'rotateX(0deg)' : 'rotateX(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
@@ -6089,6 +6173,23 @@ const DUPRE_CSS = `
.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+/* detent fader */
+.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
+.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
+ background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
+.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
+ background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
+.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
+ background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
+.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
+ box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
+.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
+ background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
+ box-shadow:0 2px 4px #000a;pointer-events:none}
+.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
+ transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
+.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
+
/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */
.rsvg{display:block}
.rsvg.drag{cursor:ns-resize;touch-action:none}
@@ -6136,6 +6237,15 @@ DUPRE.radarSweep.POLICY = { kind: 'screen',
DUPRE.abcKeypad.POLICY = { kind: 'screen',
why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)',
authentic: 'The window’s screen family. The keycap colours are functional and stay put.' };
+DUPRE.detentFader.POLICY = { kind: 'relational',
+ why: 'The gold parked detent only means anything against the quieter ordinary scale ticks.',
+ authentic: 'The detent and scale remain a contrasting pair; their material finishes and spacing may vary.' };
+DUPRE.drumRoller.POLICY = { kind: 'material',
+ why: 'Its colours describe paper, ink and the dark mechanical housing rather than application state.',
+ authentic: 'Paper and metal finishes from physical numbered drum selectors; the channel count and numeric range may vary.' };
+DUPRE.guardedToggle.POLICY = { kind: 'coded',
+ why: 'The red guard and collar mark a critical protected throw; recolouring them would erase that warning grammar.',
+ authentic: 'Red warning hardware with neutral metal lever and posts. Labels and initial position may vary.' };
Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
window.DUPRE = DUPRE;