wizz-method 2.20.1 → 2.22.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/THIRD-PARTY-NOTICES.md +18 -0
- package/package.json +4 -2
- package/skills-registry.yaml +41 -0
- package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-create-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +8 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +2 -2
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-03-implement.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-status/SKILL.md +1 -1
- package/src/core-skills/wizz-party-mode/customize.toml +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-funil/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-guia/SKILL.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/SKILL.md +5 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/01-principios.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/02-diagnostico-funil.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/03-campanha-bidcap.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04a-criativos-ia.md +6 -6
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04b-criativos-formatos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04c-criativos-roteiro.md +5 -5
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/05-oferta.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06-pagina-vendas.md +3 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06a-instagram.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/06b-player-vsl.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07-funil-upsell.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/07a-copy-humanizacao.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/08-pesquisa-publico.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/09-especialista-coproducao.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/10-levantar-caixa.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12a-mineracao-veredito.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12b-fabrica-de-ofertas.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12c-anatomia-low-ticket.md +3 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/13-benchmarks-vsl.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/14-vocabulario.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/15-como-estender.md +2 -2
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/17-rastreamento.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/18-copy-desejo-oferta.md +3 -3
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/INDEX.md +14 -12
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/guardrails.md +18 -70
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +9 -9
- package/src/modules/lowticket/skills/wizz-lowticket-minerador/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-pagina/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/SKILL.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-trafego/references/reguas.md +2 -0
- package/src/modules/wizz/_shared/cerebro.md +1 -1
- package/src/modules/wizz/_shared/organizacao.md +2 -2
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/customize.toml +5 -0
- package/src/modules/wizz/agents/wizz-copy/knowledge/playbook.md +2 -1
- package/src/modules/wizz/agents/wizz-growth/knowledge/playbook.md +1 -1
- package/src/modules/wizz/agents/wizz-social/knowledge/blueprint-roteiro-viral.md +14 -0
- package/src/modules/wizz/agents/wizz-social/knowledge/legenda-instagram.md +12 -0
- package/src/skills-lib/wizz-cerebro/SKILL.md +18 -5
- package/src/skills-lib/wizz-design-needs/SKILL.md +7 -0
- package/src/skills-lib/wizz-design-needs/references/design-state.md +34 -2
- package/src/skills-lib/wizz-design-needs/references/gap-analysis.md +1 -1
- package/src/skills-lib/wizz-design-needs/scripts/design_state.py +305 -0
- package/src/skills-lib/wizz-design-needs/scripts/tests/test_design_state.py +143 -0
- package/src/skills-lib/wizz-impeccable/references/design-rules.md +18 -0
- package/src/skills-lib/wizz-motion-3d-director/references/motion-paths.md +2 -0
- package/src/skills-lib/wizz-paid-ads/SKILL.md +2 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +6 -2
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/audit-protocol.md +7 -34
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/component-sources.md +2 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/design-sources.yaml +252 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mandatory-process.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/mining-rubric.md +123 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/source-first-protocol.md +10 -4
- package/src/skills-lib/wizz-premium-landing-ui-researcher/references/web-access-and-evidence.md +1 -1
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/design_sources.py +221 -0
- package/src/skills-lib/wizz-premium-landing-ui-researcher/scripts/tests/test_design_sources.py +130 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
- package/src/skills-lib/wizz-taste-skill/references/anti-slop-tells.md +8 -0
- package/src/skills-lib/wizz-ui-component-curator/SKILL.md +8 -7
- package/src/skills-lib/wizz-video-effects/LICENSE-motionmaxxing.txt +202 -0
- package/src/skills-lib/wizz-video-effects/SKILL.md +5 -2
- package/src/skills-lib/wizz-video-effects/assets/chrome/collect_layout.js +113 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +35 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +30 -1
- package/src/skills-lib/wizz-video-effects/references/gates.md +3 -1
- package/src/skills-lib/wizz-video-effects/scripts/check_chrome.py +376 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_dead_frames.py +193 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +13 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_chrome.py +181 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_dead_frames.py +171 -0
- package/src/skills-lib/wizz-video-styles/SKILL.md +6 -2
- package/src/skills-lib/wizz-video-styles/references/motion-laws.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +1 -0
- package/src/skills-lib/wizz-video-styles/scripts/blind_review.py +159 -0
- package/src/skills-lib/wizz-video-styles/scripts/taste.mjs +110 -0
- package/src/skills-lib/wizz-video-styles/scripts/test_blind_review.py +82 -0
- package/src/skills-lib/wizz-vsl/LICENSE +49 -0
- package/src/skills-lib/wizz-vsl/SKILL.md +122 -0
- package/src/skills-lib/wizz-vsl/references/assembly-qa.md +123 -0
- package/src/skills-lib/wizz-vsl/references/blocks-and-leads.md +149 -0
- package/src/skills-lib/wizz-vsl/references/diagnosis.md +95 -0
- package/src/skills-lib/wizz-vsl/references/intake.md +200 -0
- package/src/skills-lib/wizz-vsl/references/mechanisms.md +138 -0
- package/src/skills-lib/wizz-vsl/references/niches.md +40 -0
- package/src/skills-lib/wizz-vsl/references/offer.md +91 -0
- package/src/skills-lib/wizz-vsl/references/production.md +75 -0
- package/tools/hooks/README.md +10 -9
- package/tools/hooks/wizz-arrumar.js +1 -0
- package/tools/hooks/wizz-artifact-guard.js +31 -5
- package/tools/hooks/wizz-cerebro-lib.js +36 -13
- package/tools/hooks/wizz-feature-lib.js +4 -1
- package/tools/hooks/wizz-feature.js +66 -76
- package/tools/hooks/wizz-sprint-archive-lib.js +311 -0
- package/tools/installer/core/hooks-settings.js +1 -0
- package/tools/installer/core/installer.js +6 -0
- package/tools/installer/modules/core-seed.js +72 -0
- package/tools/installer/modules/mcp-config.js +3 -0
- package/tools/installer/ui.js +16 -26
|
@@ -39,6 +39,18 @@ Supporting knowledge for **wizz-social** (Rafa). Creates the optimized descripti
|
|
|
39
39
|
- **Fast Reels / simple feed post:** 80 to 150 characters (short, direct, impactful).
|
|
40
40
|
- **Educational carousel / storytelling:** 300 to 800 characters (delivers value, increases reading time).
|
|
41
41
|
|
|
42
|
+
## The fold check (before delivering)
|
|
43
|
+
|
|
44
|
+
Instagram shows about **125 characters** before "mais". Check the caption the way the feed prints it:
|
|
45
|
+
|
|
46
|
+
- **Decide the job first.** Reel: the video already hooked the viewer, so line one carries the ask and the context, not a second hook competing with the video. Photo, single image or carousel cover: the caption is the content, so line one is the hook and it ends at a cliff, not mid-clause.
|
|
47
|
+
- Line one fits inside the 125 characters: never a greeting, a hashtag or an emoji as the first character.
|
|
48
|
+
- **One ask** only (comment a keyword, save, or DM). A keyword ask uses one typable word with no spaces or emoji, also said in the video.
|
|
49
|
+
- No link in the caption: captions are not clickable; the link goes in the bio, a DM, or the first comment.
|
|
50
|
+
- The phrases the user wants to be found for go in the body as a human would type them; hashtags (up to 5) are labels, not reach.
|
|
51
|
+
|
|
52
|
+
Add one line under the caption in the delivery: `👁️ Antes do "mais": N/125 caracteres`. *(Adapted from the caption linter of Jakeschincariol/instagram-agent-skill, MIT, credited in `THIRD-PARTY-NOTICES.md`.)*
|
|
53
|
+
|
|
42
54
|
## General rules
|
|
43
55
|
- Never robotic or generic. Write like a human.
|
|
44
56
|
- Emojis with moderation and purpose: they guide the eye, they don't clutter.
|
|
@@ -53,12 +53,25 @@ Read offset=(line-5) limit=15 # → reads only the needed excerpt
|
|
|
53
53
|
|
|
54
54
|
## Vault
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
This section is the **single source** of vault discovery. Every other file (command references, `_shared/cerebro.md`) points here instead of repeating it. The vault is plain markdown: it works with or without the Obsidian app.
|
|
57
57
|
|
|
58
|
-
**How to discover it
|
|
59
|
-
1.
|
|
60
|
-
2.
|
|
61
|
-
3.
|
|
58
|
+
**How to discover it** (first folder that holds a `CEREBRO.md` wins; `wizz-feature.js encerrar --cerebro` reads steps 1 to 3 in the same order):
|
|
59
|
+
1. `$WIZZ_CEREBRO_VAULT`, then `cerebro_vault:` in `_wizz/bmm/config.yaml`.
|
|
60
|
+
2. Per-project Cérebro of the current repo: `_wizz-output/cerebro/` (or `{output_folder}/cerebro/` when the config sets another output folder).
|
|
61
|
+
3. Global default: `~/Documents/projects/Obsidian Vault/`.
|
|
62
|
+
4. `find ~ -maxdepth 6 -name "CEREBRO.md" -not -path "*/_wizz-output/*" 2>/dev/null | head -1` (skips other repos' per-project Cérebros).
|
|
63
|
+
|
|
64
|
+
**Nothing found.** A read-only command (`/ver`, `/dia`, `/memoria`) says there is no Cérebro yet and stops. A command that saves (`/iniciar`, `/salvar`, `/dump`, `/decisao`, `/conteudo`, `/prospect`) never writes to an invented place in silence: ask once (AskUserQuestion where available, a numbered question otherwise):
|
|
65
|
+
- **Global Cérebro** (one for every project): then ask which folder, suggesting `~/Documents/projects/Obsidian Vault/`. Outside the default path, tell the user that `export WIZZ_CEREBRO_VAULT="<folder>"` in the shell profile lets the CLI find it too (do not edit the profile yourself).
|
|
66
|
+
- **Per-project Cérebro** (inside this repo, at `_wizz-output/cerebro/`): then ask once whether it stays out of git (private: append `_wizz-output/cerebro/` to `.gitignore`) or is committed (shared with the team).
|
|
67
|
+
|
|
68
|
+
**After the choice**, create the minimal structure and never ask again (the next discovery finds it):
|
|
69
|
+
```
|
|
70
|
+
<vault>/CEREBRO.md first line: vault: <absolute path> (per-project adds a second line: scope: project)
|
|
71
|
+
<vault>/projetos/
|
|
72
|
+
<vault>/_decisions/
|
|
73
|
+
```
|
|
74
|
+
A per-project Cérebro needs no config entry: the feature-closing CLI finds `_wizz-output/cerebro/` by itself, so no machine path lands in a committed config. Then continue the command that triggered the question.
|
|
62
75
|
|
|
63
76
|
**Never Read the entire CEREBRO.md just to find the vault path** — use grep:
|
|
64
77
|
```bash
|
|
@@ -49,6 +49,13 @@ Both read `design-state.yaml`, take the need's `budget` and `floor` as given, an
|
|
|
49
49
|
|
|
50
50
|
Level 4 or 5 also hands off to `wizz-motion-3d-director` before any behaviour need is mined.
|
|
51
51
|
|
|
52
|
+
## Executable checks on the map
|
|
53
|
+
|
|
54
|
+
`scripts/design_state.py` (run with `uv run --no-cache`) reads the rubric and the level ceiling from the skill files and checks the map itself, so the gates are not only prose:
|
|
55
|
+
|
|
56
|
+
- `validate {output_folder}/design-state.yaml`: statuses and required reasons, verdict against the score band, entry conditions, the cut and each need's floor, one signature per surface, dedup links, the adaptation on every selected discovery, and the behaviour ceiling of the level. The miners run it after writing discoveries; findings are fixed in the map before the handoff.
|
|
57
|
+
- `gate {output_folder}/design-state.yaml --section <surface>/<section>` (or `--need <id>`): the implementer runs it before building a non-trivial section, component or behaviour. Exit 0 passes (selected with a discovery, `unmet` with the research as the justification, `deferred` meaning "not now", `dropped`); exit 1 blocks an open need (mine it first) or a section the map does not know (`unplanned`, back to this skill). No map, no gate: the fail-open contract of the state file holds.
|
|
58
|
+
|
|
52
59
|
## References map (read on demand)
|
|
53
60
|
|
|
54
61
|
| For... | Read |
|
|
@@ -23,6 +23,8 @@ Read contract, deliberately identical to the one the router and the maestro alre
|
|
|
23
23
|
| `implementacao` | `wizz-agent-dev` / `wizz-quick-dev` | QA |
|
|
24
24
|
| `problemas` | QA, the audit protocol, the user | `wizz-design-needs` when reopening a need |
|
|
25
25
|
|
|
26
|
+
`scripts/design_state.py validate` checks this contract and the rubric on a real file; `gate` is the implementer's check before building (both in the skill's SKILL.md).
|
|
27
|
+
|
|
26
28
|
No block has two owners. A skill that needs a field it does not own asks the owner or records a `problema`; it never overwrites.
|
|
27
29
|
|
|
28
30
|
## Schema
|
|
@@ -70,13 +72,41 @@ needs:
|
|
|
70
72
|
adaptive: { mobile: null, reduced_motion: null } # obrigatório antes de fechar
|
|
71
73
|
status: open # open | mining | evaluated | selected | unmet | dropped | deferred
|
|
72
74
|
reason: null # obrigatório em dropped, deferred, unmet
|
|
73
|
-
|
|
75
|
+
spent: { fontes: 0, secas: 0 } # escrito pelo minerador: fontes abertas, fontes seguidas sem nada elegível
|
|
76
|
+
satisfied_by: [] # ids das discoveries selecionadas
|
|
74
77
|
|
|
75
78
|
proibidos:
|
|
76
79
|
- { o_que: "partícula, glow, gradiente saturado, 3D", por: sinal:minimalista }
|
|
77
80
|
- { o_que: "carrossel de logos, card de depoimento genérico", por: tipo:autoridade }
|
|
78
81
|
|
|
79
|
-
discoveries:
|
|
82
|
+
discoveries: # escrito pelo minerador; regras em mining-rubric.md
|
|
83
|
+
- id: disc-001
|
|
84
|
+
need: b1-hero-entrada # toda descoberta responde a uma necessidade
|
|
85
|
+
surface: home
|
|
86
|
+
layer: comportamento # copiado da need: o teto de comportamento é contado por superfície
|
|
87
|
+
source:
|
|
88
|
+
url: "https://..."
|
|
89
|
+
kind: code # code | registry | visual
|
|
90
|
+
revision: "a3f1c9d" # SHA, tag ou data de leitura
|
|
91
|
+
license: MIT # null quando não há
|
|
92
|
+
access: publico # publico | cache | fornecido_pelo_usuario | pago_autorizado
|
|
93
|
+
evidence: verified # verified | unverified (gate de web-access-and-evidence.md)
|
|
94
|
+
pattern: "revelação por máscara vertical sincronizada com o scroll"
|
|
95
|
+
pattern_key: scroll-mask-reveal # chave de dedup dentro do projeto
|
|
96
|
+
observed: ["máscara 420ms, cubic-bezier(.2,.8,.2,1)", "sem useReducedMotion no código"]
|
|
97
|
+
inferred: ["provavelmente usa GSAP ScrollTrigger"]
|
|
98
|
+
disqualifiers: [] # códigos de condição de entrada não cumprida; qualquer um = DESCARTAR
|
|
99
|
+
scores: { aderencia: 3, execucao: 2, custo_adaptacao: 2, custo_performance: 1, originalidade: 1, total: 9 }
|
|
100
|
+
verdict: ASSINATURA # ASSINATURA | FORTE | UTILITARIO | DESCARTAR
|
|
101
|
+
adaptation: # obrigatório quando selected
|
|
102
|
+
padrao: "o título é revelado por uma máscara que sobe com o scroll"
|
|
103
|
+
por_que: "segura o olho no título antes de liberar o corpo"
|
|
104
|
+
invariante: "a máscara tem que andar mais rápido que o scroll, senão lê como lag"
|
|
105
|
+
variavel: [easing, duração, direção, cor]
|
|
106
|
+
reimplementacao: "reimplementar com os tokens do projeto, sem copiar seletor nem classe"
|
|
107
|
+
status: selected # proposed | selected | rejected | implemented | reverted
|
|
108
|
+
reason: null # obrigatório em rejected e reverted
|
|
109
|
+
dup_of: null # id da discovery selecionada que já resolve este padrão
|
|
80
110
|
implementacao: {} # escrito pelo dev
|
|
81
111
|
problemas: [] # escrito pelo QA
|
|
82
112
|
```
|
|
@@ -97,6 +127,8 @@ open ──▶ mining ──▶ evaluated ──▶ selected ──▶ (implemen
|
|
|
97
127
|
└──▶ deferred (perdeu para o teto do nível)
|
|
98
128
|
```
|
|
99
129
|
|
|
130
|
+
Discoveries have their own lifecycle: `proposed → selected → implemented`, or `rejected` / `reverted` with a `reason`. How a discovery is scored, when it is selected and when the miner stops are in [mining-rubric](../../wizz-premium-landing-ui-researcher/references/mining-rubric.md).
|
|
131
|
+
|
|
100
132
|
`unmet`, `dropped` and `deferred` require a `reason`. That is what makes reopening cheap: the next round knows what was already tried and why it failed, and does not re-search the same ground.
|
|
101
133
|
|
|
102
134
|
## Reopening
|
|
@@ -24,7 +24,7 @@ When the layers disagree or the coverage cannot be proven by reading (a CSS rule
|
|
|
24
24
|
- a site the user named as a reference: at the **system** layer (palette, type scale, spacing, radius), extract it instead of describing it, `npx -y dembrandt@0.39.2 <url> --design-md`, and close the need against the measured tokens
|
|
25
25
|
- a Figma file from the client, through the Framelink MCP when the user set it up (see [component-sources](../../wizz-premium-landing-ui-researcher/references/component-sources.md))
|
|
26
26
|
|
|
27
|
-
Cache freshness rule: check the revision and date before treating a cached item as current. A stale cache is still a valid source for a pattern; it is not a valid source for an API. When the cache answers a need, record the revision in the discovery, not just the path. For registry sources, the live index (`https://ui.shadcn.com/r/registries.json`, `health` per registry) is the freshness check: it costs one request and needs no clone.
|
|
27
|
+
Cache freshness rule: check the revision and date before treating a cached item as current (`uv run --no-cache ../wizz-premium-landing-ui-researcher/scripts/design_sources.py freshness` from this skill's folder answers it for every cached clone). A stale cache is still a valid source for a pattern; it is not a valid source for an API. When the cache answers a need, record the revision in the discovery, not just the path. For registry sources, the live index (`https://ui.shadcn.com/r/registries.json`, `health` per registry) is the freshness check: it costs one request and needs no clone.
|
|
28
28
|
|
|
29
29
|
User-supplied material outranks every catalogue. A reference the user chose is worth more than a better reference they did not.
|
|
30
30
|
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["ruamel.yaml>=0.18"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Check design-state.yaml against the mining rubric, and gate implementation.
|
|
6
|
+
|
|
7
|
+
Prints ONLY JSON to stdout.
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
validate <state> Structural and rubric checks on needs and discoveries:
|
|
11
|
+
statuses, required reasons, verdict vs score band, entry
|
|
12
|
+
conditions, the cut and the need's floor, one signature per
|
|
13
|
+
surface, dedup links, adaptation on selected discoveries,
|
|
14
|
+
and the behaviour ceiling of the level. Exit 0 when ok,
|
|
15
|
+
1 when there are findings, 2 on a file it cannot read.
|
|
16
|
+
gate <state> --section <surface>/<section> [--section ...] | --need <id>
|
|
17
|
+
The implementer's gate. Before building a non-trivial
|
|
18
|
+
section, component or behaviour, every need of that
|
|
19
|
+
section must be in a terminal status, and each `selected`
|
|
20
|
+
need must point at a selected discovery. Open, mining or
|
|
21
|
+
evaluated needs block (mine first); a section the map does
|
|
22
|
+
not know blocks as `unplanned` (back to wizz-design-needs).
|
|
23
|
+
`unmet` passes (build from scratch, the research is the
|
|
24
|
+
justification) and `deferred` passes with "do not build
|
|
25
|
+
this behaviour now". Exit 0 pass, 1 blocked, 2 error.
|
|
26
|
+
|
|
27
|
+
The numbers (cut, bands, entry-condition codes) are read from the
|
|
28
|
+
`# mining-rubric:v1` block of mining-rubric.md and the behaviour ceiling from
|
|
29
|
+
the Rule 3 table of derivacao.md. Nothing is restated here.
|
|
30
|
+
|
|
31
|
+
Run with `uv run --no-cache scripts/design_state.py ...`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
HERE = Path(__file__).resolve().parent
|
|
41
|
+
SKILLS = HERE.parents[1]
|
|
42
|
+
DERIVATION = HERE.parent / "references" / "derivacao.md"
|
|
43
|
+
RUBRIC_CANDIDATES = [
|
|
44
|
+
SKILLS / "wizz-premium-landing-ui-researcher" / "references" / "mining-rubric.md",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
NEED_STATUSES = {"open", "mining", "evaluated", "selected", "unmet", "dropped", "deferred"}
|
|
48
|
+
NEED_TERMINAL = {"selected", "unmet", "dropped", "deferred"}
|
|
49
|
+
NEED_NEEDS_REASON = {"unmet", "dropped", "deferred"}
|
|
50
|
+
DISC_STATUSES = {"proposed", "selected", "rejected", "implemented", "reverted"}
|
|
51
|
+
DISC_NEEDS_REASON = {"rejected", "reverted"}
|
|
52
|
+
DISC_CHOSEN = {"selected", "implemented"}
|
|
53
|
+
ADAPTATION_FIELDS = ("padrao", "por_que", "invariante", "variavel", "reimplementacao")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def emit(payload, code):
|
|
57
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
|
58
|
+
sys.exit(code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def yaml_loader():
|
|
62
|
+
from ruamel.yaml import YAML
|
|
63
|
+
|
|
64
|
+
return YAML(typ="safe")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_rubric(candidates=None):
|
|
68
|
+
for path in candidates or RUBRIC_CANDIDATES:
|
|
69
|
+
if path.exists():
|
|
70
|
+
text = path.read_text(encoding="utf-8")
|
|
71
|
+
match = re.search(r"```yaml\n(# mining-rubric:v1\n.*?)```", text, re.S)
|
|
72
|
+
if not match:
|
|
73
|
+
raise ValueError(f"{path}: no '# mining-rubric:v1' block")
|
|
74
|
+
return yaml_loader().load(match.group(1))
|
|
75
|
+
raise ValueError("mining-rubric.md not found next to this skill")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_ceiling(path=DERIVATION):
|
|
79
|
+
text = path.read_text(encoding="utf-8")
|
|
80
|
+
start = text.index("## Rule 3")
|
|
81
|
+
section = text[start : text.find("\n## ", start + 1)]
|
|
82
|
+
ceiling = {}
|
|
83
|
+
for line in section.splitlines():
|
|
84
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
85
|
+
if len(cells) >= 3:
|
|
86
|
+
level, needs = re.match(r"\d+", cells[0]), re.match(r"\d+", cells[2])
|
|
87
|
+
if level and needs:
|
|
88
|
+
ceiling[int(level.group())] = int(needs.group())
|
|
89
|
+
if len(ceiling) < 5:
|
|
90
|
+
raise ValueError(f"{path}: Rule 3 table does not have the 5 levels")
|
|
91
|
+
return ceiling
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_state(path):
|
|
95
|
+
with open(path, encoding="utf-8") as handle:
|
|
96
|
+
data = yaml_loader().load(handle)
|
|
97
|
+
if not isinstance(data, dict):
|
|
98
|
+
raise ValueError(f"{path}: not a mapping")
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def band(rubric, total):
|
|
103
|
+
for verdict in rubric["verdicts"]:
|
|
104
|
+
if verdict["min"] <= total <= verdict["max"]:
|
|
105
|
+
return verdict["id"]
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Findings:
|
|
110
|
+
def __init__(self):
|
|
111
|
+
self.items = []
|
|
112
|
+
|
|
113
|
+
def add(self, code, where, message):
|
|
114
|
+
self.items.append({"code": code, "where": where, "message": message})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_needs(needs, out):
|
|
118
|
+
seen = set()
|
|
119
|
+
for need in needs:
|
|
120
|
+
nid = need.get("id")
|
|
121
|
+
if not nid or nid in seen:
|
|
122
|
+
out.add("need_id", str(nid), "need without id or with a duplicate id")
|
|
123
|
+
seen.add(nid)
|
|
124
|
+
status = need.get("status")
|
|
125
|
+
if status not in NEED_STATUSES:
|
|
126
|
+
out.add("need_status", str(nid), f"unknown status {status!r}")
|
|
127
|
+
if status in NEED_NEEDS_REASON and not need.get("reason"):
|
|
128
|
+
out.add("need_reason", str(nid), f"status {status} needs a reason")
|
|
129
|
+
if not need.get("why"):
|
|
130
|
+
out.add("need_why", str(nid), "a need without `why` does not trace to the brief")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_scores(disc, rubric, out, where):
|
|
134
|
+
scores = disc.get("scores") or {}
|
|
135
|
+
total = 0
|
|
136
|
+
for criterion, (low, high) in rubric["criteria"].items():
|
|
137
|
+
value = scores.get(criterion)
|
|
138
|
+
if not isinstance(value, int) or not low <= value <= high:
|
|
139
|
+
out.add("score_range", where, f"{criterion}={value!r} outside {low}-{high}")
|
|
140
|
+
return None
|
|
141
|
+
total += value
|
|
142
|
+
if scores.get("total") not in (None, total):
|
|
143
|
+
out.add("score_total", where, f"total {scores.get('total')} != sum {total}")
|
|
144
|
+
return total
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_verdict(disc, rubric, out, where):
|
|
148
|
+
verdict = disc.get("verdict")
|
|
149
|
+
if disc.get("disqualifiers"):
|
|
150
|
+
unknown = set(disc["disqualifiers"]) - set(rubric["disqualifiers"])
|
|
151
|
+
if unknown:
|
|
152
|
+
out.add("disqualifier_code", where, f"unknown entry-condition codes {sorted(unknown)}")
|
|
153
|
+
if verdict != "DESCARTAR":
|
|
154
|
+
out.add("verdict_band", where, "an unmet entry condition means DESCARTAR")
|
|
155
|
+
return None
|
|
156
|
+
if disc.get("evidence") != "verified" or disc.get("dup_of"):
|
|
157
|
+
return None
|
|
158
|
+
total = check_scores(disc, rubric, out, where)
|
|
159
|
+
if total is None:
|
|
160
|
+
return None
|
|
161
|
+
expected = band(rubric, total)
|
|
162
|
+
capped = verdict == "FORTE" and expected == "ASSINATURA" and disc.get("reason") == rubric.get("signature_cap_reason")
|
|
163
|
+
if verdict != expected and not capped:
|
|
164
|
+
out.add("verdict_band", where, f"score {total} is {expected}, recorded {verdict}")
|
|
165
|
+
return total
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def check_chosen(disc, need, total, rubric, out, where):
|
|
169
|
+
if disc.get("evidence") != "verified":
|
|
170
|
+
out.add("selected_unverified", where, "an unverified discovery cannot be selected")
|
|
171
|
+
if disc.get("verdict") == "DESCARTAR" or disc.get("disqualifiers"):
|
|
172
|
+
out.add("selected_discarded", where, "a DESCARTAR discovery cannot be selected")
|
|
173
|
+
floor = max(rubric["cut"], int((need or {}).get("floor") or 0))
|
|
174
|
+
if total is not None and total < floor:
|
|
175
|
+
out.add("selected_below_floor", where, f"score {total} below the floor {floor}")
|
|
176
|
+
adaptation = disc.get("adaptation") or {}
|
|
177
|
+
missing = [field for field in ADAPTATION_FIELDS if not adaptation.get(field)]
|
|
178
|
+
if missing:
|
|
179
|
+
out.add("adaptation_missing", where, f"selected without adaptation fields {missing}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_discoveries(state, rubric, ceiling, out):
|
|
183
|
+
needs = {need.get("id"): need for need in state.get("needs") or []}
|
|
184
|
+
discoveries = state.get("discoveries") or []
|
|
185
|
+
by_id = {disc.get("id"): disc for disc in discoveries}
|
|
186
|
+
signatures, behaviours = {}, {}
|
|
187
|
+
for disc in discoveries:
|
|
188
|
+
where = str(disc.get("id"))
|
|
189
|
+
need = needs.get(disc.get("need"))
|
|
190
|
+
if need is None:
|
|
191
|
+
out.add("discovery_need", where, f"need {disc.get('need')!r} is not in the map")
|
|
192
|
+
status = disc.get("status")
|
|
193
|
+
if status not in DISC_STATUSES:
|
|
194
|
+
out.add("discovery_status", where, f"unknown status {status!r}")
|
|
195
|
+
if status in DISC_NEEDS_REASON and not disc.get("reason"):
|
|
196
|
+
out.add("discovery_reason", where, f"status {status} needs a reason")
|
|
197
|
+
if not disc.get("observed"):
|
|
198
|
+
out.add("observed_missing", where, "no `observed` list: a score must rest on what was seen")
|
|
199
|
+
if disc.get("dup_of"):
|
|
200
|
+
original = by_id.get(disc["dup_of"])
|
|
201
|
+
if not original or original.get("status") not in DISC_CHOSEN or original.get("pattern_key") != disc.get("pattern_key"):
|
|
202
|
+
out.add("dup_of", where, "dup_of must point at a selected discovery with the same pattern_key")
|
|
203
|
+
total = check_verdict(disc, rubric, out, where)
|
|
204
|
+
if status in DISC_CHOSEN:
|
|
205
|
+
check_chosen(disc, need, total, rubric, out, where)
|
|
206
|
+
surface = disc.get("surface") or (need or {}).get("surface")
|
|
207
|
+
if disc.get("verdict") == "ASSINATURA":
|
|
208
|
+
signatures[surface] = signatures.get(surface, 0) + 1
|
|
209
|
+
if (disc.get("layer") or (need or {}).get("layer")) == "comportamento":
|
|
210
|
+
behaviours[surface] = behaviours.get(surface, 0) + 1
|
|
211
|
+
limit = rubric.get("signature_per_surface", 1)
|
|
212
|
+
for surface, count in signatures.items():
|
|
213
|
+
if count > limit:
|
|
214
|
+
out.add("signature_cap", str(surface), f"{count} ASSINATURA selected, limit {limit}")
|
|
215
|
+
level = state.get("nivel")
|
|
216
|
+
if isinstance(level, int) and level in ceiling:
|
|
217
|
+
for surface, count in behaviours.items():
|
|
218
|
+
if count > ceiling[level]:
|
|
219
|
+
out.add("behaviour_ceiling", str(surface), f"{count} behaviours selected, level {level} allows {ceiling[level]}")
|
|
220
|
+
for need in needs.values():
|
|
221
|
+
if need.get("status") == "selected":
|
|
222
|
+
linked = [by_id.get(ref) for ref in need.get("satisfied_by") or []]
|
|
223
|
+
if not any(disc and disc.get("status") in DISC_CHOSEN for disc in linked):
|
|
224
|
+
out.add("need_unlinked", str(need.get("id")), "selected need without a selected discovery in satisfied_by")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate(state, rubric, ceiling):
|
|
228
|
+
out = Findings()
|
|
229
|
+
check_needs(state.get("needs") or [], out)
|
|
230
|
+
check_discoveries(state, rubric, ceiling, out)
|
|
231
|
+
return out.items
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def gate(state, sections, need_ids):
|
|
235
|
+
needs = state.get("needs") or []
|
|
236
|
+
discoveries = {disc.get("id"): disc for disc in state.get("discoveries") or []}
|
|
237
|
+
targets = []
|
|
238
|
+
for ref in sections:
|
|
239
|
+
surface, _, section = ref.partition("/")
|
|
240
|
+
matched = [n for n in needs if n.get("surface") == surface and (not section or n.get("section") == section)]
|
|
241
|
+
targets.append((ref, matched))
|
|
242
|
+
for nid in need_ids:
|
|
243
|
+
targets.append((nid, [n for n in needs if n.get("id") == nid]))
|
|
244
|
+
results, blocked = [], False
|
|
245
|
+
for ref, matched in targets:
|
|
246
|
+
if not matched:
|
|
247
|
+
results.append({"target": ref, "result": "unplanned", "action": "not in the map: back to wizz-design-needs"})
|
|
248
|
+
blocked = True
|
|
249
|
+
continue
|
|
250
|
+
for need in matched:
|
|
251
|
+
row = {"target": ref, "need": need.get("id"), "status": need.get("status")}
|
|
252
|
+
status = need.get("status")
|
|
253
|
+
if status not in NEED_TERMINAL:
|
|
254
|
+
row.update(result="blocked", action="mine this need first (wizz-ui-component-curator / researcher)")
|
|
255
|
+
blocked = True
|
|
256
|
+
elif status == "selected":
|
|
257
|
+
chosen = [discoveries.get(ref_id) for ref_id in need.get("satisfied_by") or []]
|
|
258
|
+
chosen = [disc for disc in chosen if disc and disc.get("status") in {"selected", "implemented"}]
|
|
259
|
+
if chosen:
|
|
260
|
+
row.update(result="pass", discoveries=[disc.get("id") for disc in chosen])
|
|
261
|
+
else:
|
|
262
|
+
row.update(result="blocked", action="selected need without a selected discovery")
|
|
263
|
+
blocked = True
|
|
264
|
+
elif status == "deferred":
|
|
265
|
+
row.update(result="pass", action="deferred by the level: do not build this behaviour now")
|
|
266
|
+
elif status == "unmet":
|
|
267
|
+
row.update(result="pass", action="unmet: build from scratch, cite the research as the justification")
|
|
268
|
+
else:
|
|
269
|
+
row.update(result="pass", action=need.get("reason") or "dropped")
|
|
270
|
+
results.append(row)
|
|
271
|
+
return blocked, results
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main(argv=None):
|
|
275
|
+
parser = argparse.ArgumentParser(prog="design_state.py")
|
|
276
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
277
|
+
check = sub.add_parser("validate")
|
|
278
|
+
check.add_argument("state")
|
|
279
|
+
gating = sub.add_parser("gate")
|
|
280
|
+
gating.add_argument("state")
|
|
281
|
+
gating.add_argument("--section", action="append", default=[])
|
|
282
|
+
gating.add_argument("--need", action="append", default=[])
|
|
283
|
+
try:
|
|
284
|
+
args = parser.parse_args(argv)
|
|
285
|
+
except SystemExit as error:
|
|
286
|
+
emit({"error": "invalid arguments"}, int(error.code or 2))
|
|
287
|
+
try:
|
|
288
|
+
state = load_state(args.state)
|
|
289
|
+
if args.command == "validate":
|
|
290
|
+
rubric, ceiling = load_rubric(), load_ceiling()
|
|
291
|
+
except (OSError, ValueError, ImportError) as error:
|
|
292
|
+
emit({"error": str(error)}, 2)
|
|
293
|
+
except Exception as error: # ruamel parse errors have their own hierarchy
|
|
294
|
+
emit({"error": f"cannot parse: {error}"}, 2)
|
|
295
|
+
if args.command == "validate":
|
|
296
|
+
findings = validate(state, rubric, ceiling)
|
|
297
|
+
emit({"ok": not findings, "findings": findings}, 1 if findings else 0)
|
|
298
|
+
if not args.section and not args.need:
|
|
299
|
+
emit({"error": "gate needs --section <surface>/<section> or --need <id>"}, 2)
|
|
300
|
+
blocked, results = gate(state, args.section, args.need)
|
|
301
|
+
emit({"gate": "blocked" if blocked else "pass", "results": results}, 1 if blocked else 0)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Tests for design_state.py: rubric validation and the implementation gate."""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "design_state.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("design_state", SCRIPT)
|
|
16
|
+
mod = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(mod)
|
|
18
|
+
|
|
19
|
+
RUBRIC = mod.load_rubric()
|
|
20
|
+
CEILING = mod.load_ceiling()
|
|
21
|
+
|
|
22
|
+
ADAPTATION = {
|
|
23
|
+
"padrao": "o título é revelado por uma máscara que sobe com o scroll",
|
|
24
|
+
"por_que": "segura o olho no título",
|
|
25
|
+
"invariante": "a máscara anda mais rápido que o scroll",
|
|
26
|
+
"variavel": ["easing", "duração"],
|
|
27
|
+
"reimplementacao": "tokens do projeto",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
BASE = {
|
|
31
|
+
"version": 1,
|
|
32
|
+
"nivel": 1,
|
|
33
|
+
"needs": [
|
|
34
|
+
{"id": "b1", "surface": "home", "section": "hero", "layer": "comportamento", "why": "brief editorial", "floor": 7,
|
|
35
|
+
"status": "selected", "satisfied_by": ["d1"]},
|
|
36
|
+
{"id": "s1", "surface": "home", "section": "projetos", "layer": "secao", "why": "portfólio", "floor": 6,
|
|
37
|
+
"status": "open"},
|
|
38
|
+
{"id": "s2", "surface": "home", "section": "contato", "layer": "secao", "why": "captar contato",
|
|
39
|
+
"status": "unmet", "reason": "2 fontes secas"},
|
|
40
|
+
],
|
|
41
|
+
"discoveries": [
|
|
42
|
+
{"id": "d1", "need": "b1", "surface": "home", "layer": "comportamento", "evidence": "verified",
|
|
43
|
+
"pattern_key": "scroll-mask-reveal", "observed": ["420ms"], "disqualifiers": [],
|
|
44
|
+
"scores": {"aderencia": 3, "execucao": 2, "custo_adaptacao": 2, "custo_performance": 1, "originalidade": 1, "total": 9},
|
|
45
|
+
"verdict": "ASSINATURA", "adaptation": ADAPTATION, "status": "selected"},
|
|
46
|
+
{"id": "d2", "need": "b1", "surface": "home", "layer": "comportamento", "evidence": "verified",
|
|
47
|
+
"pattern_key": "fade-up", "observed": ["fade"], "disqualifiers": ["reduced_motion"],
|
|
48
|
+
"verdict": "DESCARTAR", "status": "rejected", "reason": "ignora reduced motion"},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def state(**changes):
|
|
54
|
+
data = copy.deepcopy(BASE)
|
|
55
|
+
for key, value in changes.items():
|
|
56
|
+
data[key] = value
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def codes(data):
|
|
61
|
+
return {item["code"] for item in mod.validate(data, RUBRIC, CEILING)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_rubric_and_ceiling_are_read_from_the_skill_files():
|
|
65
|
+
assert RUBRIC["cut"] == 6 and "licenca" in RUBRIC["disqualifiers"]
|
|
66
|
+
assert CEILING[1] == 1 and CEILING[5] == 8
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_consistent_state_has_no_findings():
|
|
70
|
+
assert mod.validate(state(), RUBRIC, CEILING) == []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_verdict_must_match_the_band():
|
|
74
|
+
data = state()
|
|
75
|
+
data["discoveries"][0]["verdict"] = "FORTE"
|
|
76
|
+
assert "verdict_band" in codes(data)
|
|
77
|
+
data["discoveries"][0]["reason"] = RUBRIC["signature_cap_reason"]
|
|
78
|
+
assert "verdict_band" not in codes(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_entry_condition_forces_descartar_and_blocks_selection():
|
|
82
|
+
data = state()
|
|
83
|
+
data["discoveries"][1]["verdict"] = "UTILITARIO"
|
|
84
|
+
data["discoveries"][1]["status"] = "selected"
|
|
85
|
+
found = codes(data)
|
|
86
|
+
assert {"verdict_band", "selected_discarded"} <= found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_selected_below_the_need_floor_and_without_adaptation():
|
|
90
|
+
data = state()
|
|
91
|
+
disc = data["discoveries"][0]
|
|
92
|
+
disc["scores"] = {"aderencia": 2, "execucao": 1, "custo_adaptacao": 2, "custo_performance": 1, "originalidade": 0}
|
|
93
|
+
disc["verdict"] = "UTILITARIO"
|
|
94
|
+
disc["adaptation"] = {"padrao": "x"}
|
|
95
|
+
assert {"selected_below_floor", "adaptation_missing"} <= codes(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_two_signatures_on_one_surface_and_behaviour_ceiling():
|
|
99
|
+
data = state()
|
|
100
|
+
second = copy.deepcopy(data["discoveries"][0])
|
|
101
|
+
second.update(id="d3", pattern_key="cursor")
|
|
102
|
+
data["discoveries"].append(second)
|
|
103
|
+
assert {"signature_cap", "behaviour_ceiling"} <= codes(data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_reasons_and_dedup_links():
|
|
107
|
+
data = state()
|
|
108
|
+
data["needs"][2]["reason"] = None
|
|
109
|
+
data["discoveries"][1]["reason"] = None
|
|
110
|
+
data["discoveries"].append({"id": "d4", "need": "b1", "evidence": "verified", "observed": ["x"],
|
|
111
|
+
"pattern_key": "other", "dup_of": "d1", "status": "rejected", "reason": "dup"})
|
|
112
|
+
assert {"need_reason", "discovery_reason", "dup_of"} <= codes(data)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_gate_passes_selected_and_blocks_open_and_unplanned():
|
|
116
|
+
blocked, results = mod.gate(state(), ["home/hero"], [])
|
|
117
|
+
assert not blocked and results[0]["discoveries"] == ["d1"]
|
|
118
|
+
blocked, results = mod.gate(state(), ["home/projetos"], [])
|
|
119
|
+
assert blocked and results[0]["result"] == "blocked"
|
|
120
|
+
blocked, results = mod.gate(state(), ["home/rodape"], [])
|
|
121
|
+
assert blocked and results[0]["result"] == "unplanned"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_gate_lets_unmet_through_with_the_justification():
|
|
125
|
+
blocked, results = mod.gate(state(), [], ["s2"])
|
|
126
|
+
assert not blocked and "from scratch" in results[0]["action"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_cli_exit_codes(tmp_path, capsys):
|
|
130
|
+
from ruamel.yaml import YAML
|
|
131
|
+
|
|
132
|
+
path = tmp_path / "design-state.yaml"
|
|
133
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
134
|
+
YAML().dump(state(), handle)
|
|
135
|
+
with pytest.raises(SystemExit) as ok:
|
|
136
|
+
mod.main(["validate", str(path)])
|
|
137
|
+
assert ok.value.code == 0 and json.loads(capsys.readouterr().out)["ok"] is True
|
|
138
|
+
with pytest.raises(SystemExit) as blocked:
|
|
139
|
+
mod.main(["gate", str(path), "--section", "home/projetos"])
|
|
140
|
+
assert blocked.value.code == 1 and json.loads(capsys.readouterr().out)["gate"] == "blocked"
|
|
141
|
+
with pytest.raises(SystemExit) as missing:
|
|
142
|
+
mod.main(["validate", str(tmp_path / "nope.yaml")])
|
|
143
|
+
assert missing.value.code == 2
|
|
@@ -48,6 +48,24 @@ One hard typographic ceiling you currently miss:
|
|
|
48
48
|
**Gemini-specific defect: hard ban.** Never animate `<img>` elements on hover. This includes any `transform` on `:hover` of an image, AND `.group:hover .group-hover\:scale` / `.group:hover .group-hover\:rotate` / `.group:hover .group-hover\:translate` patterns from Tailwind that animate a child image via a parent hover. This is your single most common motion tell; it adds no information (the image isn't an action target) and reads as "AI animated this because it could". If a card needs hover feedback, animate the card's background, border, or shadow. Never the image, never via the image's parent. <!-- rule:skill-interaction-gemini-no-image-hover -->
|
|
49
49
|
</gemini>
|
|
50
50
|
|
|
51
|
+
#### Landing fine-tuning (Wizz addition, moved here from the landing Audit Protocol)
|
|
52
|
+
|
|
53
|
+
Run with real tokens, real typography and real images; these only show once the page is alive.
|
|
54
|
+
|
|
55
|
+
- **Spacing scale.** Section padding and margins follow one scale (4/8/12/16/24/32/48/64/96/128), not 24 here and 28 there.
|
|
56
|
+
- **Heading hierarchy.** H1, H2 and H3 do not compete: each step has clear size or weight contrast.
|
|
57
|
+
- **Leading.** Body `line-height` ≥ 1.5; display headlines ≤ 1.15.
|
|
58
|
+
- **Small uppercase tracking.** Mono and small-caps labels need more tracking (~0.18em).
|
|
59
|
+
- **Optical alignment.** Icons match the visual weight of adjacent text; mono numbers line up on a shared baseline.
|
|
60
|
+
- **Vertical rhythm.** No run of sections without air, no unexplained huge gap.
|
|
61
|
+
- **Colour hierarchy.** Five or more greys for text need a reason; the accent goes only on what is actionable.
|
|
62
|
+
- **Image cropping.** Thumbnails in one grid share an aspect ratio.
|
|
63
|
+
- **Weight contrast.** Heavy headings need a light or regular body to read against.
|
|
64
|
+
- **Content edge cases.** Check a 1-word and a 4-word project name, a 2-line and a 4-line description.
|
|
65
|
+
- **Tab order.** Focus follows the editorial sequence of the page, not a jump between sections.
|
|
66
|
+
|
|
67
|
+
Output of this check: up to 7 adjustments, each as a specific before/after (e.g. "Hero subhead: leading 1.1 → 1.3").
|
|
68
|
+
|
|
51
69
|
### New projects only (when no prior work exists)
|
|
52
70
|
|
|
53
71
|
#### Color & Theme
|
|
@@ -29,6 +29,8 @@ Use when you need **real interaction**.
|
|
|
29
29
|
- Framer Motion or Motion for scroll integration;
|
|
30
30
|
- GSAP (ScrollTrigger) when scroll orchestrates the scene — 100% free since v3.13, all plugins included.
|
|
31
31
|
|
|
32
|
+
**Reference for fluid effects:** when the brief asks for a fluid cursor trail, liquid distortion or ink-like smoke over a Three.js scene, `artcodev/three-fluid-fx` (MIT, npm `three-fluid-fx`, 0.1.0 on 2026-10-24) is a drop-in 2D Stable-Fluids solver with WebGL/GLSL and WebGPU/TSL pipelines. Pointer only: the method does not install it, and an early version means pinning the exact release and checking the mobile cost against the performance targets before adopting it.
|
|
33
|
+
|
|
32
34
|
**Examples:** a 3D object that follows the cursor; a mouse-reactive particle sphere; a camera that advances on scroll; real-time wireframe terrain; a configurable 3D product; an interactive WebGL background.
|
|
33
35
|
|
|
34
36
|
**Avoid if:**
|
|
@@ -86,7 +86,8 @@ LI_LeadGen_CMOs-SaaS_Whitepaper_Mar24
|
|
|
86
86
|
|
|
87
87
|
**Scaling phase:**
|
|
88
88
|
- Consolidate budget into winning combinations
|
|
89
|
-
-
|
|
89
|
+
- On Meta with a bid cap campaign, follow the order in `wizz-lowticket-metodologia` `knowledge/03-campanha-bidcap.md`: bid cap first, then validated creatives, then budget by the ROAS ruler
|
|
90
|
+
- Without a bid cap (other platforms, or a campaign not built on it yet): increase budgets 20-30% at a time
|
|
90
91
|
- Wait 3-5 days between increases for algorithm learning
|
|
91
92
|
|
|
92
93
|
---
|