opencode-skills-collection 4.1.2 → 4.1.3

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 (68) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +9 -1
  2. package/bundled-skills/antigravity-maintainer-batch-release/SKILL.md +15 -1
  3. package/bundled-skills/deep-research-framework/SKILL.md +81 -0
  4. package/bundled-skills/five-axis-code-review/SKILL.md +78 -0
  5. package/bundled-skills/git-commit-message/SKILL.md +76 -0
  6. package/bundled-skills/idea-to-blueprint/LICENSE +21 -0
  7. package/bundled-skills/idea-to-blueprint/SKILL.md +260 -0
  8. package/bundled-skills/idea-to-blueprint/assets/AGENTS.md.template +38 -0
  9. package/bundled-skills/idea-to-blueprint/assets/CLAUDE.md.template +38 -0
  10. package/bundled-skills/idea-to-blueprint/assets/DECISIONS.md.template +14 -0
  11. package/bundled-skills/idea-to-blueprint/assets/PROGRESS.md.template +47 -0
  12. package/bundled-skills/idea-to-blueprint/assets/session-prompt.md +23 -0
  13. package/bundled-skills/idea-to-blueprint/references/architecture-decisions.md +202 -0
  14. package/bundled-skills/idea-to-blueprint/references/blueprint-template.md +259 -0
  15. package/bundled-skills/idea-to-blueprint/references/code-quality-and-review.md +216 -0
  16. package/bundled-skills/idea-to-blueprint/references/design-minimal.md +96 -0
  17. package/bundled-skills/idea-to-blueprint/references/edge-case-catalog.md +239 -0
  18. package/bundled-skills/idea-to-blueprint/references/intake-questions.md +105 -0
  19. package/bundled-skills/idea-to-blueprint/references/research-protocol.md +196 -0
  20. package/bundled-skills/idea-to-blueprint/references/session-protocol.md +133 -0
  21. package/bundled-skills/idea-to-blueprint/references/stories-ac-tests.md +194 -0
  22. package/bundled-skills/idea-to-blueprint/references/ux-writing-guide.md +128 -0
  23. package/bundled-skills/idea-to-blueprint/scripts/lint_blueprint.py +460 -0
  24. package/bundled-skills/liuguang-banlan-ui/SKILL.md +22 -18
  25. package/bundled-skills/liuguang-banlan-ui/assets/starter/launcher.css +2 -2
  26. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/index.html +5 -4
  27. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/theme-config.js +3 -10
  28. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/theme.css +48 -62
  29. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/index.html +5 -4
  30. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/theme-config.js +27 -35
  31. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/theme.css +48 -62
  32. package/bundled-skills/liuguang-banlan-ui/assets/starter/shared/spectral-field.js +135 -148
  33. package/bundled-skills/liuguang-banlan-ui/references/style-contract.md +26 -7
  34. package/bundled-skills/liuguang-banlan-ui/references/verification.md +19 -7
  35. package/bundled-skills/liuguang-banlan-ui/scripts/measure_preview.py +78 -47
  36. package/bundled-skills/liuguang-banlan-ui/scripts/serve_preview.py +35 -0
  37. package/bundled-skills/liuguang-banlan-ui/scripts/validate_manifest.py +34 -3
  38. package/bundled-skills/liuguang-banlan-ui/tests/test_measure_preview.py +119 -0
  39. package/bundled-skills/liuguang-banlan-ui/tests/test_serve_preview.py +38 -0
  40. package/bundled-skills/liuguang-banlan-ui/tests/test_validate_manifest.py +73 -0
  41. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package-lock.json +12 -12
  42. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package.json +1 -1
  43. package/bundled-skills/meeting-notes/SKILL.md +80 -0
  44. package/bundled-skills/repo-foundation/SKILL.md +166 -0
  45. package/bundled-skills/repo-foundation/references/LICENSE.md +21 -0
  46. package/bundled-skills/repo-foundation/references/bootstrap.md +63 -0
  47. package/bundled-skills/repo-foundation/references/companion.md +9 -0
  48. package/bundled-skills/repo-foundation/references/continuity.md +50 -0
  49. package/bundled-skills/repo-foundation/references/evolution.md +60 -0
  50. package/bundled-skills/repo-foundation/references/migration-examples.md +53 -0
  51. package/bundled-skills/repo-foundation/references/shared-contracts.md +48 -0
  52. package/bundled-skills/repo-foundation/references/verification.md +50 -0
  53. package/bundled-skills/repo-native-refactor/SKILL.md +225 -0
  54. package/bundled-skills/repo-native-refactor/references/LICENSE.md +21 -0
  55. package/bundled-skills/repo-native-refactor/references/deterministic-tooling.md +101 -0
  56. package/bundled-skills/repo-native-refactor/references/error-reliability.md +117 -0
  57. package/bundled-skills/repo-native-refactor/references/finding-taxonomy.md +181 -0
  58. package/bundled-skills/repo-native-refactor/references/refactor-examples.md +77 -0
  59. package/bundled-skills/repo-native-refactor/references/repository-prose.md +157 -0
  60. package/bundled-skills/repo-native-refactor/references/repository-rehabilitation.md +94 -0
  61. package/bundled-skills/repo-native-refactor/references/semantic-risk.md +187 -0
  62. package/bundled-skills/repo-native-refactor/references/shared-contracts.md +48 -0
  63. package/bundled-skills/repo-native-refactor/references/testing-integrity.md +103 -0
  64. package/bundled-skills/tech-writing-proofread/SKILL.md +78 -0
  65. package/bundled-skills/typescript-expert/scripts/ts_diagnostic.py +40 -19
  66. package/bundled-skills/youtube-summarizer/SKILL.md +3 -3
  67. package/package.json +1 -1
  68. package/skills_index.json +254 -0
