aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-17 18:34:43 -0500
committerCraig Jennings <c@cjennings.net>2026-07-17 18:34:43 -0500
commit6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8 (patch)
tree007fd7c97871d0d23ae8db3afcf9951c1d48dcbe /docs/prototypes/widgets.js
parent6bcd0da82c505a2ec22268adfced0004401b74a9 (diff)
downloadarchsetup-6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8.tar.gz
archsetup-6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8.zip
feat(gallery): colour-policy system, chip-toggle accents, widgets-per-row
Every widget's colour is either the consumer's to pick or locked for a reason, and I kept classifying cards wrong from memory during the colour pass. So the policy is a declared, checked property now. Six kinds. Accent and screen are free and take colour chips. Coded, emissive, relational and material are locked and must not. Each classified builder carries a POLICY record naming the kind, why this widget is bound that way, and what it may change and stay authentic (the range that actually existed, like the CRT phosphors that shipped). Eight are classified. The rest are the worklist. The page shows all of it from that one source. A card recovers its builder from the arrow it was handed and reads GW.<builder>.POLICY, so the badge, the spec-sheet catalog and the index tally can't drift from the code. A probe holds it both ways: the recolour mechanism and the accent kind imply each other, so a locked card can't grow chips and an accent card can't forget to declare. The chip toggle is the first consumer of a shared accent family (red, amber, green, white, vfd) from GW.accentStyles, so the next card that wants those five gets them from one definition rather than a copy. Two smaller changes ride along because they touch the same header. The size toggle becomes WIDGETS/ROW, an explicit 1-4 column count instead of S/M/L zoom, and the policy panel sits level-right of the index.
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js171
1 files changed, 156 insertions, 15 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 70a63b3..5ba1226 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -114,6 +114,53 @@ const SCREEN_FAMS = {
const noop = () => {};
+/* ---- the accent family ----
+ One named set of lit colours for the widgets whose colour IS their claim: a
+ chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in
+ the next and a fault in the one after, so the colour belongs to the consumer
+ rather than the builder.
+ Deliberately NOT applied to widgets whose colour is the object rather than a
+ state — the nixie (the palette is explicit that neon is only ever orange), the
+ flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where
+ the colour is a standard rather than a preference: three greens on the landing
+ gear means down and locked, and a breaker's red/amber/green is its trip
+ semantics. Recolouring those doesn't restyle them, it makes them lie.
+ accentStyles(varName) builds a STYLES axis driving one CSS custom property, so
+ each consumer names its own var and the family stays single-source. */
+const ACCENTS = {
+ amber: 'var(--gold)',
+ green: 'var(--pass)',
+ red: 'var(--fail)',
+ white: 'var(--cream)',
+ vfd: 'var(--vfd)',
+};
+GW.accentStyles = varName => Object.fromEntries(
+ Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }]));
+
+/* ---- policy ----
+ Every widget's colour is either the consumer's to pick or locked for a reason,
+ and the reason is one of these six kinds. Declared per builder (GW.<name>.POLICY)
+ so it's a checked property rather than something the next person recalls — the
+ colour pass kept turning up cards where "is this one a standard?" was answered
+ from memory and answered wrong. The two FREE kinds get chips; the four LOCKED
+ ones must not, and the probe enforces the split.
+ Policy is a property of the WIDGET, not the colour: the same vfd cyan is a free
+ accent on a chip and a locked emissive identity on the marquee. A few cards are
+ mixed (a free display with a coded red-line) and declare per element in time.
+ Each builder's POLICY is a record { kind, why, authentic }:
+ kind — one of the six below.
+ why — why THIS widget is bound that way, in its own terms.
+ authentic — what may change and still be true to the reference (the range
+ that actually existed), or 'nothing' when the colour is fixed. */
+GW.POLICIES = {
+ accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' },
+ screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' },
+ coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' },
+ emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' },
+ relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same widget; the set is a scale or legend (VU zones, crossed needles).' },
+ material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' },
+};
+
/* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*).
opts.onStyle / offStyle / offText / thumb pick a named style per axis from
GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's
@@ -444,9 +491,33 @@ GW.indexPlate = function (host, opts = {}) {
const paper = svgEl(s, 'text', { x: 22, y: 31, 'font-size': 13, 'letter-spacing': '.1em',
'font-family': 'var(--mono)', fill: 'var(--scr-hi, #241d12)' });
- /* the index plate: cream, rounded, characters in printed rings like the Mignon's */
+ /* the index plate: cream, rounded, characters in printed rings like the Mignon's.
+ The ring area carries a halftone screen, which is what the real plate prints
+ its outer characters onto. */
+ if (!document.getElementById('ixHalf')) {
+ const defs = s.ownerDocument.querySelector('#gw-defs') || svgEl(s, 'defs', {});
+ const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' });
+ svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' });
+ svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 });
+ svgEl(pat, 'circle', { cx: 3, cy: 3, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 });
+ }
svgEl(s, 'rect', { x: PX - 8, y: PY - 10, width: COLS * CW + 14, height: ROWS * CH + 16, rx: 7,
- fill: 'url(#ixPlate)', stroke: '#8d8268', 'stroke-width': 1.5 });
+ fill: 'url(#ixHalf)', stroke: '#8d8268', 'stroke-width': 1.5 });
+ /* the inverted blocks, drawn under the cells: a light panel for the capitals,
+ a dark one for the lowercase — the plate's photographic-negative trick */
+ const ZFILL = { caps: '#e9e0bb', lower: '#231d13' };
+ const zoneAt = (r, c) => {
+ const z = GW.indexPlate.ZONES.find(z =>
+ r >= z.rows[0] && r <= z.rows[1] && c >= z.cols[0] && c <= z.cols[1]);
+ return z ? z.zone : 'ring';
+ };
+ GW.indexPlate.ZONES.forEach(z => {
+ svgEl(s, 'rect', {
+ x: PX + z.cols[0] * CW - 1, y: PY + z.rows[0] * CH - 1,
+ width: (z.cols[1] - z.cols[0] + 1) * CW + 2, height: (z.rows[1] - z.rows[0] + 1) * CH + 2,
+ fill: ZFILL[z.zone] || 'none',
+ });
+ });
let sel = null, buf = '';
/* null-prototype: cells is keyed by the plate's characters, and a plain {} would
@@ -476,11 +547,20 @@ GW.indexPlate = function (host, opts = {}) {
L.forEach((row, r) => row.forEach((c, i) => {
if (!c) return;
const x = PX + i * CW + CW / 2, y = PY + r * CH + CH / 2;
- const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell'); g.dataset.c = c;
+ /* disc and glyph invert together, by zone: dark disc + light glyph inside the
+ capitals block, the negative of that in the lowercase block, light-on-dark
+ everywhere on the ring. */
+ const z = zoneAt(r, i);
+ const DISC = { caps: '#231d13', lower: '#f2ecd6', ring: '#f2ecd6' };
+ const INK = { caps: '#f2ecd6', lower: '#231d13', ring: '#231d13' };
+ const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell');
+ g.dataset.c = c; g.dataset.zone = z;
g.style.cursor = 'pointer';
- const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: '#f7f2df', stroke: '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 });
- svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle', 'font-size': 9.5, 'font-weight': 600,
- 'font-family': 'var(--mono)', fill: '#241d12' }).textContent = c;
+ const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z],
+ stroke: z === 'caps' ? '#e9e0bb' : '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 });
+ svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle',
+ 'font-size': /[a-z]/.test(c) || /[A-Z]/.test(c) ? 9.5 : 8.5, 'font-weight': 600,
+ 'font-family': 'var(--mono)', fill: INK[z] }).textContent = c;
cells[c] = { ring, x, y };
g.addEventListener('click', () => press(c));
}));
@@ -550,14 +630,33 @@ GW.indexPlate = function (host, opts = {}) {
so a letter and its case sit in the same row six columns apart. Digits and the
punctuation ring keep the Mignon's edges. Rewrite this table to relayout the
plate: nothing below reads it except the renderer. */
+/* The Mignon Model 4's own plate, transcribed from the reference photo
+ (working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg).
+ Faithful first, iterate second — Craig's call, and the right order: the layout
+ has things in it worth understanding before replacing.
+ Two of its economies are load-bearing and easy to "fix" by accident:
+ - There is no 1 and no 0. You type them with lowercase l and capital O.
+ - J and j live out on the ring, not in the case blocks, which is why the
+ inversion below is positional and not "capitals are dark".
+ The two dashes differ: a long one top-right, a hyphen bottom-right. */
GW.indexPlate.LAYOUT = [
- ['A','B','C','D','E','F', 'a','b','c','d','e','f'],
- ['G','H','I','J','K','L', 'g','h','i','j','k','l'],
- ['M','N','O','P','Q','R', 'm','n','o','p','q','r'],
- ['S','T','U','V','W','X', 's','t','u','v','w','x'],
- ['Y','Z','Ä','Ö','Ü','§', 'y','z','ä','ö','ü','ß'],
- ['1','2','3','4','5','6', '7','8','9','0','½','¼'],
- ['.',',',';',':','!','?', "'",'"','(',')','-','+'],
+ ['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'],
+ ['§','P','F','U','G','Q', 'p','f','u','g','q',';'],
+ ['J','V','I','N','A','B', 'v','i','n','a','b','j'],
+ ['/','L','D','E','T','M', 'l','d','e','t','m',','],
+ ['%','K','O','S','R','Z', 'k','o','s','r','z','='],
+ ['¾','Y','C','H','W','X', 'y','c','h','w','x','+'],
+ ['½','¼','2','3','4','5', '6','7','8','9','.','-'],
+];
+/* The plate's two inverted regions, as rectangles over LAYOUT rather than a rule
+ about characters. On the real plate the capitals block is dark discs on a light
+ panel and the lowercase block is its photographic negative, while everything on
+ the outer ring stays light-on-halftone — including the capital J, which is why
+ no case-based rule can describe this. Move a block, move its rectangle.
+ rows and cols are inclusive [start, end] indices into LAYOUT. */
+GW.indexPlate.ZONES = [
+ { rows: [1, 5], cols: [1, 5], zone: 'caps' },
+ { rows: [1, 5], cols: [6, 10], zone: 'lower' },
];
/* Both tables are DERIVED from the layout, never maintained beside it: relaying
the plate must not leave a keybinding aimed at a character it no longer has.
@@ -663,12 +762,23 @@ GW.chipToggle = function (host, opts = {}) {
const chip = document.createElement('span'); chip.className = 'chip';
chip.textContent = opts.label || 'discoverable on';
host.appendChild(chip);
+ const setStyle = (axis, name) => {
+ const o = (GW.chipToggle.STYLES[axis] || {})[name];
+ if (!o) return;
+ for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v);
+ };
+ setStyle('accent', opts.accent || 'amber');
let on;
const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
chip.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
- return { el: chip, get: () => on, set };
+ return { el: chip, get: () => on, set, setStyle };
};
+/* The chip's lit colour, from the shared accent family. Gold was hardcoded, which
+ let the chip say exactly one thing — but "on" is good in one panel, a warning in
+ the next, and a fault in the one after. The colour is the claim, so it belongs
+ to the consumer. */
+GW.chipToggle.STYLES = { accent: GW.accentStyles('--chip-on') };
/* 08 arm-to-fire — two-stage confirm for destructive actions */
GW.armButton = function (host, opts = {}) {
@@ -4417,7 +4527,7 @@ const GW_CSS = `
.key.off{opacity:.4}
.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
-.chip.on{color:var(--gold);border-color:var(--gold)}
+.chip.on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))}
.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
.badge.red{background:var(--fail);color:var(--cream)}
@@ -4888,6 +4998,37 @@ function ensureCss() {
if (document.head) ensureCss();
else document.addEventListener('DOMContentLoaded', ensureCss);
+/* Policy classification (see GW.POLICIES). This is the colour pass's worklist made
+ explicit: every card gets a record here as we review it — the kind it's bound
+ by, why in its own terms, and the range it may vary and stay authentic. This
+ round covers the cards already touched; the rest are unclassified on purpose,
+ the review still to do card by card, not a memory dump to fill now. Assigned
+ after the builders exist, in one place, so it reads as a catalogue. */
+GW.slideToggle.POLICY = { kind: 'accent',
+ why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the widget’s.',
+ authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' };
+GW.segmented.POLICY = { kind: 'accent',
+ why: 'The active segment signals a mode whose meaning depends on the panel it sits in.',
+ authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' };
+GW.chipToggle.POLICY = { kind: 'accent',
+ why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.',
+ authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' };
+GW.dataMatrix.POLICY = { kind: 'screen',
+ why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.',
+ authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' };
+GW.roundCrt.POLICY = { kind: 'screen',
+ why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.',
+ authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' };
+GW.waveRegion.POLICY = { kind: 'screen',
+ why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.',
+ authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' };
+GW.radarSweep.POLICY = { kind: 'screen',
+ why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.',
+ authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' };
+GW.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.' };
+
Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
window.GW = GW;
})();