From 21ba45f2437f2483da290da762a2c1188007eb09 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Fri, 11 Sep 2026 15:12:35 -0400 Subject: [PATCH 1/4] test: add tooltip visual contract --- .changeset/visual-contracts-tooltip.md | 5 ++++ .github/workflows/pullrequest.yml | 24 ++++++++++++++++++ .gitignore | 5 ++++ packages/kumo/package.json | 3 ++- ...runcating-text-trigger-chromium-darwin.png | Bin 0 -> 3224 bytes .../tooltip/tooltip.visual.browser.test.tsx | 22 ++++++++++++++++ .../kumo/vitest.visual-contracts.config.ts | 19 ++++++++++++++ 7 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/visual-contracts-tooltip.md create mode 100644 packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png create mode 100644 packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx create mode 100644 packages/kumo/vitest.visual-contracts.config.ts diff --git a/.changeset/visual-contracts-tooltip.md b/.changeset/visual-contracts-tooltip.md new file mode 100644 index 000000000..fd3fa8beb --- /dev/null +++ b/.changeset/visual-contracts-tooltip.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Add a visual regression contract for truncating text in default Tooltip triggers. diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 2414f6c78..d37047011 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -133,6 +133,30 @@ jobs: - run: vp run --filter @cloudflare/kumo test - run: vp run test:ci + visual-contracts: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: ./.github/actions/install-dependencies + with: + filter: "@cloudflare/kumo" + - name: Install Chromium + working-directory: packages/kumo + run: vp exec playwright install --with-deps chromium + - name: Run visual contracts + run: vp run --filter @cloudflare/kumo test:visual-contracts + - name: Upload visual-contract artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-contract-artifacts + path: packages/kumo/.vitest-attachments/ + if-no-files-found: ignore + retention-days: 7 + test-react-compatibility: needs: build timeout-minutes: 5 diff --git a/.gitignore b/.gitignore index a311bed12..90be4a313 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,10 @@ packages/kumo/ai/schemas.ts .vitest-attachments/ __screenshots__/ +# Browser visual-contract baselines are intentional test fixtures. Keep them +# alongside their component tests so snapshot updates are visible in review. +!packages/kumo/src/components/**/__screenshots__/ +!packages/kumo/src/components/**/__screenshots__/** + # CI report artifacts ci/reports/ diff --git a/packages/kumo/package.json b/packages/kumo/package.json index 1971e52ab..a1e003f3c 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -454,7 +454,8 @@ "typecheck": "tsc --noEmit", "validate:build": "vp test run --project=unit tests/imports/export-path-validation.test.ts", "validate:changeset": "tsx ../../ci/scripts/validate-kumo-changeset.ts", - "test:browser": "vp test --config=vitest.browser.config.ts" + "test:browser": "vp test --config=vitest.browser.config.ts", + "test:visual-contracts": "vp test run --config=vitest.visual-contracts.config.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..e50867fa83d3425dd39e65cc9b1bfe2e88aecd0b GIT binary patch literal 3224 zcmb7Hc{J4h8dt<*do5w?YnGC14PsKm7*lq#W?$3X5XBHmgDhj;<|RT1*(0))v4+7A zMqYa_*|)?n)&08v-Fxmi_uM~b<~QfeJiq7re75HoV`7A2V-{ehqoZR(>+70<&ouCo zhA@J6iEFiGbabqaXx$qYL8ms_!wo*~FMixvZruuneKklrU}mT1)SxN#IwmE(G<(f0 zr&M3f=@xoTky!jh+ty5VpdrHBP&V2@mVKnqZSF#s-!ErqHR3!HLQ4)Fd{p{PP8(lU zrG3A7ny!B9XS?V0V!lA*AAEc=$-aJm5@*3h%Kvr2-?=gMt|{~w=Gqn{!*1r+@r--D4;hFNJ3PJKLviey;r) z=C8)&;b9ZZRZY#i-rfse>hIxjxR(=a$?gQEmCeniqy06Ah=@qM2>j1v-R|~8?O*zO zdW@jdz<>el{$I~q$Hy(XxVe?GNt9bgMoX;+Gab{@!XXEn(~>eWOwZb%z+V{kw1pl+ zn3)%D9336$8yfz7y!D}HW_7GwPFD7Mk=c%zUSV?my@>^{iAwvd zwa}x0zW)B5@88`Nj}Nv=Dk~Fcw8uD5JUKP>S-aE6T%!`#*|t!IGiQ`?Km{{C!o;~}0f8THSD>`0JvfQ!)Q9h=e zLnIe0dFe;H$RZW|R;`PQW?c=JQI~W~8Hm;e z981f}T$Tn13=9m4IY&pqBcr37DXFP84H*8+%Z|lGMUmxJRa|_0(S@6v_iJlww+{|H ze;(~aCMPG?C+ngCe6@9TXnp;cRaJ?pGR7{d0yg#DJhl}X?G{#6sd;&cq`e{Sp?H23 zDB5kaIpjd0rluw!AVBfqszpIzA$7I9%Gbie0ypOEzP~#5b$MA5&4n;CNcXtuiZI3n3X!PgtaTjHRzJY<8?4?VWFjEa|`NhSI zqN1X>+vS#=JUmECOWwUDk}K_Sht(ZG2^kw3!$n4l%iA@?%j7*y`9qtQM9;*|!Oh)a zvN_Y*KjrfQZ(?RfT8+ssoD^tP!Q_6XYCU#efNTcp?{;zgA$#_9E z>knaY#Lb(`=g*&y*X25xZt{;#NMJWJH>b`=i)JaiGbb=IGAa@bAlYg@dMQ#EEjaws zz<@5L#+3<%$KxS>ettn%taG7dc}!v=$_s;WQ)glqy58H@N9^fg%6|D$%*@P;gIz?c z*W~_8%cmG_832upygX~jtql1ahbtv{P@~XLINxOlDJ`uWWE`*a9>q0r=hyL;WLkRq z{Oh(h5obUU$bY|=VpCI-b%o`%!#VnhXV3Uc-8TIL14~+3a?Y^uDyFSXRJqnz4|~^Z z?R0Xh@9ys-)kBX|VMiS&Ya|PwM2?0(BdRA+6qN-JfgubG zSWo_tA|*84h~;jdnBYV8BnY1f4Hp%)+b!s0Vr$!{f8KBQ8@bS?+UZos4j_r?xi^*< ztt#)dlbigPIpld+?CtF#DN^}lFO%{yO7p`tUSwn>1kjPSq`sc#M#<=WFDRT}QGuWL zQWbEs=_W7dg(q>lpML)^u7HTZQ7G2y4+8^J)6*ljCq47T`;{dLos!S-cqFCTnH~;@Q^28fTUxX* z10E_mD_dJKaJX<>7vjMK<^247)*>J%Kp33gV)wj$$*4G(NEE6*^!O3G=rsc;CowrW zIo6Vj3N|o}%ytGQCP~^(k0?b|O-)i4iB!t@^(zcWxyD*vN=AlTKp?gin1g~t%ZXKv z78|<0O2sk=s@;DD3=C*YLP8>FvuU}}Z;=s|D1sbb3CJROw6wH92R?qJvunW2_a;l? zqN7u@v-^E5F7UwU>FHlvTv*>fIM_3`u(YgwxMm5)0$dPTNLCcW>pdHuogH%8oNkU5 z>sN;D@9#sBlapE9ot&OrvTN}D+RI7o==ek;bvq_NA660lOf`RnN$Oy-U3l|^|$lQ(Z!Ej>wMJuR5NQh=cUY;oIgkKOA z7FSs+m71282CTIg^z7&F-!ap?)fQL%k13d5q6q2tX#z?$j284`eNxBKQFLQt17O~r zAly&NRs#dp)zeeXT3B#lW@ZNG0458RjD-R(EC%QR3xD@c?MLOErU(#WSSV1Nl(e)2 z_*JoS-e5R z1mdbd7GPH}x)Nj^+$xe^P!K^-@pP$bX%TWBd4<-}zjccxCME{r?(PlMRBLN1B`q^k zCCj|T$Zz$t9Fa(T9v|-}m4szrV`EFp$QT(P*K=?XNs$tN+t9EJ2rT)kxY(ttxHu|4 zo)uSCc7aBtLHowat*9VC37UZlWHK27x&lGrstDCbqi+DvRbjA`oGp-*l9F;Vx3NK^ zP)t5;RB6|q1Z1P{{ApkUo!<4pEl+$zXJuofsId`RVR;J(4FN13h%M*p!`Pq42X(>w z-nhX*qut$I4q((ZcQF`U&~K-yp&=VTKR@L5?c0Y`s!~=`5{FTadb`t)&FNcVVb=s< zn7J`4NB4C~P5e3KL@_x0#mI=+qm3^o+mNZL>F0?F|8DcX3BX*95#}uS#p|Iwoxy3~t&^H!~g{XKA zg3&P8WKb94E@x?nUn39*Ae})o#sD#}IlXcdasZ5~CjcxUgkfvr6&=Y1 zvQSn=V24vv+3i8W!4mO;YV1BfK6+dP=Mxh+!@yu-Wreb}6#$T`s;WBGVfQ>N-&j9| z1Mas*MKKD=#YT{Tdr`m%S$O5IS6I5HW@YtmefT|?=@=G6_Uc@E5jf*aE|eJ4i0V%+L3^P!mP#3&4SLWpG&>!ZAHRqe$ob6Pe@-srOL_6y8%DTGLXhN z1kIm;{o@OcG#mb#L@q>;z3agm<~)RfxmM-pp8})a76U{BJ?jaKeGH}1^TtKic@8aX zZVtXer$SXcSOR1AktL xlQS>g{?~V~`O&h`jg9KbxVSj(f4d<6J|&2Ch!fKs#e*jfIyBNqw-VtL@lQ4kDO&&l literal 0 HcmV?d00001 diff --git a/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx b/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx new file mode 100644 index 000000000..8abbdfe27 --- /dev/null +++ b/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { Text } from "../text/text"; +import { Tooltip } from "./tooltip"; + +describe("Tooltip visual contracts", () => { + test("keeps a truncating Text trigger visible", async () => { + const { getByTestId } = await render( +
+ + + 192.0.2.0/24 + + +
, + ); + + await expect + .element(getByTestId("tooltip-text-trigger")) + .toMatchScreenshot("truncating-text-trigger"); + }); +}); diff --git a/packages/kumo/vitest.visual-contracts.config.ts b/packages/kumo/vitest.visual-contracts.config.ts new file mode 100644 index 000000000..23dc7f786 --- /dev/null +++ b/packages/kumo/vitest.visual-contracts.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser-playwright"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + test: { + include: ["**/*.visual.browser.test.tsx"], + setupFiles: ["./tests/setup-browser.css"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + screenshotFailures: true, + }, + testTimeout: 2_000, + }, +}); From 396d5664158c43964b618a1fcf806104dfc91c21 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Fri, 11 Sep 2026 15:15:28 -0400 Subject: [PATCH 2/4] chore: capture linux visual contract baseline --- .github/workflows/pullrequest.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index d37047011..6c857ccb6 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -147,13 +147,13 @@ jobs: working-directory: packages/kumo run: vp exec playwright install --with-deps chromium - name: Run visual contracts - run: vp run --filter @cloudflare/kumo test:visual-contracts + run: vp run --filter @cloudflare/kumo test:visual-contracts --update - name: Upload visual-contract artifacts - if: failure() + if: always() uses: actions/upload-artifact@v4 with: - name: visual-contract-artifacts - path: packages/kumo/.vitest-attachments/ + name: visual-contract-baselines + path: packages/kumo/src/components/**/__screenshots__/ if-no-files-found: ignore retention-days: 7 From b261de5d68bb0d8cc850c2670f5f76b88e65ab9a Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Fri, 11 Sep 2026 15:18:24 -0400 Subject: [PATCH 3/4] test: add linux visual contract baseline --- .github/workflows/pullrequest.yml | 8 ++++---- .../truncating-text-trigger-chromium-linux.png | Bin 0 -> 1236 bytes 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 6c857ccb6..d37047011 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -147,13 +147,13 @@ jobs: working-directory: packages/kumo run: vp exec playwright install --with-deps chromium - name: Run visual contracts - run: vp run --filter @cloudflare/kumo test:visual-contracts --update + run: vp run --filter @cloudflare/kumo test:visual-contracts - name: Upload visual-contract artifacts - if: always() + if: failure() uses: actions/upload-artifact@v4 with: - name: visual-contract-baselines - path: packages/kumo/src/components/**/__screenshots__/ + name: visual-contract-artifacts + path: packages/kumo/.vitest-attachments/ if-no-files-found: ignore retention-days: 7 diff --git a/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..96317aa799bbafdcbc3f0be67c54f7feb097ce86 GIT binary patch literal 1236 zcmeAS@N?(olHy`uVBq!ia0vp^en2e3!3HFi_jC6!FtE(_ba4!+V0?QpFhe+<;lRiJ zkq_+ziWI&y_s$L2abQ8Ez-I*+iwuX1gaARs{I?RD#g;u$4UW8(vsyPtmHG7Bg>Eyx zDNJja%2mdDL$cthT#I&z%7%$HJns@8Gc{Wayh}KoYURtyZu}y;Za;JXZi$LiL$+T+ z$qV!ml#$Ey`zwOZ=su7Lzqu!8!KJ4+r#u(iFpIHEMtOgX!eY*v^r?>)wLW&$xFzv( zDU+n{1>r~c^a~R=wskkzg(YatcjKyZn!fguOxjxC1{KrDggUF%Q+9RDS0AzJs58t= zs;Zlp6U18crd1~Tm{4VDH;P#wru5sEcWplFxo^K^t6Ig{q>AsFQ*$1zWSNv+le5{y zwsPj#xw+TQIbL1!xRrO6YPZ4eL*KsTa_wr4ymvHR{`|2&*PcE(WoA8FyJzQhgYw#M zpL2wZ!_&WgGrN6V{ljXttzjPi9*d(M`PEF@I#>4ExuZ7?vUey7>@ta&dL^+x>!i|| zbsP59&Te{_Fs=M!<*9NVA8V)33gsk^=0m5h`z(88BhI!Z=0dQQ7Sl|VfV)<`3MZd@ zlJdUE;^ZNu$t(E7<;#z^R&O7gw5d5{NVGZK&J@0++{k+5a1e_*$WOf`-UvxS@VC$`(`SR}FpWTi) zD6nvR6D&00z2zq@|KS#&?)AmOjT3~X>YZPfQz~&*%A-ojp24(F>4ngWAl0H;rf-5; zL0k$;Lba2%ANRa$l9SgDm{8cNKE+}Z$9evjCb^4JgnS#2^*c#Pi@n`goKn(xXtv7(xC#p?P{^ZYSjg77^v**xTz@TFC8D&bR~=%;&f{A+KR_IPpc zD~^9pZ_e1sx~H@9Q=8X~UCmd03e#*H=a{E={8vbsbyGSst#QNFQ$1@JXg1tmFlY8* zvuP1Cy%v2^a+PJXSKwk4d{k}MwM^)_>$|XtceH%sbthb7(GQwfn87Njpz!H~QftVW zr7yjDmYfo8qoC+&GuTI(+jUKj%Fm9@(XBN!62{I&p0; zYj-%mc(vU(C(iVmnXiLAS!@?RzZBSZv84Ipg08oI6RzI6@>+25WveEE`)>t!vya@> zQuS7>PoB3$#74Yohb*hF^s^NUHx%ygOqY0YC4+DIOfKmhO-;XJFF$QOD|GIM(e>q% zIQ}Vmoc(v;{bn0Mvr}hga7~}Sc~4$`td;Gjqw2=4`&QegT(9(~ea(3A1gFt)zR$Ps zuGgq5mbL!X;Jtadr_HL(0ejl^rU&m1T^h7VoN6OPx~x%GJL}?lF1(^F~ia z{T~ix&bj-|#DC^oOxe5A@b6FejI*=vSey8Ln~`#2HKvu-E1YdUE`MI|{h`3iGlEwX g<5uG+7e1Mv;Qo|b9i0;bEUg$kUHx3vIVCg!0Ci?OJ^%m! literal 0 HcmV?d00001 From e28e05be2075d40127883469f519ae57aabda16f Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Fri, 11 Sep 2026 15:30:08 -0400 Subject: [PATCH 4/4] ci: add visual contract PR gallery --- .github/workflows/pullrequest-report.yml | 48 +++++++++++ ci/scripts/post-visual-contracts-report.ts | 93 ++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 ci/scripts/post-visual-contracts-report.ts diff --git a/.github/workflows/pullrequest-report.yml b/.github/workflows/pullrequest-report.yml index e56273722..6d5703a71 100644 --- a/.github/workflows/pullrequest-report.yml +++ b/.github/workflows/pullrequest-report.yml @@ -21,6 +21,54 @@ concurrency: cancel-in-progress: true jobs: + visual-contracts: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Resolve PR metadata + id: metadata + uses: actions/github-script@v7 + with: + script: | + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + core.setOutput('pr_number', prs[0].number.toString()); + return; + } + + const run = context.payload.workflow_run; + const owner = run.head_repository.full_name.split('/')[0]; + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${owner}:${run.head_branch}`, + }); + const match = pulls.find(pr => pr.head.sha === run.head_sha); + if (!match) { + core.setFailed(`No open PR found for workflow run ${run.id}`); + return; + } + core.setOutput('pr_number', match.number.toString()); + + - name: Checkout trusted reporter + uses: actions/checkout@v4 + with: + ref: main + + - name: Install trusted reporter dependencies + uses: ./.github/actions/install-dependencies + with: + filter: kumo-workspace + + - name: Post visual-contract gallery + env: + GITHUB_PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VISUAL_CONTRACTS_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: vp exec tsx ci/scripts/post-visual-contracts-report.ts + bundle-size: if: >- github.event.workflow_run.event == 'pull_request' && diff --git a/ci/scripts/post-visual-contracts-report.ts b/ci/scripts/post-visual-contracts-report.ts new file mode 100644 index 000000000..7bc5bbc1a --- /dev/null +++ b/ci/scripts/post-visual-contracts-report.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env tsx + +/** + * Publish the curated visual-contract gallery for a pull request. + * + * This runs from the trusted default-branch checkout after the untrusted PR + * workflow completes. Screenshot paths come from GitHub's tree API, and only + * PNGs beneath Kumo component screenshot directories are rendered. + */ + +import { Octokit } from "@octokit/rest"; +import { + GITHUB_REPO_NAME, + GITHUB_REPO_OWNER, + upsertPRComment, +} from "../utils/github-api"; + +const COMMENT_MARKER = ""; +const SCREENSHOT_PATH = + /^packages\/kumo\/src\/components\/.+\/__screenshots__\/.+\.png$/; + +function displayName(path: string): string { + return ( + path + .split("/") + .at(-1) + ?.replace(/-chromium-(darwin|linux)\.png$/, "") + .replace(/\.png$/, "") + .replaceAll("-", " ") ?? path + ); +} + +async function main(): Promise { + const token = process.env.GITHUB_TOKEN ?? ""; + const prNumber = Number(process.env.GITHUB_PR_NUMBER); + const headSha = process.env.VISUAL_CONTRACTS_HEAD_SHA ?? ""; + + if (!token || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) { + throw new Error( + "GITHUB_TOKEN, GITHUB_PR_NUMBER, and VISUAL_CONTRACTS_HEAD_SHA are required", + ); + } + + const octokit = new Octokit({ auth: token }); + const { data } = await octokit.git.getTree({ + owner: GITHUB_REPO_OWNER, + repo: GITHUB_REPO_NAME, + tree_sha: headSha, + recursive: "true", + }); + const screenshots = data.tree + .filter( + (entry) => + entry.type === "blob" && SCREENSHOT_PATH.test(entry.path ?? ""), + ) + .map((entry) => entry.path as string) + .filter((path) => path.endsWith("-chromium-linux.png")) + .sort(); + + if (screenshots.length === 0) { + console.log( + "No Linux visual-contract screenshots found; skipping PR comment", + ); + return; + } + + const gallery = screenshots + .map((path) => { + const url = `https://github.com/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/blob/${headSha}/${path}?raw=true`; + return `#### ${displayName(path)}\n\n![${displayName(path)}](${url})`; + }) + .join("\n\n"); + const content = [ + "## Visual contracts", + "", + "Linux Chromium references exercised by the PR's `visual-contracts` job.", + "", + "
", + `${screenshots.length} curated contract${screenshots.length === 1 ? "" : "s"}`, + "", + gallery, + "", + "
", + ].join("\n"); + + await upsertPRComment(token, prNumber, COMMENT_MARKER, content); + console.log(`Visual-contract gallery posted to PR #${prNumber}`); +} + +main().catch((error) => { + console.error("Failed to post visual-contract gallery:", error); + process.exit(1); +});