@@ -30,17 +30,26 @@ except ModuleNotFoundError as exc:
30
30
  raise SystemExit(2)
31
31
 
32
32
 
33
- def srgb_to_oklab(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
34
- linear = np.where(
35
- rgb <= 0.04045,
36
- rgb / 12.92,
37
- ((rgb + 0.055) / 1.055) ** 2.4,
38
- )
33
+ # A color counts as visible when it deviates this far from the base (OKLab).
34
+ TINT_THRESHOLD = {"opal": 0.003, "obsidian": 0.004}
35
+ # A block is gray when its lightness moves this far with little hue change.
36
+ GRAY_SHIFT = 0.003
37
+
38
+
39
+ def load_config(path: pathlib.Path) -> dict:
40
+ return load_manifest(path)
41
+
42
+
43
+ def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
44
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
45
+
46
+
47
+ def linear_to_oklab(linear: np.ndarray) -> np.ndarray:
39
48
  r, g, b = np.moveaxis(linear, -1, 0)
40
49
  l = np.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
41
50
  m = np.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
42
51
  s = np.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
43
- lab = np.stack(
52
+ return np.stack(
44
53
  (
45
54
  0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
46
55
  1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
@@ -48,38 +57,50 @@ def srgb_to_oklab(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
48
57
  ),
49
58
  axis=-1,
50
59
  )
51
- luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
52
- return lab, luminance
53
60
 
54
61
 
55
- def circular_distance(a: np.ndarray, b: float) -> np.ndarray:
56
- delta = np.abs(a - b)
57
- return np.minimum(delta, 360.0 - delta)
62
+ def oklch_to_ab(color: dict) -> np.ndarray:
63
+ hue = np.radians(float(color["h"]))
64
+ return float(color["c"]) * np.array([np.cos(hue), np.sin(hue)])
58
65
 
59
66
 
60
- def measure(image_path: pathlib.Path, config: dict) -> dict:
61
- image = Image.open(image_path).convert("RGB")
62
- pixels = np.asarray(image, dtype=np.float32) / 255.0
63
- lab, luminance = srgb_to_oklab(pixels)
64
- chroma = np.hypot(lab[..., 1], lab[..., 2])
65
- hue = (np.degrees(np.arctan2(lab[..., 2], lab[..., 1])) + 360.0) % 360.0
67
+ def block_average(linear: np.ndarray, block: int) -> np.ndarray:
68
+ block = max(1, min(block, linear.shape[0], linear.shape[1]))
69
+ h = linear.shape[0] // block * block
70
+ w = linear.shape[1] // block * block
71
+ trimmed = linear[:h, :w].reshape(h // block, block, w // block, block, 3)
72
+ return trimmed.mean(axis=(1, 3))
66
73
 
67
- base_chroma = float(config["base"]["oklch"]["c"])
68
- threshold = base_chroma + (0.003 if config["mode"] == "opal" else 0.004)
69
- chroma_energy = np.maximum(chroma - threshold, 0.0)
70
- visible = chroma_energy > 0
71
74
 
72
- distances = np.stack(
73
- [circular_distance(hue, float(color["oklch"]["h"])) for color in config["colors"]],
74
- axis=-1,
75
- )
76
- assignments = np.argmin(distances, axis=-1)
77
- total_pixels = pixels.shape[0] * pixels.shape[1]
78
- total_energy = float(chroma_energy.sum())
75
+ def measure(image_path: pathlib.Path, config: dict, block: int = 8) -> dict:
76
+ with Image.open(image_path) as source:
77
+ image = source.convert("RGB")
78
+ linear = srgb_to_linear(np.asarray(image, dtype=np.float64) / 255.0)
79
+ luminance = 0.2126 * linear[..., 0] + 0.7152 * linear[..., 1] + 0.0722 * linear[..., 2]
80
+ lab = linear_to_oklab(block_average(linear, block))
81
+
82
+ base = config["base"]["oklch"]
83
+ base_ab = oklch_to_ab(base)
84
+ shift = lab[..., 0] - float(base["l"])
85
+ delta = lab[..., 1:] - base_ab
86
+ tint = np.hypot(delta[..., 0], delta[..., 1])
87
+
88
+ threshold = TINT_THRESHOLD.get(config["mode"], 0.003)
89
+ energy = np.maximum(tint - threshold, 0.0)
90
+ visible = energy > 0
91
+ gray = (np.abs(shift) > GRAY_SHIFT) & (tint < np.abs(shift) / 3)
92
+
93
+ directions = np.stack([oklch_to_ab(color["oklch"]) - base_ab for color in config["colors"]])
94
+ angles = np.arctan2(directions[:, 1], directions[:, 0])
95
+ deviation_angle = np.arctan2(delta[..., 1], delta[..., 0])
96
+ distance = np.abs((deviation_angle[..., None] - angles + np.pi) % (2 * np.pi) - np.pi)
97
+ assignments = np.argmin(distance, axis=-1)
98
+
99
+ total_blocks = tint.size
100
+ total_energy = float(energy.sum())
79
101
  colors = []
80
102
  for index, color in enumerate(config["colors"]):
81
103
  assigned = visible & (assignments == index)
82
- energy = float(chroma_energy[assigned].sum())
83
104
  colors.append(
84
105
  {
85
106
  "id": color["id"],
@@ -87,24 +108,40 @@ def measure(image_path: pathlib.Path, config: dict) -> dict:
87
108
  "configuredIntensity": color["intensity"],
88
109
  "oklch": color["oklch"],
89
110
  "peakOpacity": color["peakOpacity"],
90
- "measuredCoverage": round(float(assigned.sum()) / total_pixels, 4),
91
- "effectiveShare": round(energy / total_energy, 4) if total_energy else 0.0,
111
+ "measuredCoverage": round(float(assigned.sum()) / total_blocks, 4),
112
+ "effectiveShare": round(float(energy[assigned].sum()) / total_energy, 4) if total_energy else 0.0,
92
113
  }
93
114
  )
94
115
 
116
+ mean_tint = float(tint.mean())
95
117
  return {
96
118
  "image": image_path.name,
97
119
  "dimensions": {"width": image.width, "height": image.height},
98
- "method": "nearest configured OKLCH hue over pixels above the base-chroma visibility threshold",
99
- "visibilityThresholdChroma": round(threshold, 4),
120
+ "method": (
121
+ f"OKLab deviation from the configured base over {block}x{block} pixel blocks; "
122
+ "hue is attributed by the direction of the deviation"
123
+ ),
124
+ "blockSize": block,
125
+ "visibilityThresholdTint": threshold,
100
126
  "chromaticPixelRatio": round(float(visible.mean()), 4),
101
- "meanOklabChroma": round(float(chroma.mean()), 5),
127
+ "tint": {
128
+ "mean": round(mean_tint, 5),
129
+ "p50": round(float(np.percentile(tint, 50)), 5),
130
+ "p95": round(float(np.percentile(tint, 95)), 5),
131
+ },
132
+ "lightnessShift": {
133
+ "mean": round(float(shift.mean()), 5),
134
+ "p05": round(float(np.percentile(shift, 5)), 5),
135
+ "p95": round(float(np.percentile(shift, 95)), 5),
136
+ },
137
+ "shiftToTint": round(abs(float(shift.mean())) / mean_tint, 3) if mean_tint else None,
138
+ "grayRatio": round(float(gray.mean()), 4) if config["mode"] == "opal" else None,
102
139
  "luminance": {
103
140
  "mean": round(float(luminance.mean()), 5),
104
141
  "p05": round(float(np.percentile(luminance, 5)), 5),
105
142
  "p95": round(float(np.percentile(luminance, 95)), 5),
106
143
  "max": round(float(luminance.max()), 5),
107
- "configuredCap": config["field"]["luminanceCap"],
144
+ "configuredCap": config["field"].get("luminanceCap"),
108
145
  },
109
146
  "colors": colors,
110
147
  }
@@ -114,25 +151,19 @@ def main() -> None:
114
151
  parser = argparse.ArgumentParser()
115
152
  parser.add_argument("--image", required=True, type=pathlib.Path)
116
153
  parser.add_argument("--config", required=True, type=pathlib.Path)
154
+ parser.add_argument("--block", type=int, default=8, help="pixel block size used to average out dither")
117
155
  args = parser.parse_args()
118
156
  try:
119
- config = load_manifest(args.config.resolve())
157
+ config = load_config(args.config.resolve())
120
158
  except (ManifestSyntaxError, OSError) as error:
121
- print(
122
- json.dumps(
123
- {"status": "invalid", "errors": [str(error)]},
124
- ensure_ascii=False,
125
- indent=2,
126
- ),
127
- file=sys.stderr,
128
- )
159
+ print(json.dumps({"status": "invalid", "errors": [str(error)]}, ensure_ascii=False, indent=2), file=sys.stderr)
129
160
  raise SystemExit(1) from error
130
161
  payload = {
131
- "schemaVersion": "1.0",
162
+ "schemaVersion": "1.1",
132
163
  "theme": config["label"],
133
164
  "preset": config["preset"],
134
165
  "overallColorIntensity": config["overallColorIntensity"],
135
- "measurement": measure(args.image.resolve(), config),
166
+ "measurement": measure(args.image.resolve(), config, args.block),
136
167
  }
137
168
  print(json.dumps(payload, ensure_ascii=False, indent=2))
138
169
 
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """Serve a directory over local HTTP with caching disabled, for previews and screenshots."""
3
+
4
+ import argparse
5
+ import functools
6
+ import http.server
7
+ import pathlib
8
+
9
+
10
+ # python -m http.server sends Last-Modified without Cache-Control, so browsers keep
11
+ # serving a stale theme-config.js after an edit and screenshots measure the old field.
12
+ class NoStoreHandler(http.server.SimpleHTTPRequestHandler):
13
+ def end_headers(self):
14
+ self.send_header("Cache-Control", "no-store")
15
+ super().end_headers()
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser(description=__doc__)
20
+ parser.add_argument("directory", nargs="?", type=pathlib.Path, default=pathlib.Path("."))
21
+ parser.add_argument("--port", type=int, default=8000)
22
+ parser.add_argument("--bind", default="127.0.0.1")
23
+ args = parser.parse_args()
24
+ root = args.directory.resolve()
25
+ handler = functools.partial(NoStoreHandler, directory=str(root))
26
+ with http.server.ThreadingHTTPServer((args.bind, args.port), handler) as server:
27
+ print(f"serving {root} at http://{args.bind}:{args.port}/ with Cache-Control: no-store")
28
+ try:
29
+ server.serve_forever()
30
+ except KeyboardInterrupt:
31
+ pass
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -126,7 +126,7 @@ def validate(config: dict) -> list[str]:
126
126
  if not isinstance(field, dict):
127
127
  errors.append("field must be an object")
128
128
  field = {}
129
- for key in ("scale", "octaves", "warpStrength", "motionSpeed", "staticTime", "ditherStrength", "luminanceCap"):
129
+ for key in ("scale", "octaves", "warpStrength", "motionSpeed", "staticTime", "ditherStrength"):
130
130
  require(key in field, f"field missing {key}", errors)
131
131
  check_range(field.get("scale"), 0, float("inf"), "field.scale must be positive", errors, lower_inclusive=False)
132
132
  octaves = field.get("octaves")
@@ -139,10 +139,37 @@ def validate(config: dict) -> list[str]:
139
139
  check_range(field.get("motionSpeed"), 0, float("inf"), "field.motionSpeed must be finite and nonnegative", errors)
140
140
  check_finite(field.get("staticTime"), "field.staticTime must be a finite number", errors)
141
141
  check_range(field.get("ditherStrength"), 0, 1, "field.ditherStrength must be in [0, 1]", errors)
142
- check_range(field.get("luminanceCap"), 0, 1, "field.luminanceCap must be in (0, 1]", errors, lower_inclusive=False)
142
+ if config["mode"] == "obsidian" or "luminanceCap" in field:
143
+ check_range(field.get("luminanceCap"), 0, 1, "field.luminanceCap must be in (0, 1]", errors, lower_inclusive=False)
143
144
  return errors
144
145
 
145
146
 
147
+ def hue_travel(hues: list[float]) -> float:
148
+ steps = (abs(a - b) % 360 for a, b in zip(hues, hues[1:] + hues[:1]))
149
+ return sum(min(step, 360 - step) for step in steps)
150
+
151
+
152
+ def best_hue_travel(hues: list[float]) -> float:
153
+ ordered = sorted(h % 360 for h in hues)
154
+ widest = max([b - a for a, b in zip(ordered, ordered[1:])] + [ordered[0] + 360 - ordered[-1]])
155
+ return 360.0 if widest <= 180 else 2 * (360 - widest)
156
+
157
+
158
+ def collect_warnings(config: dict) -> list[str]:
159
+ notes: list[str] = []
160
+ colors = config.get("colors", [])
161
+ hues = [float(color.get("oklch", {}).get("h", 0)) for color in colors]
162
+ if len(hues) >= 3:
163
+ travel, best = hue_travel(hues), best_hue_travel(hues)
164
+ if travel > best * 1.1:
165
+ notes.append(
166
+ f"colors are not in hue order: neighbor hue steps add up to {travel:.0f} degrees, "
167
+ f"the best order gives {best:.0f}. The renderer mixes each color with its array "
168
+ "neighbors, and near-complementary neighbors mix toward gray."
169
+ )
170
+ return notes
171
+
172
+
146
173
  def main() -> None:
147
174
  parser = argparse.ArgumentParser()
148
175
  parser.add_argument("config", type=pathlib.Path)
@@ -162,7 +189,11 @@ def main() -> None:
162
189
  if errors:
163
190
  print(json.dumps({"status": "invalid", "errors": errors}, ensure_ascii=False, indent=2))
164
191
  sys.exit(1)
165
- print(json.dumps({"status": "valid", "mode": config["mode"], "preset": config["preset"], "colors": len(config["colors"])}, ensure_ascii=False, indent=2))
192
+ report = {"status": "valid", "mode": config["mode"], "preset": config["preset"], "colors": len(config["colors"])}
193
+ notes = collect_warnings(config)
194
+ if notes:
195
+ report["warnings"] = notes
196
+ print(json.dumps(report, ensure_ascii=False, indent=2))
166
197
 
167
198
 
168
199
  if __name__ == "__main__":
@@ -0,0 +1,119 @@
1
+ import pathlib
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "scripts"))
10
+ import measure_preview as mp # noqa: E402
11
+
12
+ LAB_TO_LMS = np.array([
13
+ [1.0, 0.3963377774, 0.2158037573],
14
+ [1.0, -0.1055613458, -0.0638541728],
15
+ [1.0, -0.0894841775, -1.2914855480],
16
+ ])
17
+ LMS_TO_RGB = np.array([
18
+ [4.0767416621, -3.3077115913, 0.2309699292],
19
+ [-1.2684380046, 2.6097574011, -0.3413193965],
20
+ [-0.0041960863, -0.7034186147, 1.7076147010],
21
+ ])
22
+ HUES = (("rose", 18), ("peach", 62), ("mint", 159), ("cyan", 201), ("blue", 248), ("lilac", 304))
23
+
24
+
25
+ def lab_to_srgb(lab):
26
+ linear = np.clip(LMS_TO_RGB @ ((LAB_TO_LMS @ np.asarray(lab, dtype=np.float64)) ** 3), 0.0, 1.0)
27
+ return np.where(linear <= 0.0031308, linear * 12.92, 1.055 * linear ** (1 / 2.4) - 0.055)
28
+
29
+
30
+ def config(base=(0.982, 0.004, 94)):
31
+ return {
32
+ "mode": "opal",
33
+ "base": {"oklch": {"l": base[0], "c": base[1], "h": base[2]}},
34
+ "colors": [
35
+ {"id": cid, "label": cid, "oklch": {"l": 0.93, "c": 0.045, "h": h}, "intensity": 0.5, "peakOpacity": 0.08}
36
+ for cid, h in HUES
37
+ ],
38
+ "field": {},
39
+ }
40
+
41
+
42
+ def base_lab(cfg):
43
+ o = cfg["base"]["oklch"]
44
+ h = np.radians(o["h"])
45
+ return np.array([o["l"], o["c"] * np.cos(h), o["c"] * np.sin(h)])
46
+
47
+
48
+ def flat(lab, size=(64, 64)):
49
+ return np.broadcast_to(lab_to_srgb(lab), (*size, 3)).copy()
50
+
51
+
52
+ class MeasurePreviewTest(unittest.TestCase):
53
+ def setUp(self):
54
+ self.tmp = tempfile.TemporaryDirectory()
55
+ self.addCleanup(self.tmp.cleanup)
56
+
57
+ def measure_codes(self, cfg, codes):
58
+ path = pathlib.Path(self.tmp.name) / "field.png"
59
+ Image.fromarray(np.clip(codes, 0, 255).astype(np.uint8)).save(path)
60
+ return mp.measure(path, cfg)
61
+
62
+ def measure(self, cfg, pixels):
63
+ return self.measure_codes(cfg, np.round(pixels * 255))
64
+
65
+ def test_flat_base_reads_as_base(self):
66
+ for chroma in (0.0, 0.004):
67
+ with self.subTest(chroma=chroma):
68
+ cfg = config(base=(0.982, chroma, 94))
69
+ m = self.measure(cfg, flat(base_lab(cfg)))
70
+ self.assertEqual(m["chromaticPixelRatio"], 0)
71
+ self.assertEqual(m["grayRatio"], 0)
72
+ self.assertLess(abs(m["lightnessShift"]["mean"]), 0.0025)
73
+ self.assertIsNone(m["luminance"]["configuredCap"])
74
+
75
+ def test_visibility_does_not_depend_on_base_chroma(self):
76
+ tint = np.array([0.0, -0.0045, -0.0025])
77
+ ratios = []
78
+ for chroma in (0.0, 0.004, 0.008):
79
+ cfg = config(base=(0.982, chroma, 94))
80
+ ratios.append(self.measure(cfg, flat(base_lab(cfg) + tint))["chromaticPixelRatio"])
81
+ self.assertEqual(ratios, [1.0, 1.0, 1.0])
82
+
83
+ def test_hue_follows_deviation_direction(self):
84
+ cfg = config()
85
+ blue = np.radians(248)
86
+ m = self.measure(cfg, flat(base_lab(cfg) + 0.006 * np.array([0.0, np.cos(blue), np.sin(blue)])))
87
+ coverage = {c["id"]: c["measuredCoverage"] for c in m["colors"]}
88
+ self.assertEqual(max(coverage, key=coverage.get), "blue")
89
+
90
+ def test_lightness_only_shift_is_gray(self):
91
+ cfg = config(base=(0.982, 0.0, 94))
92
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([-0.01, 0.0, 0.0])))
93
+ self.assertEqual(m["grayRatio"], 1.0)
94
+ self.assertAlmostEqual(m["lightnessShift"]["mean"], -0.01, delta=0.002)
95
+
96
+ def test_tinted_shift_is_not_gray(self):
97
+ cfg = config()
98
+ rose = np.radians(18)
99
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([-0.006, 0.008 * np.cos(rose), 0.008 * np.sin(rose)])))
100
+ self.assertEqual(m["grayRatio"], 0)
101
+ self.assertEqual(m["chromaticPixelRatio"], 1.0)
102
+
103
+ def test_gray_ratio_is_only_defined_for_opal(self):
104
+ cfg = config(base=(0.098, 0.012, 236))
105
+ cfg["mode"] = "obsidian"
106
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([0.15, 0.0, 0.0])))
107
+ self.assertIsNone(m["grayRatio"])
108
+
109
+ def test_block_average_absorbs_dither(self):
110
+ cfg = config()
111
+ rng = np.random.default_rng(7)
112
+ codes = np.round(flat(base_lab(cfg)) * 255) + rng.integers(-1, 2, size=(64, 64, 3))
113
+ m = self.measure_codes(cfg, codes)
114
+ self.assertEqual(m["chromaticPixelRatio"], 0)
115
+ self.assertEqual(m["grayRatio"], 0)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,38 @@
1
+ import functools
2
+ import http.server
3
+ import pathlib
4
+ import sys
5
+ import tempfile
6
+ import threading
7
+ import unittest
8
+ import urllib.request
9
+
10
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "scripts"))
11
+ import serve_preview # noqa: E402
12
+
13
+
14
+ class QuietHandler(serve_preview.NoStoreHandler):
15
+ def log_message(self, *args):
16
+ pass
17
+
18
+
19
+ class ServePreviewTest(unittest.TestCase):
20
+ def test_responses_are_not_cached(self):
21
+ body = b"window.SPECTRAL_THEME = {};"
22
+ with tempfile.TemporaryDirectory() as root:
23
+ pathlib.Path(root, "theme-config.js").write_bytes(body)
24
+ handler = functools.partial(QuietHandler, directory=root)
25
+ with http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) as server:
26
+ threading.Thread(target=server.serve_forever, daemon=True).start()
27
+ try:
28
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
29
+ url = f"http://127.0.0.1:{server.server_address[1]}/theme-config.js"
30
+ with opener.open(url) as response:
31
+ self.assertEqual(response.headers["Cache-Control"], "no-store")
32
+ self.assertEqual(response.read(), body)
33
+ finally:
34
+ server.shutdown()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ import pathlib
2
+ import shutil
3
+ import sys
4
+ import unittest
5
+
6
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(ROOT / "scripts"))
8
+ import validate_manifest as vm # noqa: E402
9
+
10
+ HUES = {"rose": 18, "peach": 62, "mint": 159, "cyan": 201, "blue": 248, "lilac": 304}
11
+ INTERLEAVED = ["rose", "cyan", "lilac", "peach", "mint", "blue"]
12
+
13
+
14
+ def manifest(mode="opal", order=None, cap=None):
15
+ field = {"scale": 1, "octaves": 4, "warpStrength": 0.3, "motionSpeed": 0.01, "staticTime": 1, "ditherStrength": 1}
16
+ if cap is not None:
17
+ field["luminanceCap"] = cap
18
+ return {
19
+ "schemaVersion": "1.1",
20
+ "mode": mode,
21
+ "label": "test",
22
+ "preset": "test",
23
+ "seed": 1,
24
+ "overallColorIntensity": 0.5,
25
+ "base": {"oklch": {"l": 0.98, "c": 0.004, "h": 94}},
26
+ "colors": [
27
+ {
28
+ "id": cid,
29
+ "label": cid,
30
+ "oklch": {"l": 0.93, "c": 0.04, "h": HUES[cid]},
31
+ "srgbFallback": "#ffffff",
32
+ "intensity": 0.5,
33
+ "peakOpacity": 0.08,
34
+ "fieldScale": 1,
35
+ "phase": [0, 0],
36
+ }
37
+ for cid in (order or list(HUES))
38
+ ],
39
+ "field": field,
40
+ "output": {"colorSpace": "srgb", "reducedMotion": "frozen-calibrated-frame"},
41
+ }
42
+
43
+
44
+ class ValidateManifestTest(unittest.TestCase):
45
+ def test_hue_ordered_palette_passes_without_warnings(self):
46
+ self.assertEqual(vm.validate(manifest()), [])
47
+ self.assertEqual(vm.collect_warnings(manifest()), [])
48
+
49
+ def test_interleaved_palette_warns(self):
50
+ notes = vm.collect_warnings(manifest(order=INTERLEAVED))
51
+ self.assertEqual(len(notes), 1)
52
+ self.assertIn("hue order", notes[0])
53
+
54
+ def test_luminance_cap_is_required_only_for_obsidian(self):
55
+ self.assertEqual(vm.validate(manifest(mode="opal")), [])
56
+ errors = vm.validate(manifest(mode="obsidian"))
57
+ self.assertTrue(
58
+ any(msg.startswith("field.luminanceCap must be in (0, 1]") for msg in errors),
59
+ errors,
60
+ )
61
+ self.assertEqual(vm.validate(manifest(mode="obsidian", cap=0.165)), [])
62
+
63
+ @unittest.skipUnless(shutil.which("node"), "node is needed to load the starter manifests")
64
+ def test_starter_manifests(self):
65
+ for mode in ("opal", "obsidian"):
66
+ with self.subTest(mode=mode):
67
+ config = vm.load_manifest(ROOT / "assets" / "starter" / mode / "theme-config.js")
68
+ self.assertEqual(vm.validate(config), [])
69
+ self.assertEqual(vm.collect_warnings(config), [])
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -10,7 +10,7 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "react": "^19.2.8",
13
- "react-dom": "^19.2.8"
13
+ "react-dom": "^19.3.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/react": "^19.2.7",
@@ -814,24 +814,24 @@
814
814
  }
