claude-dev-env 8.65.1 → 8.66.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.
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that stops an artifact page from publishing when dark mode hides its text.
3
+
4
+ The artifact service wraps every published page in a skeleton whose style pins
5
+ ``body`` to a light background and dark text. A page that flips its theme
6
+ tokens on ``html`` alone keeps that light body in dark mode, and its light
7
+ dark-mode text lands on it. A plain local render has no wrapper, so the page
8
+ looks fine there and fails once published.
9
+
10
+ This gate renders the page inside that same wrapper in headless Chromium,
11
+ once under ``prefers-color-scheme: dark`` and once with ``data-theme="dark"``
12
+ on the root element::
13
+
14
+ Artifact {"action": "publish", "file_path": "page.html"} -> rendered
15
+ body stays light, or visible text under 4.5:1 -> denied
16
+ every check holds -> allowed
17
+ Artifact {"action": "read", ...} -> allowed
18
+ Artifact {"asset": true, ...} or {"type_url": ...} -> allowed
19
+ a publish whose file_path is not .html -> allowed
20
+
21
+ A host without Playwright or Chromium is denied with the install command,
22
+ because an unrendered page is the case this gate exists to stop.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ from pathlib import Path
32
+
33
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
34
+ if hooks_root_directory not in sys.path:
35
+ sys.path.insert(0, hooks_root_directory)
36
+
37
+ from hooks_constants.artifact_dark_mode_gate_constants import (
38
+ ALL_SKIPPED_PUBLISH_INPUT_KEYS,
39
+ ALLOW_EXIT_CODE,
40
+ ARTIFACT_ACTION_INPUT_KEY,
41
+ ARTIFACT_FILE_PATH_INPUT_KEY,
42
+ ARTIFACT_PUBLISH_ACTION,
43
+ ARTIFACT_TOOL_NAME,
44
+ BLOCK_EXIT_CODE,
45
+ DARK_MODE_FAILURE_HEADER,
46
+ GATE_EVENT_NAME,
47
+ HTML_FILE_SUFFIX,
48
+ PUBLISH_SKELETON_PREFIX,
49
+ RENDERER_MISSING_EXIT_CODE,
50
+ RENDERER_MISSING_HEADER,
51
+ RENDERER_SCRIPT_PATH,
52
+ RENDERER_TIMEOUT_SECONDS,
53
+ RETRY_INSTRUCTION,
54
+ )
55
+ from hooks_constants.hook_block_logger import log_hook_block
56
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
57
+ from hooks_constants.spawn_readiness_hook_constants import TOOL_INPUT_KEY, TOOL_NAME_KEY
58
+
59
+
60
+ def published_page_path(tool_name: object, tool_input: object) -> Path | None:
61
+ """Return the local HTML page a call publishes as an artifact page, or None.
62
+
63
+ >>> published_page_path("Artifact", {"file_path": "/tmp/page.html"})
64
+ PosixPath('/tmp/page.html')
65
+ >>> published_page_path("Artifact", {"action": "read", "url": "x"}) is None
66
+ True
67
+
68
+ Args:
69
+ tool_name: The tool the session called.
70
+ tool_input: The tool input.
71
+
72
+ Returns:
73
+ The page path for a page publish, or None for any other call.
74
+ """
75
+ if tool_name != ARTIFACT_TOOL_NAME or not isinstance(tool_input, dict):
76
+ return None
77
+ if (
78
+ tool_input.get(ARTIFACT_ACTION_INPUT_KEY, ARTIFACT_PUBLISH_ACTION)
79
+ != ARTIFACT_PUBLISH_ACTION
80
+ ):
81
+ return None
82
+ if any(tool_input.get(each_key) for each_key in ALL_SKIPPED_PUBLISH_INPUT_KEYS):
83
+ return None
84
+ file_path = tool_input.get(ARTIFACT_FILE_PATH_INPUT_KEY)
85
+ if not isinstance(file_path, str) or not file_path.lower().endswith(HTML_FILE_SUFFIX):
86
+ return None
87
+ return Path(file_path)
88
+
89
+
90
+ def wrap_in_publish_skeleton(page_source: str) -> str:
91
+ """Return the page as the artifact service serves it, inside its skeleton.
92
+
93
+ Args:
94
+ page_source: The HTML the session is about to publish.
95
+
96
+ Returns:
97
+ The skeleton head and body opening followed by the page source.
98
+ """
99
+ return PUBLISH_SKELETON_PREFIX + page_source
100
+
101
+
102
+ def _run_renderer(wrapped_page_path: Path) -> subprocess.CompletedProcess[str] | str:
103
+ try:
104
+ return subprocess.run(
105
+ ["node", str(RENDERER_SCRIPT_PATH), str(wrapped_page_path)],
106
+ capture_output=True,
107
+ text=True,
108
+ timeout=RENDERER_TIMEOUT_SECONDS,
109
+ check=False,
110
+ )
111
+ except FileNotFoundError:
112
+ return "node is not installed; install Node.js, then run: npm install -g playwright && npx playwright install chromium"
113
+ except subprocess.TimeoutExpired:
114
+ return f"the render took longer than {RENDERER_TIMEOUT_SECONDS} seconds"
115
+
116
+
117
+ def render_findings(page_source: str) -> tuple[dict[str, list[str]] | None, str]:
118
+ """Render the wrapped page in both dark signals and return what failed.
119
+
120
+ Args:
121
+ page_source: The HTML the session is about to publish.
122
+
123
+ Returns:
124
+ The findings by dark signal (empty when the page passes) and an empty
125
+ message, or None and the reason the renderer could not run.
126
+ """
127
+ with tempfile.TemporaryDirectory() as scratch_directory:
128
+ wrapped_page_path = Path(scratch_directory) / "wrapped_page.html"
129
+ wrapped_page_path.write_text(wrap_in_publish_skeleton(page_source), encoding="utf-8")
130
+ completed_render = _run_renderer(wrapped_page_path)
131
+ if isinstance(completed_render, str):
132
+ return None, completed_render
133
+ if completed_render.returncode == RENDERER_MISSING_EXIT_CODE:
134
+ return None, completed_render.stderr.strip()
135
+ try:
136
+ findings_by_signal = json.loads(completed_render.stdout)
137
+ except json.JSONDecodeError:
138
+ return None, completed_render.stderr.strip() or "the renderer printed no result"
139
+ return findings_by_signal, ""
140
+
141
+
142
+ def format_block_reason(findings_by_signal: dict[str, list[str]]) -> str:
143
+ """Return the deny message that lists each failed check under its dark signal.
144
+
145
+ Args:
146
+ findings_by_signal: Findings keyed by the dark signal that produced them.
147
+
148
+ Returns:
149
+ The message the session reads when the publish is denied.
150
+ """
151
+ all_lines = [DARK_MODE_FAILURE_HEADER]
152
+ for each_signal, each_signal_findings in findings_by_signal.items():
153
+ all_lines.append(f"Under {each_signal}:\n")
154
+ all_lines.extend(f"- {each_finding}\n" for each_finding in each_signal_findings)
155
+ return "".join(all_lines) + RETRY_INSTRUCTION
156
+
157
+
158
+ def main() -> int:
159
+ hook_input = read_hook_input_dictionary_from_stdin()
160
+ if hook_input is None:
161
+ return ALLOW_EXIT_CODE
162
+ page_path = published_page_path(hook_input.get(TOOL_NAME_KEY), hook_input.get(TOOL_INPUT_KEY))
163
+ if page_path is None or not page_path.is_file():
164
+ return ALLOW_EXIT_CODE
165
+ findings_by_signal, renderer_problem = render_findings(page_path.read_text(encoding="utf-8"))
166
+ if findings_by_signal is None:
167
+ block_reason = RENDERER_MISSING_HEADER + renderer_problem
168
+ elif findings_by_signal:
169
+ block_reason = format_block_reason(findings_by_signal)
170
+ else:
171
+ return ALLOW_EXIT_CODE
172
+ log_hook_block(
173
+ Path(__file__).name,
174
+ GATE_EVENT_NAME,
175
+ block_reason,
176
+ tool_name=ARTIFACT_TOOL_NAME,
177
+ offending_input_preview=str(page_path),
178
+ )
179
+ sys.stderr.write(block_reason)
180
+ return BLOCK_EXIT_CODE
181
+
182
+
183
+ if __name__ == "__main__":
184
+ sys.exit(main())
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const childProcess = require("child_process");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+
8
+ const MINIMUM_CONTRAST_RATIO = 4.5;
9
+ const MAXIMUM_DARK_PAGE_LUMINANCE = 0.2;
10
+ const MAXIMUM_REPORTED_TEXT_FAILURES = 8;
11
+ const TEXT_SNIPPET_LENGTH = 60;
12
+ const PAGE_LOAD_TIMEOUT_MILLISECONDS = 15000;
13
+ const RENDERER_MISSING_EXIT_CODE = 3;
14
+ const ALL_DARK_SIGNALS = [
15
+ { name: "prefers-color-scheme: dark", colorScheme: "dark", dataTheme: null },
16
+ { name: 'data-theme="dark"', colorScheme: "light", dataTheme: "dark" },
17
+ ];
18
+
19
+ function loadPlaywright() {
20
+ try {
21
+ return require("playwright");
22
+ } catch (localError) {
23
+ const globalRoot = childProcess.execSync("npm root -g", { encoding: "utf8" }).trim();
24
+ return require(path.join(globalRoot, "playwright"));
25
+ }
26
+ }
27
+
28
+ function measurePage([maximumDarkPageLuminance, minimumContrastRatio, snippetLength]) {
29
+ const parseColor = (colorText) => {
30
+ const channels = (colorText.match(/[\d.]+/g) || []).map(Number);
31
+ return { red: channels[0] || 0, green: channels[1] || 0, blue: channels[2] || 0, alpha: channels.length > 3 ? channels[3] : 1 };
32
+ };
33
+ const channelLuminance = (channel) => {
34
+ const scaled = channel / 255;
35
+ return scaled <= 0.03928 ? scaled / 12.92 : Math.pow((scaled + 0.055) / 1.055, 2.4);
36
+ };
37
+ const luminance = (color) => 0.2126 * channelLuminance(color.red) + 0.7152 * channelLuminance(color.green) + 0.0722 * channelLuminance(color.blue);
38
+ const contrastRatio = (first, second) => {
39
+ const lighter = Math.max(luminance(first), luminance(second));
40
+ const darker = Math.min(luminance(first), luminance(second));
41
+ return (lighter + 0.05) / (darker + 0.05);
42
+ };
43
+ const canvasColor = { red: 255, green: 255, blue: 255, alpha: 1 };
44
+ const effectiveBackground = (element) => {
45
+ for (let current = element; current; current = current.parentElement) {
46
+ const background = parseColor(getComputedStyle(current).backgroundColor);
47
+ if (background.alpha > 0.5) {
48
+ return background;
49
+ }
50
+ }
51
+ return canvasColor;
52
+ };
53
+ const isVisible = (element) => {
54
+ const style = getComputedStyle(element);
55
+ const box = element.getBoundingClientRect();
56
+ return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0 && box.width > 0 && box.height > 0;
57
+ };
58
+ const allFindings = [];
59
+ const pageBackground = effectiveBackground(document.body);
60
+ if (luminance(pageBackground) > maximumDarkPageLuminance) {
61
+ allFindings.push(`the page background stays light (${getComputedStyle(document.body).backgroundColor} on body); give body an explicit background from the theme tokens`);
62
+ }
63
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
64
+ const seenElements = new Set();
65
+ for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
66
+ const element = textNode.parentElement;
67
+ const text = textNode.textContent.trim();
68
+ if (!text || !element || seenElements.has(element) || ["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT"].includes(element.tagName)) {
69
+ continue;
70
+ }
71
+ seenElements.add(element);
72
+ if (!isVisible(element)) {
73
+ continue;
74
+ }
75
+ const foreground = parseColor(getComputedStyle(element).color);
76
+ const background = effectiveBackground(element);
77
+ const ratio = contrastRatio(foreground, background);
78
+ if (ratio < minimumContrastRatio) {
79
+ allFindings.push(`text "${text.slice(0, snippetLength)}" has contrast ${ratio.toFixed(2)}:1 (${getComputedStyle(element).color} on rgb(${background.red}, ${background.green}, ${background.blue}))`);
80
+ }
81
+ }
82
+ return allFindings;
83
+ }
84
+
85
+ async function checkSignal(browser, pageHtml, darkSignal) {
86
+ const page = await browser.newPage({ colorScheme: darkSignal.colorScheme });
87
+ page.setDefaultTimeout(PAGE_LOAD_TIMEOUT_MILLISECONDS);
88
+ try {
89
+ await page.setContent(pageHtml, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT_MILLISECONDS }).catch(() => undefined);
90
+ if (darkSignal.dataTheme) {
91
+ await page.evaluate((themeName) => document.documentElement.setAttribute("data-theme", themeName), darkSignal.dataTheme);
92
+ }
93
+ const allFindings = await page.evaluate(measurePage, [MAXIMUM_DARK_PAGE_LUMINANCE, MINIMUM_CONTRAST_RATIO, TEXT_SNIPPET_LENGTH]);
94
+ return allFindings;
95
+ } finally {
96
+ await page.close();
97
+ }
98
+ }
99
+
100
+ async function main() {
101
+ const pagePath = process.argv[2];
102
+ const pageHtml = fs.readFileSync(pagePath, "utf8");
103
+ let playwright;
104
+ try {
105
+ playwright = loadPlaywright();
106
+ } catch (loadError) {
107
+ process.stderr.write("Playwright is not installed; run: npm install -g playwright && npx playwright install chromium\n");
108
+ return RENDERER_MISSING_EXIT_CODE;
109
+ }
110
+ let browser;
111
+ try {
112
+ browser = await playwright.chromium.launch();
113
+ } catch (launchError) {
114
+ process.stderr.write(`Chromium did not start for Playwright; run: npx playwright install chromium (${launchError.message.split("\n")[0]})\n`);
115
+ return RENDERER_MISSING_EXIT_CODE;
116
+ }
117
+ const report = {};
118
+ try {
119
+ for (const darkSignal of ALL_DARK_SIGNALS) {
120
+ const allFindings = await checkSignal(browser, pageHtml, darkSignal);
121
+ if (allFindings.length > 0) {
122
+ report[darkSignal.name] = allFindings.slice(0, MAXIMUM_REPORTED_TEXT_FAILURES + 1);
123
+ }
124
+ }
125
+ } finally {
126
+ await browser.close();
127
+ }
128
+ process.stdout.write(JSON.stringify(report));
129
+ return 0;
130
+ }
131
+
132
+ main().then((exitCode) => process.exit(exitCode));
@@ -0,0 +1,114 @@
1
+ """Behavior tests for the artifact dark mode publish gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
+
15
+ from blocking.artifact_dark_mode_gate import (
16
+ render_findings,
17
+ format_block_reason,
18
+ published_page_path,
19
+ wrap_in_publish_skeleton,
20
+ )
21
+ from hooks_constants.artifact_dark_mode_gate_constants import (
22
+ ALLOW_EXIT_CODE,
23
+ BLOCK_EXIT_CODE,
24
+ PUBLISH_SKELETON_PREFIX,
25
+ )
26
+
27
+ GATE_SCRIPT_PATH = Path(__file__).resolve().parent / "artifact_dark_mode_gate.py"
28
+ THEME_COLOR_STYLES = (
29
+ "<style>:root{--paper:#faf9f5;--ink:#141413;color-scheme:light dark}"
30
+ "@media (prefers-color-scheme: dark){:root:not([data-theme=light]){--paper:#262624;--ink:#faf9f5}}"
31
+ ":root[data-theme=dark]{--paper:#262624;--ink:#faf9f5}"
32
+ "html{background:var(--paper);color:var(--ink)}h1{color:var(--ink)}</style>"
33
+ )
34
+ PAGE_WITH_TOKENS_ON_HTML_ONLY = THEME_COLOR_STYLES + "<h1>Weekly totals</h1><p>Three runs today.</p>"
35
+ PAGE_WITH_THEMED_BODY = (
36
+ THEME_COLOR_STYLES + "<style>body{background:var(--paper);color:var(--ink)}</style>"
37
+ "<h1>Weekly totals</h1><p>Three runs today.</p>"
38
+ )
39
+
40
+
41
+ def _renderer_available() -> bool:
42
+ if shutil.which("node") is None:
43
+ return False
44
+ findings_by_signal, _ = render_findings("<p>probe</p>")
45
+ return findings_by_signal is not None
46
+
47
+
48
+ needs_renderer = pytest.mark.skipif(
49
+ not _renderer_available(), reason="node Playwright with Chromium is not installed"
50
+ )
51
+
52
+
53
+ def _run_gate(tool_input: dict[str, object]) -> subprocess.CompletedProcess[str]:
54
+ return subprocess.run(
55
+ [sys.executable, str(GATE_SCRIPT_PATH)],
56
+ input=json.dumps({"tool_name": "Artifact", "tool_input": tool_input}),
57
+ capture_output=True,
58
+ text=True,
59
+ check=False,
60
+ )
61
+
62
+
63
+ def test_should_return_the_page_path_for_an_html_publish() -> None:
64
+ assert published_page_path("Artifact", {"file_path": "/tmp/page.html"}) == Path(
65
+ "/tmp/page.html"
66
+ )
67
+
68
+
69
+ def test_should_skip_reads_assets_typed_creates_and_other_tools() -> None:
70
+ assert published_page_path("Artifact", {"action": "read", "url": "x"}) is None
71
+ assert published_page_path("Artifact", {"file_path": "/tmp/logo.html", "asset": True}) is None
72
+ assert published_page_path("Artifact", {"file_path": "/tmp/data.html", "type_url": "x"}) is None
73
+ assert published_page_path("Artifact", {"file_path": "/tmp/notes.md"}) is None
74
+ assert published_page_path("Write", {"file_path": "/tmp/page.html"}) is None
75
+
76
+
77
+ def test_should_put_the_page_after_the_skeleton_that_pins_body_light() -> None:
78
+ wrapped_page = wrap_in_publish_skeleton("<p>x</p>")
79
+ assert wrapped_page == PUBLISH_SKELETON_PREFIX + "<p>x</p>"
80
+ assert "background:#faf9f5" in wrapped_page
81
+
82
+
83
+ def test_should_list_each_finding_under_its_dark_signal() -> None:
84
+ block_reason = format_block_reason(
85
+ {"prefers-color-scheme: dark": ['text "A" has contrast 1.00:1']}
86
+ )
87
+ assert "Under prefers-color-scheme: dark:" in block_reason
88
+ assert '- text "A" has contrast 1.00:1' in block_reason
89
+ assert "body{background:var(--paper);color:var(--ink)}" in block_reason
90
+
91
+
92
+ @needs_renderer
93
+ def test_should_fail_a_page_that_themes_html_but_not_body() -> None:
94
+ findings_by_signal, _ = render_findings(PAGE_WITH_TOKENS_ON_HTML_ONLY)
95
+ assert set(findings_by_signal) == {"prefers-color-scheme: dark", 'data-theme="dark"'}
96
+ assert any(
97
+ "Weekly totals" in each_finding for each_finding in findings_by_signal['data-theme="dark"']
98
+ )
99
+
100
+
101
+ @needs_renderer
102
+ def test_should_pass_a_page_that_themes_body() -> None:
103
+ assert render_findings(PAGE_WITH_THEMED_BODY) == ({}, "")
104
+
105
+
106
+ @needs_renderer
107
+ def test_should_deny_and_then_allow_the_publish_call(tmp_path: Path) -> None:
108
+ page_path = tmp_path / "report.html"
109
+ page_path.write_text(PAGE_WITH_TOKENS_ON_HTML_ONLY, encoding="utf-8")
110
+ denied_run = _run_gate({"action": "publish", "file_path": str(page_path)})
111
+ assert denied_run.returncode == BLOCK_EXIT_CODE
112
+ assert "has unreadable text" in denied_run.stderr
113
+ page_path.write_text(PAGE_WITH_THEMED_BODY, encoding="utf-8")
114
+ assert _run_gate({"file_path": str(page_path)}).returncode == ALLOW_EXIT_CODE
@@ -12,6 +12,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
12
12
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
+ - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
15
16
 
16
17
  ## When it fires
17
18
 
@@ -23,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
23
24
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
24
25
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
25
26
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
27
+ - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
26
28
 
27
29
  ## Proving it
28
30
 
@@ -38,6 +40,7 @@ Preconditions:
38
40
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
39
41
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
40
42
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
43
+ - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
41
44
 
42
45
  ## Gotchas
43
46
 
@@ -47,3 +50,4 @@ Preconditions:
47
50
  - The edit marker gate scans replacement cards as well as the message text.
48
51
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
49
52
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
53
+ - The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
package/hooks/hooks.json CHANGED
@@ -152,6 +152,16 @@
152
152
  }
153
153
  ]
154
154
  },
155
+ {
156
+ "matcher": "Artifact",
157
+ "hooks": [
158
+ {
159
+ "type": "command",
160
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_dark_mode_gate.py",
161
+ "timeout": 60
162
+ }
163
+ ]
164
+ },
155
165
  {
156
166
  "matcher": "Artifact|Skill",
157
167
  "hooks": [
@@ -0,0 +1,57 @@
1
+ """Names, limits and messages for the artifact_dark_mode_gate PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ __all__ = [
8
+ "ALLOW_EXIT_CODE",
9
+ "ALL_SKIPPED_PUBLISH_INPUT_KEYS",
10
+ "ARTIFACT_ACTION_INPUT_KEY",
11
+ "ARTIFACT_FILE_PATH_INPUT_KEY",
12
+ "ARTIFACT_PUBLISH_ACTION",
13
+ "ARTIFACT_TOOL_NAME",
14
+ "BLOCK_EXIT_CODE",
15
+ "DARK_MODE_FAILURE_HEADER",
16
+ "GATE_EVENT_NAME",
17
+ "HTML_FILE_SUFFIX",
18
+ "PUBLISH_SKELETON_PREFIX",
19
+ "RENDERER_MISSING_EXIT_CODE",
20
+ "RENDERER_MISSING_HEADER",
21
+ "RENDERER_SCRIPT_PATH",
22
+ "RENDERER_TIMEOUT_SECONDS",
23
+ "RETRY_INSTRUCTION",
24
+ ]
25
+
26
+ ALLOW_EXIT_CODE = 0
27
+ BLOCK_EXIT_CODE = 2
28
+ RENDERER_MISSING_EXIT_CODE = 3
29
+ RENDERER_TIMEOUT_SECONDS = 50
30
+ ARTIFACT_TOOL_NAME = "Artifact"
31
+ ARTIFACT_ACTION_INPUT_KEY = "action"
32
+ ARTIFACT_PUBLISH_ACTION = "publish"
33
+ ARTIFACT_FILE_PATH_INPUT_KEY = "file_path"
34
+ ALL_SKIPPED_PUBLISH_INPUT_KEYS = ("asset", "type_url")
35
+ HTML_FILE_SUFFIX = ".html"
36
+ GATE_EVENT_NAME = "PreToolUse"
37
+ RENDERER_SCRIPT_PATH = (
38
+ Path(__file__).resolve().parent.parent / "blocking" / "artifact_dark_mode_render.cjs"
39
+ )
40
+ PUBLISH_SKELETON_PREFIX = (
41
+ "<!doctype html><html><head><meta charset=utf8>"
42
+ '<meta name=viewport content="width=device-width,initial-scale=1">'
43
+ "<style>:root{color-scheme:light}body{margin:0;padding:0;"
44
+ "font:14px -apple-system,BlinkMacSystemFont,sans-serif;"
45
+ "background:#faf9f5;color:#141413}img{max-width:100%}"
46
+ "[hidden]:not([hidden=until-found i]){display:none!important}</style>"
47
+ "</head><body>\n"
48
+ )
49
+ DARK_MODE_FAILURE_HEADER = (
50
+ "Inside the artifact publish wrapper, this page has unreadable text when the viewer uses dark mode.\n"
51
+ )
52
+ RENDERER_MISSING_HEADER = "The page did not render for its contrast check before publish: "
53
+ RETRY_INSTRUCTION = (
54
+ "\nThe publish wrapper pins body to a light background and dark text. Set body background and"
55
+ " color from the page's theme tokens, for example body{background:var(--paper);color:var(--ink)},"
56
+ ' so both flip under prefers-color-scheme: dark and data-theme="dark". Then publish again.'
57
+ )
@@ -0,0 +1,17 @@
1
+ """Behavioral tests for the artifact page gate's paths and publish wrapper."""
2
+
3
+ from hooks_constants.artifact_dark_mode_gate_constants import (
4
+ PUBLISH_SKELETON_PREFIX,
5
+ RENDERER_SCRIPT_PATH,
6
+ )
7
+
8
+
9
+ def test_renderer_path_names_the_packaged_render_script() -> None:
10
+ assert RENDERER_SCRIPT_PATH.is_file()
11
+ assert RENDERER_SCRIPT_PATH.suffix == ".cjs"
12
+
13
+
14
+ def test_publish_wrapper_pins_body_light_and_opens_the_body() -> None:
15
+ assert "body{margin:0;padding:0;" in PUBLISH_SKELETON_PREFIX
16
+ assert "background:#faf9f5;color:#141413" in PUBLISH_SKELETON_PREFIX
17
+ assert PUBLISH_SKELETON_PREFIX.endswith("<body>\n")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.65.1",
3
+ "version": "8.66.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -135,6 +135,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
135
135
  hooks/blocking/verify_before_acting.py -> exempt
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/session_title_stop_gate.py -> exempt
138
+ hooks/blocking/artifact_dark_mode_gate.py -> exempt
138
139
  hooks/blocking/some_new_blocker.py -> flagged
