@bongos/core 1.19.141 → 1.19.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +29 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/rate-limit.js +15 -5
- package/modules/provisioning/routes/provisioning.js +71 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +35 -8
- package/public-landing/projects.html +130 -18
- package/src/module-api.js +5 -1
- package/tests/cosmos_token_contrast.mjs +147 -11
- package/tests/provisioning_slug_available.mjs +243 -0
- package/tests/public_landing_projects.mjs +118 -0
|
@@ -161,7 +161,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
161
161
|
.humanSteps p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.55;margin-top:4px;}
|
|
162
162
|
/* a repo the platform already handled is named as a plain inline link (task 1002855) —
|
|
163
163
|
the .ghbtn treatment reads as an action to take, and there is nothing here to do */
|
|
164
|
-
.humanSteps p a{color:var(--
|
|
164
|
+
.humanSteps p a{color:var(--link-ink);font-weight:700;text-decoration:none;}
|
|
165
165
|
.humanSteps p a:hover{text-decoration:underline;}
|
|
166
166
|
/* live checklist states (task 1002694): a step the platform has verified done, and a
|
|
167
167
|
step that can't be performed yet (its prerequisite — usually the domain — is missing) */
|
|
@@ -222,7 +222,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
222
222
|
.prog .slbl{font-size:12px;font-weight:700;color:var(--ink-faint);}
|
|
223
223
|
.prog .stages li.now .slbl,.prog .stages li.done .slbl{color:color-mix(in srgb,var(--sea) 40%,var(--ink));}
|
|
224
224
|
.prog .pnote{text-align:center;font-size:13px;font-weight:600;color:color-mix(in srgb,var(--sea) 45%,var(--ink-soft));min-height:18px;}
|
|
225
|
-
.prog .pnote a{color:var(--
|
|
225
|
+
.prog .pnote a{color:var(--link-ink);font-weight:700;text-decoration:none;}
|
|
226
226
|
.prog .pnote a:hover{text-decoration:underline;}
|
|
227
227
|
.prog.isLive{background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);border-color:color-mix(in srgb,var(--ok) 30%,var(--bg-card));}
|
|
228
228
|
.prog.isLive .stages li.done:not(:first-child)::before{background:var(--ok);}
|
|
@@ -255,6 +255,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
255
255
|
.addrConfirm.optedOut{border-color:var(--line);background:var(--bg);}
|
|
256
256
|
.addrConfirm.optedOut .addrBig{color:var(--ink-soft);}
|
|
257
257
|
.addrConfirm.optedOut>p{color:var(--ink-faint);}
|
|
258
|
+
/* the handle is not free (task 1002898 N10) — a refusal must not be painted into the
|
|
259
|
+
green confirmation box that means "yours" */
|
|
260
|
+
.addrConfirm.unavailable{border-color:color-mix(in srgb,var(--err) 45%,var(--bg-card));background:var(--bg);}
|
|
261
|
+
.addrConfirm.unavailable .addrBig{color:var(--err);}
|
|
258
262
|
.addrBig{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.3px;line-height:1.4;}
|
|
259
263
|
.addrBig code{font-family:var(--font-mono);font-size:15px;background:var(--bg-deep);border-radius:8px;padding:2px 9px;color:var(--accent-deep);word-break:break-all;}
|
|
260
264
|
.addrConfirm>p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.5;margin-top:6px;}
|
|
@@ -713,8 +717,14 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
713
717
|
</section>
|
|
714
718
|
|
|
715
719
|
<!-- step 4 · address — a CONFIRMATION, not a form (task 1002704: every project
|
|
716
|
-
gets its slug subdomain assigned up front
|
|
717
|
-
|
|
720
|
+
gets its slug subdomain assigned up front). The domainless default was a
|
|
721
|
+
proven dead-end (QA walk, task 1002702).
|
|
722
|
+
Three choices, all legible without opening anything (task 1002898 N8):
|
|
723
|
+
the assigned address is stated, and BOTH alternatives sit under it — the
|
|
724
|
+
bring-your-own domain behind its disclosure, the no-address opt-out in the
|
|
725
|
+
open. The opt-out used to live INSIDE the "domain I own" fold, i.e. the
|
|
726
|
+
choice for NO address was hidden under a fold labelled the opposite
|
|
727
|
+
choice; the owner read the step as "weird" and never found it. -->
|
|
718
728
|
<section class="panel" id="panel-address" aria-labelledby="addr-h">
|
|
719
729
|
<h1 id="addr-h">Its address in the sky</h1>
|
|
720
730
|
<p class="lede">The handle names your project on the platform — and its address comes with it. Sign-in, sharing, everything just works from the moment it stands up.</p>
|
|
@@ -733,11 +743,11 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
733
743
|
<input type="text" id="f-domain" placeholder="mercury.example.com" autocomplete="off" maxlength="253" spellcheck="false">
|
|
734
744
|
<div class="hint" id="h-domain"> </div>
|
|
735
745
|
</label>
|
|
736
|
-
<label class="addrOpt"><input type="checkbox" id="f-noaddr">
|
|
737
|
-
<span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
|
|
738
|
-
</label>
|
|
739
746
|
</div>
|
|
740
747
|
</details>
|
|
748
|
+
<label class="addrOpt"><input type="checkbox" id="f-noaddr">
|
|
749
|
+
<span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
|
|
750
|
+
</label>
|
|
741
751
|
</div>
|
|
742
752
|
<div class="nav">
|
|
743
753
|
<button type="button" class="btn ghost" data-back="3">← Back</button>
|
|
@@ -2113,30 +2123,101 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2113
2123
|
if (state.noAddress) return '';
|
|
2114
2124
|
return assignedDomain() || '';
|
|
2115
2125
|
}
|
|
2126
|
+
/* What the create POST may CARRY, as opposed to what the panel displays: only a
|
|
2127
|
+
bring-your-own domain. The assigned <slug>.<apex> default is the server's to
|
|
2128
|
+
apply (task 1002704) and re-asserting it here is what task 1002898 N2 closes —
|
|
2129
|
+
apexBase() falls back to guessing from location.hostname, and a guess that
|
|
2130
|
+
disagrees with the server's apex (a subdomain hub like get.<apex>) is refused
|
|
2131
|
+
as an under-apex squat. Omit it and the server assigns the right one. */
|
|
2132
|
+
function byoDomain() {
|
|
2133
|
+
return state.domain && looksLikeDomain(state.domain) ? state.domain : '';
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/* ── slug availability (task 1002898 N10) ──
|
|
2137
|
+
The panel promised '<slug>.<apex> — yours from the moment it stands up' on FORMAT
|
|
2138
|
+
validation alone, so a collision surfaced only as a 409 at the final Create click.
|
|
2139
|
+
GET /provisioning/slug-available answers the same question the create route will,
|
|
2140
|
+
and the promise is now made only once the server has said the handle is free.
|
|
2141
|
+
This NARROWS the race, it does not close it — the 409 at create is still the only
|
|
2142
|
+
authority — so an unanswered check degrades to naming the address WITHOUT the
|
|
2143
|
+
ownership promise, never to guessing in either direction. */
|
|
2144
|
+
var slugAvail = { slug: '', state: 'unknown' }; /* unknown | checking | free | taken | yours | reserved */
|
|
2145
|
+
var slugCheckT = 0;
|
|
2146
|
+
var slugCheckGen = 0;
|
|
2147
|
+
/* Only trust a verdict that belongs to the handle currently in the field. */
|
|
2148
|
+
function slugAvailState() {
|
|
2149
|
+
return slugAvail.slug === state.slug ? slugAvail.state : 'unknown';
|
|
2150
|
+
}
|
|
2151
|
+
/* Debounced, and generation-guarded because a cleared timer cannot cancel a chain
|
|
2152
|
+
already mid-fetch — each chain carries the generation it was born under, so an
|
|
2153
|
+
orphaned answer goes quiet instead of overwriting a newer one (the manifest-poll
|
|
2154
|
+
idiom below). The caller re-renders; this only sets state. */
|
|
2155
|
+
function checkSlugAvailable() {
|
|
2156
|
+
clearTimeout(slugCheckT);
|
|
2157
|
+
slugCheckGen += 1;
|
|
2158
|
+
var slug = state.slug;
|
|
2159
|
+
if (!validSlug(slug)) { slugAvail = { slug: slug, state: 'unknown' }; return; }
|
|
2160
|
+
slugAvail = { slug: slug, state: 'checking' };
|
|
2161
|
+
var myGen = slugCheckGen;
|
|
2162
|
+
slugCheckT = setTimeout(function () {
|
|
2163
|
+
fetch(API + '/provisioning/slug-available?slug=' + encodeURIComponent(slug), { credentials: 'same-origin' })
|
|
2164
|
+
.then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
|
|
2165
|
+
.then(function (d) {
|
|
2166
|
+
if (myGen !== slugCheckGen) return;
|
|
2167
|
+
slugAvail = { slug: slug, state: d.available ? 'free' : (d.reason || 'taken') };
|
|
2168
|
+
renderAddrPanel();
|
|
2169
|
+
})
|
|
2170
|
+
.catch(function () {
|
|
2171
|
+
if (myGen !== slugCheckGen) return;
|
|
2172
|
+
/* no answer is NOT a free answer — back to the format-only wording */
|
|
2173
|
+
slugAvail = { slug: slug, state: 'unknown' };
|
|
2174
|
+
renderAddrPanel();
|
|
2175
|
+
});
|
|
2176
|
+
}, 400);
|
|
2177
|
+
}
|
|
2116
2178
|
function renderAddrPanel() {
|
|
2117
2179
|
var box = $('addrConfirm');
|
|
2118
2180
|
if (!box) return;
|
|
2119
2181
|
var assigned = assignedDomain();
|
|
2120
2182
|
var custom = state.domain && looksLikeDomain(state.domain) ? state.domain : '';
|
|
2121
|
-
box.classList.toggle('optedOut', state.noAddress && !custom);
|
|
2122
2183
|
var big = $('addrAssigned');
|
|
2123
2184
|
var note = $('addrAssignedNote');
|
|
2185
|
+
/* the verdict only bears on the ASSIGNED address — a chosen domain or the
|
|
2186
|
+
opt-out settles the step without it */
|
|
2187
|
+
var avail = !custom && !state.noAddress && assigned ? slugAvailState() : 'unknown';
|
|
2188
|
+
box.classList.toggle('optedOut', state.noAddress && !custom);
|
|
2189
|
+
box.classList.toggle('unavailable', avail === 'taken' || avail === 'yours' || avail === 'reserved');
|
|
2124
2190
|
if (custom) {
|
|
2125
2191
|
big.innerHTML = '<code>' + esc(custom) + '</code> — your own domain, wired up for you.';
|
|
2126
2192
|
note.textContent = 'DNS pointing happens after the standup; the card on your projects page walks you through it.';
|
|
2127
2193
|
} else if (state.noAddress) {
|
|
2128
2194
|
big.textContent = 'Starting without an address.';
|
|
2129
2195
|
note.textContent = 'Sign-in on your project can’t be set up until it has one — add it any time from your projects page.';
|
|
2130
|
-
} else if (assigned) {
|
|
2196
|
+
} else if (assigned && avail === 'free') {
|
|
2131
2197
|
big.innerHTML = '<code>' + esc(assigned) + '</code> — yours from the moment it stands up.';
|
|
2132
2198
|
note.textContent = 'Sign-in, sharing, everything just works. You can point a domain you own at it any time.';
|
|
2199
|
+
} else if (assigned && avail === 'taken') {
|
|
2200
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — already taken.';
|
|
2201
|
+
note.textContent = 'Another project on the platform holds that handle. Pick a different one.';
|
|
2202
|
+
} else if (assigned && avail === 'yours') {
|
|
2203
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — you already have that one.';
|
|
2204
|
+
note.textContent = 'That handle belongs to one of your own projects. Pick a different one to start something new.';
|
|
2205
|
+
} else if (assigned && avail === 'reserved') {
|
|
2206
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — reserved by the platform.';
|
|
2207
|
+
note.textContent = 'That address is the platform’s own. Pick a different handle, or bring a domain you own.';
|
|
2208
|
+
} else if (assigned && avail === 'checking') {
|
|
2209
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — checking that it’s free…';
|
|
2210
|
+
note.textContent = 'One moment.';
|
|
2211
|
+
} else if (assigned) {
|
|
2212
|
+
/* the check didn’t answer — name the address, promise nothing about it */
|
|
2213
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — the address it would take.';
|
|
2214
|
+
note.textContent = 'We couldn’t check whether that handle is free just now — you’ll know for certain when you create it.';
|
|
2133
2215
|
} else if (!validSlug(state.slug)) {
|
|
2134
2216
|
big.textContent = 'Pick a handle above and the address comes with it.';
|
|
2135
2217
|
note.textContent = '';
|
|
2136
2218
|
} else {
|
|
2137
2219
|
/* dotless host (local dev): nothing sane to auto-assign — the domain
|
|
2138
|
-
field IS the step here, so unfold it
|
|
2139
|
-
opt-out checkbox unreachable-by-default; owner walk, 2026-08-12) */
|
|
2220
|
+
field IS the step here, so unfold it */
|
|
2140
2221
|
big.textContent = 'No address is assigned on this host.';
|
|
2141
2222
|
note.textContent = 'Add a domain you own below, or start without one.';
|
|
2142
2223
|
$('addrCustom').open = true;
|
|
@@ -2202,7 +2283,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2202
2283
|
(a pre-auth probe 401s for strangers and, worse, could seed the
|
|
2203
2284
|
draft's owner with myLogin()'s placeholder) */
|
|
2204
2285
|
if (step === 3) { syncHome(); if (authKnown) ensureRepoAccess(); }
|
|
2205
|
-
|
|
2286
|
+
/* re-check on every entry, not just on typing: a resumed draft arrives here with
|
|
2287
|
+
a handle nobody has asked the server about, and a handle free ten minutes ago
|
|
2288
|
+
may not be now (task 1002898 N10) */
|
|
2289
|
+
if (step === 4) { checkSlugAvailable(); renderAddrPanel(); }
|
|
2206
2290
|
if (step === 5) renderReview();
|
|
2207
2291
|
}
|
|
2208
2292
|
|
|
@@ -2427,6 +2511,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2427
2511
|
if (!state.slug) { h.innerHTML = ' '; h.className = 'hint'; this.classList.remove('bad'); }
|
|
2428
2512
|
else if (validSlug(state.slug)) { h.textContent = 'Nice — that works.'; h.className = 'hint ok'; this.classList.remove('bad'); }
|
|
2429
2513
|
else { h.textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.'; h.className = 'hint err'; this.classList.add('bad'); }
|
|
2514
|
+
checkSlugAvailable();
|
|
2430
2515
|
renderAddrPanel();
|
|
2431
2516
|
saveDraft();
|
|
2432
2517
|
});
|
|
@@ -2481,6 +2566,23 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2481
2566
|
return false;
|
|
2482
2567
|
}
|
|
2483
2568
|
if (state.domain && !looksLikeDomain(state.domain)) return false;
|
|
2569
|
+
/* A handle the server has already said is not free would only 409 at Create —
|
|
2570
|
+
stop here instead (task 1002898 N10). ONLY a definite negative blocks:
|
|
2571
|
+
'unknown' and 'checking' must never gate the step, because the check is
|
|
2572
|
+
advisory and the 409 remains the authority. A chosen domain or the opt-out
|
|
2573
|
+
settles the address another way, so the block does not apply there. */
|
|
2574
|
+
if (!state.domain && !state.noAddress) {
|
|
2575
|
+
var av = slugAvailState();
|
|
2576
|
+
if (av === 'taken' || av === 'yours' || av === 'reserved') {
|
|
2577
|
+
$('h-slug').textContent = av === 'taken'
|
|
2578
|
+
? 'That handle is already taken — pick a different one.'
|
|
2579
|
+
: av === 'yours'
|
|
2580
|
+
? 'You already have a project with that handle — pick a different one.'
|
|
2581
|
+
: 'That handle is reserved by the platform — pick a different one.';
|
|
2582
|
+
$('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
|
|
2583
|
+
return false;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2484
2586
|
/* no auto-assigned address (dotless host): going domainless must be an
|
|
2485
2587
|
EXPLICIT choice — a domain, or the opt-out box. Never silent. */
|
|
2486
2588
|
if (!resolvedDomain() && !state.noAddress) {
|
|
@@ -2593,10 +2695,15 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2593
2695
|
slug: state.slug,
|
|
2594
2696
|
type: state.projType || undefined,
|
|
2595
2697
|
hosting_shape: 'standalone',
|
|
2596
|
-
domain
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2698
|
+
/* ONLY a bring-your-own domain travels (task 1002898 N2). The server owns
|
|
2699
|
+
the assigned <slug>.<apex> default (task 1002704) and applies it to an
|
|
2700
|
+
omitted domain, so re-sending our own derived copy could only disagree
|
|
2701
|
+
with it — apexBase() may be guessing from location.hostname, and a guess
|
|
2702
|
+
that lands under the apex but isn't the server's assignment is refused
|
|
2703
|
+
as a squat. Omitting it is how the default is now requested. */
|
|
2704
|
+
domain: byoDomain() || undefined,
|
|
2705
|
+
/* An absent domain means "assign me the default", so the opt-out must
|
|
2706
|
+
travel explicitly — it is the only thing that means "none". */
|
|
2600
2707
|
no_address: state.noAddress ? true : undefined,
|
|
2601
2708
|
target_ref: state.owner + '/' + state.repo,
|
|
2602
2709
|
/* LOAD-BEARING for the control-plane runner, not just for the checklist we
|
|
@@ -2618,9 +2725,14 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2618
2725
|
});
|
|
2619
2726
|
}).then(function (data) {
|
|
2620
2727
|
try {
|
|
2728
|
+
/* The address the instance ACTUALLY holds, read off the create response —
|
|
2729
|
+
not our preview of it (task 1002898 N2). The server owns the assigned
|
|
2730
|
+
default, so its answer is the only one the done panel may promise; a
|
|
2731
|
+
client-derived copy could name an address that was never assigned. */
|
|
2732
|
+
var madeDomain = (data.instance && data.instance.domain) || '';
|
|
2621
2733
|
sessionStorage.setItem(CREATED_KEY, JSON.stringify({
|
|
2622
|
-
id: data.instance && data.instance.id, slug: state.slug, domain:
|
|
2623
|
-
noaddr:
|
|
2734
|
+
id: data.instance && data.instance.id, slug: state.slug, domain: madeDomain,
|
|
2735
|
+
noaddr: !madeDomain,
|
|
2624
2736
|
mode: state.mode, name: state.name, at: Date.now()
|
|
2625
2737
|
}));
|
|
2626
2738
|
sessionStorage.removeItem(DRAFT_KEY);
|
package/src/module-api.js
CHANGED
|
@@ -709,8 +709,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
709
709
|
// landed since 1.19.139 with no explicit bump. run 31891805999. (task 1002620)
|
|
710
710
|
// 1.19.141 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
711
711
|
// landed since 1.19.140 with no explicit bump. run 31892484952. (task 1002620)
|
|
712
|
+
// 1.19.142 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
713
|
+
// landed since 1.19.141 with no explicit bump. run 31893298770. (task 1002620)
|
|
714
|
+
// 1.19.143 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
715
|
+
// landed since 1.19.142 with no explicit bump. run 31893905320. (task 1002620)
|
|
712
716
|
// ---------------------------------------------------------------------------
|
|
713
|
-
const CORE_VERSION = '1.19.
|
|
717
|
+
const CORE_VERSION = '1.19.143'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
714
718
|
|
|
715
719
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
716
720
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -135,6 +135,25 @@ const contrast = (fg, bg) => {
|
|
|
135
135
|
|
|
136
136
|
const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
137
137
|
|
|
138
|
+
// CIE Lab (D65) + ΔE76 — for ink-vs-ink questions contrast() cannot answer.
|
|
139
|
+
// Two colors can each clear 8:1 over the same ground and still BE the same
|
|
140
|
+
// color; a ratio between them only sees lightness, and the collision this file
|
|
141
|
+
// now guards is a chroma one.
|
|
142
|
+
const labF = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29);
|
|
143
|
+
const lab = ([r, g, b]) => {
|
|
144
|
+
const [R, G, B] = [lin(r), lin(g), lin(b)];
|
|
145
|
+
const [fx, fy, fz] = [
|
|
146
|
+
labF((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047),
|
|
147
|
+
labF(0.2126 * R + 0.7152 * G + 0.0722 * B),
|
|
148
|
+
labF((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883),
|
|
149
|
+
];
|
|
150
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
151
|
+
};
|
|
152
|
+
const deltaE76 = (a, b) => {
|
|
153
|
+
const [A, B] = [lab(a), lab(b)];
|
|
154
|
+
return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]);
|
|
155
|
+
};
|
|
156
|
+
|
|
138
157
|
// ── the pairs cosmos.css actually paints ─────────────────────────────────────
|
|
139
158
|
// need: 4.5 normal text · 3.0 large text and meaningful non-text
|
|
140
159
|
const CARD = 'var(--bg-card)';
|
|
@@ -143,14 +162,22 @@ const PAIRS = [
|
|
|
143
162
|
['.btn label on its fill', '#ffffff', 'var(--accent)', 4.5],
|
|
144
163
|
['.btn.ghost:hover label', 'var(--accent-deep)', CARD, 4.5],
|
|
145
164
|
['.btn.danger label', 'var(--err)', CARD, 4.5],
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// dark --sun-lit recolors — they pinned
|
|
149
|
-
// branding pack, so a re-themed instance got
|
|
150
|
-
// The dark blocks derive
|
|
151
|
-
['
|
|
152
|
-
['
|
|
165
|
+
// Code chips, pills, the now-step number, the hover inks: --accent-deep is
|
|
166
|
+
// CHROME ink in both modes since task 1002822 moved the text links off it
|
|
167
|
+
// (task 1002750 had already deleted the dark --sun-lit recolors — they pinned
|
|
168
|
+
// the locked space palette over the branding pack, so a re-themed instance got
|
|
169
|
+
// Cloud Bongos orange links). The dark blocks derive it from --accent.
|
|
170
|
+
['--accent-deep ink on a card', 'var(--accent-deep)', CARD, 4.5],
|
|
171
|
+
['--accent-deep ink on the ground', 'var(--accent-deep)', 'var(--bg)', 4.5],
|
|
153
172
|
['code chip on --bg-deep', 'var(--accent-deep)', 'var(--bg-deep)', 4.5],
|
|
173
|
+
// --link-ink: the text links' own pack-derived token (task 1002822). Nothing
|
|
174
|
+
// measured --accent-deep as a link ink against the copy AROUND it, and in dark
|
|
175
|
+
// the two had collapsed into one color — see the separation test below.
|
|
176
|
+
// --bg-deep is not a link ground today; it is the review panel's robustness
|
|
177
|
+
// floor, so a link dropped into a --bg-deep panel can never ship under AA.
|
|
178
|
+
['--link-ink on a card', 'var(--link-ink)', CARD, 4.5],
|
|
179
|
+
['--link-ink on the page ground', 'var(--link-ink)', 'var(--bg)', 4.5],
|
|
180
|
+
['--link-ink on --bg-deep', 'var(--link-ink)', 'var(--bg-deep)', 4.5],
|
|
154
181
|
// body copy
|
|
155
182
|
['--ink body copy', 'var(--ink)', CARD, 4.5],
|
|
156
183
|
['--ink-soft secondary copy', 'var(--ink-soft)', CARD, 4.5],
|
|
@@ -180,6 +207,15 @@ const PAIRS = [
|
|
|
180
207
|
// must still measure (19px/700 headline = large text, 3.0)
|
|
181
208
|
['optedOut .addrBig (large) on --bg', 'var(--ink-soft)', 'var(--bg)', 3.0],
|
|
182
209
|
['addrOpt warning on --bg', 'var(--warn)', 'var(--bg)', 4.5],
|
|
210
|
+
// ?view=mine (task 1002823) — both surfaces traded a subtree opacity for muted
|
|
211
|
+
// tokens, so the tokens themselves now carry the de-emphasis and must measure.
|
|
212
|
+
// The two pill rows are the pair that proves the split: one mute cannot serve
|
|
213
|
+
// both grounds, because on the pressed --accent fill no ink token clears 4.5
|
|
214
|
+
// (--ink is the best at 3.70 light / 4.05 dark) and only the button's #fff does.
|
|
215
|
+
['.filters .n count, resting pill', 'var(--ink-faint)', CARD, 4.5],
|
|
216
|
+
['.filters .n count, pressed pill', '#ffffff', 'var(--accent)', 4.5],
|
|
217
|
+
['.proj.gone name (muted)', 'var(--ink-soft)', CARD, 4.5],
|
|
218
|
+
['.proj.gone meta (dim removed)', 'var(--ink-faint)', CARD, 4.5],
|
|
183
219
|
// lifecycle strip: 12px/700
|
|
184
220
|
['.strip .slbl idle', 'var(--ink-faint)', CARD, 4.5],
|
|
185
221
|
['.strip .slbl now/done', 'color-mix(in srgb,var(--sea) 40%,var(--ink))', CARD, 4.5],
|
|
@@ -227,6 +263,103 @@ for (const mode of ['light', 'dark']) {
|
|
|
227
263
|
});
|
|
228
264
|
}
|
|
229
265
|
|
|
266
|
+
// ── ink vs ADJACENT ink ──────────────────────────────────────────────────────
|
|
267
|
+
// Every PAIRS row above is (ink, ground), so the suite was structurally blind to
|
|
268
|
+
// a link that measured 8.74:1 on its card and was STILL the same color as the
|
|
269
|
+
// prose it sat in. That is exactly what dark mode shipped: --accent-deep
|
|
270
|
+
// resolved to #e9ad8f beside --ink-soft #dcb693 — ΔE76 9.7, two lightness points
|
|
271
|
+
// apart — and dark links carry text-decoration:none until hover, so font-weight
|
|
272
|
+
// was the only cue left. The live repro was .projMsg a.
|
|
273
|
+
//
|
|
274
|
+
// ΔE76 and not a luminance ratio, even though contrast() is right there: light
|
|
275
|
+
// mode's ACCEPTED separation is ΔE76 40.9 but only 1.10:1 in luminance. The
|
|
276
|
+
// light pair is told apart by chroma, so a luminance floor would fail the good
|
|
277
|
+
// case and pass the bad one.
|
|
278
|
+
//
|
|
279
|
+
// The floor is 12, not light mode's 40.9: dark's warm ink IS a desaturated
|
|
280
|
+
// ember, and srgb color-mix can only slide the accent along the line toward
|
|
281
|
+
// white, so 51% is the most chroma --link-ink can hold before a dark navy pack
|
|
282
|
+
// accent falls under 4.5:1 on --bg-deep. Shipped: 14.1 dark, 40.9 light.
|
|
283
|
+
const LINK_SEPARATION = 12;
|
|
284
|
+
|
|
285
|
+
test('the link ink never collapses into the copy around it', () => {
|
|
286
|
+
const failures = [];
|
|
287
|
+
for (const mode of ['light', 'dark']) {
|
|
288
|
+
const card = over(resolve(CARD, mode), resolve('var(--page-bg)', mode));
|
|
289
|
+
const link = over(resolve('var(--link-ink)', mode), card);
|
|
290
|
+
for (const ink of ['--ink-soft', '--ink-faint']) {
|
|
291
|
+
const beside = over(resolve(`var(${ink})`, mode), card);
|
|
292
|
+
const got = deltaE76(link, beside);
|
|
293
|
+
if (got < LINK_SEPARATION) {
|
|
294
|
+
failures.push(
|
|
295
|
+
`${mode}: --link-ink ${hex(link)} vs ${ink} ${hex(beside)} — ` +
|
|
296
|
+
`ΔE76 ${got.toFixed(1)} (needs ${LINK_SEPARATION})`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
assert.deepEqual(failures, [], `\n ${failures.join('\n ')}\n`);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The accent packs the rest of this file never sees. Everything above measures
|
|
305
|
+
// the ONE pack config/branding.neutral.json ships — but --link-ink is DERIVED
|
|
306
|
+
// from --accent in dark, so its tuning has to survive whatever accent a host
|
|
307
|
+
// sets. These four are the review panel's own sweep (task 1002822); each is dark
|
|
308
|
+
// enough to carry white .btn text, which any pack's --accent must already do.
|
|
309
|
+
// DARK only: in light --link-ink is var(--accent-deep), a value the pack ships
|
|
310
|
+
// directly rather than derives, so light is the pack author's own contract.
|
|
311
|
+
const ACCENT_SWEEP = {
|
|
312
|
+
navy: '#1a237e', forest: '#14532d', red: '#b91c1c', magenta: '#a21caf',
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
test('--link-ink survives a re-theme in dark mode', () => {
|
|
316
|
+
const failures = [];
|
|
317
|
+
const shipped = RAW.dark['--accent'];
|
|
318
|
+
try {
|
|
319
|
+
for (const [name, accent] of Object.entries(ACCENT_SWEEP)) {
|
|
320
|
+
RAW.dark['--accent'] = accent;
|
|
321
|
+
const page = resolve('var(--page-bg)', 'dark');
|
|
322
|
+
for (const groundExpr of [CARD, 'var(--bg)', 'var(--bg-deep)']) {
|
|
323
|
+
const bg = over(resolve(groundExpr, 'dark'), page);
|
|
324
|
+
const got = contrast(over(resolve('var(--link-ink)', 'dark'), bg), bg);
|
|
325
|
+
if (got < 4.5) failures.push(`${name} on ${groundExpr}: ${got.toFixed(2)}:1 (needs 4.5)`);
|
|
326
|
+
}
|
|
327
|
+
const card = over(resolve(CARD, 'dark'), page);
|
|
328
|
+
const link = over(resolve('var(--link-ink)', 'dark'), card);
|
|
329
|
+
for (const ink of ['--ink-soft', '--ink-faint']) {
|
|
330
|
+
const got = deltaE76(link, over(resolve(`var(${ink})`, 'dark'), card));
|
|
331
|
+
if (got < LINK_SEPARATION) {
|
|
332
|
+
failures.push(`${name} vs ${ink}: ΔE76 ${got.toFixed(1)} (needs ${LINK_SEPARATION})`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} finally {
|
|
337
|
+
RAW.dark['--accent'] = shipped;
|
|
338
|
+
}
|
|
339
|
+
assert.deepEqual(failures, [], `\n ${failures.join('\n ')}\n`);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('no text link is left painting --accent-deep', () => {
|
|
343
|
+
// The migration's own regression net. A token only helps the rules that take
|
|
344
|
+
// it, and the rules are spread across two sheets: re-pointing cosmos.css alone
|
|
345
|
+
// left projects.html's own `.humanSteps p a` — a plain inline link sitting in
|
|
346
|
+
// `.humanSteps p{color:var(--ink-soft)}` — on the colliding ink. Anchor
|
|
347
|
+
// selectors only: --accent-deep is still the right ink for the chrome that
|
|
348
|
+
// never sits inside prose (code chips, .tbl/.cardRel pills, .btn.ghost:hover,
|
|
349
|
+
// .filters button:hover, the .steps/.humanSteps number chips), and each of
|
|
350
|
+
// those is measured by its own PAIRS row above.
|
|
351
|
+
const strays = [];
|
|
352
|
+
for (const [where, text] of [['cosmos.css', cssSrc], ['projects.html', pageSrc]]) {
|
|
353
|
+
for (const m of text.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
|
354
|
+
if (!/\ba\b/.test(m[1])) continue;
|
|
355
|
+
if (/(?:^|[;{\s])color\s*:\s*var\(\s*--accent-deep\s*\)/.test(m[2])) {
|
|
356
|
+
strays.push(`${where}: ${m[1].trim().slice(0, 70)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
assert.deepEqual(strays, [], `\n ${strays.join('\n ')}\n`);
|
|
361
|
+
});
|
|
362
|
+
|
|
230
363
|
// The gate's LOCKED sky palette — deliberately absent from COLOR_VARS, so a
|
|
231
364
|
// branding pack can never re-theme these. Dark GROUNDS are allowed (dark mode IS
|
|
232
365
|
// the gate's space, by design); text painted with one shows Cloud Bongos orange
|
|
@@ -292,9 +425,12 @@ const dimsBelowFull = (body) => {
|
|
|
292
425
|
|
|
293
426
|
test('text is never dimmed by subtree opacity', () => {
|
|
294
427
|
// Two nets. (1) Anywhere in either sheet: a rule that declares BOTH a color
|
|
295
|
-
// and a sub-1 opacity is dimmed text by construction. (2) The
|
|
296
|
-
//
|
|
297
|
-
//
|
|
428
|
+
// and a sub-1 opacity is dimmed text by construction. (2) The known text
|
|
429
|
+
// containers may not carry opacity at all — this also catches ancestor dims
|
|
430
|
+
// (.createSplit, .proj.gone) and blocks that declare NO color of their own,
|
|
431
|
+
// which is exactly how .filters .n and .proj.gone survived net 1 into task
|
|
432
|
+
// 1002823. Graphics are deliberately absent from the list: .orb.ghost and
|
|
433
|
+
// .worldOrb.dim carry no text and their opacity IS the state cue.
|
|
298
434
|
const offenders = [];
|
|
299
435
|
for (const [where, text] of [['cosmos.css', cssSrc], ['projects.html', pageSrc]]) {
|
|
300
436
|
for (const m of text.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
|
@@ -303,7 +439,7 @@ test('text is never dimmed by subtree opacity', () => {
|
|
|
303
439
|
}
|
|
304
440
|
}
|
|
305
441
|
}
|
|
306
|
-
for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec']) {
|
|
442
|
+
for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec', '\\.filters', '\\.proj\\b']) {
|
|
307
443
|
for (const text of [cssSrc, pageSrc]) {
|
|
308
444
|
for (const m of text.matchAll(new RegExp(`${sel}[^{}]*\\{([^}]*)\\}`, 'g'))) {
|
|
309
445
|
if (dimsBelowFull(m[1])) offenders.push(`text container dimmed: ${m[0].slice(0, 90)}`);
|