Prove page switch fix (the project lead's 0.3.19 Mac screenshot, 7 October 2026): the thin white rectangle above-left of "Prove on this machine" was the DAG legend's bare .lg rule hitting label.switch.lg, the knob outside the track at off was the inspector's bare .track rule (flex, 10 px margin) hitting every switch track; both scoped (.legend .lg, #i-track), the switch's size class is its own (big), the native input is hidden the accessible way (1 px clip, focusable, label kept) under a positioned label; three view tests, known-failed first on the old CSS (bare rules, input hiding, knob inside the track at off and on in both sizes with the theme blocks untouched)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 13:26:29 +00:00
parent 9d80b81bc1
commit b900fb585f
3 changed files with 67 additions and 16 deletions

View file

@ -474,18 +474,21 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.wrap{flex-wrap:wrap}
.row .addr-input{margin-top:0;min-width:0}
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
/* the custom switch (Prove page fix, 7 October 2026): the label is the positioned ancestor, the native input is hidden
the accessible way (1 px, clipped, still focusable, the label association kept), the knob lives inside the track at
both states; the size class is the switch's own (big), never the DAG legend's swatch class */
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4;position:relative}
.switch+.switch{border-top:1px solid var(--line)}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
.switch input:checked+.track{background:var(--ember)}
.switch input:checked+.track::after{transform:translateX(18px)}
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
.switch input:disabled+.track{opacity:.4}
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
.switch.lg .track::after{width:24px;height:24px}
.switch.lg input:checked+.track::after{transform:translateX(22px)}
.switch.big .track{width:52px;height:30px;flex-basis:52px}
.switch.big .track::after{width:24px;height:24px}
.switch.big input:checked+.track::after{transform:translateX(22px)}
.sw-text{min-width:0}
.sw-text b{font-weight:600}
.sw-text .dim{font-size:var(--t-base)}
@ -664,13 +667,14 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:7px}
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.lg.pending{border-color:var(--ash)}
.lg.included{border-color:var(--ember)}
.lg.excluded{border-color:var(--ash);opacity:.45}
.lg.proven{background:var(--ember);border-color:var(--ember)}
.lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
.legend .lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.legend .lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.legend .lg.pending{border-color:var(--ash)}
.legend .lg.included{border-color:var(--ember)}
.legend .lg.excluded{border-color:var(--ash);opacity:.45}
.legend .lg.proven{background:var(--ember);border-color:var(--ember)}
.legend .lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* Cards: the Tuning strip above the list */
.tune-strip{padding:var(--s-4) var(--card-pad)}
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
@ -799,9 +803,10 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden}
.proof-scene canvas{display:block;width:100%;height:100%}
.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px}
.track{display:flex;gap:3px;height:3px;margin-bottom:10px}
.track i{flex:1 1 0;background:var(--line);border-radius:2px}
.track i.proving{background:var(--ember)}.track i.verified{background:var(--bone)}.track i.paid{background:var(--molten)}
/* the inspector's shard track, scoped to its element (a bare .track reached every switch's track until 7 October 2026) */
#i-track{display:flex;gap:3px;height:3px;margin-bottom:10px}
#i-track i{flex:1 1 0;background:var(--line);border-radius:2px}
#i-track i.proving{background:var(--ember)}#i-track i.verified{background:var(--bone)}#i-track i.paid{background:var(--molten)}
.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto}
.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)}
.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)}

View file

@ -429,7 +429,7 @@
<h3>Prove on this machine</h3>
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
</div>
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<ul class="tier-list" id="pv-tiers"></ul>
<p class="line-text" id="pv-line"></p>

View file

