super-ux 0.56.2 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +4 -3
- package/cursor/rules/copywriting.mdc +32 -10
- package/package.json +2 -2
- package/plugins/super-ux/scripts/brand_lint.py +612 -21
- package/templates/brand/README.md +7 -1
- package/templates/brand/channels.md +27 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
+
## 0.57.0 — short video gets a playbook, a hook filter and a caption linter
|
|
2
|
+
|
|
3
|
+
`copywriting` covered posts for X, Reddit and LinkedIn and nothing that moves:
|
|
4
|
+
no Reels, Shorts or TikTok physics, no hook, no script, no caption rules, and
|
|
5
|
+
an X playbook asserting two ranking rules the open ranker does not contain.
|
|
6
|
+
This release fills that from the family's 2026-10-07 research, every rule with
|
|
7
|
+
its source and the date it was read.
|
|
8
|
+
|
|
9
|
+
- **Three new references, linked from `copywriting`.** `video-script.md`: the
|
|
10
|
+
angle in one sentence, the modular hook, body and ask, six organic and ten
|
|
11
|
+
ad structures, words per second for English, the shot list, gates G2–G4,
|
|
12
|
+
reading the retention chart. **Russian timing is measured by reading aloud,
|
|
13
|
+
and the file carries no coefficient**: the research proposed one as its
|
|
14
|
+
author's assumption, and a table would have made it a rule nobody re-measures.
|
|
15
|
+
`hooks.md`: the mechanics (frame 0, text by about 0.5 s, works muted, the body
|
|
16
|
+
pays), the triple (frame 0, on-screen text of ten words or fewer, first line),
|
|
17
|
+
a taxonomy, 26 formulas and 40 templates, each template with its source.
|
|
18
|
+
`research-outliers.md`: views ÷ the account's median, 3× a signal, under 1.5×
|
|
19
|
+
noise, a sample within 10× of your size, with a worked example.
|
|
20
|
+
- **`channel-playbooks.md`.** New `Instagram` (the 125-character feed window,
|
|
21
|
+
five hashtags since 2025-12-18, links that do not click, one ask, sends as
|
|
22
|
+
the signal for non-followers) and `Short video` (platform signals, originality,
|
|
23
|
+
AI labelling, safe zones, length, pace, the loop, trial reels). **X is re-read
|
|
24
|
+
against `xai-org/x-algorithm@e62790c`**: the published weights (share via
|
|
25
|
+
copy link 20, reply, quote and share via DM 5, a reply from a mutual follow
|
|
26
|
+
+15, follow 4, like 0.5, report −234, mute −58.8) and the 48-hour window are
|
|
27
|
+
in; "bookmarks weigh heavily" and "more than two hashtags is penalised" are
|
|
28
|
+
out, because neither is in the ranker; link suppression stays as practice,
|
|
29
|
+
marked unverified. The seeded `channels.md` X record no longer carries them.
|
|
30
|
+
- **Four new codes, 42 → 46.** `B044` filters short-video hooks: five checks,
|
|
31
|
+
0.6 × mean + 0.4 × minimum, −15 per dealbreaker, and on-screen text over ten
|
|
32
|
+
words. It is a filter, not a predictor, and its row says so in numbers (AUC
|
|
33
|
+
0.83 at catching a bad hook, 0.56 at picking a winner); a hook in Cyrillic is
|
|
34
|
+
not scored against English vocabulary. `B045` and `B046` read two new
|
|
35
|
+
physics tokens, `fold N` and `one ask`. `B066` is the new marker AT-16:
|
|
36
|
+
invisible and look-alike-space characters, eighteen named classes plus any
|
|
37
|
+
other format character, seven numbered exemptions with a fixture each, and
|
|
38
|
+
it never examines a dash or a quotation mark. `B030` now reads the `hook` and
|
|
39
|
+
`on-screen` fields as public copy. `B042`'s message no longer assumes every
|
|
40
|
+
surface's reason is reach.
|
|
41
|
+
- **New surfaces:** `Instagram`, `TikTok`, `YouTube Shorts` and `short video`.
|
|
42
|
+
- **A gate for the shelf itself.** `validate_short_video_shelf` counts HF-01..26
|
|
43
|
+
and HT-01..40, refuses a template without a source URL, a short-video or
|
|
44
|
+
Instagram rule without one, the two removed X claims coming back, and
|
|
45
|
+
recomputes every multiple and reading in the outlier example. Each was
|
|
46
|
+
watched failing on a planted defect.
|
|
47
|
+
- Because `channel-playbooks.md` links the three new files, the closure ships
|
|
48
|
+
them inside `brand-voice` (13) and `ux-audit` (24) as well; their reference
|
|
49
|
+
sentences were recounted from the tree.
|
|
50
|
+
- Provenance: the 26 formula names, the scoring arithmetic, the dealbreakers,
|
|
51
|
+
the caption mechanics, the outlier method and the eighteen character classes
|
|
52
|
+
come from `Jakeschincariol/instagram-agent-skill` at `d03c56b` (MIT); shapes,
|
|
53
|
+
examples, word lists and code are rewritten and no text is reproduced.
|
|
54
|
+
|
|
55
|
+
## 0.56.3 — check the heading the reader sees
|
|
56
|
+
|
|
57
|
+
The no-title-period rule already existed, but its linter read Markdown headings
|
|
58
|
+
only and let multi-sentence titles escape. It now reads HTML headings, inline
|
|
59
|
+
markup and line-break fragments, with source-line evidence. HTML registry checks
|
|
60
|
+
read visible text rather than attribute fragments. Paragraph punctuation,
|
|
61
|
+
questions, abbreviations, versions and URLs keep their meaning.
|
|
62
|
+
|
|
63
|
+
- `--fail-on B063` makes selected warnings block without promoting unrelated
|
|
64
|
+
advice; default warning behavior and JSON output remain compatible.
|
|
65
|
+
- `B009` blocks unmatched declared source globs. Brace patterns advertised by
|
|
66
|
+
the contract now expand; seed projects must replace placeholder sources.
|
|
67
|
+
- Copywriting now verifies rendered roles, generators, actual linter paths and
|
|
68
|
+
gate coverage. The contract no longer describes advisory B060 as an error.
|
|
69
|
+
- Regression receipts and scope limits: [DC-01](docs/evidence/audits/2026-10-01-display-copy/report.md).
|
|
70
|
+
|
|
1
71
|
## 0.56.2 — the closure says it is a closure
|
|
2
72
|
|
|
3
73
|
Hygiene from the 2026-09-13 family audit (HK-11). Five skills ship more contracts than
|
package/README.md
CHANGED
|
@@ -157,11 +157,12 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
157
157
|
python3 docs/brand/lint.py
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
46 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
|
|
161
161
|
with no sourced fact, a field over its limit with the locale coefficient
|
|
162
162
|
applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
|
|
163
163
|
a rhetorical dash, a title that ends in a full stop, a locale that lags
|
|
164
|
-
without saying so
|
|
164
|
+
without saying so, a short-video hook the filter calls weak, a caption that
|
|
165
|
+
spends the feed window or asks twice, an invisible character pasted in. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
|
|
165
166
|
|
|
166
167
|
Clean means *checkable*, not *good*: tone drift, unproven claims and a voice
|
|
167
168
|
that has overshot its own failure mode are judged by `/ux-audit copy`.
|
|
@@ -243,7 +244,7 @@ to is a skill nobody runs.
|
|
|
243
244
|
| skill `ux-scenarios` | `docs/ux/scenarios.md`: use-case scenarios (action → observable response, alt and error paths) covering every flow node and edge, `Traces:` to stories and flows, validated for conflicts, coverage and traceability |
|
|
244
245
|
| skill `ux-audit` | Batched audit with full context: code vs every scenario plus its story's acceptance criteria; verdicts PASS / PARTIAL / FAIL / BLOCKED with `file:line` evidence; depths `quick` / `standard` / `deep`; a `coverage` scope that audits the chain itself |
|
|
245
246
|
| skill `brand-voice` | `docs/brand/`: the pack and its five axes, the words the product owns and bans, canonical facts, the per-surface register, locales, plus six shipped voice packs, each declaring the degeneration it collapses into when overdone |
|
|
246
|
-
| skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
|
|
247
|
+
| skill `copywriting` | Writes in that voice and never writes *to* it: interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, lifecycle email. A missing term or an unsourced number is reported, never invented |
|
|
247
248
|
| `/ux` | **The one command**: sets up whatever is missing, reports status across every layer, then offers only the applicable actions with one marked recommended. Idempotent |
|
|
248
249
|
| `/vision` `/ux-init` `/ux-foundation` `/ux-flows` `/ux-update` `/ux-audit` `/ux-rule` `/ux-lint` `/ux-doctor` · `/brand` `/brand-init` `/brand-update` `/brand-lint` `/copy` | Direct controls for when you know exactly what you want; `/ux-rule` installs both hard rules and seeds `lint.py` + `doctor.py`; `/brand-init` seeds `docs/brand/` and its linter |
|
|
249
250
|
| `docs/ux/lint.py` + `/ux-lint` | The deterministic half: missing Figma frames, unresolved SCR/story traces, orphans, built screens without coverage, index desync, ID gaps, broken links. Stdlib-only, exit 1 on problems, so wire it into CI and drift can't merge |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
|
|
2
|
+
description: "Write or rewrite any text a user reads (interface strings, errors, empty states, landing and pricing pages, posts and captions, short-video scripts and hooks, changelogs, store listings, ads, email): apply when asked to write copy, rewrite a headline or CTA, fix microcopy, or when a draft reads machine-made"
|
|
3
3
|
alwaysApply: false
|
|
4
4
|
---
|
|
5
5
|
|
|
@@ -16,12 +16,18 @@ Read the pack: `docs/brand/voice.md`, `terminology.md`, and the
|
|
|
16
16
|
`/brand-init`.** Guessing the voice and being wrong costs more than the
|
|
17
17
|
pause.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Ownership is by file: `brand-voice` owns voice, terms, facts, channels and
|
|
20
|
+
locale policy. Missing terms or facts are reported, never invented. This pass
|
|
21
|
+
owns product text and `strings.md` rows (`Status: proposed`); claim coordinated
|
|
22
|
+
files before editing. A file living in `docs/brand/` does not forbid the edit.
|
|
22
23
|
|
|
23
24
|
## Rules that hold in every voice
|
|
24
25
|
|
|
26
|
+
- **No decorative full stop in headings or labels**, even when inline markup
|
|
27
|
+
or line breaks split the title. “Your agents. Your tools.” is still a title.
|
|
28
|
+
Keep questions, ellipses, abbreviations, versions, URLs and paragraph punctuation.
|
|
29
|
+
Apply a project's explicit hero/caption policy to those additional roles.
|
|
30
|
+
|
|
25
31
|
- **Every figure comes from `facts.md`.** No sourced fact, no number. Never
|
|
26
32
|
fabricate a statistic, a quote, or an expert.
|
|
27
33
|
- **One action keeps one name** across button, confirmation, toast, history,
|
|
@@ -48,6 +54,17 @@ effortless / unlock / elevate / robust", em-dash cadence in every paragraph,
|
|
|
48
54
|
"it's not just X, it's Y", hedges stacked two deep, and a closing sentence
|
|
49
55
|
that summarizes what was just said.
|
|
50
56
|
|
|
57
|
+
## Short video
|
|
58
|
+
|
|
59
|
+
A hook is three things at once, about one thing: frame 0, on-screen text of
|
|
60
|
+
ten words or fewer, and the first spoken line. The body pays the promise
|
|
61
|
+
before the middle. Write the angle first ("for [persona], who wants
|
|
62
|
+
[desire], we show [proof]"), time the script by reading it aloud (never
|
|
63
|
+
convert a word count into another language), and give every figure in the
|
|
64
|
+
hook its `facts.md` row. `B044` filters weak hooks; it is a filter, never a
|
|
65
|
+
predictor of which hook wins. A caption fits the feed window and makes one
|
|
66
|
+
ask (`B045`, `B046`).
|
|
67
|
+
|
|
51
68
|
## Localization
|
|
52
69
|
|
|
53
70
|
A locale is not a translation of the English: the length coefficient, the
|
|
@@ -56,10 +73,15 @@ lags the primary says so rather than pretending to be current.
|
|
|
56
73
|
|
|
57
74
|
## Check
|
|
58
75
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
Resolve the actual linter: use `docs/brand/lint.py` if present, otherwise locate
|
|
77
|
+
`brand_lint.py` in the installed super-ux scripts directory and pass the project's
|
|
78
|
+
brand directory. If neither exists, state the missing tool and manual coverage.
|
|
79
|
+
Confirm Sources patterns match the touched files and copy projections retain
|
|
80
|
+
heading roles. Inspect rendered output and generators; static checks cannot
|
|
81
|
+
resolve external CSS or runtime text.
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
Run the resolved script with `--fail-on B063` when title punctuation must block.
|
|
84
|
+
An older script rejecting the flag needs updating, not a retry that drops the gate.
|
|
85
|
+
Exit 0 means clean or advisory warnings; selected warnings exit 1; errors exit 2.
|
|
86
|
+
`--strict` selects every warning. Report actual surfaces, warnings and command.
|
|
87
|
+
Tone, grounding and voice still require `/ux-audit copy`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"scripts": {
|
|
5
|
-
"test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
|
|
5
|
+
"test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
|
|
6
6
|
"test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
|
|
7
7
|
},
|
|
8
8
|
"description": "Scenario-driven UI development for AI agents (Claude Code, Cursor, 70+ agents): a versioned design chain in docs/ux/, a scenario-first hard rule, a deterministic drift linter, and evidence-backed UX audits. This package is the installer CLI.",
|
|
@@ -20,7 +20,10 @@ Read-only by default. `--fix` applies only the changes that cannot be wrong.
|
|
|
20
20
|
python3 brand_lint.py [path] --json # machine-readable findings
|
|
21
21
|
python3 brand_lint.py [path] --strict # warnings block too
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
python3 brand_lint.py [path] --fail-on B063 # selected warnings block
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 clean or advisory warnings, 1 selected warnings (`--strict` or
|
|
26
|
+
`--fail-on`), 2 any error. JSON remains an array of findings.
|
|
24
27
|
|
|
25
28
|
One pack, one policy: `ux_lint.py` has always blocked on errors and needed
|
|
26
29
|
`--strict` before a warning could fail a build, and this file returned 1 for
|
|
@@ -36,6 +39,8 @@ import argparse
|
|
|
36
39
|
import json
|
|
37
40
|
import re
|
|
38
41
|
import sys
|
|
42
|
+
from html import unescape
|
|
43
|
+
from html.parser import HTMLParser
|
|
39
44
|
from collections import namedtuple
|
|
40
45
|
from pathlib import Path
|
|
41
46
|
|
|
@@ -76,6 +81,7 @@ STRING_KINDS = ("copy", "layout")
|
|
|
76
81
|
|
|
77
82
|
SEVERITY_ERROR = "error"
|
|
78
83
|
SEVERITY_WARN = "warn"
|
|
84
|
+
WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072".split())
|
|
79
85
|
|
|
80
86
|
Finding = namedtuple("Finding", "code severity path line message")
|
|
81
87
|
|
|
@@ -240,7 +246,7 @@ def load_sources(brand_dir: Path) -> dict[str, list[str]]:
|
|
|
240
246
|
The linter cannot guess where a project keeps its text, and guessing
|
|
241
247
|
wrong produces the worst possible output: a clean report about a surface
|
|
242
248
|
that was never read. So an absent block is a finding (B006) and an
|
|
243
|
-
absent key means its checks are
|
|
249
|
+
absent key means its checks are not run (not a passed coverage verdict).
|
|
244
250
|
"""
|
|
245
251
|
text = read(brand_dir / "README.md") or ""
|
|
246
252
|
block = re.search(r"^Sources:\s*$(.*?)(?=^\S|\Z)", text, re.M | re.S)
|
|
@@ -248,7 +254,7 @@ def load_sources(brand_dir: Path) -> dict[str, list[str]]:
|
|
|
248
254
|
return {}
|
|
249
255
|
sources: dict[str, list[str]] = {}
|
|
250
256
|
for line in block.group(1).splitlines():
|
|
251
|
-
entry = re.match(r"^\s+(\w+):\s*(
|
|
257
|
+
entry = re.match(r"^\s+(\w+):\s*(.*?)\s*$", line)
|
|
252
258
|
if not entry:
|
|
253
259
|
continue
|
|
254
260
|
key, value = entry.group(1), entry.group(2)
|
|
@@ -714,7 +720,7 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
714
720
|
text = row["text"].rstrip()
|
|
715
721
|
if not text.endswith(".") or text.endswith("..") or text.endswith("…"):
|
|
716
722
|
continue
|
|
717
|
-
if
|
|
723
|
+
if not title_full_stop(text):
|
|
718
724
|
continue
|
|
719
725
|
findings.append(Finding(
|
|
720
726
|
"B026", SEVERITY_WARN, "strings.md", 0,
|
|
@@ -734,11 +740,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
734
740
|
continue
|
|
735
741
|
|
|
736
742
|
body = read(target) or ""
|
|
737
|
-
|
|
743
|
+
html = html_copy(body) if target.suffix.lower() in HTML_SUFFIXES else None
|
|
744
|
+
source_copy = " ".join(html.blocks) if html is not None else body
|
|
745
|
+
literals = html.interface_texts if html is not None else [
|
|
738
746
|
lit for lit in code_literals(_strip_comments(body, target.suffix))
|
|
739
747
|
if _looks_like_copy(lit)
|
|
740
748
|
]
|
|
741
|
-
if literals:
|
|
749
|
+
if literals or html is not None:
|
|
742
750
|
# The registry records what a reader sees, so the built page is the
|
|
743
751
|
# authority whenever there is one. Source is the fallback and stays
|
|
744
752
|
# byte-exact for projects that do not build.
|
|
@@ -760,9 +768,9 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
760
768
|
# would then refuse, which is a check with no passing answer. It
|
|
761
769
|
# compares the same wording across a newline the reader never sees,
|
|
762
770
|
# which is exactly what the rendered-page branch above already does.
|
|
763
|
-
elif row["text"] not in
|
|
764
|
-
and row["text"].strip() not in
|
|
765
|
-
and normalise(row["text"]) not in normalise(
|
|
771
|
+
elif row["text"] not in source_copy \
|
|
772
|
+
and row["text"].strip() not in source_copy \
|
|
773
|
+
and normalise(row["text"]) not in normalise(source_copy):
|
|
766
774
|
findings.append(Finding(
|
|
767
775
|
"B021", SEVERITY_ERROR, location, 0,
|
|
768
776
|
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
@@ -889,6 +897,157 @@ CODE_SUFFIXES = {
|
|
|
889
897
|
".py", ".go", ".rb", ".rs", ".java", ".kt", ".swift", ".php",
|
|
890
898
|
}
|
|
891
899
|
|
|
900
|
+
HTML_SUFFIXES = {".html", ".htm"}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class HTMLCopy(HTMLParser):
|
|
904
|
+
"""Static visible text and heading fragments, never attribute values.
|
|
905
|
+
|
|
906
|
+
Inline CSS/hidden/template exclusions are knowable from this source. External
|
|
907
|
+
CSS, scripts and responsive visibility still need a browser review.
|
|
908
|
+
aria-hidden is deliberately not excluded: it does not hide visual text.
|
|
909
|
+
"""
|
|
910
|
+
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
911
|
+
"link", "meta", "param", "source", "track", "wbr"}
|
|
912
|
+
BLOCK = {"p", "div", "section", "article", "main", "li", "ul", "ol",
|
|
913
|
+
"header", "footer", "nav", "button", "figcaption", "td", "tr"}
|
|
914
|
+
OMIT = {"script", "style", "template", "noscript", "head"}
|
|
915
|
+
PHRASING = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
|
|
916
|
+
"li", "dt", "dd", "td", "th", "figcaption", "label", "summary"}
|
|
917
|
+
CONTROLS = {"a", "button", "label", "legend", "summary", "option"}
|
|
918
|
+
INTERFACE_ROLES = {"button", "link", "menuitem", "tab", "option", "checkbox",
|
|
919
|
+
"radio", "switch", "alert", "status"}
|
|
920
|
+
|
|
921
|
+
def __init__(self):
|
|
922
|
+
super().__init__(convert_charrefs=True)
|
|
923
|
+
self.stack = []
|
|
924
|
+
self.parts = []
|
|
925
|
+
self.headings = []
|
|
926
|
+
self.heading = None
|
|
927
|
+
self.last_anchor_end = None
|
|
928
|
+
self.controls = []
|
|
929
|
+
self.interface_texts = []
|
|
930
|
+
|
|
931
|
+
@property
|
|
932
|
+
def blocked(self):
|
|
933
|
+
return bool(self.stack and self.stack[-1][1])
|
|
934
|
+
|
|
935
|
+
def handle_starttag(self, tag, attrs):
|
|
936
|
+
attrs = dict(attrs)
|
|
937
|
+
hidden = self.blocked or tag in self.OMIT or "hidden" in attrs or bool(
|
|
938
|
+
re.search(r"(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:!important\s*)?(?:;|$)",
|
|
939
|
+
attrs.get("style", ""), re.I))
|
|
940
|
+
if not hidden:
|
|
941
|
+
if tag in self.CONTROLS or attrs.get("role", "").lower() in self.INTERFACE_ROLES:
|
|
942
|
+
self.controls.append((tag, len(self.stack), []))
|
|
943
|
+
if tag == "br" or tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag):
|
|
944
|
+
for control in self.controls:
|
|
945
|
+
control[2].append(" ")
|
|
946
|
+
# Adjacent navigation/CTA anchors are separate labels, even when
|
|
947
|
+
# minified HTML has no intervening whitespace. A link within prose
|
|
948
|
+
# stays inline; turning every <a> into a block splits sentences.
|
|
949
|
+
if tag == "a" and self.last_anchor_end is not None \
|
|
950
|
+
and not any(parent[0] in self.PHRASING for parent in self.stack) \
|
|
951
|
+
and not any(part.strip() for part in self.parts[self.last_anchor_end:]):
|
|
952
|
+
self.parts.append("\n")
|
|
953
|
+
if re.fullmatch(r"h[1-6]", tag):
|
|
954
|
+
self.parts.append("\n")
|
|
955
|
+
self.heading = (tag, self.getpos()[0], [])
|
|
956
|
+
elif tag in self.BLOCK:
|
|
957
|
+
self.parts.append("\n")
|
|
958
|
+
elif tag == "br":
|
|
959
|
+
self.parts.append("\n")
|
|
960
|
+
if self.heading:
|
|
961
|
+
self.heading[2].append("\n")
|
|
962
|
+
if tag not in self.VOID:
|
|
963
|
+
self.stack.append((tag, hidden))
|
|
964
|
+
|
|
965
|
+
def handle_startendtag(self, tag, attrs):
|
|
966
|
+
self.handle_starttag(tag, attrs)
|
|
967
|
+
if tag not in self.VOID:
|
|
968
|
+
self.handle_endtag(tag)
|
|
969
|
+
|
|
970
|
+
def handle_endtag(self, tag):
|
|
971
|
+
if not self.blocked and (tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag)):
|
|
972
|
+
for control in self.controls:
|
|
973
|
+
control[2].append(" ")
|
|
974
|
+
# Registry candidates are interface controls/messages, not every
|
|
975
|
+
# marketing paragraph on a page that contains one registered action.
|
|
976
|
+
for control in list(self.controls):
|
|
977
|
+
if control[0] == tag and control[1] == len(self.stack) - 1:
|
|
978
|
+
text = " ".join("".join(control[2]).split())
|
|
979
|
+
if text:
|
|
980
|
+
self.interface_texts.append(text)
|
|
981
|
+
self.controls.remove(control)
|
|
982
|
+
if tag == "a" and not self.blocked:
|
|
983
|
+
self.last_anchor_end = len(self.parts)
|
|
984
|
+
if self.heading and tag == self.heading[0]:
|
|
985
|
+
_, line, parts = self.heading
|
|
986
|
+
for fragment in "".join(parts).split("\n"):
|
|
987
|
+
text = " ".join(fragment.split())
|
|
988
|
+
if text:
|
|
989
|
+
self.headings.append((line, text))
|
|
990
|
+
self.heading = None
|
|
991
|
+
if not self.blocked and (tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag)):
|
|
992
|
+
self.parts.append("\n")
|
|
993
|
+
for index in range(len(self.stack) - 1, -1, -1):
|
|
994
|
+
if self.stack[index][0] == tag:
|
|
995
|
+
del self.stack[index:]
|
|
996
|
+
break
|
|
997
|
+
|
|
998
|
+
def handle_data(self, data):
|
|
999
|
+
if not self.blocked:
|
|
1000
|
+
self.parts.append(re.sub(r"\s+", " ", data))
|
|
1001
|
+
for control in self.controls:
|
|
1002
|
+
control[2].append(data)
|
|
1003
|
+
if self.heading:
|
|
1004
|
+
# Source wrapping does not create a displayed line break.
|
|
1005
|
+
self.heading[2].append(re.sub(r"\s+", " ", data))
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
def blocks(self):
|
|
1009
|
+
return [" ".join(line.split()) for line in "".join(self.parts).split("\n")
|
|
1010
|
+
if line.strip()]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def html_copy(text: str) -> HTMLCopy:
|
|
1014
|
+
parser = HTMLCopy()
|
|
1015
|
+
parser.feed(text)
|
|
1016
|
+
parser.close()
|
|
1017
|
+
return parser
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def source_paths(root: Path, pattern: str) -> list[Path]:
|
|
1021
|
+
"""Expand comma braces advertised by the brand contract, then glob."""
|
|
1022
|
+
brace = re.search(r"\{([^{}]+)\}", pattern)
|
|
1023
|
+
if brace and "," in brace.group(1):
|
|
1024
|
+
return sorted({path for choice in brace.group(1).split(",")
|
|
1025
|
+
for path in source_paths(root, pattern[:brace.start()] + choice
|
|
1026
|
+
+ pattern[brace.end():])})
|
|
1027
|
+
return sorted(path for path in root.glob(pattern) if path.is_file())
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def check_source_coverage(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
1031
|
+
root = brand_dir.parent.parent
|
|
1032
|
+
findings = []
|
|
1033
|
+
for key, patterns in sources.items():
|
|
1034
|
+
if key == "robots": # Missing robots is handled by the crawler policy.
|
|
1035
|
+
continue
|
|
1036
|
+
for pattern in patterns or [""]:
|
|
1037
|
+
if not pattern:
|
|
1038
|
+
findings.append(Finding(
|
|
1039
|
+
"B009", SEVERITY_ERROR, "README.md", 0,
|
|
1040
|
+
f"Sources {key}: no pattern declared; configure the source or remove its key",
|
|
1041
|
+
))
|
|
1042
|
+
continue
|
|
1043
|
+
if not source_paths(root, pattern):
|
|
1044
|
+
findings.append(Finding(
|
|
1045
|
+
"B009", SEVERITY_ERROR, "README.md", 0,
|
|
1046
|
+
f"Sources {key}: `{pattern}` matches no files; correct the "
|
|
1047
|
+
"path or remove the source declaration. No copy was scanned",
|
|
1048
|
+
))
|
|
1049
|
+
return findings
|
|
1050
|
+
|
|
892
1051
|
# `${...}` in a template literal is a value, not a word.
|
|
893
1052
|
#
|
|
894
1053
|
# Replaced by a space rather than dropped, so `founder of ${n} products` does not
|
|
@@ -974,12 +1133,16 @@ def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
|
|
|
974
1133
|
"""(relative path, front matter, body) for one declared source key."""
|
|
975
1134
|
root = brand_dir.parent.parent
|
|
976
1135
|
out = []
|
|
1136
|
+
seen = set()
|
|
977
1137
|
for pattern in sources.get(key, []):
|
|
978
|
-
for path in
|
|
979
|
-
if
|
|
1138
|
+
for path in source_paths(root, pattern):
|
|
1139
|
+
if path in seen:
|
|
980
1140
|
continue
|
|
1141
|
+
seen.add(path)
|
|
981
1142
|
fields, body = _front_matter(read(path) or "")
|
|
982
|
-
if path.suffix in
|
|
1143
|
+
if path.suffix.lower() in HTML_SUFFIXES:
|
|
1144
|
+
body = "\n\n".join(html_copy(body).blocks)
|
|
1145
|
+
elif path.suffix in CODE_SUFFIXES:
|
|
983
1146
|
body = _copy_in_code(body, path.suffix)
|
|
984
1147
|
out.append((path.relative_to(root).as_posix(), fields, body))
|
|
985
1148
|
return out
|
|
@@ -1206,7 +1369,13 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1206
1369
|
f"`{row['fact']}` was due for review on {row['review']}",
|
|
1207
1370
|
))
|
|
1208
1371
|
|
|
1209
|
-
for path,
|
|
1372
|
+
for path, fields, body in documents(brand_dir, sources, "marketing"):
|
|
1373
|
+
# A short-video hook is the first public sentence anyone hears, so its
|
|
1374
|
+
# figure needs a row exactly as a body figure does (video-script.md).
|
|
1375
|
+
spoken = [v for k, v in fields.items()
|
|
1376
|
+
if HOOK_FIELD_RE.match(k) or re.match(r"^on-screen(?:-\d+)?$", k)]
|
|
1377
|
+
if spoken:
|
|
1378
|
+
body = body + "\n\n" + "\n\n".join(spoken)
|
|
1210
1379
|
for m in NUMBER_RE.finditer(body):
|
|
1211
1380
|
number = m.group(0)
|
|
1212
1381
|
digits = re.sub(r"\s+", "", number)
|
|
@@ -1298,9 +1467,23 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1298
1467
|
if "link in body" in physics and re.search(r"https?://", body):
|
|
1299
1468
|
findings.append(Finding(
|
|
1300
1469
|
"B042", SEVERITY_ERROR, path, 0,
|
|
1301
|
-
"link in the post body
|
|
1302
|
-
"
|
|
1470
|
+
"link in the post body, which this surface's physics "
|
|
1471
|
+
"forbid -- it suppresses reach or does not click there; "
|
|
1472
|
+
"put it where channels.md says (first reply, bio, DM)",
|
|
1303
1473
|
))
|
|
1474
|
+
fold = re.search(r"fold (\d+)", physics)
|
|
1475
|
+
if fold:
|
|
1476
|
+
findings.extend(_feed_window(path, body, int(fold.group(1))))
|
|
1477
|
+
if "one ask" in physics:
|
|
1478
|
+
asks = caption_asks(body)
|
|
1479
|
+
if len(asks) != 1:
|
|
1480
|
+
findings.append(Finding(
|
|
1481
|
+
"B046", SEVERITY_WARN, path, 0,
|
|
1482
|
+
(f"{len(asks)} asks ({', '.join(asks)}) -- two asks "
|
|
1483
|
+
f"split one reader's single action" if asks else
|
|
1484
|
+
"no ask -- decide what this post is for and ask for "
|
|
1485
|
+
"that one thing"),
|
|
1486
|
+
))
|
|
1304
1487
|
cap = re.search(r"max (\d+) hashtags", physics)
|
|
1305
1488
|
if cap:
|
|
1306
1489
|
used = re.findall(r"(?<!\w)#\w+", body)
|
|
@@ -1317,6 +1500,236 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1317
1500
|
return findings
|
|
1318
1501
|
|
|
1319
1502
|
|
|
1503
|
+
# B045 -- the feed window. Instagram shows roughly the first 125 characters of
|
|
1504
|
+
# a caption before "... more", so the first line either fits the window or is
|
|
1505
|
+
# cut mid-thought, and a tag or a mention spent there is a hook not written.
|
|
1506
|
+
# The window size is declared per surface (`fold N`) because it is platform
|
|
1507
|
+
# physics that moves, and the number belongs in channels.md, not in this file.
|
|
1508
|
+
# Method: `skills/ig-caption/caption.py` in Jakeschincariol/instagram-agent-skill
|
|
1509
|
+
# @d03c56b (MIT), re-implemented here; see channel-playbooks.md#instagram.
|
|
1510
|
+
HASHTAG_TOKEN_RE = re.compile(r"(?<![\w&])#\w+")
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _feed_window(path: str, body: str, fold: int) -> list[Finding]:
|
|
1514
|
+
text = body.strip()
|
|
1515
|
+
if not text:
|
|
1516
|
+
return []
|
|
1517
|
+
first = text.splitlines()[0].strip()
|
|
1518
|
+
window = text[:fold]
|
|
1519
|
+
reasons = []
|
|
1520
|
+
if first[:1] in ("#", "@"):
|
|
1521
|
+
reasons.append(f"it opens with `{first.split()[0]}`")
|
|
1522
|
+
if HASHTAG_TOKEN_RE.search(window) and first[:1] != "#":
|
|
1523
|
+
reasons.append("a hashtag sits inside the window")
|
|
1524
|
+
if len(first) > fold:
|
|
1525
|
+
reasons.append(f"the first line is {len(first)} characters and the "
|
|
1526
|
+
f"feed cuts it at {fold}")
|
|
1527
|
+
if not reasons:
|
|
1528
|
+
return []
|
|
1529
|
+
shown = window.split("\n")[0] if len(first) > fold else first
|
|
1530
|
+
return [Finding(
|
|
1531
|
+
"B045", SEVERITY_WARN, path, 0,
|
|
1532
|
+
f"the feed window ({fold} characters before \"... more\"): "
|
|
1533
|
+
f"{'; '.join(reasons)}. The feed shows: \"{shown[:fold]}\"",
|
|
1534
|
+
)]
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
# B046 -- one ask per post. Patterns are the asks a caption actually makes;
|
|
1538
|
+
# a question mark alone is not an ask, because most captions ask nothing by
|
|
1539
|
+
# asking a rhetorical question. English after caption.py's eight (re-written),
|
|
1540
|
+
# Russian as their direct equivalents.
|
|
1541
|
+
CAPTION_ASKS = (
|
|
1542
|
+
(re.compile(r"(?i)\bcomment (?:the word )?[\"']?[A-Z0-9]{2,}\b"), "comment a keyword"),
|
|
1543
|
+
(re.compile(r"(?i)\b(?:dm|message) (?:me|us)\b"), "DM"),
|
|
1544
|
+
(re.compile(r"(?i)\bsave (?:this|it)\b"), "save this"),
|
|
1545
|
+
(re.compile(r"(?i)\b(?:share|send) (?:this|it)\b"), "share this"),
|
|
1546
|
+
(re.compile(r"(?i)\bfollow (?:me|us|for)\b"), "follow"),
|
|
1547
|
+
(re.compile(r"(?i)\blink in (?:my |our |the )?bio\b"), "link in bio"),
|
|
1548
|
+
(re.compile(r"(?i)\b(?:swipe|tap) (?:through|left|right|for|to)\b"), "swipe or tap"),
|
|
1549
|
+
(re.compile(r"(?i)\btell me\b|\bwhat would you\b|\bwhich one\b"), "answer a question"),
|
|
1550
|
+
(re.compile(r"(?i)напиши(?:те)? в комментари|коммент(?:арий)? [А-ЯЁA-Z0-9]{2,}"), "comment a keyword"),
|
|
1551
|
+
(re.compile(r"(?i)напиши(?:те)? (?:мне|нам) в (?:директ|личку|лс)"), "DM"),
|
|
1552
|
+
(re.compile(r"(?i)\bсохрани(?:те)?\b"), "save this"),
|
|
1553
|
+
(re.compile(r"(?i)\b(?:отправь|отправьте|перешли|перешлите)\b"), "share this"),
|
|
1554
|
+
(re.compile(r"(?i)\bподпиши(?:сь|тесь)\b"), "follow"),
|
|
1555
|
+
(re.compile(r"(?i)ссылк[аиу] в (?:профиле|шапке|био)"), "link in bio"),
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def caption_asks(body: str) -> list[str]:
|
|
1560
|
+
"""The asks a caption makes, one entry per distinct kind."""
|
|
1561
|
+
return [name for pattern, name in CAPTION_ASKS if pattern.search(body)]
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
# B044 -- the hook filter. A FILTER, never a predictor, and the reference says
|
|
1565
|
+
# so in numbers: on its author's corpus of 74 real short-form hooks the method
|
|
1566
|
+
# separates written-to-be-bad hooks from real ones at AUC 0.83 and a creator's
|
|
1567
|
+
# hits from the same creator's misses at AUC 0.56, where 0.50 is a coin. So
|
|
1568
|
+
# B044 warns on what it catches well (greetings, preambles, a line with
|
|
1569
|
+
# nothing concrete or nothing at stake) and never ranks two decent hooks.
|
|
1570
|
+
#
|
|
1571
|
+
# Method and arithmetic: `skills/ig-reel/hookscore.py` in
|
|
1572
|
+
# Jakeschincariol/instagram-agent-skill @d03c56b (MIT) -- five checks 0..100,
|
|
1573
|
+
# overall = 0.6 * mean + 0.4 * min - 15 per dealbreaker, STRONG at >= 70 with
|
|
1574
|
+
# no check under 55 and no dealbreaker, WEAK under 50. The vocabularies below
|
|
1575
|
+
# are this file's own and shorter, so a score can differ by a few points from
|
|
1576
|
+
# the original's. They are English, so a hook in another script is not scored
|
|
1577
|
+
# at all; only the two script-independent dealbreakers apply to it.
|
|
1578
|
+
HOOK_WORD_RE = re.compile(r"[A-Za-z0-9$%'’-]+")
|
|
1579
|
+
HOOK_FIGURE_RE = re.compile(
|
|
1580
|
+
r"[$€£]\s?\d[\d,]*(?:\.\d+)?"
|
|
1581
|
+
r"|\b\d[\d,]*(?:\.\d+)?\s?(?:%|k\b|x\b|hours?\b|hrs?\b|minutes?\b|mins?\b"
|
|
1582
|
+
r"|days?\b|weeks?\b|months?\b|years?\b)?",
|
|
1583
|
+
re.I,
|
|
1584
|
+
)
|
|
1585
|
+
HOOK_PROPER_RE = re.compile(r"(?<!^)\b[A-Z][a-z]{2,}\b")
|
|
1586
|
+
HOOK_SPOKEN_FIGURES = {
|
|
1587
|
+
"zero", "two", "three", "four", "five", "six", "seven", "eight", "nine",
|
|
1588
|
+
"ten", "twelve", "twenty", "thirty", "fifty", "hundred", "thousand",
|
|
1589
|
+
"million", "billion", "dozen", "half", "twice", "triple",
|
|
1590
|
+
"dollars", "dollar", "bucks", "grand", "percent", "revenue", "salary",
|
|
1591
|
+
"profit", "rent",
|
|
1592
|
+
}
|
|
1593
|
+
HOOK_STAKES = {
|
|
1594
|
+
"stop", "never", "wrong", "mistake", "mistakes", "lose", "lost", "losing",
|
|
1595
|
+
"cost", "costs", "broke", "failed", "fail", "nobody", "no", "not", "don't",
|
|
1596
|
+
"can't", "won't", "didn't", "quit", "fired", "deleted", "killed",
|
|
1597
|
+
"replaced", "cut", "free", "paid", "saved", "banned", "worst", "hate",
|
|
1598
|
+
"wasted", "waste", "scam", "lied", "truth", "secret", "hidden", "before",
|
|
1599
|
+
"until", "instead", "but", "unless", "problem", "risk", "warning",
|
|
1600
|
+
"regret", "only", "without", "versus", "vs", "actually", "forgot",
|
|
1601
|
+
}
|
|
1602
|
+
HOOK_WEAK_OPENERS = (
|
|
1603
|
+
"so", "ok", "okay", "hey", "hi", "hello", "guys", "welcome", "today",
|
|
1604
|
+
"basically", "honestly", "just", "let's", "lets", "um", "i wanted",
|
|
1605
|
+
"i want", "have you", "do you", "did you", "are you", "in this",
|
|
1606
|
+
"in today", "this is", "there is", "there are", "it is", "you know",
|
|
1607
|
+
)
|
|
1608
|
+
HOOK_IMPERATIVES = {
|
|
1609
|
+
"stop", "steal", "copy", "delete", "try", "watch", "read", "save", "use",
|
|
1610
|
+
"build", "make", "write", "send", "start", "quit", "never", "always",
|
|
1611
|
+
"don't", "check", "cancel",
|
|
1612
|
+
}
|
|
1613
|
+
HOOK_EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF☀-➿]")
|
|
1614
|
+
HOOK_DEALBREAKERS = (
|
|
1615
|
+
("stop-scrolling", re.compile(r"(?i)^\s*(?:stop scrolling|don'?t scroll)")),
|
|
1616
|
+
("preamble", re.compile(r"(?i)\b(?:in (?:this|today'?s) (?:video|reel)"
|
|
1617
|
+
r"|i'?m going to show you|i'?ll show you how)\b")),
|
|
1618
|
+
("greeting", re.compile(r"(?i)^\s*(?:hey|hi|hello|what'?s up|welcome)\b")),
|
|
1619
|
+
("hashtag", re.compile(r"(?:^|\s)#\w+")),
|
|
1620
|
+
("emoji", HOOK_EMOJI_RE),
|
|
1621
|
+
)
|
|
1622
|
+
# Applied to a hook in any script: neither depends on the vocabulary.
|
|
1623
|
+
SCRIPT_INDEPENDENT_DEALBREAKERS = ("hashtag", "emoji")
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _hook_words(text: str) -> list[str]:
|
|
1627
|
+
return HOOK_WORD_RE.findall(re.sub(r"(?<=\d),(?=\d)", "", text))
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def hook_score(text: str) -> dict:
|
|
1631
|
+
"""Score one spoken hook. `verdict` is None for a hook not in Latin script.
|
|
1632
|
+
|
|
1633
|
+
Returns {score, verdict, checks: {LENGTH, SPECIFICITY, STAKES, FRONTLOAD,
|
|
1634
|
+
ADDRESS}, flags: [dealbreaker ids]}.
|
|
1635
|
+
"""
|
|
1636
|
+
flags = [name for name, pattern in HOOK_DEALBREAKERS if pattern.search(text)]
|
|
1637
|
+
if CYRILLIC_RE.search(text) or not re.search(r"[A-Za-z]", text):
|
|
1638
|
+
return {"score": None, "verdict": None, "checks": {},
|
|
1639
|
+
"flags": [f for f in flags if f in SCRIPT_INDEPENDENT_DEALBREAKERS]}
|
|
1640
|
+
words = _hook_words(text)
|
|
1641
|
+
low = [w.lower().strip("'’") for w in words]
|
|
1642
|
+
|
|
1643
|
+
n = len(words)
|
|
1644
|
+
length = 100.0 if 5 <= n <= 12 else (100 - (5 - n) * 20 if n < 5
|
|
1645
|
+
else 100 - (n - 12) * 11)
|
|
1646
|
+
if len(text.strip()) > 60: # two lines of large on-screen text
|
|
1647
|
+
length -= 12
|
|
1648
|
+
|
|
1649
|
+
figures = [m for m in HOOK_FIGURE_RE.findall(text) if m.strip()]
|
|
1650
|
+
hits = len(figures) + len(set(HOOK_PROPER_RE.findall(text))) \
|
|
1651
|
+
+ sum(1 for w in low if w in HOOK_SPOKEN_FIGURES)
|
|
1652
|
+
specificity = 15.0 if hits == 0 else 45 + 30 * hits
|
|
1653
|
+
|
|
1654
|
+
markers = set(w for w in low if w in HOOK_STAKES)
|
|
1655
|
+
if re.search(r"[$€£]\s?\d", text):
|
|
1656
|
+
markers.add("a price")
|
|
1657
|
+
stakes = {0: 20.0, 1: 70.0}.get(len(markers), 100.0)
|
|
1658
|
+
|
|
1659
|
+
opener = " ".join(low[:2])
|
|
1660
|
+
penalty = 30 if any(opener.startswith(w) or (low and low[0] == w)
|
|
1661
|
+
for w in HOOK_WEAK_OPENERS) else 0
|
|
1662
|
+
payload = next((i for i, w in enumerate(low)
|
|
1663
|
+
if w in HOOK_STAKES or w in HOOK_SPOKEN_FIGURES
|
|
1664
|
+
or HOOK_FIGURE_RE.match(words[i])
|
|
1665
|
+
or (i and HOOK_PROPER_RE.match(words[i]))), None)
|
|
1666
|
+
frontload = (30.0 if payload is None else 100.0 if payload <= 3
|
|
1667
|
+
else 70.0 if payload <= 6 else 40.0) - penalty
|
|
1668
|
+
|
|
1669
|
+
lowered = text.lower()
|
|
1670
|
+
if re.search(r"\b(?:you|your|you're|yourself)\b", lowered):
|
|
1671
|
+
address = 100.0
|
|
1672
|
+
elif low and low[0] in HOOK_IMPERATIVES:
|
|
1673
|
+
address = 90.0
|
|
1674
|
+
elif re.search(r"\b(?:i|my|me|we|our)\b", lowered):
|
|
1675
|
+
address = 70.0
|
|
1676
|
+
else:
|
|
1677
|
+
address = 35.0
|
|
1678
|
+
|
|
1679
|
+
clamp = lambda v: max(0.0, min(100.0, float(v))) # noqa: E731
|
|
1680
|
+
checks = {"LENGTH": clamp(length), "SPECIFICITY": clamp(specificity),
|
|
1681
|
+
"STAKES": clamp(stakes), "FRONTLOAD": clamp(frontload),
|
|
1682
|
+
"ADDRESS": clamp(address)}
|
|
1683
|
+
parts = list(checks.values())
|
|
1684
|
+
score = clamp(0.6 * sum(parts) / len(parts) + 0.4 * min(parts)
|
|
1685
|
+
- 15 * len(flags))
|
|
1686
|
+
verdict = ("STRONG" if score >= 70 and min(parts) >= 55 and not flags
|
|
1687
|
+
else "OK" if score >= 50 else "WEAK")
|
|
1688
|
+
return {"score": score, "verdict": verdict, "checks": checks, "flags": flags}
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
HOOK_FIELD_RE = re.compile(r"^hook(?:-\d+)?$")
|
|
1692
|
+
ON_SCREEN_LIMIT = 10
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def check_short_video(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
1696
|
+
"""B044 -- every `hook` / `hook-N` field, and its on-screen companion."""
|
|
1697
|
+
findings: list[Finding] = []
|
|
1698
|
+
for key in ("marketing", "store"):
|
|
1699
|
+
for path, fields, _body in documents(brand_dir, sources, key):
|
|
1700
|
+
for name in sorted(f for f in fields if HOOK_FIELD_RE.match(f)):
|
|
1701
|
+
hook = fields[name]
|
|
1702
|
+
if not hook:
|
|
1703
|
+
continue
|
|
1704
|
+
got = hook_score(hook)
|
|
1705
|
+
reasons = []
|
|
1706
|
+
if got["verdict"] == "WEAK":
|
|
1707
|
+
weakest = min(got["checks"], key=got["checks"].get)
|
|
1708
|
+
reasons.append(f"scores {got['score']:.0f} (WEAK); weakest "
|
|
1709
|
+
f"check {weakest} at "
|
|
1710
|
+
f"{got['checks'][weakest]:.0f}")
|
|
1711
|
+
if got["flags"]:
|
|
1712
|
+
reasons.append(f"dealbreaker(s): {', '.join(got['flags'])}")
|
|
1713
|
+
if reasons:
|
|
1714
|
+
findings.append(Finding(
|
|
1715
|
+
"B044", SEVERITY_WARN, path, 0,
|
|
1716
|
+
f"`{name}`: \"{hook}\" {'; '.join(reasons)}. A filter, "
|
|
1717
|
+
f"not a predictor: AUC 0.83 at catching a bad hook, "
|
|
1718
|
+
f"0.56 at picking a winner (hooks.md)",
|
|
1719
|
+
))
|
|
1720
|
+
for name in sorted(f for f in fields
|
|
1721
|
+
if re.match(r"^on-screen(?:-\d+)?$", f)):
|
|
1722
|
+
words = fields[name].split()
|
|
1723
|
+
if len(words) > ON_SCREEN_LIMIT:
|
|
1724
|
+
findings.append(Finding(
|
|
1725
|
+
"B044", SEVERITY_WARN, path, 0,
|
|
1726
|
+
f"`{name}` is {len(words)} words; on-screen hook text "
|
|
1727
|
+
f"is read in about half a second, {ON_SCREEN_LIMIT} "
|
|
1728
|
+
f"words at most (hooks.md)",
|
|
1729
|
+
))
|
|
1730
|
+
return findings
|
|
1731
|
+
|
|
1732
|
+
|
|
1320
1733
|
def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
|
|
1321
1734
|
"""B041 -- the four rules that recover a third of the 100-character field."""
|
|
1322
1735
|
findings = []
|
|
@@ -1589,15 +2002,45 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
1589
2002
|
return findings
|
|
1590
2003
|
|
|
1591
2004
|
|
|
2005
|
+
def markdown_title(text: str) -> str:
|
|
2006
|
+
"""Read common inline heading markup as the displayed label."""
|
|
2007
|
+
text = re.sub(r"!?\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
2008
|
+
text = re.sub(r"\s+#+\s*$", "", text)
|
|
2009
|
+
if re.search(r"</?[A-Za-z][^>]*>", text):
|
|
2010
|
+
text = " ".join(html_copy(text).blocks)
|
|
2011
|
+
return unescape(re.sub(r"[*_~`]", "", text)).strip()
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def markdown_headings(text: str) -> list[tuple[int, str]]:
|
|
2015
|
+
"""ATX headings with source lines; fences are examples, inline code is a label."""
|
|
2016
|
+
out = []
|
|
2017
|
+
match = FRONT_MATTER_RE.match(text)
|
|
2018
|
+
if match:
|
|
2019
|
+
text = "\n" * match.group(0).count("\n") + text[match.end():]
|
|
2020
|
+
fence = None
|
|
2021
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
2022
|
+
marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
|
|
2023
|
+
if marker:
|
|
2024
|
+
current = marker.group(1)
|
|
2025
|
+
if fence is None:
|
|
2026
|
+
fence = current
|
|
2027
|
+
elif current[0] == fence[0] and len(current) >= len(fence):
|
|
2028
|
+
fence = None
|
|
2029
|
+
continue
|
|
2030
|
+
if fence is None and (match := re.match(r"^ {0,3}#{1,6}\s+(.+?)\s*$", line)):
|
|
2031
|
+
out.append((number, markdown_title(match.group(1))))
|
|
2032
|
+
return out
|
|
2033
|
+
|
|
2034
|
+
|
|
1592
2035
|
def title_full_stop(title: str) -> bool:
|
|
1593
2036
|
"""AT-07. True when a title ends in a full stop that is a full stop."""
|
|
1594
2037
|
text = title.strip()
|
|
2038
|
+
if text == "." or text.endswith(" ."):
|
|
2039
|
+
return False # A standalone current-directory token is not punctuation.
|
|
1595
2040
|
if not text.endswith("."):
|
|
1596
2041
|
return False
|
|
1597
2042
|
if text.endswith("..") or text.endswith("…"):
|
|
1598
2043
|
return False
|
|
1599
|
-
if ". " in text: # several sentences -- a different defect
|
|
1600
|
-
return False
|
|
1601
2044
|
return not ABBREVIATION_RE.search(text)
|
|
1602
2045
|
|
|
1603
2046
|
|
|
@@ -1773,10 +2216,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1773
2216
|
f'the title ends in a full stop: "{title.strip()}". '
|
|
1774
2217
|
f"A title is a name, not a statement",
|
|
1775
2218
|
))
|
|
1776
|
-
|
|
2219
|
+
source = brand_dir.parent.parent / path
|
|
2220
|
+
if source.suffix.lower() in HTML_SUFFIXES:
|
|
2221
|
+
headings = html_copy(read(source) or "").headings
|
|
2222
|
+
else:
|
|
2223
|
+
headings = markdown_headings(read(source) or "")
|
|
2224
|
+
for line, heading in headings:
|
|
1777
2225
|
if title_full_stop(heading):
|
|
1778
2226
|
findings.append(Finding(
|
|
1779
|
-
"B063", SEVERITY_WARN, path,
|
|
2227
|
+
"B063", SEVERITY_WARN, path, line,
|
|
1780
2228
|
f'a heading ends in a full stop: "{heading.strip()}". '
|
|
1781
2229
|
f"A heading is a name, not a statement",
|
|
1782
2230
|
))
|
|
@@ -1800,6 +2248,141 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1800
2248
|
return findings
|
|
1801
2249
|
|
|
1802
2250
|
|
|
2251
|
+
# AT-16 / B066 -- invisible and look-alike-space characters. They render as
|
|
2252
|
+
# nothing or as an ordinary space, survive every paste, and break search,
|
|
2253
|
+
# hyphenation and exact-match checks downstream. The eighteen named classes
|
|
2254
|
+
# are the ones the humanizer in Jakeschincariol/instagram-agent-skill @d03c56b
|
|
2255
|
+
# strips (`skills/ig-human/slop.json`); any other Unicode format character
|
|
2256
|
+
# (category Cf -- bidi embeddings, overrides and isolates among them) is
|
|
2257
|
+
# reported by the catch-all. This check REPORTS; it does not rewrite, because
|
|
2258
|
+
# every class below has a context where it is correct, and those contexts are
|
|
2259
|
+
# the numbered exemptions in ai-tells.md (AT-16-E1..E7).
|
|
2260
|
+
#
|
|
2261
|
+
# What it deliberately does NOT port from that humanizer: the typography pass
|
|
2262
|
+
# that turns an em dash into ", ", an en dash into "-" and curly quotes into
|
|
2263
|
+
# straight ones. In Russian the dash is normative and «ёлочки» are the
|
|
2264
|
+
# quotation marks; a rule that rewrote them would make correct text wrong.
|
|
2265
|
+
# The rhetorical dash is AT-06 / B062, which knows the difference.
|
|
2266
|
+
import unicodedata # noqa: E402
|
|
2267
|
+
|
|
2268
|
+
INVISIBLE_NAMED = {
|
|
2269
|
+
0x200B: "zero-width", 0x200C: "zero-width", 0x200D: "zero-width",
|
|
2270
|
+
0x2060: "zero-width", 0xFEFF: "zero-width", 0x00AD: "zero-width",
|
|
2271
|
+
0x180E: "zero-width", 0x2063: "zero-width",
|
|
2272
|
+
0x061C: "bidi", 0x200E: "bidi", 0x200F: "bidi",
|
|
2273
|
+
0x00A0: "space", 0x202F: "space", 0x2009: "space", 0x2007: "space",
|
|
2274
|
+
0x2003: "space", 0x2002: "space",
|
|
2275
|
+
}
|
|
2276
|
+
TAG_RANGE = range(0xE0000, 0xE0080)
|
|
2277
|
+
BIDI_ISOLATES = range(0x2066, 0x206A)
|
|
2278
|
+
RTL_RE = re.compile("[-ࣿיִ-﷿ﹰ-ﻼ]")
|
|
2279
|
+
# Locales whose own typography puts a no-break or narrow space where a writer
|
|
2280
|
+
# means one: «в пятницу», «1 000», « Prix : ». Short on purpose, like
|
|
2281
|
+
# COPULA_LOCALES: a wrong entry silences the space class for a whole language.
|
|
2282
|
+
TYPOGRAPHIC_SPACE_LOCALES = ("ru", "uk", "be", "fr")
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def _is_pictograph(char: str) -> bool:
|
|
2286
|
+
cp = ord(char)
|
|
2287
|
+
return (0x1F000 <= cp <= 0x1FAFF or 0x2600 <= cp <= 0x27BF
|
|
2288
|
+
or 0x2300 <= cp <= 0x23FF or cp == 0xFE0F)
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
def _needs_joiner(char: str) -> bool:
|
|
2292
|
+
"""A letter of a script that spells with ZWJ/ZWNJ (Arabic, Persian, Indic…)."""
|
|
2293
|
+
return 0x0590 <= ord(char) < 0x2000 and char.isalpha() \
|
|
2294
|
+
and not CYRILLIC_RE.match(char) and not ("Ͱ" <= char <= "Ͽ")
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def invisible_chars(text: str, locale: str | None = None) -> list[tuple]:
|
|
2298
|
+
"""(index, code point, name, class) for every reportable character.
|
|
2299
|
+
|
|
2300
|
+
Exemptions, numbered in ai-tells.md: E1 a joiner inside an emoji sequence,
|
|
2301
|
+
E2 a joiner beside a letter of a script that needs it, E3 a direction mark
|
|
2302
|
+
or isolate in text that carries right-to-left letters, E4 tag characters
|
|
2303
|
+
inside a flag sequence, E5 typographic spaces in a locale whose typography
|
|
2304
|
+
uses them, E6 dashes and quotation marks (never examined), E7 a byte-order
|
|
2305
|
+
mark opening the file.
|
|
2306
|
+
"""
|
|
2307
|
+
rtl = any(c.isalpha() for c in RTL_RE.findall(text)) # letters, not marks
|
|
2308
|
+
spaces_ok = bool(CYRILLIC_RE.search(text)) or (
|
|
2309
|
+
bool(locale) and locale.split("-")[0].lower() in TYPOGRAPHIC_SPACE_LOCALES)
|
|
2310
|
+
hits = []
|
|
2311
|
+
for i, char in enumerate(text):
|
|
2312
|
+
cp = ord(char)
|
|
2313
|
+
if cp in INVISIBLE_NAMED:
|
|
2314
|
+
cls = INVISIBLE_NAMED[cp]
|
|
2315
|
+
elif cp in TAG_RANGE:
|
|
2316
|
+
cls = "tag"
|
|
2317
|
+
elif unicodedata.category(char) == "Cf":
|
|
2318
|
+
cls = "format"
|
|
2319
|
+
else:
|
|
2320
|
+
continue
|
|
2321
|
+
prev = text[i - 1] if i else ""
|
|
2322
|
+
nxt = text[i + 1] if i + 1 < len(text) else ""
|
|
2323
|
+
if cp == 0xFEFF and i == 0: # E7
|
|
2324
|
+
continue
|
|
2325
|
+
if cp == 0x200D and prev and nxt and _is_pictograph(prev) \
|
|
2326
|
+
and _is_pictograph(nxt): # E1
|
|
2327
|
+
continue
|
|
2328
|
+
if cp in (0x200C, 0x200D) and ((prev and _needs_joiner(prev))
|
|
2329
|
+
or (nxt and _needs_joiner(nxt))): # E2
|
|
2330
|
+
continue
|
|
2331
|
+
if rtl and (cls == "bidi" or cp in BIDI_ISOLATES): # E3
|
|
2332
|
+
continue
|
|
2333
|
+
if cls == "tag": # E4
|
|
2334
|
+
start = i
|
|
2335
|
+
while start and ord(text[start - 1]) in TAG_RANGE:
|
|
2336
|
+
start -= 1
|
|
2337
|
+
end = i
|
|
2338
|
+
while end + 1 < len(text) and ord(text[end + 1]) in TAG_RANGE:
|
|
2339
|
+
end += 1
|
|
2340
|
+
if start and text[start - 1] == "\U0001F3F4" and ord(text[end]) == 0xE007F:
|
|
2341
|
+
continue
|
|
2342
|
+
if cls == "space" and spaces_ok: # E5
|
|
2343
|
+
continue
|
|
2344
|
+
name = unicodedata.name(char, f"U+{cp:04X}")
|
|
2345
|
+
hits.append((i, cp, name, cls))
|
|
2346
|
+
return hits
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def check_invisible(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
2350
|
+
"""B066 -- AT-16 over every declared copy source, read as raw text.
|
|
2351
|
+
|
|
2352
|
+
Raw on purpose: a literal character is what travels into a paste, while
|
|
2353
|
+
an HTML entity such as ` ` is markup someone typed deliberately and
|
|
2354
|
+
is not reported. One finding per file and class, at the first line.
|
|
2355
|
+
"""
|
|
2356
|
+
findings: list[Finding] = []
|
|
2357
|
+
primary, _ = declared_locales(brand_dir)
|
|
2358
|
+
root = brand_dir.parent.parent
|
|
2359
|
+
seen: set = set()
|
|
2360
|
+
for key in ("ui", "marketing", "store", "locales"):
|
|
2361
|
+
for pattern in sources.get(key, []):
|
|
2362
|
+
for path in source_paths(root, pattern):
|
|
2363
|
+
if path in seen or not path.is_file():
|
|
2364
|
+
continue
|
|
2365
|
+
seen.add(path)
|
|
2366
|
+
text = read(path) or ""
|
|
2367
|
+
fields, _ = _front_matter(text)
|
|
2368
|
+
hits = invisible_chars(text, fields.get("locale") or primary)
|
|
2369
|
+
grouped: dict = {}
|
|
2370
|
+
for index, cp, name, cls in hits:
|
|
2371
|
+
grouped.setdefault((cls, cp, name), []).append(index)
|
|
2372
|
+
rel = path.relative_to(root).as_posix()
|
|
2373
|
+
for (cls, cp, name), where in sorted(grouped.items(),
|
|
2374
|
+
key=lambda kv: kv[1][0]):
|
|
2375
|
+
line = text.count("\n", 0, where[0]) + 1
|
|
2376
|
+
findings.append(Finding(
|
|
2377
|
+
"B066", SEVERITY_WARN, rel, line,
|
|
2378
|
+
f"U+{cp:04X} {name} ({cls} class) x{len(where)} -- "
|
|
2379
|
+
f"invisible in the editor and carried into every "
|
|
2380
|
+
f"paste. Remove it, or keep it where ai-tells.md "
|
|
2381
|
+
f"AT-16 names the context that needs it",
|
|
2382
|
+
))
|
|
2383
|
+
return findings
|
|
2384
|
+
|
|
2385
|
+
|
|
1803
2386
|
def declared_locales(brand_dir: Path) -> tuple[str | None, list[str]]:
|
|
1804
2387
|
"""(primary, others) from voice.md's `Locales:` line."""
|
|
1805
2388
|
text = read(brand_dir / "voice.md") or ""
|
|
@@ -1844,7 +2427,7 @@ def check_locales(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1844
2427
|
if primary and limit and "locales" in sources:
|
|
1845
2428
|
catalogues: dict[str, set] = {}
|
|
1846
2429
|
for pattern in sources["locales"]:
|
|
1847
|
-
for path in
|
|
2430
|
+
for path in source_paths(root, pattern):
|
|
1848
2431
|
try:
|
|
1849
2432
|
data = json.loads(read(path) or "{}")
|
|
1850
2433
|
except json.JSONDecodeError:
|
|
@@ -1961,12 +2544,15 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
|
|
|
1961
2544
|
sources = load_sources(brand_dir)
|
|
1962
2545
|
findings: list[Finding] = []
|
|
1963
2546
|
findings.extend(check_contract(brand_dir))
|
|
2547
|
+
findings.extend(check_source_coverage(brand_dir, sources))
|
|
1964
2548
|
findings.extend(check_terminology(brand_dir))
|
|
1965
2549
|
findings.extend(check_consistency(brand_dir, sources))
|
|
1966
2550
|
findings.extend(check_facts(brand_dir, sources))
|
|
1967
2551
|
findings.extend(check_channels(brand_dir, sources))
|
|
2552
|
+
findings.extend(check_short_video(brand_dir, sources))
|
|
1968
2553
|
findings.extend(check_bot_safety(brand_dir, sources))
|
|
1969
2554
|
findings.extend(check_ai_tells(brand_dir, sources))
|
|
2555
|
+
findings.extend(check_invisible(brand_dir, sources))
|
|
1970
2556
|
findings.extend(check_locales(brand_dir, sources))
|
|
1971
2557
|
return findings
|
|
1972
2558
|
|
|
@@ -1997,11 +2583,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1997
2583
|
parser.add_argument("--fix", action="store_true")
|
|
1998
2584
|
parser.add_argument("--brief", action="store_true")
|
|
1999
2585
|
parser.add_argument("--json", action="store_true")
|
|
2586
|
+
parser.add_argument("--fail-on", action="append", default=[], metavar="CODE",
|
|
2587
|
+
help="make selected warning codes block (repeat or comma-separate)")
|
|
2000
2588
|
parser.add_argument(
|
|
2001
2589
|
"--strict", action="store_true",
|
|
2002
2590
|
help="let warnings fail the run too (same meaning as ux_lint.py --strict)",
|
|
2003
2591
|
)
|
|
2004
2592
|
args = parser.parse_args(argv)
|
|
2593
|
+
fail_on = {code.strip() for value in args.fail_on for code in value.split(",")}
|
|
2594
|
+
if fail_on - WARNING_CODES:
|
|
2595
|
+
parser.error("--fail-on expects a warning code: " + ", ".join(sorted(WARNING_CODES)))
|
|
2005
2596
|
|
|
2006
2597
|
brand_dir = Path(args.path)
|
|
2007
2598
|
if not brand_dir.is_dir():
|
|
@@ -2020,7 +2611,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2020
2611
|
# A warning is advice about a judgement a person still has to make -- an
|
|
2021
2612
|
# unregistered string, a fact past review, a label naming no outcome. It
|
|
2022
2613
|
# blocks only when the caller asks, which is what `--strict` is for.
|
|
2023
|
-
return 1 if (args.strict and findings) else 0
|
|
2614
|
+
return 1 if ((args.strict and findings) or any(f.code in fail_on for f in findings)) else 0
|
|
2024
2615
|
|
|
2025
2616
|
|
|
2026
2617
|
if __name__ == "__main__":
|
|
@@ -22,7 +22,7 @@ Owned by the `brand-voice` skill. Written by `copywriting`. Checked by
|
|
|
22
22
|
|
|
23
23
|
**Fill this in before anything else.** The linter scans nothing outside these
|
|
24
24
|
paths, and refuses to report a clean run over a surface it never read
|
|
25
|
-
(`B006`). Delete the keys this project does not have — a declared-but-absent
|
|
25
|
+
(`B006` for no block, `B009` for an unmatched glob). Delete the keys this project does not have — a declared-but-absent
|
|
26
26
|
source is worse than an omitted one.
|
|
27
27
|
|
|
28
28
|
```
|
|
@@ -34,6 +34,12 @@ Sources:
|
|
|
34
34
|
locales: src/locales/*.json
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
For a website, list the HTML under `marketing` or retain heading markers in a
|
|
38
|
+
copy projection. Flat paragraphs cannot prove heading coverage. To make the
|
|
39
|
+
no-title-period rule a required gate, run the resolved brand linter with
|
|
40
|
+
`--fail-on B063`; default warnings remain advisory. The placeholder source
|
|
41
|
+
patterns above must be replaced before the first real lint run.
|
|
42
|
+
|
|
37
43
|
`ui` and `marketing` also classify findings — several checks apply to only
|
|
38
44
|
one of the two.
|
|
39
45
|
|
|
@@ -103,14 +103,36 @@ Locales: <coefficient applies to both limits>
|
|
|
103
103
|
Register: density +1, distance -1
|
|
104
104
|
Format: one idea; threads 5-12 posts, each standing alone
|
|
105
105
|
Limits: 280
|
|
106
|
-
Forbidden: physics: link in body
|
|
107
|
-
editing within 30 minutes resets distribution
|
|
108
|
-
| brand: <engagement bait, fake urgency>
|
|
106
|
+
Forbidden: physics: link in body (practice; unverified in the open ranker) | brand: <engagement bait, fake urgency>
|
|
109
107
|
CTA: <in the first reply, not the post>
|
|
110
108
|
Proof: <one figure, sourced>
|
|
111
109
|
Locales: <…>
|
|
112
110
|
```
|
|
113
111
|
|
|
112
|
+
### Instagram
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Register: distance -1
|
|
116
|
+
Format: a caption; the first line is the window the feed shows
|
|
117
|
+
Limits: body 2200
|
|
118
|
+
Forbidden: physics: link in body, max 5 hashtags, fold 125, one ask | brand: <engagement bait>
|
|
119
|
+
CTA: one, spoken in the video as well
|
|
120
|
+
Proof: <one figure, sourced>
|
|
121
|
+
Locales: <…>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### short video
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
Register: distance -1, density +1
|
|
128
|
+
Format: hook triple (frame 0, on-screen text, first line), body, one ask
|
|
129
|
+
Limits: <running time; Russian and other locales timed by reading aloud>
|
|
130
|
+
Forbidden: physics: none | brand: <fabricated testimonials, unlabelled synthetic people>
|
|
131
|
+
CTA: one, or a loop back to the first line
|
|
132
|
+
Proof: <one figure, sourced>
|
|
133
|
+
Locales: <…>
|
|
134
|
+
```
|
|
135
|
+
|
|
114
136
|
### Reddit
|
|
115
137
|
|
|
116
138
|
```
|
|
@@ -125,5 +147,5 @@ Locales: <…>
|
|
|
125
147
|
```
|
|
126
148
|
|
|
127
149
|
<add the remaining marketing surfaces this product uses: landing body,
|
|
128
|
-
pricing, blog, changelog, LinkedIn, HN and Product
|
|
129
|
-
Google Play, ads, lifecycle email>
|
|
150
|
+
pricing, blog, changelog, TikTok, YouTube Shorts, LinkedIn, HN and Product
|
|
151
|
+
Hunt, App Store, Google Play, ads, lifecycle email>
|