diff --git a/README.md b/README.md index 3ddc04b..2ccd0ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ - - - PolicyChecks Banner + + + PolicyChecks Banner
diff --git a/docs/assets/SHA-pinning-enabled.png b/docs/assets/SHA-pinning-enabled.png index f45ac4b..53d5d14 100644 Binary files a/docs/assets/SHA-pinning-enabled.png and b/docs/assets/SHA-pinning-enabled.png differ diff --git a/docs/assets/banner-dark-1.png b/docs/assets/banner-dark-1.png deleted file mode 100644 index 08500f3..0000000 Binary files a/docs/assets/banner-dark-1.png and /dev/null differ diff --git a/docs/assets/banner-dark.png b/docs/assets/banner-dark.png new file mode 100644 index 0000000..66e780c Binary files /dev/null and b/docs/assets/banner-dark.png differ diff --git a/docs/assets/banner-dark.svg b/docs/assets/banner-dark.svg new file mode 100644 index 0000000..c1d400b --- /dev/null +++ b/docs/assets/banner-dark.svg @@ -0,0 +1,50 @@ + +PolicyChecks +PolicyChecks banner: enamel shield logo, the name PolicyChecks, a badge reading best practices enabled, and the tagline Not just by habit, enforced by settings. + + + + + + + + + + + + + + + + + + + + + + + + +PolicyChecks + + BEST PRACTICES: enabled + + + + + + + + + + + + + BEST PRACTICES + BEST PRACTICES + enabled + enabled + + +Not just by habit, enforced by settings. + diff --git a/docs/assets/banner-light-1.png b/docs/assets/banner-light-1.png deleted file mode 100644 index 22d923e..0000000 Binary files a/docs/assets/banner-light-1.png and /dev/null differ diff --git a/docs/assets/banner-light.png b/docs/assets/banner-light.png new file mode 100644 index 0000000..ec8b5a8 Binary files /dev/null and b/docs/assets/banner-light.png differ diff --git a/docs/assets/banner-light.svg b/docs/assets/banner-light.svg new file mode 100644 index 0000000..f00dc1c --- /dev/null +++ b/docs/assets/banner-light.svg @@ -0,0 +1,50 @@ + +PolicyChecks +PolicyChecks banner: enamel shield logo, the name PolicyChecks, a badge reading best practices enabled, and the tagline Not just by habit, enforced by settings. + + + + + + + + + + + + + + + + + + + + + + + + +PolicyChecks + + BEST PRACTICES: enabled + + + + + + + + + + + + + BEST PRACTICES + BEST PRACTICES + enabled + enabled + + +Not just by habit, enforced by settings. + diff --git a/docs/assets/generators/brand.py b/docs/assets/generators/brand.py new file mode 100644 index 0000000..f85ff7c --- /dev/null +++ b/docs/assets/generators/brand.py @@ -0,0 +1,300 @@ +""" +Generate the brand graphics: README banners, the social preview, and PNG +exports of the logo mark. + +Outputs (paths relative to the repository root): + + docs/assets/banner-light.svg, banner-light.png 800x200 (PNG at 2x) + docs/assets/banner-dark.svg, banner-dark.png + docs/assets/social-preview-light.png 1280x640 (GitHub's size) + docs/assets/social-preview-dark.png + docs/assets/logo/policychecks-mark-512.png square exports of the mark + docs/assets/logo/policychecks-mark-200.png + +The mark itself (docs/assets/logo/policychecks-mark.svg) is hand-drawn and is +the input here, not an output. Each composition embeds it once and reuses it +with , so editing the mark and re-running this script refreshes every +derived graphic. + +The badge in the middle of each graphic is the project's real "enabled" badge, +rendered by `renderBadgeSvg` through render-badges.ts with the label +"BEST PRACTICES", so it tracks the renderer (logo slot, widths, colours). + +Rendering happens in a headless Chromium via Playwright rather than +rsvg-convert, because the paper-grain texture is an SVG `feTurbulence` filter +that librsvg does not implement. Prerequisites: + + pip install playwright && playwright install chromium + npm install # tsx, for the badge renderer + +Run from the repository root: + + python3 docs/assets/generators/brand.py # everything + python3 docs/assets/generators/brand.py --only banner + python3 docs/assets/generators/brand.py --only social + python3 docs/assets/generators/brand.py --only logo +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import pathlib +import re +import sys +from typing import Final + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from common import ASSETS_DIR, Theme, render_badges # noqa: E402 + +LOGO_DIR: Final = ASSETS_DIR / "logo" +MARK_SVG: Final = LOGO_DIR / "policychecks-mark.svg" + +# --------------------------------------------------------------------------- # +# Palette +# --------------------------------------------------------------------------- # + +# Warm paper tones rather than GitHub's Primer greys: the banner is the one +# place the README is allowed to look like a thing rather than a UI panel. +BRAND: Final[dict[Theme, dict[str, str]]] = { + "light": { + "bg": "#fbf8f1", # cream paper + "ink": "#1f2a26", # wordmark "Policy", tagline body + "muted": "#5a655f", # tagline + "green": "#1f8f72", # wordmark "Checks", "enforced" + "brass": "#8a7140", # pinstripes, matches the mark's rim + "grain": "#000000", # paper grain is dark speckle on light paper + "grain_alpha": "0.05", + }, + "dark": { + "bg": "#101418", + "ink": "#f2f4f6", + "muted": "#99a2ad", + "green": "#3fc596", + "brass": "#b89a5a", + "grain": "#ffffff", # ... and light speckle on dark paper + "grain_alpha": "0.08", + }, +} + +FONT: Final = "Inter, 'Helvetica Neue', Arial, sans-serif" + +# --------------------------------------------------------------------------- # +# Building blocks +# --------------------------------------------------------------------------- # + + +def mark_symbol() -> str: + """ + The logo mark as a with a 512x512 viewBox, taken from + the master SVG so the banner can never drift from the file in logo/. + """ + svg = MARK_SVG.read_text() + match = re.search(r"]*>(.*)", svg, re.S) + assert match is not None, f"{MARK_SVG} does not look like an SVG" + inner = match.group(1) + # Drop the master's own /<desc> (the composition has its own) and any + # <metadata> an export tool may have added (e.g. a C2PA manifest). + inner = re.sub(r"<title>.*?|.*?|.*?", "", inner, flags=re.S) + return f'{inner}' + + +def paper(t: dict[str, str], w: int, h: int) -> str: + """Background fill plus a faint fractal-noise grain over the whole canvas.""" + return ( + f'' + f'' + f'' + f'' + f'' + f'' + ) + + +def pinstripes(t: dict[str, str], w: int, h: int, band: int) -> str: + """ + Fine 45-degree brass hairlines fading in from the left and right edges, + `band` px wide on each side. Defined and used in one go. + """ + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + + +def wordmark(x: float, y: float, size: float, t: dict[str, str], anchor: str = "start") -> str: + tracking = -size / 30 # Inter ExtraBold wants a touch of negative tracking at display sizes + return ( + f'' + f'PolicyChecks' + ) + + +def tagline(x: float, y: float, size: float, t: dict[str, str], anchor: str = "start") -> str: + return ( + f'Not just by habit, ' + f"enforced by settings." + ) + + +def badge_at(svg: str, x: float, y: float, scale: float) -> str: + """Place a badge SVG (its own root, 20 px tall) at (x, y), scaled.""" + return f'{svg}' + + +def badge_width(svg: str) -> int: + match = re.search(r'width="(\d+)"', svg) + assert match is not None + return int(match.group(1)) + + +# --------------------------------------------------------------------------- # +# Compositions +# --------------------------------------------------------------------------- # + +BANNER_W, BANNER_H = 800, 200 +SOCIAL_W, SOCIAL_H = 1280, 640 + + +def build_banner(theme: Theme, badge_svg: str) -> str: + """ + README banner: a shield at each end, the wordmark, the badge and the + tagline centred between them, on pinstriped paper. + """ + t = BRAND[theme] + badge_scale = 1.9 + bw = badge_width(badge_svg) * badge_scale + mark_px = 512 * 0.29 # ~148 px tall shields + return ( + f'\n' + f"PolicyChecks\n" + f"PolicyChecks banner: enamel shield logo, the name PolicyChecks, a badge reading " + f"best practices enabled, and the tagline Not just by habit, enforced by settings.\n" + f"{mark_symbol()}\n" + f"{paper(t, BANNER_W, BANNER_H)}\n" + f"{pinstripes(t, BANNER_W, BANNER_H, band=200)}\n" + f'\n' + f'\n' + f"{wordmark(BANNER_W / 2, 78, 50, t, anchor='middle')}\n" + f"{badge_at(badge_svg, (BANNER_W - bw) / 2, 98, badge_scale)}\n" + f"{tagline(BANNER_W / 2, 168, 17, t, anchor='middle')}\n" + f"\n" + ) + + +def build_social(theme: Theme, badge_svg: str) -> str: + """ + GitHub social preview (1280x640): one large shield on the left, the + wordmark, badge and tagline left-aligned beside it. Everything sits in the + central band because GitHub crops the preview to roughly 2:1 in timelines. + """ + t = BRAND[theme] + badge_scale = 3.1 + mark_px = 512 * 0.82 + text_x = 474 + return ( + f'\n' + f"PolicyChecks\n" + f"PolicyChecks: enamel shield logo, the name PolicyChecks, a badge reading " + f"best practices enabled, and the tagline Not just by habit, enforced by settings.\n" + f"{mark_symbol()}\n" + f"{paper(t, SOCIAL_W, SOCIAL_H)}\n" + f"{pinstripes(t, SOCIAL_W, SOCIAL_H, band=420)}\n" + f'\n' + f"{wordmark(text_x, 272, 112, t)}\n" + f"{badge_at(badge_svg, text_x + 4, 316, badge_scale)}\n" + f"{tagline(text_x + 4, 438, 36, t)}\n" + f"\n" + ) + + +# --------------------------------------------------------------------------- # +# Rendering +# --------------------------------------------------------------------------- # + + +async def _render_all(jobs: list[tuple[str, pathlib.Path, int, int, float]]) -> None: + """jobs: (svg source, output png, width, height, device scale factor).""" + try: + from playwright.async_api import async_playwright + except ImportError: # pragma: no cover - environment check + sys.exit( + "brand.py needs Playwright: pip install playwright && playwright install chromium" + ) + + async with async_playwright() as p: + browser = await p.chromium.launch() + for svg, out, w, h, zoom in jobs: + page = await browser.new_page(viewport={"width": w, "height": h}, device_scale_factor=zoom) + # Open the SVG as a document of its own (not inline HTML) so it is + # parsed as strict XML, exactly as GitHub and browsers parse the file. + data = base64.b64encode(svg.encode()).decode() + await page.goto(f"data:image/svg+xml;base64,{data}", wait_until="load") + await page.wait_for_timeout(100) + await page.screenshot(path=str(out), omit_background=True) + await page.close() + print(f"wrote {out.relative_to(ASSETS_DIR.parent.parent)}") + await browser.close() + + +def render_pngs(jobs: list[tuple[str, pathlib.Path, int, int, float]]) -> None: + asyncio.run(_render_all(jobs)) + + +# --------------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------------- # + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--only", choices=["banner", "social", "logo"], help="generate just one family of outputs" + ) + args = parser.parse_args() + want = {args.only} if args.only else {"banner", "social", "logo"} + + jobs: list[tuple[str, pathlib.Path, int, int, float]] = [] + + if want & {"banner", "social"}: + badge_svg = render_badges(label="BEST PRACTICES")["enabled"] + + if "banner" in want: + for theme in ("light", "dark"): + svg = build_banner(theme, badge_svg) + svg_path = ASSETS_DIR / f"banner-{theme}.svg" + svg_path.write_text(svg) + print(f"wrote {svg_path.relative_to(ASSETS_DIR.parent.parent)}") + jobs.append((svg, ASSETS_DIR / f"banner-{theme}.png", BANNER_W, BANNER_H, 2)) + + if "social" in want: + for theme in ("light", "dark"): + svg = build_social(theme, badge_svg) + jobs.append((svg, ASSETS_DIR / f"social-preview-{theme}.png", SOCIAL_W, SOCIAL_H, 1)) + + if "logo" in want: + mark = MARK_SVG.read_text() + for size in (512, 200): + # The master declares width/height 512; override for the export size. + sized = re.sub(r'width="512" height="512"', f'width="{size}" height="{size}"', mark, count=1) + jobs.append((sized, LOGO_DIR / f"policychecks-mark-{size}.png", size, size, 1)) + + render_pngs(jobs) + + +if __name__ == "__main__": + main() diff --git a/docs/assets/generators/common.py b/docs/assets/generators/common.py index 494bb7c..8340af0 100644 --- a/docs/assets/generators/common.py +++ b/docs/assets/generators/common.py @@ -1,8 +1,8 @@ """ Shared helpers for the README graphic generators in this directory. -Both `setting-vs-badge.py` and `how-it-works.py` import from here. Everything -that is *not* specific to one image lives in this module: +`setting-vs-badge.py`, `how-it-works.py` and `brand.py` import from here. +Everything that is *not* specific to one image lives in this module: * repository paths * the GitHub-flavoured colour themes (light + dark) @@ -22,6 +22,8 @@ python3 docs/assets/generators/setting-vs-badge.py python3 docs/assets/generators/how-it-works.py + python3 docs/assets/generators/brand.py # banners, social preview, logo PNGs + # (renders with Playwright, see its docstring) """ from __future__ import annotations @@ -150,15 +152,19 @@ def text( BadgeState = Literal["enabled", "disabled", "unknown"] -def render_badges() -> dict[BadgeState, str]: +def render_badges(label: str | None = None) -> dict[BadgeState, str]: """ Return the SHA-pinning badge SVG for each result state, rendered by the project's own `renderBadgeSvg` via `render-badges.ts`. Using the real renderer means the graphics stay faithful if the badge design changes; - just rerun the generator. + just rerun the generator. `label` swaps the badge label (brand.py uses + "BEST PRACTICES"); the geometry and colours are untouched. """ + command = ["npx", "tsx", str(GENERATORS_DIR / "render-badges.ts")] + if label is not None: + command += ["--label", label] completed = subprocess.run( - ["npx", "tsx", str(GENERATORS_DIR / "render-badges.ts")], + command, cwd=REPO_ROOT, check=True, capture_output=True, diff --git a/docs/assets/generators/how-it-works.py b/docs/assets/generators/how-it-works.py index 1a1e8e8..491bdd4 100644 --- a/docs/assets/generators/how-it-works.py +++ b/docs/assets/generators/how-it-works.py @@ -81,7 +81,8 @@ SUB_PX = 15 # card subtitles LABEL_PX = 18 # edge labels ("miss", "cache hit", ...) ICON_PX = 20 # card icons -BADGE_SCALE = 1.22 # badges are 20 px tall natively +BADGE_SCALE = 1.22 # badges are 20 px tall natively (upper bound; see fit below) +BADGE_ROW_INSET = 16 # minimum space between the badge row and the card edges # =========================================================================== # # Layout (1x units) @@ -149,8 +150,8 @@ def edge(points: list[tuple[float, float]], color: str, marker: str, dashed: boo ) -def chip(svg: str, x: float, cy: float) -> str: - return f'{svg}' +def chip(svg: str, x: float, cy: float, scale: float = BADGE_SCALE) -> str: + return f'{svg}' def build(theme: Theme, badges: dict[BadgeState, str]) -> str: @@ -173,8 +174,13 @@ def build(theme: Theme, badges: dict[BadgeState, str]) -> str: # Badge card: full width, the three real badges centred in a row. order: tuple[BadgeState, ...] = ("enabled", "disabled", "unknown") - widths = [badge_width(badges[k]) * BADGE_SCALE for k in order] - row_w = sum(widths) + CHIP_GAP * (len(order) - 1) + # Shrink the badges uniformly if the row would not fit inside the card; + # the badge width depends on the renderer (label text, logo slot), not on us. + native_w = sum(badge_width(badges[k]) for k in order) + gaps_w = CHIP_GAP * (len(order) - 1) + badge_scale = min(BADGE_SCALE, (W - 2 * BADGE_ROW_INSET - gaps_w) / native_w) + widths = [badge_width(badges[k]) * badge_scale for k in order] + row_w = sum(widths) + gaps_w xs: list[float] = [] cursor = (W - row_w) / 2 for w in widths: @@ -187,7 +193,7 @@ def build(theme: Theme, badges: dict[BadgeState, str]) -> str: f'fill="{t["panel"]}" stroke="{t["border"]}"/>' + icon("shield-check", 18, Y_BADGE + 14, t["muted"], ICON_PX) + text(48, Y_BADGE + 29, "Badge", size=TITLE_PX, fill=t["fg"], weight="600") - + "".join(chip(badges[k], x, chips_cy) for k, x in zip(order, xs)) + + "".join(chip(badges[k], x, chips_cy, badge_scale) for k, x in zip(order, xs)) ) card_right = CARD_X + CARD_W diff --git a/docs/assets/generators/render-badges.ts b/docs/assets/generators/render-badges.ts index e27d660..bc5b75c 100644 --- a/docs/assets/generators/render-badges.ts +++ b/docs/assets/generators/render-badges.ts @@ -4,12 +4,22 @@ // The README graphic generators (common.py -> render_badges) call this via // `npx tsx docs/assets/generators/render-badges.ts` so the pictures always use // the project's real badge renderer rather than a hand-copied SVG. +// +// Pass `--label "Some text"` to render a badge with a different label (the +// brand generator uses "BEST PRACTICES"); everything else stays the same. import { renderBadgeSvg } from "../../../src/badges/svg.js"; import { shaPinningRequiredBadge } from "../../../src/badges/sha-pinning-required.js"; import { makeBadgeResult, makeUnknownResult } from "../../../src/badges/result.js"; const input = { owner: "OWNER", repo: "REPO" }; -const badge = shaPinningRequiredBadge; +const labelFlag = process.argv.indexOf("--label"); +const badge = + labelFlag === -1 + ? shaPinningRequiredBadge + : { + ...shaPinningRequiredBadge, + label: process.argv[labelFlag + 1] ?? shaPinningRequiredBadge.label + }; const output = { enabled: renderBadgeSvg(badge, makeBadgeResult(badge, input, "enabled", {})), diff --git a/docs/assets/generators/setting-vs-badge.py b/docs/assets/generators/setting-vs-badge.py index 2f189e4..e82a959 100644 --- a/docs/assets/generators/setting-vs-badge.py +++ b/docs/assets/generators/setting-vs-badge.py @@ -103,6 +103,7 @@ ASSETS_DIR, THEMES, Theme, + badge_width, icon, pad_badge, render_badges, @@ -155,7 +156,7 @@ PILL_PX = 15 # "Admins only" / "Public" CONNECTOR_PX = 19 # "PolicyChecks" above the arrow CONNECTOR_SUB_PX = 17 # "mirrors it" below the arrow -BADGE_SCALE = 1.8 # badge is 20 px tall natively; 1.8 -> 36 px, text ~20 px +BADGE_SCALE = 1.6 # badge is 20 px tall natively; 1.6 -> 32 px, text ~18 px # =========================================================================== # # Layout (1x units) @@ -308,7 +309,7 @@ def header(x: float, w: float, ico: str, title: str, pill_svg: str) -> str: ) # -- right card: the badge ---------------------------------------------- - badge_w = 132 * BADGE_SCALE # the SHA-pinning "enabled" badge is 132x20 natively + badge_w = badge_width(badge_svg) * BADGE_SCALE # measured: the badge gains width with its logo badge_h = 20 * BADGE_SCALE badge_x = right_x + (RIGHT_W - badge_w) / 2 badge_y = body_cy - badge_h / 2 diff --git a/docs/assets/how-it-works-dark.png b/docs/assets/how-it-works-dark.png index 2c2a1ea..7fe6b33 100644 Binary files a/docs/assets/how-it-works-dark.png and b/docs/assets/how-it-works-dark.png differ diff --git a/docs/assets/how-it-works-light.png b/docs/assets/how-it-works-light.png index a5b4204..4c4f8fc 100644 Binary files a/docs/assets/how-it-works-light.png and b/docs/assets/how-it-works-light.png differ diff --git a/docs/assets/logo/README.md b/docs/assets/logo/README.md new file mode 100644 index 0000000..2170d4e --- /dev/null +++ b/docs/assets/logo/README.md @@ -0,0 +1,13 @@ +# PolicyChecks logo + +`policychecks-mark.svg` is the master vector: an enamel-pin shield with a brass rim, two-tone green cells and a cream check, on a transparent background. It is drawn by hand; everything else in this folder and the banners next door are generated from it. + +- `policychecks-mark-512.png` / `policychecks-mark-200.png` are exports for places that need a raster. GitHub Marketplace and the GitHub App listing want a square PNG under 1 MB and at least 200 px; use the 512 one. +- `../banner-light.*` and `../banner-dark.*` are the README banners, and `../social-preview-*.png` the 1280x640 image for the repository's social preview (Settings > General > Social preview; GitHub stores it outside the repo). +- The badge in those graphics is the service's real badge, rendered by `src/badges/svg.ts` with the label "BEST PRACTICES", so it tracks the renderer. + +After editing the mark, regenerate the derived files from the repository root: + + python3 docs/assets/generators/brand.py + +It renders in a headless Chromium (`pip install playwright && playwright install chromium`) because the paper grain is an SVG filter that `rsvg-convert` does not support. diff --git a/docs/assets/logo/policychecks-mark-200.png b/docs/assets/logo/policychecks-mark-200.png new file mode 100644 index 0000000..d8f18ff Binary files /dev/null and b/docs/assets/logo/policychecks-mark-200.png differ diff --git a/docs/assets/logo/policychecks-mark-512.png b/docs/assets/logo/policychecks-mark-512.png new file mode 100644 index 0000000..9a82625 Binary files /dev/null and b/docs/assets/logo/policychecks-mark-512.png differ diff --git a/docs/assets/logo/policychecks-mark.svg b/docs/assets/logo/policychecks-mark.svg new file mode 100644 index 0000000..29d6c61 --- /dev/null +++ b/docs/assets/logo/policychecks-mark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/setting-vs-badge-dark.png b/docs/assets/setting-vs-badge-dark.png index 4789205..ec24d76 100644 Binary files a/docs/assets/setting-vs-badge-dark.png and b/docs/assets/setting-vs-badge-dark.png differ diff --git a/docs/assets/setting-vs-badge-light.png b/docs/assets/setting-vs-badge-light.png index fe5e909..264d6fd 100644 Binary files a/docs/assets/setting-vs-badge-light.png and b/docs/assets/setting-vs-badge-light.png differ diff --git a/docs/assets/social-preview-dark.png b/docs/assets/social-preview-dark.png new file mode 100644 index 0000000..4844434 Binary files /dev/null and b/docs/assets/social-preview-dark.png differ diff --git a/docs/assets/social-preview-light.png b/docs/assets/social-preview-light.png new file mode 100644 index 0000000..e4cd7ea Binary files /dev/null and b/docs/assets/social-preview-light.png differ diff --git a/src/badges/logo.ts b/src/badges/logo.ts new file mode 100644 index 0000000..817908b --- /dev/null +++ b/src/badges/logo.ts @@ -0,0 +1,23 @@ +// The PolicyChecks shield, drawn at the left of every badge label in the same +// slot shields.io uses for its `logo` parameter: a 14 px icon, 5 px from the +// left edge, with 3 px between it and the label text. The fill is the badge's +// own "brightgreen" (#4c1) regardless of result, so the mark stays a constant +// brand element next to red or grey messages instead of changing with them. +// +// The path is the outline of the mark in docs/assets/logo/policychecks-mark.svg +// (512-unit box), scaled down here. There is deliberately no check inside it: +// at 14 px a check would read as a status symbol and contradict a "disabled". + +export const logoSize = 14; +export const logoPaddingLeft = 5; +export const logoPaddingRight = 3; + +/** Extra label width needed to make room for the logo. */ +export const logoWidth = logoSize + logoPaddingRight; + +const shieldPath = "M256 56 L420 110 V246 C420 350 350 420 256 458 C162 420 92 350 92 246 V110 Z"; + +export function renderLogo(fill = "#4c1"): string { + const scale = logoSize / 512; + return ``; +} diff --git a/src/badges/svg.ts b/src/badges/svg.ts index c2cad78..597fc87 100644 --- a/src/badges/svg.ts +++ b/src/badges/svg.ts @@ -1,5 +1,6 @@ import type { BadgeDefinition, BadgeResult } from "./types.js"; import { colorForResult, messageForResult } from "./shields-json.js"; +import { logoWidth, renderLogo } from "./logo.js"; const colorHex = { brightgreen: "#4c1", @@ -29,9 +30,11 @@ export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult) const message = messageForResult(definition, result); const labelTextWidth = textWidth(label); const messageTextWidth = textWidth(message); - const labelWidth = segmentWidth(labelTextWidth); + // The label segment is widened by the logo; the text is centred in what is left. + const labelWidth = segmentWidth(labelTextWidth) + logoWidth; const messageWidth = segmentWidth(messageTextWidth); const width = labelWidth + messageWidth; + const labelX = (labelWidth + logoWidth) / 2; const messageX = labelWidth + messageWidth / 2; const color = svgColor(colorForResult(definition, result)); @@ -49,9 +52,10 @@ export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult) + ${renderLogo()} - ${escapeXml(label)} - ${escapeXml(label)} + ${escapeXml(label)} + ${escapeXml(label)} ${escapeXml(message)} ${escapeXml(message)} diff --git a/test/badges.test.ts b/test/badges.test.ts index 1ed41c0..df16554 100644 --- a/test/badges.test.ts +++ b/test/badges.test.ts @@ -67,10 +67,19 @@ describe("badge renderers", () => { }; const svg = renderBadgeSvg(definition, result("enabled")); - expect(svg).toContain('width="188" height="20"'); - expect(svg).toContain('width="134" height="20" fill="#555"'); - expect(svg).toContain('x="67" y="14" textLength="124" lengthAdjust="spacing"'); - expect(svg).toContain('x="161" y="14" textLength="44" lengthAdjust="spacing"'); + // Label segment: 124 px of text + 10 px padding + 17 px for the logo slot. + expect(svg).toContain('width="205" height="20"'); + expect(svg).toContain('width="151" height="20" fill="#555"'); + expect(svg).toContain('x="84" y="14" textLength="124" lengthAdjust="spacing"'); + expect(svg).toContain('x="178" y="14" textLength="44" lengthAdjust="spacing"'); + }); + + it("draws the shield logo in the label with a fixed fill for every result", () => { + for (const state of ["enabled", "disabled", "unknown"] as const) { + const svg = renderBadgeSvg(shaPinningRequiredBadge, result(state)); + expect(svg).toContain(''); + } }); it("allocates more width to wider glyphs in equal-length labels", () => {