diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/prototypes/2026-07-23-world-clock-horizontal.html | 190 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-23-world-clock-vertical-centered.html | 191 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-23-world-clock-vertical-even.html | 128 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-23-world-clock-vertical-longitude.html | 177 | ||||
| -rw-r--r-- | docs/prototypes/dupre-kit-additions.js | 30 | ||||
| -rw-r--r-- | docs/prototypes/gallery-widget.el | 29 | ||||
| -rw-r--r-- | docs/prototypes/gen_tokens.py | 21 | ||||
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 14 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 160 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-desktop-settings-panel-spec.org | 1 | ||||
| -rw-r--r-- | docs/specs/2026-07-11-net-doctor-expansion-spec.org | 1 |
11 files changed, 876 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 · 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 + " · <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 · 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 + " · <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 + " · <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 · 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 + " · <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; diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org index 4618e75..ad01aa0 100644 --- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -14,6 +14,7 @@ :PROPERTIES: :ID: fb7eec22-a214-4568-82c4-903612f4832f :END: +- [2026-07-22 Wed] IMPLEMENTED — post-ship correction (dotfiles ba3ec5e): the CLOCK channel now projects the garage clock itself — the clock package gained a wallpaper mode and settings-project run clock execs it; faces/clock.html (the off-spec SVG dial phase 4 shipped) is deleted. Enforces the clock-channel backing decision: one owner per clock design. Verified live on a headless output (PNG face, ticking seconds, clean stop; panel service untouched). - [2026-07-22 Wed] IMPLEMENTED — all four phases shipped in dotfiles: 7a15237 (phase 1, backings engine), 74f723e (bar slice, landed early), 5172289 (phase 2, presenters), 33d82eb (phase 3, GTK port of prototype 37), 680b50d (phase 4, projected faces / night watch / location / tick), 9038eee (the 17-point end-to-end pass re-derived from prototype 37's suite). Full dotfiles suite green (85 suites), AT-SPI smoke 13/13, e2e 17/17 on the live compositor. The Cairo drag gestures and the locked-path night-watch swap are on todo.org's "Manual testing and validation" checklist — human-eye checks, not blockers. Build adaptations from the phase logs: five-stage idle dial (WATCH ran as its own stage), held-to-unpause DND, no-backlight drums dim out, kiosk profile wiped per start, reveal gating pid-matched. - [2026-07-22 Wed] DOING — spec-response: the four rewritten implementation phases decomposed into build tasks in todo.org under a :SPEC_ID:-stamped parent, with the test surface and the final flip-to-IMPLEMENTED task emitted. The build proceeds in dotfiles settings/ per the location decision; archsetup drives. - [2026-07-22 Wed] READY — spec-review passed (rubric: Ready with caveats; see Review findings — two findings fixed in-run, two non-blocking build-time picks recorded). The prototyping gate closed at prototype 37, sealed by Craig's "Build it. Anything more would be bit fiddling." The FOCUS headphones glyph stands confirmed by the same words. Normative reference: prototype 37. diff --git a/docs/specs/2026-07-11-net-doctor-expansion-spec.org b/docs/specs/2026-07-11-net-doctor-expansion-spec.org index 2727702..0df74b3 100644 --- a/docs/specs/2026-07-11-net-doctor-expansion-spec.org +++ b/docs/specs/2026-07-11-net-doctor-expansion-spec.org @@ -8,6 +8,7 @@ :PROPERTIES: :ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 :END: +- [2026-07-23 Thu @ 08:40 -0500] IMPLEMENTED — Phase 4 (docs + rollout) closed by the dotfiles session, =138da7b=. Accepted against the phase's *stated acceptance criteria*, all three met: help complete, user guide (=net/README.md=), ratio rollout documented. The audit behind it found a real defect worth recording — =net repair='s action list named 9 of 19 registered actions, so ten tiers were reachable but undocumented, and =repair.py='s docstring carried the same stale copy; both now generate from the =ACTIONS= registry plus =viewmodel.STEP_NARRATION=, so they can't drift again. Two corrections to this spec from the build: the dep list is one item heavy — =gtk4-layer-shell= is no longer required, the net panel is a plain floating window now. And the Phase 4 *narrative* lists a panel help affordance that its acceptance criteria never did; no sibling panel (net, maint, settings, audio, bluetooth, panelkit) has one, so there is no convention to inherit and the shape is a genuine design call. It is tracked as its own =[#C]= in dotfiles rather than invented unilaterally, and it does not hold this phase open. - [2026-07-12 Sun @ 09:12 -0500] IMPLEMENTED — all v1 phases shipped on dotfiles main: Phase 0 read-only control-plane probe (=21ca3ff=), Phase 1 control-plane verdicts through the shared panelkit privilege model (=d73eba6=, =c9f8604=, =be15a81=), Phase 2 sharpened auth verdict (=12e3e76=). 813 net tests + 65 suites green against the fake harness. The privileged-fix live halves (real masked-NM/rival/keyfile states, real WPA3/hidden profiles) are handed off to the VM scenario harness (archsetup =a364c1e=) and the "Manual testing and validation" checklist in =todo.org= — findings there come back as bugs, not spec work. vNext items logged to =todo.org=. - [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phase 0 (read-only control-plane probe) is buildable now and =:solo:=; Phases 1-2 are gated on the shared cross-panel privilege model. The systemic connection-name redaction gap is a separate filed task. - [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phases 1-2 depend on the shared cross-panel run-time privilege model, which doesn't exist yet, so only Phase 0 (read-only detection) is buildable today. The redaction de-scope (parity + separate systemic task) is the one product call for Craig's eye. |