815
815
  },
816
816
  "node_modules/react": {
817
- "version": "19.2.8",
818
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
819
- "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
817
+ "version": "19.3.0",
818
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
819
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
820
820
  "license": "MIT",
821
821
  "engines": {
822
822
  "node": ">=0.10.0"
823
823
  }
824
824
  },
825
825
  "node_modules/react-dom": {
826
- "version": "19.2.8",
827
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
828
- "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
826
+ "version": "19.3.0",
827
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
828
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
829
829
  "license": "MIT",
830
830
  "dependencies": {
831
- "scheduler": "^0.27.0"
831
+ "scheduler": "^0.28.0"
832
832
  },
833
833
  "peerDependencies": {
834
- "react": "^19.2.8"
834
+ "react": "^19.3.0"
835
835
  }
836
836
  },
837
837
  "node_modules/rolldown": {
@@ -869,9 +869,9 @@
869
869
  }
870
870
  },
871
871
  "node_modules/scheduler": {
872
- "version": "0.27.0",
873
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
874
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
872
+ "version": "0.28.0",
873
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
874
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
875
875
  "license": "MIT"
876
876
  },
877
877
  "node_modules/source-map-js": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "react": "^19.2.8",
23
- "react-dom": "^19.2.8"
23
+ "react-dom": "^19.3.0"
24
24
  },
