handmade 0.3.0__py3-none-any.whl

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.
handmade/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Find what makes a UI look AI-generated and get the prompt that fixes it.
2
+
3
+ __version__ = "0.3.0"
handmade/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
handmade/brief.py ADDED
@@ -0,0 +1,287 @@
1
+ # The interview: learn what a designer needs to know, without guessing.
2
+ #
3
+ # 1. Read the request and keep only what it literally says (product, place, page type).
4
+ # 2. Ask only for what is still missing, with questions that fit the kind of product.
5
+ # 3. Push back once on vague answers, with examples that fit the product.
6
+ # 4. Remember the answers in handmade.system.json, so nobody is asked twice.
7
+ #
8
+ # Nothing is invented. A fact nobody gave stays missing and the prompt tells the AI
9
+ # to ask for it or leave a marked slot.
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+
17
+ # ---------- domains ----------
18
+ # Each domain: trigger words, specific questions (key, question, example answer) and audience examples.
19
+
20
+ @dataclass(frozen=True)
21
+ class Domain:
22
+ name: str
23
+ words: tuple[str, ...]
24
+ questions: tuple[tuple[str, str, str], ...]
25
+ audience_examples: str
26
+
27
+
28
+ DOMAINS: tuple[Domain, ...] = (
29
+ Domain("food", ("bakery", "cafe", "coffee", "restaurant", "food", "kitchen", "pizza", "butchery", "catering", "juice"),
30
+ (("ordering", "How do customers buy: walk in, order ahead for pickup, delivery or a mix?",
31
+ "walk in and WhatsApp orders for pickup"),
32
+ ("best_sellers", "What are your three best sellers? Add prices if you know them.",
33
+ "white bread KSh 65, queen cakes KSh 20, birthday cakes from KSh 1,500"),
34
+ ("hours", "When are you open?", "Monday to Saturday, 6am to 8pm")),
35
+ "students buying breakfast, families nearby, offices ordering for meetings"),
36
+ Domain("retail", ("shop", "store", "duka", "boutique", "hardware", "pharmacy", "supermarket", "kiosk", "electronics"),
37
+ (("catalogue", "Roughly how many products and in which main categories?", "about 200 items: phones, accessories, repairs"),
38
+ ("buying", "How do people pay and get their items?", "M-Pesa, then delivery in Nairobi or pickup"),
39
+ ("trust", "What makes people trust you over the shop next door?", "genuine products with a 6-month warranty")),
40
+ "repeat customers on phones, first-time buyers comparing prices"),
41
+ Domain("health", ("clinic", "hospital", "health", "pharmacy", "dental", "medical", "patient", "nurse", "doctor"),
42
+ (("users_tasks", "What is the one task staff do most often on this screen?", "see who is waiting and for how long"),
43
+ ("data", "Which numbers or records matter most?", "visits today, drugs below reorder level, staff on duty"),
44
+ ("constraints", "Any rules about privacy or devices?", "shared desktops, no patient names on the overview")),
45
+ "nurses at a reception desk, clinic managers on shared desktops"),
46
+ Domain("education", ("school", "college", "university", "student", "parent", "teacher", "course", "fees", "tuition"),
47
+ (("users_tasks", "What do people come here to do first?", "check the fee balance and pay"),
48
+ ("payments", "How are payments made and confirmed?", "M-Pesa paybill, receipt by SMS"),
49
+ ("calendar", "Which dates matter most to them?", "term opening dates and exam weeks")),
50
+ "parents on phones with slow data, students on campus Wi-Fi"),
51
+ Domain("finance", ("bank", "sacco", "loan", "savings", "insurance", "fintech", "wallet", "payments", "invest"),
52
+ (("products", "Which products or accounts do people use most?", "savings, development loans, emergency loans"),
53
+ ("trust", "What proves you are safe and regulated?", "licensed by SASRA, 12 years in operation"),
54
+ ("tasks", "What is the first thing a member wants to do?", "check their balance and loan limit")),
55
+ "matatu owners on Android phones, members checking balances monthly"),
56
+ Domain("government", ("county", "government", "ministry", "council", "public", "permit", "license", "tender", "citizen"),
57
+ (("services", "Which services do people come for most?", "business permits and land rates"),
58
+ ("steps", "What documents or steps does the main service need?", "ID, KRA PIN, payment by M-Pesa"),
59
+ ("languages", "Which languages must it support?", "English and Swahili")),
60
+ "small business owners, journalists, suppliers checking tenders"),
61
+ Domain("software", ("api", "saas", "developer", "sdk", "platform", "tool", "monitoring", "software", "app", "cloud"),
62
+ (("problem", "What problem does it solve, in one sentence a user would say?", "we find out about failed payments before customers do"),
63
+ ("proof", "What real proof can the page show: a demo, real numbers, customers who agreed to be named?",
64
+ "a live status page and two named fintech customers"),
65
+ ("pricing", "How is it priced?", "free up to 10,000 checks a month, then $49")),
66
+ "backend engineers at fintechs, CTOs comparing tools"),
67
+ Domain("transport", ("matatu", "transport", "logistics", "delivery", "courier", "taxi", "boda", "fleet", "travel"),
68
+ (("service", "What exactly is offered and where?", "parcel delivery within Nairobi, same day"),
69
+ ("pricing", "How is it priced?", "from KSh 250 per parcel by distance"),
70
+ ("tracking", "What do customers check most often?", "where their parcel is and when it arrives")),
71
+ "online sellers sending parcels, office workers ordering lunch"),
72
+ Domain("events", ("event", "meetup", "conference", "festival", "concert", "wedding", "workshop", "hackathon"),
73
+ (("when_where", "When and where is it?", "Saturday 14 March, Swahili Pot, Mombasa"),
74
+ ("program", "What happens there? Name sessions or speakers if known.", "4 talks, a panel and a build session"),
75
+ ("join", "How do people sign up and is it free?", "free, register on the page, 120 seats")),
76
+ "students and junior developers, mostly on phones"),
77
+ Domain("portfolio", ("portfolio", "resume", "cv", "personal site", "freelancer", "designer", "photographer"),
78
+ (("work", "Which 3 projects should people see first and what did you do on each?", "a delivery app I built end to end"),
79
+ ("role", "Which job or client are you aiming for?", "junior full-stack roles in Nairobi"),
80
+ ("contact", "How should people contact you?", "email and LinkedIn")),
81
+ "hiring managers skimming on laptops, recruiters on phones"),
82
+ Domain("news", ("news", "magazine", "blog", "article", "newsletter", "journal", "publication", "story"),
83
+ (("topic", "What is the piece or publication about?", "a long investigation into county water tenders"),
84
+ ("format", "Which features does it need: pull quotes, footnotes, charts, photos?", "pull quotes, footnotes and two charts"),
85
+ ("reading", "Where and how do people read it?", "on phones during commutes")),
86
+ "readers on phones during commutes, researchers on laptops"),
87
+ Domain("hospitality", ("hotel", "guest house", "airbnb", "lodge", "resort", "spa", "salon", "barber", "beauty"),
88
+ (("offer", "What do you offer, with prices if known?", "haircuts from KSh 300, braiding from KSh 1,500"),
89
+ ("booking", "How do people book?", "WhatsApp or walk in, no deposits"),
90
+ ("place", "Where are you and what makes the place special?", "in the city centre, open late")),
91
+ "students on phones, working people booking for the weekend"),
92
+ Domain("agriculture", ("farm", "farmer", "agri", "crop", "dairy", "maize", "coffee farm", "cooperative", "livestock"),
93
+ (("users_tasks", "What do farmers or buyers come here to do?", "check this week's prices"),
94
+ ("data", "Which numbers matter most?", "price per kg by market and the change from last week"),
95
+ ("access", "How do they get online?", "basic Android phones, often on 3G")),
96
+ "smallholder farmers on basic phones, extension officers"),
97
+ )
98
+
99
+ GENERIC_DOMAIN = Domain("general", (), (("goal", "What should a person be able to do on this screen?", "book an appointment"),), "")
100
+
101
+
102
+ def detect_domain(text: str) -> Domain:
103
+ low = text.lower()
104
+ best, best_hits = GENERIC_DOMAIN, 0
105
+ for d in DOMAINS:
106
+ hits = sum(1 for w in d.words if re.search(rf"\b{re.escape(w)}s?\b", low))
107
+ if hits > best_hits:
108
+ best, best_hits = d, hits
109
+ return best
110
+
111
+
112
+ # ---------- reading the request ----------
113
+
114
+ PLACE = re.compile(r"\b(?:in|at|near|around|based in|from)\s+((?:[A-Z][\w'’-]+)(?:\s+(?:[A-Z][\w'’-]+|Town|City))*)")
115
+ PRODUCT = re.compile(
116
+ r"\bfor\s+(?:(?:a|an|my|our|the|this)\s+)?((?:[a-z][\w'’-]*\s+){0,5}?[a-z][\w'’-]*)"
117
+ r"(?=\s+(?:in|at|near|that|which|who|with|where|so|for|to|and)\b|[,.;:!?]|$)", re.I)
118
+
119
+
120
+ def extract(request: str) -> dict[str, str]:
121
+ # Facts the request states literally. Nothing is inferred.
122
+ facts: dict[str, str] = {}
123
+ m = PRODUCT.search(request)
124
+ if m:
125
+ product = m.group(1).strip()
126
+ if product.lower() not in ("me", "us", "it", "them", "this", "that"):
127
+ facts["product"] = product
128
+ m = PLACE.search(request)
129
+ if m:
130
+ facts["place"] = m.group(1).strip()
131
+ return facts
132
+
133
+
134
+ # ---------- vague answers ----------
135
+
136
+ VAGUE_WORDS = {
137
+ "users", "user", "anyone", "everyone", "everybody", "people", "customers", "clients", "all", "public",
138
+ "general public", "general", "amazing", "awesome", "nice", "good", "great", "cool", "modern", "beautiful",
139
+ "best", "premium", "clean", "simple", "professional", "attractive", "stunning", "perfect", "wow",
140
+ "anything", "whatever", "idk", "not sure", "n/a", "na", "none", "nothing", "very", "and", "a", "the",
141
+ "screen", "page", "landing", "website", "site", "app", "design", "look", "looks", "looking", "it",
142
+ }
143
+
144
+
145
+ def is_vague(answer: str) -> bool:
146
+ words = re.findall(r"[a-zA-Z'’]+", answer.lower())
147
+ if not words:
148
+ return True
149
+ specific = [w for w in words if w not in VAGUE_WORDS]
150
+ return len(specific) == 0 or (len(words) <= 2 and len(specific) <= 1 and len(specific[0]) < 4)
151
+
152
+
153
+ # ---------- the interview ----------
154
+
155
+ MOOD_CHOICES = ("editorial", "utilitarian", "warm", "playful", "technical", "luxury", "civic")
156
+
157
+
158
+ @dataclass
159
+ class Question:
160
+ key: str
161
+ text: str
162
+ example: str
163
+ follow_up: str
164
+ optional: bool = False
165
+
166
+
167
+ @dataclass
168
+ class Facts:
169
+ values: dict[str, str] = field(default_factory=dict)
170
+ vague: dict[str, str] = field(default_factory=dict) # key -> what the user said, when still vague
171
+ source: dict[str, str] = field(default_factory=dict) # key -> request, answer, saved
172
+
173
+ def get(self, key: str) -> str:
174
+ return self.values.get(key, "")
175
+
176
+ def to_dict(self) -> dict:
177
+ return {"answers": dict(self.values), "vague": dict(self.vague)}
178
+
179
+ @classmethod
180
+ def from_dict(cls, data: dict) -> "Facts":
181
+ f = cls()
182
+ for k, v in (data or {}).get("answers", {}).items():
183
+ f.values[k] = v
184
+ f.source[k] = "saved"
185
+ f.vague = dict((data or {}).get("vague", {}))
186
+ return f
187
+
188
+
189
+ def plan(request: str, facts: Facts, intent: str, suggested_mood: str, mood_reason: str = "") -> list[Question]:
190
+ # The questions still worth asking, in the order a designer would ask them.
191
+ domain = detect_domain(" ".join([request, facts.get("product")]))
192
+ place = facts.get("place")
193
+ where = f" in {place}" if place else ""
194
+ qs: list[Question] = []
195
+ if not facts.get("product"):
196
+ qs.append(Question("product", "What is it and what does it do? One line.",
197
+ "a family bakery that sells bread and celebration cakes",
198
+ "Name the business or product and what it offers."))
199
+ if not facts.get("audience"):
200
+ example = domain.audience_examples or "who uses it most and on what device"
201
+ qs.append(Question("audience", f"Who uses it most{where} and on what device?", example,
202
+ f"Be specific about who uses it most. For example: {example}."))
203
+ goal_q = {
204
+ "landing": ("goal", "What should a visitor do after reading this page?", "order a cake on WhatsApp"),
205
+ "ecommerce": ("goal", "What should a shopper be able to do here?", "find a product, see the price, pay with M-Pesa"),
206
+ "dashboard": ("goal", "What decision should this dashboard help someone make each day?", "what to reorder before stock runs out"),
207
+ "form": ("goal", "What happens after someone submits it?", "they get a member number by SMS"),
208
+ "docs": ("goal", "What should a reader be able to do after reading?", "read the weekly price report correctly"),
209
+ "portfolio": ("goal", "What should a visitor do after looking?", "invite you to an interview"),
210
+ "app": ("goal", "What is the main task on this screen?", "pay this term's fees"),
211
+ }[intent]
212
+ if not facts.get("goal"):
213
+ qs.append(Question(goal_q[0], goal_q[1], goal_q[2], f"Name one concrete action. For example: {goal_q[2]}."))
214
+ for key, text, example in domain.questions:
215
+ if not facts.get(key):
216
+ qs.append(Question(key, text, example, f"A specific answer helps most. For example: {example}.", optional=True))
217
+ if not facts.get("mood"):
218
+ qs.append(Question("mood", f"Which mood fits? {', '.join(MOOD_CHOICES)}. Press Enter for {suggested_mood}"
219
+ + (f" ({mood_reason})." if mood_reason else "."),
220
+ suggested_mood, "", optional=True))
221
+ if not facts.get("reference"):
222
+ qs.append(Question("reference", "Is there a website, magazine, place or object whose feel you like?",
223
+ "the chalkboard menus in old Nairobi bakeries", "", optional=True))
224
+ return qs
225
+
226
+
227
+ Asker = Callable[[str], str]
228
+
229
+
230
+ def interview(request: str, facts: Facts, intent: str, suggested_mood: str, ask: Asker,
231
+ say: Callable[[str], None], mood_reason: str = "") -> Facts:
232
+ # Ask the planned questions. One follow-up for vague answers. Empty answers stay missing.
233
+ for q in plan(request, facts, intent, suggested_mood, mood_reason):
234
+ answer = ask(f"{q.text}\n e.g. {q.example}\n> ").strip()
235
+ if q.key == "mood":
236
+ if not answer:
237
+ facts.values["mood"] = suggested_mood
238
+ facts.source["mood"] = "suggested, accepted"
239
+ elif answer.lower() in MOOD_CHOICES:
240
+ facts.values["mood"] = answer.lower()
241
+ facts.source["mood"] = "answer"
242
+ else:
243
+ say(f" '{answer}' is not one of the moods. Using {suggested_mood}; change it with --mood.")
244
+ facts.values["mood"] = suggested_mood
245
+ facts.source["mood"] = "suggested, accepted"
246
+ continue
247
+ if not answer:
248
+ continue
249
+ if is_vague(answer):
250
+ better = ask(f" {q.follow_up}\n> ").strip() if q.follow_up else ""
251
+ if better and not is_vague(better):
252
+ answer = better
253
+ else:
254
+ facts.vague[q.key] = better or answer
255
+ continue
256
+ facts.values[q.key] = answer
257
+ facts.source[q.key] = "answer"
258
+ return facts
259
+
260
+
261
+ QUESTION_LABELS = {
262
+ "product": "What it is", "audience": "Who uses it and on what device", "goal": "What a person should do here",
263
+ "place": "Where it is", "mood": "Mood", "reference": "A reference the user likes",
264
+ "ordering": "How customers buy", "best_sellers": "Best sellers", "hours": "Opening hours",
265
+ "catalogue": "Products and categories", "buying": "How people pay and collect", "trust": "Why people trust them",
266
+ "users_tasks": "The most common task", "data": "The numbers that matter most", "constraints": "Privacy and device rules",
267
+ "payments": "How payments work", "calendar": "Key dates", "products": "Main products", "tasks": "The first task",
268
+ "services": "Main services", "steps": "Documents and steps", "languages": "Languages",
269
+ "problem": "The problem it solves", "proof": "Real proof to show", "pricing": "Pricing",
270
+ "service": "What is offered and where", "tracking": "What customers track", "when_where": "When and where",
271
+ "program": "Program", "join": "How to join", "work": "Projects to show", "role": "Target role",
272
+ "contact": "Contact", "topic": "Topic", "format": "Features needed", "reading": "Where people read it",
273
+ "offer": "What is offered", "booking": "How people book", "access": "How people get online",
274
+ }
275
+
276
+
277
+ def missing_facts(request: str, facts: Facts, intent: str, suggested_mood: str) -> list[Question]:
278
+ # Facts nobody gave. The prompt asks the AI to get them from the user, never to invent them.
279
+ return [q for q in plan(request, facts, intent, suggested_mood) if q.key not in ("mood", "reference")]
280
+
281
+
282
+ def questions_for_ai(request: str, facts: Facts, intent: str, suggested_mood: str) -> list[dict]:
283
+ # The open questions as data, for an AI tool to ask the user in its own words.
284
+ out = []
285
+ for q in plan(request, facts, intent, suggested_mood):
286
+ out.append({"key": q.key, "question": q.text, "example": q.example, "required": not q.optional})
287
+ return out
handmade/check.py ADDED
@@ -0,0 +1,112 @@
1
+ # The gate: one PASS or FAIL that combines the AI-default scan and design-system conformance.
2
+ #
3
+ # Used by `handmade check`, by the MCP tool handmade_check and by the agent skill.
4
+ # An AI agent is not done with UI work until this returns PASS.
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .conform import PASS_THRESHOLDS, Conformance, check_conformance, find_system, load_system
12
+ from .extract import load_docs
13
+ from .profile import build_profile
14
+ from .scan import Report, scan
15
+ from .tells import by_id
16
+
17
+ DEFAULT_MIN_SCORE = 85
18
+ # Signature AI defaults: any one of these fails the gate, whatever the score.
19
+ HARD_TELLS = {
20
+ "color.purple-gradient", "color.indigo-accent", "color.neon-glow", "color.tasteful-default",
21
+ "type.gradient-text", "type.generic-font", "type.trend-font", "deco.glassmorphism", "deco.stock-effects",
22
+ "deco.emoji-icons", "deco.blur-blob", "layout.centered-hero",
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class CheckResult:
28
+ passed: bool
29
+ report: Report
30
+ conformance: Conformance | None
31
+ system_path: str | None
32
+ min_score: int
33
+ rendered: bool = False
34
+
35
+ def to_dict(self) -> dict:
36
+ return {
37
+ "passed": self.passed,
38
+ "min_score": self.min_score,
39
+ "score": self.report.score,
40
+ "verdict": self.report.verdict,
41
+ "rendered": self.rendered,
42
+ "system": self.system_path,
43
+ "conformance": self.conformance.to_dict() if self.conformance else None,
44
+ "findings": [f.__dict__ for f in self.report.findings],
45
+ }
46
+
47
+ def text(self, max_items: int = 12) -> str:
48
+ tells = by_id()
49
+ lines = [f"handmade check: {'PASS' if self.passed else 'FAIL'}", ""]
50
+ hard = sorted({tells[f.tell].title for f in self.report.findings if f.tell in HARD_TELLS})
51
+ mark = "ok " if self.report.score >= self.min_score and not hard else "FAIL"
52
+ lines.append(f"{mark} AI defaults: score {self.report.score}/100 ({self.report.verdict}), "
53
+ f"needs {self.min_score} and no signature defaults")
54
+ if hard:
55
+ lines.append(f" Signature AI defaults found (each one fails the check): {', '.join(hard)}")
56
+ shown = 0
57
+ for f in sorted(self.report.findings, key=lambda f: -tells[f.tell].weight):
58
+ if shown >= max_items:
59
+ lines.append(f" ... {len(self.report.findings) - shown} more, run `handmade scan -v`")
60
+ break
61
+ where = f"{f.file}:{f.line}" if f.line else f.file
62
+ lines.append(f" - {tells[f.tell].title}: {where} {f.detail}. Fix: {tells[f.tell].fix}")
63
+ shown += 1
64
+ lines.append("")
65
+ if self.conformance is None:
66
+ lines.append("skip Design system: no handmade.system.json found, so only AI defaults were checked.")
67
+ lines.append(" Create one with: handmade prompt \"...\" --save-system handmade.system.json")
68
+ else:
69
+ lines.append(f"Design system: {self.system_path}")
70
+ for cat, need in PASS_THRESHOLDS.items():
71
+ pct = self.conformance.percent(cat)
72
+ seen = self.conformance.seen.get(cat, 0)
73
+ mark = "ok " if pct >= need else "FAIL"
74
+ hard_here = [o for o in self.conformance.hard_offenders if o.category == cat]
75
+ mark = "ok " if pct >= need and not hard_here else "FAIL"
76
+ extra = " (any off-system color with a hue or any off-system font, fails)" if hard_here else ""
77
+ lines.append(f"{mark} {cat:<11} {pct:>3}% from the system ({seen} values), needs {need}%{extra}")
78
+ offs = [o for o in self.conformance.offenders if o.category == cat]
79
+ for o in offs[:5]:
80
+ times = f" x{o.count}" if o.count > 1 else ""
81
+ lines.append(f" - {o.value}{times} at {o.file}:{o.line}")
82
+ if len(offs) > 5:
83
+ lines.append(f" ... {len(offs) - 5} more off-system values")
84
+ lines.append("")
85
+ if self.passed:
86
+ lines.append("Done: no AI defaults above the threshold and everything comes from the design system.")
87
+ else:
88
+ lines.append("NOT DONE. Fix every FAIL above, then run handmade check again.")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def run_check(path: str | Path, system_path: str | Path | None = None, min_score: int = DEFAULT_MIN_SCORE,
93
+ ignore: set[str] | None = None, render: bool = False) -> CheckResult:
94
+ if render:
95
+ from .render import render_scan # optional dependency (Playwright)
96
+ report = render_scan(path, ignore=ignore)
97
+ else:
98
+ report = scan(path, ignore=ignore)
99
+ sys_file = Path(system_path) if system_path else find_system(path)
100
+ conformance = None
101
+ if sys_file is not None:
102
+ system = load_system(sys_file)
103
+ if render:
104
+ from .render import render_conformance
105
+ conformance = render_conformance(path, system)
106
+ else:
107
+ docs = load_docs(path)
108
+ conformance = check_conformance(docs, build_profile(docs), system)
109
+ hard = [f for f in report.findings if f.tell in HARD_TELLS]
110
+ passed = report.score >= min_score and not hard and (conformance is None or conformance.passed)
111
+ return CheckResult(passed=passed, report=report, conformance=conformance,
112
+ system_path=str(sys_file) if sys_file else None, min_score=min_score, rendered=render)