synorch 0.1.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -0
  4. package/dist/application/doctor-service.d.ts +24 -0
  5. package/dist/application/doctor-service.d.ts.map +1 -0
  6. package/dist/application/doctor-service.js +508 -0
  7. package/dist/application/doctor-service.js.map +1 -0
  8. package/dist/application/project-discovery.d.ts +23 -0
  9. package/dist/application/project-discovery.d.ts.map +1 -0
  10. package/dist/application/project-discovery.js +741 -0
  11. package/dist/application/project-discovery.js.map +1 -0
  12. package/dist/application/skill-resolver.d.ts +25 -0
  13. package/dist/application/skill-resolver.d.ts.map +1 -0
  14. package/dist/application/skill-resolver.js +90 -0
  15. package/dist/application/skill-resolver.js.map +1 -0
  16. package/dist/application/structure-service.d.ts +11 -0
  17. package/dist/application/structure-service.d.ts.map +1 -0
  18. package/dist/application/structure-service.js +116 -0
  19. package/dist/application/structure-service.js.map +1 -0
  20. package/dist/cli.d.ts +3 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +170 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/domain/config.d.ts +210 -0
  25. package/dist/domain/config.d.ts.map +1 -0
  26. package/dist/domain/config.js +108 -0
  27. package/dist/domain/config.js.map +1 -0
  28. package/dist/domain/errors.d.ts +5 -0
  29. package/dist/domain/errors.d.ts.map +1 -0
  30. package/dist/domain/errors.js +9 -0
  31. package/dist/domain/errors.js.map +1 -0
  32. package/dist/domain/generation.d.ts +21 -0
  33. package/dist/domain/generation.d.ts.map +1 -0
  34. package/dist/domain/generation.js +2 -0
  35. package/dist/domain/generation.js.map +1 -0
  36. package/dist/domain/product.d.ts +3 -0
  37. package/dist/domain/product.d.ts.map +1 -0
  38. package/dist/domain/product.js +3 -0
  39. package/dist/domain/product.js.map +1 -0
  40. package/dist/domain/skill-packs.d.ts +39 -0
  41. package/dist/domain/skill-packs.d.ts.map +1 -0
  42. package/dist/domain/skill-packs.js +98 -0
  43. package/dist/domain/skill-packs.js.map +1 -0
  44. package/dist/domain/skill-sources.d.ts +20 -0
  45. package/dist/domain/skill-sources.d.ts.map +1 -0
  46. package/dist/domain/skill-sources.js +95 -0
  47. package/dist/domain/skill-sources.js.map +1 -0
  48. package/dist/infrastructure/bundled-skill-library.d.ts +10 -0
  49. package/dist/infrastructure/bundled-skill-library.d.ts.map +1 -0
  50. package/dist/infrastructure/bundled-skill-library.js +108 -0
  51. package/dist/infrastructure/bundled-skill-library.js.map +1 -0
  52. package/dist/infrastructure/file-system.d.ts +21 -0
  53. package/dist/infrastructure/file-system.d.ts.map +1 -0
  54. package/dist/infrastructure/file-system.js +72 -0
  55. package/dist/infrastructure/file-system.js.map +1 -0
  56. package/dist/infrastructure/serialization.d.ts +3 -0
  57. package/dist/infrastructure/serialization.d.ts.map +1 -0
  58. package/dist/infrastructure/serialization.js +11 -0
  59. package/dist/infrastructure/serialization.js.map +1 -0
  60. package/dist/templates/structure-templates.d.ts +4 -0
  61. package/dist/templates/structure-templates.d.ts.map +1 -0
  62. package/dist/templates/structure-templates.js +471 -0
  63. package/dist/templates/structure-templates.js.map +1 -0
  64. package/dist/templates/technology-skill-templates.d.ts +3 -0
  65. package/dist/templates/technology-skill-templates.d.ts.map +1 -0
  66. package/dist/templates/technology-skill-templates.js +73 -0
  67. package/dist/templates/technology-skill-templates.js.map +1 -0
  68. package/package.json +59 -0
  69. package/skill-sources/ingenium/NOTICE.md +11 -0
  70. package/skill-sources/ingenium/skills/db-schema-craft/SKILL.md +127 -0
  71. package/skill-sources/ingenium/skills/debug-detective/SKILL.md +67 -0
  72. package/skill-sources/ingenium/skills/design-system/SKILL.md +57 -0
  73. package/skill-sources/ingenium/skills/docs-sync/SKILL.md +68 -0
  74. package/skill-sources/ingenium/skills/dotnet-backend/SKILL.md +110 -0
  75. package/skill-sources/ingenium/skills/frontend-craft/SKILL.md +69 -0
  76. package/skill-sources/ingenium/skills/game-audio/SKILL.md +73 -0
  77. package/skill-sources/ingenium/skills/game-design/SKILL.md +92 -0
  78. package/skill-sources/ingenium/skills/godot-dev/SKILL.md +78 -0
  79. package/skill-sources/ingenium/skills/human-made-design/SKILL.md +73 -0
  80. package/skill-sources/ingenium/skills/java-backend/SKILL.md +98 -0
  81. package/skill-sources/ingenium/skills/jev/SKILL.md +150 -0
  82. package/skill-sources/ingenium/skills/motion-craft/SKILL.md +66 -0
  83. package/skill-sources/ingenium/skills/multiplayer-netcode/SKILL.md +63 -0
  84. package/skill-sources/ingenium/skills/node-backend/SKILL.md +113 -0
  85. package/skill-sources/ingenium/skills/node-backend/reference.md +144 -0
  86. package/skill-sources/ingenium/skills/perf-audit/SKILL.md +70 -0
  87. package/skill-sources/ingenium/skills/pixel-art-assets/SKILL.md +91 -0
  88. package/skill-sources/ingenium/skills/pixel-art-assets/scripts/px.py +169 -0
  89. package/skill-sources/ingenium/skills/pixel-game-dev/SKILL.md +79 -0
  90. package/skill-sources/ingenium/skills/project-onboard/SKILL.md +78 -0
  91. package/skill-sources/ingenium/skills/pwa-offline/SKILL.md +70 -0
  92. package/skill-sources/ingenium/skills/query-tuning/SKILL.md +137 -0
  93. package/skill-sources/ingenium/skills/react-modern/SKILL.md +92 -0
  94. package/skill-sources/ingenium/skills/refactor-safe/SKILL.md +63 -0
  95. package/skill-sources/ingenium/skills/release-prep/SKILL.md +57 -0
  96. package/skill-sources/ingenium/skills/safe-merge/SKILL.md +91 -0
  97. package/skill-sources/ingenium/skills/session-recap/SKILL.md +95 -0
  98. package/skill-sources/ingenium/skills/session-recap/scripts/extract_session.py +409 -0
  99. package/skill-sources/ingenium/skills/shader-vfx/SKILL.md +70 -0
  100. package/skill-sources/ingenium/skills/tailwind-v4-tokens/SKILL.md +165 -0
  101. package/skill-sources/ingenium/skills/task-conductor/SKILL.md +158 -0
  102. package/skill-sources/ingenium/skills/tauri-game-dev/SKILL.md +81 -0
  103. package/skill-sources/ingenium/skills/ui-ux-design/SKILL.md +96 -0
  104. package/skill-sources/ingenium/skills/vue-modern/SKILL.md +88 -0
  105. package/skill-sources/ingenium/skills/web-kickoff/SKILL.md +63 -0
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env python3
2
+ """Distill Claude Code session transcripts into a resumable digest.
3
+
4
+ Reads ~/.claude/projects/<encoded-cwd>/*.jsonl without loading transcripts into
5
+ the model's context. Stdlib only.
6
+
7
+ python extract_session.py --list
8
+ python extract_session.py --session latest
9
+ python extract_session.py --session <session-id> --json
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from collections import Counter, OrderedDict
18
+
19
+ EDIT_TOOLS = ("Edit", "Write", "NotebookEdit", "MultiEdit")
20
+ SHELL_TOOLS = ("Bash", "PowerShell")
21
+ RESEARCH_TOOLS = ("WebSearch", "WebFetch")
22
+ INTERRUPT_MARK = "[Request interrupted by user"
23
+ NOISE_PREFIXES = ("<local-command", "<command-name>", "<command-message>")
24
+
25
+
26
+ def projects_root():
27
+ return os.path.join(os.path.expanduser("~"), ".claude", "projects")
28
+
29
+
30
+ def encode_cwd(path):
31
+ return re.sub(r"[^a-zA-Z0-9]", "-", path)
32
+
33
+
34
+ def resolve_project_dir(cwd):
35
+ root = projects_root()
36
+ direct = os.path.join(root, encode_cwd(os.path.abspath(cwd)))
37
+ if os.path.isdir(direct):
38
+ return direct
39
+ target = os.path.abspath(cwd).rstrip("\\/").lower()
40
+ if not os.path.isdir(root):
41
+ return None
42
+ for name in os.listdir(root):
43
+ cand = os.path.join(root, name)
44
+ if not os.path.isdir(cand):
45
+ continue
46
+ for f in transcript_files(cand)[:1]:
47
+ for rec in read_records(f, limit=40):
48
+ rc = rec.get("cwd")
49
+ if rc and rc.rstrip("\\/").lower() == target:
50
+ return cand
51
+ return None
52
+
53
+
54
+ def transcript_files(project_dir):
55
+ """All transcripts, newest first."""
56
+ files = [
57
+ os.path.join(project_dir, n)
58
+ for n in os.listdir(project_dir)
59
+ if n.endswith(".jsonl")
60
+ ]
61
+ return sorted(files, key=os.path.getmtime, reverse=True)
62
+
63
+
64
+ def seconds_idle(path):
65
+ import time
66
+
67
+ return time.time() - os.path.getmtime(path)
68
+
69
+
70
+ def read_records(path, limit=None):
71
+ out = []
72
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
73
+ for line in fh:
74
+ line = line.strip()
75
+ if not line:
76
+ continue
77
+ try:
78
+ out.append(json.loads(line))
79
+ except ValueError:
80
+ continue
81
+ if limit and len(out) >= limit:
82
+ break
83
+ return out
84
+
85
+
86
+ def blocks(rec):
87
+ msg = rec.get("message")
88
+ if not isinstance(msg, dict):
89
+ return []
90
+ content = msg.get("content")
91
+ if isinstance(content, list):
92
+ return [b for b in content if isinstance(b, dict)]
93
+ return []
94
+
95
+
96
+ def text_of(rec):
97
+ msg = rec.get("message")
98
+ if not isinstance(msg, dict):
99
+ return ""
100
+ content = msg.get("content")
101
+ if isinstance(content, str):
102
+ return content
103
+ return " ".join(b.get("text", "") for b in blocks(rec) if b.get("type") == "text")
104
+
105
+
106
+ def is_real_prompt(rec):
107
+ if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
108
+ return False
109
+ if rec.get("promptSource") not in ("typed", "queued", None):
110
+ return False
111
+ txt = text_of(rec).strip()
112
+ if not txt or txt.startswith(NOISE_PREFIXES):
113
+ return False
114
+ return rec.get("promptSource") in ("typed", "queued")
115
+
116
+
117
+ def squeeze(text, limit):
118
+ text = " ".join(text.split())
119
+ return text if len(text) <= limit else text[: limit - 1] + "…"
120
+
121
+
122
+ def rel(path, cwd):
123
+ if not path:
124
+ return path
125
+ norm = path.replace("/", os.sep).replace("\\", os.sep)
126
+ base = os.path.abspath(cwd).replace("/", os.sep).replace("\\", os.sep)
127
+ if norm.lower().startswith(base.lower()):
128
+ norm = norm[len(base) :].lstrip("\\/")
129
+ return norm.replace("\\", "/")
130
+
131
+
132
+ def summarize(path, cwd, prompt_chars=400, cap=25):
133
+ recs = read_records(path)
134
+ stamps = [r["timestamp"] for r in recs if r.get("timestamp")]
135
+ title = next(
136
+ (r["aiTitle"] for r in reversed(recs) if r.get("type") == "ai-title" and r.get("aiTitle")),
137
+ None,
138
+ )
139
+ session_cwd = next((r["cwd"] for r in recs if r.get("cwd")), cwd)
140
+ branches = OrderedDict(
141
+ (r["gitBranch"], None) for r in recs if r.get("gitBranch")
142
+ )
143
+
144
+ prompts, edits, shell, research, skills, agents = [], Counter(), [], [], [], []
145
+ errors, denials, interrupts = [], [], 0
146
+ last_assistant = ""
147
+ tool_names = {}
148
+
149
+ def tool_of(rec):
150
+ """Resolve which tool a tool_result block belongs to."""
151
+ for blk in blocks(rec):
152
+ tid = blk.get("tool_use_id")
153
+ if tid and tid in tool_names:
154
+ return tool_names[tid]
155
+ return None
156
+
157
+ for rec in recs:
158
+ if is_real_prompt(rec):
159
+ txt = text_of(rec)
160
+ if INTERRUPT_MARK in txt:
161
+ interrupts += 1
162
+ prompts.append(squeeze(txt, prompt_chars))
163
+ if rec.get("toolDenialKind"):
164
+ denials.append(
165
+ "%s (%s)" % (tool_of(rec) or "unknown tool", rec["toolDenialKind"])
166
+ )
167
+ if rec.get("interruptedMessageId"):
168
+ interrupts += 1
169
+ if rec.get("type") == "file-history-delta" and rec.get("trackingPath"):
170
+ edits[rel(rec["trackingPath"], session_cwd)] += 0
171
+
172
+ msg = rec.get("message") or {}
173
+ role = msg.get("role") if isinstance(msg, dict) else None
174
+ if role == "assistant" and not rec.get("isSidechain"):
175
+ t = text_of(rec).strip()
176
+ if t:
177
+ last_assistant = t
178
+ for blk in blocks(rec):
179
+ kind = blk.get("type")
180
+ if kind == "tool_result" and blk.get("is_error"):
181
+ body = blk.get("content")
182
+ if isinstance(body, list):
183
+ body = " ".join(
184
+ b.get("text", "") for b in body if isinstance(b, dict)
185
+ )
186
+ who = tool_names.get(blk.get("tool_use_id")) or "tool"
187
+ errors.append("%s: %s" % (who, squeeze(str(body), 180)))
188
+ if kind != "tool_use":
189
+ continue
190
+ name, inp = blk.get("name"), blk.get("input") or {}
191
+ if blk.get("id"):
192
+ tool_names[blk["id"]] = name
193
+ if name in EDIT_TOOLS:
194
+ fp = inp.get("file_path") or inp.get("notebook_path")
195
+ if fp:
196
+ edits[rel(fp, session_cwd)] += 1
197
+ elif name in SHELL_TOOLS:
198
+ cmd = squeeze(str(inp.get("command", "")), 160)
199
+ if cmd:
200
+ shell.append(cmd)
201
+ elif name in RESEARCH_TOOLS:
202
+ research.append(
203
+ "%s: %s" % (name, squeeze(str(inp.get("query") or inp.get("url") or ""), 160))
204
+ )
205
+ elif name == "Skill":
206
+ if inp.get("skill"):
207
+ skills.append(inp["skill"])
208
+ elif name == "Agent":
209
+ agents.append(squeeze(str(inp.get("description") or ""), 90))
210
+
211
+ return {
212
+ "session_id": os.path.basename(path)[:-6],
213
+ "file": path,
214
+ "title": title,
215
+ "cwd": session_cwd,
216
+ "started": stamps[0] if stamps else None,
217
+ "ended": stamps[-1] if stamps else None,
218
+ "branches": list(branches),
219
+ "prompts": prompts,
220
+ "files": [
221
+ {"path": p, "edits": n} for p, n in sorted(edits.items(), key=lambda kv: -kv[1])
222
+ ],
223
+ "commands": dedupe(shell)[:cap],
224
+ "research": dedupe(research)[:cap],
225
+ "skills": dedupe(skills),
226
+ "agents": dedupe(agents)[:cap],
227
+ "errors": dedupe(errors)[:cap],
228
+ "denials": denials,
229
+ "interrupts": interrupts,
230
+ "last_assistant": squeeze(last_assistant, 700),
231
+ "record_count": len(recs),
232
+ }
233
+
234
+
235
+ def dedupe(items):
236
+ return list(OrderedDict((i, None) for i in items))
237
+
238
+
239
+ def brief(path, cwd):
240
+ """Cheap listing entry."""
241
+ d = summarize(path, cwd, prompt_chars=110, cap=1)
242
+ return {
243
+ "session_id": d["session_id"],
244
+ "title": d["title"],
245
+ "started": d["started"],
246
+ "ended": d["ended"],
247
+ "prompts": len(d["prompts"]),
248
+ "files": len(d["files"]),
249
+ "first_prompt": d["prompts"][0] if d["prompts"] else "",
250
+ }
251
+
252
+
253
+ def render(d):
254
+ L = ["# Session digest — %s" % (d["title"] or d["session_id"])]
255
+ L.append("")
256
+ L.append("- session: `%s`" % d["session_id"])
257
+ L.append("- window: %s → %s" % (d["started"], d["ended"]))
258
+ L.append("- cwd: `%s`" % d["cwd"])
259
+ if d["branches"]:
260
+ L.append("- branch(es): %s" % ", ".join("`%s`" % b for b in d["branches"]))
261
+ L.append("- records: %d" % d["record_count"])
262
+
263
+ def block(head, items, fmt=lambda x: "- %s" % x):
264
+ if not items:
265
+ return
266
+ L.append("")
267
+ L.append("## %s" % head)
268
+ L.extend(fmt(i) for i in items)
269
+
270
+ block("User prompts (verbatim intent, in order)", list(enumerate(d["prompts"], 1)),
271
+ lambda t: "%d. %s" % t)
272
+ block("Files touched", d["files"],
273
+ lambda f: "- `%s`%s" % (f["path"], "" if not f["edits"] else " (%d edit calls)" % f["edits"]))
274
+ block("Research", d["research"])
275
+ block("Skills loaded", d["skills"])
276
+ block("Subagents", d["agents"])
277
+ block("Commands run", d["commands"])
278
+ block("Tool errors (dead-end candidates)", d["errors"])
279
+ if d["denials"] or d["interrupts"]:
280
+ L.append("")
281
+ L.append("## Friction signals")
282
+ if d["denials"]:
283
+ L.append("- denied tool calls: %s" % ", ".join(d["denials"]))
284
+ if d["interrupts"]:
285
+ L.append("- user interruptions: %d" % d["interrupts"])
286
+ if d["last_assistant"]:
287
+ L.append("")
288
+ L.append("## Final assistant state")
289
+ L.append(d["last_assistant"])
290
+ return "\n".join(L)
291
+
292
+
293
+ def force_utf8():
294
+ for stream in (sys.stdout, sys.stderr):
295
+ try:
296
+ stream.reconfigure(encoding="utf-8", errors="replace")
297
+ except (AttributeError, ValueError):
298
+ pass
299
+
300
+
301
+ def main():
302
+ force_utf8()
303
+ ap = argparse.ArgumentParser()
304
+ ap.add_argument("--cwd", default=os.getcwd())
305
+ ap.add_argument("--list", action="store_true")
306
+ ap.add_argument("--session", help="'latest' or a session id")
307
+ ap.add_argument("--limit", type=int, default=10)
308
+ ap.add_argument("--prompt-chars", type=int, default=400)
309
+ ap.add_argument("--json", action="store_true")
310
+ ap.add_argument(
311
+ "--exclude-session",
312
+ action="append",
313
+ default=[],
314
+ help="session id to leave out of 'latest' selection (repeatable) — use for the live session",
315
+ )
316
+ ap.add_argument(
317
+ "--active-window",
318
+ type=int,
319
+ default=60,
320
+ help="seconds of idleness below which a transcript is flagged as possibly live (default 60)",
321
+ )
322
+ ap.add_argument(
323
+ "--all",
324
+ action="store_true",
325
+ help="keep sessions with no typed prompt (headless -p runs, aborted starts)",
326
+ )
327
+ args = ap.parse_args()
328
+
329
+ pdir = resolve_project_dir(args.cwd)
330
+ if not pdir:
331
+ print("No transcript directory for cwd: %s" % args.cwd, file=sys.stderr)
332
+ print("Looked under: %s" % projects_root(), file=sys.stderr)
333
+ return 2
334
+ files = transcript_files(pdir)
335
+ if not files:
336
+ print("No transcripts in %s" % pdir, file=sys.stderr)
337
+ return 2
338
+
339
+ # An explicit id always wins: no emptiness, liveness or limit filtering.
340
+ if args.session and args.session != "latest":
341
+ target = next(
342
+ (f for f in files if os.path.basename(f).startswith(args.session)), None
343
+ )
344
+ if not target:
345
+ print("Session not found: %s" % args.session, file=sys.stderr)
346
+ print("Known ids: %s" % ", ".join(os.path.basename(f)[:8] for f in files[:10]), file=sys.stderr)
347
+ return 2
348
+ d = summarize(target, args.cwd, prompt_chars=args.prompt_chars)
349
+ print(json.dumps(d, indent=2, ensure_ascii=False) if args.json else render(d))
350
+ return 0
351
+
352
+ rows, kept, skipped = [], [], 0
353
+ for f in files:
354
+ r = brief(f, args.cwd)
355
+ if not args.all and r["prompts"] == 0:
356
+ skipped += 1
357
+ continue
358
+ idle = seconds_idle(f)
359
+ r["idle_seconds"] = int(idle)
360
+ r["maybe_live"] = idle < args.active_window
361
+ r["excluded"] = any(r["session_id"].startswith(x) for x in args.exclude_session)
362
+ rows.append(r)
363
+ kept.append(f)
364
+ if len(rows) >= args.limit:
365
+ break
366
+ if not kept:
367
+ print("No session with a typed prompt in %s (--all to inspect them)" % pdir, file=sys.stderr)
368
+ return 2
369
+
370
+ eligible = [
371
+ (r, f) for r, f in zip(rows, kept) if not r["excluded"] and not r["maybe_live"]
372
+ ] or [(r, f) for r, f in zip(rows, kept) if not r["excluded"]]
373
+
374
+ if args.list or not args.session:
375
+ if args.json:
376
+ print(json.dumps(
377
+ {"project_dir": pdir, "sessions": rows, "skipped_empty": skipped,
378
+ "suggested": eligible[0][0]["session_id"] if eligible else None},
379
+ indent=2, ensure_ascii=False))
380
+ else:
381
+ print("# Recent sessions — %s\n" % pdir)
382
+ for i, r in enumerate(rows, 1):
383
+ flags = []
384
+ if r["maybe_live"]:
385
+ flags.append("POSSIBLY THE SESSION YOU ARE IN — written %ds ago" % r["idle_seconds"])
386
+ if r["excluded"]:
387
+ flags.append("excluded")
388
+ print("%d. **%s**%s" % (i, r["title"] or "(untitled)",
389
+ " ⟨%s⟩" % "; ".join(flags) if flags else ""))
390
+ print(" id `%s` | %s → %s" % (r["session_id"], r["started"], r["ended"]))
391
+ print(" %d prompts, %d files | opened with: %s" % (r["prompts"], r["files"], r["first_prompt"]))
392
+ if skipped:
393
+ print("\n_(%d session(s) with no typed prompt hidden — headless or aborted runs; --all to show)_" % skipped)
394
+ if eligible:
395
+ print("\nSuggested: `%s`. Confirm it is not this conversation before summarizing." % eligible[0][0]["session_id"])
396
+ return 0
397
+
398
+ if not eligible:
399
+ print("Every candidate is excluded or possibly live; pass --session <id> explicitly.", file=sys.stderr)
400
+ return 2
401
+ target = eligible[0][1]
402
+ d = summarize(target, args.cwd, prompt_chars=args.prompt_chars)
403
+ print(json.dumps(d, indent=2, ensure_ascii=False) if args.json else render(d))
404
+ return 0
405
+
406
+
407
+ if __name__ == "__main__":
408
+ sys.exit(main())
409
+
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: shader-vfx
3
+ description: Shaders and visual effects for web and games - fragment/vertex shader mental model, GLSL toolkit (SDFs, smoothstep, noise, fbm), ready recipes for game VFX (hit flash, dissolve, outline, water, shockwave, palette swap, CRT), pixel-art-safe effects, post-processing chains, per-environment usage (three.js, PixiJS filters, Phaser pipelines, Godot shading language) and shader debugging/performance. Use when writing or fixing shaders, adding visual effects like glow, dissolve, distortion or outlines, doing post-processing, or when an effect needs GPU work. Türkçe tetikleyiciler - "shader yaz", "efekt ekle", "glow efekti", "dissolve efekti", "outline shader", "su efekti", "ekran efekti", "crt efekti", "godot shader", "glsl".
4
+ ---
5
+
6
+ # Shader VFX
7
+
8
+ You write shaders that serve the game's look and run fast. Shaders are per-pixel programs running massively parallel: think "what color is *this* pixel, given its coordinates, time and textures" — not loops over the image.
9
+
10
+ Always communicate with the user in their own language.
11
+
12
+ ## Mental model (2 minutes, saves hours)
13
+
14
+ - **Vertex shader** positions geometry; **fragment shader** colors each pixel. 95% of 2D game VFX is fragment work.
15
+ - Coordinates: work in **UV space** (0–1 across the texture/quad). Center-origin trick: `vec2 p = uv - 0.5;` (aspect-correct with `p.x *= aspect;`).
16
+ - Everything is a gradient: you compute values (distances, noise, masks) and map them to color with `mix` and `smoothstep`. There is no "if pixel is inside shape" — there is "how far is this pixel from the shape edge".
17
+ - Inputs come as **uniforms** (time, resolution, effect strength, textures); per-frame animation = a `time` uniform, not new shaders.
18
+
19
+ ## The core toolkit (memorize these five)
20
+
21
+ 1. `smoothstep(a, b, x)` — THE shaping function: soft thresholds, anti-aliased edges (`smoothstep(0.0, 2.0/resolution.y, d)`).
22
+ 2. `mix(a, b, t)` — blend anything: colors, positions, whole effects.
23
+ 3. **SDF shapes**: `length(p) - r` (circle), rect/segment SDFs; combine with `min` (union) / `max` (intersect); distance drives glow, outlines, soft shadows.
24
+ 4. `fract`/`mod` — repetition: tiles, stripes, scanlines (`fract(uv.y * 100.0)`).
25
+ 5. **Noise**: cheap hash → value noise → **fbm** (4–5 octaves of noise at doubling frequency, halving amplitude) for anything organic — fire, water, smoke, dissolve. Include a known-good hash/noise snippet rather than inventing one.
26
+
27
+ ## Recipe book (game VFX that ship)
28
+
29
+ - **Hit flash**: `color = mix(color, vec3(1.0), u_flash)` — drive `u_flash` from gameplay (spike to 1, decay over ~80ms).
30
+ - **Dissolve**: `if (noise(uv * scale) < u_threshold) discard;` + emissive edge where `noise - threshold < 0.05`. Animate threshold 0→1 to disintegrate.
31
+ - **Outline (sprite)**: sample alpha at 4–8 neighbor offsets; outside pixels with an opaque neighbor get outline color. Pixel-art: 4 offsets of exactly one texel.
32
+ - **Water**: scroll two noise layers at different speeds/scales, use them to distort sample UVs of the scene below + tint + specular band via smoothstep.
33
+ - **Shockwave**: radial UV displacement — `uv += dir * sin((dist - u_t) * freq) * falloff(dist, u_t)` expanding ring.
34
+ - **Heat haze / refraction**: small animated-noise UV offsets on the background sample.
35
+ - **Palette swap / LUT grading**: map luminance (or index) through a lookup texture — day/night, damage states, retro palettes for free.
36
+ - **CRT/retro post**: scanlines (sine on screen y), slight barrel distortion, chromatic aberration (offset R/B samples), vignette. Subtlety: each at 10–20% of what first looks cool.
37
+ - **Glow/bloom**: threshold bright pixels → downscale-blur passes → additive composite. Use the engine's bloom before hand-rolling.
38
+
39
+ ## Pixel-art-safe VFX
40
+
41
+ - Snap effect UVs to the texel grid (`floor(uv * texSize) / texSize`) or effects smear across the chunky pixels and break the look.
42
+ - Render effects at the game's native low resolution, then integer-upscale — post applied after upscale reads as "HD filter on retro game".
43
+ - Prefer **dither patterns** over smooth gradients (Bayer-matrix threshold) to stay on-palette; palette-swap via LUT beats hue-shifting.
44
+
45
+ ## Where shaders live per environment
46
+
47
+ - **three.js**: `ShaderMaterial`/`onBeforeCompile`; post via `EffectComposer`.
48
+ - **PixiJS**: `Filter` (fragment + uniforms) per-sprite or per-container.
49
+ - **Phaser**: PostFX/PreFX pipelines (built-ins: bloom, glow, blur) or custom `PostFXPipeline` — check built-ins first.
50
+ - **Godot**: `.gdshader`, GLSL-like with `shader_type canvas_item;` for 2D; `hint_range` uniforms editable in Inspector; screen-reading via `SCREEN_TEXTURE`/`hint_screen_texture`. Same recipes translate almost 1:1.
51
+ - **WebGPU/WGSL**: different syntax, same mental model; compute shaders unlock particles/sim — port there when the target supports it (see tauri-game-dev webview notes for desktop caveats).
52
+
53
+ ## Post-processing chains
54
+
55
+ Render scene → render target A → effect pass A→B → effect pass B→A (**ping-pong**) → screen. Order matters and is a look decision: grade → bloom → grain reads different from bloom → grade. Keep a debug toggle per pass.
56
+
57
+ ## Performance
58
+
59
+ - Cost = pixels covered × work per pixel. Fullscreen passes are the budget hogs — count them; half-resolution for blurry effects (blur/bloom/haze) is visually free, 4× cheaper.
60
+ - Texture fetches dominate: 8 neighbor samples per pixel is fine; 64 is a blur done wrong (separable blur: two 1D passes beat one 2D pass).
61
+ - `mediump` default precision on mobile; branches on *uniforms* are fine, per-pixel divergent branches less so — but measure before contorting code.
62
+ - Overdraw from stacked transparent quads (particles) kills mobile — fewer, bigger, smarter particles.
63
+ - Profile with SpectorJS (WebGL frame inspector) or the engine's GPU timers; never optimize a shader you haven't measured.
64
+
65
+ ## Debugging (there is no console.log)
66
+
67
+ - **Output the value as color**: `gl_FragColor = vec4(vec3(suspectValue), 1.0);` — the image *is* the debugger. Expect NaN to render black/weird: check divisions and `normalize(vec2(0))`.
68
+ - Isolate: comment passes back to a solid color, re-add until it breaks.
69
+ - Prototype on Shadertoy/The Book of Shaders playgrounds (instant iteration), then port — mind their uniform naming (`iTime` → your `u_time`).
70
+
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: tailwind-v4-tokens
3
+ description: Tailwind CSS v4 token architecture and its silent failure modes - the three-layer setup (raw palette to theme-scoped semantic tokens to an @theme inline handoff), why @theme inline is mandatory for values that change at runtime, why hand-written CSS classes never get hover/focus variants and fail without a build error, @utility and @variant for real custom utilities, custom breakpoints via --breakpoint-*, opacity modifiers on custom tokens, and CSS-first config differences from v3. Carries executable diagnostics - grep patterns that find variant-on-custom-class bugs and escaped-selector verification in the compiled CSS. Use when setting up or migrating Tailwind 4 theming, when hover/focus/dark styles silently do nothing, when a class sits in the markup but has no effect, or when wiring CSS variables into Tailwind. Türkçe tetikleyiciler - "tailwind 4'e geç", "hover çalışmıyor", "sınıf uygulanmıyor", "tema token sistemi kur", "dark mode altyapısı", "css değişkenini tailwind'e bağla", "stil neden gitmiyor".
4
+ ---
5
+
6
+ # Tailwind v4 Tokens
7
+
8
+ Tailwind v4 fails **silently**. A class it does not own produces no CSS, no warning and no non-zero exit — the build is green and the pixels are missing. This skill owns that mechanic: which classes Tailwind can generate, how to hand CSS variables over so theming survives, and how to prove a class actually compiled. Token *taste* (which semantic roles exist, contrast in both themes) belongs to the design-system skill.
9
+
10
+ Always communicate with the user in their own language.
11
+
12
+ ## Phase 1 — Diagnose an existing project
13
+
14
+ The failure has one shape: a class is **hand-written CSS**, but used **with a variant**. Tailwind generates variants only for utilities it owns — its own, `@theme`-derived, or `@utility`-registered. `hover:bg-theme-card` where `.bg-theme-card` is your own rule compiles to nothing.
15
+
16
+ **Why no error:** the compiler scans source files for candidate strings and emits CSS for recognized ones. An unrecognized candidate is indistinguishable from any ordinary class name (`card`, `swiper-slide`, a third-party class) — erroring would break every project on earth. Class attributes are opaque strings; nothing type-checks them. Missing pixels are the only signal.
17
+
18
+ **Find variant-on-custom-class bugs** — every class defined in your CSS, cross-checked against variant usage in source:
19
+
20
+ ```bash
21
+ rg -oNI -g '*.css' '^\.([a-z0-9-]+)' src | sed 's/^\.//' | sort -u | while read -r c; do
22
+ rg -qI "(hover|focus|focus-within|focus-visible|active|disabled|group-hover|peer-focus|dark|xs|sm|md|lg|xl|2xl):$c\b" src \
23
+ && echo "DEAD: variant used on hand-written class -> $c"
24
+ done
25
+ ```
26
+
27
+ It flags *variant* usage only — a hand-written class used bare (`class="bg-theme-card"`) still works as ordinary CSS, so that one is a naming problem, not a broken-pixel one.
28
+
29
+ **Find invented breakpoints** — v4 ships `sm md lg xl 2xl` only; anything else must be a `--breakpoint-*` token, or the chain silently loses a step and two branches of a `hidden xs:inline sm:hidden` sequence show at once:
30
+
31
+ ```bash
32
+ rg -oI '\b(xs|sm|md|lg|xl|2xl|3xl):' src | sort -u # variants in use
33
+ rg -n -g '*.css' -- '--breakpoint-' src # variants actually defined
34
+ ```
35
+
36
+ **Prove a class compiled** — build, then find the escaped selector in the output CSS. Ground truth, not a guess. The output selector is `.hover\:bg-hover:hover`; match the backslash with a wildcard `.` instead of escaping it — a backslash eaten by a shell quoting layer turns a working class into a false zero, and a lying verifier is worse than none:
37
+
38
+ ```bash
39
+ npm run build
40
+ rg -c 'hover.:bg-hover|focus-within.:border-accent' dist/assets/*.css # no output = not generated
41
+ rg -o -- '--color-[a-z-]+' dist/assets/*.css | sort -u # tokens Tailwind knows
42
+ ```
43
+
44
+ Calibrate it once against a class you know exists and once against an invented name that must return nothing.
45
+
46
+ ## Phase 2 — `@theme` vs `@theme inline`
47
+
48
+ The single most consequential decision. `@theme` emits the token as a variable in `:root` and points utilities at *that* variable; `@theme inline` substitutes the referenced value straight into the utility. Both compile, so the difference only shows up as a theme toggle that does nothing.
49
+
50
+ ```css
51
+ /* WRONG — one indirection too many */
52
+ @theme {
53
+ --color-surface: var(--bg-surface);
54
+ }
55
+ /* emits :root { --color-surface: var(--bg-surface) }
56
+ .bg-surface { background-color: var(--color-surface) }
57
+ --bg-surface is resolved on the root element and inherited as a finished value. */
58
+ ```
59
+
60
+ ```css
61
+ /* RIGHT */
62
+ @theme inline {
63
+ --color-surface: var(--bg-surface);
64
+ }
65
+ /* emits .bg-surface { background-color: var(--bg-surface) }
66
+ resolved on the element itself, so the nearest .light/.dark ancestor wins. */
67
+ ```
68
+
69
+ Be precise about when the wrong version bites, or you will "disprove" it by accident: with `.dark` on `<html>` (the root element) a global toggle appears to work, because root is where the resolution happens. It breaks when the theme class sits anywhere else (`<body>`, an app wrapper) and it breaks for **nested** theme scopes — a light-themed panel inside a dark page — even when the class is on `<html>`. The inline version is correct in all three cases, which is why it is the default here.
70
+
71
+ **Rule:** literal value → `@theme`. A `var()` whose value is redefined in any scope (`.dark`, `[data-theme]`, a subtree) → `@theme inline`.
72
+
73
+ ## Phase 3 — Three-layer token architecture (copyable skeleton)
74
+
75
+ ```css
76
+ @import "tailwindcss";
77
+
78
+ @custom-variant dark (&:where(.dark, .dark *));
79
+
80
+ /* Layer 1 — raw palette. Theme-independent, never referenced by components. */
81
+ :root {
82
+ --gray-50: oklch(0.985 0 0); --gray-200: oklch(0.92 0.004 260);
83
+ --gray-400: oklch(0.71 0.012 260); --gray-800: oklch(0.28 0.014 260);
84
+ --gray-950: oklch(0.15 0.012 260);
85
+ --brand-400: oklch(0.70 0.16 255); --brand-500: oklch(0.62 0.19 255);
86
+ }
87
+
88
+ /* Layer 2 — semantic roles, redefined per theme. The only layer that knows themes. */
89
+ .light {
90
+ --bg-base: var(--gray-50); --bg-surface: #fff; --bg-hover: var(--gray-200);
91
+ --fg-base: var(--gray-950); --fg-muted: var(--gray-400);
92
+ --line: var(--gray-200); --accent: var(--brand-500);
93
+ }
94
+ .dark {
95
+ --bg-base: var(--gray-950); --bg-surface: var(--gray-800);
96
+ --bg-hover: oklch(0.33 0.014 260);
97
+ --fg-base: var(--gray-50); --fg-muted: var(--gray-400);
98
+ --line: var(--gray-800); --accent: var(--brand-400);
99
+ }
100
+
101
+ /* Layer 3 — handoff. Runtime-swapped values MUST be inline. */
102
+ @theme inline {
103
+ --color-base: var(--bg-base);
104
+ --color-surface: var(--bg-surface);
105
+ --color-hover: var(--bg-hover);
106
+ --color-fg: var(--fg-base);
107
+ --color-muted: var(--fg-muted);
108
+ --color-line: var(--line);
109
+ --color-accent: var(--accent);
110
+ }
111
+
112
+ /* Static scales — no runtime swap, so plain @theme. */
113
+ @theme {
114
+ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
115
+ --radius-card: 0.75rem;
116
+ --text-caption: 0.8125rem; --text-caption--line-height: 1.4;
117
+ --shadow-card-lifted: 0 12px 32px oklch(0 0 0 / 0.16);
118
+ --breakpoint-xs: 30rem;
119
+ --animate-fade-in: fade-in 200ms ease-out;
120
+ @keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
121
+ }
122
+ ```
123
+
124
+ What this buys: `bg-surface`, `text-muted`, `border-line`, `hover:bg-hover`, `focus-within:border-accent`, `hover:shadow-card-lifted`, `xs:inline`, `animate-fade-in` all compile natively, with every variant, because Tailwind owns them. Note that `dark:` variants become largely unnecessary for color — the swap happens one layer below. Keep `@custom-variant dark` for the exceptions only (an inverted image filter, a different shadow strategy).
125
+
126
+ ## Phase 4 — v4 mechanics worth knowing
127
+
128
+ - **Own utilities via `@utility`** — the fix for `transition-smooth` / `interactive-scale` style helpers. Registered utilities get variants; `.class {}` rules do not. `@variant` applies a Tailwind variant inside custom CSS:
129
+
130
+ ```css
131
+ @utility transition-smooth {
132
+ transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
133
+ }
134
+ @utility interactive-scale {
135
+ transition: transform 150ms ease-out;
136
+ @variant hover { transform: scale(1.02); }
137
+ @variant active { transform: scale(0.98); }
138
+ }
139
+ ```
140
+ Verify with the Phase 1 escaped-selector grep — `rg -c 'md.:interactive-scale' dist/assets/*.css` must be non-zero.
141
+
142
+ - **Opacity modifiers work on custom tokens**: `bg-surface/60` compiles to `color-mix(in oklab, var(--bg-surface) 60%, transparent)`, behind a `@supports` guard with the flat color as fallback. It requires the token to resolve to a *color* — a var holding a gradient or a shadow yields nothing usable.
143
+
144
+ - **Custom breakpoints** are tokens: `--breakpoint-xs: 30rem` in `@theme` creates the `xs:` variant; without the token the prefix is simply dropped.
145
+
146
+ | v3 | v4 |
147
+ |---|---|
148
+ | `tailwind.config.js` | CSS-first `@theme` (legacy opt-in: `@config "./tailwind.config.js"`) |
149
+ | `@tailwind base/components/utilities` | `@import "tailwindcss"` |
150
+ | `content: [...]` globs | automatic source detection |
151
+ | `theme.extend.colors` | `--color-*` in `@theme` / `@theme inline` |
152
+ | `@layer utilities { .foo {} }` | `@utility foo { }` |
153
+ | `darkMode: 'class'` | `@custom-variant dark (&:where(.dark, .dark *))` |
154
+
155
+ ## Phase 5 — Migration order (each step has a done-check)
156
+
157
+ 1. **Tokens first.** Build the three layers; keep the old classes alive alongside so nothing breaks mid-flight. *Done-check:* toggling `.dark` on the root element changes computed colors in DevTools, and `--color-*` names appear in the built CSS.
158
+ 2. **Variant audit.** Replace every variant-prefixed custom class with a token-derived utility; register genuine helpers with `@utility`; define missing breakpoints. *Done-check:* the Phase 1 loop prints nothing, and every variant class in the diff appears as an escaped selector in `dist`.
159
+ 3. **Dead class sweep.** Delete the hand-written color classes and any never-defined utilities. *Done-check:* `rg -w '<class-name>' src` returns zero hits per removed class; CSS bundle size drops.
160
+ 4. **Contrast verification** in both themes — hand off to the design-system skill (4.5:1 body, 3:1 large text and UI); a token swap that compiles can still be unreadable.
161
+
162
+ ## Anti-patterns
163
+
164
+ Hand-writing `.my-color { color: var(--x) }` for anything Tailwind could generate from a token; defining the same token in both `@theme` and `:root` (two sources of truth, whichever loses is the confusing one); `@theme` for a value redefined under `.dark`; using variants on classes you wrote yourself; relying on a utility that was never defined anywhere; inventing a breakpoint prefix without a `--breakpoint-*` token; hoarding tokens no component consumes; patching a cascade fight with `!important` instead of finding which layer owns the property.
165
+