It needs theme.qwe, as every component does, and readability_meter.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
import ui/theme.qwe use theme_cssuse ui/headline_analyzer.qwerouteGET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>
Example
The code of the preview above.
demos/headline_analyzer.qwe
let headline_analyzer_demo_variants = ["5 Simple Ways to Write Better Headlines Today", "Some Things You Might Want To Know", "Why the City Council Finally Approved the New Bicycle Lanes on Main Street After Years of Arguing Over Parking"]componentHeadlineAnalyzerDemoLive() {statebest = null <div.demo-stack> <HeadlineAnalyzer id="headline-analyzer-demo-live" name="title" value="Tea cools fast" on_change={(r) => { best = r.best }}/> <p.demo-note>{best == null ? "Change the headline to see on_change." : `on_change: the best is "${best}".`}</p> </div>}viewHeadlineAnalyzerDemo() = <div.demo-stack> <HeadlineAnalyzer id="headline-analyzer-demo" variants={headline_analyzer_demo_variants}/> <HeadlineAnalyzerDemoLive/> </div>
Source
ui/headline_analyzer.qwe
// HeadlineAnalyzer: how good a headline is before it goes out. Each one gets a score out// of 100 from its length, the balance of common and uncommon words, numbers, power and// weak words and its shape, with what earned or lost the points, and shows where a search// result and a phone would cut it off. Several can be compared side by side, and the best// is marked. A headline not in the Latin alphabet is judged on length and shape alone,// and it says so, as the word lists are English.//// <HeadlineAnalyzer variants={["5 Simple Ways to Write Better Headlines", "Writing Headlines"]}/>// <HeadlineAnalyzer value={post.title} name="title" on_change={(r) => { best = r.best }}/>//// value: one headline to start with. variants: several, in place of value (a list of// text). name: the fields' name ("headline"), so a plain <form> sends them. id: a prefix// for its ids ("headline-analyzer"), unique on the page. most: how many it compares (6).// on_change(result): inside one of your components, given { headlines, best } as they// change: headlines lists each one's analysis below, in order, and best is the text of// the highest score (the first of equals), null with none. class: more classes for its// outer element.//// headline_analyzer_of(text) is the analysis, for a program's own use: { text, words,// characters, score, grade, checks, common, uncommon, power, weak, numbers, search, phone,// capped, latin }, where score is a whole number, grade is "Strong", "Okay" or "Weak",// checks lists { id, label, ok, points, most, note }, common and uncommon count words,// power, weak and numbers list them, search is { text, cut, width } and phone// { lines, cut }.//// The points: length 30 (6 to 12 words in 60 characters; half for 4 to 14 in 70), word// balance 20 (a fifth to a half common words; half for a tenth to three fifths), a// number 10, a power word 20, no weak words 15 (5 off for each), and shape 5 (not all// capitals, no full stop at the end, no "!!"). Widths are worked out from Arial's, so// they are close to a browser's and the same on the server: a search result is one// line of 600 pixels at 20 pixels, a phone two lines of 328 at 18.import ui/theme.qwe use ui_clsimport ui/readability_meter.qwe use readability_meter_foreign_script, readability_meter_stop_sets, readability_meter_wordslet headline_analyzer_css = css`.ui-headline-analyzer { display: grid; gap: 14px; min-width: 0; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-headline-analyzer-fields { display: grid; gap: 8px; min-width: 0; }.ui-headline-analyzer-row { display: grid; gap: 4px; min-width: 0; }.ui-headline-analyzer-row label { font-size: 13px; font-weight: 500; }.ui-headline-analyzer-entry { display: flex; gap: 6px; min-width: 0; }.ui-headline-analyzer-entry input { box-sizing: border-box; flex: 1; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-headline-analyzer-entry input:hover { border-color: var(--ui-strong); }.ui-headline-analyzer-entry input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-headline-analyzer-btn { flex: none; height: 36px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-headline-analyzer-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-headline-analyzer-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-headline-analyzer-add { justify-self: start; }.ui-headline-analyzer-said { margin: 0; font-weight: 600; }.ui-headline-analyzer-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; min-width: 0; }.ui-headline-analyzer-card { display: grid; align-content: start; gap: 10px; min-width: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); --k: var(--ui-muted); }.ui-headline-analyzer-strong { --k: var(--ui-success); }.ui-headline-analyzer-okay { --k: var(--ui-warning); }.ui-headline-analyzer-weak { --k: var(--ui-danger); }.ui-headline-analyzer-best { border-color: var(--ui-success); }.ui-headline-analyzer-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 0; }.ui-headline-analyzer-score { font-size: 26px; font-weight: 700; line-height: 1.1; color: var(--k); font-variant-numeric: tabular-nums; }.ui-headline-analyzer-grade { font-weight: 600; }.ui-headline-analyzer-flag { padding: 1px 7px; border-radius: 999px; background: var(--ui-success-soft); color: var(--ui-success); font-size: 12px; font-weight: 600; }.ui-headline-analyzer-name { margin: 0; font-weight: 600; overflow-wrap: anywhere; }.ui-headline-analyzer-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-headline-analyzer-checks { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; font-size: 13px; }.ui-headline-analyzer-checks li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }.ui-headline-analyzer-checks b { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-headline-analyzer-checks span { overflow-wrap: anywhere; }.ui-headline-analyzer-lost b { color: var(--ui-danger); }.ui-headline-analyzer-view { display: grid; gap: 2px; padding: 8px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-size: 13px; }.ui-headline-analyzer-view small { color: var(--ui-muted); font-size: 12px; }.ui-headline-analyzer-cutoff { color: var(--ui-info); overflow-wrap: anywhere; }`// Arial's widths, in thousandths of the type size, by the characters that share one.let headline_analyzer_width_groups = [[191, "'"], [222, "ijl"], [260, "|"], [278, " .,:;!/\\[]tfI"], [333, "-()r{}`"], [355, "\""], [500, "cksvxyzJ"], [584, "+<=>~"], [667, "&ABEKPSVXY"], [722, "CDHNRUw"], [778, "GOQ"], [833, "m"], [889, "%"], [944, "W"], [1015, "@"], [611, "FTZ"]]let headline_analyzer_widths = reduce(headline_analyzer_width_groups, {}, (acc, g) => reduce(split(g[1], ""), acc, (m, ch) => set(m, ch, g[0])))// The width of each character: 556 is Arial's digit and common small letter, 1000 the// square of Chinese, Japanese, Korean and emoji.fn headline_analyzer_char_width(ch: str) -> int { let w = get(headline_analyzer_widths, ch, null) if w != null { return w } re.test(ch, "[ᄀ-ᇿ⺀-豈-︰-﹏-⦆\u{1F000}-\u{1FAFF}]") ? 1000 : 556}// How wide some text comes out at a size in pixels.fn headline_analyzer_width(text: str, size) = (split(text, "") | map(headline_analyzer_char_width) | sum()) * size / 1000// The most of `text` that fits `room` pixels with "..." after it, by whole words where// there is a space to cut at, as search engines cut: { text, cut }.fn headline_analyzer_fit(text: str, room, size) { if headline_analyzer_width(text, size) <= room { return { text, cut: false } } let budget = room - headline_analyzer_width("...", size) let chars = split(text, "") let used = 0 let i = 0 while i < len(chars) && used + headline_analyzer_char_width(chars[i]) * size / 1000 <= budget { used += headline_analyzer_char_width(chars[i]) * size / 1000 i += 1 } let fits = i let space = fits while space > 0 && chars[space] != "" { space -= 1 } let upto = space > fits / 2 ? space : fits { text: trim(join(take(chars, upto), "")) + "...", cut: true }}// The text in `most` lines of `room` pixels: { lines, cut }.fn headline_analyzer_wrap(text: str, room, size, most: int) { let lines = [] let line = "" let words = split(text, "") let i = 0 while i < len(words) { let next = line == "" ? words[i] : `${line} ${words[i]}` if headline_analyzer_width(next, size) <= room || line == "" { line = next; i += 1 } else { lines = push(lines, line); line = "" } } lines = line == "" ? lines : push(lines, line) if len(lines) <= most && all(lines, (l) => headline_analyzer_width(l, size) <= room) { return { lines, cut: false } } let kept = take(lines, most - 1) let rest = join(drop(lines, most - 1), "") { lines: push(kept, headline_analyzer_fit(rest, room, size).text), cut: true }}let headline_analyzer_power_words = "amazing best better breakthrough complete easy effortless essential exclusive fast free guaranteed instant new powerful proven quick remarkable secret shocking simple surprising today ultimate".split("")let headline_analyzer_weak_words = "maybe might nice okay perhaps possibly probably pretty quite really some somewhat stuff thing things very".split("")let headline_analyzer_number_words = "one two three four five six seven eight nine ten eleven twelve hundred thousand million billion".split("")// The most characters read, as nothing past a tweet's length is a headline.let headline_analyzer_cap = 300// The analysis (see the top of the file).fn headline_analyzer_of(text: str) { let capped = len(text) > headline_analyzer_cap let t = trim(capped ? slice(text, 0, headline_analyzer_cap) : text) let words = readability_meter_words(t) | map((w) => replace(lower(w.text), "’", "'"))let n = len(words)let chars = len(t)let latin = readability_meter_foreign_script(t) == nulllet stop = readability_meter_stop_sets.enlet common = count(words, (w) => has(stop, w))let power = words | filter((w) => contains(headline_analyzer_power_words, w)) | unique()let weak = words | filter((w) => contains(headline_analyzer_weak_words, w)) | unique()let numbers = words | filter((w) => re.test(w, "^[0-9]") || contains(headline_analyzer_number_words, w))let share = n == 0 ? 0 : common / nlet length_points = n >= 6 && n <= 12 && chars <= 60 ? 30 : n >= 4 && n <= 14 && chars <= 70 ? 15 : 0let length_note = chars > 60 ? `${chars} characters: aim for 60 at most.` : n < 6 ? `${n} ${n == 1 ? "word" : "words"}: aim for 6 to 12.` : n > 12 ? `${n} words: aim for 6 to 12.` : `${n} words, ${chars} characters.`let balance_points = share >= 0.2 && share <= 0.5 ? 20 : share >= 0.1 && share <= 0.6 ? 10 : 0let loud = re.test(t, "[A-Z]{2}") && upper(t) == tlet shape_ok = !loud && !ends_with(t, ".") && !contains(t, "!!")let shape_note = loud ? "All capitals read as shouting." : ends_with(t, ".") ? "A headline ends without a full stop." : contains(t, "!!") ? "One exclamation mark is plenty." : "Reads as a headline."let weak_points = max([0, 15 - 5 * len(weak)])let checks = [ { id: "length", label: "Length", ok: length_points == 30, points: length_points, most: 30, note: length_note }, ...(latin ? [ { id: "balance", label: "Word balance", ok: balance_points == 20, points: balance_points, most: 20, note: `${common} common, ${n - common} uncommon: aim for a fifth to a half common.` }, { id: "number", label: "Number", ok: len(numbers) > 0, points: len(numbers) > 0 ? 10 : 0, most: 10, note: len(numbers) > 0 ? `Has ${join(numbers, ", ")}.` : "A number catches the eye." }, { id: "power", label: "Power words", ok: len(power) > 0, points: len(power) > 0 ? 20 : 0, most: 20, note: len(power) > 0 ? join(power, ", ") : "None: try a stronger word." }, { id: "weak", label: "Weak words", ok: len(weak) == 0, points: weak_points, most: 15, note: len(weak) > 0 ? join(weak, ", ") : "None." }, ] : []), { id: "shape", label: "Shape", ok: shape_ok, points: shape_ok ? 5 : 0, most: 5, note: shape_note }, ]let earned = checks | map((c) => c.points) | sum()let possible = checks | map((c) => c.most) | sum()let score = round(earned * 100 / possible) { text: t, words: n, characters: chars, score, grade: score >= 70 ? "Strong" : score >= 45 ? "Okay" : "Weak", checks, common, uncommon: n - common, power, weak, numbers, search: { ...headline_analyzer_fit(t, 600, 20), width: round(headline_analyzer_width(t, 20)) }, phone: headline_analyzer_wrap(t, 328, 18, 2), capped, latin }}// The highest score of a list of analyses, the first of equals; null for none.fnheadline_analyzer_best(results: list) {iflen(results) == 0 { returnnull }reduce(results, results[0], (b, r) => r.score > b.score ? r : b).text}componentHeadlineAnalyzer(value: str = "", variants: list? = null, name: str = "headline", id: str = "headline-analyzer", most: int = 6, on_change = null, class: str? = null) { style global headline_analyzer_cssstaterows = (variants ?? [value]) | enumerate | map((p) => { key: p[0], text: str(p[1] ?? "") })statenext_key = len(variants ?? [value])statefocus_key = -1let filled_rows = rows | filter((r) => trim(r.text) != "")let results = filled_rows | map((r) => { key: r.key, ...headline_analyzer_of(r.text) })let best = len(results) > 1 ? headline_analyzer_best(results) : nulllet said = len(results) == 0 ? "Type a headline to score it." : len(results) == 1 ? `${results[0].score} out of 100: ${lower(results[0].grade)}.` : `Compared ${len(results)} headlines. The best scores ${max(results | map((r) => r.score))}.`let tell = (next: list) => {if on_change != null {let done = next | filter((r) => trim(r.text) != "") | map((r) => headline_analyzer_of(r.text))on_change({ headlines: done, best: headline_analyzer_best(done) }) } }let edit = (key: int, text: str) => { rows = rows | map((r) => r.key == key ? { key, text } : r)tell(rows) }let add = () => { focus_key = next_key rows = push(rows, { key: next_key, text: "" }) next_key = next_key + 1 }let drop_row = (e, key: int) => {// Its button goes with it, so the focus moves to the field next to it.let left = rows | filter((r) => r.key != key)let after = rows | filter((r) => r.key > key)let next = len(after) > 0 ? first(after) : last(left) e.target.closest(".ui-headline-analyzer").querySelector(`[id="${id}-${next.key}"]`).focus() rows = lefttell(rows) } <section class={ui_cls(["ui-headline-analyzer", class])} aria-labelledby={`${id}-said`}> <div.ui-headline-analyzer-fields> {for [i, r] inenumerate(rows) => <div.ui-headline-analyzer-row key={r.key}> <labelfor={`${id}-${r.key}`}>{len(rows) == 1 ? "Headline" : `Headline ${i + 1}`}</label> <div.ui-headline-analyzer-entry> <inputtype="text" id={`${id}-${r.key}`} name={name} value={r.text} autocomplete="off"@mount={(el) => { if focus_key == r.key { el.focus() } }}@input={(e) => { edit(r.key, e.value) }}/> {len(rows) > 1 && <button.ui-headline-analyzer-btn type="button" aria-label={`Remove headline ${i + 1}`} @click={(e) => { drop_row(e, r.key) }}>Remove</button>} </div> </div>} {len(rows) < most && <button.ui-headline-analyzer-btn.ui-headline-analyzer-add type="button"@click={() => { add() }}>Add a variant</button>} </div> <p.ui-headline-analyzer-said id={`${id}-said`} aria-live="polite">{said}</p> {len(results) > 0 && <div.ui-headline-analyzer-cards> {for r in results => <article key={r.key} class={ui_cls(["ui-headline-analyzer-card", `ui-headline-analyzer-${lower(r.grade)}`, best != null && r.text == best && "ui-headline-analyzer-best"])}> <p.ui-headline-analyzer-top> <span.ui-headline-analyzer-score>{r.score}</span> <span.ui-headline-analyzer-grade>{r.grade}</span> {best != null && r.text == best && <span.ui-headline-analyzer-flag>Best</span>} </p> <h4.ui-headline-analyzer-name>{r.text}</h4> {!r.latin && <p.ui-headline-analyzer-note>The word lists are English, so this is judged on length and shape alone.</p>} {r.capped && <p.ui-headline-analyzer-note>Only the first {headline_analyzer_cap} characters were read.</p>} <ul.ui-headline-analyzer-checks> {for c in r.checks => <li key={c.id} class={c.ok ? null : "ui-headline-analyzer-lost"}> <span>{c.label}: {c.note}</span><b>{c.points}/{c.most}</b> </li>} </ul> <div.ui-headline-analyzer-view> <small>In a search result{r.search.cut ? ": cut off" : ""}</small> <span.ui-headline-analyzer-cutoff>{r.search.text}</span> </div> <div.ui-headline-analyzer-view> <small>On a phone{r.phone.cut ? ": cut off" : ""}</small> {for [i, l] inenumerate(r.phone.lines) => <span.ui-headline-analyzer-cutoff key={i}>{l}</span>} </div> </article>} </div>} </section>}