super-ux 0.56.3 → 0.58.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 +123 -0
- package/README.md +8 -7
- package/cursor/rules/copywriting.mdc +12 -1
- package/cursor/rules/ux-flows.mdc +23 -16
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +391 -4
- package/plugins/super-ux/scripts/ux_doctor.py +24 -0
- package/plugins/super-ux/scripts/ux_lint.py +240 -8
- package/templates/brand/channels.md +27 -5
- package/templates/flows.md +4 -0
- package/templates/foundation.md +11 -4
- package/templates/screens.md +21 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
+
## 0.58.0 — the look is decided before the frames, and approved on a critique
|
|
2
|
+
|
|
3
|
+
`ux-flows` picked the style pack itself, from three or four names copied out of
|
|
4
|
+
a catalogue that had grown to dozens, then drew a frame for every state of
|
|
5
|
+
every flow in that one guess and asked for approval of the lot. The approval
|
|
6
|
+
covered the flow and the look at once, on frames nobody had critiqued. This
|
|
7
|
+
release hands the visual decision to its owner and splits the approvals, from
|
|
8
|
+
the family's 2026-10-07 design-pipeline plan, tasks U1–U5 plus the screen
|
|
9
|
+
matrix and the onboarding budget.
|
|
10
|
+
|
|
11
|
+
- **U1. `/sheleg-design` decides the look; super-ux keeps no pack list.**
|
|
12
|
+
`ux-flows` step 4, `visual-identity.md`, `/ux`, the Cursor rule, the
|
|
13
|
+
`screens.md` seed and the README now delegate to the companion's own index,
|
|
14
|
+
defaults and director record. An underdetermined brief gets **directions
|
|
15
|
+
before frames**: one key screen in one state, two directions only when the
|
|
16
|
+
fork is real (rubric first), the human picks on a 2-up mini sheet, then the
|
|
17
|
+
rest of the frames. `validate_direction_before_frames` refuses a pack name in
|
|
18
|
+
any of those seven files.
|
|
19
|
+
- **U2. `Style pack: none` keeps the floor.** With sheleg-design installed its
|
|
20
|
+
`SLOP_MARKERS.md` catalogue applies pack or no pack; where code exists,
|
|
21
|
+
`npx sheleg-design-skill --lint <dir>` runs and its result is recorded;
|
|
22
|
+
companion absent or a version without `--lint` → `Markers: NOT_RUN`, never a
|
|
23
|
+
pass. `visual-identity.md`, `figma-integration.md`, `audit-depth.md`.
|
|
24
|
+
- **U3. Flow approval and art-direction approval are two decisions.** Frames
|
|
25
|
+
are critiqued with `get_screenshot` against the director record's rubric
|
|
26
|
+
before approval, findings as region → defect → fix triples, one re-render
|
|
27
|
+
round (two at most), then `unresolved` to the human. The flow records
|
|
28
|
+
`Art direction: approved <date> — critique: <where>`; **`U085`** refuses an
|
|
29
|
+
approval that cites no critique or a path that does not resolve.
|
|
30
|
+
- **U4. `visual-drift` in `ux-audit`.** The live pass compares the build with
|
|
31
|
+
its Figma frame in the same state, viewport and theme, under the capture
|
|
32
|
+
record of sheleg-design's `VISUAL_REVIEW.md`; an unfit or missing capture is
|
|
33
|
+
BLOCKED, never PASS. The live pass is offered by default for
|
|
34
|
+
`surface_class: flagship` or `ad`, and for landing, onboarding, paywall and
|
|
35
|
+
store screens when no class is recorded.
|
|
36
|
+
- **U5. Figma is a file per surface.** `foundation.md` → Design tooling
|
|
37
|
+
records the folder and a `Figma files` table with a row per surface the
|
|
38
|
+
product has (App / Web / ASO); the "single project file" wording is gone
|
|
39
|
+
from the template, the contract, `figma-integration.md` and `ux-foundation`.
|
|
40
|
+
- **Every screen declares its states and axes.** States: default, loading,
|
|
41
|
+
empty, error, offline, long-content, keyboard-up, first-run (those that
|
|
42
|
+
apply). Axes: `viewport; theme; text; locale`, per screen or as a Design
|
|
43
|
+
system default. **`U079`** (no states, Figma or not), **`U080`** (no axes or
|
|
44
|
+
one missing), **`U081`** (a built screen's state with an empty `Story`
|
|
45
|
+
cell). `U020` now finds the frame column by its header and covers every
|
|
46
|
+
declared state, not only loading / empty / error / success: a `keyboard-up`
|
|
47
|
+
row with no frame used to pass.
|
|
48
|
+
- **The onboarding budget is one screen** (R2 of the Apple Design
|
|
49
|
+
Awards-derived rubric), in `onboarding.md` ON-06 and the flow contract. Flows record `Onboarding:` and `First value:`;
|
|
50
|
+
**`U082`** refuses a second screen without `Onboarding budget:` citing a
|
|
51
|
+
director record that exists and discusses onboarding; **`U083`** warns on a
|
|
52
|
+
flow named for onboarding that declares nothing; **`U084`** on a count with
|
|
53
|
+
no first value.
|
|
54
|
+
- **Warn first, gate on opt-in.** Existing `screens.md` files are format 1:
|
|
55
|
+
`U079`, `U080`, `U081`, and `U020` on states beyond loading / empty / error /
|
|
56
|
+
success, report as **warnings** there, and the lint still exits 0 — what
|
|
57
|
+
`U020` failed before 0.58.0 still fails. A registry carrying
|
|
58
|
+
`<!-- screens-format: 2 -->` makes them errors; the seeded `screens.md`
|
|
59
|
+
writes the marker, so a new project is strict from its first line.
|
|
60
|
+
`docs/ux/doctor.py` names the migration on a format-1 file and `/ux-update`
|
|
61
|
+
performs it: one `- **Axes:** …` line in the Design system block, a state
|
|
62
|
+
list per screen, the lint clean of those warnings, then the marker. This
|
|
63
|
+
repository's own registry is on format 2. `--strict` still fails on every
|
|
64
|
+
warning. Measured over all 118 registries on the maintainer's machine: the first
|
|
65
|
+
cut of this release newly failed 71 of the 80 that passed; this one newly
|
|
66
|
+
fails none and adds no error anywhere.
|
|
67
|
+
- Evals `EV-06` (visual drift is found) and `EV-07` (one key screen, 2-up,
|
|
68
|
+
no pack list) join `test/evals/cases.json`; not run in CI, as before.
|
|
69
|
+
|
|
70
|
+
## 0.57.0 — short video gets a playbook, a hook filter and a caption linter
|
|
71
|
+
|
|
72
|
+
`copywriting` covered posts for X, Reddit and LinkedIn and nothing that moves:
|
|
73
|
+
no Reels, Shorts or TikTok physics, no hook, no script, no caption rules, and
|
|
74
|
+
an X playbook asserting two ranking rules the open ranker does not contain.
|
|
75
|
+
This release fills that from the family's 2026-10-07 research, every rule with
|
|
76
|
+
its source and the date it was read.
|
|
77
|
+
|
|
78
|
+
- **Three new references, linked from `copywriting`.** `video-script.md`: the
|
|
79
|
+
angle in one sentence, the modular hook, body and ask, six organic and ten
|
|
80
|
+
ad structures, words per second for English, the shot list, gates G2–G4,
|
|
81
|
+
reading the retention chart. **Russian timing is measured by reading aloud,
|
|
82
|
+
and the file carries no coefficient**: the research proposed one as its
|
|
83
|
+
author's assumption, and a table would have made it a rule nobody re-measures.
|
|
84
|
+
`hooks.md`: the mechanics (frame 0, text by about 0.5 s, works muted, the body
|
|
85
|
+
pays), the triple (frame 0, on-screen text of ten words or fewer, first line),
|
|
86
|
+
a taxonomy, 26 formulas and 40 templates, each template with its source.
|
|
87
|
+
`research-outliers.md`: views ÷ the account's median, 3× a signal, under 1.5×
|
|
88
|
+
noise, a sample within 10× of your size, with a worked example.
|
|
89
|
+
- **`channel-playbooks.md`.** New `Instagram` (the 125-character feed window,
|
|
90
|
+
five hashtags since 2025-12-18, links that do not click, one ask, sends as
|
|
91
|
+
the signal for non-followers) and `Short video` (platform signals, originality,
|
|
92
|
+
AI labelling, safe zones, length, pace, the loop, trial reels). **X is re-read
|
|
93
|
+
against `xai-org/x-algorithm@e62790c`**: the published weights (share via
|
|
94
|
+
copy link 20, reply, quote and share via DM 5, a reply from a mutual follow
|
|
95
|
+
+15, follow 4, like 0.5, report −234, mute −58.8) and the 48-hour window are
|
|
96
|
+
in; "bookmarks weigh heavily" and "more than two hashtags is penalised" are
|
|
97
|
+
out, because neither is in the ranker; link suppression stays as practice,
|
|
98
|
+
marked unverified. The seeded `channels.md` X record no longer carries them.
|
|
99
|
+
- **Four new codes, 42 → 46.** `B044` filters short-video hooks: five checks,
|
|
100
|
+
0.6 × mean + 0.4 × minimum, −15 per dealbreaker, and on-screen text over ten
|
|
101
|
+
words. It is a filter, not a predictor, and its row says so in numbers (AUC
|
|
102
|
+
0.83 at catching a bad hook, 0.56 at picking a winner); a hook in Cyrillic is
|
|
103
|
+
not scored against English vocabulary. `B045` and `B046` read two new
|
|
104
|
+
physics tokens, `fold N` and `one ask`. `B066` is the new marker AT-16:
|
|
105
|
+
invisible and look-alike-space characters, eighteen named classes plus any
|
|
106
|
+
other format character, seven numbered exemptions with a fixture each, and
|
|
107
|
+
it never examines a dash or a quotation mark. `B030` now reads the `hook` and
|
|
108
|
+
`on-screen` fields as public copy. `B042`'s message no longer assumes every
|
|
109
|
+
surface's reason is reach.
|
|
110
|
+
- **New surfaces:** `Instagram`, `TikTok`, `YouTube Shorts` and `short video`.
|
|
111
|
+
- **A gate for the shelf itself.** `validate_short_video_shelf` counts HF-01..26
|
|
112
|
+
and HT-01..40, refuses a template without a source URL, a short-video or
|
|
113
|
+
Instagram rule without one, the two removed X claims coming back, and
|
|
114
|
+
recomputes every multiple and reading in the outlier example. Each was
|
|
115
|
+
watched failing on a planted defect.
|
|
116
|
+
- Because `channel-playbooks.md` links the three new files, the closure ships
|
|
117
|
+
them inside `brand-voice` (13) and `ux-audit` (24) as well; their reference
|
|
118
|
+
sentences were recounted from the tree.
|
|
119
|
+
- Provenance: the 26 formula names, the scoring arithmetic, the dealbreakers,
|
|
120
|
+
the caption mechanics, the outlier method and the eighteen character classes
|
|
121
|
+
come from `Jakeschincariol/instagram-agent-skill` at `d03c56b` (MIT); shapes,
|
|
122
|
+
examples, word lists and code are rewritten and no text is reproduced.
|
|
123
|
+
|
|
1
124
|
## 0.56.3 — check the heading the reader sees
|
|
2
125
|
|
|
3
126
|
The no-title-period rule already existed, but its linter read Markdown headings
|
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`.
|
|
@@ -218,7 +219,7 @@ fine without either.
|
|
|
218
219
|
|
|
219
220
|
| When | Companion | What it adds |
|
|
220
221
|
|---|---|---|
|
|
221
|
-
| At VISUALIZE / BUILD, a frame or a screen is about to be drawn | **[sheleg-design](https://github.com/ssheleg/sheleg-design-skill)** | The look
|
|
222
|
+
| At VISUALIZE / BUILD, a frame or a screen is about to be drawn | **[sheleg-design](https://github.com/ssheleg/sheleg-design-skill)** | The look. `/sheleg-design` picks one locked style pack (palette, type, texture, motion tokens, bans) from its own index and defaults, keeps a director record of the decision, and holds a visual floor of slop markers that applies even when the answer is no pack. When the brief leaves the look open, one key screen is drawn in two directions and a person picks on a 2-up sheet before any other frame. The pack is recorded in `screens.md`; its tokens become the Figma variables *and* the code tokens. `npx sheleg-design-skill` |
|
|
222
223
|
| After an audit or an Improve pass produced a UX plan | **[task-pipeline](https://github.com/ssheleg/task-pipeline)** | Executes the plan end-to-end through gated stages: spec → plan → subagent build → tests → deploy → docs. `/task-pipeline docs/ux/plans/<file>` |
|
|
223
224
|
|
|
224
225
|
The boundary that keeps them from fighting: BP-079..090 and BP-130..135 are
|
|
@@ -243,10 +244,10 @@ 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
|
-
| `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 |
|
|
250
|
+
| `docs/ux/lint.py` + `/ux-lint` | The deterministic half: missing Figma frames, a screen with no states or no render axes, a built state with no story, more than one onboarding screen before the first value with no reason on file, art direction approved with no critique cited, 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 |
|
|
250
251
|
| `cursor/rules/*.mdc` | The same methodology for Cursor: one always-on hard rule + seven agent-requested rules (vision, foundation, flows, scenarios, audit, brand voice, copywriting) |
|
|
251
252
|
| `templates/` | Seeds for `docs/ux/`: the vision skeleton, foundation, flows, screens, scenario base, the folder README, and the audit-report skeleton. Both hard-rule snippets live here as their single source, `claude-rule.md` (scenario-first) and `vision-rule.md` (vision alignment), and the validator fails if a command's embedded copy drifts from them. Seeds for `docs/brand/`: voice, terminology, facts, channels, the string registry, a locale delta, and its folder README. The tree is mirrored into `plugins/super-ux/templates/` (and the seeds each skill names into that skill's own directory) by `test/sync_references.py`, so installed channels carry the seeds their texts point at; the validator refuses drift between the copies |
|
|
252
253
|
|
|
@@ -263,8 +264,8 @@ The contracts every skill reads:
|
|
|
263
264
|
| [practice-selection.md](plugins/super-ux/skills/references/practice-selection.md) | The deterministic bridge: product profile → mandatory consideration sets → per-artifact checklists → a compliance table where every pulled practice gets a verdict. No silent skips, no cargo cult |
|
|
264
265
|
| [funnel-research.md](plugins/super-ux/skills/references/funnel-research.md) | Reading a funnel market before designing one, `FR-01..FR-07`: where competitor funnels are visible, the four signals that survive when revenue is invisible, the fields that make a corpus comparable, which adjacent categories transfer, the stop before copying, and where each finding lands in the chain. Carried by `ux-foundation` and `ux-flows` |
|
|
265
266
|
| [component-guidelines.md](plugins/super-ux/skills/references/component-guidelines.md) | Which control for which job (radios/select/switch, sheet/alert, modal/disclosure, combobox, nav bar/rail, FAB, dates, toasts) and the platform rules of Apple HIG, Material 3, W3C ARIA APG and GOV.UK |
|
|
266
|
-
| [visual-identity.md](plugins/super-ux/skills/references/visual-identity.md) | The visual layer and its owner: one style pack
|
|
267
|
-
| [figma-integration.md](plugins/super-ux/skills/references/figma-integration.md) · [figma-structure.md](plugins/super-ux/skills/references/figma-structure.md) | The optional Figma surface (on by default):
|
|
267
|
+
| [visual-identity.md](plugins/super-ux/skills/references/visual-identity.md) | The visual layer and its owner: `/sheleg-design` decides the one style pack, directions come before frames when the brief leaves the look open, the slop-marker floor holds even with no pack, and how the pack meets Figma and code |
|
|
268
|
+
| [figma-integration.md](plugins/super-ux/skills/references/figma-integration.md) · [figma-structure.md](plugins/super-ux/skills/references/figma-structure.md) | The optional Figma surface (on by default): one file per surface (App, Web, ASO), frames critiqued against the director record before anyone approves the look, flow and art direction approved separately, and frames named `SCR-NN/<Screen>/<state>` that map 1:1 to `screens.md`, giving deterministic lookup and checkable drift |
|
|
268
269
|
|
|
269
270
|
## Keeping installs current
|
|
270
271
|
|
|
@@ -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
|
|
|
@@ -54,6 +54,17 @@ effortless / unlock / elevate / robust", em-dash cadence in every paragraph,
|
|
|
54
54
|
"it's not just X, it's Y", hedges stacked two deep, and a closing sentence
|
|
55
55
|
that summarizes what was just said.
|
|
56
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
|
+
|
|
57
68
|
## Localization
|
|
58
69
|
|
|
59
70
|
A locale is not a translation of the English: the length coefficient, the
|
|
@@ -25,9 +25,13 @@ recovery edges), `Screens traversed` table (`| Screen | States used here |`
|
|
|
25
25
|
|
|
26
26
|
Each screen entry in `screens.md`: `Used by`, `Purpose`, `Elements` (mark
|
|
27
27
|
the ONE primary action), `States` table (`| State | Trigger | Figma frame |
|
|
28
|
-
Behavior |` — a row per loading/empty/error/
|
|
28
|
+
Story | Behavior |` — a row per default/loading/empty/error/offline/
|
|
29
|
+
long-content/keyboard-up/first-run state that applies), `Axes`
|
|
30
|
+
(viewport; theme; text; locale — or the Design system default),
|
|
29
31
|
`Wireframe`, `Coverage` (file:line), `Scenarios`, `Resources`, `Status`
|
|
30
|
-
(designed|built|drifted|retired).
|
|
32
|
+
(designed|blocked|built|drifted|retired). A flow that onboards records
|
|
33
|
+
`Onboarding:` and `First value:`; the budget is one screen before the first
|
|
34
|
+
value unless the director record says why.
|
|
31
35
|
|
|
32
36
|
## Design rules
|
|
33
37
|
|
|
@@ -40,20 +44,23 @@ Behavior |` — a row per loading/empty/error/success that applies),
|
|
|
40
44
|
primary action, not pixels); storyboard only when usage context drives
|
|
41
45
|
design.
|
|
42
46
|
- Visual identity BEFORE any frame: `screens.md` → Design system →
|
|
43
|
-
`Style pack`. Empty and no design system in the project →
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
`Style pack`. Empty and no design system in the project →
|
|
48
|
+
`/sheleg-design` decides from its own pack index and defaults and keeps
|
|
49
|
+
the director record; record the pack + token file. A brief that leaves
|
|
50
|
+
the look open gets directions first: one key screen in one state, two
|
|
51
|
+
directions only when the fork is real, the human picks on a 2-up sheet,
|
|
52
|
+
then the rest. Missing → offer the one-time install once
|
|
53
|
+
(`npx sheleg-design-skill`) and continue on platform defaults; `none`
|
|
54
|
+
still keeps the sheleg-design slop-marker floor where it is installed.
|
|
55
|
+
The pack owns palette/type/motion and its bans; BP-079..090 stay the
|
|
56
|
+
floors it must clear. Never invent a look per screen.
|
|
57
|
+
- Figma mockups optional (default on): one Figma file per surface (App /
|
|
58
|
+
Web / ASO). If the foundation's Design tooling enables Figma and a Figma
|
|
59
|
+
MCP is available, mirror each screen-state into a frame built on the
|
|
60
|
+
pack's tokens applying BP-079..090, and link every state to its frame.
|
|
61
|
+
Critique the frames with `get_screenshot` (region → defect → fix) before
|
|
62
|
+
approval; the art direction is approved separately from the flow and the
|
|
63
|
+
approval cites the critique.
|
|
57
64
|
- Backwards mode (existing product): reconstruct flows as they ARE from
|
|
58
65
|
code with file:line evidence, tag `inferred` until confirmed; gaps
|
|
59
66
|
between is/should become the improvement backlog.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"scripts": {
|
|
5
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"
|
|
@@ -81,7 +81,7 @@ STRING_KINDS = ("copy", "layout")
|
|
|
81
81
|
|
|
82
82
|
SEVERITY_ERROR = "error"
|
|
83
83
|
SEVERITY_WARN = "warn"
|
|
84
|
-
WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B051 B053 B054 B060 B063 B064 B071 B072".split())
|
|
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())
|
|
85
85
|
|
|
86
86
|
Finding = namedtuple("Finding", "code severity path line message")
|
|
87
87
|
|
|
@@ -1369,7 +1369,13 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1369
1369
|
f"`{row['fact']}` was due for review on {row['review']}",
|
|
1370
1370
|
))
|
|
1371
1371
|
|
|
1372
|
-
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)
|
|
1373
1379
|
for m in NUMBER_RE.finditer(body):
|
|
1374
1380
|
number = m.group(0)
|
|
1375
1381
|
digits = re.sub(r"\s+", "", number)
|
|
@@ -1461,9 +1467,23 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1461
1467
|
if "link in body" in physics and re.search(r"https?://", body):
|
|
1462
1468
|
findings.append(Finding(
|
|
1463
1469
|
"B042", SEVERITY_ERROR, path, 0,
|
|
1464
|
-
"link in the post body
|
|
1465
|
-
"
|
|
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)",
|
|
1466
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
|
+
))
|
|
1467
1487
|
cap = re.search(r"max (\d+) hashtags", physics)
|
|
1468
1488
|
if cap:
|
|
1469
1489
|
used = re.findall(r"(?<!\w)#\w+", body)
|
|
@@ -1480,6 +1500,236 @@ def check_channels(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1480
1500
|
return findings
|
|
1481
1501
|
|
|
1482
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
|
+
|
|
1483
1733
|
def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
|
|
1484
1734
|
"""B041 -- the four rules that recover a third of the 100-character field."""
|
|
1485
1735
|
findings = []
|
|
@@ -1998,6 +2248,141 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1998
2248
|
return findings
|
|
1999
2249
|
|
|
2000
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
|
+
|
|
2001
2386
|
def declared_locales(brand_dir: Path) -> tuple[str | None, list[str]]:
|
|
2002
2387
|
"""(primary, others) from voice.md's `Locales:` line."""
|
|
2003
2388
|
text = read(brand_dir / "voice.md") or ""
|
|
@@ -2164,8 +2549,10 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
|
|
|
2164
2549
|
findings.extend(check_consistency(brand_dir, sources))
|
|
2165
2550
|
findings.extend(check_facts(brand_dir, sources))
|
|
2166
2551
|
findings.extend(check_channels(brand_dir, sources))
|
|
2552
|
+
findings.extend(check_short_video(brand_dir, sources))
|
|
2167
2553
|
findings.extend(check_bot_safety(brand_dir, sources))
|
|
2168
2554
|
findings.extend(check_ai_tells(brand_dir, sources))
|
|
2555
|
+
findings.extend(check_invisible(brand_dir, sources))
|
|
2169
2556
|
findings.extend(check_locales(brand_dir, sources))
|
|
2170
2557
|
return findings
|
|
2171
2558
|
|
|
@@ -40,6 +40,19 @@ ADDITIVE = [
|
|
|
40
40
|
|
|
41
41
|
ARTIFACTS = ["vision.md", "foundation.md", "flows.md", "screens.md", "scenarios.md"]
|
|
42
42
|
|
|
43
|
+
# `screens.md` format 2 (0.58.0): the screen matrix — a state list and render
|
|
44
|
+
# axes per screen, a frame for every declared state, a story per state once
|
|
45
|
+
# built — is a gate. Format 1 gets the same findings as lint warnings. The
|
|
46
|
+
# upgrade is content, so the doctor names it and never stamps the marker.
|
|
47
|
+
SCREENS_FORMAT = re.compile(r"<!--\s*screens-format:\s*(\d+)\s*-->")
|
|
48
|
+
SCREENS_MIGRATION = (
|
|
49
|
+
"screens.md is on format 1: U079/U080/U081 (and U020 for states beyond "
|
|
50
|
+
"loading/empty/error/success) are lint warnings, not a gate. To migrate: "
|
|
51
|
+
"add `- **Axes:** viewport: …; theme: …; text: …; locale: …` to the Design "
|
|
52
|
+
"system block, give every screen a state list, run the linter until those "
|
|
53
|
+
"warnings are gone, then add `<!-- screens-format: 2 -->` under the title."
|
|
54
|
+
)
|
|
55
|
+
|
|
43
56
|
# Names the contract owns, and the near-misses seen in the wild.
|
|
44
57
|
RENAMES = {
|
|
45
58
|
"ux-scenarios.md": "scenarios.md",
|
|
@@ -89,7 +102,13 @@ def diagnose(ux: Path) -> dict:
|
|
|
89
102
|
if f.exists() and needle not in f.read_text(encoding="utf-8", errors="ignore"):
|
|
90
103
|
missing_additive.append(label)
|
|
91
104
|
|
|
105
|
+
screens_format = None
|
|
106
|
+
if present.get("screens.md"):
|
|
107
|
+
m = SCREENS_FORMAT.search((ux / "screens.md").read_text(encoding="utf-8", errors="ignore"))
|
|
108
|
+
screens_format = int(m.group(1)) if m else 1
|
|
109
|
+
|
|
92
110
|
return {
|
|
111
|
+
"screens_format": screens_format,
|
|
93
112
|
"present": present,
|
|
94
113
|
"markers": markers,
|
|
95
114
|
"effective": min(known) if known else None,
|
|
@@ -148,6 +167,9 @@ def report(ux: Path, d: dict) -> int:
|
|
|
148
167
|
for label in d["missing_additive"]:
|
|
149
168
|
print(f" optional, absent: {label}")
|
|
150
169
|
|
|
170
|
+
if d.get("screens_format") == 1:
|
|
171
|
+
print(f" migration offered: {SCREENS_MIGRATION}")
|
|
172
|
+
|
|
151
173
|
print(" OK — on the current contract" if problems == 0 else f" {problems} problem(s)")
|
|
152
174
|
return problems
|
|
153
175
|
|
|
@@ -222,6 +244,8 @@ def main() -> int:
|
|
|
222
244
|
flags.append("loose-audits")
|
|
223
245
|
if d["audits"] and not d["present"].get("scenarios.md"):
|
|
224
246
|
flags.append("audits-without-base")
|
|
247
|
+
if d.get("screens_format") == 1:
|
|
248
|
+
flags.append("screens-format-1")
|
|
225
249
|
print(f"{ux} {state}{' ' + ','.join(flags) if flags else ''}")
|
|
226
250
|
return 0
|
|
227
251
|
|
|
@@ -136,6 +136,121 @@ CITED_PATH = re.compile(
|
|
|
136
136
|
)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
# The four axes a screen is rendered across, by the names the contact sheet
|
|
140
|
+
# uses for its columns. A fifth axis is allowed; these four are required.
|
|
141
|
+
AXES = ("viewport", "theme", "text", "locale")
|
|
142
|
+
AXES_FIELD = re.compile(r"\*\*Axes:\*\*[ \t]*(.*)")
|
|
143
|
+
|
|
144
|
+
# A cell or value that is still the template's placeholder, or a dash, says
|
|
145
|
+
# nothing — the same as an empty cell.
|
|
146
|
+
def _unfilled(value: str) -> bool:
|
|
147
|
+
v = value.strip()
|
|
148
|
+
return (not v or v in ("-", "—", "–")
|
|
149
|
+
or bool(re.fullmatch(r"<[^>]*>", v)))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def state_table(body: str) -> tuple[list[str], list[list[str]]]:
|
|
153
|
+
"""(lower-cased headers, rows) of a screen's States table, found by header.
|
|
154
|
+
|
|
155
|
+
The table is the one whose first header cell is `State`. Columns are read
|
|
156
|
+
by their header name, never by their position after the state name — the
|
|
157
|
+
position is what let U020 read a `Behavior` cell as a frame link.
|
|
158
|
+
"""
|
|
159
|
+
lines = body.splitlines()
|
|
160
|
+
for i, line in enumerate(lines):
|
|
161
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
162
|
+
if not line.strip().startswith("|") or not cells or cells[0].lower() != "state":
|
|
163
|
+
continue
|
|
164
|
+
headers = [c.lower() for c in cells]
|
|
165
|
+
rows: list[list[str]] = []
|
|
166
|
+
for row in lines[i + 1:]:
|
|
167
|
+
s = row.strip()
|
|
168
|
+
if not s.startswith("|"):
|
|
169
|
+
break
|
|
170
|
+
if re.fullmatch(r"\|[\s:|-]+\|?", s):
|
|
171
|
+
continue # the separator row
|
|
172
|
+
rows.append([c.strip() for c in s.strip("|").split("|")])
|
|
173
|
+
return headers, rows
|
|
174
|
+
return [], []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def declared_states(body: str) -> list[str]:
|
|
178
|
+
"""State names a screen declares — its table rows, or an inline list."""
|
|
179
|
+
_, rows = state_table(body)
|
|
180
|
+
names = [r[0] for r in rows if r and r[0]]
|
|
181
|
+
if names:
|
|
182
|
+
return names
|
|
183
|
+
m = re.search(r"\*\*States:\*\*[ \t]*([^\n|]+)$", body, re.MULTILINE)
|
|
184
|
+
if m and not _unfilled(m.group(1)):
|
|
185
|
+
return [s.strip() for s in re.split(r"[,;]", m.group(1)) if s.strip()]
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def axes_value(text: str) -> str | None:
|
|
190
|
+
"""The `**Axes:**` value in a block, wrapped lines joined; None when absent."""
|
|
191
|
+
value = field_body(text, AXES_FIELD)
|
|
192
|
+
return None if value is None else " ".join(value.split())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def missing_axes(value: str) -> list[str]:
|
|
196
|
+
"""Which of the four required axes a declaration leaves out or leaves blank."""
|
|
197
|
+
found: dict[str, str] = {}
|
|
198
|
+
for part in value.split(";"):
|
|
199
|
+
if ":" not in part:
|
|
200
|
+
continue
|
|
201
|
+
name, _, val = part.partition(":")
|
|
202
|
+
found[name.strip().lower()] = val
|
|
203
|
+
return [a for a in AXES if _unfilled(found.get(a, ""))]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# The registry format a `screens.md` opted into. Format 2 (0.58.0) makes the
|
|
207
|
+
# screen matrix -- states, axes, stories, a frame for every declared state --
|
|
208
|
+
# a gate; format 1 reports the same findings as warnings, because turning them
|
|
209
|
+
# on as errors newly failed 71 of 80 passing real registries on upgrade. The marker is an
|
|
210
|
+
# HTML comment, so it is read from the RAW file: `read()` strips comments.
|
|
211
|
+
SCREENS_FORMAT = re.compile(r"<!--\s*screens-format:\s*(\d+)\s*-->")
|
|
212
|
+
FORMAT_HINT = (" (a warning while screens.md is format 1 — add the missing "
|
|
213
|
+
"fields, then `<!-- screens-format: 2 -->` to make it a gate)")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def screens_format(path: Path) -> int:
|
|
217
|
+
"""1 unless the file carries `<!-- screens-format: N -->`."""
|
|
218
|
+
try:
|
|
219
|
+
m = SCREENS_FORMAT.search(path.read_text(encoding="utf-8"))
|
|
220
|
+
except OSError:
|
|
221
|
+
return 1
|
|
222
|
+
return int(m.group(1)) if m else 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def legacy_frameless(body: str) -> set[str]:
|
|
226
|
+
"""The states the pre-0.58 U020 flagged: one of four names, cell read by
|
|
227
|
+
position. Kept so a format-1 registry fails exactly where it always did."""
|
|
228
|
+
out = set()
|
|
229
|
+
for state, rest in re.findall(r"^\s*\|\s*(loading|empty|error|success)\s*\|(.*)\|\s*$",
|
|
230
|
+
body, re.MULTILINE | re.IGNORECASE):
|
|
231
|
+
cells = [c.strip() for c in rest.split("|")]
|
|
232
|
+
frame = cells[1] if len(cells) >= 2 else ""
|
|
233
|
+
if not frame or frame in ("-", "—", "<frame deep-link>", "<frame link>"):
|
|
234
|
+
out.add(state.lower())
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def section(text: str, heading: str) -> str:
|
|
239
|
+
"""The body of `## <heading>` up to the next `## `, or ''."""
|
|
240
|
+
m = re.search(rf"^##[ \t]+{re.escape(heading)}[ \t]*$", text, re.MULTILINE)
|
|
241
|
+
if not m:
|
|
242
|
+
return ""
|
|
243
|
+
return re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.MULTILINE)[0]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resolves(ref: str, ux: Path, root: Path) -> bool:
|
|
247
|
+
"""A cited local path exists — relative to the project root or to docs/ux."""
|
|
248
|
+
path = ref.split("#", 1)[0].strip()
|
|
249
|
+
if not path:
|
|
250
|
+
return False
|
|
251
|
+
return (root / path).exists() or (ux / path).exists()
|
|
252
|
+
|
|
253
|
+
|
|
139
254
|
def screen_blocks(text: str) -> dict[str, str]:
|
|
140
255
|
"""Map SCR-id -> its section body."""
|
|
141
256
|
return entry_blocks(text, "SCR")
|
|
@@ -737,6 +852,70 @@ HOST_TARGET = {"claude": "CLAUDE.md", "codex": "AGENTS.md", "gemini": "GEMINI.md
|
|
|
737
852
|
HOST_MARKER = {"claude": ".claude", "codex": ".codex", "gemini": ".gemini"}
|
|
738
853
|
|
|
739
854
|
|
|
855
|
+
# The onboarding budget (R2 of the design-award rubric): at most one onboarding screen before
|
|
856
|
+
# the first value. A flow states the count in `Onboarding:` and the destination
|
|
857
|
+
# in `First value:`; more than one screen needs `Onboarding budget:` pointing at
|
|
858
|
+
# the director record that says why. The linter checks the record exists and
|
|
859
|
+
# talks about onboarding — whether the reason is good is a person's call.
|
|
860
|
+
ONBOARDING_NAME = re.compile(r"onboarding|first[- ]run|онбординг", re.IGNORECASE)
|
|
861
|
+
LOCAL_REF = re.compile(r"(?<![\w/])([\w.-]+(?:/[\w.-]+)+\.[A-Za-z]{1,5}(?:#[\w-]+)?)")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def check_onboarding(flows: str, ux: Path, root: Path) -> None:
|
|
865
|
+
names = {fid: name for fid, _, name in entry_names(flows, "FLW")}
|
|
866
|
+
for fid, body in entry_blocks(flows, "FLW").items():
|
|
867
|
+
declared = field_body(body, re.compile(r"\*\*Onboarding:\*\*[ \t]*(.*)"))
|
|
868
|
+
if declared is None:
|
|
869
|
+
if ONBOARDING_NAME.search(names.get(fid, "")):
|
|
870
|
+
warn(f"[U083] flows.md: {fid} reads as onboarding and declares no "
|
|
871
|
+
f"**Onboarding:** — list the screens before the first value "
|
|
872
|
+
f"(or `none`) so the budget can be counted")
|
|
873
|
+
continue
|
|
874
|
+
count = len(set(re.findall(r"\bSCR-\d+\b", declared)))
|
|
875
|
+
first = field_body(body, re.compile(r"\*\*First value:\*\*[ \t]*(.*)"))
|
|
876
|
+
if not stated(first):
|
|
877
|
+
warn(f"[U084] flows.md: {fid} counts onboarding screens and names no "
|
|
878
|
+
f"**First value:** — the budget is counted against a destination "
|
|
879
|
+
f"nobody wrote down")
|
|
880
|
+
if count <= 1:
|
|
881
|
+
continue
|
|
882
|
+
budget = field_body(body, re.compile(r"\*\*Onboarding budget:\*\*[ \t]*(.*)")) or ""
|
|
883
|
+
ref = LOCAL_REF.search(budget)
|
|
884
|
+
if not ref:
|
|
885
|
+
err(f"[U082] flows.md: {fid} puts {count} onboarding screens before the "
|
|
886
|
+
f"first value — the budget is one; more needs **Onboarding budget:** "
|
|
887
|
+
f"citing the director record that justifies it")
|
|
888
|
+
elif not resolves(ref.group(1), ux, root):
|
|
889
|
+
err(f"[U082] flows.md: {fid} cites '{ref.group(1)}' for its onboarding "
|
|
890
|
+
f"budget, which does not exist")
|
|
891
|
+
else:
|
|
892
|
+
record = (root / ref.group(1).split("#")[0])
|
|
893
|
+
if not record.exists():
|
|
894
|
+
record = ux / ref.group(1).split("#")[0]
|
|
895
|
+
if "onboarding" not in read(record).lower():
|
|
896
|
+
err(f"[U082] flows.md: {fid} cites '{ref.group(1)}' for its onboarding "
|
|
897
|
+
f"budget and the record never mentions onboarding")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# Flow approval and art-direction approval are two decisions. The second is made
|
|
901
|
+
# on frames that were critiqued first, so an approval names the critique it
|
|
902
|
+
# read — a director record section, a file, or a link — and a local one resolves.
|
|
903
|
+
def check_art_direction(flows: str, ux: Path, root: Path) -> None:
|
|
904
|
+
for fid, body in entry_blocks(flows, "FLW").items():
|
|
905
|
+
value = field_body(body, re.compile(r"\*\*Art direction:\*\*[ \t]*(.*)"))
|
|
906
|
+
if not value or not value.strip().lower().startswith("approved"):
|
|
907
|
+
continue
|
|
908
|
+
if re.search(r"https?://\S+", value):
|
|
909
|
+
continue
|
|
910
|
+
ref = LOCAL_REF.search(value)
|
|
911
|
+
if not ref:
|
|
912
|
+
err(f"[U085] flows.md: {fid} records art direction approved and cites no "
|
|
913
|
+
f"critique — the approval names the frame critique it was made on")
|
|
914
|
+
elif not resolves(ref.group(1), ux, root):
|
|
915
|
+
err(f"[U085] flows.md: {fid} cites '{ref.group(1)}' as its critique, "
|
|
916
|
+
f"which does not exist")
|
|
917
|
+
|
|
918
|
+
|
|
740
919
|
def active_hosts(root: Path) -> list[str]:
|
|
741
920
|
"""Hosts present in this project, by their marker directory. When none is
|
|
742
921
|
detectable the target defaults to Claude, matching the seed default."""
|
|
@@ -971,6 +1150,15 @@ def main() -> int:
|
|
|
971
1150
|
if has_screens:
|
|
972
1151
|
fig = figma_enabled(foundation)
|
|
973
1152
|
screens_root = project_root
|
|
1153
|
+
default_axes = axes_value(section(screens, "Design system"))
|
|
1154
|
+
gated = screens_format(ux / "screens.md") >= 2
|
|
1155
|
+
|
|
1156
|
+
def matrix(msg: str) -> None:
|
|
1157
|
+
"""U079-U081 and the widened U020: errors on format 2, warnings before."""
|
|
1158
|
+
if gated:
|
|
1159
|
+
err(msg)
|
|
1160
|
+
else:
|
|
1161
|
+
warn(msg + FORMAT_HINT)
|
|
974
1162
|
for sid, body in screen_blocks(screens).items():
|
|
975
1163
|
# Read by value, not matched against a copy of the enum: the copy
|
|
976
1164
|
# was one value short of the contract for as long as `blocked`
|
|
@@ -979,15 +1167,57 @@ def main() -> int:
|
|
|
979
1167
|
status = declared_status(body)
|
|
980
1168
|
if status == "retired":
|
|
981
1169
|
continue
|
|
982
|
-
#
|
|
983
|
-
|
|
984
|
-
|
|
1170
|
+
# The state list is required whatever the tooling: it is the rows of
|
|
1171
|
+
# the contact sheet, and a screen with none is reviewed in the one
|
|
1172
|
+
# state somebody happened to render.
|
|
1173
|
+
states = declared_states(body)
|
|
1174
|
+
if not states:
|
|
1175
|
+
matrix(f"[U079] screens.md: {sid} declares no states — list the ones "
|
|
1176
|
+
f"that apply (default, loading, empty, error, offline, "
|
|
1177
|
+
f"long-content, keyboard-up, first-run)")
|
|
1178
|
+
# Axes are the sheet's columns. A screen may inherit the project's
|
|
1179
|
+
# default from the Design system block, and override it.
|
|
1180
|
+
axes = axes_value(body)
|
|
1181
|
+
if axes is None:
|
|
1182
|
+
axes = default_axes
|
|
1183
|
+
if axes is None:
|
|
1184
|
+
matrix(f"[U080] screens.md: {sid} declares no **Axes:** and the Design "
|
|
1185
|
+
f"system sets no default — name viewport, theme, text and locale")
|
|
1186
|
+
else:
|
|
1187
|
+
gaps = missing_axes(axes)
|
|
1188
|
+
if gaps:
|
|
1189
|
+
matrix(f"[U080] screens.md: {sid} axes leave out {', '.join(gaps)} — "
|
|
1190
|
+
f"write `name: values` for each of {', '.join(AXES)}")
|
|
1191
|
+
headers, rows = state_table(body)
|
|
1192
|
+
frame_col = next((i for i, h in enumerate(headers)
|
|
1193
|
+
if "figma" in h or "frame" in h), None)
|
|
1194
|
+
story_col = next((i for i, h in enumerate(headers)
|
|
1195
|
+
if re.search(r"\b(?:stor(?:y|ies)|preview)\b", h)), None)
|
|
985
1196
|
if fig is not False: # enabled or default-on
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1197
|
+
frameless: list[str] = []
|
|
1198
|
+
if rows:
|
|
1199
|
+
for row in rows:
|
|
1200
|
+
frame = row[frame_col] if frame_col is not None and frame_col < len(row) else ""
|
|
1201
|
+
if _unfilled(frame):
|
|
1202
|
+
frameless.append(row[0])
|
|
1203
|
+
else:
|
|
1204
|
+
frameless = list(states)
|
|
1205
|
+
# What the old rule caught stays an error on every format;
|
|
1206
|
+
# only the newly covered states wait for the opt-in.
|
|
1207
|
+
legacy = legacy_frameless(body)
|
|
1208
|
+
for state in legacy:
|
|
1209
|
+
err(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
|
|
1210
|
+
for state in frameless:
|
|
1211
|
+
if state.lower() not in legacy:
|
|
1212
|
+
matrix(f"[U020] screens.md: {sid} state '{state}' has no Figma frame link")
|
|
1213
|
+
# A Story column is the build-side twin of the frame: once a screen
|
|
1214
|
+
# is built, each state it declares can be shown on its own.
|
|
1215
|
+
if story_col is not None and status == "built":
|
|
1216
|
+
for row in rows:
|
|
1217
|
+
story = row[story_col] if story_col < len(row) else ""
|
|
1218
|
+
if _unfilled(story):
|
|
1219
|
+
matrix(f"[U081] screens.md: {sid} is built and state '{row[0]}' "
|
|
1220
|
+
f"names no story — the state cannot be rendered on its own")
|
|
991
1221
|
cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
|
|
992
1222
|
cov = cov_m.group(1).strip() if cov_m else ""
|
|
993
1223
|
if status == "built" and (not cov or cov.lower().startswith("none")):
|
|
@@ -1023,6 +1253,8 @@ def main() -> int:
|
|
|
1023
1253
|
check_jobs(foundation, scenarios, flows, screens)
|
|
1024
1254
|
check_vision(ux, vision)
|
|
1025
1255
|
check_web_surface(screens, flows)
|
|
1256
|
+
check_onboarding(flows, ux, project_root)
|
|
1257
|
+
check_art_direction(flows, ux, project_root)
|
|
1026
1258
|
check_links(ux)
|
|
1027
1259
|
|
|
1028
1260
|
# --- Report ---
|
|
@@ -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>
|
package/templates/flows.md
CHANGED
|
@@ -10,6 +10,10 @@ node and edge. -->
|
|
|
10
10
|
- **Goal:** <observable end state for the user>
|
|
11
11
|
- **Entry points:** <all of them: screen, deep link, push, empty-state CTA>
|
|
12
12
|
- **Success exit:** <where the user lands on success>
|
|
13
|
+
- **First value:** <SCR-ID / step where the user first gets what they came for>
|
|
14
|
+
- **Onboarding:** <SCR-IDs shown before the first value, or none; budget is one>
|
|
15
|
+
- **Onboarding budget:** <only past one screen: the director record that justifies it>
|
|
16
|
+
- **Art direction:** <pending | approved YYYY-MM-DD — critique: <record#critique or link>>
|
|
13
17
|
- **Task analysis:**
|
|
14
18
|
1. <user-visible micro-step; cut everything that doesn't serve the job>
|
|
15
19
|
- **Flow:**
|
package/templates/foundation.md
CHANGED
|
@@ -50,11 +50,18 @@ even when the answer is "none":
|
|
|
50
50
|
## Design tooling
|
|
51
51
|
|
|
52
52
|
<!-- Visual mockups in Figma are optional (default on). Ask the user at the
|
|
53
|
-
start of design; record the choice here (see figma-integration).
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
start of design; record the choice here (see figma-integration). Figma is a
|
|
54
|
+
set of files in the product's folder, one per surface the product has; the
|
|
55
|
+
design-system details and frame links live in docs/ux/screens.md (the UI
|
|
56
|
+
map). This section records the on/off choice and where the files are.
|
|
56
57
|
- **Figma:** enabled | disabled
|
|
57
|
-
- **Figma
|
|
58
|
+
- **Figma folder:** <team / project the product's files live in>
|
|
59
|
+
- **Figma files:**
|
|
60
|
+
| Surface | File | Holds |
|
|
61
|
+
|---------|------|-------|
|
|
62
|
+
| App | <url> | every SCR-NN frame of the product |
|
|
63
|
+
| Web | <url> | landing, pricing, docs pages |
|
|
64
|
+
| ASO | <url> | store screenshots, icon, preview frames |
|
|
58
65
|
-->
|
|
59
66
|
|
|
60
67
|
## User stories
|
package/templates/screens.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# UI Screen Registry
|
|
2
2
|
|
|
3
|
+
<!-- screens-format: 2 -->
|
|
4
|
+
<!-- The line above puts this registry on format 2: the screen matrix
|
|
5
|
+
(states, axes, a frame per state, a story per built state) is a lint gate.
|
|
6
|
+
Keep it. -->
|
|
3
7
|
<!-- Managed with super-ux (ux-contract v4). The design map: every screen and
|
|
4
8
|
state with its Figma frame, wireframe, code coverage, and related UX/UI
|
|
5
9
|
resources. Update in the same change as any interface change; when Figma is
|
|
@@ -14,10 +18,14 @@ whose code diverges from its record here is a "drifted" finding. -->
|
|
|
14
18
|
## Design system
|
|
15
19
|
|
|
16
20
|
<!-- Style pack = the locked visual identity every frame and built screen obeys.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
/sheleg-design chooses it from its own pack index and defaults, before any
|
|
22
|
+
frame is drawn (directions first when the brief leaves the look open), and
|
|
23
|
+
writes the director record. "none" declines a pack, not the slop-marker
|
|
24
|
+
floor. Axes = the columns every screen is reviewed across; a screen's own
|
|
25
|
+
**Axes:** line overrides this default. -->
|
|
26
|
+
- **Style pack:** <the pack /sheleg-design chose, or "none — platform defaults">
|
|
27
|
+
- **Director record:** <docs/design/<surface>/director-record.md, or "none">
|
|
28
|
+
- **Axes:** viewport: <widths>; theme: <light, dark, …>; text: <default, 200%, …>; locale: <locales>
|
|
21
29
|
- **Figma library:** <url/name, or "none — platform defaults">
|
|
22
30
|
- **Tokens in code:** <where color/type/spacing tokens live, e.g. src/theme/tokens.ts>
|
|
23
31
|
- **Component source:** <shared UI components dir, e.g. src/components/>
|
|
@@ -42,13 +50,15 @@ the problem it can no longer fix. -->
|
|
|
42
50
|
- **Used by:** <FLW-… and the step(s)>
|
|
43
51
|
- **Purpose:** <the job step this screen serves>
|
|
44
52
|
- **Elements:** <each element; mark the ONE primary action>
|
|
45
|
-
- **States:**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
53
|
+
- **States:** (every one that applies: default, loading, empty, error,
|
|
54
|
+
offline, long-content, keyboard-up, first-run, product-specific ones)
|
|
55
|
+
| State | Trigger | Figma frame | Story | Behavior |
|
|
56
|
+
|-------|---------|-------------|-------|----------|
|
|
57
|
+
| default | <trigger> | <frame deep-link> | <story/preview, once built> | <what shows> |
|
|
58
|
+
| empty | <trigger> | <frame deep-link> | <story> | <prompt to act> |
|
|
59
|
+
| error | <trigger> | <frame deep-link> | <story> | <message + recovery> |
|
|
60
|
+
| loading | <trigger> | <frame deep-link> | <story> | <skeleton/progress> |
|
|
61
|
+
- **Axes:** (only when this screen differs from the Design system default)
|
|
52
62
|
- **Web surface:** (only when this screen is a public URL; all five required)
|
|
53
63
|
- **Route:** </pricing — the path, readable and stable>
|
|
54
64
|
- **Answers:** <the ONE question this page answers; a second question is a second page>
|