25
25
  "overrides": {
26
26
  "picomatch": "4.0.4",
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: meeting-notes
3
+ description: "Turns raw meeting notes into structured minutes: conclusion first, then decisions / action items / open questions; every action item must have an owner and a deadline. Use when the user pastes meeting transcripts or rough notes and asks for minutes or a summary."
4
+ category: productivity
5
+ risk: safe
6
+ source: https://github.com/alapha888/agent-skills-en
7
+ source_repo: alapha888/agent-skills-en
8
+ source_type: community
9
+ date_added: "2026-10-01"
10
+ author: alapha888
11
+ tags: [meetings, minutes, action-items]
12
+ tools: [claude, cursor, gemini, codex]
13
+ license: MIT
14
+ license_source: https://github.com/alapha888/agent-skills-en/blob/main/LICENSE
15
+ ---
16
+
17
+ # Meeting Notes
18
+
19
+ Turn messy meeting notes into minutes that let someone who missed the meeting catch up in 3 minutes — and let attendees know exactly what they owe.
20
+
21
+ ## When to Use
22
+
23
+ - Use when the user pastes meeting transcripts or rough notes and asks for minutes or a summary.
24
+ - Use when decisions and action items need owners and deadlines assigned.
25
+
26
+ ## Workflow
27
+
28
+ 1. **Read the raw notes**: read the transcript or jotted notes the user pasted. Find "what was finally decided" first, then look back for the reasoning. Minutes record conclusions and points of disagreement, not the full discussion.
29
+ 2. **Sort into three buckets**:
30
+ - **Decisions**: one per line, phrased as "Decided to do X", never "Discussed X".
31
+ - **Action items**: each must have an owner + a deadline. If the notes name no owner, mark `[unassigned]`; if no deadline, mark `[TBD]` — never invent them.
32
+ - **Open questions**: issues left undecided that need a follow-up; state "what is blocking, and who owns the next step."
33
+ 3. **Conclusion first**: open the minutes with ≤3 lines on "what this meeting decided", then expand the three buckets.
34
+ 4. **Self-check before output**: does every action item have an owner? A deadline? Are decisions affirmative statements? Do open questions have a next step? Flag any "no" — don't gloss over it.
35
+
36
+ ## Rules
37
+
38
+ - Do not fabricate. Names, dates, and numbers not in the notes must not appear; mark missing ones `[to confirm]`.
39
+ - Do not editorialize. No "Maria's proposal seemed shaky" — only "Maria's proposal was rejected because…" (if a reason was stated).
40
+ - Length control: the minutes body must be ≤1/3 of the raw notes. Longer means you didn't distill — redo it.
41
+
42
+ ## Minimal example
43
+
44
+ Input (raw notes excerpt):
45
+
46
+ ```text
47
+ ...James said the login page is too slow, Maria suggested adding a cache layer, everyone agreed it's worth a pilot; draft the proposal by next Wednesday; pricing strategy still open, invite Finance next time...
48
+ ```
49
+
50
+ Output:
51
+
52
+ ```markdown
53
+ ## Meeting minutes (example)
54
+
55
+ **Bottom line**: Pilot a cache layer to speed up the login page; pricing strategy undecided, pending a session with Finance.
56
+
57
+ ### Decisions
58
+ - Adopt a cache layer for login-page performance (pilot first, not a full rollout).
59
+
60
+ ### Action items
61
+ - [ ] Maria: draft the caching proposal, due next Wednesday `[date to confirm]`
62
+ - [ ] [unassigned]: schedule pricing discussion with Finance `[time TBD]`
63
+
64
+ ### Open questions
65
+ - Pricing strategy: no decision reached; the action-item owner will book a dedicated session with Finance.
66
+ ```
67
+
68
+ ## Limitations
69
+
70
+ - Works only from notes the user supplies. It cannot join the meeting, recover audio, or resolve a name the notes never recorded.
71
+ - Missing owners and deadlines are marked `[unassigned]` / `[TBD]`, never invented; the minutes are therefore only as complete as the raw notes.
72
+ - It records decisions, not consensus. If the notes only say something was "discussed", the skill reports that as an open question instead of a decision.
73
+ - Summaries bias toward brevity (≤1/3 of the raw notes), so fine-grained discussion detail is intentionally dropped.
74
+
75
+ ## Anti-patterns
76
+
77
+ - ❌ Chronological minutes: replaying two hours of discussion in order is the same as not organizing it.
78
+ - ❌ Ownerless action items: "everyone please follow up" — an action item without an owner and a deadline is not an action item.
79
+ - ❌ Invented details: guessing dates or names the notes never mentioned turns minutes into fiction.
80
+ - ❌ Decisions written as discussion: "Discussed the feasibility of a caching layer" — did the meeting adopt it or not? Minutes need affirmative statements.