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})