mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Sync generated provider output
This commit is contained in:
@@ -14,6 +14,10 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
// reuse the r/g/b-only channel tolerance.
|
||||
const SHADOW_ALPHA_TOLERANCE = 0.02;
|
||||
const RADIUS_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
@@ -474,6 +478,25 @@ function addSidecarRadii(out, sidecar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the
|
||||
// documented shadow vocabulary that Stitch's frontmatter schema can't hold.
|
||||
// Their colors go into a separate allowlist — NOT allowedColorKeys — because a
|
||||
// shadow black is only documented *as a shadow*: feeding it into the general
|
||||
// color allowlist would legalize #000 as a page ground (alpha is dropped from
|
||||
// colorKey), which is the hole issue #547 warns against.
|
||||
function addSidecarShadows(out, sidecar) {
|
||||
const shadows = sidecar?.extensions?.shadows;
|
||||
if (!Array.isArray(shadows)) return;
|
||||
|
||||
for (const entry of shadows) {
|
||||
if (typeof entry?.value !== 'string') continue;
|
||||
for (const match of entry.value.matchAll(CSS_COLOR_RE)) {
|
||||
const parsed = parseDesignColor(match[0]);
|
||||
if (parsed) out.allowedShadowColors.push({ color: parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignSystem(input = {}) {
|
||||
const frontmatter = input.frontmatter || {};
|
||||
const sidecar = input.sidecar || null;
|
||||
@@ -486,6 +509,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
allowedShadowColors: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
@@ -495,6 +519,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
addSidecarRadii(out, sidecar);
|
||||
addSidecarShadows(out, sidecar);
|
||||
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
@@ -614,6 +639,20 @@ function isAllowedColorRaw(raw, designSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A color is a documented shadow color only when both the r/g/b channels AND
|
||||
// the alpha match a sidecar shadow token's color. Alpha has to be compared
|
||||
// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone
|
||||
// would let every black at every alpha through.
|
||||
function isAllowedShadowColorRaw(raw, designSystem) {
|
||||
if (!designSystem?.allowedShadowColors?.length) return false;
|
||||
const parsed = parseDesignColor(String(raw || '').trim().toLowerCase());
|
||||
if (!parsed) return false;
|
||||
return designSystem.allowedShadowColors.some(entry =>
|
||||
colorsClose(parsed, entry.color) &&
|
||||
Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedRadiusRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasRadii) return true;
|
||||
const text = String(raw || '').trim().toLowerCase();
|
||||
@@ -691,6 +730,40 @@ function isProbablyColorLiteral(line, match) {
|
||||
return styleContext || cssFunctionContext || jsColorKeyContext;
|
||||
}
|
||||
|
||||
// One complete `${...}` template interpolation. Its content may carry paired
|
||||
// quoted strings (function arguments, ternary branches) and one level of
|
||||
// braces (an object-literal argument, itself allowing paired quotes). Deeper
|
||||
// nesting would need a parser, so the regex deliberately fails safe there:
|
||||
// the context check misses and the finding fires — a false positive a waiver
|
||||
// can silence, never a leak.
|
||||
const QUOTED_STRING_SRC = `"[^"]*"|'[^']*'`;
|
||||
const INTERPOLATION_SRC =
|
||||
`\\$\\{(?:${QUOTED_STRING_SRC}|\\{(?:${QUOTED_STRING_SRC}|[^{}"'\`])*\\}|[^{}"'\`])*\\}`;
|
||||
// The two shadow-context tails. Unlike jsColorKeyContext, the JS tail admits
|
||||
// commas: a multi-layer shadow string is comma-separated, and a later
|
||||
// property on the same line is still blocked because it sits past the
|
||||
// string's closing quote. Both tails admit complete interpolations; a bare
|
||||
// `}`, quote, or `;` still ends the context.
|
||||
const SHADOW_CSS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[{\\s;"'\`(,])(?:box-shadow|text-shadow)\\s*:\\s*(?:${INTERPOLATION_SRC}|[^;{}"'\`])*$`, 'i',
|
||||
);
|
||||
const SHADOW_JS_CONTEXT_RE = new RegExp(
|
||||
`(?:^|[,{]\\s*)(?:boxShadow|textShadow)\\s*[:=]\\s*["'\`]?(?:${INTERPOLATION_SRC}|[^"'\`}])*$`, 'i',
|
||||
);
|
||||
|
||||
// True when the color literal sits inside a box-shadow / text-shadow value —
|
||||
// the only contexts where a documented shadow color is legal. Anchored to the
|
||||
// end of `before` (no ; } { or quote in between) so a shadow property earlier
|
||||
// on the line can't leak the allowance into a later declaration. Kept separate
|
||||
// from isProbablyColorLiteral(), which stays a boolean for its existing call
|
||||
// sites and deliberately discards which property matched.
|
||||
function isShadowPropertyContext(line, match) {
|
||||
const index = match.index ?? -1;
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
return SHADOW_CSS_CONTEXT_RE.test(before) || SHADOW_JS_CONTEXT_RE.test(before);
|
||||
}
|
||||
|
||||
function isInsideCssAttributeSelector(line, index) {
|
||||
if (index < 0) return false;
|
||||
const before = line.slice(0, index);
|
||||
@@ -824,6 +897,7 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
if (!isProbablyColorLiteral(line, match)) continue;
|
||||
const raw = cssColorLabel(match[0]);
|
||||
if (isAllowedColorRaw(raw, designSystem)) continue;
|
||||
if (isShadowPropertyContext(line, match) && isAllowedShadowColorRaw(raw, designSystem)) continue;
|
||||
findings.push(makeDesignFinding(
|
||||
'design-system-color',
|
||||
filePath,
|
||||
@@ -1038,6 +1112,7 @@ export {
|
||||
loadDesignSystemForCwd,
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedShadowColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
|
||||
@@ -483,8 +483,9 @@ function page() {
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
const inspirationSrc = option.heroSrc || option.boardSrc;
|
||||
const inspiration = inspirationSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(inspirationSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
@@ -492,10 +493,12 @@ function page() {
|
||||
// and a declined card's comp slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceComp(option)) {
|
||||
const textOnlyFacts = backFacts(option);
|
||||
return `<div class="media comp-pending" data-comp="${esc(option.compSrc)}">
|
||||
<div class="shimmer"><span class="comp-note">rendering…</span></div>
|
||||
<img class="comp" alt="" hidden>
|
||||
${inspiration}
|
||||
<template class="text-only-facts">${textOnlyFacts}</template>
|
||||
<div class="chips">${expandChip}${details}</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1018,17 +1021,42 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// A live elapsed count is the difference between "working" and "frozen".
|
||||
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
|
||||
const settle = () => { clearInterval(tick); m.classList.remove('comp-pending', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
|
||||
const standIn = () => {
|
||||
const fallback = () => {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (!pip || m.classList.contains('stand-in')) return;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
if (pip) {
|
||||
if (m.classList.contains('stand-in')) return false;
|
||||
img.src = pip.getAttribute('src'); img.hidden = false;
|
||||
m.classList.add('stand-in');
|
||||
m.querySelector('.shimmer')?.remove();
|
||||
clearInterval(tick);
|
||||
const label = document.createElement('p');
|
||||
label.className = 'stand-in-label';
|
||||
label.textContent = 'inspiration · comp pending';
|
||||
m.appendChild(label);
|
||||
return false;
|
||||
}
|
||||
|
||||
// No comp and no inspiration is the text-only card the payload would
|
||||
// have rendered without a comp declaration. Bring the complete read
|
||||
// forward before removing the now-unreachable back face.
|
||||
const card = m.closest('.card');
|
||||
const front = card?.querySelector('.face.front');
|
||||
const body = front?.querySelector('.body');
|
||||
const back = card?.querySelector('.face.back');
|
||||
const textOnlyFacts = m.querySelector('template.text-only-facts');
|
||||
const choose = body?.querySelector(':scope > button.choose');
|
||||
if (body && textOnlyFacts && choose) {
|
||||
const plainDetail = body.querySelector(':scope > .detail:not(.more)');
|
||||
[...body.children].filter((el) => el.classList.contains('fact') || el.matches('.detail.more')).forEach((el) => el.remove());
|
||||
choose.before(textOnlyFacts.content.cloneNode(true));
|
||||
if (plainDetail) choose.before(plainDetail);
|
||||
}
|
||||
card?.classList.remove('flipped');
|
||||
front?.classList.add('text-only');
|
||||
back?.remove();
|
||||
settle();
|
||||
m.remove();
|
||||
return true;
|
||||
};
|
||||
const tryLoad = () => {
|
||||
// A slot the user flipped back out of leaves the DOM; let its loop die.
|
||||
@@ -1037,7 +1065,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
probe.onload = () => { landTracker.last = Date.now(); img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
const quiet = Date.now() - landTracker.last > 240000;
|
||||
if (Date.now() - started > 240000 && quiet) standIn();
|
||||
if (Date.now() - started > 240000 && quiet && fallback()) return;
|
||||
setTimeout(tryLoad, m.classList.contains('stand-in') ? 5000 : 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
|
||||
Reference in New Issue
Block a user