From 7440b1607784af0b9341eef5131f1fafb76657d7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 27 May 2026 13:58:24 +0300 Subject: [PATCH 1/2] Add rehype-inject-figure plugin for injecting figures into docs pages New rehype plugin (src/lib/rehype-inject-figure.mjs) that inserts captioned figures into docs pages at a specified heading without editing the markdown. Each figure is configured in a single declarative array in astro.config.mjs (slug, afterHeading, before, replace, src, alt, width/height, caption). Includes a Vite dev-server hook to full-reload on /public/*.svg changes so diagrams hot-update. First injection: the running-tests-options diagram on the Running Tests Manually page. Adds hast-util-from-html dependency, supporting styles in custom.css, and a Cloudflare status CI workflow. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/cloudflare-status.yml | 103 ++++++++++ astro.config.mjs | 47 +++++ package.json | 4 +- public/running-tests-options.svg | 81 ++++++++ src/lib/rehype-inject-figure.mjs | 256 ++++++++++++++++++++++++ src/styles/custom.css | 42 ++++ 6 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cloudflare-status.yml create mode 100644 public/running-tests-options.svg create mode 100644 src/lib/rehype-inject-figure.mjs diff --git a/.github/workflows/cloudflare-status.yml b/.github/workflows/cloudflare-status.yml new file mode 100644 index 00000000..32d22330 --- /dev/null +++ b/.github/workflows/cloudflare-status.yml @@ -0,0 +1,103 @@ +name: Cloudflare Pages Status + +on: + pull_request: + branches: + - docs + +permissions: + pull-requests: write + contents: read + +jobs: + cloudflare-status: + name: Check Cloudflare Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Wait for Cloudflare deployment and check status + id: deployment + run: | + # Poll Cloudflare API for deployment status + # Maximum wait time: 15 minutes (90 retries * 10 seconds) + max_retries=90 + retry_count=0 + status="pending" + + echo "Waiting for Cloudflare deployment to complete..." + + while [ $retry_count -lt $max_retries ]; do + response=$(curl -s \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + "https://api.cloudflare.com/client/v4/accounts/60b76f40370d8320885e92e3daa114b1/pages/projects/docs/deployments?per_page=3") + + # Get the most recent deployment + deployment_status=$(echo "$response" | jq -r '.result[0].latest_stage // "unknown"') + deployment_url=$(echo "$response" | jq -r '.result[0].url // ""') + deployment_created=$(echo "$response" | jq -r '.result[0].created_on // ""') + + echo "Check #$((retry_count + 1)): status=$deployment_status" + + # Check if deployment is complete + if [ "$deployment_status" = "success" ]; then + status="success" + echo "status=$status" >> $GITHUB_OUTPUT + echo "url=$deployment_url" >> $GITHUB_OUTPUT + echo "Deployment succeeded!" + break + elif [ "$deployment_status" = "failure" ] || [ "$deployment_status" = "failed" ]; then + status="failure" + echo "status=$status" >> $GITHUB_OUTPUT + echo "url=$deployment_url" >> $GITHUB_OUTPUT + echo "Deployment failed!" + break + fi + + retry_count=$((retry_count + 1)) + sleep 10 + done + + # If we exhausted retries, mark as unknown + if [ "$status" = "pending" ]; then + echo "status=timeout" >> $GITHUB_OUTPUT + echo "Timed out waiting for deployment" + fi + + - name: Post failure comment + if: steps.deployment.outputs.status == 'failure' + uses: actions/github-script@v7 + with: + script: | + const url = '${{ steps.deployment.outputs.url }}'; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ⚠️ Cloudflare Build Failed + + The Cloudflare Pages deployment failed for this PR. + + **View full logs:** [Cloudflare Dashboard](${url}) + + Please check the Cloudflare dashboard for detailed error messages. Common issues: + - Missing images or assets + - Broken links in markdown files + - Build configuration errors` + }); + + - name: Post timeout comment + if: steps.deployment.outputs.status == 'timeout' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ⏱️ Cloudflare Build Status Unknown + + Could not determine Cloudflare deployment status within 15 minutes. + + Please check the [Cloudflare Dashboard](https://dash.cloudflare.com/60b76f40370d8320885e92e3daa114b1/pages/view/docs) manually.` + }); diff --git a/astro.config.mjs b/astro.config.mjs index 0be712a9..124cc166 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,12 +3,40 @@ import starlight from '@astrojs/starlight'; import starlightImageZoom from 'starlight-image-zoom'; import rehypeAstroRelativeMarkdownLinks from "astro-rehype-relative-markdown-links"; import starlightLinksValidator from 'starlight-links-validator'; +import rehypeInjectFigure from './src/lib/rehype-inject-figure.mjs'; import redirects from "./redirects.js"; const options = { collectionBase: false, }; +// Diagrams injected into docs pages after (or before) a specific heading. Lets +// us enrich a page with a figure without editing its markdown. Add a row per +// figure — no plugin edits needed. +// slug — page slug relative to src/content/docs (no extension) +// afterHeading — heading text to find (case-insensitive, trimmed) +// before — if true, insert immediately *before* the matched heading +// (useful when the figure introduces the section) +// replace — optional HTML tagName to swap with the figure within that +// section (e.g. 'table'). If omitted, the figure is inserted +// immediately after the heading. +// src — image path under /public +// alt — required for a11y +// width/height — optional, recommended to avoid layout shift +// caption — optional
text +// className — optional, defaults to "injected-figure" +const figureInjections = [ + { + slug: 'project/runs/running-tests-manually', + afterHeading: 'How to Launch from the Tests Page', + before: true, + src: '/running-tests-options.svg', + alt: 'Five ways to run tests in Testomat.io: quick-launch from the Tests page, fully configured runs from the Runs page, checklist mode, step-by-step execution, and running automated tests manually.', + width: 1004, + height: 488, + }, +]; + export default defineConfig({ site: 'https://docs.testomat.io', image: { @@ -469,6 +497,25 @@ export default defineConfig({ markdown: { rehypePlugins: [ [rehypeAstroRelativeMarkdownLinks, options], + [rehypeInjectFigure, { injections: figureInjections }], + ], + }, + vite: { + plugins: [ + { + // rehypeInjectFigure reads diagram SVGs from /public at render + // time; Vite doesn't know about that dependency, so editing a + // diagram wouldn't refresh the page in `astro dev`. Force a full + // reload when any /public/*.svg changes. + name: 'reload-on-public-svg-change', + handleHotUpdate({ file, server }) { + const f = file.replace(/\\/g, '/'); + if (f.includes('/public/') && f.endsWith('.svg')) { + server.ws.send({ type: 'full-reload' }); + return []; + } + }, + }, ], }, redirects: redirects, diff --git a/package.json b/package.json index c2b7a770..2aa28e2e 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "canvaskit-wasm": "^0.40.0", "dotenv": "^17.3.1", "gray-matter": "^4.0.3", + "hast-util-from-html": "^2.0.3", "meilisearch-docsearch": "^0.8.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "starlight-image-zoom": "^0.13.2", - "starlight-links-validator": "^0.19.2" + "starlight-links-validator": "^0.19.2", + "unist-util-visit": "^5.0.0" }, "type": "module" } diff --git a/public/running-tests-options.svg b/public/running-tests-options.svg new file mode 100644 index 00000000..d4636a47 --- /dev/null +++ b/public/running-tests-options.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 01 + + Tests Page + Quick-launch tests or suites, or add + them to a run already in progress. + + + + + + 02 + + Runs Page + Build fully configured runs from all + tests, a test plan, or a selection. + + + + + + 03 + + Checklist Mode + Hide test descriptions for fast, + distraction-free manual execution. + + + + + + 04 + + Steps Execution + Mark each step Passed, Failed, or + Skipped for detailed traceability. + + + + + + 05 + + Automated Tests + Run automated tests in manual mode + via a plan or the Runs page toggle. + + diff --git a/src/lib/rehype-inject-figure.mjs b/src/lib/rehype-inject-figure.mjs new file mode 100644 index 00000000..2a65555b --- /dev/null +++ b/src/lib/rehype-inject-figure.mjs @@ -0,0 +1,256 @@ +import { visit } from 'unist-util-visit'; +import { fromHtml } from 'hast-util-from-html'; +import fs from 'node:fs'; +import path from 'node:path'; + +const DOCS_ROOT = path.join(process.cwd(), 'src', 'content', 'docs'); +const PUBLIC_ROOT = path.join(process.cwd(), 'public'); + +function fileSlug(filePath) { + if (!filePath) return null; + const rel = path.relative(DOCS_ROOT, filePath); + if (rel.startsWith('..')) return null; + return rel.replace(/\.(md|mdx)$/i, '').split(path.sep).join('/'); +} + +function headingText(node) { + let out = ''; + visit(node, 'text', (n) => { out += n.value; }); + return out.trim(); +} + +// CSS inside an inline (tag name + control button from starlight-image-zoom/libs/rehype.ts). +// We build it ourselves because that plugin only auto-wraps /, +// not inline — but its runtime DOES handle inline (full-screen +// zoom). So an inlined SVG gets the same lightbox the PNG figures had. +const IMAGE_ZOOM_TAG = 'starlight-image-zoom-zoomable'; +function zoomControlButton(alt) { + return { + type: 'element', + tagName: 'button', + properties: { + 'aria-label': `Zoom image${alt ? `: ${alt}` : ''}`, + class: 'starlight-image-zoom-control', + }, + children: [ + { + type: 'element', + tagName: 'svg', + properties: { 'aria-hidden': 'true', fill: 'currentColor', viewBox: '0 0 24 24' }, + children: [ + { + type: 'element', + tagName: 'use', + properties: { href: '#starlight-image-zoom-icon-zoom' }, + children: [], + }, + ], + }, + ], + }; +} + +function buildFigureChild(cfg) { + // Inline SVG content so the browser uses the page's loaded fonts. An + // tag would render the SVG in "secure static mode" — external @font-face + // declarations don't load there, so titles fall back to serif. + if (cfg.src.toLowerCase().endsWith('.svg')) { + const svgNode = loadInlineSvg(cfg.src); + const cloned = structuredClone(svgNode); + cloned.properties = cloned.properties ?? {}; + // Drop fixed width/height so it scales to the figure's max-width. + delete cloned.properties.width; + delete cloned.properties.height; + if (cfg.alt) { + if (!cloned.properties.role) { + cloned.properties.role = 'img'; + cloned.properties['aria-label'] = cfg.alt; + } + // = accessible fallback + the caption image-zoom shows in the + // lightbox + native hover tooltip. + cloned.children = [ + { type: 'element', tagName: 'title', properties: {}, children: [{ type: 'text', value: cfg.alt }] }, + ...cloned.children, + ]; + } + return { + type: 'element', + tagName: IMAGE_ZOOM_TAG, + properties: {}, + children: [cloned, zoomControlButton(cfg.alt)], + }; + } + const imgProps = { src: cfg.src, alt: cfg.alt ?? '', loading: 'lazy' }; + if (cfg.width) imgProps.width = cfg.width; + if (cfg.height) imgProps.height = cfg.height; + return { type: 'element', tagName: 'img', properties: imgProps, children: [] }; +} + +function buildFigure(cfg) { + const children = [buildFigureChild(cfg)]; + if (cfg.captionHtml) { + const parsed = fromHtml(cfg.captionHtml, { fragment: true }); + children.push({ + type: 'element', + tagName: 'figcaption', + properties: {}, + children: parsed.children, + }); + } else if (cfg.caption) { + children.push({ + type: 'element', + tagName: 'figcaption', + properties: {}, + children: [{ type: 'text', value: cfg.caption }], + }); + } + return { + type: 'element', + tagName: 'figure', + properties: { className: cfg.className ? [cfg.className] : ['injected-figure'] }, + children, + }; +} + +export default function rehypeInjectFigure(options = {}) { + const injections = options.injections ?? []; + const bySlug = new Map(); + for (const cfg of injections) { + if (!cfg.slug || !cfg.afterHeading || !cfg.src) { + console.warn('[rehype-inject-figure] skipping invalid entry:', cfg); + continue; + } + if (!bySlug.has(cfg.slug)) bySlug.set(cfg.slug, []); + bySlug.get(cfg.slug).push(cfg); + } + + return (tree, file) => { + const slug = fileSlug(file?.path); + if (!slug) return; + const entries = bySlug.get(slug); + if (!entries?.length) return; + + const remaining = new Map(entries.map((e) => [e.afterHeading.trim().toLowerCase(), e])); + + visit(tree, 'element', (node, index, parent) => { + if (!parent || index == null) return; + if (!/^h[1-6]$/.test(node.tagName)) return; + const key = headingText(node).toLowerCase(); + const cfg = remaining.get(key); + if (!cfg) return; + remaining.delete(key); + + // If `replace` is set, swap the next matching sibling within the section + // (i.e. before the next heading of any level) with the figure. + if (cfg.replace) { + for (let i = index + 1; i < parent.children.length; i++) { + const sibling = parent.children[i]; + if (sibling.type !== 'element') continue; + if (/^h[1-6]$/.test(sibling.tagName)) break; // crossed section boundary + if (sibling.tagName === cfg.replace) { + parent.children.splice(i, 1, buildFigure(cfg)); + return [visit.SKIP, i + 1]; + } + } + console.warn( + `[rehype-inject-figure] ${slug}.md: no <${cfg.replace}> found after "${cfg.afterHeading}" — falling back to insert-after-heading` + ); + } + + // `before: true` puts the figure immediately before the matched heading + // instead of after — useful when the heading marks the start of the + // section the diagram introduces. + const insertAt = cfg.before ? index : index + 1; + parent.children.splice(insertAt, 0, buildFigure(cfg)); + return [visit.SKIP, insertAt + 1]; + }); + + for (const [, cfg] of remaining) { + console.warn( + `[rehype-inject-figure] ${slug}.md: heading "${cfg.afterHeading}" not found — figure ${cfg.src} not injected` + ); + } + }; +} diff --git a/src/styles/custom.css b/src/styles/custom.css index 31988be7..9873b75f 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -89,4 +89,46 @@ html[data-theme=dark] .docsearch-btn-placeholder { top: 40px; right: -260px; } +} + +/* Figures injected into docs pages by rehype-inject-figure.mjs. + starlight-image-zoom-zoomable is a custom element that defaults to + display:inline, which makes max-width on its SVG/img child ineffective — + force display:block + max-width:100% on the wrapper so the diagram never + overflows the docs content area. */ +.sl-markdown-content .injected-figure { + margin: 1.75em 0; + text-align: center; + max-width: 100% !important; + width: 100% !important; + overflow: hidden; +} +.sl-markdown-content .injected-figure starlight-image-zoom-zoomable { + display: block !important; + max-width: 100% !important; + width: 100% !important; + /* Diagrams shrink small on phones — signal they can be tapped to zoom. */ + cursor: zoom-in; +} +.sl-markdown-content .injected-figure img { + max-width: 100% !important; + width: 100% !important; + height: auto !important; + display: block; + margin: 0 auto; +} +/* Inlined SVG diagrams: same responsive sizing as the <img> variant. + `> svg` so the zoom-control button's icon <svg> is left alone. */ +.sl-markdown-content .injected-figure starlight-image-zoom-zoomable > svg { + max-width: 100% !important; + width: 100% !important; + height: auto !important; + display: block; + margin: 0 auto; +} +.sl-markdown-content .injected-figure figcaption { + margin-top: 0.6em; + font-size: 0.875em; + color: var(--sl-color-text-secondary, #6b7280); + font-style: italic; } \ No newline at end of file From 66575cd0ab78c16d9e0cf2793fbdf86b82605c96 Mon Sep 17 00:00:00 2001 From: DavertMik <davert@testomat.io> Date: Wed, 27 May 2026 20:10:04 +0300 Subject: [PATCH 2/2] Remove running-tests-options figure injection Drop the example figureInjections entry and the corresponding SVG asset; the plugin ships with an empty injections array by default. --- astro.config.mjs | 12 +---- public/running-tests-options.svg | 81 -------------------------------- 2 files changed, 1 insertion(+), 92 deletions(-) delete mode 100644 public/running-tests-options.svg diff --git a/astro.config.mjs b/astro.config.mjs index 124cc166..264ea7e6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -25,17 +25,7 @@ const options = { // width/height — optional, recommended to avoid layout shift // caption — optional <figcaption> text // className — optional, defaults to "injected-figure" -const figureInjections = [ - { - slug: 'project/runs/running-tests-manually', - afterHeading: 'How to Launch from the Tests Page', - before: true, - src: '/running-tests-options.svg', - alt: 'Five ways to run tests in Testomat.io: quick-launch from the Tests page, fully configured runs from the Runs page, checklist mode, step-by-step execution, and running automated tests manually.', - width: 1004, - height: 488, - }, -]; +const figureInjections = []; export default defineConfig({ site: 'https://docs.testomat.io', diff --git a/public/running-tests-options.svg b/public/running-tests-options.svg deleted file mode 100644 index d4636a47..00000000 --- a/public/running-tests-options.svg +++ /dev/null @@ -1,81 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1004" height="488" viewBox="0 0 1004 488"> - <defs> - <style> - @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap'); - .kicker { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 11px; letter-spacing: 0.20em; text-transform: uppercase; fill: #6B7280; } - .title { font-family: 'Inter', sans-serif; font-weight: 700; font-size: 22px; letter-spacing: -0.02em; fill: #111827; } - .body { font-family: 'Inter', sans-serif; font-weight: 400; font-size: 13px; letter-spacing: -0.005em; fill: #4B5563; } - </style> - - <filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%"> - <feDropShadow dx="1" dy="2" stdDeviation="3" flood-color="#0F172A" flood-opacity="0.05"/> - </filter> - - <symbol id="fb-rocket" viewBox="0 0 24 24" fill="none"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10.051 8.102-3.778.322-1.994 1.994a.94.94 0 0 0 .533 1.6l2.698.316m8.39 1.617-.322 3.78-1.994 1.994a.94.94 0 0 1-1.595-.533l-.4-2.652m8.166-11.174a1.366 1.366 0 0 0-1.12-1.12c-1.616-.279-4.906-.623-6.38.853-1.671 1.672-5.211 8.015-6.31 10.023a.932.932 0 0 0 .162 1.111l.828.835.833.832a.932.932 0 0 0 1.111.163c2.008-1.102 8.35-4.642 10.021-6.312 1.475-1.478 1.133-4.77.855-6.385Zm-2.961 3.722a1.88 1.88 0 1 1-3.76 0 1.88 1.88 0 0 1 3.76 0Z"/> - </symbol> - <symbol id="fb-cog" viewBox="0 0 24 24" fill="none"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 4.757V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757l-1.707.707-.536-.535a1 1 0 0 0-1.414 0L4.929 6.343a1 1 0 0 0 0 1.414l.536.536L4.757 10H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535 1.707.707V20a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.708.536.536a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z"/> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/> - </symbol> - <symbol id="fb-clipboard-list" viewBox="0 0 24 24" fill="none"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-3 5h3m-6 0h.01M12 16h3m-6 0h.01M10 3v4h4V3h-4Z"/> - </symbol> - <symbol id="fb-bars-from-left" viewBox="0 0 24 24" fill="none"> - <path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M5 7h14M5 12h14M5 17h10"/> - </symbol> - <symbol id="fb-code" viewBox="0 0 24 24" fill="none"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14"/> - </symbol> - </defs> - - <!-- Card 1 --> - <g transform="translate(24,40)"> - <rect width="300" height="184" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1" filter="url(#cardShadow)"/> - <text class="kicker" x="24" y="42">01</text> - <use href="#fb-rocket" x="24" y="52" width="30" height="30" color="#3B82F6"/> - <text class="title" x="24" y="114">Tests Page</text> - <text class="body" x="24" y="140">Quick-launch tests or suites, or add</text> - <text class="body" x="24" y="160">them to a run already in progress.</text> - </g> - - <!-- Card 2 --> - <g transform="translate(352,40)"> - <rect width="300" height="184" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1" filter="url(#cardShadow)"/> - <text class="kicker" x="24" y="42">02</text> - <use href="#fb-cog" x="24" y="52" width="30" height="30" color="#3B82F6"/> - <text class="title" x="24" y="114">Runs Page</text> - <text class="body" x="24" y="140">Build fully configured runs from all</text> - <text class="body" x="24" y="160">tests, a test plan, or a selection.</text> - </g> - - <!-- Card 3 --> - <g transform="translate(680,40)"> - <rect width="300" height="184" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1" filter="url(#cardShadow)"/> - <text class="kicker" x="24" y="42">03</text> - <use href="#fb-clipboard-list" x="24" y="52" width="30" height="30" color="#3B82F6"/> - <text class="title" x="24" y="114">Checklist Mode</text> - <text class="body" x="24" y="140">Hide test descriptions for fast,</text> - <text class="body" x="24" y="160">distraction-free manual execution.</text> - </g> - - <!-- Card 4 --> - <g transform="translate(188,252)"> - <rect width="300" height="184" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1" filter="url(#cardShadow)"/> - <text class="kicker" x="24" y="42">04</text> - <use href="#fb-bars-from-left" x="24" y="52" width="30" height="30" color="#3B82F6"/> - <text class="title" x="24" y="114">Steps Execution</text> - <text class="body" x="24" y="140">Mark each step Passed, Failed, or</text> - <text class="body" x="24" y="160">Skipped for detailed traceability.</text> - </g> - - <!-- Card 5 --> - <g transform="translate(516,252)"> - <rect width="300" height="184" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1" filter="url(#cardShadow)"/> - <text class="kicker" x="24" y="42">05</text> - <use href="#fb-code" x="24" y="52" width="30" height="30" color="#3B82F6"/> - <text class="title" x="24" y="114">Automated Tests</text> - <text class="body" x="24" y="140">Run automated tests in manual mode</text> - <text class="body" x="24" y="160">via a plan or the Runs page toggle.</text> - </g> -</svg>