139
140
 
140
141
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -163,6 +164,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
163
164
  denies a malformed title, and the other asks for a title before a turn
164
165
  ends. They decide the shape of a title and no code or safety policy.
165
166
 
167
+ The artifact page gate denies a page publish whose text is unreadable in
168
+ dark mode. It decides how a page renders and no code or safety policy.
169
+
166
170
  Args:
167
171
  registered_string: One command, path, script, or entrypoint string.
168
172
  """
@@ -71,6 +71,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
71
71
  "blocking/verify_before_acting.py",
72
72
  "blocking/session_title_format_gate.py",
73
73
  "blocking/session_title_stop_gate.py",
74
+ "blocking/artifact_dark_mode_gate.py",
74
75
  )
75
76
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
76
77
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -109,6 +109,14 @@ def test_new_session_title_gate_registrations_are_clean(tmp_path: Path) -> None:
109
109
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
110
110
 
111
111
 
112
+ def test_new_artifact_page_gate_registration_is_clean(tmp_path: Path) -> None:
113
+ current_document = _hook_document(
114
+ ["hooks/blocking/artifact_dark_mode_gate.py"],
115
+ '{"hooks": {}}',
116
+ )
117
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
118
+
119
+
112
120
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
113
121
  current_document = _hook_document(
114
122
  ["hooks/blocking/new.py"],