Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/GUIDELINES.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
42 changes: 39 additions & 3 deletions src/evaluate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 [];

Expand All@@ -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;

Expand Down
37 changes: 37 additions & 0 deletions test-cliche.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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})
Expand Down
Loading