From e7c5117ad296fcada01892362d74d1b89c0ff35c Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 04:32:24 -0800 Subject: [PATCH 01/21] experimental theme: hunt69 (how amounts: one parser for every way an amount is said, fractional px, part line height, opacity and font size the asked way, exact whole-theme radius and border width, own side and per-side padding, corner percents as amounts, zero words, px moves and steps keep state) (cherry picked from commit 2ec2752c36cc4dc0e29c4388b7e5a14fabfaecb0) --- .changeset/experimental-theme-hunt69.md | 7 + packages/cli/api/theme-edit/_how-question.mjs | 487 +++++++++++++++--- .../cli/api/theme-edit/theme-hunt61.test.mjs | 5 +- .../cli/api/theme-edit/theme-hunt69.test.mjs | 212 ++++++++ 4 files changed, 634 insertions(+), 77 deletions(-) create mode 100644 .changeset/experimental-theme-hunt69.md create mode 100644 packages/cli/api/theme-edit/theme-hunt69.test.mjs diff --git a/.changeset/experimental-theme-hunt69.md b/.changeset/experimental-theme-hunt69.md new file mode 100644 index 0000000000000..1ef15c83123d4 --- /dev/null +++ b/.changeset/experimental-theme-hunt69.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor how` reads every way an amount is said ("a quarter bigger", "half again", "2x", "×2", "x0", "4 times", "by a factor of 2", "+50%", "-25%", "50 percent", "½") as a factor of what is there now, never as a length and never with an extra dial step; keeps fractions of a pixel; scales a part's line height and opacity the asked way ("50% more transparent" lowers the opacity and leaves the fill); answers whole-theme radius and border width amounts with exact token values (a whole-theme spacing or text amount it can only step says so); says when a part has no width to scale instead of giving a fixed cap; scales an own side padding with the rest; keeps the state and variant of a "2px rounder on hover" move and of a padding step; reads a corner percent with an amount ("50% of now", "20% bigger") as an exact radius, never a CSS percent; says when a corner amount can only step; and reads "no inner spacing", "zero padding" and "no gap" as zero of that thing, in the state asked. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_how-question.mjs b/packages/cli/api/theme-edit/_how-question.mjs index d353c72a493e7..b8888b2c66618 100644 --- a/packages/cli/api/theme-edit/_how-question.mjs +++ b/packages/cli/api/theme-edit/_how-question.mjs @@ -550,7 +550,32 @@ function scaled(v, f) { const m = /^(\d*\.?\d+)(px|rem|em)$/.exec(String(v ?? '').trim()); if (!m) return null; const n = Number(m[1]) * f; - return `${m[2] === 'px' ? Math.round(n) : Number(n.toFixed(3))}${m[2]}`; + // A fraction of a pixel is a real length (hi-dpi screens draw it): kept + // to 0.01px, never rounded away to the same value or to 0. + return `${m[2] === 'px' ? Number(n.toFixed(2)) : Number(n.toFixed(3))}${m[2]}`; +} + +/** + * The question without its amount words ("3 times", "2x", "x2", "×2", + * "+50%", "50 percent", "by a factor of 2"), so a number in them is never + * read as a length. + * @param {string} text + */ +function withoutAmount(text) { + return text + .replace(/\bby a factor of \d*\.?\d+\b/g, ' ') + .replace( + /\b(scale|multiply)\b([^.]*?)\bby \d*\.?\d+\b(?!\s*(px|rem|em|%))/g, + '$1$2', + ) + .replace(/\b\d*\.?\d+(?:px)?\s*(?:times|x|×)(?=\s|$|\b)/g, ' ') + .replace(/(^|\s)[x×]\s?\d*\.?\d+\b/g, ' ') + .replace(/(^|[\s(])[+-]\s?\d{1,3}(?:\.\d+)?(?:px)?\s?(%|percent\b)/g, ' ') + .replace(/(?:^|\s)0?\.\d+(?:px)?\s+(?=(the|of)\b)/g, ' ') + .replace( + /\b\d{1,3}(?:\.\d+)?(?:px)?\s?(%|percent\b)\s*(?=(bigger|larger|more|wider|taller|thicker|higher|roomier|smaller|less|tighter|narrower|thinner|shorter|lower|transparent|opaque|strength|of|as)\b)/g, + ' ', + ); } /** @@ -622,37 +647,92 @@ const compared = new WeakMap(); * @returns {number | null} */ export function exactFactor(text) { - const t = text.toLowerCase(); - if (/\b(half|halve|halved)\b/.test(t)) return 0.5; + const t = text + .toLowerCase() + .replace(/½/g, ' 1/2 ') + .replace(/¼/g, ' 1/4 ') + .replace(/⅓/g, ' 1/3 ') + .replace(/×/g, ' x ') + .replace(/(\d)(?:px)?\s*percent\b/g, '$1%') + .replace(/(\d)px(?=\s*(?:%|times|x\b))/g, '$1') + .replace(/\s+/g, ' '); + const SHRINK = /^(smaller|less|tighter|narrower|thinner|shorter|lower)$/; + const GROW = /^(bigger|larger|more|wider|taller|thicker|higher|roomier)$/; + /** A change by a share: "a quarter bigger", "+50%", "cut by a third". @param {number} share @param {boolean} down */ + const by = (share, down) => { + const f = down ? 1 - share : 1 + share; + return f >= 0 ? f : null; + }; + const fraction = (/** @type {string} */ w) => + ({ + half: 0.5, + 'a half': 0.5, + 'a third': 1 / 3, + 'a quarter': 0.25, + '1/2': 0.5, + '1/3': 1 / 3, + '1/4': 0.25, + })[w] ?? null; + // "half again as big": one and a half. + if (/\bhalf again\b/.test(t)) return 1.5; + // "a quarter bigger", "a third smaller", "cut by a third", "reduce by half". + const share = + /\b(a half|half|a third|a quarter|1\/2|1\/3|1\/4)\s+(bigger|larger|more|wider|taller|thicker|higher|smaller|less|tighter|narrower|thinner|shorter|lower)\b/.exec( + t, + ); + if (share) { + const f = fraction(share[1]); + if (f != null) return by(f, SHRINK.test(share[2])); + } + const cut = + /\b(cut|reduce|lower|shrink|decrease|increase|raise|grow)\b[^.]*?\bby (a half|half|a third|a quarter|1\/2|1\/3|1\/4)\b/.exec( + t, + ); + if (cut) { + const f = fraction(cut[2]); + if (f != null) return by(f, !/^(increase|raise|grow)$/.test(cut[1])); + } + // "+50%", "-25%", "50% bigger", "100% smaller". + const signed = /(^|[\s(])([+-])\s?(\d{1,3}(?:\.\d+)?)\s?%/.exec(t); + if (signed) return by(Number(signed[3]) / 100, signed[2] === '-'); + const pctBy = + /\b(\d{1,3}(?:\.\d+)?)\s?%\s*(bigger|larger|more|wider|taller|thicker|higher|roomier|smaller|less|tighter|narrower|thinner|shorter|lower)\b/.exec( + t, + ); + if (pctBy && Number(pctBy[1]) > 0) + return by(Number(pctBy[1]) / 100, SHRINK.test(pctBy[2])); + const pct = /\b(\d{1,3}(?:\.\d+)?)\s?%\s*(?:strength|of|as)\b/.exec(t); + if (pct && Number(pct[1]) > 0) return Number(pct[1]) / 100; + if (/\b(half|halve|halved|1\/2)\b/.test(t)) return 0.5; + // "½ the padding" (read as 0.5 the padding). + const lead = /(?:^|\s)(0?\.\d+)(?:px)?\s+(?:the|of)\b/.exec(t); + if (lead) return Number(lead[1]); if (/\b(double|doubled|twice)\b/.test(t)) return 2; if (/\b(triple|tripled|three times)\b/.test(t)) return 3; - if (/\ba third\b/.test(t)) return 1 / 3; - if (/\ba quarter\b/.test(t)) return 0.25; - const pct = /\b(\d{1,3})\s?%\s*(?:strength|of|as)\b/.exec(t); - if (pct && Number(pct[1]) > 0) return Number(pct[1]) / 100; - // "50% bigger", "20% less": a change by that share. - const by = - /\b(\d{1,3})\s?%\s*(bigger|larger|more|wider|taller|smaller|less|tighter|narrower)\b/.exec( + if (/\b(quadruple|quadrupled|four times)\b/.test(t)) return 4; + if (/\b(a third|1\/3)\b/.test(t)) return 1 / 3; + if (/\b(a quarter|1\/4)\b/.test(t)) return 0.25; + // "by a factor of 2", "scale … by 1.5", "multiply … by 2". + const factor = + /\bby a factor of (\d*\.?\d+)\b/.exec(t) ?? + /\b(?:scale|multiply)\b[^.]*?\bby (\d*\.?\d+)\b(?!\s*(?:px|rem|em|%))/.exec( t, ); - if (by && Number(by[1]) > 0) { - const f = /^(smaller|less|tighter|narrower)$/.test(by[2]) - ? 1 - Number(by[1]) / 100 - : 1 + Number(by[1]) / 100; - return f > 0 ? f : null; - } - // "3 times bigger", "1.5 times as big", "2 times smaller". + if (factor && Number(factor[1]) >= 0 && Number(factor[1]) <= 10) + return Number(factor[1]); + // "3 times bigger", "2 times smaller", "4 times", "2x", "1.5x", "x2". const nTimes = - /\b(\d*\.?\d+)(?:px)?\s*(?:times|x)\s+(bigger|larger|more|wider|taller|thicker|as|smaller|less|thinner|narrower)\b/.exec( + /\b(\d*\.?\d+)(?:px)?\s*(?:times|x)\b(?:\s+(bigger|larger|more|wider|taller|thicker|as|smaller|less|thinner|narrower))?/.exec( t, ); - if (nTimes && Number(nTimes[1]) > 0 && Number(nTimes[1]) <= 10) - return /^(smaller|less|thinner|narrower)$/.test(nTimes[2]) - ? 1 / Number(nTimes[1]) - : Number(nTimes[1]); - const times = /\bx\s?(\d*\.?\d+)\b/.exec(t); - if (times && Number(times[1]) > 0 && Number(times[1]) <= 4) + if (nTimes && Number(nTimes[1]) >= 0 && Number(nTimes[1]) <= 20) { + const n = Number(nTimes[1]); + return nTimes[2] && SHRINK.test(nTimes[2]) ? (n > 0 ? 1 / n : null) : n; + } + const times = /(?:^|\s)x\s?(\d*\.?\d+)\b/.exec(t); + if (times && Number(times[1]) >= 0 && Number(times[1]) <= 20) return Number(times[1]); + void GROW; return null; } @@ -1136,11 +1216,38 @@ function clausesOf(q, partWords) { if (parts[i].on === 'badge' && !/\b(badges?|chips?|tags?)\b/.test(text)) parts.splice(i, 1); // "Space Grotesk headings": a font's name is a font, never its words. - const aspects = aspectsOf( + const aspects0 = aspectsOf( text .replace(new RegExp(`\\b(${FONT_NAMES})\\b`, 'g'), ' font ') .replace(/\bspace (mono|grotesk|grotesque)\b/g, ' font '), ); + // "padding 3 times bigger", "half the border width": the comparative + // goes with the amount of the thing named, never a size change too. + // "more transparent", "less opaque": the part's opacity, never its + // background made transparent too. + const seeThrough = + /\b(more|less) (transparent|translucent|see-through|opaque)\b/.test( + text, + ) && parts.length > 0; + const aspects = seeThrough + ? ['opacity'] + : exactFactor(text) != null && aspects0.length > 1 + ? (() => { + // An amount goes with the measured thing it names (padding, + // corners, a font size…), never a size or motion change too. + const SCALAR = [ + 'spacing', + 'corners', + 'fontsize', + 'leading', + 'tracking', + 'opacity', + 'border', + ]; + const named = aspects0.filter(a => SCALAR.includes(a)); + return named.length ? named : aspects0.filter(a => a !== 'size'); + })() + : aspects0; // "orange not red" negates a color, not the change. const hue = Object.keys(HUES).join('|'); const negText = text.replace( @@ -1234,9 +1341,7 @@ function clausesOf(q, partWords) { lengths: [ ...(text.match(/\b(?:clamp|calc|min|max)\([^)]*\)/g) ?? []), // "3 times bigger": the 3 is an amount, never a length. - ...lengthsIn( - text.replace(/\b\d*\.?\d+(?:px)?\s*(?:times|x)\s+(?=\w)/g, ' '), - ), + ...lengthsIn(withoutAmount(text)), ], state, mode, @@ -3392,13 +3497,29 @@ export function draftAnswer(ctx, env, question) { if (cornerAsk && c.parts.length) { const on = c.parts[0].on; const read = C.partReads(one(on), 'corners')[0]; + // The clause's state and variant ("on hover", "primary") are kept, + // and the move starts from that state's own radius. + const {noState: _ns, ...pf} = flagsFor(c, c.parts[0]); + const scopedRadius = + pf.state || pf.when + ? ([...env.data.settings] + .reverse() + .find( + st => + st.thing === 'border-radius' && + st.on === on && + (st.state ?? null) === (pf.state ?? null) && + (st.when ?? null) === (pf.when ?? null) && + !st.mode, + )?.value ?? null) + : null; // A radius the theme set on the part in px moves from that value. - const ownRadius = partValue(on, 'border-radius'); + const ownRadius = scopedRadius ?? partValue(on, 'border-radius'); if (ownRadius && /^\d*\.?\d+px$/.test(ownRadius)) { const next = Math.max(0, parseFloat(ownRadius) + (down ? -n : n)); const d = { text: `${on}'s corners ${down ? 'down' : 'up'} by ${n}px: from ${ownRadius} to ${next}px`, - options: [[set('border-radius', `${next}px`, {on})]], + options: [[set('border-radius', `${next}px`, pf)]], }; push(d); recordTop(c, d.options[0] ?? []); @@ -3412,7 +3533,7 @@ export function draftAnswer(ctx, env, question) { set( 'border-radius', `max(0px, calc(var(${read.token}) ${down ? '-' : '+'} ${n}px))`, - {on}, + pf, ), ], ], @@ -3604,9 +3725,12 @@ export function draftAnswer(ctx, env, question) { // "padding 3 times bigger", "25% darker": an amount of what is there, // never the number as a value. !( - exactFactor(c.text) && - (/^\d*\.?\d+%?$/.test(String(named.value).trim()) || - /\b\d*\.?\d+(px)?\s*(times|x)\s/.test(c.text)) + exactFactor(c.text) != null && + withoutAmount(c.text) !== c.text.replace(/\s+/g, ' ') + ) && + !( + exactFactor(c.text) != null && + /^\d*\.?\d+%?$/.test(String(named.value).trim()) ) && !( /^\d*\.?\d+%$/.test(String(named.value).trim()) && @@ -4286,26 +4410,51 @@ export function draftAnswer(ctx, env, question) { // bigger") of a part's scalar value: that factor of what the part has // now (its own setting, else the token it reads), never a step. { - const amount = on && !compared.get(c) ? exactFactor(c.text) : null; + const typed = on && !compared.get(c) ? exactFactor(c.text) : null; + // "a bit more transparent": a step of the opacity there now. + const seeStep = + typed == null && a === 'opacity' && on + ? /\bmore (transparent|translucent|see-through)\b|\bless opaque\b/.test( + c.text, + ) + ? 0.8 + : /\bless (transparent|translucent)\b|\bmore opaque\b/.test(c.text) + ? 1.25 + : null + : null; + const amount = typed ?? seeStep; const borderWidth = a === 'border' && /\b(border[- ]width|thick(er|ness)?|thin(ner)?)\b/.test(c.text); - const prop = !amount - ? null - : borderWidth - ? 'border-width' - : a === 'fontsize' - ? 'font-size' - : a === 'leading' - ? 'line-height' - : a === 'tracking' - ? 'letter-spacing' - : a === 'opacity' - ? 'opacity' - : a === 'size' && /\b(width|wider|narrower)\b/.test(c.text) - ? 'max-width' - : null; - if (prop && on && amount) { + const prop = + amount == null + ? null + : borderWidth + ? 'border-width' + : a === 'fontsize' + ? 'font-size' + : a === 'leading' + ? 'line-height' + : a === 'tracking' + ? 'letter-spacing' + : a === 'opacity' + ? 'opacity' + : a === 'size' && /\b(width|wider|narrower)\b/.test(c.text) + ? 'max-width' + : null; + // "50% more transparent": the opacity loses that share; "less opaque" + // already reads as a factor below 1. + const opacityAmount = + prop === 'opacity' && + typed != null && + /\bmore (transparent|translucent|see-through)\b|\bless opaque\b/.test( + c.text, + ) && + typed > 1 + ? 2 - typed + : amount; + if (prop && on && opacityAmount != null) { + const amount = opacityAmount; const part = /** @type {string} */ (on); const group = C.groupOfProp(prop); const read = group @@ -4322,7 +4471,14 @@ export function draftAnswer(ctx, env, question) { now == null ? null : /^\d*\.?\d+$/.test(String(now).trim()) - ? String(Number((n * amount).toFixed(3))) + ? String( + Number( + (prop === 'opacity' + ? Math.min(1, n * amount) + : n * amount + ).toFixed(3), + ), + ) : scaled(String(now), amount); if (value) return { @@ -4341,20 +4497,42 @@ export function draftAnswer(ctx, env, question) { }; } case 'leading': { + // An amount ("1.5x", "half") or a percent ("150%": of the line + // height there now) is a factor, never a typed number. + const pctLeading = /\b(\d{1,3}(?:\.\d+)?)(?:px)?\s?%/.exec(c.text); + const leadFactor = + exactFactor(c.text) ?? + (pctLeading ? Number(pctLeading[1]) / 100 : null); const n = - /\b(normal)\b/.exec(c.text)?.[1] ?? - /\b(\d(?:\.\d+)?)\b(?!\s*(px|rem|em|%))/.exec(c.text)?.[1]; + leadFactor != null + ? null + : (/\b(normal)\b/.exec(c.text)?.[1] ?? + /(?:^|\s)(\d(?:\.\d+)?)(?![\d.]|\s*(px|rem|em|%|x\b|times\b))/.exec( + c.text, + )?.[1]); const tok = !on || on === 'body' || p?.role === 'body' ? '--text-body-leading' : null; - const now = tok ? current(tok) : null; + // What it is now: the token, else the part's own line height. + // A part with no line height of its own reads the body's. + const now = tok + ? current(tok) + : on + ? (nowIn(on, 'line-height') ?? + ('--text-body-leading' in TOKENS + ? current('--text-body-leading') + : null)) + : null; + const numeric = now != null && /^\d*\.?\d+$/.test(String(now).trim()); const value = n ?? - (now && /^\d*\.?\d+$/.test(now) + (numeric ? String( Number( - (Number(now) * (c.dir === 'less' ? 0.9 : 1.1)).toFixed(2), + ( + Number(now) * (leadFactor ?? (c.dir === 'less' ? 0.9 : 1.1)) + ).toFixed(2), ), ) : c.dir === 'less' @@ -4366,7 +4544,7 @@ export function draftAnswer(ctx, env, question) { options: [[set(tok, value)]], }; return { - text: `line height${where}`, + text: `line height${where}${numeric && !n ? ` (now ${now})` : ''}`, options: [[set('line-height', value, f)]], }; } @@ -4374,11 +4552,16 @@ export function draftAnswer(ctx, env, question) { const tall = tallIn(c.text); const tied = radiusIn(c.text); // "1.5 times bigger": an amount, never a radius. - const typedPx = /\b\d*\.?\d+(px)?\s*(times|x)\s/.test(c.text) - ? null - : ((tied && /^-?\d*\.?\d+$/.test(tied) ? `${tied}px` : tied) ?? - c.lengths.find(l => l !== tall) ?? - null); + // A percent with an amount ("50% of now", "20% bigger") is an + // amount too: a CSS percent radius is an ellipse. + const typedPx = + /\b\d*\.?\d+(px)?\s*(times|x)\s/.test(c.text) || + (exactFactor(c.text) != null && + !c.lengths.some(l => l !== tall && !/%$/.test(l))) + ? null + : ((tied && /^-?\d*\.?\d+$/.test(tied) ? `${tied}px` : tied) ?? + c.lengths.find(l => l !== tall) ?? + null); // A radius of 0 is square. const px = typedPx && parseFloat(typedPx) === 0 ? null : typedPx; const zero = Boolean(typedPx) && !px; @@ -4396,18 +4579,41 @@ export function draftAnswer(ctx, env, question) { ) ? exactFactor(c.text) : null; - if (cornerFactor) { + if (cornerFactor != null) { const part = /** @type {string} */ (on); const read = C.partReads(one(part), 'corners')[0]; + // Else the part's plain rule as built (the base theme's radius + // for it), its tokens read in this theme. + const rule = partRuleTokens( + ctx.project.cwd, + env.slug, + env.data.like, + one(part), + ).get('border-radius'); + const ruleNow = rule + ? String(rule).replace( + /var\(\s*(--[a-z0-9-]+)\s*(?:,[^)]*)?\)/gi, + (_, t) => current(t) || _, + ) + : null; const now = nowIn(part, 'border-radius') ?? - (read?.token ? current(read.token) : null); + (read?.token ? current(read.token) : null) ?? + (ruleNow && scaled(ruleNow, 1) ? ruleNow : null); const value = now ? scaled(now, cornerFactor) : null; if (value) return { text: `corners${where}: border-radius ${value} (now ${now})`, options: [[set('border-radius', value, f)]], }; + // Nothing this can read: a step its way, said to be a step. + if (cornerFactor !== 1) + return { + text: `corners${where}: corners ${cornerFactor < 1 ? 'less' : 'more'} (one step, not an exact amount: ${part} has no radius this can read; give one, such as border-radius 6px)`, + options: [ + [set('corners', cornerFactor < 1 ? 'less' : 'more', f)], + ], + }; } // "revert the rounded corners": take back what the theme sets. if (/\b(revert|undo|take back)\b/.test(c.text)) { @@ -4452,6 +4658,31 @@ export function draftAnswer(ctx, env, question) { ? 'more' : 'round'); if (!value) return null; + // "double the corner radius everywhere", "double --radius-element": + // each radius token (or the one named) from its value now, exactly. + const wholeFactor = !on && !px ? exactFactor(c.text) : null; + if (wholeFactor != null) { + const named = /--radius-[a-z]+/.exec(c.text)?.[0]; + const toks = named + ? [named] + : [ + '--radius-inner', + '--radius-element', + '--radius-container', + '--radius-page', + ]; + const steps = toks.flatMap(tk => { + const nowTk = current(tk); + const next = nowTk ? scaled(nowTk, wholeFactor) : null; + return next ? [set(tk, next)] : []; + }); + if (steps.length) + return { + text: `corners for the whole theme: ${toks.map(tk => `${tk} ${current(tk)}`).join(', ')}, each ×${Number(wholeFactor.toFixed(3))}`, + options: [steps], + sequence: steps.length > 1, + }; + } if (!on) { // The corners dial takes pixels: a rem or em size is converted at // 16px to the rem. @@ -4606,6 +4837,16 @@ export function draftAnswer(ctx, env, question) { } : null; } + // An amount ("double", "half") of the width there now. + const bwFactor = c.lengths[0] ? null : exactFactor(c.text); + const bwNow = current('--border-width'); + const bwExact = + bwFactor != null && bwNow ? scaled(bwNow, bwFactor) : null; + if (bwExact) + return { + text: `the border width of the whole theme: --border-width ${bwExact} (now ${bwNow})`, + options: [[set('--border-width', bwExact)]], + }; if (c.lengths[0] || c.dir) return { text: 'the border width of the whole theme: --border-width', @@ -4718,7 +4959,9 @@ export function draftAnswer(ctx, env, question) { const value = /\b(dense|compact)\b/.test(c.text) && !c.dir ? 'compact' - : c.neg && /\b(padding|space|gap|margin)\b/.test(c.text) + : (c.neg || /\b(zero|no|none|without)\b/.test(c.text)) && + /\b(padding|spac(e|es|ing)|gaps?|margins?)\b/.test(c.text) && + !/\bno (more|less)\b/.test(c.text) ? null : /\b(cramped|crowded|airy|spacious|roomy)\b/.test(c.text) || c.dir === 'more' @@ -4729,7 +4972,7 @@ export function draftAnswer(ctx, env, question) { if (!on) return value ? { - text: 'room in the whole theme: the spacing dial (padding, gaps and control heights together)', + text: `room in the whole theme: the spacing dial (padding, gaps and control heights together)${exactFactor(c.text) != null ? '; one step, not the exact amount asked: give a value for a token (--spacing-4 24px) for that' : ''}`, options: [[set('spacing', value)]], } : null; @@ -4756,6 +4999,51 @@ export function draftAnswer(ctx, env, question) { const padNow = part => { const own = nowIn(part, gap ? 'gap' : 'padding'); if (own) return own; + // Own side settings (padding-inline 20px) on top of what the part + // has for the rest: one box, each side as it is now. + if (!gap) { + const block = nowIn(part, 'padding-block'); + const inline = nowIn(part, 'padding-inline'); + const sideOf = (/** @type {string} */ k) => + nowIn(part, `padding-${k}`); + const sides = ['top', 'right', 'bottom', 'left'].map(sideOf); + if (block || inline || sides.some(Boolean)) { + const rest = padNow0(part); + const restSides = rest + ? (() => { + const v = String(rest).trim().split(/\s+/); + return [ + v[0], + v[1] ?? v[0], + v[2] ?? v[0], + v[3] ?? v[1] ?? v[0], + ]; + })() + : null; + if (restSides) { + const [bt, bi] = [block, inline].map(x => + x ? String(x).trim().split(/\s+/) : null, + ); + const box = [ + sides[0] ?? bt?.[0] ?? restSides[0], + sides[1] ?? bi?.[1] ?? bi?.[0] ?? restSides[1], + sides[2] ?? bt?.[1] ?? bt?.[0] ?? restSides[2], + sides[3] ?? bi?.[0] ?? restSides[3], + ]; + const out = + box[0] === box[2] && box[1] === box[3] + ? box[0] === box[1] + ? box[0] + : `${box[0]} ${box[1]}` + : box.join(' '); + return out; + } + } + } + return padNow0(part); + }; + /** padNow without the part's own setting. @param {string} part */ + const padNow0 = part => { // The part's plain rule in the theme as built (the base theme's // card padding), its tokens read in this theme. const rule = partRuleTokens( @@ -4774,6 +5062,27 @@ export function draftAnswer(ctx, env, question) { const reads = C.partReads(one(part), 'spacing').filter(r => r.props.some(p => (gap ? /gap/ : /^padding/).test(p)), ); + // Each side from the token Core reads for it (a button's block and + // inline padding differ; a badge has no block padding): one box. + if (!gap) { + /** @param {RegExp} re */ + const sideTok = re => + reads.find(r => r.props.some(x => re.test(x)))?.token ?? null; + const toks = [ + sideTok(/^padding-(block(-start)?|top)$/), + sideTok(/^padding-(inline(-end)?|right)$/), + sideTok(/^padding-(block(-end)?|bottom)$/), + sideTok(/^padding-(inline(-start)?|left)$/), + ]; + if (toks.some(Boolean)) { + const box = toks.map(tk => (tk ? current(tk) || '0px' : '0px')); + return box[0] === box[2] && box[1] === box[3] + ? box[0] === box[1] + ? box[0] + : `${box[0]} ${box[1]}` + : box.join(' '); + } + } const read = reads.find(r => /^--spacing-/.test(r.token)) ?? reads[0] ?? null; return read?.token ? current(read.token) : null; @@ -4782,7 +5091,7 @@ export function draftAnswer(ctx, env, question) { // the other part's padding, when the theme sets one; else a step, // and the answer says so. const versus = compared.get(c) ?? null; - if (padFactor && versus) { + if (padFactor != null && versus) { const now = padNow(versus); const exact = now ? scaledBox(now, padFactor) : null; return exact @@ -4795,7 +5104,7 @@ export function draftAnswer(ctx, env, question) { options: [[set('spacing', value ?? 'more', f)]], }; } - if (padFactor) { + if (padFactor != null) { const now = padNow(on); const value = now ? scaledBox(now, padFactor) : null; if (value) @@ -4825,12 +5134,32 @@ export function draftAnswer(ctx, env, question) { }; if (!value) return { - text: `no padding${where}`, - options: [[set('padding', '0px', f)]], + text: `no ${gap ? 'gap' : 'padding'}${where}`, + options: [[set(gap ? 'gap' : 'padding', '0px', f)]], }; + // A step in one state or variant ("on hover") stays there. + const stepFlags = { + on, + ...(f.state ? {state: f.state} : {}), + ...(f.when ? {when: f.when} : {}), + }; + const stepOk = env.check([set('spacing', value, stepFlags)]).ok; + // The dial takes no state: a step of the padding there now, in that + // state, said to be a step. + if (!stepOk && (f.state || f.when)) { + const nowPad = padNow(on); + const stepped = nowPad + ? scaledBox(nowPad, value === 'less' ? 0.8 : 1.25) + : null; + if (stepped) + return { + text: `${gap ? 'the gap' : 'padding'}${where}: ${stepped} (now ${nowPad}; a step: give an amount, such as double or 24px, for an exact one)`, + options: [[set(gap ? 'gap' : 'padding', stepped, f)]], + }; + } return { text: `room inside ${on}: spacing ${value} (padding, gaps and heights inside it; one step${padFactor ? ', not an exact amount: give a value, such as 24px, for one' : ''})`, - options: [[set('spacing', value, {on})]], + options: [[set('spacing', value, stepOk ? stepFlags : {on})]], }; } case 'tracking': { @@ -4947,7 +5276,8 @@ export function draftAnswer(ctx, env, question) { }; const px = c.lengths[0]; const f2 = /\bmuch\b/.test(c.text) ? 1.5 : 1.25; - const factor = c.dir === 'less' ? 1 / f2 : f2; + // An exact amount ("double", "half") of the size there now. + const factor = exactFactor(c.text) ?? (c.dir === 'less' ? 1 / f2 : f2); // An exact --font-size-base wins over the text-size dial, so the // dial would change nothing there: step that token instead. const pinned = on @@ -4993,7 +5323,7 @@ export function draftAnswer(ctx, env, question) { } if (!on) return { - text: 'the base text size: text-size (every heading scales from it)', + text: `the base text size: text-size (every heading scales from it)${!px && exactFactor(c.text) != null ? '; one step, not the exact amount asked: give a size (text-size 18) for that' : ''}`, options: [ [ set( @@ -5240,9 +5570,16 @@ export function draftAnswer(ctx, env, question) { ], }; if ( - /\b(wider|narrower|width)\b/.test(c.text) && + /\b(wider|narrower|width|wide)\b/.test(c.text) && !/\bborder[- ]width\b/.test(c.text) ) { + // An amount ("20% wider", "twice as wide") with no width there to + // take it of: said so, never a made-up cap. + if (!c.lengths[0] && exactFactor(c.text) != null) + return { + text: `${on} has no width of its own to scale (it takes the width its content and container give it); give a width, such as max-width 480px`, + options: [], + }; const w = c.lengths[0] ?? (/\bnarrower\b/.test(c.text) || c.dir === 'less' diff --git a/packages/cli/api/theme-edit/theme-hunt61.test.mjs b/packages/cli/api/theme-edit/theme-hunt61.test.mjs index 601b64331de19..5f2fd2b331830 100644 --- a/packages/cli/api/theme-edit/theme-hunt61.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt61.test.mjs @@ -180,8 +180,9 @@ describe('hunt61c: clause breaks and comparisons', () => { // No padding of the theme's own on buttons: twice the padding a button // has now (the token it reads, hunt63), on cards. const step = await top('cards with twice the padding of buttons'); - expect(step.command).toMatch(/^set q padding \S+ --on card$/); - expect(step.text).toMatch(/\(button has \S+\)/); + // Each side of the button's padding (F24-12). + expect(step.command).toMatch(/^set q padding ('[^']+'|\S+) --on card$/); + expect(step.text).toMatch(/\(button has [^)]+\)/); // With one: twice that, on cards only. await themeEditSet('q', 'padding', '10px', {cwd, on: 'button'}); const exact = await top('cards with twice the padding of buttons'); diff --git a/packages/cli/api/theme-edit/theme-hunt69.test.mjs b/packages/cli/api/theme-edit/theme-hunt69.test.mjs new file mode 100644 index 0000000000000..bb1a6c4e3ff05 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt69.test.mjs @@ -0,0 +1,212 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The sixty-ninth pass over `astryx experimental theme editor how` + * (FINAL-24 amounts): one amount parser for every way an amount is said, + * never read as a length or with a second dial step; fractional pixels + * kept; a part's line height and opacity scaled the right way; whole-theme + * radius and border width amounts exact; a width amount with no width says + * so; own side paddings kept; px moves keep their state and variant. + */ + +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import {themeEditHow, themeEditNew, themeEditSet} from './theme-edit.mjs'; +import {exactFactor} from './_how-question.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import {FIXTURE_PACKAGES} from '../../test-utils/base-theme-fixture.mjs'; + +vi.mock('./_preview.mjs', async importOriginal => + (await import('../../test-utils/theme-render-stub.mjs')).stubRenders( + await importOriginal(), + ), +); + +/** @type {string} */ +let cwd; + +beforeAll(async () => { + cwd = makeLinkedFixture('astryx-theme-hunt69-', FIXTURE_PACKAGES); + await themeEditNew('t', {cwd}); + await themeEditNew('s', {cwd}); + for (const [ + thing, + value, + flags, + ] of /** @type {Array<[string, string, any]>} */ ([ + ['padding', '12px', {on: 'card'}], + ['line-height', '1.2', {on: 'card'}], + ['opacity', '0.8', {on: 'card'}], + ['border-width', '1px', {on: 'button'}], + ['font-size', '15px', {on: 'badge'}], + ['letter-spacing', '1px', {on: 'badge'}], + ['border-radius', '8px', {on: 'button', state: 'hover'}], + ['--border-width', '3px', {}], + ])) + await themeEditSet('t', thing, value, {cwd, ...flags}); + await themeEditSet('s', 'padding-inline', '20px', {cwd, on: 'card'}); +}, 240_000); + +afterAll(() => { + if (cwd) removeLinkedFixture(cwd); +}); + +/** @param {string} q @param {string} [theme] */ +const how = async (q, theme = 't') => + JSON.stringify(/** @type {any} */ (await themeEditHow(q, {cwd, theme})).data); + +describe('hunt69: how amounts', () => { + it.each([ + ['a quarter bigger', 1.25], + ['half again as big', 1.5], + ['2x', 2], + ['0.5x', 0.5], + ['quadruple', 4], + ['x0', 0], + ['4 times', 4], + ['11 times bigger', 11], + ['100% smaller', 0], + ['scale by 1.5', 1.5], + ['by a factor of 2', 2], + ['×2', 2], + ['½ the padding', 0.5], + ['50 percent bigger', 1.5], + ['+50%', 1.5], + ['-25%', 0.75], + ['cut it by a third', 2 / 3], + ])('F24e-1: exactFactor(%s) = %s', (words, f) => { + expect(exactFactor(words)).toBeCloseTo(f, 5); + }); + + it('F24e-1/2: an amount is never a length, and no second dial step', async () => { + for (const [q, want] of [ + ['make card padding 4 times', /set t padding 48px --on card/], + ['card padding 2× bigger', /set t padding 24px --on card/], + [ + 'make the card padding 50 percent bigger', + /set t padding 18px --on card/, + ], + ['make card padding 3 times bigger', /set t padding 36px --on card/], + ]) { + const out = await how(String(q)); + expect(out, String(q)).toMatch(/** @type {RegExp} */ (want)); + expect(out, String(q)).not.toMatch(/spacing more|min-height|height 2px/); + } + }, 240_000); + + it('F24e-10: fractional pixels are kept', async () => { + expect(await how('half the border width on buttons')).toMatch( + /border-width 0\.5px --on button/, + ); + expect(await how('half the font size on badges')).toMatch( + /font-size 7\.5px --on badge/, + ); + expect(await how('a third of the letter spacing on badges')).toMatch( + /letter-spacing 0\.33px --on badge/, + ); + }, 240_000); + + it('F24e-15: a part line height goes the asked way, by the asked factor', async () => { + expect(await how('less line height on cards')).toMatch( + /line-height 1\.08 --on card/, + ); + expect(await how('make line height on cards 150%')).toMatch( + /line-height 1\.8 --on card/, + ); + expect(await how('make line height on cards 1.5x')).toMatch( + /line-height 1\.8 --on card/, + ); + }, 240_000); + + it('F24e-16: whole-theme radius amounts are exact token values; steps say so', async () => { + const out = await how('double --radius-element'); + expect(out).toMatch(/set t --radius-element \d/); + expect(out).not.toMatch(/corners more/); + expect(await how('make everything 20% roomier')).toMatch( + /one step, not the exact amount asked/, + ); + }, 240_000); + + it('F24e-17: more transparent lowers the opacity and leaves the fill', async () => { + const out = await how('make cards 50% more transparent'); + expect(out).toMatch(/set t opacity 0\.4 --on card/); + expect(out).not.toMatch(/background transparent/); + }, 240_000); + + it('F24e-19: a width amount with no width says so, never a 720px cap', async () => { + const out = await how('make menus 20% wider'); + expect(out).toMatch(/no width of its own to scale/); + expect(out).not.toMatch(/720px/); + }, 240_000); + + it('F24e-20: an own side padding is scaled with the rest', async () => { + expect(await how('double the card padding', 's')).toMatch( + /set s padding '\S+ 40px' --on card/, + ); + }, 240_000); + + it('F24e-3: a px move keeps the state and starts from its value', async () => { + expect(await how('make button corners 2px rounder on hover')).toMatch( + /set t border-radius 10px --on button --state hover/, + ); + expect(await how('make primary buttons 2px rounder')).toMatch( + /--on button --when variant:primary/, + ); + }, 240_000); + + it('F24e-4: whole-theme border width amounts scale the token', async () => { + expect(await how('double the border width everywhere')).toMatch( + /set t --border-width 6px/, + ); + expect(await how('half the border width everywhere')).toMatch( + /set t --border-width 1\.5px/, + ); + }, 240_000); + + it('R26-E: a corner percent with an amount is an exact length, never a % radius', async () => { + for (const q of [ + 'card corners 50% of now', + 'card corners 50% smaller', + 'card corners 20% bigger', + ]) { + const out = await how(q, 's'); + expect(out, q).toMatch(/set s border-radius \d/); + expect(out, q).not.toMatch(/border-radius '?\d+%|spacing (more|less)/); + } + }, 240_000); + + it('R26-K: a corner amount with no radius to read says it is a step', async () => { + expect(await how('halve the corner radius on the popover', 's')).toMatch( + /one step, not an exact amount/, + ); + }, 240_000); + + it('R26-B: zero words remove the asked thing, on the asked state', async () => { + expect(await how('no inner spacing on cards', 's')).toMatch( + /set s padding 0px --on card/, + ); + expect(await how('zero padding on cards', 's')).toMatch( + /set s padding 0px --on card/, + ); + expect(await how('no gap on cards', 's')).toMatch( + /set s gap 0px --on card/, + ); + expect( + await how('no inner spacing on text-input when disabled', 's'), + ).toMatch(/padding 0px --on inputs --state disabled/); + expect(await how('more padding on text inputs on hover', 's')).toMatch( + /--on inputs --state hover/, + ); + }, 240_000); + + it('R25-A: font size and line height amounts on parts with no size of their own', async () => { + expect(await how('double the font size on avatars', 's')).toMatch( + /set s font-size \S+ --on avatar/, + ); + expect(await how('double the line height on cards', 's')).toMatch( + /line-height [2-9]/, + ); + }, 240_000); +}); From 63c4127c2db683c156a82e54f915d3c5fa6c37cf Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 04:43:46 -0800 Subject: [PATCH 02/21] experimental theme: hunt68 (how: fills from the part's own at rest, except keeps every excepted value, sub-parts and catalog parts by name, comparisons copy onto the subject, own variants per clause, bare sizes, heading levels, font stacks, padding sides, state-word parts, own asks after and, --theme typo keeps the question) (cherry picked from commit ea013ba4bc0a68e407828f4e57c6d71b1180af81) --- .changeset/experimental-theme-hunt68.md | 7 + packages/cli/api/theme-edit/_how-question.mjs | 645 ++++++++++++++++-- packages/cli/api/theme-edit/_how.mjs | 17 +- .../cli/api/theme-edit/theme-how.test.mjs | 9 +- .../cli/api/theme-edit/theme-hunt68.test.mjs | 293 ++++++++ .../cli/api/theme-edit/theme-hunt69.test.mjs | 5 +- 6 files changed, 929 insertions(+), 47 deletions(-) create mode 100644 .changeset/experimental-theme-hunt68.md create mode 100644 packages/cli/api/theme-edit/theme-hunt68.test.mjs diff --git a/.changeset/experimental-theme-hunt68.md b/.changeset/experimental-theme-hunt68.md new file mode 100644 index 0000000000000..3743ed52c243f --- /dev/null +++ b/.changeset/experimental-theme-hunt68.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor how`: "lighter" or "darker" on a part's fill starts from the fill it shows at rest (its own setting first; never a pressed overlay), uses the amount asked and keeps a see-through color see-through. "except" keeps every excepted part or variant at the value it has now (its own shadow, its own weight) in one answer, says when a part reads none of what changes, and says when an excepted place is not a part. Sub-parts and catalog parts named in words go to that part ("dialog header", "dialog title", "code block header", "text inputs", "date pickers", "toggle buttons", "select boxes", "switches"); a sub-part Core lacks ("card header") is answered for the whole part, and the answer says so. "As round as", "the same padding as" and "match" copy the other part's value onto the first part only (an amount of it with "twice as round as"). Each clause keeps its own variant; a bare size in a list of text changes is the font size; "headings 20% bigger" scales every heading level from its own size; a font stack stays whole on the part; side words write only those padding sides; a part whose name has a state word ("hover card") is the part; "card text lighter" and "dialog text twice as big" get commands; a second part with an ask of its own keeps it; and a theme name near the one typed with `--theme` suggests the same question. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_how-question.mjs b/packages/cli/api/theme-edit/_how-question.mjs index b8888b2c66618..77e7bd23525f9 100644 --- a/packages/cli/api/theme-edit/_how-question.mjs +++ b/packages/cli/api/theme-edit/_how-question.mjs @@ -102,6 +102,9 @@ const TRANSLATE = /** @type {Array<[RegExp, string]>} */ ( */ const DESIGN_WORDS = [ 'leading', + // A part's name, never a near miss of "radius". + 'radio', + 'radios', 'header', 'headers', 'input', @@ -515,6 +518,52 @@ function shift(color, amount) { return hex(rgb.map(c => c + (to - c) * a)); } +/** + * {@link shift} that keeps a see-through color see-through: its alpha + * stays, only its hue moves. + * @param {string} color @param {number} amount + */ +export function shiftKeepAlpha(color, amount) { + const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(String(color).trim()); + if (!m) return shift(color, amount); + // A see-through ink: lighter means less of a dark ink (fainter) or more + // of a light one (stronger); its hue stays. + const rgb = parseColor(`#${m[1]}`) ?? [0, 0, 0]; + const darkInk = rgb[0] + rgb[1] + rgb[2] < 382; + const k = Math.min(0.9, Math.abs(amount) * 2.5); + const f = amount > 0 === darkInk ? 1 - k : 1 + k; + const a = Math.max(1, Math.min(255, Math.round(parseInt(m[2], 16) * f))); + return `#${m[1]}${a.toString(16).padStart(2, '0')}`; +} + +/** + * The color token a part reads at rest for a group (its fill or its text): + * the variant asked for, else its main variant (primary, accent), else any + * read at rest; never a pressed or hover overlay. + * @param {Array<{token: string, when: string[]}>} reads + * @param {string} [when] a variant asked for, `variant:primary` + * @returns {string | null} + */ +export function restColorRead(reads, when) { + const rest = reads.filter( + r => + /^--color-/.test(r.token) && + !/overlay|transparent|tint/.test(r.token) && + r.when.some(w => !w.includes(':')), + ); + const asked = when ? when.replace(':', '=') : null; + return ( + ( + (asked ? rest.find(r => r.when.includes(asked)) : null) ?? + rest.find(r => + r.when.some(w => w === '' || /=(primary|accent)$/.test(w)), + ) ?? + rest[0] ?? + null + )?.token ?? null + ); +} + /** * A color made stronger or fainter: a see-through color (#00000014) keeps * its hue and scales its alpha; an opaque one moves toward black or white. @@ -611,6 +660,9 @@ function sharedAsk(q, partWords) { .map(seg => { const m = /\s+and\s+/.exec(seg); if (!m || /\band\b/.test(seg.slice(m.index + m[0].length))) return seg; + // "everything rounder except buttons and cards": both are excepted, + // from one ask. + if (/\bexcept\b/.test(seg.slice(0, m.index))) return seg; const left = seg.slice(0, m.index); const right = seg.slice(m.index + m[0].length); for (const [re] of partWords) { @@ -623,6 +675,21 @@ function sharedAsk(q, partWords) { const tail = right.slice(r[0].length); // Only when something is shared: an ask on one side. if (!lead.trim() && !tail.trim()) return seg; + // "the text bigger on buttons and cards red": the second part + // has an ask of its own (a color, an aspect), so only the first + // takes the ask before; nothing is shared. + const tailAsk = tail + .replace( + /\b(?:on|when|while|in)\s+(?:hover|focus|press(?:ed)?|active|disabled|selected|dark mode|light mode)\b|\bto\s+\S+/g, + ' ', + ) + .trim(); + if ( + tailAsk && + (colorsIn(tailAsk).length || aspectsOf(tailAsk).length) && + (colorsIn(lead).length || aspectsOf(lead).length) + ) + return seg; return `${lead}${l[0].trim()}${tail} and ${lead}${r[0].trim()}${tail}`; } } @@ -632,6 +699,9 @@ function sharedAsk(q, partWords) { ); } +/** A keep step for a part the change does not reach. */ +const UNTOUCHED = /** @type {const} */ ('untouched'); + /** * The part a clause measures against ("cards twice the padding of * buttons": buttons), by clause. @@ -737,7 +807,7 @@ export function exactFactor(text) { } /** - * @typedef {{on: string, when?: string, role?: string, level?: string}} PartRef + * @typedef {{on: string, when?: string, role?: string, level?: string, missing?: string}} PartRef */ /** @@ -821,7 +891,7 @@ const OVERRIDES = [ [/\b(checkbox(es)?|check ?marks?)\b/, {on: 'checks'}], // "switch off", "switch the buttons to" are the verb, not the part. [ - /\b(toggles?|switch(es)?(?! (off|on|to|the|out|it|them|over|back|from)\b))\b/, + /\b(toggles?|switch(?:e?s)?(?! (off|on|to|the|out|it|them|over|back|from)\b))\b/, {on: 'switch'}, ], [/\b((table )?cells?)\b/, {on: 'table-cell'}], @@ -901,7 +971,7 @@ function partLexicon(ctx) { .filter(key => key.includes('-') || !other(key)) .sort((x, y) => y.length - x.length) .map(key => [ - new RegExp(`\\b${key.replace(/-/g, '[\\s-]?')}s?\\b`), + new RegExp(`\\b${key.replace(/-/g, '[\\s-]?')}(?:e?s)?\\b`), {on: key}, ]); // Near misses are corrected to one-word part names and design words. @@ -921,8 +991,55 @@ function partLexicon(ctx) { new RegExp(`\\b${name.replace(/s$/, '')}s?\\b`), {on: name}, ]); + // A part's own sub-part named in words ("dialog header", "date range + // input", "number inputs") is that catalog part, before any phrase of + // one of its words. A sub-part Core does not have ("card header") is the + // whole part, said in the answer. + const keys = new Set(project.target.keys()); + /** @type {Array<[RegExp, PartRef]>} */ + const subParts = []; + for (const key of keys) { + if (key.includes('-')) continue; + for (const sub of ['header', 'footer', 'title']) { + const real = + sub === 'title' && keys.has(`${key}-header-title-block`) + ? `${key}-header-title-block` + : keys.has(`${key}-${sub}`) + ? `${key}-${sub}` + : null; + // A phrase an override reads whole ("table header") keeps it. + const phrase = `${key} ${sub}`; + if (OVERRIDES.some(([re]) => re.exec(phrase)?.[0] === phrase)) continue; + subParts.push([ + new RegExp(`\\b${key}s?[\\s-]${sub}s?\\b`), + real ? {on: real} : {on: key, missing: `${key} ${sub}`}, + ]); + } + } const lex = { parts: [ + ...subParts, + // A catalog name of two or more words said in full ("toggle + // buttons", "code block header", "hover card") is that part. + ...derived.filter( + ([, x]) => + x.on.includes('-') && + // A phrase an override already reads whole ("progress bar", + // "table header") keeps the override's part. + !OVERRIDES.some(([re]) => { + const phrase = x.on.replace(/-/g, ' '); + return re.exec(phrase)?.[0] === phrase; + }), + ), + /** @type {[RegExp, PartRef]} */ ([ + /\bselect(?:ion)?[\s-]?(?:box|menu|field|dropdown)(?:e?s)?\b/, + {on: 'selector'}, + ]), + // "date pickers": Core's date input. + /** @type {[RegExp, PartRef]} */ ([ + /\bdate ?pickers?\b/, + {on: 'date-input'}, + ]), ...OVERRIDES, ...derived, ...groups, @@ -975,7 +1092,7 @@ const STATE_WORDS = /** @type {Array<[RegExp, string]>} */ ([ * One clause, read. * @typedef {object} Clause * @property {string} text - * @property {Array<{on: string, when?: string, role?: string, level?: string}>} parts + * @property {Array<{on: string, when?: string, role?: string, level?: string, missing?: string}>} parts * @property {string[]} aspects * @property {boolean} neg * @property {'more' | 'less' | null} dir @@ -1169,7 +1286,8 @@ function clausesOf(q, partWords) { /\bswap\b/.test(q) ? q.replace(/\band\b/g, '&') : sharedAsk(q, partWords) ) .split( - /;|\.(?=\s|$)|,(?![^(]*\))|\b(?:and|with|but|then|plus|also|y|et|und|и)\b|\bwhile\b|(?=\bexcept\b)/, + // A font stack ("Georgia, serif") is one value, not two asks. + /;|\.(?=\s|$)|,(?![^(]*\))(?!\s*(?:serif|sans-serif|monospace|system-ui|cursive|fantasy|ui-[a-z-]+)\b)|\b(?:and|with|but|then|plus|also|y|et|und|и)\b|\bwhile\b|(?=\bexcept\b)/, ) .map(s => s.trim()) .filter(Boolean); @@ -1306,8 +1424,20 @@ function clausesOf(q, partWords) { ) decl[m[1]] = m[2].trim(); let state = null; + // A part whose name has a state word ("hover card") is the part, not + // the state: read the state from the words left once parts are taken. + const stateText = parts + .filter(x => /hover|focus|active|disabled|selected|checked/.test(x.on)) + .reduce( + (t, x) => + t.replace( + new RegExp(`\\b${x.on.replace(/-/g, '[\\s-]?')}s?\\b`, 'g'), + ' ', + ), + text, + ); for (const [re, s] of STATE_WORDS) - if (re.test(text)) { + if (re.test(stateText)) { state = s; break; } @@ -1322,8 +1452,10 @@ function clausesOf(q, partWords) { ) ? 'light' : null; + // "except ghost", "except in the nav": an except clause without a part + // is one too (a variant, or a place the answer says it cannot keep). const except = - parts.length > 0 && + (parts.length > 0 || /^except\b/.test(text)) && (/^except\b/.test(text) || (neg && aspects.length === 0 && @@ -1788,7 +1920,7 @@ export function draftAnswer(ctx, env, question) { // A place ("not in the header") is the header part too. const before = String(all).slice(Math.max(0, at - 16), at); const owned = - /\b(table|column|accordion|card|dialog|modal|page|section|app|site|product|top ?nav)\s+$/i.test( + /\b(table|column|accordion|card|dialog|modal|page|section|app|site|product|top ?nav|block|drawer|popover|panel)\s+$/i.test( before, ) || /\b(in|on|inside|into|across|of)\s+(the\s+|my\s+)?$/i.test(before); return text && !surface && !owned @@ -2478,6 +2610,16 @@ export function draftAnswer(ctx, env, question) { // "flat cards, not flat buttons": the second part stays as it is. .replace(/^(.*\b(\w{3,})\b.*?),\s*(?:but )?not \2 [a-z ]+$/, '$1') .replace(/\b(everything|everywhere|all) but\b/g, '$1 except') + // "dialog header and footer": both are the dialog's. + .replace( + /\b([a-z]+) (header|footer|title)s? and (header|footer|title)s?\b/g, + '$1 $2 and $1 $3', + ) + // "all buttons except primary ones rounder": the ask before the except. + .replace( + /^(.*?)\s+except\s+((?:the\s+)?(?:primary|secondary|ghost|destructive|danger)(?:\s+ones?|\s+buttons?)?)\s+(\S.*)$/i, + '$1 $3 except $2', + ) .replace( /\b(apart from|other than|excluding|but not (on|in|for)|but not)\b/g, 'except', @@ -2613,6 +2755,56 @@ export function draftAnswer(ctx, env, question) { } // "cards and dialogs with no shadow", "tooltips and toasts black": parts // listed before the ask all take it. + // "headings twice as big", "dialog text twice as big": size words on + // headings or on a part's text are the text size. + for (const c of clauses) + if ( + (!c.aspects.length || + (c.aspects.length === 1 && + c.aspects[0] === 'text' && + !c.colors.length)) && + c.parts.length && + /\b(big|bigger|small|smaller|large|larger)\b/.test(c.text) && + (c.parts.some(x => x.on === 'heading') || /\btext\b/.test(c.text)) + ) + c.aspects = ['fontsize']; + // "bold, uppercase and 14px": a bare size in a list of text changes is + // the text size. + for (let i = 1; i < clauses.length; i++) { + const c = clauses[i]; + if ( + !c.aspects.length && + c.lengths.length === 1 && + /^\s*(?:and\s+)?\d*\.?\d+(?:px|rem|em|pt)\s*$/.test(c.text) && + clauses + .slice(0, i) + .some(x => + x.aspects.some(a => + ['weight', 'case', 'fontsize', 'text', 'family', 'font'].includes( + a, + ), + ), + ) + ) + c.aspects = ['fontsize']; + } + // "except buttons and cards": the parts listed after an except clause are + // excepted too. + for (let i = 0; i < clauses.length - 1; i++) { + if (!clauses[i].except) continue; + while ( + i + 1 < clauses.length && + clauses[i + 1].parts.length && + !clauses[i + 1].aspects.length && + !clauses[i + 1].colors.length && + !clauses[i + 1].lengths.length && + !clauses[i + 1].dir && + !clauses[i + 1].except + ) { + clauses[i].parts.push(...clauses[i + 1].parts); + clauses.splice(i + 1, 1); + } + } for (let i = 0; i < clauses.length - 1; i++) { const c = clauses[i]; const onlyParts = (/** @type {Clause} */ x) => @@ -3230,6 +3422,10 @@ export function draftAnswer(ctx, env, question) { ) .filter(o => o.length); // The caller reads what was kept. + // A sub-part Core does not have: the answer is for the whole part. + const missing = clauseNow?.parts.find(x => x.missing); + if (missing && !d.text.startsWith('Core has no')) + d.text = `Core has no ${missing.missing} part of its own, so this is for the whole ${missing.on}: ${d.text}`; input.text = d.text; input.options = options; drafts.push({ @@ -3283,6 +3479,17 @@ export function draftAnswer(ctx, env, question) { } return c.colors[0] ?? null; }; + // "primary buttons red and the secondary blue": a clause that names its + // own variant keeps it, never the variant of the clause before. + for (const c of clauses) { + const own = /\b(primary|secondary|ghost|destructive|danger)\b/.exec( + c.text, + )?.[1]; + if (!own) continue; + const want = `variant:${own === 'danger' ? 'destructive' : own}`; + for (const p of c.parts) + if (p.when?.startsWith('variant:') && p.when !== want) p.when = want; + } /** @type {Array<{clause: Clause, steps: Step[]}>} */ const tops = []; const recordTop = (/** @type {Clause} */ c, /** @type {Step[]} */ steps) => { @@ -3302,28 +3509,66 @@ export function draftAnswer(ctx, env, question) { if (c.except) { const prior = tops[tops.length - 1]; if (!prior) continue; - for (const p of c.parts) { + const priorOn = prior.steps.find(st => st.on)?.on ?? null; + // "except primary ones", "except ghost": a variant of the part being + // changed. + const variant = /\b(primary|secondary|ghost|destructive|danger)\b/.exec( + c.text, + )?.[1]; + /** @type {Array<{on: string, when?: string}>} */ + const excepted = c.parts.length + ? c.parts + : variant && priorOn + ? [ + { + on: priorOn, + when: `variant:${variant === 'danger' ? 'destructive' : variant}`, + }, + ] + : []; + if (!excepted.length) { + // "except in the nav": a place, not a part. + push({ + text: `"${c.text}" names no part the editor can keep apart, so ${prior.clause.text} applies everywhere`, + options: [], + }); + continue; + } + /** @type {Step[]} */ + const keeps = []; + /** @type {string[]} */ + const said = []; + for (const p of excepted) { seenPart(p.on); - const keep = prior.steps - .map(st => keepAsIs(st, p)) - .filter(x => x != null); - if (!keep.length) { - push({ - text: `${p.on} keeps what it has: a setting --on ${p.on} wins over a whole-theme one there, but this change has no setting for ${p.on} to keep`, - options: [], - lookup: `${HOW} ${p.on}`, - }); + const name = `${p.on}${p.when ? ` (${p.when.replace(':', ' ')})` : ''}`; + // A part the words do not tell apart from the part being changed. + if (!p.when && p.on === priorOn) { + said.push( + `${name} is the part being changed, so it cannot be kept apart`, + ); continue; } - push({ - text: `${prior.clause.text}, except ${p.on}, which keeps what it has now`, - options: [[...prior.steps, .../** @type {Step[]} */ (keep)]], - sequence: true, - }); - // The whole ask comes first, before the change alone. - drafts.unshift(/** @type {Draft} */ (drafts.pop())); - tops.pop(); + const keep = prior.steps.map(st => keepAsIs(st, p)); + if (keep.every(k => k === UNTOUCHED || k == null)) { + said.push( + keep.some(k => k === UNTOUCHED) + ? `${name} reads none of what changes, so it keeps what it has` + : `${name} has nothing this change sets to keep`, + ); + continue; + } + for (const k of keep) + if (k && k !== UNTOUCHED) keeps.push(/** @type {Step} */ (k)); + said.push(`${name} keeps what it has now`); } + push({ + text: `${prior.clause.text}, except ${said.join('; ')}`, + options: [[...prior.steps, ...keeps]], + sequence: true, + }); + // The whole ask comes first, before the change alone. + drafts.unshift(/** @type {Draft} */ (drafts.pop())); + tops.pop(); continue; } // A color the theme generates, named by its role ("error background", @@ -3354,6 +3599,70 @@ export function draftAnswer(ctx, env, question) { }); continue; } + // "cards as round as buttons", "card padding the same as button + // padding", "make the cards match the buttons' corners", "inputs as + // tall as buttons": the part named first takes the other part's value + // of that property now, written out; the other part is not changed. + { + const measure = + /\bas (round(?:ed)?|tall|high|bold|big|large) as\b|\bsame (corners?|radius|roundness|padding|height|font size|text size|weight) as\b|\b(?:match(?:es|ing)?|like)\b[^.]*?\b(corners?|radius|padding|height)\b|\b(corners?|radius|padding|height)\b[^.]*?\b(?:the )?same as\b/.exec( + c.text, + ); + const word = measure ? (measure.slice(1).find(Boolean) ?? '') : ''; + const prop = /round|corner|radius/.test(word) + ? 'border-radius' + : /padding/.test(word) + ? 'padding' + : /tall|high|height/.test(word) + ? 'height' + : /bold|weight/.test(word) + ? 'font-weight' + : /big|large|size/.test(word) + ? 'font-size' + : null; + // The parts as the words name them (the comparison's own part may have + // been taken out of the clause's parts already). + const named = [ + ...new Set([ + ...c.parts.map(x => x.on), + ...lex.parts.filter(([re]) => re.test(c.text)).map(([, x]) => x.on), + ]), + ] + .map(on => ({ + on, + at: Math.min( + ...lex.parts + .filter(([, x]) => x.on === on) + .map(([re]) => re.exec(c.text)?.index ?? Infinity), + ), + })) + .sort((x, y) => x.at - y.at); + const sourceOn = + named.length >= 2 ? named[1].on : (compared.get(c) ?? null); + const subjectOn = named[0]?.on ?? null; + if (prop && subjectOn && sourceOn && subjectOn !== sourceOn) { + const now = partPropNow(sourceOn, prop); + // "twice as round as inputs": that amount of the other part's; + // else the other part's own value, or the token it reads (so the + // copy follows it). + const factor = exactFactor(c.text); + const value = + now && factor + ? scaledBox(now, factor) + : partPropNow(sourceOn, prop, true); + if (value) { + seenPart(subjectOn); + seenPart(sourceOn); + const d = { + text: `${subjectOn} takes ${factor ? `${factor} times ` : ''}${sourceOn}'s ${prop}: ${value} (${sourceOn} has ${now} now and is not changed)`, + options: [[set(prop, value, {on: subjectOn})]], + }; + push(d); + recordTop(c, d.options[0] ?? []); + continue; + } + } + } // "cards the same radius as buttons": the first part takes the second // part's value, read from the token the second part reads. const same = /\b(?:the )?same (\w+(?: \w+)?) as (?:the )?([a-z-]+)\b/.exec( @@ -3741,9 +4050,23 @@ export function draftAnswer(ctx, env, question) { c.parts.length && c.parts.every(p => TARGET.has(one(p.on)) || FAMILIES[p.on]) ) { + // "horizontal padding 20px", "padding top 12px": only those sides. + const side = + named.prop === 'padding' + ? /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( + c.text, + )?.[1] + : null; + const prop = !side + ? named.prop + : /^(left|right|top|bottom)$/.test(side) + ? `padding-${side}` + : /^(vertical|block)/.test(side) + ? 'padding-block' + : 'padding-inline'; const steps = c.parts.slice(0, 3).map(p => { const {noState: _n, ...f} = flagsFor(c, p); - return {p, f, st: set(named.prop, named.value, {...f, ...modeOf(c)})}; + return {p, f, st: set(prop, named.value, {...f, ...modeOf(c)})}; }); // A property set cannot use on a part (a heading's color comes from // its prop) is answered the usual way, which says why. @@ -3751,7 +4074,7 @@ export function draftAnswer(ctx, env, question) { for (const {p, f, st} of steps) { seenPart(p.on); const d = { - text: `${named.prop} on ${p.on}${f.state ? ` when ${f.state}` : ''}, as you wrote it`, + text: `${prop} on ${p.on}${f.state ? ` when ${f.state}` : ''}, as you wrote it`, options: [[st]], }; push(d); @@ -3923,18 +4246,104 @@ export function draftAnswer(ctx, env, question) { }; } + /** + * A token's value now with every var() it reads written out. + * @param {string} token + */ + function resolvedValue(token) { + let v = String(current(token) || ''); + for (let i = 0; i < 6 && /var\(/.test(v); i++) + v = v.replace( + /var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^()]*))?\)/gi, + (_, t, fb) => String(current(t) || fb || ''), + ); + return v.trim() || null; + } + + /** + * A part's value of a property now: its own setting, else the token its + * plain rule reads for it, written out. + * @param {string} part + * @param {string} prop + * @param {boolean} [live] the token itself (`var(--x)`) when the part has + * no setting of its own, so the copy follows it + * @returns {string | null} + */ + function partPropNow(part, prop, live = false) { + const own = partValue(part, prop); + if (own) return own; + // The part's plain rule as built (both sides of a two-value padding). + if (prop === 'padding') { + const rule = partRuleTokens( + ctx.project.cwd, + env.slug, + env.data.like, + one(part), + ).get('padding'); + if (rule) + return live + ? String(rule) + : String(rule).replace( + /var\(\s*(--[a-z0-9-]+)\s*(?:,[^)]*)?\)/gi, + (m, t) => resolvedValue(t) || m, + ); + } + const group = /** @type {Record} */ ({ + 'border-radius': 'corners', + padding: 'spacing', + height: 'spacing', + 'font-size': 'font', + 'font-weight': 'font', + })[prop]; + if (!group) return null; + const want = + prop === 'padding' + ? /^padding/ + : prop === 'height' + ? /^(min-)?(block-size|height)$/ + : new RegExp(`^${prop}$`); + const reads = C.partReads(one(part), group).filter( + r => group === 'corners' || r.props.some(x => want.test(x)), + ); + const read = + reads.find(r => r.when.includes('')) ?? + reads.find(r => r.when.some(w => /=(primary|md|medium)$/.test(w))) ?? + reads[0]; + if (!read) return null; + return live ? `var(${read.token})` : resolvedValue(read.token); + } + + /** + * Whether a part reads a token in any of its rules. + * @param {string} part + * @param {string} token + */ + function partReadsToken(part, token) { + return Object.keys(GROUPS).some(g => + C.partReads(one(part), g).some(r => r.token === token), + ); + } + /** * The step that keeps one part as it is now when a step changes the whole * theme (or a group the part is in), or null when none can. * @param {Step} st * @param {{on: string, when?: string}} p - * @returns {Step | null} + * @returns {Step | null | typeof UNTOUCHED} */ function keepAsIs(st, p) { const flags = {on: p.on, ...(p.when ? {when: p.when} : {})}; + // A token the part never reads does not change it: nothing to keep. + // (Only tokens a part never inherits: a text token reaches every part's + // text through inheritance.) + if ( + /^--(radius|spacing|size)-/.test(st.thing) && + !partReadsToken(p.on, st.thing) + ) + return UNTOUCHED; if (st.on && st.on !== p.on && !(FAMILIES[st.on] ?? []).includes(one(p.on))) return null; - if (st.thing === 'corners') { + if (st.thing === 'corners' || st.thing === 'border-radius') { // What the part has now: its own setting, else the token it reads // (its plain rule first, else any rule's), never another token. const reads = C.partReads(one(p.on), 'corners'); @@ -3960,18 +4369,35 @@ export function draftAnswer(ctx, env, question) { /^--shadow-/.test(st.thing) || st.thing === 'box-shadow' || st.thing === 'surfaces' - ) - return set('box-shadow', 'none', flags); + ) { + // The shadow it has now, written out (the tokens it reads may be set + // to none in the same answer). + // Its shadow at rest; none when it has none. + const read = C.partReads(one(p.on), 'shadow').find(r => + r.when.some(w => !w.includes(':')), + ); + const now = + partValue(p.on, 'box-shadow') ?? + (read ? resolvedValue(read.token) : 'none'); + return now ? set('box-shadow', now, flags) : null; + } if ( st.thing === 'headings' || st.thing === '--text-body-weight' || st.thing === 'font-weight' - ) - return set( - 'font-weight', - String(current('--text-body-weight') || '400'), - flags, + ) { + // The weight it has now: its own, else the token it reads for it. + const read = C.partReads(one(p.on), 'font').find(r => + r.props.includes('font-weight'), ); + // Its own weight, else the token it reads, else the body weight it + // inherits now. + const now = + partValue(p.on, 'font-weight') ?? + (read ? resolvedValue(read.token) : null) ?? + String(current('--text-body-weight') || '400'); + return set('font-weight', now, flags); + } const group = /** @type {Record} */ ({ background: 'background', 'background-color': 'background', @@ -4336,6 +4762,8 @@ export function draftAnswer(ctx, env, question) { a === 'background' && on && !f.state && + // A region of a part (a dialog's header) takes a fill of its own. + !/-(header|footer|title-block)$/.test(on) && !/\b(stripe[sd]?|striped|zebra)\b/.test(c.text) && C.MAP[one(on)] && !Object.keys(C.MAP[one(on)].own ?? {}).some(k => @@ -4453,7 +4881,13 @@ export function draftAnswer(ctx, env, question) { typed > 1 ? 2 - typed : amount; - if (prop && on && opacityAmount != null) { + // Every heading level scales from its own size (the font-size case). + if ( + prop && + on && + opacityAmount != null && + !(prop === 'font-size' && on === 'heading' && !p?.level) + ) { const amount = opacityAmount; const part = /** @type {string} */ (on); const group = C.groupOfProp(prop); @@ -4976,6 +5410,31 @@ export function draftAnswer(ctx, env, question) { options: [[set('spacing', value)]], } : null; + // "padding top 12px", "more padding on the left", "horizontal + // padding 20px": only those sides. + const sideWord = gap + ? null + : /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( + c.text, + )?.[1]; + if (sideWord) { + const sideProp = /^(left|right|top|bottom)$/.test(sideWord) + ? `padding-${sideWord}` + : /^(vertical|block)/.test(sideWord) + ? 'padding-block' + : 'padding-inline'; + const nowSide = + partValue(on, sideProp) ?? + partPropNow(on, 'padding')?.split(/\s+/)[0] ?? + null; + const factor = exactFactor(c.text) ?? (value === 'less' ? 0.8 : 1.25); + const sideValue = px ?? (nowSide ? scaledBox(nowSide, factor) : null); + if (sideValue) + return { + text: `padding on ${sideWord === 'sides' ? 'the sides' : `the ${sideWord}`}${where}: ${sideProp} ${sideValue}${!px && nowSide ? ` (now ${nowSide})` : ''}`, + options: [[set(sideProp, sideValue, f)]], + }; + } if (px) { const box = boxIn(c.text) ?? px; return { @@ -5264,7 +5723,20 @@ export function draftAnswer(ctx, env, question) { const family = FONTS[font]?.[0] ?? OPEN_FONTS[font]?.family ?? font; return { text: `the font${where}: font-family`, - options: [[set('font-family', family, f)]], + options: [ + [ + set( + 'font-family', + // A stack given with the family ("Inter, sans-serif") whole. + (g => (g ? `${family}, ${g[1]}` : family))( + /,\s*(serif|sans-serif|monospace|system-ui|cursive)\b/.exec( + c.text, + ), + ), + f, + ), + ], + ], }; } case 'fontsize': { @@ -5278,6 +5750,36 @@ export function draftAnswer(ctx, env, question) { const f2 = /\bmuch\b/.test(c.text) ? 1.5 : 1.25; // An exact amount ("double", "half") of the size there now. const factor = exactFactor(c.text) ?? (c.dir === 'less' ? 1 / f2 : f2); + // "headings 20% bigger", "headings twice as big": every heading + // level from its own size, so the steps between them stay. + if (on === 'heading' && !p?.level && !f.state && !f.when) { + const pct = /(\d{1,3}(?:\.\d+)?)\s?%/.exec(c.text); + const amount = + exactFactor(c.text) ?? + (pct + ? c.dir === 'less' + ? 1 - Number(pct[1]) / 100 + : 1 + Number(pct[1]) / 100 + : null); + if (amount && !(px && !pct && !exactFactor(c.text))) { + const levels = Object.keys(TOKENS) + .filter(t => /^--text-heading-\d-size$/.test(t)) + .sort(); + const steps = levels + .map(t => { + const now = resolvedValue(t); + const v = now ? scaledBox(now, amount) : null; + return v ? set(t, v) : null; + }) + .filter(st => st != null); + if (steps.length) + return { + text: `every heading level ${amount > 1 ? 'bigger' : 'smaller'} from its own size (x${Number(amount.toFixed(3))}): ${levels.join(', ')}`, + options: [/** @type {Step[]} */ (steps)], + sequence: true, + }; + } + } // An exact --font-size-base wins over the text-size dial, so the // dial would change nothing there: step that token instead. const pinned = on @@ -5748,6 +6250,44 @@ export function draftAnswer(ctx, env, question) { text: `the link color: --color-text-accent, the token a link reads (it also colors other accent text)${f.state ? `; a link has no color of its own for ${f.state}, so this changes it in every state` : ''}`, options: [[set('--color-text-accent', color, modeOf(c))]], }; + // "card text lighter", "a bit darker": from the text color the part + // shows now (its own, else the token it reads at rest). + if (!color && c.dir && /\b(light|dark)(er)?\b/.test(c.text)) { + const lighter = /\blight(er)?\b/.test(c.text); + const amount = /\b(a (little|bit|touch)|slightly|a tad)\b/.test( + c.text, + ) + ? 0.1 + : 0.2; + const own = partValue(on, 'color'); + if (own && parseColor(own)) { + const v = shiftKeepAlpha(own, lighter ? amount : -amount); + if (v) + return { + text: `text color${where}: ${v} (now ${own})`, + options: [[set('color', v, f)]], + }; + } + // A part with no text color of its own shows the main text color. + const read = + restColorRead(C.partReads(one(on), 'text'), f.when) ?? + '--color-text-primary'; + if (read) { + const steps = perMode( + read, + 'color', + f, + now => shiftKeepAlpha(now, lighter ? amount : -amount), + c, + ); + if (steps.length) + return { + text: `${lighter ? 'lighter' : 'darker'} text${where}, from ${read}${modesSaid(steps)}`, + options: [steps], + sequence: steps.length > 1, + }; + } + } if (!color) return null; // A part that takes its color from a prop (a heading): the text // token, on that part only. @@ -5916,12 +6456,33 @@ export function draftAnswer(ctx, env, question) { if (!color && c.dir) { const lighter = /\blight(er)?\b/.test(c.text); const reads = C.partReads(one(on), 'background'); + // The fill the part shows at rest (its main variant, or the one + // asked for), never its pressed overlay. const read = C.MAP[one(on)] - ? (reads.find(r => r.when.includes(''))?.token ?? + ? (restColorRead(reads, f.when) ?? (FAMILIES.containers.includes(one(on)) ? '--color-background-card' - : reads[0]?.token)) + : null)) : null; + // The amount asked ("20% darker", "a bit lighter"), else a step. + const pct = /(\d{1,2})\s?%/.exec(c.text); + const amount = pct + ? Math.min(0.9, Number(pct[1]) / 100) + : /\b(a (little|bit|touch)|slightly|a tad)\b/.test(c.text) + ? 0.06 + : 0.12; + // A fill of the theme's own on the part comes first. + const ownFill = + partValue(on, 'background-color') ?? partValue(on, 'background'); + const ownShifted = + ownFill && parseColor(ownFill) + ? shiftKeepAlpha(ownFill, lighter ? amount : -amount) + : null; + if (ownShifted && !f.state) + return { + text: `${lighter ? 'a lighter' : 'a darker'} background${where}: ${ownShifted} (now ${ownFill})`, + options: [[set('background-color', ownShifted, f)]], + }; // A part with no recorded fill: what its color is now is unknown, // so a brightness filter makes it darker or lighter from there. if (!read || f.state) @@ -5935,7 +6496,7 @@ export function draftAnswer(ctx, env, question) { read, 'background', f, - now => shift(now, lighter ? 0.12 : -0.12), + now => shiftKeepAlpha(now, lighter ? amount : -amount), c, ); if (steps.length) diff --git a/packages/cli/api/theme-edit/_how.mjs b/packages/cli/api/theme-edit/_how.mjs index 365aedcb84711..696d0b2a5ffec 100644 --- a/packages/cli/api/theme-edit/_how.mjs +++ b/packages/cli/api/theme-edit/_how.mjs @@ -214,7 +214,22 @@ async function answer(subject, options, cwd) { run: [`${HOW} ${t ? JSON.stringify(t) : 'card'}`], }); if (options.theme != null) { - loadTheme(cwd, options.theme); + try { + loadTheme(cwd, options.theme); + } catch (e) { + // A theme name near the one typed: the same question for it, never + // only `show` (the question is kept). + const err = /** @type {any} */ (e); + if (err?.code === ERROR_CODES.ERR_UNKNOWN_THEME && err.suggestions) + for (const s of err.suggestions) { + const near = / show ([a-z][a-z0-9-]*)$/.exec( + String(s.name ?? ''), + )?.[1]; + if (near) + s.name = `${HOW} ${quoteCommandArgument(t || 'card')} --theme ${near}`; + } + throw e; + } // A theme whose source no longer extends its settings takes no setting: // an answer would suggest commands that cannot run. const broken = themeSourceProblem(cwd, options.theme); diff --git a/packages/cli/api/theme-edit/theme-how.test.mjs b/packages/cli/api/theme-edit/theme-how.test.mjs index 89a85af21c73e..994a72025f12e 100644 --- a/packages/cli/api/theme-edit/theme-how.test.mjs +++ b/packages/cli/api/theme-edit/theme-how.test.mjs @@ -2343,7 +2343,11 @@ const QUESTIONS_G = [ 'Times New Roman for headings, lol', {top: /^set q font 'Times New Roman' --on heading$/}, ], - ['code blocks should use Menlo', {top: /^set q font Menlo --on code$/}], + // Code blocks are Core's code-block part (hunt68). + [ + 'code blocks should use Menlo', + {top: /^set q font-family Menlo --on code-block$/}, + ], ['Fira Code for monospace', {top: /^set q font 'Fira Code' --on code$/}], // part words: a catalog part, or say there is none ['menu rounder', {top: /^set q corners more --on dropdown-menu$/}], @@ -3117,7 +3121,8 @@ const QUESTIONS_J = [ ], [ 'buttons twice as round as inputs', - {top: /^set q corners more --on button$/}, + // Twice the inputs' radius, on buttons only (hunt68). + {top: /^set q border-radius \d*\.?\d+(px|rem) --on button$/}, ], [ 'focus ring the same color as the danger color', diff --git a/packages/cli/api/theme-edit/theme-hunt68.test.mjs b/packages/cli/api/theme-edit/theme-hunt68.test.mjs new file mode 100644 index 0000000000000..1b5a3baee0387 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt68.test.mjs @@ -0,0 +1,293 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file hunt68: `editor how` scope and semantics: a fill made lighter or + * darker from the part's own fill at rest; "except" keeps every excepted + * part's value now; sub-parts and catalog parts named in words; + * comparisons copy the other part's value onto the subject only; each + * clause keeps its own variant; a bare size in a text list; every heading + * level scaled from its own size; a font stack kept whole; padding sides; + * a part named with a state word; a typed theme name near one keeps the + * question. + * + * @position api/theme-edit — tests + */ + +import * as path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; +import {themeEditHow, themeEditNew} from './theme-edit.mjs'; +import {shiftKeepAlpha} from './_how-question.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import { + FIXTURE_PACKAGES, + writeFixtureBaseTheme, +} from '../../test-utils/base-theme-fixture.mjs'; + +/** @type {string} */ +let cwd; + +beforeAll(async () => { + cwd = makeLinkedFixture('astryx-theme-hunt68-', FIXTURE_PACKAGES); + writeFixtureBaseTheme(cwd, 'neutral'); + await themeEditNew('q', {cwd, like: 'neutral'}); + await themeEditNew('brand', {cwd, like: 'neutral'}); +}, 240_000); + +afterAll(() => { + removeLinkedFixture(cwd); +}); + +/** The first answer with a change: its commands (all, in a sequence) and text. @param {string} q */ +const top = async q => { + const {data} = await themeEditHow(q, {cwd, theme: 'q'}); + const r = (data.results ?? []).find(x => + x.commands.some(c => / editor (set|undo) /.test(c)), + ); + return { + all: (r ? (r.sequence ? r.commands : [r.commands[0]]) : []) + .map(c => c.replace(/^.* editor /, '')) + .join('\n'), + text: r?.text ?? '', + every: (data.results ?? []).map(x => x.text).join('\n'), + }; +}; + +const T = 120_000; + +describe('hunt68 F24e-5: lighter or darker fills', () => { + it( + 'a button starts from its fill at rest, not the pressed overlay', + async () => { + const r = await top('make the button background a bit lighter'); + expect(r.text).toMatch(/background on button, from --color-accent/); + }, + T, + ); + it('a see-through fill stays see-through, and lighter is fainter ink', () => { + expect(shiftKeepAlpha('#0000000f', 0.12)).toMatch(/^#000000[0-9a-f]{2}$/); + expect( + parseInt(String(shiftKeepAlpha('#0000000f', 0.12)).slice(7), 16), + ).toBeLessThan(0x0f); + expect( + parseInt(String(shiftKeepAlpha('#ffffff1a', 0.12)).slice(7), 16), + ).toBeGreaterThan(0x1a); + }); +}); + +describe('hunt68 F24e-8: except', () => { + it( + 'the excepted dialog keeps its shadow', + async () => { + const r = await top('remove shadows everywhere except dialogs'); + expect(r.all).toMatch(/box-shadow '.+' --on dialog/); + expect(r.all).not.toMatch(/box-shadow none --on dialog/); + }, + T, + ); + it( + 'headings are not pinned lighter', + async () => { + const r = await top('make everything bolder except headings'); + expect(r.all).not.toMatch(/font-weight 400 --on heading/); + }, + T, + ); + it( + 'two excepted parts in one answer', + async () => { + const r = await top('make everything rounder except buttons and cards'); + expect(r.all).toMatch(/--on button/); + expect(r.all).toMatch(/--on card/); + }, + T, + ); + it( + 'an excepted variant', + async () => { + const r = await top('make all buttons except primary ones rounder'); + expect(r.all).toMatch(/--on button --when variant:primary/); + }, + T, + ); + it( + 'a place is said, not dropped', + async () => { + const {data} = await themeEditHow('make links red except in the nav', { + cwd, + theme: 'q', + }); + expect(data.results.map(x => x.text).join('\n')).toContain( + 'names no part the editor can keep apart', + ); + }, + T, + ); +}); + +describe('hunt68 F24e-6/18, R26-D/J: parts named in words', () => { + it.each( + /** @type {Array<[string, RegExp]>} */ ([ + ['dialog header gray', /--on dialog-header$/], + ['dialog title bigger', /--on dialog-header-title-block$/], + ['text inputs rounder', /--on text-input/], + ['date pickers rounder', /--on date-input/], + ['toggle buttons bold', /--on toggle-button$/], + ['code block header bold', /--on code-block-header$/], + ['select boxes rounder', /--on selector/], + ['hover card background gray', /--on hover-card$/], + ]), + )( + '%s', + async (q, want) => { + expect((await top(q)).all).toMatch(want); + }, + T, + ); + it( + 'dialog header and footer stay the dialog', + async () => { + expect((await top('dialog header and footer gray')).all).not.toMatch( + /layout-footer/, + ); + }, + T, + ); + it( + 'a sub-part Core lacks says so', + async () => { + const r = await top('card header background gray'); + expect(r.text).toContain('Core has no card header part of its own'); + }, + T, + ); + it( + 'switches is the switch', + async () => { + const r = await themeEditHow('switches rounder', {cwd, theme: 'q'}); + expect(JSON.stringify(r.data)).toContain('switch'); + expect(JSON.stringify(r.data)).not.toContain('no part is called'); + }, + T, + ); +}); + +describe('hunt68 F24e-7: comparisons', () => { + it( + 'as round as: the subject only takes the other part value', + async () => { + const r = await top('cards as round as buttons'); + expect(r.all).toMatch(/^set q border-radius .+ --on card$/); + }, + T, + ); + it( + 'the same padding: padding, never a gap', + async () => { + const r = await top('card padding the same as button padding'); + expect(r.all).toMatch(/^set q padding .+ --on card$/); + }, + T, + ); +}); + +describe('hunt68: lists, levels, stacks, sides', () => { + it( + 'F24e-9 each clause keeps its own variant', + async () => { + const r = await top('make primary buttons red and the secondary blue'); + expect(r.all).toMatch(/--when variant:secondary/); + }, + T, + ); + it( + 'F24e-11 a bare size in a text list', + async () => { + const r = await top('make buttons bold, uppercase and 14px'); + expect(r.all).toMatch(/font-size 14px --on button/); + }, + T, + ); + it( + 'F24e-13 every heading level from its own size', + async () => { + const r = await top('make headings 20% bigger'); + expect(r.all).toMatch(/--text-heading-1-size/); + expect(r.all).toMatch(/--text-heading-2-size/); + }, + T, + ); + it( + 'F24e-14 a font stack whole on the part', + async () => { + const r = await top('card font Inter, sans-serif'); + expect(r.all).toBe("set q font-family 'Inter, sans-serif' --on card"); + }, + T, + ); + it( + 'F24e-12 card text lighter and dialog text twice as big', + async () => { + expect((await top('make the card text lighter')).all).toMatch( + /color .+ --on card/, + ); + expect((await top('make the dialog text twice as big')).all).toMatch( + /font-size .+ --on dialog/, + ); + }, + T, + ); + it( + 'R26-I padding sides', + async () => { + expect((await top('padding top 12px on cards')).all).toBe( + 'set q padding-top 12px --on card', + ); + expect((await top('horizontal padding 20px on buttons')).all).toBe( + 'set q padding-inline 20px --on button', + ); + }, + T, + ); +}); + +describe('hunt68: a second part with an ask of its own', () => { + it( + 'keeps its own ask, not the first part', + async () => { + const r = await top('make the text bigger on buttons and cards red'); + expect(r.all).toMatch(/--on button/); + expect(r.all).toMatch(/background '#dc2626' --on card/); + }, + T, + ); +}); + +describe('hunt68 F24e-21: a theme name near the typed one', () => { + it( + 'suggests the same question with the right name', + () => { + const r = spawnSync( + process.execPath, + [ + path.resolve(import.meta.dirname, '../../clients/cli/bin/astryx.mjs'), + 'experimental', + 'theme', + 'editor', + 'how', + 'buttons rounder', + '--theme', + 'brnad', + ], + {cwd, encoding: 'utf8'}, + ); + expect(r.stdout + r.stderr).toContain( + "how 'buttons rounder' --theme brand", + ); + }, + T, + ); +}); diff --git a/packages/cli/api/theme-edit/theme-hunt69.test.mjs b/packages/cli/api/theme-edit/theme-hunt69.test.mjs index bb1a6c4e3ff05..0d8764b49817c 100644 --- a/packages/cli/api/theme-edit/theme-hunt69.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt69.test.mjs @@ -195,9 +195,10 @@ describe('hunt69: how amounts', () => { ); expect( await how('no inner spacing on text-input when disabled', 's'), - ).toMatch(/padding 0px --on inputs --state disabled/); + ).toMatch(/padding 0px --on text-input --state disabled/); + // A group member named in words is that member (hunt68 F24e-18). expect(await how('more padding on text inputs on hover', 's')).toMatch( - /--on inputs --state hover/, + /--on text-input --state hover/, ); }, 240_000); From 9ebbee45502e9228a1bd6b4e3bb01cf5645d8870 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 04:36:34 -0800 Subject: [PATCH 03/21] experimental theme: hunt70 (the app copy is said behind by every build and check, links/folders/write failures stop it loudly, a lost descriptor is written back, compile finds the copy's source, lock and fonts, a refused compile writes no copy) (cherry picked from commit b94bbebef9f3754e9390b091f2afe44b47f875d4) --- .changeset/experimental-theme-hunt70.md | 7 + packages/cli/api/theme-edit/_adapter.mjs | 130 +++++++++- packages/cli/api/theme-edit/_edit.mjs | 2 + packages/cli/api/theme-edit/_preview.mjs | 86 +++++-- .../cli/api/theme-edit/theme-hunt70.test.mjs | 241 ++++++++++++++++++ .../experimental-theme-compile.doc.mjs | 2 +- .../experimental-theme-editor.doc.mjs | 2 +- .../cli/commands/experimental/theme.mjs | 2 + packages/cli/clients/cli/commands/theme.mjs | 18 ++ 9 files changed, 461 insertions(+), 29 deletions(-) create mode 100644 .changeset/experimental-theme-hunt70.md create mode 100644 packages/cli/api/theme-edit/theme-hunt70.test.mjs diff --git a/.changeset/experimental-theme-hunt70.md b/.changeset/experimental-theme-hunt70.md new file mode 100644 index 0000000000000..51becc432ac2c --- /dev/null +++ b/.changeset/experimental-theme-hunt70.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme`: every command that builds or checks an editor theme says when its app copy in `src/themes//` is behind: `compile` warns, `compile --check` fails while the copy is kept back or gone, and a `set` that changes nothing still carries `appCopyStale`. A link or a folder of yours at a copy file name stops the copy like a file of yours does, and a copy that cannot be written says why (not writable, disk full, a file where the folder goes). A copy whose descriptor was deleted is still the editor's and is written back. Compiling the copy's source takes the theme's lock and writes the copy again; with the copy gone it names `editor show`. Fonts the copy's font stylesheet loads are not called unloaded. The editor and compile help say the copy rule. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_adapter.mjs b/packages/cli/api/theme-edit/_adapter.mjs index 0e4bcb89d8e6f..44c022617d669 100644 --- a/packages/cli/api/theme-edit/_adapter.mjs +++ b/packages/cli/api/theme-edit/_adapter.mjs @@ -35,6 +35,7 @@ import {writeThemeTemplate} from '../theme/template/template.mjs'; import {buildSuggestion} from '../theme/build/build.mjs'; import {createHash} from 'node:crypto'; import * as fs from 'node:fs'; +import {createRequire} from 'node:module'; import * as path from 'node:path'; import * as edit from './_edit.mjs'; import {baseInstalled, themeCacheRoot} from './_project.mjs'; @@ -53,15 +54,18 @@ import {LIKE, P} from './_tables.mjs'; import {setLine} from './_check.mjs'; import { SHOWCASE_GROUPS, + appCopyStale, takeFirstAppCopy, writeAppCopy, writeAppCopyAt, } from './_preview.mjs'; import {humanWarn} from '../../foundation/response/json.mjs'; import { + camel, folderIsEmpty, loadTheme, setAfterBuild, + themeFiles, typedThemeName, } from './_settings.mjs'; @@ -674,8 +678,9 @@ function runLine(cwd, rest) { * @returns {AstryxError | null} */ export function editorThemeRefusal(err, cwd) { - // A refused compile ends here: its theme locks go now, not at exit. - void releaseCompileHold?.(); + // A refused compile ends here: its theme locks go now, not at exit, and + // it writes no app copy (from build files it refused). + void releaseCompileHold?.(true); if ( err.code !== ERROR_CODES.ERR_UNKNOWN_THEME && !/unknown theme|is not added|not a theme package/i.test(err.message) @@ -921,7 +926,7 @@ export async function editorCompileProblem( /** The editor themes a running `compile` holds the lock of, by project and name. */ const COMPILE_HELD = new Set(); -/** @type {(() => Promise) | null} The release of the running compile's hold. */ +/** @type {((refused?: boolean) => Promise) | null} The release of the running compile's hold; `refused`: it wrote nothing, so no app copy either. */ let releaseCompileHold = null; /** @@ -941,13 +946,18 @@ export async function editorThemeHold(files, {cwd}) { const at = editorThemeAt(cwdRoot, path.resolve(cwd, f)); if (at && editorOwnFile(at.slug, f)) found.set(`${at.root}\0${at.slug}`, at); + // The app copy's source (src/themes//Theme.ts) of an editor + // theme: built under the theme's lock, so it never lands between a + // change's two writes. + const copy = appCopyTheme(cwdRoot, path.resolve(cwd, f)); + if (copy) found.set(`${copy.root}\0${copy.slug}`, copy); } const themes = [...found.keys()].sort().map(k => found.get(k)); - /** @type {Array<() => Promise>} */ + /** @type {Array<(refused?: boolean) => Promise>} */ const releases = []; - const releaseAll = async () => { + const releaseAll = async (refused = false) => { if (releaseCompileHold === releaseAll) releaseCompileHold = null; - for (const r of releases.splice(0).reverse()) await r(); + for (const r of releases.splice(0).reverse()) await r(refused); }; releaseCompileHold = releaseAll; for (const { @@ -979,10 +989,13 @@ export async function editorThemeHold(files, {cwd}) { await releaseAll(); throw err; } - releases.push(async () => { + releases.push(async (refused = false) => { // A compile that wrote the build refreshes the app's copy too - // (src/themes//), under the same lock; a check writes nothing. - if (!process.argv.includes('--check')) { + // (src/themes//), under the same lock; a check, or a compile + // that was refused or failed, writes nothing. + if (refused || (process.exitCode ?? 0) !== 0) { + // Nothing built: the copy stays as it was. + } else if (!process.argv.includes('--check')) { try { // The same whole copy the editor's build writes, its font // stylesheets from the theme's settings. @@ -1008,6 +1021,24 @@ export async function editorThemeHold(files, {cwd}) { } catch { // A theme that does not load keeps its copy as it was. } + // A copy a file or a link keeps behind, or one that could not be + // written, is said, as set and show say it. + const stale = appCopyStale(root, slug).appCopyStale; + if (stale) humanWarn(stale); + } else { + // A check: a copy behind the theme (kept back, or gone) is not up + // to date. + const stale = appCopyStale(root, slug).appCopyStale; + const gone = !fs.existsSync( + path.join(root, 'src', 'themes', slug, `${camel(slug)}Theme.ts`), + ); + if (stale || gone) { + humanWarn( + stale ?? + `the app copy in src/themes/${slug}/ is gone, so it is not up to date; ${runLine(root, `experimental theme editor show ${slug}`)} writes it again`, + ); + process.exitCode = 1; + } } release(); await done; @@ -1016,6 +1047,79 @@ export async function editorThemeHold(files, {cwd}) { return releaseAll; } +/** + * The editor theme whose app copy source a file is + * (`src/themes//Theme.ts`, with themes// an editor theme), or + * null. + * @param {string} root the project root + * @param {string} file absolute + * @returns {{root: string, slug: string} | null} + */ +function appCopyTheme(root, file) { + const rel = path.relative(root, file).split(path.sep); + if (rel.length !== 4 || rel[0] !== 'src' || rel[1] !== 'themes') return null; + const slug = rel[2]; + if (rel[3] !== `${camel(slug)}Theme.ts`) return null; + return fs.existsSync(themeFiles(root, slug).settings) ? {root, slug} : null; +} + +/** + * For `compile` of an editor theme (themes// or its app copy's source): + * whether a font family is loaded by the app copy's font stylesheet + * (src/themes//.fonts.css: an @fontsource package by its name, a + * stylesheet by the `family=` it requests), so the build's "named but not + * loaded" notice leaves it out. Null for any other file. + * @param {string} file the path as given + * @param {string} cwd + * @returns {((family: string) => boolean) | null} + */ +export function editorLoadedFonts(file, cwd) { + const root = projectRoot(cwd); + const abs = path.resolve(cwd, file); + const slug = + appCopyTheme(root, abs)?.slug ?? + (editorThemeAt(root, abs) && + editorOwnFile(editorThemeAt(root, abs)?.slug ?? '', file) + ? editorThemeAt(root, abs)?.slug + : null); + if (!slug) return null; + /** @param {string} s */ + const key = s => + String(s) + .replace(/["']/g, '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + /** @type {Set} */ + const loaded = new Set(); + /** @type {string} */ + let text; + try { + text = fs.readFileSync( + path.join(root, 'src', 'themes', slug, `${slug}.fonts.css`), + 'utf8', + ); + } catch { + return null; + } + const req = createRequire(path.join(root, 'package.json')); + for (const m of text.matchAll(/@import\s+"([^"]+)"/g)) { + const spec = m[1]; + const pkg = /^@fontsource(?:-variable)?\/([^/]+)/.exec(spec); + if (pkg) { + loaded.add(key(pkg[1])); + continue; + } + try { + const css = fs.readFileSync(req.resolve(spec), 'utf8'); + for (const f of css.matchAll(/family=([^:&'")]+)/g)) + loaded.add(key(decodeURIComponent(f[1].replace(/\+/g, ' ')))); + } catch { + // A stylesheet that cannot be read loads nothing known here. + } + } + return family => loaded.has(key(family)); +} + /** * For `compile` of a file that is not there: when it is an editor theme's * own source (`themes//.theme.ts` beside its settings), the command @@ -1025,6 +1129,14 @@ export async function editorThemeHold(files, {cwd}) { * @returns {{name: string, reason: string} | null} */ export function editorMissingSource(file, cwd) { + // The app copy's source of an editor theme, its folder gone: the editor + // writes it again. + const copy = appCopyTheme(projectRoot(cwd), path.resolve(cwd, file)); + if (copy) + return { + name: runLine(cwd, `experimental theme editor show ${copy.slug}`), + reason: `the editor writes ${copy.slug}'s app copy in src/themes/${copy.slug}/ again`, + }; const at = editorThemeAt(projectRoot(cwd), path.resolve(cwd, file)); if (!at || path.basename(file) !== `${at.slug}.theme.ts`) return null; return { diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index c8787868c98ee..d707b26f7316c 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -2088,6 +2088,8 @@ async function changeOnce( notChecked: null, previewStale: [], see: seeLine(cwd, slug, changeFocus(s)), + // A copy something keeps behind is said here too. + ...appCopyStale(cwd, slug), } : await previewAndRoute(ctx, slug, data, png, changeFocus(s)); return { diff --git a/packages/cli/api/theme-edit/_preview.mjs b/packages/cli/api/theme-edit/_preview.mjs index a979a214de3a2..f685425553a30 100644 --- a/packages/cli/api/theme-edit/_preview.mjs +++ b/packages/cli/api/theme-edit/_preview.mjs @@ -1607,17 +1607,36 @@ export function writeAppCopyAt(cwd, slug, fontsCss) { ? `${head('/*', '*/')}${fonts.map(c => `@import ${JSON.stringify(c)};`).join('\n')}\n` : `${head('/*', '*/')}/* No font stylesheets: the theme uses fonts the system has. */\n`, }; - fs.mkdirSync(dir, {recursive: true}); - for (const [n, text] of Object.entries(files)) { - const file = path.join(dir, n); - if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === text) continue; - writeWhole(file, text); + const failKey = `${path.resolve(cwd)}\0${slug}`; + try { + fs.mkdirSync(dir, {recursive: true}); + for (const [n, text] of Object.entries(files)) { + const file = path.join(dir, n); + if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === text) + continue; + writeWhole(file, text); + } + // A copy's own file this build no longer writes goes; anything else in + // the folder (yours, or the system's) stays as it is. + for (const n of fs.readdirSync(dir)) + if (appCopyNames(slug).has(n) && !(n in files)) + fs.rmSync(path.join(dir, n), {force: true}); + } catch (e) { + // A copy that cannot be written is said, with the system's reason; + // the change itself stands. + const code = /** @type {any} */ (e)?.code ?? 'error'; + const rel = path.relative(cwd, dir).split(path.sep).join('/'); + COPY_FAILED.set( + failKey, + code === 'EACCES' || code === 'EPERM' || code === 'EROFS' + ? `${rel} is not writable (${code})` + : code === 'ENOSPC' + ? `the disk is full (${code})` + : `${rel} could not be written (${code})`, + ); + return null; } - // A copy's own file this build no longer writes goes; anything else in - // the folder (yours, or the system's) stays as it is. - for (const n of fs.readdirSync(dir)) - if (appCopyNames(slug).has(n) && !(n in files)) - fs.rmSync(path.join(dir, n), {force: true}); + COPY_FAILED.delete(failKey); // The first write is named in the command's result; later ones are not. if (first) FIRST_COPIES.set( @@ -1639,7 +1658,10 @@ export function writeAppCopyAt(cwd, slug, fontsCss) { * @returns {{appCopyStale?: string}} */ export function appCopyStale(cwd, slug) { - const blocker = appCopyBlocker(cwd, slug); + const blocker = + appCopyBlocker(cwd, slug) ?? + COPY_FAILED.get(`${path.resolve(cwd)}\0${slug}`) ?? + null; return blocker ? { appCopyStale: `the app copy in src/themes/${slug}/ was not updated: ${blocker}. The app keeps the theme as it was until that is out of the way; then any set or show writes it.`, @@ -1683,19 +1705,31 @@ export function appCopyBlocker(cwd, slug) { // (src/, src/themes/, the copy's folder), as for themes/. for (const part of ['src', 'src/themes', rel]) { try { - if (fs.lstatSync(path.join(cwd, part)).isSymbolicLink()) + const st = fs.lstatSync(path.join(cwd, part)); + if (st.isSymbolicLink()) return `${part} is a link (to ${fs.readlinkSync(path.join(cwd, part))}), and the editor writes nothing through a link`; + // A file where a folder on the way goes: nothing is half-written. + if (!st.isDirectory()) + return part === rel + ? `${rel} is a file, where the copy's folder goes` + : `${part} is a file, where a folder on the way to the copy goes`; } catch { break; } } + try { + if (!fs.lstatSync(dir).isDirectory()) + return `${rel} is a file, where the copy's folder goes`; + } catch { + // Not there yet: the copy makes it. + } if (folderIsEmpty(dir)) return null; /** @type {string[]} */ let names; try { names = fs.readdirSync(dir); - } catch { - return `${rel} cannot be read`; + } catch (e) { + return `${rel} cannot be read (${/** @type {any} */ (e)?.code ?? 'error'})`; } /** @param {string} n */ const marked = n => { @@ -1707,11 +1741,24 @@ export function appCopyBlocker(cwd, slug) { }; const own = appCopyNames(slug); const present = names.filter(n => own.has(n)); - const ours = marked(`${name}.doc.mjs`); + // A link at a copy name is never written through or replaced. + for (const n of present) + try { + const st = fs.lstatSync(path.join(dir, n)); + if (st.isSymbolicLink()) + return `${rel}/${n} is a link (to ${fs.readlinkSync(path.join(dir, n))}), at a name the copy uses, and the editor writes nothing through a link`; + if (st.isDirectory()) + return `${rel}/${n} is a folder, at a name the copy uses for a file`; + } catch { + // Gone since it was listed. + } + // The copy is the editor's when any of its marked files carries the + // mark (one of them deleted is written back); then only a marked name + // without the mark is yours. + const markedNames = [`${name}.doc.mjs`, `${name}.ts`, `${slug}.fonts.css`]; + const ours = markedNames.some(n => present.includes(n) && marked(n)); const foreign = ours - ? present.find( - n => (n === `${name}.ts` || n === `${slug}.fonts.css`) && !marked(n), - ) + ? present.find(n => markedNames.includes(n) && !marked(n)) : present[0]; return foreign ? `${rel}/${foreign} is yours, at a name the copy uses, and the editor never writes over your files` @@ -1721,6 +1768,9 @@ export function appCopyBlocker(cwd, slug) { /** The build outputs of a theme, by their ending after ``. */ const APP_COPY_BUILT = ['.css', '.css.d.ts', '.js', '.d.ts', '.variants.d.ts']; +/** Why the last write of a theme's app copy failed, by app and theme. @type {Map} */ +const COPY_FAILED = new Map(); + /** App copies written for the first time in this process, by app and theme. @type {Map} */ const FIRST_COPIES = new Map(); diff --git a/packages/cli/api/theme-edit/theme-hunt70.test.mjs b/packages/cli/api/theme-edit/theme-hunt70.test.mjs new file mode 100644 index 0000000000000..60d9445b777bc --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt70.test.mjs @@ -0,0 +1,241 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The app copy's follow-through: every command that builds or checks + * an editor theme says when its copy is behind, a link or folder at a copy + * name stops it, a failed write is said, a missing descriptor is written + * back, compile finds the copy's source and its fonts, and the help says so. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; +import {themeEditNew, themeEditSet} from './theme-edit.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import { + FIXTURE_PACKAGES, + writeFixtureBaseTheme, +} from '../../test-utils/base-theme-fixture.mjs'; + +/** @type {string} */ +let cwd; +const BIN = path.resolve( + import.meta.dirname, + '../../clients/cli/bin/astryx.mjs', +); + +beforeAll(() => { + cwd = makeLinkedFixture('astryx-theme-hunt70-', FIXTURE_PACKAGES); + writeFixtureBaseTheme(cwd, 'neutral'); +}, 120_000); + +afterAll(() => { + removeLinkedFixture(cwd); +}); + +/** @param {string} rel */ +const read = rel => fs.readFileSync(path.join(cwd, rel), 'utf8'); +/** @param {string[]} args */ +const cli = args => + spawnSync(process.execPath, [BIN, ...args], {cwd, encoding: 'utf8'}); +/** @param {any} r */ +const stale = r => /** @type {any} */ (r.data).appCopyStale; + +describe('hunt70: every build and check says the copy is behind', () => { + it('compile, compile --check and a no-op set name a file in the way', async () => { + await themeEditNew('n1', {cwd}); + await themeEditSet('n1', 'corners', 'pill', {cwd}); + fs.writeFileSync(path.join(cwd, 'src/themes/n1/n1Theme.ts'), '// mine\n'); + const compiled = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/n1/n1.theme.ts', + ]); + expect(compiled.stdout + compiled.stderr).toContain( + 'the app copy in src/themes/n1/ was not updated: src/themes/n1/n1Theme.ts is yours', + ); + const check = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/n1/n1.theme.ts', + '--check', + ]); + expect(check.status).toBe(1); + expect(check.stdout + check.stderr).toContain('was not updated'); + const same = await themeEditSet('n1', 'corners', 'pill', {cwd}); + expect(stale(same)).toContain('n1Theme.ts is yours'); + }, 240_000); + + it('compile --check fails when the copy is gone, and compile of its source names the editor', async () => { + await themeEditNew('n9', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/n9'), {recursive: true}); + const check = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/n9/n9.theme.ts', + '--check', + ]); + expect(check.status).toBe(1); + expect(check.stdout + check.stderr).toContain( + 'the app copy in src/themes/n9/ is gone', + ); + const r = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'src/themes/n9/n9Theme.ts', + ]); + expect(r.stdout).toContain('experimental theme editor show n9'); + expect(r.stdout).not.toContain('theme template'); + }, 240_000); +}); + +describe('hunt70: what stops the copy, said', () => { + it('a link of yours at a copy name stops it, and stays a link', async () => { + await themeEditNew('n3', {cwd}); + const shared = path.join(cwd, 'shared-n3.css'); + fs.writeFileSync(shared, '/* mine */\n'); + fs.rmSync(path.join(cwd, 'src/themes/n3/n3.css')); + fs.symlinkSync(shared, path.join(cwd, 'src/themes/n3/n3.css')); + const r = await themeEditSet('n3', 'corners', 'pill', {cwd}); + expect(stale(r)).toContain('src/themes/n3/n3.css is a link'); + expect( + fs.lstatSync(path.join(cwd, 'src/themes/n3/n3.css')).isSymbolicLink(), + ).toBe(true); + expect(fs.readFileSync(shared, 'utf8')).toBe('/* mine */\n'); + }, 240_000); + + it('a folder at a copy file name, a file where the folder goes, and a folder that cannot be written are said', async () => { + await themeEditNew('n5', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/n5/n5.js')); + fs.mkdirSync(path.join(cwd, 'src/themes/n5/n5.js')); + const folder = await themeEditSet('n5', 'corners', 'pill', {cwd}); + expect(stale(folder)).toContain( + 'src/themes/n5/n5.js is a folder, at a name the copy uses for a file', + ); + await themeEditNew('n5f', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/n5f'), {recursive: true}); + fs.writeFileSync(path.join(cwd, 'src/themes/n5f'), 'x'); + const file = await themeEditSet('n5f', 'corners', 'pill', {cwd}); + expect(stale(file)).toContain( + "src/themes/n5f is a file, where the copy's folder goes", + ); + if (process.getuid?.() !== 0) { + await themeEditNew('n5w', {cwd}); + const dir = path.join(cwd, 'src/themes/n5w'); + fs.chmodSync(dir, 0o555); + try { + const ro = await themeEditSet('n5w', 'corners', 'pill', {cwd}); + expect(stale(ro)).toContain('src/themes/n5w is not writable'); + } finally { + fs.chmodSync(dir, 0o755); + } + } + }, 240_000); + + it('a missing descriptor leaves the copy the editor own, and is written back', async () => { + await themeEditNew('n6', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/n6/n6Theme.doc.mjs')); + const before = read('src/themes/n6/n6.css'); + const r = await themeEditSet('n6', 'corners', 'pill', {cwd}); + expect(stale(r)).toBeUndefined(); + expect(fs.existsSync(path.join(cwd, 'src/themes/n6/n6Theme.doc.mjs'))).toBe( + true, + ); + expect(read('src/themes/n6/n6.css')).not.toBe(before); + }, 240_000); +}); + +describe('hunt70: compile of the copy, its lock and its fonts', () => { + it('compiling the copy source writes the copy again from themes/', async () => { + await themeEditNew('n4', {cwd}); + await themeEditSet('n4', 'corners', 'pill', {cwd}); + fs.writeFileSync(path.join(cwd, 'src/themes/n4/n4.css'), '/* old */\n'); + const r = cli([ + 'experimental', + 'theme', + 'compile', + 'src/themes/n4/n4Theme.ts', + ]); + expect(r.status).toBe(0); + expect(read('src/themes/n4/n4.css')).not.toBe('/* old */\n'); + }, 240_000); + + it('a font the copy font stylesheet loads is not named as not loaded', async () => { + await themeEditNew('n2', {cwd}); + await themeEditSet('n2', 'font', 'Inter', {cwd}); + const sheet = path.join(cwd, 'src/themes/n2/n2.fonts.css'); + const head = read('src/themes/n2/n2.fonts.css').split('\n')[0]; + fs.writeFileSync( + sheet, + `${head}\n@import "@fontsource/inter/index.css";\n`, + ); + const r = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/n2/n2.theme.ts', + ]); + expect(r.stdout + r.stderr).not.toMatch(/not loaded: [^\n]*Inter/); + }, 240_000); +}); + +describe('hunt70: never half-written, never from a refused build', () => { + it('src/themes as a file stops the copy, said, with nothing written', async () => { + await themeEditNew('r26', {cwd}); + const keep = path.join(cwd, 'themes-keep'); + fs.renameSync(path.join(cwd, 'src/themes'), keep); + fs.writeFileSync(path.join(cwd, 'src/themes'), 'x'); + try { + const r = await themeEditSet('r26', 'corners', 'pill', {cwd}); + expect(stale(r)).toContain( + 'src/themes is a file, where a folder on the way to the copy goes', + ); + expect(fs.readFileSync(path.join(cwd, 'src/themes'), 'utf8')).toBe('x'); + } finally { + fs.rmSync(path.join(cwd, 'src/themes')); + fs.renameSync(keep, path.join(cwd, 'src/themes')); + } + }, 240_000); + + it('a refused compile writes no app copy', async () => { + await themeEditNew('r26f', {cwd}); + const copy = read('src/themes/r26f/r26f.css'); + // A damaged build file the compile refuses to replace. + fs.writeFileSync(path.join(cwd, 'themes/r26f/r26f.css'), '/* hand */\n'); + fs.writeFileSync( + path.join(cwd, 'themes/r26f/r26f.theme.ts'), + 'not a theme(\n', + ); + const r = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/r26f/r26f.theme.ts', + ]); + expect(r.status).not.toBe(0); + expect(read('src/themes/r26f/r26f.css')).toBe(copy); + }, 240_000); +}); + +describe('hunt70: the help says the copy rule', () => { + it('editor and compile help', async () => { + const editor = ( + await import('../../clients/cli/commands/experimental-theme-editor.doc.mjs') + ).doc; + expect(JSON.stringify(editor)).not.toContain('is left alone'); + expect(JSON.stringify(editor)).toContain('appCopyStale'); + const compile = ( + await import('../../clients/cli/commands/experimental-theme-compile.doc.mjs') + ).doc; + expect(compile.description).toContain('src/themes// again'); + }); +}); diff --git a/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs b/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs index 6823c99121497..f6e152aa8c964 100644 --- a/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs +++ b/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs @@ -14,7 +14,7 @@ export const doc = { stability: 'experimental', summary: 'Compile standalone themes or one keyed theme family', description: - 'Compiles defineTheme() sources through the same theme pipeline into scoped CSS, standard ESM, and type declarations. Ordinary mode writes one complete set per theme. --family combines one selected extension tree into the three files named by the required --family-key, ready for one CSS load and identity-only switching. With --check it writes nothing and reports source drift. --icons-specifier keeps its existing generated-module behavior. It prints one line per built theme and one line naming fonts the themes do not load; --detail full adds the install example and the font recipe, once for a batch.', + "Compiles defineTheme() sources through the same theme pipeline into scoped CSS, standard ESM, and type declarations. Compiling a theme made by experimental theme editor (themes//.theme.ts, or its app copy src/themes//Theme.ts) takes that theme's lock and also writes its app copy in src/themes// again; --check fails while that copy is behind or gone. Ordinary mode writes one complete set per theme. --family combines one selected extension tree into the three files named by the required --family-key, ready for one CSS load and identity-only switching. With --check it writes nothing and reports source drift. --icons-specifier keeps its existing generated-module behavior. It prints one line per built theme and one line naming fonts the themes do not load; --detail full adds the install example and the font recipe, once for a batch.", fn: 'themeBuild', args: [ { diff --git a/packages/cli/clients/cli/commands/experimental-theme-editor.doc.mjs b/packages/cli/clients/cli/commands/experimental-theme-editor.doc.mjs index 77a933fa7ca98..59266811fa252 100644 --- a/packages/cli/clients/cli/commands/experimental-theme-editor.doc.mjs +++ b/packages/cli/clients/cli/commands/experimental-theme-editor.doc.mjs @@ -15,7 +15,7 @@ export const doc = { summary: 'Make and change a theme one checked change at a time (dials, tokens, part styles), with previews', description: - "A type-safe compiler for themes. A theme lives in themes//: .settings.json holds the checked settings and is owned by this command; .settings.ts is generated from it on every change; .theme.ts extends it, is written once and is yours to edit; .css and .js are built from it; build them again with `astryx experimental theme compile themes//.theme.ts`, which checks the editor's settings first. Every build also writes the theme's app copy, src/themes//: a standard local theme (descriptor, source, built module and stylesheets), never edited by hand, so `astryx theme add ` puts the theme into the app and `theme use`, `remove` and `list` take it as they take any local theme. A folder there that is not the editor's copy is left alone, and `new` and `show` then print the import lines by hand instead (`wiring`). Every change is CHECKED against what the installed Core's components really read (derived from Core itself, version for version, and cached under node_modules/.cache/) and against the token's type; a change that cannot take effect is refused, nothing is written, and the error leads with the command that works. The thing you set is a dial (a big move with named rungs: corners, spacing, font, headings, text-size, base, mode; or a color: accent, neutral, success, warning, danger, info, each generating its light and dark shades from one color for the color tokens, and vibrancy, how saturated they are), a design token by name, or a CSS property on a part. Where it applies: --on , --when :, --state , --mode light|dark, --only. Every accepted change rebuilds and writes preview.html (the sample app page); `show` adds the theme-building page with every component. PNG pictures only with --png. Commands on one theme run one after the other, through a lock in node_modules/.cache/astryx-theme/ of the project; when node_modules cannot be written (a read-only install), changes are refused and `show` still reads the last build. `how` answers everything else: what a part, group, setting or aspect does, how the colors are made, plain-words questions (with exact commands), a picture's colors, and lists of what you can set. Experimental: it may change or be removed in a patch release.", + "A type-safe compiler for themes. A theme lives in themes//: .settings.json holds the checked settings and is owned by this command; .settings.ts is generated from it on every change; .theme.ts extends it, is written once and is yours to edit; .css and .js are built from it; build them again with `astryx experimental theme compile themes//.theme.ts`, which checks the editor's settings first. Every build also writes the theme's app copy, src/themes//: a standard local theme (descriptor, source, built module and stylesheets), never edited by hand, so `astryx theme add ` puts the theme into the app and `theme use`, `remove` and `list` take it as they take any local theme. Your own files in that folder are left as they are and the copy is written around them; a file, folder or link of yours at one of the copy's names, a link on the way to it, or a folder that cannot be written stops it, and every set, undo, show and compile says so (`appCopyStale`), with `compile --check` failing while the copy is behind or gone. Every change is CHECKED against what the installed Core's components really read (derived from Core itself, version for version, and cached under node_modules/.cache/) and against the token's type; a change that cannot take effect is refused, nothing is written, and the error leads with the command that works. The thing you set is a dial (a big move with named rungs: corners, spacing, font, headings, text-size, base, mode; or a color: accent, neutral, success, warning, danger, info, each generating its light and dark shades from one color for the color tokens, and vibrancy, how saturated they are), a design token by name, or a CSS property on a part. Where it applies: --on , --when :, --state , --mode light|dark, --only. Every accepted change rebuilds and writes preview.html (the sample app page); `show` adds the theme-building page with every component. PNG pictures only with --png. Commands on one theme run one after the other, through a lock in node_modules/.cache/astryx-theme/ of the project; when node_modules cannot be written (a read-only install), changes are refused and `show` still reads the last build. `how` answers everything else: what a part, group, setting or aspect does, how the colors are made, plain-words questions (with exact commands), a picture's colors, and lists of what you can set. Experimental: it may change or be removed in a patch release.", subcommands: ['new', 'set', 'undo', 'show', 'how'], examples: [ { diff --git a/packages/cli/clients/cli/commands/experimental/theme.mjs b/packages/cli/clients/cli/commands/experimental/theme.mjs index 2844828cb15db..ad2f8e53f9d88 100644 --- a/packages/cli/clients/cli/commands/experimental/theme.mjs +++ b/packages/cli/clients/cli/commands/experimental/theme.mjs @@ -49,6 +49,7 @@ import { dialValueFits, newFlagList, editorCompileProblem, + editorLoadedFonts, editorThemeHold, editorThemeRefusal, editorMissingSource, @@ -1306,6 +1307,7 @@ export function registerExperimentalTheme(group, program) { missingSource: editorMissingSource, template: editorThemeTemplate, hold: editorThemeHold, + loadedFonts: editorLoadedFonts, }, ); } diff --git a/packages/cli/clients/cli/commands/theme.mjs b/packages/cli/clients/cli/commands/theme.mjs index c1f97c1d0986a..0f5d0d4578fc8 100644 --- a/packages/cli/clients/cli/commands/theme.mjs +++ b/packages/cli/clients/cli/commands/theme.mjs @@ -120,6 +120,7 @@ import {doc as themePaletteGenerateFn} from '../../../api/theme/themePaletteGene * @property {(err: import('../../../api/error.mjs').AstryxError, cwd: string) => import('../../../api/error.mjs').AstryxError | null} [explain] - A clearer refusal for this group, or null. * @property {(files: string[], options: {cwd: string, check?: boolean, family?: boolean, iconsSpecifier?: string, out?: string}) => Promise} [preflight] - A refusal before a build starts, or null. * @property {(files: string[], options: {cwd: string}) => Promise<() => Promise>} [hold] - Locks held while a build or check runs; resolves to their release. + * @property {(file: string, cwd: string) => ((family: string) => boolean) | null} [loadedFonts] - Font families the app loads for a theme file that the file itself cannot say, so the build does not name them as unloaded. * @property {(file: string, cwd: string) => {name: string, reason: string} | null} [missingSource] - For a source file that is not there, this group's way to write it again, or null. * @property {(options: {targetPath?: string, overwrite?: boolean, cwd?: string}) => import('../../../api/theme/theme.type.mjs').ThemeTemplateResponse} [template] - This group's template write, in place of `themeTemplate`. */ @@ -1338,6 +1339,23 @@ function registerThemeCommands(theme, program, docs, words) { handOff, ); result = built.receipt; + // A group may know fonts the app loads that the theme file + // cannot say (the editor's app copy stylesheet): those are not + // named as unloaded. + const loads = words.loadedFonts?.(entry.file, process.cwd()); + if (loads && built.trailer) { + const named = (/** @type {string} */ msg) => + built.trailer?.unloadedFonts.some( + (/** @type {string} */ f) => + loads(f) && msg.includes(`"${f}"`), + ) ?? false; + built.trailer.fontNotices = built.trailer.fontNotices.filter( + (/** @type {string} */ m) => !named(m), + ); + built.trailer.unloadedFonts = built.trailer.unloadedFonts.filter( + (/** @type {string} */ f) => !loads(f), + ); + } if (built.trailer) trailers.push(built.trailer); if (compact && result?.type === 'theme.build') { const {outputs, sizeKB, tokenCount, componentCount} = result.data; From e805d4d5b98da3e0f349029ae8762dccdbe72a38 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 04:37:33 -0800 Subject: [PATCH 04/21] experimental theme: hunt71 (path and camelCase names reach the theme meant, nearest by spelling first, refusals name a command that works, desktop.ini is OS junk, undo names the , a stop after the save says so) (cherry picked from commit fc08375a624c86bd8a34ec7553c3dd0e43b60f8c) --- .changeset/experimental-theme-hunt71.md | 13 ++ packages/cli/api/theme-edit/_edit.mjs | 101 ++++++++-- packages/cli/api/theme-edit/_settings.mjs | 63 ++++-- .../cli/api/theme-edit/theme-hunt71.test.mjs | 180 ++++++++++++++++++ .../experimental-theme-editor-undo.doc.mjs | 2 +- 5 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 .changeset/experimental-theme-hunt71.md create mode 100644 packages/cli/api/theme-edit/theme-hunt71.test.mjs diff --git a/.changeset/experimental-theme-hunt71.md b/.changeset/experimental-theme-hunt71.md new file mode 100644 index 0000000000000..12edb654d0ce1 --- /dev/null +++ b/.changeset/experimental-theme-hunt71.md @@ -0,0 +1,13 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor`: names, damaged themes and stops. + +- A theme name typed as a path (`themes/brand`, `./t`, `t/`) names the theme it ends in, and says when that theme is here already; a typo of a theme name offers the nearest theme by spelling first (`brand-drak` is `brand-dark`, not `brand`), and a camelCase name is the dashed one (`brandDark` is `brand-dark`, `themePark` is `theme-park`), never a new theme. +- A refusal names a command that works: `undo` on a theme whose settings cannot be read and that has no history names `new `, not `show`; a damaged mode or base no longer sends each fix to the other (changing or removing a setting puts the field back to its default and says so); a settings entry with no thing or no source is damage `new` keeps aside, as the refusal says. +- A folder holding only `desktop.ini` is empty, like one holding only `Thumbs.db` or `.DS_Store`. +- An `undo` that switches the theme between one mode and both names the app's `` to add or take out, as `set mode` does; `undo --help` says `--mode` removes a setting saved for one mode in a one-mode theme. +- Stopped (Ctrl-C) while the checks and pictures run after a change, the editor says the change is saved and built and that `show` runs the rest. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index d707b26f7316c..7dbae0b8b739a 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -67,8 +67,9 @@ import { NEW_MARKER, clearNewMarker, refuseThemeSource, - settingsShapeOk, + settingsEntriesOk, slugFor, + typedThemeName, themeSource, themeSourceProblem, refuseThemeParse, @@ -212,7 +213,9 @@ function editorLeftovers(dir, slug, damaged = false) { /** Whether a settings file parses, in the shape the editor reads. @param {string} file */ function readableJson(file) { try { - return settingsShapeOk( + // An entry with no thing or no source is damage too: `new` keeps the + // file aside, as the refusal for it says. + return settingsEntriesOk( JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')), ); } catch { @@ -349,9 +352,13 @@ export async function themeEditNew(name, options = {}) { /** @type {string | null | undefined} */ likeValue = likeFixed, ) => `${P} new ${newName}${newFlags(likeValue, fitSettings, png)}`; if (!slugOk(name)) { - const fixed = slugFor(name); - // Only a name typed in another case is the theme here already. - const sameName = String(name ?? '').toLowerCase() === fixed; + // A path typed for the name (themes/brand, ./t, t/) names the theme the + // path ends in. + const typed = String(name ?? ''); + const path_ = /[\\/]/.test(typed); + const fixed = slugFor(path_ ? typedThemeName(typed) : typed); + // A name typed in another case, or as a path, is the theme here already. + const sameName = path_ || typed.toLowerCase() === fixed; const exists = sameName && fs.existsSync(themeFiles(cwd, fixed).settings); refuse( E.ERR_INVALID_ARGUMENT, @@ -836,6 +843,23 @@ function changeFocus(s) { return 'overview'; } +/** + * A signal handler for the checks and pictures after a change is saved: it + * says the change is saved and how to run what did not, then lets the + * signal end the process (the render's own handler re-raises it; with none, + * this one does). + * @param {string} slug + * @returns {(sig: NodeJS.Signals) => void} + */ +export function stoppedAfterSave(slug) { + return sig => { + process.stderr.write( + `Stopped: the change to ${slug} is saved and built (and its undo step kept); its checks and pictures did not run. ${P} show ${slug} runs them.\n`, + ); + if (process.listenerCount(sig) === 0) process.kill(process.pid, sig); + }; +} + /** * The preview after a change, with the in-app preview route kept in step: * its imports follow the theme, such as the stylesheet of a font package @@ -848,7 +872,19 @@ function changeFocus(s) { * most about the change: Colors for a color, Components for a part */ async function previewAndRoute(ctx, slug, data, png, focus = 'overview') { - const p = await previewAfter(ctx, slug, data, png); + // Stopped (Ctrl-C) while the checks and pictures run: the change is saved + // and built already, which one line says before the process ends. + const stopped = stoppedAfterSave(slug); + process.once('SIGINT', stopped); + process.once('SIGTERM', stopped); + /** @type {Awaited>} */ + let p; + try { + p = await previewAfter(ctx, slug, data, png); + } finally { + process.removeListener('SIGINT', stopped); + process.removeListener('SIGTERM', stopped); + } const wrote = takeFirstAppCopy(ctx.project.cwd, slug); return { ...p, @@ -892,6 +928,33 @@ async function keepPreviewRoute(ctx, slug) { } } +/** + * What a change of mode asks of the app's wiring: a one-mode theme is + * given its mode by the app's , and one back to both modes no longer. + * @param {string} slug + * @param {string | undefined} before + * @param {string | undefined} after + * @returns {string[]} + */ +function modeWiringNotes(slug, before, after) { + const one = (/** @type {string | undefined} */ m) => + m === 'light-only' ? 'light' : m === 'dark-only' ? 'dark' : null; + const [was, now] = [one(before), one(after)]; + if (was === now) return []; + return [ + ...(was + ? [ + `if the app's was given mode="${was}" for ${slug}, take it out${now ? ` (or make it mode="${now}")` : ''}: with it the app stays ${was}, whatever the system asks for`, + ] + : []), + ...(now && !was + ? [ + `the app shows ${slug} in ${now} mode when its is given mode="${now}" (), as the preview does; the theme keeps both halves of its colors, so a toast or a lightbox Core draws in the other mode stays readable`, + ] + : []), + ]; +} + /** * Change one thing in a theme: checked, built, previewed. * @experimental @@ -1160,9 +1223,10 @@ async function changeOnce( const data = loadTheme(cwd, slug, target); /** @type {string[]} */ const fieldNotes = []; - if (sub === 'set' && (thing === 'base' || thing === 'mode')) { - // The other field, when it is damaged too, goes back to its default, - // so the theme loads again. + { + // A damaged field the command does not set goes back to its default, + // so the theme loads again (a change or removal of any setting repairs + // it on the way). if ( thing !== 'mode' && !['both', 'light-only', 'dark-only'].includes(data.mode) @@ -1820,6 +1884,7 @@ async function changeOnce( : null; /** @type {string[]} */ const notes = [ + ...fieldNotes, ...(covered.length ? [`replaces ${covered.map(describe).join(', ')}`] : []), ...(ownPartHits.length ? [ @@ -3189,10 +3254,18 @@ export async function themeEditUndo(theme, options = {}) { // A link in the theme is refused as set refuses it, never read as an // empty history. if (slugOk(theme)) refuseLinks(cwd, theme); + /** The command that works when the settings cannot be read. @type {string[] | null} */ + let repair = null; try { current = loadTheme(cwd, theme); } catch (e) { if (!(e instanceof AstryxError && e.code === E.ERR_THEME_INVALID)) throw e; + // What the refusal itself names (make the theme again, keeping the + // damaged file), never `show`, which fails the same way. + repair = (e.suggestions ?? []) + .map(x => x.name) + .filter(n => !/ show /.test(` ${n} `) && !/ undo /.test(` ${n} `)); + if (!repair.length) repair = [`${P} new ${theme}`]; } const f = themeFiles(cwd, theme); const {steps: history, dropped} = readHistory(f.history); @@ -3202,14 +3275,14 @@ export async function themeEditUndo(theme, options = {}) { `${theme}.history.json is damaged, so there is no step to go back to.`, { fix: `move ${shown(cwd, f.history)} out of the theme folder (it is kept as it is): the next change starts a new history, and your settings stay as they are`, - run: [`${P} show ${theme}`], + run: repair ?? [`${P} show ${theme}`], }, ); if (!history.length) refuse( E.ERR_THEME_NOTHING_TO_UNDO, - `Nothing to undo: ${theme} has no earlier change.`, - {run: [`${P} show ${theme}`]}, + `Nothing to undo: ${theme} has no earlier change${repair ? `, and ${theme}.settings.json cannot be read` : ''}.`, + {run: repair ?? [`${P} show ${theme}`]}, ); // A top step equal to the settings now (left by an undo stopped between // its two writes) changes nothing: skip it. @@ -3339,8 +3412,10 @@ export async function themeEditUndo(theme, options = {}) { undone.push( `base ${current.like ?? 'none'} is back to ${prev.like ?? 'none'}`, ); - if (current && current.mode !== prev.mode) + if (current && current.mode !== prev.mode) { undone.push(`mode ${current.mode} is back to ${prev.mode}`); + undone.push(...modeWiringNotes(theme, current.mode, prev.mode)); + } // The page most about what the step reverted: Colors for a color, // Components for a part setting, else Overview. const touched = [ diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index b332d876033f3..f98f52098ac47 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -198,7 +198,12 @@ export function themeFiles(cwd, slug) { } /** Files a system or a tool puts in any folder; a folder of only these is empty. */ -export const FOLDER_JUNK = new Set(['.DS_Store', '.gitkeep', 'Thumbs.db']); +export const FOLDER_JUNK = new Set([ + '.DS_Store', + '.gitkeep', + 'Thumbs.db', + 'desktop.ini', +]); /** * Whether `dir` holds nothing of anyone's: absent, empty, or only system @@ -496,16 +501,19 @@ export const shown = (cwd, f) => * @param {number} [n] */ export function nearest(x, list, n = 3) { + const limit = Math.max(2, Math.floor(x.length / 3)); + // Nearest by edit distance first (brand-drak is brand-dark, not brand); a + // name one holds inside the other is near too, after the close ones. return [...list] .filter(c => c.length > 2) - .map( - c => - /** @type {[string, number]} */ ([ - c, - c.includes(x) || x.includes(c) ? 1 : levenshteinDistance(x, c), - ]), - ) - .filter(([, s]) => s <= Math.max(2, Math.floor(x.length / 3))) + .map(c => { + const d = levenshteinDistance(x, c); + return /** @type {[string, number]} */ ([ + c, + d <= limit ? d : c.includes(x) || x.includes(c) ? limit + 0.5 : d, + ]); + }) + .filter(([, s]) => s <= limit + 0.5) .sort((a, b) => a[1] - b[1]) .slice(0, n) .map(([c]) => c); @@ -616,9 +624,16 @@ export function loadTheme( ); const plain = typedThemeName(slug); const named = slugFor(plain); + // brandDark is brand-dark. + const camelWords = slugFor( + unlabeledThemeName(String(slug ?? '')).replace( + /([a-z0-9])([A-Z])/g, + '$1-$2', + ), + ); const exists = (/** @type {string} */ n) => Boolean(n) && fs.existsSync(themeFiles(cwd, n).settings); - const fixed = !exists(named) && exists(whole) ? whole : named; + const fixed = [named, whole, camelWords].find(exists) ?? named; const same = fs.existsSync(themeFiles(cwd, fixed).settings) ? fixed : null; // A list where one theme goes (t,u or "t;u"): one theme at a time, // each that exists named; never a new theme made of the list. A name @@ -988,9 +1003,11 @@ export function settingsProblem( ) { if (!data || typeof data !== 'object' || Array.isArray(data)) return 'the file is not a settings object'; - // `set mode` and `set base` fix their own field. + // A command that changes or removes a setting (or sets mode or base) + // puts a damaged mode or base back to its default itself, with a note, so + // two damaged fields never send each fix to the other. const fixing = typeof skip === 'string' ? skip : skip?.thing; - const field = fixing === 'mode' || fixing === 'base'; + const field = Boolean(fixing); if (!field && !['both', 'light-only', 'dark-only'].includes(data.mode)) return data.mode == null ? 'it has no mode' @@ -4627,8 +4644,26 @@ function parseSettings(text) { } /** - * Whether parsed settings have the shape the editor reads: an object with a - * list of settings. + * Whether every settings entry has the parts the editor reads (a thing, a + * value and a source); one without is damage `new` keeps aside. + * @param {any} data + */ +export function settingsEntriesOk(data) { + return ( + settingsShapeOk(data) && + data.settings.every( + (/** @type {any} */ s) => + s && + typeof s.thing === 'string' && + 'value' in s && + ['dial', 'token', 'property'].includes(s.source), + ) + ); +} + +/** + * Whether settings data has the shape the editor reads: an object with a + * settings list. * @param {any} data */ export function settingsShapeOk(data) { diff --git a/packages/cli/api/theme-edit/theme-hunt71.test.mjs b/packages/cli/api/theme-edit/theme-hunt71.test.mjs new file mode 100644 index 0000000000000..6ee59529122d1 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt71.test.mjs @@ -0,0 +1,180 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The seventy-first pass over `astryx experimental theme editor`: + * names typed as paths or in camelCase reach the theme meant, the nearest + * theme by edit distance comes first, a refusal never points at a command + * that fails the same way, OS files leave a folder empty, a mode change by + * undo names the app's , and a stop after the save says so. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import {themeEditNew, themeEditSet, themeEditUndo} from './theme-edit.mjs'; +import {stoppedAfterSave} from './_edit.mjs'; +import {doc as undoDoc} from '../../clients/cli/commands/experimental-theme-editor-undo.doc.mjs'; +import {refusal} from '../../test-utils/theme-edit-fixture.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import {FIXTURE_PACKAGES} from '../../test-utils/base-theme-fixture.mjs'; + +vi.mock('./_preview.mjs', async importOriginal => + (await import('../../test-utils/theme-render-stub.mjs')).stubRenders( + await importOriginal(), + ), +); + +/** @type {string} */ +let cwd; + +beforeAll(() => { + cwd = makeLinkedFixture('astryx-theme-hunt71-', FIXTURE_PACKAGES); +}); + +afterAll(() => { + if (cwd) removeLinkedFixture(cwd); +}); + +describe('hunt71', () => { + it('E3: a path typed for a new name names the theme it ends in', async () => { + const fresh = await refusal(() => themeEditNew('themes/brand', {cwd})); + expect(fresh.run[0]).toMatch(/ new brand$/); + await themeEditNew('t', {cwd}); + for (const name of ['./t', 't/', 'themes/t']) { + const r = await refusal(() => themeEditNew(name, {cwd})); + expect(r.message, name).toContain('t is a theme here already'); + expect(r.run[0], name).toMatch(/ show t$/); + } + }, 240_000); + + it('E4: undo with unreadable settings and no history names a command that works', async () => { + await themeEditNew('d4', {cwd}); + fs.writeFileSync(path.join(cwd, 'themes/d4/d4.settings.json'), ''); + fs.rmSync(path.join(cwd, 'themes/d4/d4.history.json'), {force: true}); + const r = await refusal(() => themeEditUndo('d4', {cwd})); + expect(r.run.join(' ')).not.toMatch(/ show d4/); + expect(r.run.join(' ')).toMatch(/ new d4/); + }, 240_000); + + it('E5: a folder holding only desktop.ini is empty', async () => { + fs.mkdirSync(path.join(cwd, 'themes/e5'), {recursive: true}); + fs.writeFileSync( + path.join(cwd, 'themes/e5/desktop.ini'), + '[.ShellClassInfo]\n', + ); + await themeEditNew('e5', {cwd}); + expect(fs.existsSync(path.join(cwd, 'themes/e5/e5.settings.json'))).toBe( + true, + ); + }, 240_000); + + it('E9: a typo or camelCase of a theme name offers the theme meant', async () => { + await themeEditNew('brand', {cwd}); + await themeEditNew('brand-dark', {cwd}); + const typo = await refusal(() => + themeEditSet('brand-drak', 'corners', 'pill', {cwd}), + ); + expect(typo.run[0]).toMatch(/ set brand-dark corners pill$/); + const undo = await refusal(() => + themeEditUndo('brand-drk', {cwd, thing: 'corners'}), + ); + expect(undo.run[0]).toMatch(/ undo brand-dark corners$/); + const camel = await refusal(() => + themeEditSet('brandDark', 'corners', 'pill', {cwd}), + ); + expect(camel.run[0]).toMatch(/ set brand-dark corners pill$/); + // CC-2: themePark is theme-park, never a new themepark. + await themeEditNew('theme-park', {cwd}); + const park = await refusal(() => + themeEditSet('themePark', 'corners', 'pill', {cwd}), + ); + expect(park.run.join(' ')).not.toMatch(/ new themepark/); + expect(park.run[0]).toMatch(/ set theme-park corners pill$/); + }, 240_000); + + it("an undo that changes the mode names the app's ", async () => { + await themeEditNew('u', {cwd}); + await themeEditSet('u', 'mode', 'dark-only', {cwd}); + const back = await themeEditUndo('u', {cwd}); + const said = (/** @type {any} */ r) => + [...(r.data.undone ?? []), ...(r.data.notes ?? [])].join('\n'); + expect(said(back)).toMatch(/mode="dark" for u, take it out/); + await themeEditSet('u', 'mode', 'light-only', {cwd}); + await themeEditSet('u', 'mode', 'both', {cwd}); + const again = await themeEditUndo('u', {cwd}); + expect(said(again)).toMatch(/given mode="light"/); + }, 240_000); + + it('E8: undo --help says --mode removes a setting saved for one mode', () => { + const mode = /** @type {any} */ (undoDoc).options.find( + (/** @type {any} */ o) => o.flag === '--mode ', + ); + expect(mode.description).not.toContain('takes no --mode'); + expect(mode.description).toContain('removes a setting saved for one mode'); + }); + + it('E10: a stop after the save says the change is saved and how to run the rest', () => { + const write = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // Another handler (the render's) is there to end the process. + const other = () => {}; + process.on('SIGINT', other); + /** @type {string} */ + let line; + try { + stoppedAfterSave('t')('SIGINT'); + line = String(write.mock.calls[0]?.[0]); + } finally { + process.removeListener('SIGINT', other); + write.mockRestore(); + } + expect(line).toMatch(/change to t is saved and built.*show t runs them/); + }); + + it('R26-C: a damaged mode and a bad value: the fix for either one works', async () => { + await themeEditNew('c26', {cwd}); + await themeEditSet('c26', 'corners', 'pill', {cwd}); + const file = path.join(cwd, 'themes/c26/c26.settings.json'); + const damage = () => { + const j = JSON.parse(fs.readFileSync(file, 'utf8')); + j.mode = ''; + j.settings[0].value = 12; + fs.writeFileSync(file, JSON.stringify(j, null, 2)); + fs.rmSync(path.join(cwd, 'themes/c26/c26.history.json'), {force: true}); + }; + damage(); + // Removing the bad setting repairs the mode on the way, and says so. + const r = await themeEditUndo('c26', {cwd, thing: 'corners'}); + expect(JSON.stringify(r.data)).toContain('mode was missing or damaged'); + expect(JSON.parse(fs.readFileSync(file, 'utf8')).mode).toBe('both'); + // Fixing the mode first names the undo, which then works. + await themeEditSet('c26', 'corners', 'pill', {cwd}); + damage(); + const first = await refusal(() => + themeEditSet('c26', 'mode', 'both', {cwd}), + ); + expect(first.run[0]).toMatch(/ undo c26 corners$/); + await themeEditUndo('c26', {cwd, thing: 'corners'}); + }, 240_000); + + it('R26-L: an entry with no source is damage new keeps aside, as the refusal says', async () => { + await themeEditNew('l26', {cwd}); + const file = path.join(cwd, 'themes/l26/l26.settings.json'); + const j = JSON.parse(fs.readFileSync(file, 'utf8')); + j.settings = [{thing: 'corners', value: 'x'}]; + fs.writeFileSync(file, JSON.stringify(j, null, 2)); + fs.rmSync(path.join(cwd, 'themes/l26/l26.history.json'), {force: true}); + const r = await refusal(() => + themeEditSet('l26', 'corners', 'pill', {cwd}), + ); + expect(r.run.join(' ')).toMatch(/ new l26/); + await themeEditNew('l26', {cwd}); + expect( + fs.existsSync(path.join(cwd, 'themes/l26/l26.settings.damaged.json')), + ).toBe(true); + }, 240_000); +}); diff --git a/packages/cli/clients/cli/commands/experimental-theme-editor-undo.doc.mjs b/packages/cli/clients/cli/commands/experimental-theme-editor-undo.doc.mjs index dec7eda121718..f1433243ae113 100644 --- a/packages/cli/clients/cli/commands/experimental-theme-editor-undo.doc.mjs +++ b/packages/cli/clients/cli/commands/experimental-theme-editor-undo.doc.mjs @@ -54,7 +54,7 @@ export const doc = { flag: '--mode ', param: 'options.mode', description: - 'light or dark: only that color mode. Colors only; sizes, numbers, fonts and shadows are the same in both modes, and a light-only or dark-only theme takes no --mode', + 'light or dark: only that color mode. Colors only; sizes, numbers, fonts and shadows are the same in both modes. In a light-only or dark-only theme, it removes a setting saved for one mode (one made before the theme became one-mode)', }, { flag: '--only', From 6aac8d684f78c03915022613f4edbbc303a86aa6 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 04:55:46 -0800 Subject: [PATCH 05/21] fix(cli): experimental theme preview follows through (remove of a file, stopped picture runs, the see line, unserved and hand-edited previews), and the input cleanup reads pairs, labels, escapes and JSON (cherry picked from commit 5f91942a9742c6c42b185eed31fed8e3b286a937) --- .changeset/experimental-theme-hunt72.md | 7 + packages/cli/api/theme-edit/_adapter.mjs | 19 +- packages/cli/api/theme-edit/_check.mjs | 89 ++++- packages/cli/api/theme-edit/_context.mjs | 39 +- packages/cli/api/theme-edit/_edit.mjs | 31 +- packages/cli/api/theme-edit/_normal.mjs | 78 +++- packages/cli/api/theme-edit/_preview-app.mjs | 111 +++++- .../cli/api/theme-edit/theme-edit.type.mjs | 2 +- .../cli/api/theme-edit/theme-hunt72.test.mjs | 369 ++++++++++++++++++ .../theme-edit/theme-preview-safety.test.mjs | 7 +- .../experimental-theme-editor-show.doc.mjs | 2 +- 11 files changed, 729 insertions(+), 25 deletions(-) create mode 100644 .changeset/experimental-theme-hunt72.md create mode 100644 packages/cli/api/theme-edit/theme-hunt72.test.mjs diff --git a/.changeset/experimental-theme-hunt72.md b/.changeset/experimental-theme-hunt72.md new file mode 100644 index 0000000000000..e6dbe372f22d2 --- /dev/null +++ b/.changeset/experimental-theme-hunt72.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor` and `preview`: `preview remove --path` given a file is refused with the preview's folder (it reported an unknown error). Pictures are taken under a part name and moved into place, so a `--png` run stopped while the browser runs leaves nothing the next run reads as a file of yours. The `see` line names `preview add --theme ` when the project has several editor themes; gives no URL for a preview moved where Next does not serve it, saying why; says preview.html could not be written when a folder is in the way; says what blocked `--png` when a picture of yours did; names files by paths that work from a subfolder; and says when the in-app preview was changed by hand, as `previewStale` now does with what to move out. The show help says pictures change nothing in the app, except that show first brings the app copy up to date after a hand edit. A dial value labeled with another setting (`accent 'neutral: #64748b'`) is refused with that setting's line. The input cleanup reads corner brackets as quotes, keeps escaped quotes and brackets inside strings, reads a light and a dark color typed as a pair (offering `light-dark()` or two `--mode` lines, or one color for a dial), splits a JSON object of several settings into one line each, reads a JSON-escaped font stack, maps `--mode night` and `day` to dark and light, and reads `vibrancy 0.5` and `"50"` as 50; a saved value with paste debris or `!important` is offered cleaned. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_adapter.mjs b/packages/cli/api/theme-edit/_adapter.mjs index 44c022617d669..6833666d211ed 100644 --- a/packages/cli/api/theme-edit/_adapter.mjs +++ b/packages/cli/api/theme-edit/_adapter.mjs @@ -376,16 +376,25 @@ const lockName = name => */ function absolute(result, root, from) { if (path.resolve(from) === root) return result; - /** @param {any} v @returns {any} */ - const map = v => + /** A line that names theme files inside it (`see`, a picture note). */ + const PROSE = new Set(['see', 'pictureProblem']); + /** @param {any} v @param {string} [key] @returns {any} */ + const map = (v, key) => typeof v === 'string' ? /^themes\//.test(v) ? path.join(root, v) - : v + : key && PROSE.has(key) + ? v.replace( + /(^|[\s(,])(themes\/[^\s,;)]+)/g, + (_, before, file) => `${before}${path.join(root, file)}`, + ) + : v : Array.isArray(v) - ? v.map(map) + ? v.map(x => map(x, key)) : v && typeof v === 'object' - ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, map(x)])) + ? Object.fromEntries( + Object.entries(v).map(([k, x]) => [k, map(x, k)]), + ) : v; return map(result); } diff --git a/packages/cli/api/theme-edit/_check.mjs b/packages/cli/api/theme-edit/_check.mjs index 2fccd265be655..df8386f76f202 100644 --- a/packages/cli/api/theme-edit/_check.mjs +++ b/packages/cli/api/theme-edit/_check.mjs @@ -102,7 +102,10 @@ import { cssFamily, familySpaces, } from './_settings.mjs'; +import {isColorToken} from './_compiler.mjs'; import { + colorPair, + otherSettingLabel, familiesOf, familyPieces, normalValueText, @@ -357,6 +360,8 @@ function valueMeant(type, value) { .replace(/\s*!\s*important\s*$/i, '') .replace(/;+\s*$/, '') .trim(); + // Copy debris around it (a trailing comma, a stray quote or bracket). + p = withoutDebris(p).trim(); const q = /^(["'])(.*)\1$/.exec(p); if (q && type !== 'font') p = q[2].trim(); const out = [p]; @@ -1472,7 +1477,58 @@ export function checkSetting( // Any other dial's value pasted with its property name (`font-size: // 18px;` for text-size, `color: #e11d48` for accent) is the value: one // path for every dial (the font dial reads its own below). + // A light and a dark color typed as one value: a token takes both as + // light-dark() or one --mode at a time; a color dial takes one color and + // makes both modes from it. Never one of them for both. + if ( + !unset && + typeof value === 'string' && + (PALETTE_DIALS.includes(thing) || isColorToken(thing)) + ) { + const pair = colorPair(value); + if (pair && !colorProblem(pair.light) && !colorProblem(pair.dark)) { + const q = quoteCommandArgument; + const on = flags.on ? ` --on ${flags.on}` : ''; + refuse( + E.ERR_THEME_BAD_VALUE, + PALETTE_DIALS.includes(thing) + ? `"${value}" is a light and a dark color; ${thing} takes one color and makes both modes from it.` + : `"${value}" is a light and a dark color; ${thing} takes them as light-dark(), or one --mode at a time.`, + { + run: PALETTE_DIALS.includes(thing) + ? [ + `${P} set ${slug} ${thing} ${q(pair.light)}${on}`, + ...(thing === 'accent' + ? [ + `${P} set ${slug} --color-accent ${q(`light-dark(${pair.light}, ${pair.dark})`)}${on}`, + ] + : []), + ] + : [ + `${P} set ${slug} ${thing} ${q(`light-dark(${pair.light}, ${pair.dark})`)}${on}`, + `${P} set ${slug} ${thing} ${q(pair.light)}${on} --mode light`, + `${P} set ${slug} ${thing} ${q(pair.dark)}${on} --mode dark`, + ], + }, + ); + } + } if (!unset && typeof value === 'string' && DIALS[thing] && thing !== 'font') { + // A value labeled with another setting's name is that setting's, never + // this dial's (`accent 'neutral: #64748b'`). + const other = otherSettingLabel(thing, value); + if (other) + refuse( + E.ERR_THEME_BAD_VALUE, + `"${value}" is labeled ${other.thing}, another setting than ${thing}; nothing is set from it.`, + { + didYouMean: [other.thing], + run: [ + `${P} set ${slug} ${other.thing} ${quoteCommandArgument(other.value)}${flags.on ? ` --on ${flags.on}` : ''}`, + `${P} set ${slug} ${thing} ${quoteCommandArgument(other.value)}${flags.on ? ` --on ${flags.on}` : ''}`, + ], + }, + ); const d = pastedDeclaration(value); if (d?.value) value = d.value; } @@ -1697,7 +1753,19 @@ function checkOne(ctx, slug, data, thing, value, flags, unset) { .trim() .toLowerCase(); if (mode !== 'light' && mode !== 'dark') { - const near = nearest(mode, ['light', 'dark']); + // A word for one of the two (night, day) is that one; never the + // nearest spelling, which can be the other (night → light). + const meant = { + night: 'dark', + nighttime: 'dark', + dim: 'dark', + black: 'dark', + day: 'light', + daytime: 'light', + bright: 'light', + white: 'light', + }[mode]; + const near = meant ? [meant] : nearest(mode, ['light', 'dark']); refuse( E.ERR_THEME_BAD_VALUE, `--mode takes light or dark, not "${flags.mode}".`, @@ -2809,12 +2877,23 @@ function checkOne(ctx, slug, data, thing, value, flags, unset) { if (!step && canApply && (!/^\d{1,3}$/.test(v) || Number(v) > 100)) { // The value meant: a step in other letters (MORE), or the number // read as CSS reads it (70%, 7e1), kept in range. - const word = v.trim().toLowerCase(); - const n = cssNumber(v); + // Quotes around it (`"50"`) are not part of it. + const bare = v + .trim() + .replace(/^(["'])(.*)\1$/, '$2') + .trim(); + const word = bare.toLowerCase(); + const n = cssNumber(bare); + // A fraction of one (0.5) is that share of the scale (50), as a + // number with no unit from 0 to 1 is read. + const scaled = + n && n.unit === '' && n.value > 0 && n.value < 1 + ? n.value * 100 + : n?.value; const near = ['more', 'less'].includes(word) ? word - : n && (n.unit === '' || n.unit === '%') - ? String(Math.max(0, Math.min(100, Math.round(n.value)))) + : n && (n.unit === '' || n.unit === '%') && scaled != null + ? String(Math.max(0, Math.min(100, Math.round(scaled)))) : null; refuse( E.ERR_THEME_BAD_VALUE, diff --git a/packages/cli/api/theme-edit/_context.mjs b/packages/cli/api/theme-edit/_context.mjs index 0837783596b2a..81ae8f93496c4 100644 --- a/packages/cli/api/theme-edit/_context.mjs +++ b/packages/cli/api/theme-edit/_context.mjs @@ -49,6 +49,7 @@ import { recordWritten, themeFiles, refuseThemesNotFolder, + shown, } from './_settings.mjs'; import {SHOWCASE_GROUPS, renderPreview, screenshot} from './_preview.mjs'; import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; @@ -422,8 +423,34 @@ export async function pictures(project, shots, modes) { for (const {html, png} of shots) { for (const m of modes) { const out = outOf(png, m); - const r = await screenshot(project, html, out, m); - if (!r.ok) return {files, problem: r.reason}; + // Taken under a name of its own and moved into place once whole: a + // run stopped while the browser runs leaves no picture at the real + // name (one the next run would read as a file of yours). A part left + // by a run stopped before is cleared. + const part = path.join( + path.dirname(out), + `.${path.basename(out, '.png')}.part-${process.pid}.png`, + ); + for (const n of fs.existsSync(path.dirname(out)) + ? fs.readdirSync(path.dirname(out)) + : []) + if (n.startsWith(`.${path.basename(out, '.png')}.part-`)) + fs.rmSync(path.join(path.dirname(out), n), {force: true}); + const r = await screenshot(project, html, part, m); + if (!r.ok) { + fs.rmSync(part, {force: true}); + return {files, problem: r.reason}; + } + try { + fs.renameSync(part, out); + } catch (e) { + fs.rmSync(part, {force: true}); + return { + files, + problem: `the picture could not be moved into ${path.relative(project.cwd, out)} (${/** @type {any} */ (e)?.code ?? e})`, + }; + } + r.file = path.relative(project.cwd, out).split(path.sep).join('/'); files.push(r.file); // A picture in a theme folder is recorded as the editor's, so a later // change removes it and never a picture of the user's. @@ -650,6 +677,7 @@ export async function previewAfter(ctx, slug, data, png) { /** @type {string | null} */ let preview = null; let cause = ctx.unchecked ?? null; + const f0 = themeFiles(ctx.project.cwd, slug); try { preview = await renderPreview(ctx, slug, data); } catch (e) { @@ -659,7 +687,12 @@ export async function previewAfter(ctx, slug, data, png) { ? renderCause(ctx.project, /** @type {Error} */ (e).message) : ['ENOSPC', 'EACCES', 'EPERM', 'EROFS', 'EIO'].includes(code) ? `preview.html could not be written (${code === 'ENOSPC' ? 'the disk is full' : code}), so it still shows the theme as it was` - : `checking it failed: ${e instanceof Error ? e.message : String(e)}`; + : // A folder (or another thing) where preview.html goes: the page + // was rendered, and could not be written there. + code === ERROR_CODES.ERR_WRITE_FAILED || + (fs.existsSync(f0.preview) && !fs.statSync(f0.preview).isFile()) + ? `preview.html could not be written (${shown(ctx.project.cwd, f0.preview)} is ${fs.existsSync(f0.preview) && fs.statSync(f0.preview).isDirectory() ? 'a folder' : 'in the way'}; move it, then run ${P} show ${slug}), so it still shows the theme as it was` + : `checking it failed: ${e instanceof Error ? e.message : String(e)}`; } const f = themeFiles(ctx.project.cwd, slug); // The sheet of every component is made by show only: after a change it diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index 7dbae0b8b739a..ee401f2abbaf1 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -886,9 +886,10 @@ async function previewAndRoute(ctx, slug, data, png, focus = 'overview') { process.removeListener('SIGTERM', stopped); } const wrote = takeFirstAppCopy(ctx.project.cwd, slug); + const previewStale = await keepPreviewRoute(ctx, slug); return { ...p, - previewStale: await keepPreviewRoute(ctx, slug), + previewStale, // How to see the change: the page about it, at its own URL. // A page not written this time (preview null) still shows the theme // as it was; checks that failed with the page written do not. @@ -902,6 +903,7 @@ async function previewAndRoute(ctx, slug, data, png, focus = 'overview') { cause: 'no preview was rendered', }) : null, + {pictureProblem: png ? p.pictureProblem : null, stale: previewStale}, ), // The first time the theme's app copy is written, its files are named. ...(wrote ? {appCopy: {wrote}} : {}), @@ -1129,6 +1131,33 @@ async function change(sub, slug, thing, value, options) { // As typed: an undo of a saved entry written in a form the cleanup below // repairs (`Corners`, `var(--radius-page)`) removes that exact entry. const typedThing = thing; + // A JSON object of several settings pasted as one: one set line each. + if ( + typeof thing === 'string' && + (value == null || value === '') && + /^\s*\{[\s\S]*\}\s*$/.test(thing) + ) { + /** @type {any} */ + let obj = null; + try { + obj = JSON.parse(thing); + } catch { + // Not JSON: read as typed. + } + if (obj && typeof obj === 'object' && Object.keys(obj).length > 1) + refuse( + E.ERR_INVALID_ARGUMENT, + `This holds ${Object.keys(obj).length} settings; ${sub} changes one at a time.`, + { + run: Object.entries(obj) + .slice(0, 6) + .map( + ([k, x]) => + `${P} ${sub} ${slug} ${quoteCommandArgument(k)}${sub === 'set' ? ` ${quoteCommandArgument(String(x))}` : ''}`, + ), + }, + ); + } // How it was typed, put right before anything reads it (a pasted // declaration, a dash, a var(), invisible spaces). if (typeof thing === 'string') { diff --git a/packages/cli/api/theme-edit/_normal.mjs b/packages/cli/api/theme-edit/_normal.mjs index 7b4b8625f4832..6fff7e3e988a7 100644 --- a/packages/cli/api/theme-edit/_normal.mjs +++ b/packages/cli/api/theme-edit/_normal.mjs @@ -90,6 +90,8 @@ const CURLY_SINGLE = new Set(['\u2018', '\u2019', '\u201A', '\u201B']); /** The other delimiting quotes and the one that closes each. */ /** @type {Record} */ const QUOTE_PAIRS = { + '\u300C': '\u300D', + '\u300E': '\u300F', '\u00AB': '\u00BB', '\u00BB': '\u00AB', '\u2039': '\u203A', @@ -180,6 +182,21 @@ export function normalizeEntry(thing, value) { v = v.replace(/\s*!\s*important\s*;?\s*$/i, '').replace(/;+\s*$/, ''); } if (typeof v === 'string') v = withoutDebris(v); + // A font stack written as a JSON string (`"\\"Open Sans\\", serif"`): the + // stack it holds. + if ( + typeof v === 'string' && + /font/i.test(t) && + /^"(?:[^"\\]|\\.)*"$/.test(v.trim()) && + v.includes('\\"') + ) { + try { + const inner = JSON.parse(v.trim()); + if (typeof inner === 'string') v = inner; + } catch { + // Not a JSON string. + } + } // A font family's name never starts or ends with a space: French quotes // (« Inter », with a no-break or narrow no-break space) hold the name. if (typeof v === 'string' && /font/i.test(t)) v = trimInsideQuotes(v); @@ -319,7 +336,12 @@ export function withoutSecondDeclaration(v) { continue; } if (c === '"' || c === "'") q = c; - else if (c === ';' && /^\s*[a-z-]+\s*:/i.test(v.slice(i + 1))) + else if ( + c === ';' && + /^\s*[a-z-]+\s*:/i.test(v.slice(i + 1)) && + // `light: red; dark: blue` is one value for two modes. + !/^\s*(?:light|dark)\s*:/i.test(v.slice(i + 1)) + ) return v.slice(0, i); } return v; @@ -338,6 +360,7 @@ export function cleanDialValue(dial, value) { return read .trim() .replace(new RegExp(`^${dial.replace(/-/g, '\\-')}\\s*[:=]\\s*`, 'i'), '') + .replace(/\s*!\s*important\s*$/i, '') .replace(/;+\s*$/, '') .replace(/^(["'])(.*)\1$/, '$2') .trim() @@ -357,3 +380,56 @@ export function trimInsideQuotes(v) { '$1$2$1', ); } + +/** + * The other setting a dial's value is labeled with, when pasted with a + * label that names a different dial or a token (`neutral: #64748b` given + * to accent): that label and the value after it, else null. A label that + * names this dial, or a CSS property (`color: #e11d48`), is no other + * setting. + * @param {string} dial + * @param {string} value + * @returns {{thing: string, value: string} | null} + */ +export function otherSettingLabel(dial, value) { + const m = + /^\s*["']?([a-z][a-z0-9-]*|--[a-z0-9-]+)["']?\s*[:=]\s*(.+?)\s*;?\s*$/i.exec( + String(value), + ); + if (!m) return null; + const label = m[1].toLowerCase(); + if (label === dial) return null; + if (!DIALS[label] && !label.startsWith('--')) return null; + return {thing: label, value: m[2]}; +} + +/** + * A light and a dark color typed as a pair: labeled (`light: red; dark: + * blue`), a JSON object (`{"light": "red", "dark": "blue"}`), or two colors + * with a slash or a comma between (`red / blue`, `red, blue`). Null when the + * value is no such pair; the caller checks that both are colors. + * @param {string} value + * @returns {{light: string, dark: string} | null} + */ +export function colorPair(value) { + const v = String(value).trim(); + const unq = (/** @type {string} */ x) => + x + .trim() + .replace(/^(["'])(.*)\1$/, '$2') + .trim(); + const labeled = + /^\{?\s*["']?(light|dark)["']?\s*[:=]\s*(.+?)\s*[;,]\s*["']?(light|dark)["']?\s*[:=]\s*(.+?)\s*;?\s*\}?$/i.exec( + v, + ); + if (labeled && labeled[1].toLowerCase() !== labeled[3].toLowerCase()) { + const a = unq(labeled[2]); + const b = unq(labeled[4]); + return labeled[1].toLowerCase() === 'light' + ? {light: a, dark: b} + : {light: b, dark: a}; + } + const two = /^([^\s,/()]+)\s*(?:\s\/\s|,)\s*([^\s,/()]+)$/.exec(v); + if (two) return {light: unq(two[1]), dark: unq(two[2])}; + return null; +} diff --git a/packages/cli/api/theme-edit/_preview-app.mjs b/packages/cli/api/theme-edit/_preview-app.mjs index 32a1b19a8fdbf..790e18f2f5385 100644 --- a/packages/cli/api/theme-edit/_preview-app.mjs +++ b/packages/cli/api/theme-edit/_preview-app.mjs @@ -3244,7 +3244,7 @@ export async function refreshPreviewRoute(cwd, slug) { stale.push( standIn ? `${shown(cwd, file)} (changed by hand, so it is kept; the theme has no generated colors now, so ${standIn}, which the page imports, holds an empty stand-in that keeps the app building. Give the page another name if you want to keep your changes, then run ${PREVIEW} add)` - : shown(cwd, file), + : `${shown(cwd, file)} (changed by hand, so it does not follow this change: move it out of ${shownDir(cwd, dir)}/, keeping your copy elsewhere in the app and importing nothing from the preview folder, then run ${PREVIEW} add${dirFlag(cwd, dir)} to write it again)`, ); return false; } @@ -3339,11 +3339,39 @@ function routesOf(cwd, at) { function previewPath(cwd, at) { const app = routesOf(cwd, at); if (!app) return null; + // A folder Next does not serve (_private, @slot, an intercepting route): + // no URL, as add refuses it. + if (unservedWhy(cwd, at.dir)) return null; const segs = urlSegments(app, at.dir); if (segs.some(seg => /^\[.+\]$/.test(seg))) return null; const base = nextBasePath(cwd); if (base == null) return null; - return `${base}/${segs.map(seg => encodeURIComponent(seg)).join('/')}`; + // A folder named %5F… is served at the decoded name (`_x`), encoded as a + // URL segment needs it. + const decoded = (/** @type {string} */ seg) => { + try { + return decodeURIComponent(seg); + } catch { + return seg; + } + }; + return `${base}/${segs.map(seg => encodeURIComponent(decoded(seg))).join('/')}`; +} + +/** + * Why a preview folder found in the routes folder is not served, or null. + * @param {string} cwd + * @param {string} dir + * @returns {string | null} + */ +function unservedWhy(cwd, dir) { + const app = routesOf(cwd, {dir, app: null}); + if (!app) return null; + // A [param] folder is served, with a value for it; only _private, @slot + // and intercepting folders are not. + const segs = path.relative(app, dir).split(path.sep); + if (!segs.some(x => /^[_@]/.test(x) || INTERCEPT.test(x))) return null; + return unroutedSegment(app, dir); } /** @@ -3374,13 +3402,22 @@ function pageUrls(cwd, at, ids) { * @param {'colors' | 'components' | 'overview'} focus * @param {{code: string, cause: string} | null} [notChecked] why the change * could not be rendered + * @param {{pictureProblem?: string | null, stale?: string[]}} [extra] this + * result's picture problem (what kept --png from taking pictures) and its + * previewStale entries (an in-app preview changed by hand) * @returns {string} */ -export function seeLine(cwd, slug, focus, notChecked = null) { +export function seeLine(cwd, slug, focus, notChecked = null, extra = {}) { /** @type {string[]} */ const urls = []; /** Previews whose URL depends on the app's routing. @type {string[]} */ const unplain = []; + /** Previews in a folder Next does not serve. @type {string[]} */ + const unserved = []; + /** @type {string[]} */ + const stale = extra.stale ?? []; + /** Previews whose route was changed by hand. @type {string[]} */ + const handEdited = []; // The previews recorded for the theme, and any found in the routes // folders that wear it (a folder renamed by hand), as remove finds them. const dirs = [ @@ -3395,6 +3432,20 @@ export function seeLine(cwd, slug, focus, notChecked = null) { continue; } if (data?.theme !== slug) continue; + const why = unservedWhy(cwd, dir); + if (why) { + unserved.push(`${shownDir(cwd, dir)}/ (${why})`); + continue; + } + if ( + stale.some( + f => + f.startsWith(`${shownDir(cwd, dir)}/`) && /changed by hand/.test(f), + ) + ) { + handEdited.push(shownDir(cwd, dir)); + continue; + } const ids = Array.isArray(data?.pages) ? data.pages.map((/** @type {any} */ x) => String(x?.id)) : []; @@ -3408,16 +3459,44 @@ export function seeLine(cwd, slug, focus, notChecked = null) { ); } const f = themeFiles(cwd, slug); + // What keeps --png from taking pictures, when this result's own picture + // run failed (a file of yours named like a picture). + const pngNote = extra.pictureProblem + ? `--png took no pictures: ${extra.pictureProblem}` + : `run it with --png for pictures in ${shownDir(cwd, f.dir)}/ with no dev server (it needs a Playwright or a Chrome)`; const own = notChecked ? /could not be written/.test(notChecked.cause) ? `the change is saved, but ${notChecked.cause}; --png takes no pictures until that is fixed` : `the change is saved, but the components could not be rendered here (${notChecked.cause}), so ${shown(cwd, f.preview)} still shows the theme as it was and --png takes no pictures until that is fixed` - : `look without changing your app: open or screenshot ${shown(cwd, f.preview)} (one self-contained page, current after this change; ${EDITOR} show ${slug} also writes ${shown(cwd, f.sheet)}, every component), or run it with --png for pictures in ${shownDir(cwd, f.dir)}/ with no dev server (it needs a Playwright or a Chrome)`; + : `look without changing your app: open or screenshot ${shown(cwd, f.preview)} (one self-contained page, current after this change; ${EDITOR} show ${slug} also writes ${shown(cwd, f.sheet)}, every component), or ${pngNote}`; + const edited = handEdited.length + ? `; the in-app preview in ${handEdited.slice(0, 2).join(', ')}/ was changed by hand, so it does not follow this change (previewStale says what to move out, then ${PREVIEW} add writes it again)` + : ''; if (urls.length) - return `${own}; in the running app: open ${urls.slice(0, 2).join(' or ')}`; + return `${own}; in the running app: open ${urls.slice(0, 2).join(' or ')}${edited}`; if (unplain.length) - return `${own}; the in-app preview is in ${unplain.slice(0, 2).join(', ')}, at a URL that depends on the app's routing`; - return `${own}; to see it inside your app, ${PREVIEW} add puts the preview pages there, each at its own URL`; + return `${own}; the in-app preview is in ${unplain.slice(0, 2).join(', ')}, at a URL that depends on the app's routing${edited}`; + if (unserved.length) + return `${own}; the in-app preview is in ${unserved.slice(0, 2).join(', ')}, which the app does not serve: move it back, or make one with ${PREVIEW} add --theme ${slug}${edited}`; + if (edited) return `${own}${edited}`; + // With more than one editor theme, a bare add may wear another one. + const many = editorThemeCount(cwd) > 1; + return `${own}; to see it inside your app, ${PREVIEW} add${many ? ` --theme ${slug}` : ''} puts the preview pages there, each at its own URL`; +} + +/** + * How many editor themes the project has. + * @param {string} cwd + */ +function editorThemeCount(cwd) { + const root = themeFiles(cwd, 'x').root; + try { + return fs + .readdirSync(root) + .filter(d => fs.existsSync(themeFiles(cwd, d).settings)).length; + } catch { + return 0; + } } /** @@ -4994,6 +5073,24 @@ async function removePreview(page, options, box) { const retry = (/** @type {string | null} */ p) => `${PREVIEW} remove${page != null ? ` ${quoteCommandArgument(page)}` : ''}${p == null ? '' : ` --path ${quoteCommandArgument(p)}`}`; const at = previewDir(cwd, options.path, {retry}); + // A file given for the preview's folder: its folder when the file is a + // preview's own, else the previews there are (as add refuses a file). + if (fs.existsSync(at.dir) && !fs.statSync(at.dir).isDirectory()) { + const parent = path.dirname(at.dir); + const own = fs.existsSync(path.join(parent, PREVIEW_DATA)); + const others = previewsIn(cwd); + refuse( + E.ERR_INVALID_ARGUMENT, + `${shown(cwd, at.dir)} is a file; a preview is a folder${own ? `, and this file is in the preview ${shownDir(cwd, parent)}/` : ''}.`, + { + run: own + ? [retry(shownDir(cwd, parent))] + : others.length + ? others.slice(0, 3).map(d => retry(shownDir(cwd, d))) + : [`${PREVIEW} list`], + }, + ); + } // The pages a stopped change was removing, read before it is finished. /** @type {string[]} */ let removing = []; diff --git a/packages/cli/api/theme-edit/theme-edit.type.mjs b/packages/cli/api/theme-edit/theme-edit.type.mjs index ba6634f604084..feccd72c874a8 100644 --- a/packages/cli/api/theme-edit/theme-edit.type.mjs +++ b/packages/cli/api/theme-edit/theme-edit.type.mjs @@ -53,7 +53,7 @@ * @property {{code: string, cause: string} | null} notChecked - Set when the change is saved but the components could not be rendered, so it was not checked and has no preview: the cause, such as two copies of React. * @property {string} [sheetStale] - Set when sheet.html (the sheet of every component, made by `show`) shows the theme as it was before this change: it says so at its top until `show` makes it again. * @property {string[]} [previewStale] - Files of the in-app theme preview that were not written again and still show the theme as it was: changed by hand, or a preview folder where a stopped preview change waits for the next preview command. - * @property {string} see - One line on how to see the change: first a look that changes nothing in the app (the editor's preview.html, current after the change, and `png`, which takes pictures with no dev server), then the in-app preview page most about it (Colors for a color, Components for a part, else Overview) at its own URL when there is a preview, and adding one to the app last, as an option. When the change could not be rendered, it names why instead of the editor's page. + * @property {string} see - One line on how to see the change: first a look that changes nothing in the app (the editor's preview.html, current after the change, and `png`, which takes pictures with no dev server; when this result's own picture run was blocked, what blocked it instead), then the in-app preview page most about it (Colors for a color, Components for a part, else Overview) at its own URL when there is a preview, and adding one to the app last, as an option. When the change could not be rendered, it names why instead of the editor's page. * @property {{wrote: string[]}} [appCopy] - Only the first time the theme's app copy is written (src/themes//, a standard local theme `astryx theme add ` takes, written again after every build): the files written. * @property {string} [appCopyStale] - Set when the app copy (src/themes//) was not written because a file of yours has one of its names, or a link is on the way to it (src/, src/themes/ or the copy's folder is a link, and the editor writes nothing through one): the app keeps the theme as it was, and this says which file or link. */ diff --git a/packages/cli/api/theme-edit/theme-hunt72.test.mjs b/packages/cli/api/theme-edit/theme-hunt72.test.mjs new file mode 100644 index 0000000000000..60ec9cf6f0626 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt72.test.mjs @@ -0,0 +1,369 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The seventy-second pass: the preview follows through. Remove given a + * file, a picture run stopped part way, the `see` line with several themes, + * a preview moved where Next does not serve it, preview.html as a folder, a + * picture of yours in the way, a run from a subfolder, a route changed by + * hand, the show help on pictures, and a dial value labeled with another + * setting. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import { + themeEditNew, + themeEditSet, + themeEditPreviewAdd, + themeEditPreviewList, + themeEditPreviewRemove, +} from './theme-edit.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import { + FIXTURE_PACKAGES, + writeFixtureBaseTheme, +} from '../../test-utils/base-theme-fixture.mjs'; +import { + cleanDialValue, + colorPair, + normalValueText, + normalizeEntry, + withoutDebris, +} from './_normal.mjs'; +import {doc as showDoc} from '../../clients/cli/commands/experimental-theme-editor-show.doc.mjs'; + +/** The paths the picture stand-in was asked to write. */ +const shotPaths = /** @type {string[]} */ ([]); + +vi.mock('./_preview.mjs', async importOriginal => { + const original = /** @type {any} */ (await importOriginal()); + const stubbed = ( + await import('../../test-utils/theme-render-stub.mjs') + ).stubRenders(original); + return { + ...stubbed, + /** A picture stand-in: a tiny PNG at the path asked for. */ + async screenshot( + /** @type {any} */ project, + /** @type {string} */ html, + /** @type {string} */ png, + ) { + shotPaths.push(png); + fs.writeFileSync( + png, + Buffer.from( + '89504e470d0a1a0a0000000d4948445200000001000000010806000000' + + '1f15c4890000000d49444154789c6360000002000154a24f5d0000000049454e44ae426082', + 'hex', + ), + ); + return {ok: true, file: path.relative(project.cwd, png)}; + }, + }; +}); + +/** @type {string} */ +let cwd; +beforeAll(async () => { + cwd = makeLinkedFixture('astryx-theme-hunt72-', FIXTURE_PACKAGES); + writeFixtureBaseTheme(cwd, 'neutral'); + fs.mkdirSync(path.join(cwd, 'src/app'), {recursive: true}); + fs.writeFileSync( + path.join(cwd, 'src/app/layout.tsx'), + "import '@astryxdesign/core/astryx.css';\nexport default function RootLayout({children}) { return children; }\n", + ); + await themeEditNew('t', {cwd, like: 'neutral'}); +}, 240_000); +afterAll(() => { + if (cwd) removeLinkedFixture(cwd); +}); + +/** @param {() => Promise} run */ +async function refusal(run) { + try { + await run(); + } catch (error) { + return /** @type {any} */ (error); + } + throw new Error('expected a refusal'); +} +const names = (/** @type {any} */ e) => + (e.suggestions ?? []).map((/** @type {any} */ x) => x.name); + +describe('CC-3: with several editor themes, the see line names the theme', () => { + it('suggests preview add --theme ', async () => { + await themeEditNew('u', {cwd, like: 'neutral'}); + const r = await themeEditSet('u', 'corners', 'pill', {cwd}); + expect(r.data.see).toMatch(/preview add --theme u puts the preview/); + }, 240_000); +}); + +describe('CC-6: preview remove given a file', () => { + it('names the preview folder the file is in', async () => { + await themeEditPreviewAdd(undefined, {cwd, theme: 't'}); + const err = await refusal(() => + themeEditPreviewRemove(undefined, { + cwd, + path: 'src/app/theme-preview/page.tsx', + }), + ); + expect(err.code).toBe('ERR_INVALID_ARGUMENT'); + expect(names(err)[0]).toMatch(/remove --path src\/app\/theme-preview$/); + }, 240_000); + + it('any other file is refused, never ERR_UNKNOWN', async () => { + const err = await refusal(() => + themeEditPreviewRemove(undefined, {cwd, path: 'src/app/layout.tsx'}), + ); + expect(err.code).toBe('ERR_INVALID_ARGUMENT'); + }); +}); + +describe('CC-10: a route changed by hand', () => { + it('previewStale and see say why and what to do', async () => { + const page = path.join(cwd, 'src/app/theme-preview/page.tsx'); + const kept = fs.readFileSync(page, 'utf8'); + fs.writeFileSync(page, `${kept}\n// mine\n`); + try { + const r = await themeEditSet('t', 'mode', 'dark-only', {cwd}); + const stale = /** @type {string[]} */ (r.data.previewStale ?? []); + expect(stale.join(' ')).toMatch(/changed by hand/); + expect(stale.join(' ')).toMatch( + /move it out of src\/app\/theme-preview\//, + ); + expect(r.data.see).toMatch(/was changed by hand, so it does not follow/); + // The look that changes nothing in the app still leads. + expect(r.data.see).toMatch(/^look without changing your app/); + } finally { + fs.writeFileSync(page, kept); + await themeEditSet('t', 'mode', 'both', {cwd}); + } + }, 240_000); +}); + +describe('CC-8: a preview moved where Next does not serve it', () => { + it('see and list hand out no URL', async () => { + const from = path.join(cwd, 'src/app/theme-preview'); + const to = path.join(cwd, 'src/app/_private/tp'); + fs.mkdirSync(path.dirname(to), {recursive: true}); + fs.renameSync(from, to); + try { + const r = await themeEditSet('t', 'corners', 'round', {cwd}); + expect(r.data.see).toMatch(/does not serve/); + expect(r.data.see).not.toMatch(/\/_private\/tp\?page=/); + const list = await themeEditPreviewList({cwd}); + expect(JSON.stringify(list)).not.toMatch(/\/_private\/tp\?page=/); + } finally { + fs.renameSync(to, from); + } + }, 240_000); +}); + +describe('CC-1: preview.html is a folder', () => { + it('says it could not be written, never that it could not be rendered', async () => { + const f = path.join(cwd, 'themes/t/preview.html'); + fs.rmSync(f, {force: true}); + fs.mkdirSync(f); + try { + const r = await themeEditSet('t', 'corners', 'small', {cwd}); + expect(r.data.see).toMatch(/could not be written/); + expect(r.data.see).toMatch(/a folder/); + expect(r.data.see).not.toMatch(/could not be rendered/); + } finally { + fs.rmSync(f, {recursive: true, force: true}); + } + }, 240_000); +}); + +describe('CC-4 and CC-9: pictures', () => { + it('a picture of yours in the way: see says what blocks --png', async () => { + const own = path.join(cwd, 'themes/t/preview.png'); + fs.writeFileSync(own, 'mine'); + try { + const r = await themeEditSet('t', 'corners', 'medium', {cwd, png: true}); + expect(r.data.pictureProblem).toMatch(/preview\.png/); + expect(r.data.see).toMatch(/--png took no pictures/); + expect(r.data.see).not.toMatch(/run it with --png/); + } finally { + fs.rmSync(own, {force: true}); + } + }, 240_000); + + it('takes each picture under a part name and moves it into place; a part left before is cleared', async () => { + const left = path.join(cwd, 'themes/t/.preview.part-99999.png'); + fs.writeFileSync(left, 'stopped'); + shotPaths.length = 0; + const r = await themeEditSet('t', 'corners', 'large', {cwd, png: true}); + expect(shotPaths.length).toBeGreaterThan(0); + for (const p of shotPaths) + expect(path.basename(p)).toMatch(/\.part-\d+\.png$/); + expect(fs.existsSync(left)).toBe(false); + expect( + fs + .readdirSync(path.join(cwd, 'themes/t')) + .filter(n => n.includes('.part-')), + ).toEqual([]); + expect(r.data.pictures.length).toBeGreaterThan(0); + // The next run takes pictures again: nothing it left reads as yours. + const again = await themeEditSet('t', 'corners', 'pill', {cwd, png: true}); + expect(again.data.pictureProblem ?? '').not.toMatch(/did not write/); + expect(again.data.pictures.length).toBeGreaterThan(0); + }, 240_000); +}); + +describe('CC-7: run from a subfolder', () => { + it('see names files by paths that work from there', async () => { + const r = await themeEditSet('t', 'corners', 'round', { + cwd: path.join(cwd, 'src'), + }); + expect(r.data.see).toContain(path.join(cwd, 'themes/t/preview.html')); + expect(r.data.see).not.toMatch(/(^|\s)themes\/t\/preview\.html/); + }, 240_000); +}); + +describe('CC-5: the show help on pictures', () => { + it('says show may bring the app copy up to date after a hand edit', () => { + const png = showDoc.options.find( + (/** @type {any} */ o) => o.flag === '--png', + ); + expect(png.description).toMatch( + /changes nothing in the app, except that show first brings the app copy/, + ); + }); +}); + +describe('R26-M: a dial value labeled with another setting', () => { + it.each([ + ['accent', 'neutral: #64748b', 'neutral'], + ['accent', '--color-accent: #64748b', '--color-accent'], + ])('%s %j is refused, offering %s', async (thing, value, other) => { + const err = await refusal(() => themeEditSet('t', thing, value, {cwd})); + expect(err.code).toBe('ERR_THEME_BAD_VALUE'); + expect(names(err)[0]).toMatch(new RegExp(` set t ${other} '#64748b'$`)); + }); + + it('a label naming this dial or a CSS property is still read', async () => { + await themeEditSet('t', 'accent', 'accent: #2563eb', {cwd}); + await themeEditSet('t', 'accent', 'color: #e11d48', {cwd}); + }, 240_000); +}); + +describe('FINAL-24 R02: the normaliser', () => { + it.each([ + ['\u201DInter\u201C', '"Inter"'], + ['\u201EInter\u201C', '"Inter"'], + ['\u300CInter\u300D', '"Inter"'], + ['\u300EInter\u300F', '"Inter"'], + ['url("a.png?x=1;y:2")', 'url("a.png?x=1;y:2")'], + ['"Foo; Bar: X", serif', '"Foo; Bar: X", serif'], + ])('N71/N59 %j reads as %j', (typed, want) => { + expect(normalValueText(typed)).toBe(want); + }); + + it.each([ + ['content', 'url("a.png?x=1;y:2")'], + ['--font-family-body', '"Foo; Bar: X", serif'], + ['content', '"5\\" screen"'], + ['content', "'it\\'s'"], + ['content', '"\\""'], + ['content', '"a (b"'], + ])('N72/N74 %s %j is kept whole', (thing, value) => { + expect(normalizeEntry(thing, value).value).toBe(value); + expect(withoutDebris(value)).toBe(value); + }); + + it.each([ + ['text-size', '18,', '18'], + ['text-size', '"18"', '18'], + ['corners', 'pill,', 'pill'], + ['corners', '\u201Cpill\u201D', 'pill'], + ['buttons', 'soft,', 'soft'], + ['accent', '"#e11d48",', '#e11d48'], + ['accent', '#e11d48 !important', '#e11d48'], + ])('N75 saved %s %j is cleaned to %j', (dial, raw, want) => { + expect(cleanDialValue(dial, raw)).toBe(want); + }); + + it.each([ + ['light: red; dark: blue', {light: 'red', dark: 'blue'}], + ['dark: blue; light: red', {light: 'red', dark: 'blue'}], + ['{"light":"red","dark":"blue"}', {light: 'red', dark: 'blue'}], + ['red / blue', {light: 'red', dark: 'blue'}], + ['red, blue', {light: 'red', dark: 'blue'}], + ])('N79 %j is a pair', (typed, want) => { + expect(colorPair(typed)).toEqual(want); + expect(normalizeEntry('--color-accent', typed).value).toBe(typed); + }); + + it('N79 rgb() with a slash is no pair', () => { + expect(colorPair('rgb(1 2 3 / 50%)')).toBeNull(); + }); + + it('N78 a font stack as a JSON string is the stack', () => { + expect( + normalizeEntry('--font-family-body', '"\\"Open Sans\\", serif"').value, + ).toBe('"Open Sans", serif'); + }); +}); + +describe('FINAL-24 R02: set end to end', () => { + it('N79 a token offers light-dark() and the two --mode lines', async () => { + const err = await refusal(() => + themeEditSet('t', '--color-accent', 'light: red; dark: blue', {cwd}), + ); + expect(names(err)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/--color-accent 'light-dark\(red, blue\)'$/), + expect.stringMatching(/--color-accent red --mode light$/), + expect.stringMatching(/--color-accent blue --mode dark$/), + ]), + ); + }); + + it('N79 the accent dial takes one color, and the token takes the pair', async () => { + const err = await refusal(() => + themeEditSet('t', 'accent', 'red / blue', {cwd}), + ); + expect(err.message).toMatch(/takes one color/); + expect(names(err)[0]).toMatch(/ set t accent red$/); + }); + + it('N78 a JSON object of two settings gives one line each', async () => { + const err = await refusal(() => + themeEditSet('t', '{"accent":"#e11d48","corners":"pill"}', '', {cwd}), + ); + expect(names(err)).toEqual([ + expect.stringMatching(/ set t accent '#e11d48'$/), + expect.stringMatching(/ set t corners pill$/), + ]); + }); + + it.each([ + ['night', 'dark'], + ['day', 'light'], + ])('N77 --mode %s is %s', async (mode, want) => { + const err = await refusal(() => + themeEditSet('t', '--color-accent', 'red', {cwd, mode}), + ); + expect(names(err)[0]).toMatch(new RegExp(`--mode ${want}$`)); + }); + + it.each([ + ['0.5', / set t vibrancy 50$/], + ['"50"', / set t vibrancy 50$/], + ])( + 'N80 vibrancy %j', + async (value, want) => { + await themeEditSet('t', 'accent', '#e11d48', {cwd}); + const err = await refusal(() => + themeEditSet('t', 'vibrancy', value, {cwd}), + ); + expect(names(err)[0]).toMatch(want); + }, + 240_000, + ); +}); diff --git a/packages/cli/api/theme-edit/theme-preview-safety.test.mjs b/packages/cli/api/theme-edit/theme-preview-safety.test.mjs index 933bef558e5bc..69c3a152424cf 100644 --- a/packages/cli/api/theme-edit/theme-preview-safety.test.mjs +++ b/packages/cli/api/theme-edit/theme-preview-safety.test.mjs @@ -779,8 +779,13 @@ describe('pass 2', () => { await themeEditPreviewAdd(undefined, {cwd, theme: 'brand'}); fs.appendFileSync(path.join(cwd, ROUTE, '_pages/colors.tsx'), '// MINE\n'); const r = await themeEditSet('brand', 'accent', '#e11d48', {cwd}); + // Named, with why it was left and what to do (hunt72 CC-10). expect(/** @type {any} */ (r.data).previewStale).toEqual([ - `${ROUTE}/_pages/colors.tsx`, + expect.stringMatching( + new RegExp( + `^${ROUTE}/_pages/colors\\.tsx \\(changed by hand, so it does not follow this change: move it out of ${ROUTE}/`, + ), + ), ]); await themeEditSet('brand', 'accent', '#7c3aed', {cwd}); }, 180_000); diff --git a/packages/cli/clients/cli/commands/experimental-theme-editor-show.doc.mjs b/packages/cli/clients/cli/commands/experimental-theme-editor-show.doc.mjs index 52d1ee20dc8d1..e672a600205ff 100644 --- a/packages/cli/clients/cli/commands/experimental-theme-editor-show.doc.mjs +++ b/packages/cli/clients/cli/commands/experimental-theme-editor-show.doc.mjs @@ -32,7 +32,7 @@ export const doc = { flag: '--png', param: 'options.png', description: - 'Also take PNG pictures of the previews (light and dark), with no dev server running: it needs a Playwright installed in the project or a Chrome or Chromium found on the machine, and adds a few seconds while the browser starts; without one, the result says how to get it. Each picture carries the editor stamp and is recorded, by name and content, in themes//.pictures.json; a file of yours named like a picture (preview.png, sheet.png) is never written over: no picture is taken, and the result names the file', + 'Also take PNG pictures of the previews (light and dark), with no dev server running: it needs a Playwright installed in the project or a Chrome or Chromium found on the machine, and adds a few seconds while the browser starts; without one, the result says how to get it. Each picture carries the editor stamp and is recorded, by name and content, in themes//.pictures.json; a file of yours named like a picture (preview.png, sheet.png) is never written over: no picture is taken, and the result names the file. Taking pictures changes nothing in the app, except that show first brings the app copy (src/themes//) and an in-app preview up to date after a hand edit to .theme.ts', }, ], examples: [ From 23f45ce12cdd341490cd165c0a1c8c259e8106f4 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 05:04:05 -0800 Subject: [PATCH 06/21] experimental theme: hunt73 (a hand-edited headings value with no axis, or a mode or base entry in the settings list, is offered only lines that unlock the theme) (cherry picked from commit 2942394ce49135ba83f97bee54d95026e34e9972) --- .changeset/experimental-theme-hunt73.md | 7 ++ packages/cli/api/theme-edit/_edit.mjs | 18 +++- packages/cli/api/theme-edit/_settings.mjs | 8 +- .../cli/api/theme-edit/theme-hunt73.test.mjs | 88 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 .changeset/experimental-theme-hunt73.md create mode 100644 packages/cli/api/theme-edit/theme-hunt73.test.mjs diff --git a/.changeset/experimental-theme-hunt73.md b/.changeset/experimental-theme-hunt73.md new file mode 100644 index 0000000000000..47d7be68b61c9 --- /dev/null +++ b/.changeset/experimental-theme-hunt73.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor`: a hand-edited settings entry the editor refuses is offered only commands that unlock the theme. A `headings` value saved with no axis is its weight half, so `set headings ` or `undo headings` replaces or removes it; a `mode` or `base` entry put in the settings list is removed by `undo mode` (or `base`), and no `set` line is offered for it (those set the theme's own mode and base). + +@josephfarina diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index ee401f2abbaf1..87cb4f18374c9 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -3196,7 +3196,23 @@ export async function themeEditUndo(theme, options = {}) { const {cwd = process.cwd(), png = false, thing} = options; // base and mode are not settings in the list: undoing one goes back to // what the theme was made with (the oldest step the history keeps). - if (thing === 'base' || thing === 'mode') { + // A `base` or `mode` entry a hand edit put in the settings list is a + // setting like any other there: undo removes it. + const listed = + (thing === 'base' || thing === 'mode') && + slugOk(theme) && + (() => { + try { + return JSON.parse( + fs + .readFileSync(themeFiles(cwd, theme).settings, 'utf8') + .replace(/^\uFEFF/, ''), + ).settings.some((/** @type {any} */ x) => x?.thing === thing); + } catch { + return false; + } + })(); + if ((thing === 'base' || thing === 'mode') && !listed) { requireProject(cwd); const now = loadTheme(cwd, theme, thing); const historyFile = themeFiles(cwd, theme).history; diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index f98f52098ac47..59aea9621654a 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -906,7 +906,9 @@ export function loadTheme( `themes/${slug}/${slug}.settings.json: ${problem}, a value the editor no longer accepts. Remove or change that setting; nothing else needs to change. Nothing was changed.`, [ {name: `${P} undo ${slug} ${undoArgs(bad)}`, reason: 'do this instead'}, - ...(example + // Only a set that replaces this very entry: `mode` and `base` set + // the theme's own fields, not an entry in the list. + ...(example && !['mode', 'base'].includes(bad.thing) ? [ { name: `${P} set ${slug} ${undoArgs(bad).replace(bad.thing, `${bad.thing} ${quoteCommandArgument(example)}`)}`, @@ -1831,7 +1833,9 @@ function scopeProblem(s) { export const keyOf = s => [ s.thing, - s.axis ?? '', + // A headings value saved with no axis is its weight half, as the build + // reads it, so a set or undo of the weight replaces it. + s.thing === 'headings' ? (s.axis ?? 'weight') : (s.axis ?? ''), s.on ?? '', s.when ?? '', s.state ?? '', diff --git a/packages/cli/api/theme-edit/theme-hunt73.test.mjs b/packages/cli/api/theme-edit/theme-hunt73.test.mjs new file mode 100644 index 0000000000000..9adba45960480 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt73.test.mjs @@ -0,0 +1,88 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The seventy-third pass over `astryx experimental theme editor`: a + * hand-edited headings value with no axis, or a `mode` or `base` entry in + * the settings list, is offered only lines that unlock the theme, and + * following each does. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import {themeEditNew, themeEditShow} from './theme-edit.mjs'; +import {refusal} from '../../test-utils/theme-edit-fixture.mjs'; +import {runCli} from '../../test-utils/run-cli.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import {FIXTURE_PACKAGES} from '../../test-utils/base-theme-fixture.mjs'; + +vi.mock('./_preview.mjs', async importOriginal => + (await import('../../test-utils/theme-render-stub.mjs')).stubRenders( + await importOriginal(), + ), +); + +/** @type {string} */ +let cwd; + +beforeAll(() => { + cwd = makeLinkedFixture('astryx-theme-hunt73-', FIXTURE_PACKAGES); +}); + +afterAll(() => { + if (cwd) removeLinkedFixture(cwd); +}); + +describe('hunt73', () => { + it.each([ + ['headings', 'bold,'], + ['base', '"butter"'], + ['mode', '"dark-only"'], + ])( + 'N76: a saved %s entry is offered only lines that unlock the theme', + async (thing, value) => { + const slug = `n76-${thing}`; + await themeEditNew(slug, {cwd}); + const file = path.join(cwd, 'themes', slug, `${slug}.settings.json`); + const j = JSON.parse(fs.readFileSync(file, 'utf8')); + j.settings = [{thing, value, source: 'dial'}]; + fs.writeFileSync(file, JSON.stringify(j, null, 2)); + const r = await refusal(() => themeEditShow(slug, {cwd})); + // mode and base set the theme's own fields: no set line for the entry. + if (thing !== 'headings') + expect(r.run.join('\n'), thing).not.toMatch( + new RegExp(` set ${slug} `), + ); + // Every line offered is followed; then the theme opens. + const line = r.run[0]; + expect(line, thing).toMatch(new RegExp(` undo ${slug} ${thing}`)); + const words = line + .split(' experimental ')[1] + .split(' ') + .map(x => x.replace(/^'(.*)'$/, '$1')); + const ran = await runCli(['experimental', ...words], {cwd}); + expect(ran.status, ran.stderr).toBe(0); + await themeEditShow(slug, {cwd}); + // For headings, the set line offered (the cleaned value) unlocks too. + if (thing === 'headings') { + fs.writeFileSync(file, JSON.stringify(j, null, 2)); + const again = await refusal(() => themeEditShow(slug, {cwd})); + const set = again.run.find(x => / set /.test(x)); + expect(set).toBeTruthy(); + const w = /** @type {string} */ (set) + .split(' experimental ')[1] + .split(' '); + const r2 = await runCli( + ['experimental', ...w.map(x => x.replace(/^'(.*)'$/, '$1'))], + {cwd}, + ); + expect(r2.status, r2.stderr).toBe(0); + await themeEditShow(slug, {cwd}); + } + }, + 240_000, + ); +}); From ef6eab025d263bfb902f1bf8bdb07806195fef0a Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 05:03:06 -0800 Subject: [PATCH 07/21] experimental theme: parts43 (one-mode colors per variant with each variant's exact other half, hover on every variant, later longhands over base keys, nested card padding, kept sizes on the spacing dial, per-mode runs and corner notes) --- .changeset/experimental-theme-parts43.md | 15 + packages/cli/api/theme-edit/_check.mjs | 44 +- packages/cli/api/theme-edit/_edit.mjs | 31 +- packages/cli/api/theme-edit/_parts.mjs | 30 +- packages/cli/api/theme-edit/_settings.mjs | 478 ++++++++++++++++-- .../cli/api/theme-edit/theme-how.test.mjs | 6 +- .../cli/api/theme-edit/theme-parts43.test.mjs | 402 +++++++++++++++ .../api/theme-edit/theme-real-bases.test.mjs | 6 +- 8 files changed, 961 insertions(+), 51 deletions(-) create mode 100644 .changeset/experimental-theme-parts43.md create mode 100644 packages/cli/api/theme-edit/theme-parts43.test.mjs diff --git a/.changeset/experimental-theme-parts43.md b/.changeset/experimental-theme-parts43.md new file mode 100644 index 0000000000000..13eca27cb9047 --- /dev/null +++ b/.changeset/experimental-theme-parts43.md @@ -0,0 +1,15 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor`: per-variant colors in one mode, hover on every variant, later longhands, nested cards and kept sizes. + +- A one-mode color (`--mode`, or any color in a `light-only` or `dark-only` theme) on a part whose variants each have their own (a button or card background, a badge's text) goes to each variant's own rule, with the other mode's half exactly what that variant showed there (the base theme's value, else Core's; transparent stays transparent), so the other mode is unchanged for every variant; a variant's own hover still shows. +- A whole-part hover background (`--state hover`) reaches every variant, including ones the base theme gives their own hover or resting color. +- A longhand set after a shorthand wins even where the base theme holds that longhand (`padding-inline` after `padding`, `border-color` after `border`). +- A plain card inside a variant card keeps the card padding the editor gives it, in each text direction, and a variant's own padding (from the settings or your own source) no longer leaks into it; a variant your own source pads keeps its padding after an unequal editor padding on the plain card. +- The spacing dial keeps every exact control size the theme keeps (in px, rem or calc, from your own source, or set on the part) in order, on a part too; a size with no room between two kept ones takes the smaller one's size, and the result says so. +- `--mode` refused on a border names the variant in the per-mode color step; a gradient is offered as written for both modes. +- With your own source setting one corner, the corners note says the dial shows on the other corners after the undo. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_check.mjs b/packages/cli/api/theme-edit/_check.mjs index df8386f76f202..26b5277ed27fd 100644 --- a/packages/cli/api/theme-edit/_check.mjs +++ b/packages/cli/api/theme-edit/_check.mjs @@ -3694,13 +3694,41 @@ function checkOne(ctx, slug, data, thing, value, flags, unset) { // A color mixed into the value (a border's) can go per mode on its // own; a value with none (a radius) is just set for both modes. const hasColor = /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i.test(v); - const alt = !hasColor - ? null - : prop === 'border' || prop.startsWith('border-') - ? 'border-color' - : prop === 'background' - ? 'background-color' - : null; + // A gradient's colors sit in its image, above any background-color: + // no color step can make them differ per mode. + const alt = + !hasColor || /gradient\(/i.test(v) + ? null + : prop === 'border' || prop.startsWith('border-') + ? 'border-color' + : prop === 'background' + ? 'background-color' + : null; + // On a part whose variants each have their own color, the color per + // mode is set for one variant at a time (as that refusal says): the + // step names the default one, or the first. + let altWhen = ''; + if (alt && !s.when) { + const g = C.groupOfProp(alt); + const variants = [ + ...new Set( + partsOf(ctx, s).flatMap(t => + (g ? C.partReads(t, g) : []) + .filter(r => r.props.includes(alt)) + .flatMap(r => r.when) + .map(w => /^variant=([\w-]+)$/.exec(w)?.[1]) + .filter(Boolean), + ), + ), + ]; + const plain = partsOf(ctx, s).some(t => + (g ? C.partReads(t, g) : []).some( + r => r.props.includes(alt) && r.when.includes(''), + ), + ); + if (variants.length && !plain) + altWhen = ` --when variant:${variants.includes('default') ? 'default' : variants[0]}`; + } refuse( E.ERR_THEME_NO_MODE, `--mode works on plain colors; ${prop} ${prop === 'box-shadow' || prop === 'border' ? 'mixes a color with sizes' : 'is not a color'}, so it cannot differ between light and dark.`, @@ -3708,7 +3736,7 @@ function checkOne(ctx, slug, data, thing, value, flags, unset) { run: alt ? [ `${P} set ${slug} ${prop} ${quoteCommandArgument(v.replace(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/i, 'currentcolor'))} --on ${s.on}${whenFlag} # the shape, both modes`, - `${P} set ${slug} ${alt} ${quoteCommandArgument(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/i.exec(v)?.[0] ?? '#1f2937')} --on ${s.on}${whenFlag} --mode ${s.mode} # then the color per mode`, + `${P} set ${slug} ${alt} ${quoteCommandArgument(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/i.exec(v)?.[0] ?? '#1f2937')} --on ${s.on}${altWhen}${whenFlag} --mode ${s.mode} # then the color per mode${altWhen ? ', for each variant' : ''}`, ] : [ `${P} set ${slug} ${prop} ${quoteCommandArgument(v)} --on ${s.on}${whenFlag} # both modes`, diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index 87cb4f18374c9..c95170eb54d62 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -1688,6 +1688,10 @@ async function changeOnce( : []; const r = await commitTheme(ctx, slug, data, {history: true}); const compiledAfter = compileTheme(ctx, data, base); + // What the spacing dial said of the sizes it could not keep apart. + const sizeNotes = + /** @type {{sizeNotes?: string[]}} */ (/** @type {unknown} */ (ctx)) + .sizeNotes ?? []; const styled = styledChanges(compiledBefore, compiledAfter, base); const parts = partsOf(ctx, s); // Exact whole-theme tokens that win over what this dial writes: the dial @@ -1713,6 +1717,17 @@ async function changeOnce( !x.when && x.thing === `--font-family-${s.on}`, ); + // A spacing dial on a part keeps the exact spacing and sizes there too: + // the whole theme's, and the part's own. + if (sub === 'set' && s.source === 'dial' && s.thing === 'spacing' && s.on) + exactWins = data.settings.filter( + x => + x.source === 'token' && + !x.when && + !x.state && + /^--(size-element|spacing)-/.test(x.thing) && + (!x.on || x.on === s.on), + ); // The theme's own source wins over an exact setting, as the exact // setting does over a dial: an exact token the file also sets is named // as the file's below, never as the one that shows after an undo. @@ -1938,6 +1953,7 @@ async function changeOnce( `keeps your exact ${exactWins.map(describe).join(', ')}: an exact setting wins over a dial, so ${exactWins.length > 1 ? 'those do' : 'that does'} not change; this shows after \`${P} undo ${slug} ${exactWins.map(x => undoArgs(x)).join(`\` and \`${P} undo ${slug} `)}\``, ] : []), + ...(sub === 'set' && s.thing === 'spacing' ? sizeNotes : []), ...(covers.length ? [ `comes after ${covers.map(describe).join(', ')}, so ${covers.length > 1 ? 'those parts show' : `${covers[0].on} shows`} this value now; set ${covers.length > 1 ? 'one' : 'it'} again to make it win`, @@ -1983,9 +1999,18 @@ async function changeOnce( // An exact property on the same part wins over a dial there. ...(exactOver ? [ - ownPartHits.length - ? `${describe(exactOver)} is an exact property and wins over ${s.thing} there too; undoing it does not make ${s.thing} show on ${[...new Set(ownPartHits.map(h => h.target))].join(', ')}, where ${slug}.theme.ts wins` - : `${describe(exactOver)} is an exact property and wins over ${s.thing} there, so this shows only after \`${P} undo ${slug} ${undoArgs(exactOver)}\``, + // The own source holding only some sides or corners there leaves + // the dial the others once the exact property is undone. + ownPartHits.length && + ownPartHits.every(h => + /(Top|Bottom|Left|Right|Start|End|Block|Inline)/.test( + camel(String(h.prop)), + ), + ) + ? `${describe(exactOver)} is an exact property and wins over ${s.thing} there, so this shows only after \`${P} undo ${slug} ${undoArgs(exactOver)}\`, and then not on the ${[...new Set(ownPartHits.map(h => kebab(String(h.prop))))].join(', ')} ${slug}.theme.ts sets itself` + : ownPartHits.length + ? `${describe(exactOver)} is an exact property and wins over ${s.thing} there too; undoing it does not make ${s.thing} show on ${[...new Set(ownPartHits.map(h => h.target))].join(', ')}, where ${slug}.theme.ts wins` + : `${describe(exactOver)} is an exact property and wins over ${s.thing} there, so this shows only after \`${P} undo ${slug} ${undoArgs(exactOver)}\``, ] : []), // A whole-theme font under fonts set for each role shows only where no diff --git a/packages/cli/api/theme-edit/_parts.mjs b/packages/cli/api/theme-edit/_parts.mjs index d26957a8b0143..5fa0e7983af53 100644 --- a/packages/cli/api/theme-edit/_parts.mjs +++ b/packages/cli/api/theme-edit/_parts.mjs @@ -31,11 +31,12 @@ import { import {SAMPLE_PAGE, elementNames, pageSource} from './_catalog.mjs'; /** Bump when the shape of the cached part data changes. */ -const PART_DATA_VERSION = 4; +const PART_DATA_VERSION = 5; /** * @typedef {object} PartRecord * @property {Record, values: string[]}>} own - property -> tokens (with conditions) and raw values + * @property {Record>} [at] - property -> the value under each condition, private variables resolved * @property {Record} inside - tokens read by unnamed elements inside the part -> properties * @property {Record} attrs - data attributes seen on the part * @property {string[]} contains - parts rendered inside it @@ -666,6 +667,24 @@ function expand(v, env, tokens, seen = new Set(), depth = 0) { return out; } +/** + * A value with the private variables set on the element or its ancestors + * put in (a variable with no fallback only), so it reads public tokens and + * literal values alone where it can. + * @param {string} v + * @param {Record} env + * @param {Set} tokens + * @param {number} [depth] + * @returns {string} + */ +function resolvePrivate(v, env, tokens, depth = 0) { + if (depth > 8) return v; + const out = v.replace(/var\((--[a-zA-Z0-9_-]+)\)/g, (m, name) => + !tokens.has(name) && env[name] != null ? env[name] : m, + ); + return out === v ? v : resolvePrivate(out, env, tokens, depth + 1); +} + /** * Walk server-rendered markup: for each element, its classes' declarations * become what its part reads; text records the color pair it sits on. @@ -675,7 +694,7 @@ function expand(v, env, tokens, seen = new Set(), depth = 0) { * @returns {{map: Record, pairs: TextPair[]}} */ export function walkMarkup(html, decls, tokens) { - /** @type {Record>, values: Set}>, inside: Record>, attrs: Record>, contains: Set}>} */ + /** @type {Record>, values: Set}>, at: Record>, inside: Record>, attrs: Record>, contains: Set}>} */ const map = {}; /** @type {Array<{env: Record, target: string | null, fg: PaintSide | null, bg: PaintSide | null}>} */ const stack = [{env: {}, target: null, fg: null, bg: null}]; @@ -726,6 +745,7 @@ export function walkMarkup(html, decls, tokens) { if (target) { const T = (map[target] ??= { own: {}, + at: {}, inside: {}, attrs: {}, contains: new Set(), @@ -742,6 +762,11 @@ export function walkMarkup(html, decls, tokens) { const cond = [cond0, d.cond].filter(Boolean).join(' '); if (ownsTarget) { const P = (T.own[d.prop] ??= {tokens: {}, values: new Set()}); + (T.at[d.prop] ??= {})[cond || 'always'] ??= resolvePrivate( + d.value, + env, + tokens, + ); P.values.add( d.value.length > 80 ? `${d.value.slice(0, 80)}...` : d.value, ); @@ -792,6 +817,7 @@ export function walkMarkup(html, decls, tokens) { }, ]), ), + at: v.at, inside: Object.fromEntries( Object.entries(v.inside).map(([k, s]) => [k, [...s]]), ), diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index 59aea9621654a..ed94a40a31162 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -117,7 +117,7 @@ setOwnTokenReader((cwd, slug) => ownSourceTokenValues(cwd, slug)); /** What lifted secondary text sits on: the contrast check measures it there. */ const LIFT_GROUND_TOKENS = new Set(LIFT_PARTS.map(([, , g]) => g)); -/** @typedef {{project: import('./_project.mjs').ThemeProject, C: import('./_compiler.mjs').Compiler, unchecked?: string | null, uncheckedCode?: string | null, wrote?: Map, base?: {light: Record, dark: Record, components?: Record}, keepStops?: Record> | null, keepRadii?: Record | null, derived?: Record>}} Ctx */ +/** @typedef {{project: import('./_project.mjs').ThemeProject, C: import('./_compiler.mjs').Compiler, unchecked?: string | null, uncheckedCode?: string | null, wrote?: Map, base?: {light: Record, dark: Record, components?: Record}, keepStops?: Record> | null, keepRadii?: Record | null, sizeNotes?: string[], derived?: Record>}} Ctx */ /** @typedef {import('./_parts.mjs').PaintSide} PaintSide */ /** @typedef {import('./_parts.mjs').TextPair} TextPair */ @@ -2007,6 +2007,44 @@ function partDefault(ctx, target, prop, when, state) { return r ? `var(${r.token})` : null; } +/** + * The variants (`variant:`) of a part that each read their own value + * of a color property (the button's background, a badge's text), in a + * state when one is named and changes it: one rule's other-mode half would + * give every one of them one variant's color. Empty when the part reads one + * value for all. + * @param {Ctx} ctx + * @param {string} target + * @param {string} prop camelCase or kebab-case + * @param {string} [state] + * @returns {string[]} + */ +function variantsReading(ctx, target, prop, state) { + const k = kebab(prop); + const g = ctx.C.groupOfProp(k === 'background' ? 'background-color' : k); + if (!g) return []; + const reads = ctx.C.partReads(target, g).filter(x => + x.props.some( + p => p === k || (k === 'background' && p === 'background-color'), + ), + ); + const named = (/** @type {(w: string) => boolean} */ ok) => [ + ...new Set( + reads + .flatMap(x => x.when) + .filter(ok) + .map(w => /(?:^|,)variant=([\w-]+)/.exec(w)?.[1]) + .filter(Boolean) + .map(n => `variant:${n}`), + ), + ]; + const inState = state ? named(w => w.includes(`:${state}`)) : []; + if (inState.length) return inState; + return reads.some(x => x.when.includes('')) + ? [] + : named(w => /^variant=[\w-]+$/.test(w)); +} + /** The line that records a generated module's own hash. */ const SEAL = /^\/\/ Module ([0-9a-f]{16})\.\n/m; @@ -2149,6 +2187,36 @@ function keepShades(pal, kept) { return {...pal, tokens, lift: kept['lifted parts'] ? pal.lift : null}; } +/** + * A size in px: px, rem (16px each), or a calc() of those added and + * subtracted; null for anything else. + * @param {string} v + * @returns {number | null} + */ +export function sizePx(v) { + let t = String(v).trim(); + const calc = /^calc\((.*)\)$/is.exec(t); + if (calc) t = calc[1].trim(); + const terms = t.match(/[+-]?\s*[\d.]+(?:px|rem)\b|\S+/g) ?? []; + let sum = 0; + let sign = 1; + for (const raw of terms) { + const x = raw.replace(/\s+/g, ''); + if (x === '+' || x === '-') { + if (!calc) return null; + sign = x === '-' ? -1 : 1; + continue; + } + const m = /^([+-]?)([\d.]+)(px|rem)$/.exec(x); + if (!m || (!calc && terms.length > 1)) return null; + const n = parseFloat(m[2]) * (m[3] === 'rem' ? 16 : 1); + if (!Number.isFinite(n)) return null; + sum += (m[1] === '-' ? -1 : 1) * sign * n; + sign = 1; + } + return terms.length ? sum : null; +} + /** * Control heights scaled with the spacing dial (a denser theme has shorter * controls), each kept distinct and in order: smallest to largest, a @@ -2160,9 +2228,11 @@ function keepShades(pal, kept) { * @param {number} f the spacing rung's factor * @param {Record} [kept] exact sizes the theme keeps, in px, * which the others stay in order around + * @param {string[]} [noRoom] filled with the sizes left no room between + * two kept ones (each then equals the one below it) * @returns {Array<[string, string]>} the tokens it writes, in px */ -export function controlSizes(sizes, f, kept = {}) { +export function controlSizes(sizes, f, kept = {}, noRoom = []) { const px = sizes .filter(([, v]) => /^[\d.]+px$/.test(String(v).trim())) .map(([k, v]) => /** @type {[string, number]} */ ([k, parseFloat(v)])) @@ -2179,6 +2249,18 @@ export function controlSizes(sizes, f, kept = {}) { if (!(px[i][0] in kept) && n[i] <= n[i - 1]) n[i] = n[i - 1] + step(i); for (let i = n.length - 2; i >= 0; i--) if (!(px[i][0] in kept) && n[i] >= n[i + 1]) n[i] = n[i + 1] - step(i + 1); + // A size between two that cannot both move (kept, or one kept and the + // other already at its place) sits halfway between them; with no room + // between them it takes the smaller one's size, and the result says so. + for (let i = 1; i < n.length - 1; i++) + if (!(px[i][0] in kept) && (n[i] <= n[i - 1] || n[i] >= n[i + 1])) { + const mid = Math.round(((n[i - 1] + n[i + 1]) / 2) * 2) / 2; + if (mid > n[i - 1] && mid < n[i + 1]) n[i] = mid; + else { + n[i] = n[i - 1]; + noRoom.push(px[i][0]); + } + } return px .map(([k], i) => /** @type {[string, string]} */ ([k, `${n[i]}px`])) .filter(([k]) => !(k in kept)); @@ -2192,6 +2274,7 @@ export function controlSizes(sizes, f, kept = {}) { * @returns {any} */ export function compileTheme(ctx, data, base) { + ctx.sizeNotes = []; // A setting the editor no longer accepts never reaches the build. data = {...data, settings: data.settings.filter(s => !valueProblem(s))}; const TOKENS = ctx.project.core.tokenDefaults; @@ -2214,6 +2297,42 @@ export function compileTheme(ctx, data, base) { : data.mode === 'dark-only' ? 'dark' : null; + /** The tokens the theme's own source sets, which win over the settings. */ + /** @type {Record | null} */ + let ownTok = null; + const ownTokenValues = () => { + if (ownTok) return ownTok; + try { + ownTok = ownSourceTokenValues(ctx.project.cwd, data.name); + } catch { + ownTok = {}; + } + return ownTok; + }; + /** + * The exact tokens kept where a dial writes (whole theme with no part): + * the whole-theme settings', then the own source's, by name. + * @param {string | null} t a part, for its own settings too + * @returns {Map} + */ + const keptTokens = t => { + /** @type {Map} */ + const out = new Map(); + for (const x of data.settings) + if ( + x.source === 'token' && + !x.when && + !x.state && + !(modeOnly && x.mode && x.mode !== modeOnly) && + (!x.on || (t != null && partsOf(ctx, x).includes(t))) + ) + out.set(x.thing, String(x.value)); + for (const [k, v] of Object.entries(ownTokenValues())) out.set(k, v); + return out; + }; + /** @param {string | null} t */ + const keptSizes = t => + [...keptTokens(t)].filter(([k]) => /^--size-element-/.test(k)); /** @type {Record} */ const tok = {}; /** @param {string} name @param {string | [string, string]} v @param {string} [mode] */ @@ -2678,6 +2797,31 @@ export function compileTheme(ctx, data, base) { const b = block(variant, owner); for (const x of l.filter(x => r.includes(x))) b[`--astryx-${owner}-padding-${x}`] ??= 'initial'; + // A pair variable the plain rule writes (the inline or block + // padding) feeds both its sides, under the side variables: where + // the own source pads both, it is left without a value too, so + // the variant's own padding holds there. + for (const pair of ['block', 'inline']) + if ( + [`${pair}-start`, `${pair}-end`].every( + x => l.includes(x) && r.includes(x), + ) + ) + b[`--astryx-${owner}-padding-${pair}`] ??= 'initial'; + // Its own sides inherit into a plain one of the part inside it: + // they are reset there like the editor's (below). + const sel = narrowSelector(owner, variant); + if (sel) { + const bySel = narrow.get(owner) ?? new Map(); + const names = bySel.get(sel) ?? new Set(); + for (const x of new Set([...l, ...r])) + names.add(`--astryx-${owner}-padding-${x}`); + for (const pair of ['block', 'inline']) + if ([...l, ...r].some(x => x.startsWith(pair))) + names.add(`--astryx-${owner}-padding-${pair}`); + bySel.set(sel, names); + narrow.set(owner, bySel); + } for (const [dir, xs] of /** @type {const} */ ([ [':dir(ltr)', l.filter(x => !r.includes(x))], [':dir(rtl)', r.filter(x => !l.includes(x))], @@ -2860,11 +3004,18 @@ export function compileTheme(ctx, data, base) { const keys = Object.keys(b); const plain = keys.filter(k => !k.startsWith(':')); const dirs = keys.filter(k => /^:(where\()?dir\(/.test(k)); + // The reset a plain part inside a narrower one gets comes before the + // part's own direction rules at the same weight, so a side the part + // sets for a direction holds inside a variant too. + const resets = keys.filter(k => /^:where\((?!:?dir\().* \*\)$/.test(k)); const rest = keys.filter( - k => k.startsWith(':') && !/^:(where\()?dir\(/.test(k), + k => + k.startsWith(':') && + !/^:(where\()?dir\(/.test(k) && + !resets.includes(k), ); return Object.fromEntries( - [...plain, ...dirs, ...rest].map(k => [ + [...plain, ...resets, ...dirs, ...rest].map(k => [ k, k.startsWith(':') && b[k] && typeof b[k] === 'object' ? dirFirst(b[k]) @@ -2915,6 +3066,56 @@ export function compileTheme(ctx, data, base) { const parent = block({...s, state: undefined}, target); return [parent, `${stateKey(s.state, wrapsOneControl(ctx, target))}${dir}`]; }; + /** + * One half of a light-dark() pair (1 light, 2 dark), or the value itself. + * @param {string} x @param {1 | 2} side + */ + const halfOf = (x, side) => { + const inner = /^light-dark\((.*)\)$/s.exec(x.trim())?.[1]; + const halves = inner == null ? [] : splitTop(inner, ','); + return halves.length === 2 ? halves[side - 1].trim() : x.trim(); + }; + /** + * The value a variant of a part shows for a color property where the + * theme sets none of its own: the base theme's rule for that variant (in + * the state, else at rest), else Core's, else transparent for a + * background Core never paints; null for a text color Core never sets. + * @param {string} target @param {'background-color' | 'color'} css + * @param {string} name the variant @param {string} [state] + * @param {boolean} [stateOnly] the state's own value only, never the resting one + * @returns {string | null} + */ + const exactVariantValue = (target, css, name, state, stateOnly = false) => { + const when = `variant:${name}`; + const props = + css === 'color' ? ['color'] : ['backgroundColor', 'background']; + const steps = state + ? stateOnly + ? [state] + : [state, undefined] + : [undefined]; + for (const st of steps) + for (const p of props) { + const b = baseComponent(base, ctx, target, p, {when, state: st}); + if (b != null) return b; + } + const keyOf = (/** @type {string} */ k) => { + const [attrs, cond = ''] = k.split(' '); + return attrs.split(',').includes(`variant=${name}`) ? cond : null; + }; + for (const st of steps) + for (const p of css === 'color' + ? ['color'] + : ['background-color', 'background']) { + const at = ctx.C.MAP[target]?.at?.[p]; + for (const [k, val] of Object.entries(at ?? {})) { + const cond = keyOf(k); + if (cond == null) continue; + if (st ? cond.includes(`:${st}`) : cond === '') return val; + } + } + return css === 'color' || stateOnly ? null : 'transparent'; + }; /** @param {Setting} s @param {string} target @param {string} prop @param {string} v */ const putComp = (s, target, prop, v) => { const b = block(s, target); @@ -2924,6 +3125,52 @@ export function compileTheme(ctx, data, base) { const mode = s.mode ?? (modeOnly && parseColor(String(v).trim()) ? modeOnly : undefined); + if (mode && !s.when && variantsReading(ctx, target, prop, s.state).length) { + // A color for one mode on a part whose variants each have their own: + // each variant's rule (in the state, for a state setting) gets the + // color in that mode and, in the other, exactly the value it shows + // unset there (the base theme's, else Core's), so no variant takes + // another's and the other mode stays as it was. The part's own rule + // is left alone. + const css = kebab(prop) === 'color' ? 'color' : 'background-color'; + const other = mode === 'light' ? 2 : 1; + const names = new Set([ + ...(ctx.C.variantValues(target)?.variant ?? []), + ...(ctx.C.MAP[target]?.attrs?.variant ?? []), + ]); + for (const name of names) { + const x = exactVariantValue(target, css, name, s.state); + if (x == null) continue; + const half = halfOf(x, other); + block({...s, when: `variant:${name}`}, target)[camel(css)] = + mode === 'light' + ? `light-dark(${v}, ${half})` + : `light-dark(${half}, ${v})`; + // A resting color written on the variant's rule would also hide the + // hover the variant shows unset: that hover is written back as it + // was, in both modes, unless a hover setting of the theme's own + // gives it one. + if ( + !s.state && + !data.settings.some( + y => + y.on === s.on && + y.state === 'hover' && + kebab(y.thing) === kebab(s.thing), + ) + ) { + const hover = exactVariantValue(target, css, name, 'hover', true); + if (hover != null && hover !== x) { + const hb = block( + {...s, when: `variant:${name}`, state: 'hover', mode: undefined}, + target, + ); + hb[camel(css)] ??= hover; + } + } + } + return; + } if (mode) { const prev = b[prop]; // The other mode keeps exactly what it had: the value set for it, the @@ -3155,8 +3402,12 @@ export function compileTheme(ctx, data, base) { // The rungs scale the spacing the base theme starts from. /** @param {string} k @param {string} nv */ const put = (k, nv) => { - if (parts.length) for (const t of parts) block(s, t)[k] = nv; - else putTok(k, nv); + if (parts.length) { + // An exact whole-theme or own-source token holds inside the + // part too. + if (!keptTokens(null).has(k)) + for (const t of parts) block(s, t)[k] = nv; + } else putTok(k, nv); }; for (const [k, core] of Object.entries( /** @type {Record} */ (TOKENS), @@ -3166,27 +3417,33 @@ export function compileTheme(ctx, data, base) { put(k, `${Number((parseFloat(v) * f).toFixed(3))}rem`); } // An exact size the theme keeps (it wins over the dial) is a fixed - // point the others stay in order around. - /** @type {Record} */ - const kept = {}; - for (const x of data.settings) - if ( - x.source === 'token' && - /^--size-element-/.test(x.thing) && - !x.on && - !x.when && - !x.state && - /^[\d.]+px$/.test(String(x.value).trim()) - ) - kept[x.thing] = parseFloat(String(x.value)); - for (const [k, nv] of controlSizes( - Object.keys(/** @type {Record} */ (TOKENS)) - .filter(k => /^--size-element-/.test(k)) - .map(k => [k, base.light[k] ?? TOKENS[k]]), - f, - parts.length ? {} : kept, - )) - put(k, nv); + // point the others stay in order around: a whole-theme one, one of + // the theme's own source, and on a part the part's own, in any + // unit that comes to a length. + for (const t of parts.length ? parts : [null]) { + /** @type {Record} */ + const kept = {}; + for (const [k, v] of keptSizes(t)) { + const n = sizePx(v); + if (n != null) kept[k] = n; + } + /** @type {string[]} */ + const noRoom = []; + for (const [k, nv] of controlSizes( + Object.keys(/** @type {Record} */ (TOKENS)) + .filter(k => /^--size-element-/.test(k)) + .map(k => [k, base.light[k] ?? TOKENS[k]]), + f, + kept, + noRoom, + )) + if (t) block(s, t)[k] = nv; + else putTok(k, nv); + for (const k of noRoom) + (ctx.sizeNotes ??= []).push( + `${k}${t ? ` on ${t}` : ''} has no room between the exact sizes kept around it, so it takes the size of the one below it`, + ); + } } } else if (s.source === 'token') { if (parts.length) for (const t of parts) putComp(s, t, s.thing, s.value); @@ -3394,11 +3651,115 @@ export function compileTheme(ctx, data, base) { }, ], }; + // A longhand set after a shorthand that covers it is the one in effect, + // as the result says. The build merges these blocks over the base + // theme's, and a key the base holds keeps the base's (earlier) place, so + // such a longhand would come before the shorthand and lose: it is written + // as its own sides instead, which the base does not hold. + const baseBlocks = + /** @type {{components?: Record}} */ (base).components ?? {}; + for (const [target, keys] of Object.entries(theme.components ?? {})) + for (const [key, b] of Object.entries( + /** @type {Record} */ (keys), + )) + laterLonghandsAsSides(b, baseBlocks[target]?.[key]); if (!Object.keys(theme.tokens).length) delete theme.tokens; if (!Object.keys(theme.components).length) delete theme.components; return theme; } +/** + * Whether shorthand `a` sets `b`, a logical side included: padding sets + * padding-inline, border-color sets border-inline-start-color. + * @param {string} a @param {string} b + */ +function coversSide(a, b) { + if (coversProperty(a, b)) return true; + const root = b.replace(/-(inline|block)(-start|-end)?(?=-|$)/, ''); + return root !== b && (a === root || coversProperty(a, root)); +} + +/** A side word in a longhand's name, in the order a box value lists sides. */ +const BOX_SIDES = ['top', 'right', 'bottom', 'left']; + +/** + * A block's longhands that follow a shorthand covering them, where the base + * theme's block holds the same key, rewritten as that longhand's own sides + * (padding-inline as padding-inline-start and -end, border-color as the + * four side colors), in place and at the same position; a state's block + * nested in it too. A longhand that is no side pair or box is kept. + * @param {Record} b + * @param {Record | undefined} baseBlock + */ +function laterLonghandsAsSides(b, baseBlock) { + if ( + !b || + typeof b !== 'object' || + !baseBlock || + typeof baseBlock !== 'object' + ) + return; + for (const [k, v] of Object.entries(b)) + if (k.startsWith(':') && v && typeof v === 'object') + laterLonghandsAsSides(v, baseBlock[k]); + const keys = Object.keys(b); + /** @type {Record} */ + const out = {}; + let changed = false; + keys.forEach((k, i) => { + const v = b[k]; + const css = kebab(k); + const sides = + typeof v === 'string' && + !k.startsWith('-') && + typeof baseBlock[k] === 'string' && + keys + .slice(0, i) + .some(e => !e.startsWith(':') && coversSide(kebab(e), css)) + ? sidesOf(css, v) + : null; + if (!sides) { + out[k] = v; + return; + } + changed = true; + for (const [p, x] of Object.entries(sides)) { + delete out[camel(p)]; + out[camel(p)] = x; + } + }); + if (!changed) return; + for (const k of Object.keys(b)) delete b[k]; + Object.assign(b, out); +} + +/** + * A two-sided (start/end) or four-sided (top/right/bottom/left) longhand's + * value, as each side's property and value by the shorthand rules; null for + * any other property. + * @param {string} prop kebab-case + * @param {string} v + * @returns {Record | null} + */ +function sidesOf(prop, v) { + const direct = [...longhandsOf(prop)].filter(p => longhandsOf(p).size === 0); + const w = splitTop(v.trim(), ' ').map(x => x.trim()); + if (!w.length || /!important/i.test(v)) return null; + const start = direct.find(p => /-start(-|$)/.test(p)); + const end = direct.find(p => /-end(-|$)/.test(p)); + if (direct.length === 2 && start && end && w.length <= 2) + return {[start]: w[0], [end]: w[1] ?? w[0]}; + const named = BOX_SIDES.map(side => + direct.find(p => new RegExp(`-${side}(-|$)`).test(p)), + ); + if (direct.length !== 4 || named.some(x => !x) || w.length > 4) return null; + const [t, r = t, bo = t, l = r] = w; + const vals = [t, r, bo, l]; + return Object.fromEntries( + named.map((p, i) => [/** @type {string} */ (p), vals[i]]), + ); +} + /** Core's default type scale ratio. */ const STANDARD_RATIO = 1.2; /** The font-size tokens below the base, with their step on the scale. */ @@ -3510,10 +3871,10 @@ export function maskedSettings(data, ctx = null) { * {font-weight}`. Find those rules in the built stylesheet. * @param {string} cssText * @param {any} theme - * @returns {Array<{target: string, key: string, prop: string, value: string}>} + * @returns {Array<{target: string, key: string, prop: string, value: string, state?: string}>} */ export function findOverrides(cssText, theme) { - /** @type {Array<{target: string, key: string, prop: string, value: string}>} */ + /** @type {Array<{target: string, key: string, prop: string, value: string, state?: string}>} */ const extra = []; for (const [target, keys] of Object.entries( /** @type {Record} */ (theme.components ?? {}), @@ -3558,6 +3919,51 @@ export function findOverrides(cssText, theme) { } } } + // A state setting on the plain part (a whole-part hover) loses the same + // way to a variant's own rule in that state: it is repeated there too. + for (const [target, keys] of Object.entries( + /** @type {Record} */ (theme.components ?? {}), + )) + for (const [state, decl] of Object.entries(keys.base ?? {})) { + if (!/^:[a-z-]+$/.test(state) || !decl || typeof decl !== 'object') + continue; + const props = Object.keys(decl).filter( + p => !p.startsWith('--') && !p.startsWith(':'), + ); + if (!props.length) continue; + const re = new RegExp( + `\\.astryx-${target.replace(/-/g, '\\-')}\\[data-([a-z-]+)="([^"]+)"\\](?:${state}(?![\\w-])[^{}]*|\\s*)\\{([^}]*)\\}`, + 'g', + ); + // The variant's own rule in that state, and its resting rule: that + // one comes after the plain state rule at the same weight. + const seen = new Set(); + for (const m of cssText.matchAll(re)) { + const declared = [...m[3].matchAll(/(?:^|[;\s])([a-z-]+)\s*:/g)].map( + d => d[1], + ); + for (const prop of props) { + const css = kebab(prop); + if (css === m[1] || css.endsWith(`-${m[1]}`)) continue; + if ( + !seen.has(`${m[1]}:${m[2]} ${prop}`) && + declared.some( + d => + d === css || coversProperty(d, css) || coversProperty(css, d), + ) + ) { + seen.add(`${m[1]}:${m[2]} ${prop}`); + extra.push({ + target, + key: `${m[1]}:${m[2]}`, + state, + prop, + value: decl[prop], + }); + } + } + } + } return extra; } @@ -4157,7 +4563,10 @@ export async function buildTheme(ctx, slug, data) { }); let receipt = await step(() => runBuild(ctx, slug)); const found = findOverrides(fs.readFileSync(f.css, 'utf8'), theme).filter( - x => !(theme.components[x.target][x.key] ?? {})[x.prop], + x => + !((x.state + ? theme.components[x.target][x.key]?.[x.state] + : theme.components[x.target][x.key]) ?? {})[x.prop], ); /** @type {string[]} */ const lifted = []; @@ -4168,13 +4577,16 @@ export async function buildTheme(ctx, slug, data) { // longhand or shorthand the variant sets itself (border-color after // a base border) still wins over it. const own = t[x.key] ?? {}; + const ownState = x.state ? (own[x.state] ?? {}) : own; // A variant that sets every longhand of the property itself needs // none of the base value (and a lifted radius would feed Core's copy // of it into the content's radius, after the variant's own). const longs = [...longhandsOf(kebab(x.prop))]; - if (longs.length && longs.every(l => camel(l) in own)) continue; - t[x.key] = {[x.prop]: x.value, ...own}; - lifted.push(`${x.target} ${x.key} ${kebab(x.prop)}`); + if (longs.length && longs.every(l => camel(l) in ownState)) continue; + t[x.key] = x.state + ? {...own, [x.state]: {[x.prop]: x.value, ...ownState}} + : {[x.prop]: x.value, ...own}; + lifted.push(`${x.target} ${x.key}${x.state ?? ''} ${kebab(x.prop)}`); } await step(() => { writeWhole(f.settingsTs, settingsSource(slug, data, theme, pal, cwd)); diff --git a/packages/cli/api/theme-edit/theme-how.test.mjs b/packages/cli/api/theme-edit/theme-how.test.mjs index 994a72025f12e..1ed5a87a3f1b8 100644 --- a/packages/cli/api/theme-edit/theme-how.test.mjs +++ b/packages/cli/api/theme-edit/theme-how.test.mjs @@ -268,7 +268,7 @@ const QUESTIONS = [ { parts: ['card'], top: /--on card --mode dark$/, - adds: /card\.base\.background=light-dark\(/, + adds: /card\.variant:default\.backgroundColor=light-dark\(/, }, ], [ @@ -1148,7 +1148,7 @@ const QUESTIONS_2 = [ 'in dark mode, cards should be #1c1c1e', { top: /^set q background '#1c1c1e' --on card --mode dark$/, - adds: /card\.base\.background=light-dark\(/, + adds: /card\.variant:default\.backgroundColor=light-dark\(/, }, ], [ @@ -1461,7 +1461,7 @@ describe('how answers pass-2 questions', () => { ], [ 'in dark mode, cards should be #1c1c1e', - /card[^{]*\{[^}]*background:\s*light-dark\(.*#1c1c1e/, + /card[^{]*\{[^}]*background-color:\s*light-dark\(.*#1c1c1e/, ], ['button padding 12px 20px', /button[^{]*\{[^}]*padding:\s*12px 20px/], [ diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs new file mode 100644 index 0000000000000..971e403eaf0f6 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -0,0 +1,402 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Padding, modes and colors against real bundled base themes (parts43): a + * one-mode background on parts whose variants each have their own color, a + * whole-part hover on a base whose variants have their own hover, a + * longhand after a shorthand the base holds, nested cards, the spacing dial + * around kept sizes, and the notes and runs that go with them. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest'; +import {themeEditNew, themeEditSet, themeEditShow} from './theme-edit.mjs'; +import {controlSizes, sizePx} from './_settings.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import {FIXTURE_PACKAGES} from '../../test-utils/base-theme-fixture.mjs'; +import {installRealBaseThemes} from '../../test-utils/real-base-themes.mjs'; +import {refusal} from '../../test-utils/theme-edit-fixture.mjs'; + +vi.mock('./_preview.mjs', async importOriginal => + (await import('../../test-utils/theme-render-stub.mjs')).stubRenders( + await importOriginal(), + ), +); + +/** @type {string} */ +let cwd; + +/** Why a bundled theme could not be built here, when it could not. */ +let unbuilt = ''; + +beforeAll(async () => { + cwd = makeLinkedFixture('astryx-theme-parts43-', FIXTURE_PACKAGES); + unbuilt = await installRealBaseThemes(cwd, ['butter', 'neutral']); +}, 300_000); + +afterAll(() => { + if (cwd) removeLinkedFixture(cwd); +}); + +/** @param {string} slug */ +const cssOf = slug => + fs.readFileSync(path.join(cwd, 'themes', slug, `${slug}.css`), 'utf8'); + +/** @param {string} slug @param {string} selector */ +const ruleOf = (slug, selector) => { + const css = cssOf(slug); + const i = css.indexOf(`${selector} {`); + return i < 0 ? '' : css.slice(i, css.indexOf('}', i)); +}; + +/** @param {string} slug @param {string} mine one components entry */ +const ownSource = (slug, mine) => { + const ts = path.join(cwd, `themes/${slug}/${slug}.theme.ts`); + const src = fs.readFileSync(ts, 'utf8'); + const own = src.replace(/ {2}name: '[^']+',/, m => `${m}\n ${mine}`); + expect(own).not.toBe(src); + fs.writeFileSync(ts, own); +}; + +/** @param {import('vitest').TestContext} ctx */ +const needBases = ctx => { + if (!unbuilt) return false; + console.warn(`skipped: ${unbuilt}`); + ctx.skip(); + return true; +}; + +describe('one-mode backgrounds on parts whose variants have their own (F24-5)', () => { + /** @param {string} slug @param {string} sel */ + const bgOf = (slug, sel) => + /background-color: ([^;]+);/.exec(ruleOf(slug, sel))?.[1]; + /** Every hover rule of the button and badge, as written. @param {string} slug */ + const hoverRules = slug => + ( + cssOf(slug).match(/\.astryx-(button|badge)[^{]*:hover[^{]*\{[^}]*\}/g) ?? + [] + ).join('\n'); + + it('keeps every variant exactly as it was in dark for a light-only background, and leaves hover as it was (neutral)', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5n', {cwd, like: 'neutral'}); + await themeEditSet('m5n', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + // The base theme's own value (destructive), Core's (primary), and a + // variant Core paints nothing on (ghost) stays transparent. + expect(bgOf('m5n', '.astryx-button[data-variant="destructive"]')).toBe( + 'light-dark(#eeeeee, var(--color-error-muted))', + ); + expect(bgOf('m5n', '.astryx-button[data-variant="primary"]')).toBe( + 'light-dark(#eeeeee, var(--color-accent))', + ); + expect(bgOf('m5n', '.astryx-button[data-variant="ghost"]')).toBe( + 'light-dark(#eeeeee, transparent)', + ); + // The part's own rule carries no variant's color. + expect(ruleOf('m5n', '.astryx-button')).not.toMatch(/#eeeeee/); + // No hover rule takes the resting color. + expect(hoverRules('m5n')).not.toMatch(/#eeeeee/); + }, 240_000); + + it('takes the base dark half of a light-dark() and keeps the base hover (butter)', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5b', {cwd, like: 'butter'}); + await themeEditSet('m5b', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + expect(bgOf('m5b', '.astryx-button[data-variant="destructive"]')).toBe( + 'light-dark(#eeeeee, #ffb09b)', + ); + expect(bgOf('m5b', '.astryx-button[data-variant="secondary"]')).toBe( + 'light-dark(#eeeeee, transparent)', + ); + // Butter's own secondary hover still shows, in both modes. + expect( + bgOf( + 'm5b', + '.astryx-button[data-variant="secondary"]:hover:where(:not(:disabled,[aria-disabled="true"]))', + ), + ).toBe('light-dark(#225bff14, #fdee8c14)'); + // A hover setting for one mode does the same in the hover rule. + await themeEditSet('m5b', 'background', '#dddddd', { + cwd, + on: 'button', + state: 'hover', + mode: 'light', + }); + expect( + bgOf( + 'm5b', + '.astryx-button[data-variant="secondary"]:hover:where(:not(:disabled,[aria-disabled="true"]))', + ), + ).toBe('light-dark(#dddddd, #fdee8c14)'); + }, 240_000); + + it('does the same in a light-only theme, with no --mode', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5o', {cwd, like: 'neutral'}); + await themeEditSet('m5o', 'mode', 'light-only', {cwd}); + await themeEditSet('m5o', 'background', '#eeeeee', {cwd, on: 'button'}); + expect(bgOf('m5o', '.astryx-button[data-variant="destructive"]')).toBe( + 'light-dark(#eeeeee, var(--color-error-muted))', + ); + expect(ruleOf('m5o', '.astryx-button')).not.toMatch(/#eeeeee/); + // A badge's text: each variant keeps its own dark text. + await themeEditSet('m5o', 'color', '#222222', {cwd, on: 'badge'}); + expect( + /[\s;{]color: ([^;]+);/.exec( + ruleOf('m5o', '.astryx-badge[data-variant="red"]'), + )?.[1], + ).toBe('light-dark(#222222, var(--color-text-red))'); + }, 240_000); +}); + +describe('a whole-button hover on butter (F24-6)', () => { + it('reaches every variant, the ones with their own hover or resting color included', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('h6', {cwd, like: 'butter'}); + await themeEditSet('h6', 'background', '#ee0000', { + cwd, + on: 'button', + state: 'hover', + }); + for (const v of ['secondary', 'destructive']) + expect( + ruleOf( + 'h6', + `.astryx-button[data-variant="${v}"]:hover:where(:not(:disabled,[aria-disabled="true"]))`, + ), + ).toMatch(/background: #ee0000/); + }, 240_000); +}); + +describe('a longhand after a shorthand the base holds (F24-10)', () => { + it('wins on butter: padding-inline after padding, border-color after border', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('l10', {cwd, like: 'butter'}); + await themeEditSet('l10', 'padding', '4px 6px', {cwd, on: 'button'}); + await themeEditSet('l10', 'padding-inline', '2px', {cwd, on: 'button'}); + const button = ruleOf('l10', '.astryx-button'); + const at = (/** @type {string} */ x) => button.indexOf(x); + expect(at('padding: 4px 6px')).toBeGreaterThan(-1); + expect(at('padding-inline-start: 2px')).toBeGreaterThan( + at('padding: 4px 6px'), + ); + expect(at('padding-inline-end: 2px')).toBeGreaterThan( + at('padding: 4px 6px'), + ); + await themeEditSet('l10', 'border', '2px solid #ff0000', { + cwd, + on: 'button', + when: 'variant:secondary', + }); + await themeEditSet('l10', 'border-color', '#00aa00', { + cwd, + on: 'button', + when: 'variant:secondary', + }); + const sec = ruleOf('l10', '.astryx-button[data-variant="secondary"]'); + expect(sec.indexOf('border-top-color: #00aa00')).toBeGreaterThan( + sec.indexOf('border: 2px solid #ff0000'), + ); + }, 240_000); +}); + +describe('cards inside variant cards (F24-4, N20-2b)', () => { + it("keeps the plain card's side inside a variant card: the reset comes before its direction rules", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('c4', {cwd, like: 'neutral'}); + await themeEditSet('c4', 'padding-right', '9px', {cwd, on: 'card'}); + await themeEditSet('c4', 'padding', '10px', { + cwd, + on: 'card', + when: 'variant:muted', + }); + const css = cssOf('c4'); + const reset = css.indexOf( + '.astryx-card:where(.astryx-card[data-variant="muted"] *) {', + ); + const dir = css.indexOf('.astryx-card:where(:dir(ltr)) {'); + expect(reset).toBeGreaterThan(-1); + expect(dir).toBeGreaterThan(reset); + }, 240_000); + + it("resets the own source's variant sides inside it, and the pair variable on the variant (N20-2b)", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('c4o', {cwd, like: 'neutral'}); + ownSource( + 'c4o', + "components: {card: {'variant:muted': {paddingTop: '30px', paddingInlineStart: '40px'}}},", + ); + await themeEditShow('c4o', {cwd}); + await themeEditSet('c4o', 'padding', '20px', {cwd, on: 'card'}); + const nested = ruleOf( + 'c4o', + '.astryx-card:where(.astryx-card[data-variant="muted"] *)', + ); + expect(nested).toMatch(/--astryx-card-padding-block-start:\s*initial/); + expect(nested).toMatch(/--astryx-card-padding-inline-start:\s*initial/); + + await themeEditNew('n2b', {cwd, like: 'neutral'}); + ownSource( + 'n2b', + "components: {card: {'variant:muted': {padding: '30px'}}},", + ); + await themeEditShow('n2b', {cwd}); + await themeEditSet('n2b', 'padding', '4px 8px', {cwd, on: 'card'}); + await themeEditSet('n2b', 'padding-left', '5px', {cwd, on: 'card'}); + const muted = ruleOf('n2b', '.astryx-card[data-variant="muted"]'); + expect(muted).toMatch(/--astryx-card-padding-inline:\s*initial/); + expect(muted).toMatch(/--astryx-card-padding-block:\s*initial/); + }, 240_000); +}); + +describe('the spacing dial around kept sizes (F24-1, F24-2, F24-9)', () => { + it('reads a kept size in rem or calc as px', () => { + expect(sizePx('34px')).toBe(34); + expect(sizePx('2.125rem')).toBe(34); + expect(sizePx('calc(30px + 4px)')).toBe(34); + expect(sizePx('calc(2rem - 2px)')).toBe(30); + expect(sizePx('var(--x)')).toBeNull(); + expect(sizePx('1px 2px')).toBeNull(); + }); + + it('keeps a size between two close kept ones in order, and says when there is no room', () => { + const sizes = /** @type {Array<[string, string]>} */ ([ + ['--size-element-sm', '28px'], + ['--size-element-md', '32px'], + ['--size-element-lg', '40px'], + ]); + for (const f of [0.85, 1, 1.25]) { + /** @type {string[]} */ + const noRoom = []; + const md = controlSizes( + sizes, + f, + {'--size-element-sm': 30, '--size-element-lg': 31}, + noRoom, + ); + expect(md).toEqual([['--size-element-md', '30.5px']]); + expect(noRoom).toEqual([]); + } + /** @type {string[]} */ + const noRoom = []; + const md = controlSizes( + sizes, + 1, + {'--size-element-sm': 30, '--size-element-lg': 30}, + noRoom, + ); + expect(md).toEqual([['--size-element-md', '30px']]); + expect(noRoom).toEqual(['--size-element-md']); + }); + + it('keeps a rem, calc or own-source size and the part sizes in order (real neutral)', async ctx => { + if (needBases(ctx)) return; + const lgOf = (/** @type {string} */ slug, sel = ':root') => { + const css = cssOf(slug); + const i = css.indexOf(`${sel} {`); + const rule = i < 0 ? css : css.slice(i, css.indexOf('}', i)); + return parseFloat( + /--size-element-lg:\s*([\d.]+)px/.exec(rule)?.[1] ?? '0', + ); + }; + for (const v of ['2.125rem', 'calc(30px + 4px)']) { + const slug = v.startsWith('calc') ? 'd1c' : 'd1r'; + await themeEditNew(slug, {cwd, like: 'neutral'}); + await themeEditSet(slug, '--size-element-md', v, {cwd}); + await themeEditSet(slug, 'spacing', 'tight', {cwd}); + expect(cssOf(slug)).toMatch(/--size-element-lg:\s*3[5-9]px/); + } + await themeEditNew('d1o', {cwd, like: 'neutral'}); + ownSource('d1o', "tokens: {'--size-element-md': '34px'},"); + await themeEditShow('d1o', {cwd}); + await themeEditSet('d1o', 'spacing', 'tight', {cwd}); + expect(cssOf('d1o')).toMatch(/--size-element-lg:\s*3[5-9]px/); + + await themeEditNew('d2', {cwd, like: 'neutral'}); + await themeEditSet('d2', '--size-element-md', '34px', {cwd, on: 'card'}); + const r = await themeEditSet('d2', 'spacing', 'tight', {cwd, on: 'card'}); + const card = ruleOf('d2', '.astryx-card'); + expect(card).toMatch(/--size-element-md:\s*34px/); + expect(lgOf('d2', '.astryx-card')).toBeGreaterThan(34); + expect(r.data.notes.join(' ')).toMatch( + /keeps your exact --size-element-md/, + ); + + await themeEditNew('d2b', {cwd, like: 'neutral'}); + await themeEditSet('d2b', '--size-element-md', '34px', {cwd}); + await themeEditSet('d2b', '--spacing-4', '13px', {cwd}); + await themeEditSet('d2b', 'spacing', 'tight', {cwd, on: 'card'}); + const inCard = ruleOf('d2b', '.astryx-card'); + expect(inCard).not.toMatch(/--size-element-md:/); + expect(inCard).not.toMatch(/--spacing-4:/); + expect(lgOf('d2b', '.astryx-card')).toBeGreaterThan(34); + }, 240_000); +}); + +describe('runs and notes (F24-3, F24-8, N23-5)', () => { + it('offers the per-mode color step that works, and never a currentcolor gradient', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('r3', {cwd, like: 'neutral'}); + const border = await refusal(() => + themeEditSet('r3', 'border', '2px solid #ff0000', { + cwd, + on: 'card', + mode: 'dark', + }), + ); + expect(border.code).toBe('ERR_THEME_NO_MODE'); + expect(border.run).toContainEqual( + expect.stringMatching( + /border-color '#ff0000' --on card --when variant:[\w-]+ --mode dark/, + ), + ); + const grad = await refusal(() => + themeEditSet('r3', 'background', 'linear-gradient(#ff0000, #0000ff)', { + cwd, + on: 'card', + mode: 'dark', + }), + ); + expect(grad.run.join('\n')).not.toMatch(/currentcolor/); + expect(grad.run.join('\n')).toMatch(/linear-gradient\(#ff0000, #0000ff\)/); + }, 240_000); + + it('says the corners show on the other corners when the own source sets one', async ctx => { + if (needBases(ctx)) return; + await themeEditNew('r8', {cwd, like: 'butter'}); + ownSource( + 'r8', + "components: {button: {base: {borderStartEndRadius: '2px'}}},", + ); + await themeEditShow('r8', {cwd}); + await themeEditSet('r8', 'border-radius', '9px', {cwd, on: 'button'}); + const r = await themeEditSet('r8', 'corners', 'pill', {cwd, on: 'button'}); + const notes = r.data.notes.join(' '); + expect(notes).not.toMatch(/does not make corners show/); + expect(notes).toMatch( + /shows only after `[^`]*undo r8 border-radius --on button`, and then not on the border-start-end-radius/, + ); + }, 240_000); + + it("tells a one-mode theme's wiring to give its mode", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('r23', {cwd, like: 'neutral'}); + await themeEditSet('r23', 'mode', 'light-only', {cwd}); + const r = await themeEditShow('r23', {cwd}); + expect(/** @type {any} */ (r.data).wiring.join('\n')).toMatch( + /mode="light"/, + ); + }, 240_000); +}); diff --git a/packages/cli/api/theme-edit/theme-real-bases.test.mjs b/packages/cli/api/theme-edit/theme-real-bases.test.mjs index 9aa794de7db8c..6774bfc9f0e60 100644 --- a/packages/cli/api/theme-edit/theme-real-bases.test.mjs +++ b/packages/cli/api/theme-edit/theme-real-bases.test.mjs @@ -273,8 +273,10 @@ describe('padding on real base themes', () => { /background(-color)?:\s*light-dark\(#eeeeee,/, ); await themeEditSet('om1', 'background', '#f4f4f4', {cwd, on: 'card'}); - expect(ruleOf('om1', '.astryx-card')).toMatch( - /background(-color)?:\s*light-dark\(#f4f4f4,/, + // Each card variant reads its own background: each keeps exactly its + // own other half (parts43). + expect(ruleOf('om1', '.astryx-card[data-variant="default"]')).toMatch( + /background-color:\s*light-dark\(#f4f4f4, var\(--color-background-card\)\)/, ); await themeEditSet('om1', '--color-background-card', '#fafafa', {cwd}); expect(cssOf('om1')).toMatch( From 84d6f0c7652a774bb7da60a7798cd4199b5fa12f Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 05:42:43 -0800 Subject: [PATCH 08/21] fix(cli): experimental theme editor offers a saved French-quoted font family trimmed, as set saves it --- .changeset/experimental-theme-hunt74.md | 7 +++++ packages/cli/api/theme-edit/_check.mjs | 7 ++++- .../cli/api/theme-edit/theme-hunt64.test.mjs | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/experimental-theme-hunt74.md diff --git a/.changeset/experimental-theme-hunt74.md b/.changeset/experimental-theme-hunt74.md new file mode 100644 index 0000000000000..b388e766d8c4c --- /dev/null +++ b/.changeset/experimental-theme-hunt74.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor`: a saved font family in French quotes (`« Inter »`, with plain, no-break or narrow no-break spaces) is offered as `"Inter"`, the value set saves, not `" Inter "`. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_check.mjs b/packages/cli/api/theme-edit/_check.mjs index 26b5277ed27fd..764cc62f8018f 100644 --- a/packages/cli/api/theme-edit/_check.mjs +++ b/packages/cli/api/theme-edit/_check.mjs @@ -104,6 +104,7 @@ import { } from './_settings.mjs'; import {isColorToken} from './_compiler.mjs'; import { + trimInsideQuotes, colorPair, otherSettingLabel, familiesOf, @@ -675,7 +676,11 @@ export function storedProblems(ctx, slug, data, skip = '') { // it. for (const s of data.settings) { if (replaced(s) || typeof s.value !== 'string') continue; - const clean = normalValueText(s.value); + // Read as typed input is (a font family's quotes trimmed too), so the + // line offered is the value set would save. + const clean = /font/i.test(s.thing) + ? trimInsideQuotes(normalValueText(s.value)) + : normalValueText(s.value); if (clean !== s.value) { // Name what is wrong: invisible spaces, or quotes around the value // CSS does not read (a curly quote inside a quoted string is fine, diff --git a/packages/cli/api/theme-edit/theme-hunt64.test.mjs b/packages/cli/api/theme-edit/theme-hunt64.test.mjs index 28b109e48c607..955daca43634a 100644 --- a/packages/cli/api/theme-edit/theme-hunt64.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt64.test.mjs @@ -419,3 +419,29 @@ describe('#11527: quotes and declarations, set end to end', () => { 240_000, ); }); + +describe('hunt74: a saved font family in French quotes', () => { + it.each(['\u00AB Inter \u00BB', '\u00AB\u00A0Inter\u202F\u00BB'])( + '%j offers the trimmed family', + async raw => { + const kept = fs.readFileSync(file(), 'utf8'); + const data = JSON.parse(kept); + data.settings.push({ + thing: '--font-family-body', + value: raw, + source: 'token', + }); + fs.writeFileSync(file(), JSON.stringify(data)); + try { + const err = await refusal(() => themeEditShow('b', {cwd})); + const line = names(err).find((/** @type {string} */ n) => + / set b --font-family-body /.test(n), + ); + expect(line).toMatch(/ set b --font-family-body '"Inter"'$/); + } finally { + fs.writeFileSync(file(), kept); + } + }, + 240_000, + ); +}); From b67e27fa8197c5bc2e367cd09dd038164bc0bef3 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 06:34:56 -0800 Subject: [PATCH 09/21] test(cli): experimental theme tests are named by what they check --- .../cli/api/theme-edit/theme-hunt61.test.mjs | 2 +- .../cli/api/theme-edit/theme-hunt68.test.mjs | 22 +++++----- .../cli/api/theme-edit/theme-hunt69.test.mjs | 32 +++++++------- .../cli/api/theme-edit/theme-hunt71.test.mjs | 18 ++++---- .../cli/api/theme-edit/theme-hunt72.test.mjs | 44 +++++++++---------- .../cli/api/theme-edit/theme-hunt73.test.mjs | 2 +- .../cli/api/theme-edit/theme-parts43.test.mjs | 14 +++--- .../theme-edit/theme-preview-safety.test.mjs | 2 +- 8 files changed, 68 insertions(+), 68 deletions(-) diff --git a/packages/cli/api/theme-edit/theme-hunt61.test.mjs b/packages/cli/api/theme-edit/theme-hunt61.test.mjs index 5f2fd2b331830..f4e536b60dcd4 100644 --- a/packages/cli/api/theme-edit/theme-hunt61.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt61.test.mjs @@ -180,7 +180,7 @@ describe('hunt61c: clause breaks and comparisons', () => { // No padding of the theme's own on buttons: twice the padding a button // has now (the token it reads, hunt63), on cards. const step = await top('cards with twice the padding of buttons'); - // Each side of the button's padding (F24-12). + // Each side of the button's padding. expect(step.command).toMatch(/^set q padding ('[^']+'|\S+) --on card$/); expect(step.text).toMatch(/\(button has [^)]+\)/); // With one: twice that, on cards only. diff --git a/packages/cli/api/theme-edit/theme-hunt68.test.mjs b/packages/cli/api/theme-edit/theme-hunt68.test.mjs index 1b5a3baee0387..bf7d1d74a4a05 100644 --- a/packages/cli/api/theme-edit/theme-hunt68.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt68.test.mjs @@ -58,7 +58,7 @@ const top = async q => { const T = 120_000; -describe('hunt68 F24e-5: lighter or darker fills', () => { +describe('hunt68 lighter or darker fills', () => { it( 'a button starts from its fill at rest, not the pressed overlay', async () => { @@ -78,7 +78,7 @@ describe('hunt68 F24e-5: lighter or darker fills', () => { }); }); -describe('hunt68 F24e-8: except', () => { +describe('hunt68 except', () => { it( 'the excepted dialog keeps its shadow', async () => { @@ -128,7 +128,7 @@ describe('hunt68 F24e-8: except', () => { ); }); -describe('hunt68 F24e-6/18, R26-D/J: parts named in words', () => { +describe('hunt68 parts named in words', () => { it.each( /** @type {Array<[string, RegExp]>} */ ([ ['dialog header gray', /--on dialog-header$/], @@ -175,7 +175,7 @@ describe('hunt68 F24e-6/18, R26-D/J: parts named in words', () => { ); }); -describe('hunt68 F24e-7: comparisons', () => { +describe('hunt68 comparisons', () => { it( 'as round as: the subject only takes the other part value', async () => { @@ -196,7 +196,7 @@ describe('hunt68 F24e-7: comparisons', () => { describe('hunt68: lists, levels, stacks, sides', () => { it( - 'F24e-9 each clause keeps its own variant', + 'each clause keeps its own variant', async () => { const r = await top('make primary buttons red and the secondary blue'); expect(r.all).toMatch(/--when variant:secondary/); @@ -204,7 +204,7 @@ describe('hunt68: lists, levels, stacks, sides', () => { T, ); it( - 'F24e-11 a bare size in a text list', + 'a bare size in a text list', async () => { const r = await top('make buttons bold, uppercase and 14px'); expect(r.all).toMatch(/font-size 14px --on button/); @@ -212,7 +212,7 @@ describe('hunt68: lists, levels, stacks, sides', () => { T, ); it( - 'F24e-13 every heading level from its own size', + 'every heading level from its own size', async () => { const r = await top('make headings 20% bigger'); expect(r.all).toMatch(/--text-heading-1-size/); @@ -221,7 +221,7 @@ describe('hunt68: lists, levels, stacks, sides', () => { T, ); it( - 'F24e-14 a font stack whole on the part', + 'a font stack whole on the part', async () => { const r = await top('card font Inter, sans-serif'); expect(r.all).toBe("set q font-family 'Inter, sans-serif' --on card"); @@ -229,7 +229,7 @@ describe('hunt68: lists, levels, stacks, sides', () => { T, ); it( - 'F24e-12 card text lighter and dialog text twice as big', + 'card text lighter and dialog text twice as big', async () => { expect((await top('make the card text lighter')).all).toMatch( /color .+ --on card/, @@ -241,7 +241,7 @@ describe('hunt68: lists, levels, stacks, sides', () => { T, ); it( - 'R26-I padding sides', + 'padding sides', async () => { expect((await top('padding top 12px on cards')).all).toBe( 'set q padding-top 12px --on card', @@ -266,7 +266,7 @@ describe('hunt68: a second part with an ask of its own', () => { ); }); -describe('hunt68 F24e-21: a theme name near the typed one', () => { +describe('hunt68 a theme name near the typed one', () => { it( 'suggests the same question with the right name', () => { diff --git a/packages/cli/api/theme-edit/theme-hunt69.test.mjs b/packages/cli/api/theme-edit/theme-hunt69.test.mjs index 0d8764b49817c..26dc10125c8b3 100644 --- a/packages/cli/api/theme-edit/theme-hunt69.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt69.test.mjs @@ -2,7 +2,7 @@ /** * @file The sixty-ninth pass over `astryx experimental theme editor how` - * (FINAL-24 amounts): one amount parser for every way an amount is said, + * one amount parser for every way an amount is said, * never read as a length or with a second dial step; fractional pixels * kept; a part's line height and opacity scaled the right way; whole-theme * radius and border width amounts exact; a width amount with no width says @@ -76,11 +76,11 @@ describe('hunt69: how amounts', () => { ['+50%', 1.5], ['-25%', 0.75], ['cut it by a third', 2 / 3], - ])('F24e-1: exactFactor(%s) = %s', (words, f) => { + ])('exactFactor(%s) = %s', (words, f) => { expect(exactFactor(words)).toBeCloseTo(f, 5); }); - it('F24e-1/2: an amount is never a length, and no second dial step', async () => { + it('an amount is never a length, and no second dial step', async () => { for (const [q, want] of [ ['make card padding 4 times', /set t padding 48px --on card/], ['card padding 2× bigger', /set t padding 24px --on card/], @@ -96,7 +96,7 @@ describe('hunt69: how amounts', () => { } }, 240_000); - it('F24e-10: fractional pixels are kept', async () => { + it('fractional pixels are kept', async () => { expect(await how('half the border width on buttons')).toMatch( /border-width 0\.5px --on button/, ); @@ -108,7 +108,7 @@ describe('hunt69: how amounts', () => { ); }, 240_000); - it('F24e-15: a part line height goes the asked way, by the asked factor', async () => { + it('a part line height goes the asked way, by the asked factor', async () => { expect(await how('less line height on cards')).toMatch( /line-height 1\.08 --on card/, ); @@ -120,7 +120,7 @@ describe('hunt69: how amounts', () => { ); }, 240_000); - it('F24e-16: whole-theme radius amounts are exact token values; steps say so', async () => { + it('whole-theme radius amounts are exact token values; steps say so', async () => { const out = await how('double --radius-element'); expect(out).toMatch(/set t --radius-element \d/); expect(out).not.toMatch(/corners more/); @@ -129,25 +129,25 @@ describe('hunt69: how amounts', () => { ); }, 240_000); - it('F24e-17: more transparent lowers the opacity and leaves the fill', async () => { + it('more transparent lowers the opacity and leaves the fill', async () => { const out = await how('make cards 50% more transparent'); expect(out).toMatch(/set t opacity 0\.4 --on card/); expect(out).not.toMatch(/background transparent/); }, 240_000); - it('F24e-19: a width amount with no width says so, never a 720px cap', async () => { + it('a width amount with no width says so, never a 720px cap', async () => { const out = await how('make menus 20% wider'); expect(out).toMatch(/no width of its own to scale/); expect(out).not.toMatch(/720px/); }, 240_000); - it('F24e-20: an own side padding is scaled with the rest', async () => { + it('an own side padding is scaled with the rest', async () => { expect(await how('double the card padding', 's')).toMatch( /set s padding '\S+ 40px' --on card/, ); }, 240_000); - it('F24e-3: a px move keeps the state and starts from its value', async () => { + it('a px move keeps the state and starts from its value', async () => { expect(await how('make button corners 2px rounder on hover')).toMatch( /set t border-radius 10px --on button --state hover/, ); @@ -156,7 +156,7 @@ describe('hunt69: how amounts', () => { ); }, 240_000); - it('F24e-4: whole-theme border width amounts scale the token', async () => { + it('whole-theme border width amounts scale the token', async () => { expect(await how('double the border width everywhere')).toMatch( /set t --border-width 6px/, ); @@ -165,7 +165,7 @@ describe('hunt69: how amounts', () => { ); }, 240_000); - it('R26-E: a corner percent with an amount is an exact length, never a % radius', async () => { + it('a corner percent with an amount is an exact length, never a % radius', async () => { for (const q of [ 'card corners 50% of now', 'card corners 50% smaller', @@ -177,13 +177,13 @@ describe('hunt69: how amounts', () => { } }, 240_000); - it('R26-K: a corner amount with no radius to read says it is a step', async () => { + it('a corner amount with no radius to read says it is a step', async () => { expect(await how('halve the corner radius on the popover', 's')).toMatch( /one step, not an exact amount/, ); }, 240_000); - it('R26-B: zero words remove the asked thing, on the asked state', async () => { + it('zero words remove the asked thing, on the asked state', async () => { expect(await how('no inner spacing on cards', 's')).toMatch( /set s padding 0px --on card/, ); @@ -196,13 +196,13 @@ describe('hunt69: how amounts', () => { expect( await how('no inner spacing on text-input when disabled', 's'), ).toMatch(/padding 0px --on text-input --state disabled/); - // A group member named in words is that member (hunt68 F24e-18). + // A group member named in words is that member. expect(await how('more padding on text inputs on hover', 's')).toMatch( /--on text-input --state hover/, ); }, 240_000); - it('R25-A: font size and line height amounts on parts with no size of their own', async () => { + it('font size and line height amounts on parts with no size of their own', async () => { expect(await how('double the font size on avatars', 's')).toMatch( /set s font-size \S+ --on avatar/, ); diff --git a/packages/cli/api/theme-edit/theme-hunt71.test.mjs b/packages/cli/api/theme-edit/theme-hunt71.test.mjs index 6ee59529122d1..fe5290772703d 100644 --- a/packages/cli/api/theme-edit/theme-hunt71.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt71.test.mjs @@ -39,7 +39,7 @@ afterAll(() => { }); describe('hunt71', () => { - it('E3: a path typed for a new name names the theme it ends in', async () => { + it('a path typed for a new name names the theme it ends in', async () => { const fresh = await refusal(() => themeEditNew('themes/brand', {cwd})); expect(fresh.run[0]).toMatch(/ new brand$/); await themeEditNew('t', {cwd}); @@ -50,7 +50,7 @@ describe('hunt71', () => { } }, 240_000); - it('E4: undo with unreadable settings and no history names a command that works', async () => { + it('undo with unreadable settings and no history names a command that works', async () => { await themeEditNew('d4', {cwd}); fs.writeFileSync(path.join(cwd, 'themes/d4/d4.settings.json'), ''); fs.rmSync(path.join(cwd, 'themes/d4/d4.history.json'), {force: true}); @@ -59,7 +59,7 @@ describe('hunt71', () => { expect(r.run.join(' ')).toMatch(/ new d4/); }, 240_000); - it('E5: a folder holding only desktop.ini is empty', async () => { + it('a folder holding only desktop.ini is empty', async () => { fs.mkdirSync(path.join(cwd, 'themes/e5'), {recursive: true}); fs.writeFileSync( path.join(cwd, 'themes/e5/desktop.ini'), @@ -71,7 +71,7 @@ describe('hunt71', () => { ); }, 240_000); - it('E9: a typo or camelCase of a theme name offers the theme meant', async () => { + it('a typo or camelCase of a theme name offers the theme meant', async () => { await themeEditNew('brand', {cwd}); await themeEditNew('brand-dark', {cwd}); const typo = await refusal(() => @@ -86,7 +86,7 @@ describe('hunt71', () => { themeEditSet('brandDark', 'corners', 'pill', {cwd}), ); expect(camel.run[0]).toMatch(/ set brand-dark corners pill$/); - // CC-2: themePark is theme-park, never a new themepark. + // themePark is theme-park, never a new themepark. await themeEditNew('theme-park', {cwd}); const park = await refusal(() => themeEditSet('themePark', 'corners', 'pill', {cwd}), @@ -108,7 +108,7 @@ describe('hunt71', () => { expect(said(again)).toMatch(/given mode="light"/); }, 240_000); - it('E8: undo --help says --mode removes a setting saved for one mode', () => { + it('undo --help says --mode removes a setting saved for one mode', () => { const mode = /** @type {any} */ (undoDoc).options.find( (/** @type {any} */ o) => o.flag === '--mode ', ); @@ -116,7 +116,7 @@ describe('hunt71', () => { expect(mode.description).toContain('removes a setting saved for one mode'); }); - it('E10: a stop after the save says the change is saved and how to run the rest', () => { + it('a stop after the save says the change is saved and how to run the rest', () => { const write = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -135,7 +135,7 @@ describe('hunt71', () => { expect(line).toMatch(/change to t is saved and built.*show t runs them/); }); - it('R26-C: a damaged mode and a bad value: the fix for either one works', async () => { + it('a damaged mode and a bad value: the fix for either one works', async () => { await themeEditNew('c26', {cwd}); await themeEditSet('c26', 'corners', 'pill', {cwd}); const file = path.join(cwd, 'themes/c26/c26.settings.json'); @@ -161,7 +161,7 @@ describe('hunt71', () => { await themeEditUndo('c26', {cwd, thing: 'corners'}); }, 240_000); - it('R26-L: an entry with no source is damage new keeps aside, as the refusal says', async () => { + it('an entry with no source is damage new keeps aside, as the refusal says', async () => { await themeEditNew('l26', {cwd}); const file = path.join(cwd, 'themes/l26/l26.settings.json'); const j = JSON.parse(fs.readFileSync(file, 'utf8')); diff --git a/packages/cli/api/theme-edit/theme-hunt72.test.mjs b/packages/cli/api/theme-edit/theme-hunt72.test.mjs index 60ec9cf6f0626..caf59616eaa3a 100644 --- a/packages/cli/api/theme-edit/theme-hunt72.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt72.test.mjs @@ -94,7 +94,7 @@ async function refusal(run) { const names = (/** @type {any} */ e) => (e.suggestions ?? []).map((/** @type {any} */ x) => x.name); -describe('CC-3: with several editor themes, the see line names the theme', () => { +describe('with several editor themes, the see line names the theme', () => { it('suggests preview add --theme ', async () => { await themeEditNew('u', {cwd, like: 'neutral'}); const r = await themeEditSet('u', 'corners', 'pill', {cwd}); @@ -102,7 +102,7 @@ describe('CC-3: with several editor themes, the see line names the theme', () => }, 240_000); }); -describe('CC-6: preview remove given a file', () => { +describe('preview remove given a file', () => { it('names the preview folder the file is in', async () => { await themeEditPreviewAdd(undefined, {cwd, theme: 't'}); const err = await refusal(() => @@ -123,7 +123,7 @@ describe('CC-6: preview remove given a file', () => { }); }); -describe('CC-10: a route changed by hand', () => { +describe('a route changed by hand', () => { it('previewStale and see say why and what to do', async () => { const page = path.join(cwd, 'src/app/theme-preview/page.tsx'); const kept = fs.readFileSync(page, 'utf8'); @@ -145,7 +145,7 @@ describe('CC-10: a route changed by hand', () => { }, 240_000); }); -describe('CC-8: a preview moved where Next does not serve it', () => { +describe('a preview moved where Next does not serve it', () => { it('see and list hand out no URL', async () => { const from = path.join(cwd, 'src/app/theme-preview'); const to = path.join(cwd, 'src/app/_private/tp'); @@ -163,7 +163,7 @@ describe('CC-8: a preview moved where Next does not serve it', () => { }, 240_000); }); -describe('CC-1: preview.html is a folder', () => { +describe('preview.html is a folder', () => { it('says it could not be written, never that it could not be rendered', async () => { const f = path.join(cwd, 'themes/t/preview.html'); fs.rmSync(f, {force: true}); @@ -179,7 +179,7 @@ describe('CC-1: preview.html is a folder', () => { }, 240_000); }); -describe('CC-4 and CC-9: pictures', () => { +describe('pictures', () => { it('a picture of yours in the way: see says what blocks --png', async () => { const own = path.join(cwd, 'themes/t/preview.png'); fs.writeFileSync(own, 'mine'); @@ -215,7 +215,7 @@ describe('CC-4 and CC-9: pictures', () => { }, 240_000); }); -describe('CC-7: run from a subfolder', () => { +describe('run from a subfolder', () => { it('see names files by paths that work from there', async () => { const r = await themeEditSet('t', 'corners', 'round', { cwd: path.join(cwd, 'src'), @@ -225,7 +225,7 @@ describe('CC-7: run from a subfolder', () => { }, 240_000); }); -describe('CC-5: the show help on pictures', () => { +describe('the show help on pictures', () => { it('says show may bring the app copy up to date after a hand edit', () => { const png = showDoc.options.find( (/** @type {any} */ o) => o.flag === '--png', @@ -236,7 +236,7 @@ describe('CC-5: the show help on pictures', () => { }); }); -describe('R26-M: a dial value labeled with another setting', () => { +describe('a dial value labeled with another setting', () => { it.each([ ['accent', 'neutral: #64748b', 'neutral'], ['accent', '--color-accent: #64748b', '--color-accent'], @@ -252,7 +252,7 @@ describe('R26-M: a dial value labeled with another setting', () => { }, 240_000); }); -describe('FINAL-24 R02: the normaliser', () => { +describe('the normaliser', () => { it.each([ ['\u201DInter\u201C', '"Inter"'], ['\u201EInter\u201C', '"Inter"'], @@ -260,7 +260,7 @@ describe('FINAL-24 R02: the normaliser', () => { ['\u300EInter\u300F', '"Inter"'], ['url("a.png?x=1;y:2")', 'url("a.png?x=1;y:2")'], ['"Foo; Bar: X", serif', '"Foo; Bar: X", serif'], - ])('N71/N59 %j reads as %j', (typed, want) => { + ])('%j reads as %j', (typed, want) => { expect(normalValueText(typed)).toBe(want); }); @@ -271,7 +271,7 @@ describe('FINAL-24 R02: the normaliser', () => { ['content', "'it\\'s'"], ['content', '"\\""'], ['content', '"a (b"'], - ])('N72/N74 %s %j is kept whole', (thing, value) => { + ])('%s %j is kept whole', (thing, value) => { expect(normalizeEntry(thing, value).value).toBe(value); expect(withoutDebris(value)).toBe(value); }); @@ -284,7 +284,7 @@ describe('FINAL-24 R02: the normaliser', () => { ['buttons', 'soft,', 'soft'], ['accent', '"#e11d48",', '#e11d48'], ['accent', '#e11d48 !important', '#e11d48'], - ])('N75 saved %s %j is cleaned to %j', (dial, raw, want) => { + ])('saved %s %j is cleaned to %j', (dial, raw, want) => { expect(cleanDialValue(dial, raw)).toBe(want); }); @@ -294,24 +294,24 @@ describe('FINAL-24 R02: the normaliser', () => { ['{"light":"red","dark":"blue"}', {light: 'red', dark: 'blue'}], ['red / blue', {light: 'red', dark: 'blue'}], ['red, blue', {light: 'red', dark: 'blue'}], - ])('N79 %j is a pair', (typed, want) => { + ])('%j is a pair', (typed, want) => { expect(colorPair(typed)).toEqual(want); expect(normalizeEntry('--color-accent', typed).value).toBe(typed); }); - it('N79 rgb() with a slash is no pair', () => { + it('rgb() with a slash is no pair', () => { expect(colorPair('rgb(1 2 3 / 50%)')).toBeNull(); }); - it('N78 a font stack as a JSON string is the stack', () => { + it('a font stack as a JSON string is the stack', () => { expect( normalizeEntry('--font-family-body', '"\\"Open Sans\\", serif"').value, ).toBe('"Open Sans", serif'); }); }); -describe('FINAL-24 R02: set end to end', () => { - it('N79 a token offers light-dark() and the two --mode lines', async () => { +describe('set end to end', () => { + it('a token offers light-dark() and the two --mode lines', async () => { const err = await refusal(() => themeEditSet('t', '--color-accent', 'light: red; dark: blue', {cwd}), ); @@ -324,7 +324,7 @@ describe('FINAL-24 R02: set end to end', () => { ); }); - it('N79 the accent dial takes one color, and the token takes the pair', async () => { + it('the accent dial takes one color, and the token takes the pair', async () => { const err = await refusal(() => themeEditSet('t', 'accent', 'red / blue', {cwd}), ); @@ -332,7 +332,7 @@ describe('FINAL-24 R02: set end to end', () => { expect(names(err)[0]).toMatch(/ set t accent red$/); }); - it('N78 a JSON object of two settings gives one line each', async () => { + it('a JSON object of two settings gives one line each', async () => { const err = await refusal(() => themeEditSet('t', '{"accent":"#e11d48","corners":"pill"}', '', {cwd}), ); @@ -345,7 +345,7 @@ describe('FINAL-24 R02: set end to end', () => { it.each([ ['night', 'dark'], ['day', 'light'], - ])('N77 --mode %s is %s', async (mode, want) => { + ])('--mode %s is %s', async (mode, want) => { const err = await refusal(() => themeEditSet('t', '--color-accent', 'red', {cwd, mode}), ); @@ -356,7 +356,7 @@ describe('FINAL-24 R02: set end to end', () => { ['0.5', / set t vibrancy 50$/], ['"50"', / set t vibrancy 50$/], ])( - 'N80 vibrancy %j', + 'vibrancy %j', async (value, want) => { await themeEditSet('t', 'accent', '#e11d48', {cwd}); const err = await refusal(() => diff --git a/packages/cli/api/theme-edit/theme-hunt73.test.mjs b/packages/cli/api/theme-edit/theme-hunt73.test.mjs index 9adba45960480..d91d5b2da6cab 100644 --- a/packages/cli/api/theme-edit/theme-hunt73.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt73.test.mjs @@ -42,7 +42,7 @@ describe('hunt73', () => { ['base', '"butter"'], ['mode', '"dark-only"'], ])( - 'N76: a saved %s entry is offered only lines that unlock the theme', + 'a saved %s entry is offered only lines that unlock the theme', async (thing, value) => { const slug = `n76-${thing}`; await themeEditNew(slug, {cwd}); diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index 971e403eaf0f6..b6368e4fb1aef 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -70,7 +70,7 @@ const needBases = ctx => { return true; }; -describe('one-mode backgrounds on parts whose variants have their own (F24-5)', () => { +describe('one-mode backgrounds on parts whose variants have their own', () => { /** @param {string} slug @param {string} sel */ const bgOf = (slug, sel) => /background-color: ([^;]+);/.exec(ruleOf(slug, sel))?.[1]; @@ -161,7 +161,7 @@ describe('one-mode backgrounds on parts whose variants have their own (F24-5)', }, 240_000); }); -describe('a whole-button hover on butter (F24-6)', () => { +describe('a whole-button hover on butter', () => { it('reaches every variant, the ones with their own hover or resting color included', async ctx => { if (needBases(ctx)) return; await themeEditNew('h6', {cwd, like: 'butter'}); @@ -180,7 +180,7 @@ describe('a whole-button hover on butter (F24-6)', () => { }, 240_000); }); -describe('a longhand after a shorthand the base holds (F24-10)', () => { +describe('a longhand after a shorthand the base holds', () => { it('wins on butter: padding-inline after padding, border-color after border', async ctx => { if (needBases(ctx)) return; await themeEditNew('l10', {cwd, like: 'butter'}); @@ -212,7 +212,7 @@ describe('a longhand after a shorthand the base holds (F24-10)', () => { }, 240_000); }); -describe('cards inside variant cards (F24-4, N20-2b)', () => { +describe('cards inside variant cards', () => { it("keeps the plain card's side inside a variant card: the reset comes before its direction rules", async ctx => { if (needBases(ctx)) return; await themeEditNew('c4', {cwd, like: 'neutral'}); @@ -231,7 +231,7 @@ describe('cards inside variant cards (F24-4, N20-2b)', () => { expect(dir).toBeGreaterThan(reset); }, 240_000); - it("resets the own source's variant sides inside it, and the pair variable on the variant (N20-2b)", async ctx => { + it("resets the own source's variant sides inside it, and the pair variable on the variant", async ctx => { if (needBases(ctx)) return; await themeEditNew('c4o', {cwd, like: 'neutral'}); ownSource( @@ -261,7 +261,7 @@ describe('cards inside variant cards (F24-4, N20-2b)', () => { }, 240_000); }); -describe('the spacing dial around kept sizes (F24-1, F24-2, F24-9)', () => { +describe('the spacing dial around kept sizes', () => { it('reads a kept size in rem or calc as px', () => { expect(sizePx('34px')).toBe(34); expect(sizePx('2.125rem')).toBe(34); @@ -345,7 +345,7 @@ describe('the spacing dial around kept sizes (F24-1, F24-2, F24-9)', () => { }, 240_000); }); -describe('runs and notes (F24-3, F24-8, N23-5)', () => { +describe('runs and notes', () => { it('offers the per-mode color step that works, and never a currentcolor gradient', async ctx => { if (needBases(ctx)) return; await themeEditNew('r3', {cwd, like: 'neutral'}); diff --git a/packages/cli/api/theme-edit/theme-preview-safety.test.mjs b/packages/cli/api/theme-edit/theme-preview-safety.test.mjs index 69c3a152424cf..9c3641c599b40 100644 --- a/packages/cli/api/theme-edit/theme-preview-safety.test.mjs +++ b/packages/cli/api/theme-edit/theme-preview-safety.test.mjs @@ -779,7 +779,7 @@ describe('pass 2', () => { await themeEditPreviewAdd(undefined, {cwd, theme: 'brand'}); fs.appendFileSync(path.join(cwd, ROUTE, '_pages/colors.tsx'), '// MINE\n'); const r = await themeEditSet('brand', 'accent', '#e11d48', {cwd}); - // Named, with why it was left and what to do (hunt72 CC-10). + // Named, with why it was left and what to do. expect(/** @type {any} */ (r.data).previewStale).toEqual([ expect.stringMatching( new RegExp( From d19ec5d5af56a5af7a7112fb745e74a54c139311 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 07:16:14 -0800 Subject: [PATCH 10/21] experimental theme: a one-mode color on each variant keeps the other mode's color the variant rule already holds --- packages/cli/api/theme-edit/_settings.mjs | 12 ++++++++--- .../cli/api/theme-edit/theme-parts43.test.mjs | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index ed94a40a31162..70d0be4eb433b 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -3140,9 +3140,15 @@ export function compileTheme(ctx, data, base) { ]); for (const name of names) { const x = exactVariantValue(target, css, name, s.state); - if (x == null) continue; - const half = halfOf(x, other); - block({...s, when: `variant:${name}`}, target)[camel(css)] = + const vb = block({...s, when: `variant:${name}`}, target); + // What the variant's rule already holds (a setting for the other + // mode, or one for both) keeps its other half; else the value the + // variant shows unset. + const prev = vb[camel(css)]; + const from = typeof prev === 'string' ? prev : x; + if (from == null) continue; + const half = halfOf(from, other); + vb[camel(css)] = mode === 'light' ? `light-dark(${v}, ${half})` : `light-dark(${half}, ${v})`; diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index b6368e4fb1aef..964bbc2081554 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -142,6 +142,27 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { ).toBe('light-dark(#dddddd, #fdee8c14)'); }, 240_000); + for (const [first, second] of [ + ['light', 'dark'], + ['dark', 'light'], + ]) + it(`keeps a ${first}-mode background when a ${second}-mode one follows, on every variant`, async ctx => { + if (needBases(ctx)) return; + const slug = `m5${first[0]}${second[0]}`; + await themeEditNew(slug, {cwd, like: 'neutral'}); + const color = {light: '#eeeeee', dark: '#222222'}; + for (const mode of [first, second]) + await themeEditSet(slug, 'background', color[mode], { + cwd, + on: 'button', + mode, + }); + for (const name of ['primary', 'secondary', 'ghost', 'destructive']) + expect(bgOf(slug, `.astryx-button[data-variant="${name}"]`)).toBe( + 'light-dark(#eeeeee, #222222)', + ); + }, 240_000); + it('does the same in a light-only theme, with no --mode', async ctx => { if (needBases(ctx)) return; await themeEditNew('m5o', {cwd, like: 'neutral'}); From 879678c8ae386a03df670d53f6ac2978c7f41b5c Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 07:12:47 -0800 Subject: [PATCH 11/21] experimental theme: how reads padding sides outside part names, bigger than another part as a step past it, and N x M as a size --- .../experimental-theme-how-sides-in-names.md | 7 +++ packages/cli/api/theme-edit/_how-question.mjs | 56 ++++++++++++++----- .../cli/api/theme-edit/theme-hunt68.test.mjs | 47 +++++++++++++++- 3 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 .changeset/experimental-theme-how-sides-in-names.md diff --git a/.changeset/experimental-theme-how-sides-in-names.md b/.changeset/experimental-theme-how-sides-in-names.md new file mode 100644 index 0000000000000..dc0b1c49374b3 --- /dev/null +++ b/.changeset/experimental-theme-how-sides-in-names.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor how`: padding side words are read with the part's name taken out, so "code block padding" and "top nav padding" set every side of that part. "Card padding bigger (or smaller) than button padding" gives a step past (or under) the button's padding, never a copy of it. A dimension pair such as "16 x 16" is a size, never an amount. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_how-question.mjs b/packages/cli/api/theme-edit/_how-question.mjs index 77e7bd23525f9..59c40a4388fc0 100644 --- a/packages/cli/api/theme-edit/_how-question.mjs +++ b/packages/cli/api/theme-edit/_how-question.mjs @@ -790,6 +790,8 @@ export function exactFactor(text) { ); if (factor && Number(factor[1]) >= 0 && Number(factor[1]) <= 10) return Number(factor[1]); + // "16 x 16", "24x24px": a size, never a factor. + if (/\b\d*\.?\d+(?:px|rem|em)?\s*[x×]\s*\d*\.?\d+\b/.test(t)) return null; // "3 times bigger", "2 times smaller", "4 times", "2x", "1.5x", "x2". const nTimes = /\b(\d*\.?\d+)(?:px)?\s*(?:times|x)\b(?:\s+(bigger|larger|more|wider|taller|thicker|as|smaller|less|thinner|narrower))?/.exec( @@ -3605,7 +3607,7 @@ export function draftAnswer(ctx, env, question) { // of that property now, written out; the other part is not changed. { const measure = - /\bas (round(?:ed)?|tall|high|bold|big|large) as\b|\bsame (corners?|radius|roundness|padding|height|font size|text size|weight) as\b|\b(?:match(?:es|ing)?|like)\b[^.]*?\b(corners?|radius|padding|height)\b|\b(corners?|radius|padding|height)\b[^.]*?\b(?:the )?same as\b/.exec( + /\bas (round(?:ed)?|tall|high|bold|big|large) as\b|\bsame (corners?|radius|roundness|padding|height|font size|text size|weight) as\b|\b(?:match(?:es|ing)?|(? 1 ? 'past' : 'under'} ${sourceOn}'s: ${value} (${sourceOn} has ${now} now and is not changed)` + : `${subjectOn} takes ${factor ? `${factor} times ` : ''}${sourceOn}'s ${prop}: ${value} (${sourceOn} has ${now} now and is not changed)`, options: [[set(prop, value, {on: subjectOn})]], }; push(d); @@ -4051,12 +4069,7 @@ export function draftAnswer(ctx, env, question) { c.parts.every(p => TARGET.has(one(p.on)) || FAMILIES[p.on]) ) { // "horizontal padding 20px", "padding top 12px": only those sides. - const side = - named.prop === 'padding' - ? /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( - c.text, - )?.[1] - : null; + const side = named.prop === 'padding' ? sideIn(c) : null; const prop = !side ? named.prop : /^(left|right|top|bottom)$/.test(side) @@ -4313,6 +4326,23 @@ export function draftAnswer(ctx, env, question) { return live ? `var(${read.token})` : resolvedValue(read.token); } + /** + * The side word of a padding ask ("top", "horizontal", "block"), read with + * the clause's part names taken out ("code block", "top nav" are parts, + * not sides). + * @param {Clause} c + * @returns {string | undefined} + */ + function sideIn(c) { + const ons = new Set(c.parts.map(x => x.on)); + const text = lex.parts + .filter(([, x]) => ons.has(x.on)) + .reduce((t, [re]) => t.replace(new RegExp(re.source, 'g'), ' '), c.text); + return /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( + text, + )?.[1]; + } + /** * Whether a part reads a token in any of its rules. * @param {string} part @@ -5412,11 +5442,7 @@ export function draftAnswer(ctx, env, question) { : null; // "padding top 12px", "more padding on the left", "horizontal // padding 20px": only those sides. - const sideWord = gap - ? null - : /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( - c.text, - )?.[1]; + const sideWord = gap ? null : sideIn(c); if (sideWord) { const sideProp = /^(left|right|top|bottom)$/.test(sideWord) ? `padding-${sideWord}` diff --git a/packages/cli/api/theme-edit/theme-hunt68.test.mjs b/packages/cli/api/theme-edit/theme-hunt68.test.mjs index bf7d1d74a4a05..554f278b1bf93 100644 --- a/packages/cli/api/theme-edit/theme-hunt68.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt68.test.mjs @@ -17,7 +17,7 @@ import * as path from 'node:path'; import {spawnSync} from 'node:child_process'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import {themeEditHow, themeEditNew} from './theme-edit.mjs'; -import {shiftKeepAlpha} from './_how-question.mjs'; +import {exactFactor, shiftKeepAlpha} from './_how-question.mjs'; import { makeLinkedFixture, removeLinkedFixture, @@ -254,6 +254,51 @@ describe('hunt68: lists, levels, stacks, sides', () => { ); }); +describe('padding sides are read outside part names', () => { + it( + 'code block padding is all sides of the code block', + async () => { + expect((await top('code block padding 12px')).all).toBe( + 'set q padding 12px --on code-block', + ); + }, + T, + ); + it( + 'top nav padding is all sides of the top nav', + async () => { + expect((await top('top nav padding 20px')).all).toBe( + 'set q padding 20px --on top-nav', + ); + }, + T, + ); +}); + +describe('bigger or smaller than another part', () => { + it( + 'is a step past the other part value, never a copy', + async () => { + const r = await top( + 'I would like card padding bigger than button padding', + ); + expect(r.all).toMatch(/^set q padding \S+ --on card$/); + expect(r.text).toContain("a step past button's"); + const under = await top('card padding smaller than button padding'); + expect(under.text).toContain("a step under button's"); + }, + T, + ); +}); + +describe('a dimension pair is a size', () => { + it('16 x 16 is not sixteen times', () => { + expect(exactFactor('icons 16 x 16')).toBeNull(); + expect(exactFactor('24x24px icons')).toBeNull(); + expect(exactFactor('2x bigger')).toBe(2); + }); +}); + describe('hunt68: a second part with an ask of its own', () => { it( 'keeps its own ask, not the first part', From ca8b99ea23b45fb34a644f48e7d4b0e4a3b8ccad Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 07:11:38 -0800 Subject: [PATCH 12/21] experimental theme: the appCopyStale description lists every reason it is set --- packages/cli/api/theme-edit/theme-edit.type.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/api/theme-edit/theme-edit.type.mjs b/packages/cli/api/theme-edit/theme-edit.type.mjs index feccd72c874a8..a8117062be796 100644 --- a/packages/cli/api/theme-edit/theme-edit.type.mjs +++ b/packages/cli/api/theme-edit/theme-edit.type.mjs @@ -55,7 +55,7 @@ * @property {string[]} [previewStale] - Files of the in-app theme preview that were not written again and still show the theme as it was: changed by hand, or a preview folder where a stopped preview change waits for the next preview command. * @property {string} see - One line on how to see the change: first a look that changes nothing in the app (the editor's preview.html, current after the change, and `png`, which takes pictures with no dev server; when this result's own picture run was blocked, what blocked it instead), then the in-app preview page most about it (Colors for a color, Components for a part, else Overview) at its own URL when there is a preview, and adding one to the app last, as an option. When the change could not be rendered, it names why instead of the editor's page. * @property {{wrote: string[]}} [appCopy] - Only the first time the theme's app copy is written (src/themes//, a standard local theme `astryx theme add ` takes, written again after every build): the files written. - * @property {string} [appCopyStale] - Set when the app copy (src/themes//) was not written because a file of yours has one of its names, or a link is on the way to it (src/, src/themes/ or the copy's folder is a link, and the editor writes nothing through one): the app keeps the theme as it was, and this says which file or link. + * @property {string} [appCopyStale] - Set when the app copy (src/themes//) was not written: a file, folder or link of yours at one of its names; a link on the way to it (src/, src/themes/ or the copy's folder; the editor writes nothing through one); a file where src/, src/themes/ or the copy's folder goes; or a folder that cannot be written, or a write that failed (with the system's reason, such as not writable or the disk is full). The app keeps the theme as it was, and this says what is in the way and why. */ /** @@ -101,7 +101,7 @@ * @property {string[]} [previewStale] - In-app preview files changed by hand: see ThemeEditPreviewFields. * @property {string} see - How to see the change: see ThemeEditPreviewFields. * @property {string} [sheetStale] - Set when sheet.html shows the theme as it was before this change: see ThemeEditPreviewFields. - * @property {string} [appCopyStale] - Set when a file of yours at one of the app copy's names, or a link on the way to it, kept the copy from being written: see ThemeEditPreviewFields. + * @property {string} [appCopyStale] - Set when the app copy could not be written (something of yours at a copy name or on the way, or a write that failed): see ThemeEditPreviewFields. * @property {{wrote: string[]}} [appCopy] - Only the first time the theme's app copy is written: see ThemeEditPreviewFields. */ @@ -139,7 +139,7 @@ * @property {string | null} data.pictureProblem * @property {{config: string, module: string, receipt: string} | null} data.colorFiles - The generated color files: the input (in the shape `theme palette generate` reads), the module the theme imports, and the receipt; null when the theme sets no color. * @property {{wrote: string[]}} [data.appCopy] - Only the first time the theme's app copy is written: the files written in src/themes//. - * @property {string} [data.appCopyStale] - Set when a file of yours at one of the app copy's names, or a link on the way to it, kept the copy from being written. + * @property {string} [data.appCopyStale] - Set when the app copy was not written: a file, folder or link of yours at one of its names; a link on the way to it (src/, src/themes/ or the copy's folder; the editor writes nothing through one); a file where src/, src/themes/ or the copy's folder goes; or a folder that cannot be written, or a write that failed (with the system's reason, such as not writable or the disk is full). * @property {string[]} data.wiring - The steps that put the theme into this app: the root file, its imports, the Theme around the app, and what builds it (`theme add `, for the app copy the editor writes in src/themes//; the imports by hand when a folder of yours is there). */ From 0b8c4b39b903e7dddd55977ee9e15aa937d34d8d Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 07:52:12 -0800 Subject: [PATCH 13/21] experimental theme: a one-mode color per variant keeps a variant's own background as its other half and leaves no background shorthand over it --- packages/cli/api/theme-edit/_settings.mjs | 13 +++++++++++-- .../cli/api/theme-edit/theme-parts43.test.mjs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index 70d0be4eb433b..c0e304605d391 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -3144,9 +3144,18 @@ export function compileTheme(ctx, data, base) { // What the variant's rule already holds (a setting for the other // mode, or one for both) keeps its other half; else the value the // variant shows unset. - const prev = vb[camel(css)]; - const from = typeof prev === 'string' ? prev : x; + // The background shorthand is the same property: its color counts, + // and it is taken out so the color written here is in effect. + const family = [ + css, + ...(css === 'background-color' ? ['background'] : []), + ]; + const prev = family + .map(k => vb[camel(k)]) + .find(y => typeof y === 'string' && !/gradient\(|url\(/i.test(y)); + const from = prev ?? x; if (from == null) continue; + for (const k of family) delete vb[camel(k)]; const half = halfOf(from, other); vb[camel(css)] = mode === 'light' diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index 964bbc2081554..7ad72b1409693 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -163,6 +163,24 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { ); }, 240_000); + it("keeps a variant's own background as the other half of a later one-mode background", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5v', {cwd, like: 'neutral'}); + await themeEditSet('m5v', 'background', '#ff0000', { + cwd, + on: 'button', + when: 'variant:primary', + }); + await themeEditSet('m5v', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + const rule = ruleOf('m5v', '.astryx-button[data-variant="primary"]'); + expect(rule).toMatch(/background-color: light-dark\(#eeeeee, #ff0000\)/); + expect(rule).not.toMatch(/background:/); + }, 240_000); + it('does the same in a light-only theme, with no --mode', async ctx => { if (needBases(ctx)) return; await themeEditNew('m5o', {cwd, like: 'neutral'}); From 7e0c5047d6648b85d1f8728b202fc1dec8eb76e3 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 07:44:19 -0800 Subject: [PATCH 14/21] experimental theme: compile and check follow the app copy (check fails with a code when it is behind, each copy follows its own build, copy files are no theme sources, missing copy files written back) --- .../experimental-theme-app-copy-check.md | 7 + packages/cli/api/theme-edit/_adapter.mjs | 208 +++++++++++--- packages/cli/api/theme-edit/_edit.mjs | 4 + packages/cli/api/theme-edit/_preview.mjs | 87 +++++- .../cli/api/theme-edit/theme-hunt75.test.mjs | 263 ++++++++++++++++++ .../experimental-theme-compile.doc.mjs | 2 +- .../cli/commands/experimental/theme.mjs | 2 + packages/cli/clients/cli/commands/theme.mjs | 50 +++- 8 files changed, 563 insertions(+), 60 deletions(-) create mode 100644 .changeset/experimental-theme-app-copy-check.md create mode 100644 packages/cli/api/theme-edit/theme-hunt75.test.mjs diff --git a/.changeset/experimental-theme-app-copy-check.md b/.changeset/experimental-theme-app-copy-check.md new file mode 100644 index 0000000000000..c27ed75bd1ba8 --- /dev/null +++ b/.changeset/experimental-theme-app-copy-check.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme compile`: `--check` of an editor theme also checks its app copy in `src/themes//` and fails with `ERR_THEME_STALE`, saying why, when the copy is kept back, missing a file, gone, or not the current build. Each theme's copy follows its own build, so a later theme failing no longer leaves an earlier one's copy behind. The copy's files are not theme sources: compiling its descriptor, a copy build file or `src/astryx-themes.ts` names the theme to compile, the copy's source from a subfolder names the app root, and an `--out` into the copy folder is refused. A copy left with only its build outputs is still the editor's, a copy file that is gone is written back by the next `set`, and a folder the copy could not be made in is named. The compile help says it writes and checks the app copy. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_adapter.mjs b/packages/cli/api/theme-edit/_adapter.mjs index 6833666d211ed..a02168d7b87bd 100644 --- a/packages/cli/api/theme-edit/_adapter.mjs +++ b/packages/cli/api/theme-edit/_adapter.mjs @@ -54,6 +54,7 @@ import {LIKE, P} from './_tables.mjs'; import {setLine} from './_check.mjs'; import { SHOWCASE_GROUPS, + appCopyBehind, appCopyStale, takeFirstAppCopy, writeAppCopy, @@ -65,6 +66,8 @@ import { folderIsEmpty, loadTheme, setAfterBuild, + editedThemes, + staleBuild, themeFiles, typedThemeName, } from './_settings.mjs'; @@ -809,10 +812,96 @@ export async function editorCompileProblem( from, COMPILE, ); + // An --out into an editor theme's app copy folder: the copy refresh would + // write the theme's own build over it. + if (out) { + const rel = path.relative(cwdRoot, path.resolve(cwd, out)).split(path.sep); + if ( + rel[0] === 'src' && + rel[1] === 'themes' && + rel.length >= 4 && + fs.existsSync(themeFiles(cwdRoot, rel[2]).settings) + ) + return new AstryxError( + `--out ${out} is in ${rel[2]}'s app copy (src/themes/${rel[2]}/), which the editor writes after every build of themes/${rel[2]}/; write the build somewhere else.`, + [ + { + name: compileLine( + path.relative( + cwd, + path.join(cwdRoot, 'themes', rel[2], `${rel[2]}.theme.ts`), + ) || `themes/${rel[2]}/${rel[2]}.theme.ts`, + cwd, + ), + reason: 'do this instead', + }, + ], + ERROR_CODES.ERR_INVALID_ARGUMENT, + ); + } for (const file of files) { - const at = editorThemeAt(cwdRoot, path.resolve(cwd, file)); + const abs = path.resolve(cwd, file); + const relParts = path.relative(cwdRoot, abs).split(path.sep); + // The app's theme module `theme add` writes: not a theme source. + if (relParts.join('/') === 'src/astryx-themes.ts') + return new AstryxError( + `${file} is the app's theme module (\`theme add\` writes it), not a theme source; compile the editor theme instead.`, + editedThemes(cwdRoot) + .slice(0, 3) + .map(t => ({ + name: compileLine(`themes/${t}/${t}.theme.ts`, cwdRoot), + reason: 'do this instead', + })), + ERROR_CODES.ERR_INVALID_ARGUMENT, + ); + // A file of an editor theme's app copy (src/themes//): the copy + // is written from themes//, so that is what compiles. + if ( + relParts[0] === 'src' && + relParts[1] === 'themes' && + relParts.length === 4 && + fs.existsSync(themeFiles(cwdRoot, relParts[2]).settings) + ) { + const t = relParts[2]; + const own = `themes/${t}/${t}.theme.ts`; + if (path.resolve(cwd) !== path.resolve(cwdRoot)) + return new AstryxError( + `${t} is a theme the editor made; compile it from the app root (${path.relative(cwd, cwdRoot) || '.'}), where the editor builds it.`, + [ + { + name: `cd ${quoteCommandArgument(path.relative(cwd, cwdRoot) || '.')} && ${compileLine(own, cwdRoot)}`, + reason: 'do this instead', + }, + ], + ERROR_CODES.ERR_INVALID_ARGUMENT, + ); + if (relParts[3] !== `${camel(t)}Theme.ts`) + return new AstryxError( + `${file} is part of ${t}'s app copy, which the editor writes from themes/${t}/ after every build; compile the theme itself.`, + [{name: compileLine(own, cwd), reason: 'do this instead'}], + ERROR_CODES.ERR_INVALID_ARGUMENT, + ); + } + const at = editorThemeAt(cwdRoot, abs); if (!at || !editorOwnFile(at.slug, file)) continue; const {root, slug} = at; + // A check of an editor theme checks its app copy too: a copy behind + // its build (kept back, a file missing, or content not the current + // build) is not up to date. + if (check && path.resolve(cwd) === path.resolve(root)) { + const behind = appCopyBehind(root, slug); + if (behind) + return new AstryxError( + `${slug}'s app copy in src/themes/${slug}/ is not up to date: ${behind}.`, + [ + { + name: runLine(root, `experimental theme editor show ${slug}`), + reason: 'writes the app copy again from the theme', + }, + ], + ERROR_CODES.ERR_THEME_STALE, + ); + } const source = `themes/${slug}/${slug}.theme.ts`; // An editor theme is built from its project's root, as every editor // command runs there: from a subfolder the build would write outside @@ -962,11 +1051,17 @@ export async function editorThemeHold(files, {cwd}) { if (copy) found.set(`${copy.root}\0${copy.slug}`, copy); } const themes = [...found.keys()].sort().map(k => found.get(k)); - /** @type {Array<(refused?: boolean) => Promise>} */ + /** @type {Array<{copy: () => boolean, finish: (later: boolean) => Promise}>} */ const releases = []; + // Every theme's copy is written first, at once (a refusal ends the run + // without waiting), then each lock goes. const releaseAll = async (refused = false) => { + void refused; if (releaseCompileHold === releaseAll) releaseCompileHold = null; - for (const r of releases.splice(0).reverse()) await r(refused); + const items = releases.splice(0); + const later = items.map(x => x.copy()); + for (const [i, x] of [...items.entries()].reverse()) + await x.finish(later[i]); }; releaseCompileHold = releaseAll; for (const { @@ -998,28 +1093,46 @@ export async function editorThemeHold(files, {cwd}) { await releaseAll(); throw err; } - releases.push(async (refused = false) => { + releases.push({ // A compile that wrote the build refreshes the app's copy too - // (src/themes//), under the same lock; a check, or a compile - // that was refused or failed, writes nothing. - if (refused || (process.exitCode ?? 0) !== 0) { - // Nothing built: the copy stays as it was. - } else if (!process.argv.includes('--check')) { + // (src/themes//), under the same lock; a check writes + // nothing. Each theme's copy follows its own build: a theme this run + // built (its build now matches its settings) gets its copy, whatever + // a later theme did; one that was refused or failed keeps its copy. + // True when the copy still needs its font stylesheet from the + // theme's settings (written in finish). + copy: () => { + if (process.argv.includes('--check')) return false; + /** @type {boolean} */ + let builtNow; try { - // The same whole copy the editor's build writes, its font - // stylesheets from the theme's settings. - // A copy without its font stylesheet (missing): from the theme. - if ( - !fs.existsSync( - path.join(root, 'src', 'themes', slug, `${slug}.fonts.css`), - ) + builtNow = staleBuild(root, slug, loadTheme(root, slug)) == null; + } catch { + builtNow = false; + } + if (!builtNow) return false; + if ( + !fs.existsSync( + path.join(root, 'src', 'themes', slug, `${slug}.fonts.css`), ) - try { - writeAppCopy(await context(root), slug, loadTheme(root, slug)); - } catch { - writeAppCopyAt(root, slug, null); - } - else writeAppCopyAt(root, slug, null); + ) + return true; + try { + writeAppCopyAt(root, slug, null); + } catch { + // A theme that does not load keeps its copy as it was. + } + return false; + }, + finish: async later => { + if (later) + try { + // A copy without its font stylesheet: from the theme. + writeAppCopy(await context(root), slug, loadTheme(root, slug)); + } catch { + writeAppCopyAt(root, slug, null); + } + if (!process.argv.includes('--check')) { // The first write of the app copy is named once (the compile's // own JSON envelope is the stable command's). const wrote = takeFirstAppCopy(root, slug); @@ -1027,30 +1140,14 @@ export async function editorThemeHold(files, {cwd}) { humanWarn( `wrote ${slug}'s app copy, a local theme \`theme add ${slug}\` takes, written again after every build: ${wrote.join(', ')}`, ); - } catch { - // A theme that does not load keeps its copy as it was. - } - // A copy a file or a link keeps behind, or one that could not be - // written, is said, as set and show say it. - const stale = appCopyStale(root, slug).appCopyStale; - if (stale) humanWarn(stale); - } else { - // A check: a copy behind the theme (kept back, or gone) is not up - // to date. - const stale = appCopyStale(root, slug).appCopyStale; - const gone = !fs.existsSync( - path.join(root, 'src', 'themes', slug, `${camel(slug)}Theme.ts`), - ); - if (stale || gone) { - humanWarn( - stale ?? - `the app copy in src/themes/${slug}/ is gone, so it is not up to date; ${runLine(root, `experimental theme editor show ${slug}`)} writes it again`, - ); - process.exitCode = 1; + // A copy a file or a link keeps behind, or one that could not be + // written, is said, as set and show say it. + const stale = appCopyStale(root, slug).appCopyStale; + if (stale) humanWarn(stale); } - } - release(); - await done; + release(); + await done; + }, }); } return releaseAll; @@ -1072,6 +1169,27 @@ function appCopyTheme(root, file) { return fs.existsSync(themeFiles(root, slug).settings) ? {root, slug} : null; } +/** + * For `compile` of an editor theme: `appCopyStale` for its result data when + * the app copy is behind (the same line set and show give); null for any + * other file or a current copy. + * @param {string} file the path as given + * @param {string} cwd + * @param {{check: boolean}} _opts + * @returns {Record | null} + */ +export function editorBuildExtra(file, cwd, _opts) { + const root = projectRoot(cwd); + const abs = path.resolve(cwd, file); + const own = editorThemeAt(root, abs); + const slug = + appCopyTheme(root, abs)?.slug ?? + (own && editorOwnFile(own.slug, file) ? own.slug : null); + if (!slug) return null; + const stale = appCopyStale(root, slug).appCopyStale; + return stale ? {appCopyStale: stale} : null; +} + /** * For `compile` of an editor theme (themes// or its app copy's source): * whether a font family is loaded by the app copy's font stylesheet diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index c95170eb54d62..3476caa18c6ad 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -114,6 +114,7 @@ import { renderSheet, previewIsCurrent, takeFirstAppCopy, + appCopyBehind, appCopyStale, writeAppCopy, } from './_preview.mjs'; @@ -2195,6 +2196,9 @@ async function changeOnce( // once preview.html was rendered from the theme as it is now (a retry // after a render that failed renders it). const f0 = themeFiles(cwd, slug); + // A change that changed nothing still writes back an app copy file that + // is gone or not the current build. + if (r.unchanged && appCopyBehind(cwd, slug)) writeAppCopy(ctx, slug, data); const p = r.unchanged && !png && diff --git a/packages/cli/api/theme-edit/_preview.mjs b/packages/cli/api/theme-edit/_preview.mjs index f685425553a30..aec1341b05204 100644 --- a/packages/cli/api/theme-edit/_preview.mjs +++ b/packages/cli/api/theme-edit/_preview.mjs @@ -1557,23 +1557,20 @@ export function writeAppCopy(ctx, slug, data) { } /** - * {@link writeAppCopy} with the font stylesheets given, or null to keep the - * copy's own (a compile, which changes no setting). + * The files of a theme's app copy as the editor would write them now, by + * name; null with no build to copy. Font stylesheets as given, or null for + * the copy's own. * @param {string} cwd * @param {string} slug * @param {string[] | null} fontsCss - * @returns {string | null} + * @returns {Record | null} */ -export function writeAppCopyAt(cwd, slug, fontsCss) { +function appCopyFiles(cwd, slug, fontsCss) { const f = themeFiles(cwd, slug); const dir = path.join(cwd, 'src', 'themes', slug); const name = `${camel(slug)}Theme`; - const first = !fs.existsSync(path.join(dir, `${name}.doc.mjs`)); if (!fs.existsSync(f.js) || !fs.existsSync(f.css)) return null; - // The copy's source, as the stable build's header names it. const source = `src/themes/${slug}/${name}.ts`; - // Only the editor's own copy is written over. - if (appCopyBlocker(cwd, slug) !== null) return null; const head = (/** @type {string} */ open, /** @type {string} */ close) => `${open} ${APP_COPY_MARK} after every build of themes/${slug}/; do not edit it here. Change the theme with the editor. ${close}\n`; const keptFonts = path.join(dir, `${slug}.fonts.css`); @@ -1607,6 +1604,57 @@ export function writeAppCopyAt(cwd, slug, fontsCss) { ? `${head('/*', '*/')}${fonts.map(c => `@import ${JSON.stringify(c)};`).join('\n')}\n` : `${head('/*', '*/')}/* No font stylesheets: the theme uses fonts the system has. */\n`, }; + return files; +} + +/** + * Why a theme's app copy is behind its build, or null when it is current: + * something in its way (appCopyStale), a copy file missing, or a file + * whose content is not what the editor would write now. + * @param {string} cwd + * @param {string} slug + * @returns {string | null} + */ +export function appCopyBehind(cwd, slug) { + const stale = appCopyStale(cwd, slug).appCopyStale; + if (stale) return stale; + const files = appCopyFiles(cwd, slug, null); + if (!files) return null; + const dir = path.join(cwd, 'src', 'themes', slug); + if (!fs.existsSync(dir)) + return `the app copy in src/themes/${slug}/ is gone; the editor writes it again`; + for (const [n, text] of Object.entries(files)) { + /** @type {string} */ + let now; + try { + now = fs.readFileSync(path.join(dir, n), 'utf8'); + } catch { + return `src/themes/${slug}/${n} is missing from the app copy`; + } + if (now !== text) + return `src/themes/${slug}/${n} is not the current build of themes/${slug}/`; + } + return null; +} + +/** + * {@link writeAppCopy} with the font stylesheets given, or null to keep the + * copy's own (a compile, which changes no setting). + * @param {string} cwd + * @param {string} slug + * @param {string[] | null} fontsCss + * @returns {string | null} + */ +export function writeAppCopyAt(cwd, slug, fontsCss) { + const f = themeFiles(cwd, slug); + const dir = path.join(cwd, 'src', 'themes', slug); + const name = `${camel(slug)}Theme`; + const first = !fs.existsSync(path.join(dir, `${name}.doc.mjs`)); + if (!fs.existsSync(f.js) || !fs.existsSync(f.css)) return null; + // Only the editor's own copy is written over. + if (appCopyBlocker(cwd, slug) !== null) return null; + const files = appCopyFiles(cwd, slug, fontsCss); + if (!files) return null; const failKey = `${path.resolve(cwd)}\0${slug}`; try { fs.mkdirSync(dir, {recursive: true}); @@ -1625,7 +1673,13 @@ export function writeAppCopyAt(cwd, slug, fontsCss) { // A copy that cannot be written is said, with the system's reason; // the change itself stands. const code = /** @type {any} */ (e)?.code ?? 'error'; - const rel = path.relative(cwd, dir).split(path.sep).join('/'); + // The folder that failed: the copy's, or a parent it could not be + // made in. + const failed = /** @type {any} */ (e)?.path + ? path.resolve(String(/** @type {any} */ (e).path)) + : dir; + const at = fs.existsSync(failed) ? failed : path.dirname(failed); + const rel = path.relative(cwd, at).split(path.sep).join('/'); COPY_FAILED.set( failKey, code === 'EACCES' || code === 'EPERM' || code === 'EROFS' @@ -1756,7 +1810,20 @@ export function appCopyBlocker(cwd, slug) { // mark (one of them deleted is written back); then only a marked name // without the mark is yours. const markedNames = [`${name}.doc.mjs`, `${name}.ts`, `${slug}.fonts.css`]; - const ours = markedNames.some(n => present.includes(n) && marked(n)); + // A build output of the copy's source carries the build's own header. + const generated = (/** @type {string} */ n) => { + try { + return /@generated by `astryx theme build`/.test( + fs.readFileSync(path.join(dir, n), 'utf8').slice(0, 600), + ); + } catch { + return false; + } + }; + const ours = + markedNames.some(n => present.includes(n) && marked(n)) || + (present.length > 0 && + present.every(n => !markedNames.includes(n) && generated(n))); const foreign = ours ? present.find(n => markedNames.includes(n) && !marked(n)) : present[0]; diff --git a/packages/cli/api/theme-edit/theme-hunt75.test.mjs b/packages/cli/api/theme-edit/theme-hunt75.test.mjs new file mode 100644 index 0000000000000..33844117b7c80 --- /dev/null +++ b/packages/cli/api/theme-edit/theme-hunt75.test.mjs @@ -0,0 +1,263 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Compile and check follow the app copy: a check fails with a code + * when the copy is behind, each theme's copy follows its own build, the + * copy's build outputs alone are still the editor's, a failed write names + * the real folder, copy files are no theme sources, and a no-op set writes + * back a missing copy file. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; +import {themeEditNew, themeEditSet} from './theme-edit.mjs'; +import { + makeLinkedFixture, + removeLinkedFixture, +} from '../../test-utils/linked-fixture.mjs'; +import { + FIXTURE_PACKAGES, + writeFixtureBaseTheme, +} from '../../test-utils/base-theme-fixture.mjs'; + +/** @type {string} */ +let cwd; +const BIN = path.resolve( + import.meta.dirname, + '../../clients/cli/bin/astryx.mjs', +); + +beforeAll(() => { + cwd = makeLinkedFixture('astryx-theme-hunt75-', FIXTURE_PACKAGES); + writeFixtureBaseTheme(cwd, 'neutral'); +}, 120_000); + +afterAll(() => { + removeLinkedFixture(cwd); +}); + +/** @param {string} rel */ +const read = rel => fs.readFileSync(path.join(cwd, rel), 'utf8'); +/** @param {string[]} args @param {string} [at] */ +const cli = (args, at = cwd) => + spawnSync(process.execPath, [BIN, ...args], {cwd: at, encoding: 'utf8'}); +/** @param {any} r */ +const stale = r => /** @type {any} */ (r.data).appCopyStale; +const compile = (/** @type {string[]} */ ...args) => + cli(['experimental', 'theme', 'compile', ...args]); + +describe('a check fails when the app copy is behind, saying why', () => { + it('a hand-edited copy file fails --check with a code and no up-to-date claim', async () => { + await themeEditNew('c1', {cwd}); + fs.writeFileSync(path.join(cwd, 'src/themes/c1/c1.css'), '/* hand */\n'); + const r = compile('themes/c1/c1.theme.ts', '--check'); + expect(r.status).toBe(1); + expect(r.stdout + r.stderr).not.toContain('up to date.'); + expect(r.stdout + r.stderr).toContain( + "c1's app copy in src/themes/c1/ is not up to date: src/themes/c1/c1.css is not the current build", + ); + const j = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'themes/c1/c1.theme.ts', + '--check', + ]); + const out = JSON.parse(j.stdout); + expect(out.code).toBe('ERR_THEME_STALE'); + expect(out.data?.upToDate).not.toBe(true); + }, 240_000); + + it('one copy file deleted fails --check, and a no-op set writes it back', async () => { + await themeEditNew('c13', {cwd}); + await themeEditSet('c13', 'corners', 'pill', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/c13/c13.js')); + const r = compile('themes/c13/c13.theme.ts', '--check'); + expect(r.status).toBe(1); + expect(r.stdout + r.stderr).toContain( + 'src/themes/c13/c13.js is missing from the app copy', + ); + await themeEditSet('c13', 'corners', 'pill', {cwd}); + expect(fs.existsSync(path.join(cwd, 'src/themes/c13/c13.js'))).toBe(true); + expect(compile('themes/c13/c13.theme.ts', '--check').status).toBe(0); + }, 240_000); +}); + +describe('a check never passes over a copy behind its build', () => { + it('a folder at a copy file name fails --check --json with a code, never up to date', async () => { + await themeEditNew('h1', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/h1/h1.css.d.ts')); + fs.mkdirSync(path.join(cwd, 'src/themes/h1/h1.css.d.ts')); + const j = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'themes/h1/h1.theme.ts', + '--check', + ]); + expect(j.status).toBe(1); + const out = JSON.parse(j.stdout); + expect(out.code).toBe('ERR_THEME_STALE'); + expect(out.data?.upToDate).not.toBe(true); + }, 240_000); + + it('a copy left behind while its folder was read-only fails --check once it is writable again', async () => { + if (process.getuid?.() === 0) return; + await themeEditNew('t1', {cwd}); + const dir = path.join(cwd, 'src/themes/t1'); + fs.chmodSync(dir, 0o555); + try { + await themeEditSet('t1', 'corners', 'pill', {cwd}); + } finally { + fs.chmodSync(dir, 0o755); + } + const r = compile('themes/t1/t1.theme.ts', '--check'); + expect(r.status).toBe(1); + expect(r.stdout + r.stderr).toContain('is not the current build'); + }, 240_000); +}); + +describe('compile --json says what the text says', () => { + it('carries appCopyStale, and leaves fonts the copy loads out of its notices and the full help', async () => { + await themeEditNew('j1', {cwd}); + await themeEditSet('j1', 'font', 'Inter', {cwd}); + const sheet = path.join(cwd, 'src/themes/j1/j1.fonts.css'); + const head = read('src/themes/j1/j1.fonts.css').split('\n')[0]; + fs.writeFileSync( + sheet, + `${head}\n@import "@fontsource/inter/index.css";\n`, + ); + const j = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'themes/j1/j1.theme.ts', + ]); + const out = JSON.parse(j.stdout); + expect(JSON.stringify(out.data.notices ?? [])).not.toContain('"Inter"'); + const full = cli([ + 'experimental', + 'theme', + 'compile', + 'themes/j1/j1.theme.ts', + '--detail', + 'full', + ]); + expect(full.stdout + full.stderr).not.toMatch(/not load[^\n]*Inter/); + fs.writeFileSync(path.join(cwd, 'src/themes/j1/j1Theme.ts'), '// mine\n'); + const blocked = JSON.parse( + cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'themes/j1/j1.theme.ts', + ]).stdout, + ); + expect(blocked.data.appCopyStale).toContain( + 'src/themes/j1/j1Theme.ts is yours', + ); + }, 240_000); +}); + +describe('each copy follows its own build', () => { + it('a later theme failing does not keep the first theme copy behind', async () => { + await themeEditNew('c4a', {cwd}); + await themeEditNew('c4b', {cwd}); + await themeEditSet('c4a', 'corners', 'pill', {cwd}); + fs.writeFileSync(path.join(cwd, 'src/themes/c4a/c4a.css'), '/* old */\n'); + fs.writeFileSync( + path.join(cwd, 'themes/c4b/c4b.theme.ts'), + 'not a theme(\n', + ); + const r = compile('themes/c4a/c4a.theme.ts', 'themes/c4b/c4b.theme.ts'); + expect(r.status).not.toBe(0); + expect(read('src/themes/c4a/c4a.css')).not.toBe('/* old */\n'); + }, 240_000); +}); + +describe('what the copy is, and what stops it', () => { + it('only the copy build outputs left are still the editor own, and written over', async () => { + await themeEditNew('c5', {cwd}); + for (const n of ['c5Theme.doc.mjs', 'c5Theme.ts', 'c5.fonts.css']) + fs.rmSync(path.join(cwd, 'src/themes/c5', n)); + const r = await themeEditSet('c5', 'corners', 'pill', {cwd}); + expect(stale(r)).toBeUndefined(); + expect(fs.existsSync(path.join(cwd, 'src/themes/c5/c5Theme.ts'))).toBe( + true, + ); + }, 240_000); + + it('a read-only src/themes is named, not the copy folder it could not make', async () => { + if (process.getuid?.() === 0) return; + await themeEditNew('c7', {cwd}); + fs.rmSync(path.join(cwd, 'src/themes/c7'), {recursive: true}); + const themes = path.join(cwd, 'src/themes'); + fs.chmodSync(themes, 0o555); + try { + const r = await themeEditSet('c7', 'corners', 'pill', {cwd}); + expect(stale(r)).toContain('src/themes is not writable'); + expect(stale(r)).not.toContain('src/themes/c7 is not writable'); + } finally { + fs.chmodSync(themes, 0o755); + } + }, 240_000); +}); + +describe('copy files are no theme sources', () => { + it('the copy descriptor, the theme module, a subfolder run and --out into the copy are refused by name', async () => { + await themeEditNew('c10', {cwd}); + const doc = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'src/themes/c10/c10Theme.doc.mjs', + ]); + expect(doc.stdout).toContain("part of c10's app copy"); + expect(doc.stdout).toContain('themes/c10/c10.theme.ts'); + expect(doc.stdout).not.toContain('theme template'); + fs.writeFileSync(path.join(cwd, 'src/astryx-themes.ts'), 'export {};\n'); + const mod = cli([ + '--json', + 'experimental', + 'theme', + 'compile', + 'src/astryx-themes.ts', + ]); + expect(mod.stdout).toContain("is the app's theme module"); + fs.rmSync(path.join(cwd, 'src/astryx-themes.ts')); + fs.mkdirSync(path.join(cwd, 'src/app'), {recursive: true}); + const sub = cli( + [ + '--json', + 'experimental', + 'theme', + 'compile', + '../themes/c10/c10Theme.ts', + ], + path.join(cwd, 'src/app'), + ); + expect(sub.stdout).toContain('compile it from the app root'); + expect(sub.stdout).not.toContain('ERR_PATH_TRAVERSAL'); + const before = read('src/themes/c10/c10.css'); + const out = compile( + 'themes/c10/c10.theme.ts', + '--out', + 'src/themes/c10/c10.css', + ); + expect(out.status).not.toBe(0); + expect(out.stdout + out.stderr).toContain("in c10's app copy"); + expect(read('src/themes/c10/c10.css')).toBe(before); + }, 240_000); + + it('compile help names the app copy', () => { + const r = cli(['experimental', 'theme', 'compile', '--help']); + expect(r.stdout).toContain('src/themes//'); + }); +}); diff --git a/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs b/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs index f6e152aa8c964..f06b70b548597 100644 --- a/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs +++ b/packages/cli/clients/cli/commands/experimental-theme-compile.doc.mjs @@ -89,7 +89,7 @@ export const doc = { }, { code: 1, - when: 'a build or validation error (an error envelope), or --check finds stale or missing outputs (the check result, with upToDate false, the stale files and code ERR_THEME_STALE, is still printed: it is a result, not an error). One exception: an editor theme whose settings changed after its build is refused (ERR_THEME_INVALID, an error envelope) before any file is checked, with `editor show ` to build it again. An editor theme run from a folder other than its app root is refused (ERR_INVALID_ARGUMENT) with the `cd` to the root and the same command; `--family` does not take an editor theme (ERR_INVALID_ARGUMENT); its damaged or unbuildable settings are refused (ERR_THEME_INVALID)', + when: "a build or validation error (an error envelope), or --check finds stale or missing outputs (the check result, with upToDate false, the stale files and code ERR_THEME_STALE, is still printed: it is a result, not an error). One exception: an editor theme whose settings changed after its build is refused (ERR_THEME_INVALID, an error envelope) before any file is checked, with `editor show ` to build it again. An editor theme run from a folder other than its app root is refused (ERR_INVALID_ARGUMENT) with the `cd` to the root and the same command; `--family` does not take an editor theme (ERR_INVALID_ARGUMENT); its damaged or unbuildable settings are refused (ERR_THEME_INVALID). An editor theme's compile also writes its app copy in src/themes// (the copy follows each theme this run built), and --check also compares that copy: a copy that is kept back, missing a file or not the current build fails the check (ERR_THEME_STALE), naming why", }, ], related: ['experimental theme add', 'experimental theme list'], diff --git a/packages/cli/clients/cli/commands/experimental/theme.mjs b/packages/cli/clients/cli/commands/experimental/theme.mjs index ad2f8e53f9d88..78ad663222dd2 100644 --- a/packages/cli/clients/cli/commands/experimental/theme.mjs +++ b/packages/cli/clients/cli/commands/experimental/theme.mjs @@ -49,6 +49,7 @@ import { dialValueFits, newFlagList, editorCompileProblem, + editorBuildExtra, editorLoadedFonts, editorThemeHold, editorThemeRefusal, @@ -1308,6 +1309,7 @@ export function registerExperimentalTheme(group, program) { template: editorThemeTemplate, hold: editorThemeHold, loadedFonts: editorLoadedFonts, + extraData: editorBuildExtra, }, ); } diff --git a/packages/cli/clients/cli/commands/theme.mjs b/packages/cli/clients/cli/commands/theme.mjs index 0f5d0d4578fc8..daaa1a522fc99 100644 --- a/packages/cli/clients/cli/commands/theme.mjs +++ b/packages/cli/clients/cli/commands/theme.mjs @@ -120,6 +120,7 @@ import {doc as themePaletteGenerateFn} from '../../../api/theme/themePaletteGene * @property {(err: import('../../../api/error.mjs').AstryxError, cwd: string) => import('../../../api/error.mjs').AstryxError | null} [explain] - A clearer refusal for this group, or null. * @property {(files: string[], options: {cwd: string, check?: boolean, family?: boolean, iconsSpecifier?: string, out?: string}) => Promise} [preflight] - A refusal before a build starts, or null. * @property {(files: string[], options: {cwd: string}) => Promise<() => Promise>} [hold] - Locks held while a build or check runs; resolves to their release. + * @property {(file: string, cwd: string, opts: {check: boolean}) => Record | null} [extraData] - Fields a group adds to a build's result data (the editor's `appCopyStale`). * @property {(file: string, cwd: string) => ((family: string) => boolean) | null} [loadedFonts] - Font families the app loads for a theme file that the file itself cannot say, so the build does not name them as unloaded. * @property {(file: string, cwd: string) => {name: string, reason: string} | null} [missingSource] - For a source file that is not there, this group's way to write it again, or null. * @property {(options: {targetPath?: string, overwrite?: boolean, cwd?: string}) => import('../../../api/theme/theme.type.mjs').ThemeTemplateResponse} [template] - This group's template write, in place of `themeTemplate`. @@ -1309,6 +1310,37 @@ function registerThemeCommands(theme, program, docs, words) { const writes = []; const log = logger.log; const warn = logger.warn; + // A group may know fonts the app loads that the theme file cannot + // say (the editor's app copy stylesheet): those are not named as + // unloaded, in the notices, the trailer or the full help. + const loads = words.loadedFonts?.(entry.file, process.cwd()) ?? null; + /** @param {string} msg */ + const namesOnlyLoaded = msg => { + // The families the notice names: on its first line, after the + // theme's own name. + const line = + msg + .split('\n') + .find(l => /not load/.test(l)) + ?.replace(/^.*?(names fonts it does not load:|Font )/, '') ?? ''; + const fonts = [...line.matchAll(/"([^"]+)"/g)].map(m => m[1]); + return ( + Boolean(loads) && fonts.length > 0 && fonts.every(f => loads?.(f)) + ); + }; + if (loads && !deferred) { + logger.log = (/** @type {string | undefined} */ message) => { + if ( + typeof message === 'string' && + /names fonts it does not load|named by this theme but not loaded/.test( + message, + ) && + namesOnlyLoaded(message) + ) + return; + log.call(logger, message); + }; + } if (deferred) { logger.log = (/** @type {string | undefined} */ message) => { lines.push(() => log.call(logger, message)); @@ -1339,10 +1371,6 @@ function registerThemeCommands(theme, program, docs, words) { handOff, ); result = built.receipt; - // A group may know fonts the app loads that the theme file - // cannot say (the editor's app copy stylesheet): those are not - // named as unloaded. - const loads = words.loadedFonts?.(entry.file, process.cwd()); if (loads && built.trailer) { const named = (/** @type {string} */ msg) => built.trailer?.unloadedFonts.some( @@ -1378,6 +1406,20 @@ function registerThemeCommands(theme, program, docs, words) { } logger.log = log; logger.warn = warn; + const data = /** @type {any} */ (result)?.data; + if (loads && data && Array.isArray(data.notices)) + data.notices = data.notices.filter( + (/** @type {string} */ m) => + !( + /named by this theme but not loaded/.test(m) && + namesOnlyLoaded(m) + ), + ); + // A group may add to a build's result (the editor's app copy note). + const extra = words.extraData?.(entry.file, process.cwd(), { + check: Boolean(options.check), + }); + if (extra && result?.data) Object.assign(result.data, extra); results.push({file: entry.file, receipt: result ?? null}); if (deferred) built.push({writes, lines}); if ( From 3dd568e3666e30badf5b1a3802673357605cd5ef Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 08:27:26 -0800 Subject: [PATCH 15/21] experimental theme: a one-mode color per variant keeps the part's earlier color as its other half, and leaves a variant's own background image as it is --- packages/cli/api/theme-edit/_edit.mjs | 5 +++ packages/cli/api/theme-edit/_settings.mjs | 26 ++++++++++-- .../cli/api/theme-edit/theme-parts43.test.mjs | 41 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index 3476caa18c6ad..01c46475b4224 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -1690,6 +1690,10 @@ async function changeOnce( const r = await commitTheme(ctx, slug, data, {history: true}); const compiledAfter = compileTheme(ctx, data, base); // What the spacing dial said of the sizes it could not keep apart. + // What the build said of settings it could not write as they are. + const compileNotes = + /** @type {{compileNotes?: string[]}} */ (/** @type {unknown} */ (ctx)) + .compileNotes ?? []; const sizeNotes = /** @type {{sizeNotes?: string[]}} */ (/** @type {unknown} */ (ctx)) .sizeNotes ?? []; @@ -1955,6 +1959,7 @@ async function changeOnce( ] : []), ...(sub === 'set' && s.thing === 'spacing' ? sizeNotes : []), + ...(sub === 'set' ? compileNotes : []), ...(covers.length ? [ `comes after ${covers.map(describe).join(', ')}, so ${covers.length > 1 ? 'those parts show' : `${covers[0].on} shows`} this value now; set ${covers.length > 1 ? 'one' : 'it'} again to make it win`, diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index c0e304605d391..502bd73bb3a38 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -117,7 +117,7 @@ setOwnTokenReader((cwd, slug) => ownSourceTokenValues(cwd, slug)); /** What lifted secondary text sits on: the contrast check measures it there. */ const LIFT_GROUND_TOKENS = new Set(LIFT_PARTS.map(([, , g]) => g)); -/** @typedef {{project: import('./_project.mjs').ThemeProject, C: import('./_compiler.mjs').Compiler, unchecked?: string | null, uncheckedCode?: string | null, wrote?: Map, base?: {light: Record, dark: Record, components?: Record}, keepStops?: Record> | null, keepRadii?: Record | null, sizeNotes?: string[], derived?: Record>}} Ctx */ +/** @typedef {{project: import('./_project.mjs').ThemeProject, C: import('./_compiler.mjs').Compiler, unchecked?: string | null, uncheckedCode?: string | null, wrote?: Map, base?: {light: Record, dark: Record, components?: Record}, keepStops?: Record> | null, keepRadii?: Record | null, sizeNotes?: string[], compileNotes?: string[], derived?: Record>}} Ctx */ /** @typedef {import('./_parts.mjs').PaintSide} PaintSide */ /** @typedef {import('./_parts.mjs').TextPair} TextPair */ @@ -2275,6 +2275,7 @@ export function controlSizes(sizes, f, kept = {}, noRoom = []) { */ export function compileTheme(ctx, data, base) { ctx.sizeNotes = []; + ctx.compileNotes = []; // A setting the editor no longer accepts never reaches the build. data = {...data, settings: data.settings.filter(s => !valueProblem(s))}; const TOKENS = ctx.project.core.tokenDefaults; @@ -3134,6 +3135,14 @@ export function compileTheme(ctx, data, base) { // is left alone. const css = kebab(prop) === 'color' ? 'color' : 'background-color'; const other = mode === 'light' ? 2 : 1; + // What the part's own rule holds of it (an earlier setting for both + // modes): the other half each variant keeps, before its unset value. + const plainHeld = [ + css, + ...(css === 'background-color' ? ['background'] : []), + ] + .map(k => b[camel(k)]) + .find(y => typeof y === 'string' && !/gradient\(|url\(/i.test(y)); const names = new Set([ ...(ctx.C.variantValues(target)?.variant ?? []), ...(ctx.C.MAP[target]?.attrs?.variant ?? []), @@ -3150,10 +3159,21 @@ export function compileTheme(ctx, data, base) { css, ...(css === 'background-color' ? ['background'] : []), ]; + // A variant whose own background is an image (a gradient) keeps + // it: one mode's color is not written there, and the result says so. + const image = family + .map(k => vb[camel(k)]) + .find(y => typeof y === 'string' && /gradient\(|url\(/i.test(y)); + if (image) { + (ctx.compileNotes ??= []).push( + `${target} variant:${name} keeps its own background image (${image}), so the ${mode} color is not written there`, + ); + continue; + } const prev = family .map(k => vb[camel(k)]) - .find(y => typeof y === 'string' && !/gradient\(|url\(/i.test(y)); - const from = prev ?? x; + .find(y => typeof y === 'string'); + const from = prev ?? plainHeld ?? x; if (from == null) continue; for (const k of family) delete vb[camel(k)]; const half = halfOf(from, other); diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index 7ad72b1409693..abb6e4d3b2b5d 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -181,6 +181,47 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { expect(rule).not.toMatch(/background:/); }, 240_000); + it("keeps the part's earlier color for both modes as each variant's other half", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5p', {cwd, like: 'neutral'}); + await themeEditSet('m5p', 'background', '#123456', {cwd, on: 'button'}); + await themeEditSet('m5p', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + for (const name of ['primary', 'secondary', 'ghost', 'destructive']) + expect( + ruleOf('m5p', `.astryx-button[data-variant="${name}"]`), + name, + ).toMatch(/background-color: light-dark\(#eeeeee, #123456\)/); + }, 240_000); + + it("leaves a variant's own gradient as it is, and says so", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5g', {cwd, like: 'neutral'}); + await themeEditSet( + 'm5g', + 'background', + 'linear-gradient(#ff0000, #0000ff)', + {cwd, on: 'button', when: 'variant:primary'}, + ); + const r = await themeEditSet('m5g', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + const primary = ruleOf('m5g', '.astryx-button[data-variant="primary"]'); + expect(primary).toMatch(/linear-gradient\(#ff0000, #0000ff\)/); + expect(primary).not.toMatch(/#eeeeee/); + expect(ruleOf('m5g', '.astryx-button[data-variant="destructive"]')).toMatch( + /light-dark\(#eeeeee, /, + ); + expect(r.data.notes.join(' ')).toMatch( + /button variant:primary keeps its own background image/, + ); + }, 240_000); + it('does the same in a light-only theme, with no --mode', async ctx => { if (needBases(ctx)) return; await themeEditNew('m5o', {cwd, like: 'neutral'}); From 304f4e31f37b1d6770692fac0b4c522d9d571a96 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 09:09:05 -0800 Subject: [PATCH 16/21] experimental theme: no plain background is repeated after a variant's one-mode color; build notes are said once, and a part's own gradient over a one-mode color is named --- packages/cli/api/theme-edit/_edit.mjs | 18 +++++- packages/cli/api/theme-edit/_settings.mjs | 31 +++++++++- .../cli/api/theme-edit/theme-parts43.test.mjs | 60 +++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/packages/cli/api/theme-edit/_edit.mjs b/packages/cli/api/theme-edit/_edit.mjs index 01c46475b4224..89f45062314a9 100644 --- a/packages/cli/api/theme-edit/_edit.mjs +++ b/packages/cli/api/theme-edit/_edit.mjs @@ -1484,6 +1484,9 @@ async function changeOnce( ); } const compiledBefore = compileTheme(ctx, data, base); + // The build notes the theme had before this change: only new ones are + // this change's to report. + const notesBefore = new Set(buildNotes(ctx)); if (sub === 'set' && (s.thing === 'base' || s.thing === 'mode')) { const before = s.thing === 'base' ? (data.like ?? 'none') : data.mode; if (s.thing === 'base') { @@ -1691,9 +1694,7 @@ async function changeOnce( const compiledAfter = compileTheme(ctx, data, base); // What the spacing dial said of the sizes it could not keep apart. // What the build said of settings it could not write as they are. - const compileNotes = - /** @type {{compileNotes?: string[]}} */ (/** @type {unknown} */ (ctx)) - .compileNotes ?? []; + const compileNotes = buildNotes(ctx).filter(n => !notesBefore.has(n)); const sizeNotes = /** @type {{sizeNotes?: string[]}} */ (/** @type {unknown} */ (ctx)) .sizeNotes ?? []; @@ -3535,3 +3536,14 @@ export async function editorStale(cwd, slug) { const data = loadTheme(cwd, slug); return staleBuild(cwd, slug, data) || null; } + +/** + * The notes the last build of the theme left (settings it could not write + * as they are). + * @param {unknown} ctx + * @returns {string[]} + */ +function buildNotes(ctx) { + const notes = /** @type {{compileNotes?: unknown}} */ (ctx)?.compileNotes; + return Array.isArray(notes) ? notes.map(String) : []; +} diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index 502bd73bb3a38..6b291f4d0112a 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -3143,6 +3143,19 @@ export function compileTheme(ctx, data, base) { ] .map(k => b[camel(k)]) .find(y => typeof y === 'string' && !/gradient\(|url\(/i.test(y)); + // The part's own background being an image (a gradient) covers any + // color under it: the color is written, and the result says it does + // not show. + const plainImage = + css === 'background-color' + ? ['background', 'backgroundImage'] + .map(k => b[k]) + .find(y => typeof y === 'string' && /gradient\(|url\(/i.test(y)) + : undefined; + if (plainImage) + (ctx.compileNotes ??= []).push( + `${target}'s own background is an image (${plainImage}), which covers the ${mode} color: it does not show until that background is a color`, + ); const names = new Set([ ...(ctx.C.variantValues(target)?.variant ?? []), ...(ctx.C.MAP[target]?.attrs?.variant ?? []), @@ -4599,9 +4612,21 @@ export async function buildTheme(ctx, slug, data) { let receipt = await step(() => runBuild(ctx, slug)); const found = findOverrides(fs.readFileSync(f.css, 'utf8'), theme).filter( x => - !((x.state - ? theme.components[x.target][x.key]?.[x.state] - : theme.components[x.target][x.key]) ?? {})[x.prop], + // A variant whose rule the settings already give this property (or + // one it covers, such as a one-mode background color written per + // variant, which carries the plain value as its other half) needs no + // copy of it. + !Object.keys( + (x.state + ? theme.components[x.target][x.key]?.[x.state] + : theme.components[x.target][x.key]) ?? {}, + ).some( + k => + !k.startsWith(':') && + (k === x.prop || + coversProperty(kebab(k), kebab(x.prop)) || + coversProperty(kebab(x.prop), kebab(k))), + ), ); /** @type {string[]} */ const lifted = []; diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index abb6e4d3b2b5d..022e5863f1141 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -197,6 +197,66 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { ).toMatch(/background-color: light-dark\(#eeeeee, #123456\)/); }, 240_000); + for (const [like, names] of /** @type {Array<[string, string[]]>} */ ([ + ['neutral', ['destructive', 'primary']], + ['butter', ['secondary', 'destructive', 'primary']], + ])) + it(`puts no plain background after a variant's one-mode color (${like})`, async ctx => { + if (needBases(ctx)) return; + const slug = `m5l${like[0]}`; + await themeEditNew(slug, {cwd, like}); + await themeEditSet(slug, 'background', '#123456', {cwd, on: 'button'}); + await themeEditSet(slug, 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + for (const name of names) { + const rule = ruleOf(slug, `.astryx-button[data-variant="${name}"]`); + const at = rule.indexOf( + 'background-color: light-dark(#eeeeee, #123456)', + ); + expect(at, name).toBeGreaterThan(-1); + expect(rule.slice(at), name).not.toMatch(/background:/); + } + }, 240_000); + + it("says once that a variant keeps its own gradient, and that a part's own gradient covers the color", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('m5n1', {cwd, like: 'neutral'}); + await themeEditSet( + 'm5n1', + 'background', + 'linear-gradient(#ff0000, #0000ff)', + {cwd, on: 'button', when: 'variant:primary'}, + ); + await themeEditSet('m5n1', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + const later = await themeEditSet('m5n1', 'padding', '9px', { + cwd, + on: 'card', + }); + expect(later.data.notes.join(' ')).not.toMatch(/background image/); + await themeEditNew('m5n2', {cwd, like: 'neutral'}); + await themeEditSet( + 'm5n2', + 'background', + 'linear-gradient(#ff0000, #0000ff)', + {cwd, on: 'button'}, + ); + const r = await themeEditSet('m5n2', 'background', '#eeeeee', { + cwd, + on: 'button', + mode: 'light', + }); + expect(r.data.notes.join(' ')).toMatch( + /button's own background is an image .* which covers the light color/, + ); + }, 240_000); + it("leaves a variant's own gradient as it is, and says so", async ctx => { if (needBases(ctx)) return; await themeEditNew('m5g', {cwd, like: 'neutral'}); From 2803f8c11ffdf1b4be4dd55358c3056c9b9422c4 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 09:49:17 -0800 Subject: [PATCH 17/21] experimental theme: a whole-part shorthand is copied to a variant unless the variant's own longhand already carries it, and the variant's later longhands are written as sides --- packages/cli/api/theme-edit/_settings.mjs | 25 +++++++++++++------ .../cli/api/theme-edit/theme-parts43.test.mjs | 21 ++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index 6b291f4d0112a..eb2bb2c3060d6 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -4612,20 +4612,21 @@ export async function buildTheme(ctx, slug, data) { let receipt = await step(() => runBuild(ctx, slug)); const found = findOverrides(fs.readFileSync(f.css, 'utf8'), theme).filter( x => - // A variant whose rule the settings already give this property (or - // one it covers, such as a one-mode background color written per - // variant, which carries the plain value as its other half) needs no - // copy of it. - !Object.keys( + // A variant whose rule the settings already give this property, a + // shorthand covering it, or a longhand of it that already carries the + // plain value (a one-mode background color written per variant, the + // plain color as its other half) needs no copy of it. + !Object.entries( (x.state ? theme.components[x.target][x.key]?.[x.state] : theme.components[x.target][x.key]) ?? {}, ).some( - k => + ([k, v]) => !k.startsWith(':') && (k === x.prop || coversProperty(kebab(k), kebab(x.prop)) || - coversProperty(kebab(x.prop), kebab(k))), + (coversProperty(kebab(x.prop), kebab(k)) && + String(v).includes(String(x.value)))), ), ); /** @type {string[]} */ @@ -4646,6 +4647,16 @@ export async function buildTheme(ctx, slug, data) { t[x.key] = x.state ? {...own, [x.state]: {[x.prop]: x.value, ...ownState}} : {[x.prop]: x.value, ...own}; + // The variant's own longhands come after the copied shorthand; one + // the base theme holds would keep the base's earlier place and lose, + // so it is written as its sides. + const baseRule = /** @type {any} */ (base).components?.[x.target]?.[ + x.key + ]; + laterLonghandsAsSides( + x.state ? t[x.key][x.state] : t[x.key], + x.state ? baseRule?.[x.state] : baseRule, + ); lifted.push(`${x.target} ${x.key}${x.state ?? ''} ${kebab(x.prop)}`); } await step(() => { diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index 022e5863f1141..5b6832cd9579f 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -221,6 +221,27 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { } }, 240_000); + it("keeps a whole-part border's width and style on a variant that sets only its color (butter)", async ctx => { + if (needBases(ctx)) return; + await themeEditNew('b5', {cwd, like: 'butter'}); + await themeEditSet('b5', 'border', '3px dashed #ff0000', { + cwd, + on: 'button', + }); + await themeEditSet('b5', 'border-color', '#0000ff', { + cwd, + on: 'button', + when: 'variant:secondary', + }); + const rule = ruleOf('b5', '.astryx-button[data-variant="secondary"]'); + const at = rule.indexOf('border: 3px dashed #ff0000'); + expect(at).toBeGreaterThan(-1); + const after = rule.slice(at); + expect(after).toMatch(/border-top-color: #0000ff/); + expect(after).toMatch(/border-left-color: #0000ff/); + expect(after).not.toMatch(/border-(width|style):/); + }, 240_000); + it("says once that a variant keeps its own gradient, and that a part's own gradient covers the color", async ctx => { if (needBases(ctx)) return; await themeEditNew('m5n1', {cwd, like: 'neutral'}); From 5ba43550a0bc1779ea7348bed6f0be7241dfd51d Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 09:44:44 -0800 Subject: [PATCH 18/21] experimental theme: an --out at the app copy folder itself is refused too --- packages/cli/api/theme-edit/_adapter.mjs | 8 ++++++-- packages/cli/api/theme-edit/theme-hunt75.test.mjs | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/api/theme-edit/_adapter.mjs b/packages/cli/api/theme-edit/_adapter.mjs index a02168d7b87bd..8776e25a9968c 100644 --- a/packages/cli/api/theme-edit/_adapter.mjs +++ b/packages/cli/api/theme-edit/_adapter.mjs @@ -815,11 +815,15 @@ export async function editorCompileProblem( // An --out into an editor theme's app copy folder: the copy refresh would // write the theme's own build over it. if (out) { - const rel = path.relative(cwdRoot, path.resolve(cwd, out)).split(path.sep); + // The copy folder itself (--out's folder form) and anything inside it. + const rel = path + .relative(cwdRoot, path.resolve(cwd, out)) + .split(path.sep) + .filter(Boolean); if ( rel[0] === 'src' && rel[1] === 'themes' && - rel.length >= 4 && + rel.length >= 3 && fs.existsSync(themeFiles(cwdRoot, rel[2]).settings) ) return new AstryxError( diff --git a/packages/cli/api/theme-edit/theme-hunt75.test.mjs b/packages/cli/api/theme-edit/theme-hunt75.test.mjs index 33844117b7c80..0efc051ba83de 100644 --- a/packages/cli/api/theme-edit/theme-hunt75.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt75.test.mjs @@ -253,6 +253,12 @@ describe('copy files are no theme sources', () => { ); expect(out.status).not.toBe(0); expect(out.stdout + out.stderr).toContain("in c10's app copy"); + // The folder form of --out, with and without its slash, too. + for (const folder of ['src/themes/c10', 'src/themes/c10/']) { + const r = compile('themes/c10/c10.theme.ts', '--out', folder); + expect(r.status, folder).not.toBe(0); + expect(r.stdout + r.stderr, folder).toContain("in c10's app copy"); + } expect(read('src/themes/c10/c10.css')).toBe(before); }, 240_000); From 14e91e010ffbfd879a2acfb8cf8ba17ab6d59d16 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 09:40:53 -0800 Subject: [PATCH 19/21] experimental theme: how reads inline, block, horizontal and vertical as padding sides only beside the padding, never inside a part name --- .../experimental-theme-how-axis-words.md | 7 +++++ packages/cli/api/theme-edit/_how-question.mjs | 24 +++++++++++++-- .../cli/api/theme-edit/theme-hunt68.test.mjs | 30 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .changeset/experimental-theme-how-axis-words.md diff --git a/.changeset/experimental-theme-how-axis-words.md b/.changeset/experimental-theme-how-axis-words.md new file mode 100644 index 0000000000000..b43eb3d1ab2ef --- /dev/null +++ b/.changeset/experimental-theme-how-axis-words.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx experimental theme editor how`: "inline", "block", "horizontal" and "vertical" name a padding side only beside the padding they qualify ("inline padding", "padding-inline", "padding 8px vertical"), never as a word of a part's name, so "inline code padding 4px" and "block quote padding 8px" set every side of that part. + +@josephfarina diff --git a/packages/cli/api/theme-edit/_how-question.mjs b/packages/cli/api/theme-edit/_how-question.mjs index 59c40a4388fc0..5f34adc240a93 100644 --- a/packages/cli/api/theme-edit/_how-question.mjs +++ b/packages/cli/api/theme-edit/_how-question.mjs @@ -1037,6 +1037,13 @@ function partLexicon(ctx) { /\bselect(?:ion)?[\s-]?(?:box|menu|field|dropdown)(?:e?s)?\b/, {on: 'selector'}, ]), + // "inline code", "block quote": those parts, read whole (so their + // first word is never a padding side). + /** @type {[RegExp, PartRef]} */ ([/\binline[\s-]code\b/, {on: 'code'}]), + /** @type {[RegExp, PartRef]} */ ([ + /\bblock[\s-]?quotes?\b/, + {on: 'blockquote'}, + ]), // "date pickers": Core's date input. /** @type {[RegExp, PartRef]} */ ([ /\bdate ?pickers?\b/, @@ -4338,9 +4345,20 @@ export function draftAnswer(ctx, env, question) { const text = lex.parts .filter(([, x]) => ons.has(x.on)) .reduce((t, [re]) => t.replace(new RegExp(re.source, 'g'), ' '), c.text); - return /\b(left|right|top|bottom|horizontal(?:ly)?|vertical(?:ly)?|inline|block|sides)\b/.exec( - text, - )?.[1]; + // left/right/top/bottom/sides name a side anywhere; inline, block, + // horizontal and vertical only beside the padding or margin they + // qualify ("inline padding", "padding-inline", "padding 8px vertical"), + // never as a word of a part's name ("inline code", "block quote"). + const plain = /\b(left|right|top|bottom|sides)\b/.exec(text)?.[1]; + if (plain) return plain; + const axis = + /\b(horizontal(?:ly)?|vertical(?:ly)?|inline|block)[\s-]+(?:padding|margin)\b/.exec( + text, + )?.[1] ?? + /\b(?:padding|margin)[\s-]+(?:(?:of\s+)?-?\d*\.?\d+(?:px|rem|em)?\s+)?(?:on\s+the\s+)?(horizontal(?:ly)?|vertical(?:ly)?|inline|block)\b/.exec( + text, + )?.[1]; + return axis; } /** diff --git a/packages/cli/api/theme-edit/theme-hunt68.test.mjs b/packages/cli/api/theme-edit/theme-hunt68.test.mjs index 554f278b1bf93..31e7d9a8b445f 100644 --- a/packages/cli/api/theme-edit/theme-hunt68.test.mjs +++ b/packages/cli/api/theme-edit/theme-hunt68.test.mjs @@ -275,6 +275,36 @@ describe('padding sides are read outside part names', () => { ); }); +describe('inline and block name a side only beside the padding', () => { + it( + 'inline code and block quote padding are all sides of those parts', + async () => { + expect((await top('inline code padding 4px')).all).toBe( + 'set q padding 4px --on code', + ); + expect((await top('block quote padding 8px')).all).toBe( + 'set q padding 8px --on blockquote', + ); + expect((await top('code block padding 12px')).all).toBe( + 'set q padding 12px --on code-block', + ); + }, + T, + ); + it( + 'inline or vertical beside padding is the side', + async () => { + expect((await top('inline padding 10px on cards')).all).toBe( + 'set q padding-inline 10px --on card', + ); + expect((await top('padding 8px vertical on cards')).all).toBe( + 'set q padding-block 8px --on card', + ); + }, + T, + ); +}); + describe('bigger or smaller than another part', () => { it( 'is a step past the other part value, never a copy', From 6cc2a038b1cdbf1b32db6245e68dcbce18b8d42a Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 10:27:43 -0800 Subject: [PATCH 20/21] experimental theme: whether a whole-part property is copied to a variant is decided by property, so a variant's later longhand is written after it --- packages/cli/api/theme-edit/_settings.mjs | 30 ++++--- .../cli/api/theme-edit/theme-parts43.test.mjs | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/packages/cli/api/theme-edit/_settings.mjs b/packages/cli/api/theme-edit/_settings.mjs index eb2bb2c3060d6..8dbe3a1a78e9b 100644 --- a/packages/cli/api/theme-edit/_settings.mjs +++ b/packages/cli/api/theme-edit/_settings.mjs @@ -4612,22 +4612,28 @@ export async function buildTheme(ctx, slug, data) { let receipt = await step(() => runBuild(ctx, slug)); const found = findOverrides(fs.readFileSync(f.css, 'utf8'), theme).filter( x => - // A variant whose rule the settings already give this property, a - // shorthand covering it, or a longhand of it that already carries the - // plain value (a one-mode background color written per variant, the - // plain color as its other half) needs no copy of it. + // By property: a variant whose rule the settings give this property + // or a shorthand covering it needs no copy; nor one holding a + // longhand of it that the base theme's rule also holds and that has + // no sides to be written as (a background color, one-mode or not): + // the copy would come after it there and win. Any other longhand + // takes the copy, then is written after it (as its sides where the + // base holds it). !Object.entries( (x.state ? theme.components[x.target][x.key]?.[x.state] : theme.components[x.target][x.key]) ?? {}, - ).some( - ([k, v]) => - !k.startsWith(':') && - (k === x.prop || - coversProperty(kebab(k), kebab(x.prop)) || - (coversProperty(kebab(x.prop), kebab(k)) && - String(v).includes(String(x.value)))), - ), + ).some(([k, v]) => { + if (k.startsWith(':')) return false; + if (k === x.prop || coversProperty(kebab(k), kebab(x.prop))) + return true; + if (!coversProperty(kebab(x.prop), kebab(k))) return false; + const baseRule = /** @type {any} */ (base).components?.[x.target]?.[ + x.key + ]; + const held = (x.state ? baseRule?.[x.state] : baseRule)?.[k]; + return held != null && sidesOf(kebab(k), String(v)) == null; + }), ); /** @type {string[]} */ const lifted = []; diff --git a/packages/cli/api/theme-edit/theme-parts43.test.mjs b/packages/cli/api/theme-edit/theme-parts43.test.mjs index 5b6832cd9579f..3078101c2ef59 100644 --- a/packages/cli/api/theme-edit/theme-parts43.test.mjs +++ b/packages/cli/api/theme-edit/theme-parts43.test.mjs @@ -242,6 +242,94 @@ describe('one-mode backgrounds on parts whose variants have their own', () => { expect(after).not.toMatch(/border-(width|style):/); }, 240_000); + for (const [ + like, + plain, + plainValue, + name, + prop, + value, + wholeAfter, + ] of /** @type {Array<[string, string, string, string, string, string, string]>} */ ([ + [ + 'butter', + 'border-width', + '2px', + 'secondary', + 'border-bottom-width', + '12px', + 'border-width: 2px', + ], + [ + 'butter', + 'border-color', + '#ff0000', + 'secondary', + 'border-bottom-color', + '#ff000080', + 'border-color: #ff0000', + ], + [ + 'butter', + 'border-style', + 'dashed', + 'secondary', + 'border-bottom-style', + 'dashed', + 'border-style: dashed', + ], + [ + 'butter', + 'background', + '#123456', + 'secondary', + 'background-color', + '#00ff00', + '', + ], + [ + 'neutral', + 'background', + '#123456', + 'destructive', + 'background-color', + '#00ff00', + '', + ], + [ + 'neutral', + 'border-width', + '2px', + 'destructive', + 'border-bottom-width', + '12px', + 'border-width: 2px', + ], + ])) + it(`writes a variant's later ${prop} after the part's ${plain} (${like})`, async ctx => { + if (needBases(ctx)) return; + const slug = + `o6${like[0]}${plain.replace(/\W/g, '')}${prop.replace(/\W/g, '')}`.slice( + 0, + 40, + ); + await themeEditNew(slug, {cwd, like}); + await themeEditSet(slug, plain, plainValue, {cwd, on: 'button'}); + await themeEditSet(slug, prop, value, { + cwd, + on: 'button', + when: `variant:${name}`, + }); + const rule = ruleOf(slug, `.astryx-button[data-variant="${name}"]`); + const own = rule.lastIndexOf(`${prop}: ${value}`); + expect(own, rule).toBeGreaterThan(-1); + // No shorthand covering it comes after the variant's own value. + const short = plain === 'background' ? 'background:' : `${plain}:`; + expect(rule.slice(own), rule).not.toContain(short); + // The part's value still reaches the variant's other sides. + if (wholeAfter) expect(rule, rule).toContain(wholeAfter); + }, 240_000); + it("says once that a variant keeps its own gradient, and that a part's own gradient covers the color", async ctx => { if (needBases(ctx)) return; await themeEditNew('m5n1', {cwd, like: 'neutral'}); From e0735a0372df8f75669f126feecce83a12f4bd59 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Fri, 13 Nov 2026 10:51:13 -0800 Subject: [PATCH 21/21] test: record the preview-safety suite's run time --- scripts/vitest-node-durations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/vitest-node-durations.json b/scripts/vitest-node-durations.json index b97b1ef660c43..2a1d753631e0a 100644 --- a/scripts/vitest-node-durations.json +++ b/scripts/vitest-node-durations.json @@ -492,7 +492,7 @@ "packages/cli/api/theme-edit/theme-new-suggestions.test.mjs": 40.5, "packages/cli/api/theme-edit/theme-palette.test.mjs": 28.7, "packages/cli/api/theme-edit/theme-picture.test.mjs": 0, - "packages/cli/api/theme-edit/theme-preview-safety.test.mjs": 344.7, + "packages/cli/api/theme-edit/theme-preview-safety.test.mjs": 324.8, "packages/cli/api/theme-edit/theme-preview.test.mjs": 140.3, "packages/cli/api/theme-edit/theme-stress.test.mjs": 37.6, "packages/cli/api/theme-edit/theme-unchecked.test.mjs": 2.9,