@ -811,3 +811,49 @@ test('the light theme contrast ratio of every vendor colour on its well is at le
assert.equal(/\.badge\.(nvidia|amd|intel|apple|gpu)\{[^}]*var\(--ember/.test(css), false, 'no badge rule tints with ember');
assert.equal(/\.badge[^{]*\{[^}]*animation/.test(css), false, 'nothing on the mark animates');
});
// ---------- the custom switch (the Prove page fix, 7 October 2026) ----------
// the project lead's screenshot from the live 0.3.19 Mac app: a thin white rectangle above-left of the "Prove on this machine"
// switch and the knob drawn outside the track's left edge at off. Two generic classes reached the switch (the DAG
// legend swatch .lg hit label.switch.lg, the shard track .track hit .switch .track) and the native input was an
// unpositioned absolute. This test reads app.css: no bare .lg or .track rule may exist (every use is scoped), the
// native input is hidden the accessible way (1 px clip, still focusable), and the knob's box sits inside the track's
// box at off and at on for both sizes, in the base rules and untouched by both theme blocks.
const cssRules = (text) => { const out = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; text = text.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = re.exec(text))) out.push({ sel: m[1].trim(), body: m[2] }); return out; };
const ruleOf = (sel) => cssRules(css).filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)).map((r) => r.body).join(';');
const px = (body, prop) => { const m = new RegExp('(?:^|;)\\s*' + prop + ':\\s*(-?[\\d.]+)px').exec(body); return m ? parseFloat(m[1]) : null; };
const translate = (body) => { const m = /transform:\s*translateX\((-?[\d.]+)px\)/.exec(body); return m ? parseFloat(m[1]) : 0; };
test('no generic .lg or .track rule reaches a custom switch: every use of those class names is scoped (known-failed on the 0.3.19 CSS)', () => {
const bare = cssRules(css).flatMap((r) => r.sel.split(',').map((x) => x.trim())).filter((sel) => /^\.(lg|track)(\.[\w-]+)*(\s+[\w.:-]+)*$/.test(sel) && !/^\.switch\b/.test(sel));
assert.deepEqual(bare, [], 'selectors that start with a bare .lg or .track: ' + bare.join(' | '));
assert.equal(/<label class="switch lg"/.test(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8')), false, 'the Prove switch no longer shares the legend swatch class');
});
test('the native input of every custom switch is hidden the accessible way: 1 px clipped, still in the tree and focusable, the label kept', () => {
const sw = ruleOf('.switch'), input = ruleOf('.switch input');
assert.match(sw, /position:\s*relative/, '.switch is the positioned ancestor of its input');
assert.match(input, /position:\s*absolute/);
assert.equal(px(input, 'width'), 1); assert.equal(px(input, 'height'), 1);
assert.match(input, /clip:\s*rect\(0 0 0 0\)|clip-path:\s*inset\(50%\)/, 'the clip pattern');
assert.match(input, /overflow:\s*hidden/);
assert.equal(/display:\s*none|visibility:\s*hidden/.test(input), false, 'display none would take the input out of the tab order');
assert.match(ruleOf('.switch input:focus-visible+.track'), /outline/, 'focus stays visible on the track');
});
test('the knob sits inside the track at off and at on, both sizes, and neither theme block touches the switch geometry', () => {
const track = ruleOf('.switch .track'), knob = ruleOf('.switch .track::after'), on = ruleOf('.switch input:checked+.track::after');
const big = ruleOf('.switch.big .track'), bigKnob = ruleOf('.switch.big .track::after'), bigOn = ruleOf('.switch.big input:checked+.track::after');
const inside = (tw, th, kw, kh, left, top, dx, label) => {
assert.ok(left >= 0 && top >= 0, label + ': the knob starts inside (left ' + left + ', top ' + top + ')');
assert.ok(left + kw <= tw && top + kh <= th, label + ' off: knob ' + kw + 'x' + kh + ' at ' + left + ',' + top + ' in a ' + tw + 'x' + th + ' track');
assert.ok(left + dx + kw <= tw, label + ' on: knob right edge ' + (left + dx + kw) + ' in a ' + tw + ' track');
assert.ok(left + dx >= 0, label + ' on: knob left edge ' + (left + dx));
};
assert.match(track, /position:\s*relative/, 'the track positions its knob');
inside(px(track, 'width'), px(track, 'height'), px(knob, 'width'), px(knob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(on), 'the 40 px switch');
inside(px(big, 'width'), px(big, 'height'), px(bigKnob, 'width'), px(bigKnob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(bigOn), 'the big switch');
// the two theme blocks (prefers-color-scheme light, data-theme light) only set tokens: no .switch or .track rule inside them
for (const m of css.matchAll(/@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme="dark"\]\)\{[^}]*\}\}|:root\[data-theme="light"\]\{[^}]*\}/g)) assert.equal(/\.switch|\.track/.test(m[0]), false, 'a theme block touches the switch');
assert.equal(cssRules(css).some((r) => /\.switch|\.track/.test(r.sel) && /@media \(prefers-color-scheme/.test(r.sel)), false);
});