From db30a20b4a00a7f3e75d08bef786987c2ddc6372 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Thu, 3 Sep 2026 19:12:16 +0200 Subject: [PATCH] fix: the eyebrow tell no longer counts a type role's default tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third finding from building the v2.1.0 checkout example. Eleven ordinary form labels — "Email", "Full name", "City" — were counted as decorative eyebrows, because the tell qualified any small text with letterSpacing above zero and the `soft` personality sets letterSpacing on its `label` type role. Every label in the design inherited an eyebrow signal it never asked for. The threshold is measured rather than guessed. Across a 112-canvas corpus (the 11 shipped patterns plus 101 real canvases) the tell counted 393 texts: 154 by textTransform, 221 by content already typed in capitals, and 18 by tracking alone. Every one of those 18 was a form label or a tab. Their tracking runs 0.3 to 0.6 and comes from a type role; tracking chosen deliberately as an eyebrow treatment sits at 1 to 2. The two classes separate cleanly, so the floor sits at 1, with margin on both sides. Capitalised content now qualifies on its own terms rather than incidentally through tracking, which is what the corpus actually contains — "MODULES", "YEAR TO DATE" — and is the same design decision as textTransform, authored differently. The change is strictly narrowing: every text it stops counting was counted before, and nothing new starts counting. Measured effects, all confirmed rather than assumed: - corpus canvases firing the tell: 1 before, 1 after, the same canvas — the one true positive is preserved - benchmark: hero 72, minimal 98, bad-contrast 82, unchanged from baselines - the existing fixture asserting letter-spaced labels count (letterSpacing 2) still passes, because 2 is above the floor Verified: test:full 100/100, discoverability 117/117. --- docs/GUIDELINES.md | 2 +- src/evaluate.ts | 42 +++++++++++++++++++++++++++++++++++++++--- test-cliche.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 799d72d..6d1b6b6 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -320,7 +320,7 @@ Polishing the design to the bar is **your** job, not the user's — they should | **Fake browser / OS chrome** | A row of ≥3 small circular dots (mac traffic lights) wrapping content | Frame the content directly. Skip the fake window — it adds nothing and dates the mockup. | | **Hanging eyebrow header** | A small eyebrow/tag *beside* a large heading in a horizontal row | Stack the eyebrow **above** the heading (`layout: "vertical"`, left-aligned). | | **Fabricated content** | Invented metrics / testimonials / brand logos in placeholder copy (`"99.9% uptime"`, `"— Jane Doe, CEO"`, `"TechCrunch"`) | Use a labeled placeholder until real data exists: `"Uptime — to confirm"` + a neutral block. Don't ship invented numbers. **Exception:** on a dashboard/analytics mockup the realistic figures ARE the design — pass `genre: "dashboard"` (alias `"data"`) so they aren't flagged; on a transactional screen (cart, checkout, order confirmation, billing history) the money is the design — pass `genre: "commerce"` (alias `"checkout"`). A pricing page gets neither: its numbers are claims. | -| **Eyebrow rhythm** | More eyebrow labels (small uppercase / letter-spaced text) than ~1 per 3 sections — an eyebrow above nearly every heading | Keep eyebrows rare (≤ `ceil(sections / 3)`). Let most headings stand alone; reserve the eyebrow for sections that genuinely need a kicker. | +| **Eyebrow rhythm** | More eyebrow labels than ~1 per 3 sections — an eyebrow above nearly every heading. An eyebrow is small text that is uppercase (via `textTransform` **or** typed in capitals) or tracked at `letterSpacing` ≥ 1 | Keep eyebrows rare (≤ `ceil(sections / 3)`). Let most headings stand alone; reserve the eyebrow for sections that genuinely need a kicker. A form label with the light tracking a type role sets (0.25–0.6) is not an eyebrow and doesn't count. | | **Slop copy** | Stock AI phrasing in short copy — filler verbs (`"Elevate"`, `"Seamless"`, `"Unleash"`), scroll cues (`"Scroll to explore"`), placeholder names (`"Jane Doe"`), hype labels (`"BETA"`, `"Early access"`), section-number eyebrows (`"01 / Index"`) | Write specific, branded copy that names the concrete benefit. A numeral bound to a unit noun by a tight hyphen is a compound modifier, not an eyebrow — `"30-day returns"`, `"2-year warranty"` and `"24/7 support"` are ordinary product copy and pass. | | **Radius consistency** | 4+ distinct corner radii across the page — no single radius system (full pills, `cornerRadius: 999`+, are a shape choice and never count toward the census) | Consolidate to one small radius scale (e.g. `8` / `12`, plus `999` for pills). Define it as a `$token` and reuse. | | **Pure black / white** | `#000000` ink (text / icon / stroke / fill) or a `#ffffff` page background | Use a designed off-black (`#0A0A0A`) for ink and an off-white (`#FAFAFA`) for the page surface. | diff --git a/src/evaluate.ts b/src/evaluate.ts index 7942f17..ac1f23a 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -1305,6 +1305,19 @@ function tellHonestContent(ctx: ClicheCtx): EvaluationIssue[] { // *every* section is the template-rhythm tell. Distinct from hanging-header, // which scores one eyebrow's placement; this scores their global count vs the // section count. Cap: at most ceil(sectionCount / 3) — ~1 eyebrow per 3 sections. +/** Letter-spacing at or above this reads as a deliberate eyebrow treatment + * rather than a type role's default tracking. See the census in + * tellEyebrowRhythm for how it was chosen. */ +const EYEBROW_TRACKING_MIN = 1; + +/** Text already typed in capitals — the authored equivalent of textTransform. + * Needs at least two letters so an acronym-free string ("12 / 24") or a single + * initial doesn't read as a label. */ +function readsUppercase(content: string): boolean { + const letters = content.replace(/[^\p{L}]/gu, ''); + return letters.length >= 2 && content === content.toUpperCase(); +} + function tellEyebrowRhythm(ctx: ClicheCtx): EvaluationIssue[] { if (ctx.relaxed.has('eyebrow-rhythm')) return []; @@ -1326,9 +1339,32 @@ function tellEyebrowRhythm(ctx: ClicheCtx): EvaluationIssue[] { const isEyebrowText = (n: SceneNode): boolean => { if (n.type !== 'text' || typeof n.content !== 'string' || n.content.trim().length === 0) return false; if ((typeof n.fontSize === 'number' ? n.fontSize : 16) > eyebrowMax) return false; - // The signature: small text that is uppercased or letter-spaced (a label, - // not body copy). Either property qualifies; both is the canonical eyebrow. - return n.textTransform === 'uppercase' || (typeof n.letterSpacing === 'number' && n.letterSpacing > 0); + // The signature: small text that reads as a designed LABEL rather than as + // cramped body copy. Three ways to qualify, and the third is the calibrated + // one: + // - textTransform: 'uppercase' — the explicit form + // - content already typed in capitals ("MODULES", "YEAR TO DATE") — the + // same thing authored differently, and the most common form in practice + // - tracking heavy enough to be a deliberate styling choice + // + // Any tracking at all used to qualify, and that was the bug: a personality + // can set letterSpacing on its `label` type role, so every small label in a + // design inherited an eyebrow signal it never asked for. Eleven ordinary + // form fields ("Email", "Full name", "City") were counted as eyebrows on the + // v2.1.0 checkout example. + // + // The threshold is measured, not guessed. Across the 112-canvas corpus the + // tell counted 393 texts: 154 by textTransform, 221 by capitalised content, + // and 18 by tracking alone — and every one of those 18 was a form label or a + // tab. Their tracking runs 0.3 to 0.6, inherited from a type role. Tracking + // that is a deliberate eyebrow choice sits at 1 to 2 (the genuine + // capitalised labels reach 1.5, the fixtures below use 2). A floor of 1 + // separates the two classes with margin on both sides and changed no + // canvas's verdict in the corpus. + if (n.textTransform === 'uppercase') return true; + const tracking = typeof n.letterSpacing === 'number' ? n.letterSpacing : 0; + if (tracking <= 0) return false; + return readsUppercase(n.content) || tracking >= EYEBROW_TRACKING_MIN; }; const isHeading = (n: SceneNode): boolean => n.type === 'text' && (typeof n.fontSize === 'number' ? n.fontSize : 16) >= headingMin; diff --git a/test-cliche.ts b/test-cliche.ts index 17e33ea..312759a 100644 --- a/test-cliche.ts +++ b/test-cliche.ts @@ -354,6 +354,43 @@ I(s2, {type:"text", content:"Section two", fontSize:11, letterSpacing:2}) I(s2, {type:"text", content:"Heading two", fontSize:36})`); assert(tells(await cliche(ls), 'eyebrow-rhythm').length === 1, 'letter-spaced labels count as eyebrows'); + // …but only when the tracking is a deliberate choice. A type role that sets a + // little letterSpacing (the `soft` personality ships label at 0.25) used to + // turn every form field on a screen into an "eyebrow": the v2.1.0 checkout + // example had eleven, all of them ordinary labels sitting above inputs. The + // census across the canvas corpus put role-inherited tracking at 0.3–0.6 and + // deliberate eyebrow tracking at 1–2, so the floor sits at 1. + const formLabels = build('eyebrow-form-labels', ` +page=I("document", {type:"frame", width:1200, layout:"vertical", gap:24}) +s1=I(page, {type:"frame", layout:"vertical", gap:8}) +I(s1, {type:"text", content:"Email", fontSize:13, letterSpacing:0.25}) +I(s1, {type:"text", content:"you@example.com", fontSize:16}) +I(s1, {type:"text", content:"Full name", fontSize:13, letterSpacing:0.25}) +I(s1, {type:"text", content:"Rosa Lindqvist", fontSize:16}) +I(s1, {type:"text", content:"Heading one", fontSize:36}) +s2=I(page, {type:"frame", layout:"vertical", gap:8}) +I(s2, {type:"text", content:"City", fontSize:13, letterSpacing:0.25}) +I(s2, {type:"text", content:"Bristol", fontSize:16}) +I(s2, {type:"text", content:"Postcode", fontSize:13, letterSpacing:0.25}) +I(s2, {type:"text", content:"BS1 4TR", fontSize:16}) +I(s2, {type:"text", content:"Heading two", fontSize:36})`); + assert(tells(await cliche(formLabels), 'eyebrow-rhythm').length === 0, + 'lightly-tracked sentence-case form labels are not eyebrows'); + + // Capitalised content is an eyebrow however it was authored — typing the caps + // instead of setting textTransform is the same design decision, and it is the + // most common form in the corpus. + const literalCaps = build('eyebrow-literal-caps', ` +page=I("document", {type:"frame", width:1200, layout:"vertical", gap:24}) +s1=I(page, {type:"frame", layout:"vertical", gap:8}) +I(s1, {type:"text", content:"MODULES", fontSize:12, letterSpacing:0.5}) +I(s1, {type:"text", content:"Heading one", fontSize:36}) +s2=I(page, {type:"frame", layout:"vertical", gap:8}) +I(s2, {type:"text", content:"YEAR TO DATE", fontSize:12, letterSpacing:0.5}) +I(s2, {type:"text", content:"Heading two", fontSize:36})`); + assert(tells(await cliche(literalCaps), 'eyebrow-rhythm').length === 1, + 'capitalised content counts even without textTransform'); + // at cap: 1 eyebrow across 3 sections → within ceil(3/3)=1 const atCap = build('eyebrow-atcap', ` page=I("document", {type:"frame", width:1200, layout:"vertical", gap:24})