diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..6e499f3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "effective-html", + "description": "Skills for generating self-contained HTML artifacts — html, html-diagram, html-plan — in the effective-HTML style with dark mode support.", + "owner": { + "name": "Andrey Zagreev", + "url": "https://github.com/azagreev" + }, + "plugins": [ + { + "name": "effective-html", + "source": ".", + "description": "Skills for generating self-contained HTML artifacts — html, html-diagram, html-plan — in the effective-HTML style with dark mode support.", + "category": "design" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9ea9960..b2d38d4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,8 +1,26 @@ { - "name": "plannotator-effective-html", + "name": "effective-html", + "version": "0.1.1", + "description": "Skills for generating self-contained HTML artifacts — html, html-diagram, html-plan — in the effective-HTML style with dark mode support.", + "author": { + "name": "Andrey Zagreev", + "url": "https://github.com/azagreev" + }, + "keywords": [ + "html", + "diagram", + "plan", + "dark-mode", + "artifacts", + "visualization", + "svg" + ], + "homepage": "https://github.com/azagreev/effective-html", + "repository": "https://github.com/azagreev/effective-html", + "license": "MIT", "skills": [ "./skills/html", "./skills/html-diagram", "./skills/html-plan" ] -} \ No newline at end of file +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..99241a6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize line endings: store and check out LF everywhere. +# Prevents Windows CRLF normalization from dirtying marketplace clones, +# which can block in-place git pulls / updates. +* text=auto eol=lf + +*.md text eol=lf +*.json text eol=lf +*.js text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7f8cc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# OS / editor +.DS_Store +Thumbs.db +desktop.ini +*~ +.vscode/ +.idea/ + +# Logs / temp +*.log +*.tmp + +# Secrets / local env +.env +.env.local +*.key + +# Python (engine/) +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Node (if tooling is added later) +node_modules/ +npm-debug.log* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0698e8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.1] - 2026-06-13 + +### Fixed +- Light-theme accent contrast: darkened `--clay` (#C25E3C → #B45738) and `--gold` (#9A6B1F → #98691F) in both exemplars so all accent text meets WCAG AA (≥4.5:1) for normal text on both backgrounds, not only large text. Dark theme already AAA; unchanged. + +## [0.1.0] - 2026-06-13 + +### Added +- Claude Code marketplace distribution: root `.claude-plugin/marketplace.json` (install via `/plugin marketplace add azagreev/effective-html`). +- Enriched `.claude-plugin/plugin.json` (version, description, author, keywords, homepage, repository, license); plugin renamed to `effective-html`. +- `docs/installation.md` covering both channels (Claude Code marketplace + skills.sh) and manual install. +- New dark-mode exemplars: `skills/html/references/effective-html-example.html` and `skills/html-plan/references/plan-example.html`; all three SKILL.md now point at a canonical exemplar and carry an explicit Quality-contract checklist (dark mode, reduced-motion, focus-visible, aria-labels, contrast). +- Dependency-free structural test suite `tests/test_structure.py` (manifest integrity, name consistency, dark-mode contract, attribution). +- Repo hygiene: `.gitattributes` (LF normalization), `.gitignore`, `NOTICE` (upstream attribution: backnotprop/plannotator, thariqs/html-effectiveness). + +### Changed +- README install instructions corrected to the fork `azagreev/effective-html` npx slug (previously pointed at the upstream org); added marketplace section and upstream attribution. + +[0.1.1]: https://github.com/azagreev/effective-html/releases/tag/v0.1.1 +[0.1.0]: https://github.com/azagreev/effective-html/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE index 32ba103..2123dea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 plannotator +Copyright (c) 2026 Andrey Zagreev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..87d69e1 --- /dev/null +++ b/NOTICE @@ -0,0 +1,19 @@ +effective-html +Copyright (c) 2026 Andrey Zagreev +Licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------ +Upstream attributions +------------------------------------------------------------------------ + +Fork origin — backnotprop/plannotator + This repository is forked from https://github.com/backnotprop/plannotator, + which provides the skills layout and structure this project builds on. + +Bundled example corpus — thariqs/html-effectiveness + The html-effectiveness example corpus by Thariq Shihipar is bundled under + each skill's references/html-effectiveness/ directory. + Source: https://thariqs.github.io/html-effectiveness + https://github.com/thariqs/html-effectiveness + Licensed under the Apache License, Version 2.0 (see the LICENSE file + inside those reference folders). diff --git a/README.md b/README.md index 411bef3..9236f19 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,34 @@ Render and annotate your HTML with Plannotator (optional): https://github.com/ba --- -## Install +## Install via Claude Code marketplace ```bash -npx skills add plannotator/effective-html +/plugin marketplace add azagreev/effective-html +/plugin install effective-html@effective-html +``` + +In Cowork: **Customize → Browse plugins → Personal → + → Add marketplace from GitHub → `azagreev/effective-html`**, then install the **effective-html** plugin. + +See [docs/installation.md](docs/installation.md) for all install channels (Claude Code / Cowork, skills.sh, and manual copy), plus verify and uninstall steps. + +## Install via skills.sh + +```bash +npx skills add azagreev/effective-html ``` List available skills first: ```bash -npx skills add plannotator/effective-html --list +npx skills add azagreev/effective-html --list ``` Install a specific skill: ```bash -npx skills add plannotator/effective-html --skill html-diagram -npx skills add plannotator/effective-html --skill html-plan +npx skills add azagreev/effective-html --skill html-diagram +npx skills add azagreev/effective-html --skill html-plan ``` ## Skills @@ -54,4 +65,7 @@ npx skills add plannotator/effective-html --skill html-plan Skills live under `skills//SKILL.md`. Each skill also bundles a copy of the `html-effectiveness` example corpus under `references/html-effectiveness/` so the examples stay local to the skill. -Credit: this repo bundles and uses the `html-effectiveness` examples by Thariq Shihipar: https://thariqs.github.io/html-effectiveness +## Attribution + +- Fork origin: [backnotprop/plannotator](https://github.com/backnotprop/plannotator). +- Bundled example corpus: this repo bundles and uses the `html-effectiveness` examples by Thariq Shihipar — https://thariqs.github.io/html-effectiveness ([thariqs/html-effectiveness](https://github.com/thariqs/html-effectiveness)). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..902443d --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,141 @@ +# Installation — effective-html + +These skills ship two ways: as a **Claude Code plugin** (a GitHub marketplace) and as **skills.sh skills** you can pull into any project. The bundle contains three skills: `html`, `html-diagram`, and `html-plan`. + +Both channels stay prominent — pick whichever fits your setup. + +--- + +## Channel 1: Claude Code / Cowork — plugin marketplace (recommended) + +### Cowork (GUI, no files) + +1. Open **Customize** (bottom-left). +2. **Browse plugins → Personal → +**. +3. **Add marketplace from GitHub**. +4. Enter: `azagreev/effective-html`. +5. Install the plugin **effective-html** — the three skills connect automatically. + +### Claude Code (CLI) + +```bash +# 1. Add the marketplace from GitHub +/plugin marketplace add azagreev/effective-html + +# 2. Install the plugin (format: @) +/plugin install effective-html@effective-html +``` + +Once installed, `html`, `html-diagram`, and `html-plan` activate automatically when you ask for HTML artifacts, diagrams, or plan pages. + +### Local pre-publish check (from a clone) + +You can add the marketplace from a local folder — handy before pushing, or to test changes: + +```bash +git clone https://github.com/azagreev/effective-html.git +cd effective-html +/plugin marketplace add ./ # path to the folder containing .claude-plugin/marketplace.json +/plugin install effective-html@effective-html +``` + +> The plugin `source` is `.` (the repo root), so adding the marketplace locally resolves exactly the same way as adding it from GitHub. + +--- + +## Channel 2: skills.sh + +Install all three skills into the current project with one command: + +```bash +npx skills add azagreev/effective-html +``` + +List available skills before installing: + +```bash +npx skills add azagreev/effective-html --list +``` + +Install a specific skill only: + +```bash +npx skills add azagreev/effective-html --skill html-diagram +npx skills add azagreev/effective-html --skill html-plan +``` + +--- + +## Channel 3: Manual copy into the Claude skills dir + +Each skill is self-contained under `skills//`, so you can copy the folders straight into Claude's skills directory: + +- **macOS**: `~/Library/Application Support/Claude/skills/` +- **Windows**: `%APPDATA%/Claude/skills/` +- **Linux**: `~/.config/Claude/skills/` + +```bash +git clone https://github.com/azagreev/effective-html.git +cd effective-html + +# copy each skill folder (Linux path shown; swap for your OS dir above) +cp -r skills/html ~/.config/Claude/skills/html +cp -r skills/html-diagram ~/.config/Claude/skills/html-diagram +cp -r skills/html-plan ~/.config/Claude/skills/html-plan +``` + +On Windows (PowerShell): + +```powershell +Copy-Item -Recurse skills\html "$env:APPDATA\Claude\skills\html" +Copy-Item -Recurse skills\html-diagram "$env:APPDATA\Claude\skills\html-diagram" +Copy-Item -Recurse skills\html-plan "$env:APPDATA\Claude\skills\html-plan" +``` + +Each `skills//` folder bundles its own copy of the `html-effectiveness` example corpus under `references/html-effectiveness/`, so the examples travel with the skill — no extra steps. + +Restart Claude after copying. + +--- + +## Verify + +Ask in any chat: + +``` +Make an HTML diagram of a three-tier web app architecture +``` + +`html-diagram` should activate and produce a full-screen SVG-first HTML diagram. Try `html` for a general artifact and `html-plan` for a plan page. + +### If a skill doesn't activate + +1. **Claude Code:** run `/plugin` → confirm `effective-html` is installed and enabled; `/reload-plugins` if needed. +2. In `/plugin` → Marketplaces, check the marketplace version matches the latest release (third-party marketplaces do not auto-update). +3. **Manual / skills.sh:** confirm the skill folder exists in your skills dir and contains `SKILL.md`, then restart Claude. + +--- + +## Uninstall + +```bash +# Claude Code plugin +/plugin uninstall effective-html@effective-html +/plugin marketplace remove effective-html + +# Manual install (Linux path; swap for your OS) +rm -rf ~/.config/Claude/skills/html \ + ~/.config/Claude/skills/html-diagram \ + ~/.config/Claude/skills/html-plan +``` + +For skills.sh installs, remove the skill folders that `npx skills add` placed in your project's skills directory. + +--- + +## Attribution + +- Fork origin: [backnotprop/plannotator](https://github.com/backnotprop/plannotator). +- Bundled example corpus: `html-effectiveness` by Thariq Shihipar — https://thariqs.github.io/html-effectiveness ([thariqs/html-effectiveness](https://github.com/thariqs/html-effectiveness)). + + diff --git a/skills/html-diagram/SKILL.md b/skills/html-diagram/SKILL.md index e21ecc2..5fc1ff7 100644 --- a/skills/html-diagram/SKILL.md +++ b/skills/html-diagram/SKILL.md @@ -21,3 +21,17 @@ If it makes sense, make the diagram interactive and able to visualize and animat Also review `references/architecture-example.html` — a finished example of this skill done well (full-screen SVG stage, clickable nodes, flow chips that light up and animate request paths). Always include dark mode: hand-rolled CSS variables on `:root` / `html.dark`, a small theme toggle button, `localStorage` persistence, and an apply-before-paint script in `` (default to `prefers-color-scheme`). Style the SVG through CSS classes using those variables — never hard-coded hex inside the SVG — so the diagram follows the theme. + +The canonical exemplar to match is `references/architecture-example.html`. + +## Quality contract + +Every artifact must satisfy all of these — `references/architecture-example.html` shows each one in place: + +- **Dark mode** — hand-rolled CSS variables on `:root` and `html.dark`, a small theme toggle button, `localStorage` persistence of the choice, and an apply-before-paint inline script in `` that reads `localStorage` else `prefers-color-scheme` and sets the class before first paint (no flash). Style the SVG through those variables, never hard-coded hex. +- **`@media (prefers-reduced-motion: reduce)`** that disables or limits transitions and animations. +- **Visible `:focus-visible`** rings on every interactive element. +- **`aria-label`** on any icon-only control (e.g. the theme toggle) and on the diagram `` itself. +- **``** and a ``. +- **No emoji as structural icons** — use inline SVG or text. +- **Body and heading contrast ≥ 4.5:1 in both themes.** diff --git a/skills/html-plan/SKILL.md b/skills/html-plan/SKILL.md index fbd3b66..41bcfa2 100644 --- a/skills/html-plan/SKILL.md +++ b/skills/html-plan/SKILL.md @@ -12,4 +12,18 @@ After reviewing them, create an HTML file for the plan in a similar style. Keep it pragmatic and simple. +Also review `references/plan-example.html` — a finished example of this skill done well (phased implementation plan with tasks, owners, and status that implements the full quality contract below). Match it. + Always include dark mode: hand-rolled CSS variables on `:root` / `html.dark`, a small theme toggle button, `localStorage` persistence, and an apply-before-paint script in `` (default to `prefers-color-scheme`). + +## Quality contract + +Every artifact must satisfy all of these — `references/plan-example.html` shows each one in place: + +- **Dark mode** — hand-rolled CSS variables on `:root` and `html.dark`, a small theme toggle button, `localStorage` persistence of the choice, and an apply-before-paint inline script in `` that reads `localStorage` else `prefers-color-scheme` and sets the class before first paint (no flash). +- **`@media (prefers-reduced-motion: reduce)`** that disables or limits transitions and animations. +- **Visible `:focus-visible`** rings on every interactive element. +- **`aria-label`** on any icon-only control (e.g. the theme toggle). +- **``** and a ``. +- **No emoji as structural icons** — use inline SVG or text. +- **Body and heading contrast ≥ 4.5:1 in both themes.** diff --git a/skills/html-plan/references/plan-example.html b/skills/html-plan/references/plan-example.html new file mode 100644 index 0000000..be50aaf --- /dev/null +++ b/skills/html-plan/references/plan-example.html @@ -0,0 +1,531 @@ + + + + + +Acme — Self-serve onboarding plan + + + + +
+ +
+ implementation plan +
+ +
+ +
+
Plan · Acme growth squad
+

Self-serve onboarding without a sales call

+
+ Goal + Let a new team go from signup to first shared workspace in under five + minutes, with no human in the loop. Ship in four phases, each behind the + self_serve_v1 flag, nothing user-visible until phase 4. +
+
+ +
+
Duration
~3 weeks
+
Squad
4 people
+
Surfaces
3 services
+
Flag
self_serve_v1
+
+ +
+
01

Phases

+

+ Each phase is independently reviewable and shippable behind the flag. + Owners are accountable, not solely assigned — pair where it helps. +

+ +
+
+ Week 1 +

Phase 1 · Passwordless signup

+ Done +
+ + + + + + + + + + + + + + + + +
Email magic-link flow with rate limitingPriyadone
Provision a starter workspace on first loginDevondone
Audit-log the new signup pathSamdone
+
+ +
+
+ Week 1–2 +

Phase 2 · Guided first-run checklist

+ In progress +
+ + + + + + + + + + + + + + + + +
Checklist component & onboarding_state tableMiradone
Wire the three first-run steps to real eventsMirawip
Empty-state copy & illustrationsPriyawip
+
+ +
+
+ Week 2 +

Phase 3 · Invite teammates inline

+ Next +
+ + + + + + + + + + + +
Bulk invite by email, pending-seat modelDevontodo
Invite-accept deep link into the workspaceSamtodo
+
+ +
+
+ Week 3 +

Phase 4 · Flag ramp & instrumentation

+ Next +
+ + + + + + + + + + + +
Funnel events: signup → first-share, with drop-offMiratodo
Ramp self_serve_v1: internal → 10% → 100%Devontodo
+
+
+ +
+
02

Risks & mitigations

+
+
+
Risk
+
Sev
+
Mitigation
+
+
+
Magic-link emails land in spam, so signup silently dead-ends.
+
High
+
Warm a dedicated sending domain, monitor deliverability, and fall back to a code-entry path.
+
+
+
Auto-provisioned workspaces pile up from drive-by signups.
+
Med
+
Reap empty workspaces after 14 days of zero activity; exclude any with an accepted invite.
+
+
+
Checklist feels naggy and users dismiss it before the aha moment.
+
Low
+
Collapse after the first completed step; never re-surface once dismissed.
+
+
+
+ +
+
03

Open questions

+
+
+
Do we gate invites behind a verified email?
+
Verifying first is safer but adds a step before the social moment. Leaning toward letting invites go out immediately and verifying the inviter lazily.
+
Decide with · security, before phase 3
+
+
+
What's the starter workspace seeded with?
+
An empty workspace is a cold start. Proposal: one sample project plus a "delete this when you're ready" banner, so the first screen isn't blank.
+
Decide with · design, before phase 2 ends
+
+
+
+ +
+ Owner: growth squad · flag self_serve_v1 · review weekly +  —  last updated Sep 02 2025 +
+ +
+ + + + diff --git a/skills/html/SKILL.md b/skills/html/SKILL.md index f2a2e27..3812f6c 100644 --- a/skills/html/SKILL.md +++ b/skills/html/SKILL.md @@ -10,4 +10,18 @@ Review the files throughout `references/html-effectiveness/`. Create an HTML file for whatever the user is describing. Use the references as best you can to match alignment — style, density, and tone. +Also review `references/effective-html-example.html` — a finished example of this skill done well (status-report / comparison layout that implements the full quality contract below). Match it. + Always include dark mode: hand-rolled CSS variables on `:root` / `html.dark`, a small theme toggle button, `localStorage` persistence, and an apply-before-paint script in `` (default to `prefers-color-scheme`). + +## Quality contract + +Every artifact must satisfy all of these — `references/effective-html-example.html` shows each one in place: + +- **Dark mode** — hand-rolled CSS variables on `:root` and `html.dark`, a small theme toggle button, `localStorage` persistence of the choice, and an apply-before-paint inline script in `` that reads `localStorage` else `prefers-color-scheme` and sets the class before first paint (no flash). +- **`@media (prefers-reduced-motion: reduce)`** that disables or limits transitions and animations. +- **Visible `:focus-visible`** rings on every interactive element. +- **`aria-label`** on any icon-only control (e.g. the theme toggle). +- **``** and a ``. +- **No emoji as structural icons** — use inline SVG or text. +- **Body and heading contrast ≥ 4.5:1 in both themes.** diff --git a/skills/html/references/effective-html-example.html b/skills/html/references/effective-html-example.html new file mode 100644 index 0000000..8398ca2 --- /dev/null +++ b/skills/html/references/effective-html-example.html @@ -0,0 +1,500 @@ + + + + + +Acme Sync — Q3 Status + + + + +
+ +
+ status report +
+ +
+ +
+
Acme Sync · product status · Q3 2025
+

Real-time sync left beta on schedule

+

+ The new conflict-free sync engine shipped to 100% of workspaces + on Aug 14, three days ahead of plan. This is the quarter recap: what landed, + how we now compare to the field, and the one risk we're still carrying. +

+
+ +
+
+
+
100%
+
Rollout reached
+
from 12% in July
+
+
+
38%
+
Sync p95 cut
+
410ms → 255ms
+
+
+
1
+
Open risk
+
offline replay
+
+
+
0
+
Rollback events
+
clean ramp
+
+
+
+ +
+

What landed

+
+
    +
  • + Conflict-free merge by default. Edits from multiple + devices now reconcile through a CRDT log instead of last-write-wins, so + two people editing the same record no longer clobber each other. +
  • +
  • + Sync p95 down 38%. Replacing per-record auth checks with + one scoped batch lookup cut the hot path from 410ms to 255ms on the + staging load test. +
  • +
  • + Presence indicators shipped. Open a shared record and you + see who else is in it, live — the most-requested item from the spring + customer council. +
  • +
+
+ +
+

How we compare

+
+

+ Against the two products customers most often evaluate us next to. + Fictional positioning for this reference — not a benchmark. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityAcme SyncNorthwindGlobex
Conflict-free mergeYesLast-writeNo
Live presenceYesYesNo
Offline replayBetaNoYes
Self-host optionYesNoYes
+
+ +
+

Adoption ramp

+
+
+ + + + + + + 0 + 33 + 66 + 100 + + + + + + + + + 12% + 28% + 55% + 82% + 100% + + Wk 1 + Wk 2 + Wk 3 + Wk 4 + Wk 5 + +
+ Share of workspaces on the new engine. We held at 82% for two extra days to + watch error rates before the final push to everyone. +
+
+
+ +
+

The risk we're carrying

+
+
+

Offline replay is still beta-gated

+

+ A client that's offline for more than 24 hours can build a replay log + large enough to stall the merge on reconnect. It's behind the + offline_replay_v2 flag for now. Fix is scoped for early Q4: + chunk the replay and apply it incrementally so the UI never blocks. +

+
+
+ +
+ Sources: rollout dashboard · staging load test · customer council notes +  —  compiled Aug 18 2025 +
+ +
+ + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_structure.py b/tests/test_structure.py new file mode 100644 index 0000000..114670b --- /dev/null +++ b/tests/test_structure.py @@ -0,0 +1,426 @@ +"""Structural integrity tests for the effective-html plugin repo. + +Run from the repo root: + python -m unittest discover -s tests + +All paths are resolved relative to the tests/ directory so the suite is +CWD-independent. +""" + +import json +import re +import unittest +from pathlib import Path + +# Repo root is the parent of the tests/ directory. +REPO = Path(__file__).resolve().parents[1] + +PLUGIN_JSON = REPO / ".claude-plugin" / "plugin.json" +MARKETPLACE_JSON = REPO / ".claude-plugin" / "marketplace.json" +SKILLS_SH_JSON = REPO / "skills.sh.json" + +EXPECTED_SKILLS = {"html", "html-diagram", "html-plan"} + +EXEMPLAR_FILENAMES = { + "html": "effective-html-example.html", + "html-plan": "plan-example.html", + "html-diagram": "architecture-example.html", +} + +DARK_MODE_REQUIRED = { + "prefers-color-scheme", + "localStorage", +} +DARK_MODE_INDICATORS = { + "html.dark", + "classList.toggle", + "data-theme", + "toggleTheme", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _load_json(path: Path) -> object: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def _parse_frontmatter(path: Path) -> dict: + """Return a dict of key: value from the leading --- ... --- block. + + Uses a simple line scan — no yaml dependency. + """ + result = {} + text = _read_text(path) + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return result + for line in lines[1:]: + if line.strip() == "---": + break + if ":" in line: + key, _, value = line.partition(":") + result[key.strip()] = value.strip() + return result + + +def _html_files_under(directory: Path): + """Yield all .html files under *directory* recursively.""" + return list(directory.rglob("*.html")) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestRepoStructure(unittest.TestCase): + + # T1 ---------------------------------------------------------------- + def test_t1_all_three_manifests_parse_as_valid_json(self): + """plugin.json, marketplace.json, and skills.sh.json are valid JSON.""" + for path in (PLUGIN_JSON, MARKETPLACE_JSON, SKILLS_SH_JSON): + with self.subTest(file=path.name): + self.assertTrue( + path.exists(), + f"Manifest file not found: {path}", + ) + try: + _load_json(path) + except json.JSONDecodeError as exc: + self.fail(f"{path.name} is not valid JSON: {exc}") + + # T2 ---------------------------------------------------------------- + def test_t2_plugin_name_consistent_across_manifests(self): + """plugin.json['name'] == marketplace.json['plugins'][0]['name'] == 'effective-html'.""" + plugin = _load_json(PLUGIN_JSON) + market = _load_json(MARKETPLACE_JSON) + + plugin_name = plugin.get("name") + marketplace_name = market.get("plugins", [{}])[0].get("name") + + self.assertEqual( + plugin_name, + "effective-html", + f"plugin.json name is '{plugin_name}', expected 'effective-html'", + ) + self.assertEqual( + marketplace_name, + "effective-html", + f"marketplace.json plugins[0].name is '{marketplace_name}', expected 'effective-html'", + ) + self.assertEqual( + plugin_name, + marketplace_name, + "plugin.json name and marketplace.json plugins[0].name do not match", + ) + + # T3 ---------------------------------------------------------------- + def test_t3_plugin_skills_entries_resolve_to_dirs_with_skill_md(self): + """Every entry in plugin.json['skills'] resolves to a dir containing SKILL.md.""" + plugin = _load_json(PLUGIN_JSON) + skills_entries = plugin.get("skills", []) + self.assertTrue(skills_entries, "plugin.json['skills'] is empty") + + for entry in skills_entries: + skill_dir = (REPO / entry).resolve() + with self.subTest(entry=entry): + self.assertTrue( + skill_dir.is_dir(), + f"skills entry '{entry}' does not resolve to a directory: {skill_dir}", + ) + skill_md = skill_dir / "SKILL.md" + self.assertTrue( + skill_md.is_file(), + f"SKILL.md not found in {skill_dir}", + ) + + # T4 ---------------------------------------------------------------- + def test_t4_skill_md_frontmatter_has_nonempty_name_and_description(self): + """Each skills/*/SKILL.md has YAML frontmatter with non-empty name: and description:.""" + for skill_name in EXPECTED_SKILLS: + skill_md = REPO / "skills" / skill_name / "SKILL.md" + with self.subTest(skill=skill_name): + self.assertTrue( + skill_md.is_file(), + f"SKILL.md not found: {skill_md}", + ) + fm = _parse_frontmatter(skill_md) + self.assertIn( + "name", + fm, + f"{skill_md} frontmatter missing 'name:' key", + ) + self.assertTrue( + fm["name"], + f"{skill_md} frontmatter 'name:' is empty", + ) + self.assertIn( + "description", + fm, + f"{skill_md} frontmatter missing 'description:' key", + ) + self.assertTrue( + fm["description"], + f"{skill_md} frontmatter 'description:' is empty", + ) + + # T5 ---------------------------------------------------------------- + def test_t5_skill_names_consistent_with_skills_sh_json(self): + """skills.sh.json grouping skills match plugin.json skill basenames exactly.""" + skills_sh = _load_json(SKILLS_SH_JSON) + plugin = _load_json(PLUGIN_JSON) + + # Collect all skill names referenced in skills.sh.json groupings + sh_skills = set() + for group in skills_sh.get("groupings", []): + for s in group.get("skills", []): + sh_skills.add(s) + + # Collect basenames from plugin.json skills paths + plugin_skill_basenames = { + Path(entry).name for entry in plugin.get("skills", []) + } + + self.assertEqual( + sh_skills, + EXPECTED_SKILLS, + f"skills.sh.json groupings skills {sh_skills} != expected {EXPECTED_SKILLS}", + ) + self.assertEqual( + plugin_skill_basenames, + EXPECTED_SKILLS, + f"plugin.json skill basenames {plugin_skill_basenames} != expected {EXPECTED_SKILLS}", + ) + self.assertEqual( + sh_skills, + plugin_skill_basenames, + f"skills.sh.json skills {sh_skills} do not match plugin.json basenames {plugin_skill_basenames}", + ) + + # Also verify each referenced skill dir exists + for skill_name in sh_skills: + skill_dir = REPO / "skills" / skill_name + with self.subTest(skill=skill_name): + self.assertTrue( + skill_dir.is_dir(), + f"Skill directory not found: {skill_dir}", + ) + + # T6 ---------------------------------------------------------------- + def test_t6_each_skill_has_nonempty_html_effectiveness_references_dir(self): + """Each of the three skills has a non-empty references/html-effectiveness/ dir (≥1 .html file).""" + for skill_name in EXPECTED_SKILLS: + refs_dir = REPO / "skills" / skill_name / "references" / "html-effectiveness" + with self.subTest(skill=skill_name): + self.assertTrue( + refs_dir.is_dir(), + f"references/html-effectiveness/ dir not found for skill '{skill_name}': {refs_dir}", + ) + html_files = _html_files_under(refs_dir) + self.assertGreater( + len(html_files), + 0, + f"references/html-effectiveness/ for skill '{skill_name}' contains no .html files", + ) + + # T7 ---------------------------------------------------------------- + def test_t7_marketplace_json_source_and_category(self): + """marketplace.json plugins[0].source == '.' and category is a non-empty string.""" + market = _load_json(MARKETPLACE_JSON) + plugin_entry = market.get("plugins", [{}])[0] + + self.assertEqual( + plugin_entry.get("source"), + ".", + f"marketplace.json plugins[0].source is '{plugin_entry.get('source')}', expected '.'", + ) + category = plugin_entry.get("category", "") + self.assertIsInstance( + category, + str, + "marketplace.json plugins[0].category is not a string", + ) + self.assertTrue( + category, + "marketplace.json plugins[0].category is empty", + ) + + # T8 ---------------------------------------------------------------- + def test_t8_no_tracked_text_file_contains_stale_plannotator_npx_path(self): + """No *.md or *.json file under the repo contains the stale string 'plannotator/effective-html'.""" + stale = "plannotator/effective-html" + offending = [] + + for pattern in ("**/*.md", "**/*.json"): + for path in REPO.rglob(pattern.lstrip("**/")): + # Skip .git directory + if ".git" in path.parts: + continue + try: + text = _read_text(path) + except (OSError, PermissionError): + continue + if stale in text: + offending.append(str(path.relative_to(REPO))) + + self.assertFalse( + offending, + f"Stale string '{stale}' found in: {offending}", + ) + + # T9 ---------------------------------------------------------------- + def test_t9_dark_mode_contract_in_references(self): + """For each skill, at least one .html file under references/ contains the full dark-mode contract.""" + for skill_name in EXPECTED_SKILLS: + refs_dir = REPO / "skills" / skill_name / "references" + with self.subTest(skill=skill_name): + self.assertTrue( + refs_dir.is_dir(), + f"references/ dir not found for skill '{skill_name}': {refs_dir}", + ) + html_files = _html_files_under(refs_dir) + self.assertGreater( + len(html_files), + 0, + f"No .html files found under references/ for skill '{skill_name}'", + ) + + found_compliant = False + for html_path in html_files: + try: + content = _read_text(html_path) + except (OSError, PermissionError): + continue + + has_required = all(token in content for token in DARK_MODE_REQUIRED) + has_indicator = any(token in content for token in DARK_MODE_INDICATORS) + + if has_required and has_indicator: + found_compliant = True + break + + self.assertTrue( + found_compliant, + ( + f"No .html file under skills/{skill_name}/references/ satisfies the " + f"dark-mode contract (must contain ALL of {DARK_MODE_REQUIRED} " + f"and at least one of {DARK_MODE_INDICATORS})" + ), + ) + + # T10 --------------------------------------------------------------- + def test_t10_skill_md_mentions_canonical_exemplar_filename(self): + """Each SKILL.md mentions the canonical exemplar filename for its skill.""" + for skill_name, exemplar in EXEMPLAR_FILENAMES.items(): + skill_md = REPO / "skills" / skill_name / "SKILL.md" + with self.subTest(skill=skill_name): + self.assertTrue( + skill_md.is_file(), + f"SKILL.md not found: {skill_md}", + ) + content = _read_text(skill_md) + self.assertIn( + exemplar, + content, + f"skills/{skill_name}/SKILL.md does not mention exemplar '{exemplar}'", + ) + + # T11 --------------------------------------------------------------- + def test_t11_plugin_json_has_required_metadata_fields(self): + """plugin.json contains non-empty version, description, author, keywords, homepage, repository, license.""" + plugin = _load_json(PLUGIN_JSON) + required_fields = ["version", "description", "author", "keywords", "homepage", "repository", "license"] + + for field in required_fields: + with self.subTest(field=field): + value = plugin.get(field) + self.assertIsNotNone( + value, + f"plugin.json missing field '{field}'", + ) + # For lists and dicts, check non-empty; for strings, check truthy + if isinstance(value, (list, dict)): + self.assertTrue( + value, + f"plugin.json field '{field}' is empty", + ) + else: + self.assertTrue( + str(value).strip(), + f"plugin.json field '{field}' is empty or blank", + ) + + # T12 --------------------------------------------------------------- + def test_t12_readme_and_installation_md_contain_required_strings(self): + """README.md and docs/installation.md reference the install slug and repo; NOTICE+README credit upstream authors.""" + readme = REPO / "README.md" + installation = REPO / "docs" / "installation.md" + notice = REPO / "NOTICE" + + install_slug = "effective-html@effective-html" + repo_slug = "azagreev/effective-html" + + for path in (readme, installation): + with self.subTest(file=path.name, check="install_slug"): + self.assertTrue(path.is_file(), f"File not found: {path}") + content = _read_text(path) + self.assertIn( + install_slug, + content, + f"{path.name} does not contain install slug '{install_slug}'", + ) + with self.subTest(file=path.name, check="repo_slug"): + content = _read_text(path) + self.assertIn( + repo_slug, + content, + f"{path.name} does not contain repo slug '{repo_slug}'", + ) + + # Upstream credits in NOTICE and README (case-insensitive for flexibility) + upstream_credits = [ + ("plannotator", "backnotprop"), + ("thariqs", "html-effectiveness"), + ] + for credit_path in (notice, readme): + with self.subTest(file=credit_path.name, check="upstream_credits"): + self.assertTrue(credit_path.is_file(), f"File not found: {credit_path}") + content_lower = _read_text(credit_path).lower() + for term_a, term_b in upstream_credits: + with self.subTest(file=credit_path.name, credit=f"{term_a}/{term_b}"): + found = term_a.lower() in content_lower or term_b.lower() in content_lower + self.assertTrue( + found, + ( + f"{credit_path.name} does not mention upstream credit " + f"'{term_a}' or '{term_b}'" + ), + ) + + # T13 --------------------------------------------------------------- + def test_t13_license_and_notice_contain_author_name(self): + """LICENSE and NOTICE both contain 'Andrey Zagreev'.""" + author = "Andrey Zagreev" + for path in (REPO / "LICENSE", REPO / "NOTICE"): + with self.subTest(file=path.name): + self.assertTrue( + path.is_file(), + f"File not found: {path}", + ) + content = _read_text(path) + self.assertIn( + author, + content, + f"{path.name} does not contain author name '{author}'", + ) + + +if __name__ == "__main__": + unittest.main()