From eeea5b1c11e62ccef1b2c930ad8ac9aa4c8b9372 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:38:32 +0900 Subject: [PATCH 001/164] feat(toolify): contextual recent-tool navigation on detail pages --- .../components/tools/ContinueWithRecent.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 site/src/components/tools/ContinueWithRecent.tsx diff --git a/site/src/components/tools/ContinueWithRecent.tsx b/site/src/components/tools/ContinueWithRecent.tsx new file mode 100644 index 00000000..27386248 --- /dev/null +++ b/site/src/components/tools/ContinueWithRecent.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { useRecent } from "@/lib/recent"; +import { useSearchIndex } from "@/lib/search/useSearchIndex"; +import { RelatedToolLink } from "./RelatedToolLink"; + +/** + * Offers a contextual return path after completing a tool. + * This is intentionally shown only to returning visitors and does not store inputs. + * Reuses the existing recent-slug store and lazily fetched localized search index. + */ +export function ContinueWithRecent({ currentSlug, category }: { currentSlug: string; category: string }) { + const locale = useLocale(); + const t = useTranslations("tool"); + const recent = useRecent(); + const alternatives = recent.filter((slug) => slug !== currentSlug).slice(0, 3); + const index = useSearchIndex(locale, alternatives.length > 0); + + if (!index || alternatives.length === 0) return null; + const bySlug = new Map(index.map((item) => [item.slug, item])); + const items = alternatives.map((slug) => bySlug.get(slug)).filter((item) => item !== undefined); + if (items.length === 0) return null; + + return ( +
+

🕘 {t("recent")}

+ +
+ ); +} From 0ec9ac8f37d6df9adcfabac842be7d5686d494d2 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:38:45 +0900 Subject: [PATCH 002/164] feat(toolify): place return-visit navigation below related tools --- site/src/components/tools/ToolFrame.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/components/tools/ToolFrame.tsx b/site/src/components/tools/ToolFrame.tsx index 36214db7..d11bde39 100644 --- a/site/src/components/tools/ToolFrame.tsx +++ b/site/src/components/tools/ToolFrame.tsx @@ -6,6 +6,7 @@ import { RelatedServices } from "@/components/affiliates/RelatedServices"; import { SisterSiteCta } from "@/components/cross/SisterSiteCta"; import { ToolInteractionTracker } from "./ToolInteractionTracker"; import { RelatedToolLink } from "./RelatedToolLink"; +import { ContinueWithRecent } from "./ContinueWithRecent"; import { TrustBox } from "./TrustBox"; import { ShareBar } from "@/components/tools/ShareBar"; import { FavoriteButton } from "@/components/tools/FavoriteButton"; @@ -95,6 +96,9 @@ export function ToolFrame({ meta, title, description, related, children, article )} + {/* Return-visit path: show up to three previously used tools without storing inputs. */} + +

{t("tool.howItWorks")}

{article} From d24551ce6b0ab21db6e9da9fcba4d58b0e1b5fb9 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:48:05 +0900 Subject: [PATCH 003/164] feat(analytics): distinguish recent-tool navigation from contextual related links --- site/src/components/tools/ContinueWithRecent.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/site/src/components/tools/ContinueWithRecent.tsx b/site/src/components/tools/ContinueWithRecent.tsx index 27386248..8df73bec 100644 --- a/site/src/components/tools/ContinueWithRecent.tsx +++ b/site/src/components/tools/ContinueWithRecent.tsx @@ -3,7 +3,8 @@ import { useLocale, useTranslations } from "next-intl"; import { useRecent } from "@/lib/recent"; import { useSearchIndex } from "@/lib/search/useSearchIndex"; -import { RelatedToolLink } from "./RelatedToolLink"; +import { Link } from "@/lib/i18n/navigation"; +import { trackRecentToolClick } from "@/lib/analytics/events"; /** * Offers a contextual return path after completing a tool. @@ -28,16 +29,14 @@ export function ContinueWithRecent({ currentSlug, category }: { currentSlug: str
    {items.map((item) => (
  • - trackRecentToolClick({ tool: currentSlug, locale, category, destination: item.slug })} className="flex min-h-11 items-center rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-brand-700 hover:border-brand-400 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-brand-400 dark:hover:bg-slate-800" > {item.title} - +
  • ))}
From 59c6567e9aab5e223c7d744e5d580451dfa6372a Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:48:08 +0900 Subject: [PATCH 004/164] feat(analytics): distinguish recent-tool navigation from contextual related links --- site/src/lib/analytics/events.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/src/lib/analytics/events.ts b/site/src/lib/analytics/events.ts index 93adc29d..fda70f15 100644 --- a/site/src/lib/analytics/events.ts +++ b/site/src/lib/analytics/events.ts @@ -143,3 +143,11 @@ export function trackAffiliateClick(p: { ...(p.locale ? { locale: p.locale } : {}), }); } + +/** Returning-visitor navigation. No inputs, query content, or URL values are tracked. */ +export function trackRecentToolClick(p: ToolEventParams & { destination: string }): void { + emit("recent_tool_click", { + ...toParams(p), + destination_tool: p.destination, + }); +} From 4ce2ea5d077678c83909bf7a824dc4b6ebc21337 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:48:28 +0900 Subject: [PATCH 005/164] feat: add deterministic recent-tool recommendation selector --- site/src/lib/tools/recent-recommendations.ts | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 site/src/lib/tools/recent-recommendations.ts diff --git a/site/src/lib/tools/recent-recommendations.ts b/site/src/lib/tools/recent-recommendations.ts new file mode 100644 index 00000000..21e5075b --- /dev/null +++ b/site/src/lib/tools/recent-recommendations.ts @@ -0,0 +1,22 @@ +/** + * Keep a small, deterministic return path on the tool detail page. + * Never recommend the current page, duplicate entries, or unrecognized slugs. + * All history consists of slug identifiers, not calculator inputs. + */ +export function selectRecentRecommendations( + recent: readonly string[], + currentSlug: string, + availableSlugs: ReadonlySet, + limit = 3, +): string[] { + if (limit <= 0) return []; + const seen = new Set([currentSlug]); + const selected: string[] = []; + for (const slug of recent) { + if (seen.has(slug) || !availableSlugs.has(slug)) continue; + seen.add(slug); + selected.push(slug); + if (selected.length >= limit) break; + } + return selected; +} From 6fa4195640b6df69da3ca0b1aa7cd2cf3556d42c Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:48:45 +0900 Subject: [PATCH 006/164] fix: skip stale and duplicate recent tool history --- site/src/components/tools/ContinueWithRecent.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/site/src/components/tools/ContinueWithRecent.tsx b/site/src/components/tools/ContinueWithRecent.tsx index 8df73bec..effa97f7 100644 --- a/site/src/components/tools/ContinueWithRecent.tsx +++ b/site/src/components/tools/ContinueWithRecent.tsx @@ -5,6 +5,7 @@ import { useRecent } from "@/lib/recent"; import { useSearchIndex } from "@/lib/search/useSearchIndex"; import { Link } from "@/lib/i18n/navigation"; import { trackRecentToolClick } from "@/lib/analytics/events"; +import { selectRecentRecommendations } from "@/lib/tools/recent-recommendations"; /** * Offers a contextual return path after completing a tool. @@ -15,11 +16,12 @@ export function ContinueWithRecent({ currentSlug, category }: { currentSlug: str const locale = useLocale(); const t = useTranslations("tool"); const recent = useRecent(); - const alternatives = recent.filter((slug) => slug !== currentSlug).slice(0, 3); - const index = useSearchIndex(locale, alternatives.length > 0); + const hasHistory = recent.some((slug) => slug !== currentSlug); + const index = useSearchIndex(locale, hasHistory); - if (!index || alternatives.length === 0) return null; + if (!index || !hasHistory) return null; const bySlug = new Map(index.map((item) => [item.slug, item])); + const alternatives = selectRecentRecommendations(recent, currentSlug, new Set(bySlug.keys())); const items = alternatives.map((slug) => bySlug.get(slug)).filter((item) => item !== undefined); if (items.length === 0) return null; From 65ce6ab7e022fd0818329cbd8568cb9e373fc240 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:49:00 +0900 Subject: [PATCH 007/164] test(toolify): cover recent-tool selection and first-visit cases --- .../lib/tools/recent-recommendations.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 site/src/lib/tools/recent-recommendations.test.ts diff --git a/site/src/lib/tools/recent-recommendations.test.ts b/site/src/lib/tools/recent-recommendations.test.ts new file mode 100644 index 00000000..df414d04 --- /dev/null +++ b/site/src/lib/tools/recent-recommendations.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { selectRecentRecommendations } from "./recent-recommendations"; + +const known = new Set(["bmi-calculator", "percentage-calculator", "age-calculator", "length-converter"]); + +describe("selectRecentRecommendations", () => { + it("hides the current page and preserves most-recent order", () => { + expect(selectRecentRecommendations(["bmi-calculator", "age-calculator", "length-converter"], "bmi-calculator", known)) + .toEqual(["age-calculator", "length-converter"]); + }); + + it("deduplicates history and ignores removed tools", () => { + expect(selectRecentRecommendations(["deleted", "age-calculator", "age-calculator", "bmi-calculator"], "bmi-calculator", known)) + .toEqual(["age-calculator"]); + }); + + it("limits suggestions and returns none for new users", () => { + expect(selectRecentRecommendations([], "bmi-calculator", known)).toEqual([]); + expect(selectRecentRecommendations(["age-calculator", "length-converter", "percentage-calculator"], "bmi-calculator", known, 2)) + .toEqual(["age-calculator", "length-converter"]); + expect(selectRecentRecommendations(["age-calculator"], "bmi-calculator", known, 0)).toEqual([]); + }); +}); From 48858b8b97620a44cef360eccc72f76afd6d64f6 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 16:50:03 +0900 Subject: [PATCH 008/164] docs: release QA, GA4 evaluation and privacy gates for engagement features --- ...TOOL_ENGAGEMENT_RELEASE_GATE_2026-11-05.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/TOOL_ENGAGEMENT_RELEASE_GATE_2026-11-05.md diff --git a/docs/TOOL_ENGAGEMENT_RELEASE_GATE_2026-11-05.md b/docs/TOOL_ENGAGEMENT_RELEASE_GATE_2026-11-05.md new file mode 100644 index 00000000..a2cb91c4 --- /dev/null +++ b/docs/TOOL_ENGAGEMENT_RELEASE_GATE_2026-11-05.md @@ -0,0 +1,49 @@ +# Toolify365: engagement & return-flow release gate (2026-11-05) + +## Scope +PR #39 adds a return-visitor "recent tools" section on tool detail pages. It reuses the existing recent-slug store (max 8), existing localized search index, and existing tool layout, and adds GA4 `recent_tool_click` to separate revisits from category-based `related_click`. All 17 active locales use the existing `tool.recent` key and localized catalog titles. No inputs, clipboard contents, or calculated values are recorded. + +The current site already has six related-tool links, Favorites/Recent sections, calculate, preset and copy event capture, FAQ and articles. Do not duplicate those features. + +## Before merge (owner checkout, no GitHub Actions) +```bash +git fetch origin +git switch growth/tool-detail-recent-return-20261105 +cd site +npm ci +npm run typecheck +npm run test +npm run validate +npm run audit:seo +npm run audit:i18n +npm run build +``` +If any command fails, do not merge. PR stays draft until validated. + +## Manual checks +- Fresh visitor: no recent-tools section, no unused search-index request and no flash/CLS. +- Returning visitor: current tool excluded; up to three distinct, valid, previously visited tools shown, in recency order. +- Removed/deleted slug: skipped; never generates broken tool links. +- Check `/en/tools/percentage-calculator` and `/ja/tools/percentage-calculator` on mobile and desktop (including keyboard). +- Existing Related tools and Favorites continue to function. +- On click, GA4 DebugView shows `recent_tool_click` with `tool_slug`, `locale`, `category`, `destination_tool`. Do not send user text, numerical inputs, generated output or URLs. +- Consent and analytics-blocker cases: navigation must continue if tracking is blocked. +- Verify AdSense slots and Core Web Vitals do not regress. + +## Deployment / rollback +Owner merges approved PR after all checks and deploys via their normal VPS process (do not invoke GitHub Actions). Verify deployed release SHA, page response and GA4 DebugView. If navigation breaks, revert merge commit and redeploy. No production changes are implied by this document. + +## GA4 configuration & success evaluation +GA4 Admin > Custom definitions: register **event-scoped** `destination_tool` (in addition to existing `tool_slug`, `locale`, `category`). GA4 does not backfill custom dimensions. + +Record 28-day pre-release baseline and post-release 7-day/28-day windows at the same weekday mix, split by locale, traffic source and device. Review: +- Average engagement time per session (not unqualified wall-clock page time). +- Views per session and engaged sessions / sessions. +- `related_click` and `recent_tool_click` per 100 engaged tool-detail sessions. +- `calculate` per 100 tool-detail sessions and repeat tool visits. +- Ad revenue per 1,000 sessions and LCP / CLS / INP, with privacy/consent impact. + +Decision rule: keep if repeat flows and meaningful tool engagement rise without worsened UX, performance, or monetization. Investigate regressions before broader rollout. Any improvement is a hypothesis until observed in GA4. + +## Separate future scope +Scenario side-by-side comparison should be designed per calculator domain, not forced generically onto hundreds of tools. Input-resume requires explicit privacy and storage policies; **not implemented** in this PR. Both are deliberately excluded from an untested blanket rollout. From 59aa863d57e5173a710a189eaa2594f78c6b1cd4 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:44:20 +0900 Subject: [PATCH 009/164] feat: percentage calculator A/B scenario compare without persistent input storage --- .../tools/percentage-calculator/Component.tsx | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/site/src/tools/percentage-calculator/Component.tsx b/site/src/tools/percentage-calculator/Component.tsx index 074131dc..309af5e6 100644 --- a/site/src/tools/percentage-calculator/Component.tsx +++ b/site/src/tools/percentage-calculator/Component.tsx @@ -1,10 +1,11 @@ "use client"; import { useMemo, useState } from "react"; +import { scenarioResult, type PercentageMode } from "./scenario-comparison"; import { useLocale, useTranslations } from "next-intl"; import { useShareableState } from "@/lib/hooks/useShareableState"; -type Mode = "pct_of" | "what_pct" | "pct_change" | "pct_add" | "pct_sub"; +type Mode = PercentageMode; const MODES: Mode[] = ["pct_of", "what_pct", "pct_change", "pct_add", "pct_sub"]; export default function PercentageCalculator() { @@ -15,6 +16,7 @@ export default function PercentageCalculator() { // 代表値を初期表示し、最初の描画から結果を見せる(2026-08-22)。 const [a, setA] = useState("20"); const [b, setB] = useState("250"); + const [baseline, setBaseline] = useState<{ mode: Mode; a: string; b: string; value: number } | null>(null); useShareableState("percentage-calculator", { mode, a, b }, (d) => { if (typeof d.mode === "string" && (MODES as string[]).includes(d.mode)) setMode(d.mode as Mode); @@ -22,23 +24,7 @@ export default function PercentageCalculator() { if (typeof d.b === "string") setB(d.b); }); - const result = useMemo(() => { - const x = parseFloat(a); - const y = parseFloat(b); - if (!isFinite(x) || !isFinite(y)) return null; - switch (mode) { - case "pct_of": - return (x / 100) * y; - case "what_pct": - return y === 0 ? null : (x / y) * 100; - case "pct_change": - return x === 0 ? null : ((y - x) / x) * 100; - case "pct_add": - return y * (1 + x / 100); - case "pct_sub": - return y * (1 - x / 100); - } - }, [mode, a, b]); + const result = useMemo(() => scenarioResult(mode, a, b), [mode, a, b]); const fmt = useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 4 }), [locale]); // 加算・減算モードは「X% of Y」と同じ入力欄(X=率, Y=値)なので文言を流用する。 @@ -105,6 +91,22 @@ export default function PercentageCalculator() {
{t("empty")}
)} + {/* Temporary comparison: no automatic storage, analytics payload, or server transfer. */} +
+

{locale.startsWith("ja") ? "条件を比較" : "A / B"}

+
+ + {baseline && } + {baseline && } +
+ {baseline && ( +
+
A · {t(`mode.${baseline.mode}.title`)}
{fmt.format(baseline.value)}
+
B · {t(`mode.${mode}.title`)}
{result === null ? "—" : fmt.format(result)}
+
{locale.startsWith("ja") ? "差分 (B − A)" : "B − A"}
{result === null ? "—" : fmt.format(result - baseline.value)}
+
+ )} +
); } From 220cb6146614da884ed8318dc99cb3156103185e Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:44:36 +0900 Subject: [PATCH 010/164] refactor: pure percentage scenario computation for A/B comparison --- .../scenario-comparison.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 site/src/tools/percentage-calculator/scenario-comparison.ts diff --git a/site/src/tools/percentage-calculator/scenario-comparison.ts b/site/src/tools/percentage-calculator/scenario-comparison.ts new file mode 100644 index 00000000..9fc25ee8 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-comparison.ts @@ -0,0 +1,17 @@ +export type PercentageMode = "pct_of" | "what_pct" | "pct_change" | "pct_add" | "pct_sub"; + +/** Pure calculation shared by the live result and A/B comparison. */ +export function scenarioResult(mode: PercentageMode, a: string, b: string): number | null { + const x = Number.parseFloat(a); + const y = Number.parseFloat(b); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + let value: number; + switch (mode) { + case "pct_of": value = (x / 100) * y; break; + case "what_pct": if (y === 0) return null; value = (x / y) * 100; break; + case "pct_change": if (x === 0) return null; value = ((y - x) / x) * 100; break; + case "pct_add": value = y * (1 + x / 100); break; + case "pct_sub": value = y * (1 - x / 100); break; + } + return Number.isFinite(value) ? value : null; +} From 4d95aaa33a4df6d86980a5bba9213c11b34e7abf Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:45:16 +0900 Subject: [PATCH 011/164] test: percentage scenario modes and invalid inputs --- .../scenario-comparison.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 site/src/tools/percentage-calculator/scenario-comparison.test.ts diff --git a/site/src/tools/percentage-calculator/scenario-comparison.test.ts b/site/src/tools/percentage-calculator/scenario-comparison.test.ts new file mode 100644 index 00000000..ca98bde3 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-comparison.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { scenarioResult } from "./scenario-comparison"; + +describe("percentage scenarios", () => { + it("computes all supported modes consistently", () => { + expect(scenarioResult("pct_of", "20", "250")).toBe(50); + expect(scenarioResult("what_pct", "20", "250")).toBe(8); + expect(scenarioResult("pct_change", "20", "250")).toBe(1150); + expect(scenarioResult("pct_add", "20", "250")).toBe(300); + expect(scenarioResult("pct_sub", "20", "250")).toBe(200); + }); + it("handles empty, zero denominator and non-finite values", () => { + expect(scenarioResult("pct_of", "", "250")).toBeNull(); + expect(scenarioResult("what_pct", "20", "0")).toBeNull(); + expect(scenarioResult("pct_change", "0", "20")).toBeNull(); + expect(scenarioResult("pct_of", "Infinity", "250")).toBeNull(); + expect(scenarioResult("pct_of", "1e308", "1e308")).toBeNull(); + }); +}); From b89cfe378d4248c519db1150aa2e679f77ee8165 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:45:40 +0900 Subject: [PATCH 012/164] i18n: localize percentage A/B scenario controls in 17 locales --- .../percentage-calculator/scenario-copy.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 site/src/tools/percentage-calculator/scenario-copy.ts diff --git a/site/src/tools/percentage-calculator/scenario-copy.ts b/site/src/tools/percentage-calculator/scenario-copy.ts new file mode 100644 index 00000000..1d28c485 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-copy.ts @@ -0,0 +1,25 @@ +/** Compact A/B controls for all active locales. */ +const COPY = { + en: ["Compare scenarios", "Save current as A", "Restore A", "Clear A", "Difference (B − A)"], + ja: ["条件を比較", "現在の条件をAに保存", "Aに戻す", "比較を解除", "差分 (B − A)"], + "zh-CN": ["比较方案", "将当前方案保存为A", "恢复A", "清除A", "差值 (B − A)"], + "zh-TW": ["比較方案", "將目前方案儲存為A", "還原A", "清除A", "差值 (B − A)"], + ko: ["조건 비교", "현재 조건을 A로 저장", "A 복원", "A 지우기", "차이 (B − A)"], + es: ["Comparar escenarios", "Guardar como A", "Restaurar A", "Borrar A", "Diferencia (B − A)"], + "pt-BR": ["Comparar cenários", "Salvar como A", "Restaurar A", "Limpar A", "Diferença (B − A)"], + fr: ["Comparer les scénarios", "Enregistrer comme A", "Restaurer A", "Effacer A", "Différence (B − A)"], + de: ["Szenarien vergleichen", "Als A speichern", "A wiederherstellen", "A löschen", "Differenz (B − A)"], + it: ["Confronta scenari", "Salva come A", "Ripristina A", "Cancella A", "Differenza (B − A)"], + ru: ["Сравнить сценарии", "Сохранить как A", "Восстановить A", "Очистить A", "Разница (B − A)"], + ar: ["مقارنة السيناريوهات", "حفظ الحالي كـ A", "استعادة A", "مسح A", "الفرق (B − A)"], + hi: ["परिदृश्यों की तुलना करें", "वर्तमान को A के रूप में सहेजें", "A पुनर्स्थापित करें", "A हटाएँ", "अंतर (B − A)"], + id: ["Bandingkan skenario", "Simpan sebagai A", "Pulihkan A", "Hapus A", "Selisih (B − A)"], + th: ["เปรียบเทียบเงื่อนไข", "บันทึกเป็น A", "คืนค่า A", "ล้าง A", "ผลต่าง (B − A)"], + vi: ["So sánh kịch bản", "Lưu hiện tại làm A", "Khôi phục A", "Xóa A", "Chênh lệch (B − A)"], + tr: ["Senaryoları karşılaştır", "A olarak kaydet", "A'yı geri yükle", "A'yı temizle", "Fark (B − A)"], +} as const; + +export function scenarioCopy(locale: string) { + const labels = COPY[locale as keyof typeof COPY] ?? COPY.en; + return { heading: labels[0], save: labels[1], restore: labels[2], clear: labels[3], difference: labels[4] }; +} From 3f6c3d180456159308823f51211be944d9a145e3 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:45:52 +0900 Subject: [PATCH 013/164] i18n: use localized A/B controls across 17 locales --- site/src/tools/percentage-calculator/Component.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/tools/percentage-calculator/Component.tsx b/site/src/tools/percentage-calculator/Component.tsx index 309af5e6..7eeb970b 100644 --- a/site/src/tools/percentage-calculator/Component.tsx +++ b/site/src/tools/percentage-calculator/Component.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { scenarioResult, type PercentageMode } from "./scenario-comparison"; +import { scenarioCopy } from "./scenario-copy"; import { useLocale, useTranslations } from "next-intl"; import { useShareableState } from "@/lib/hooks/useShareableState"; @@ -11,6 +12,7 @@ const MODES: Mode[] = ["pct_of", "what_pct", "pct_change", "pct_add", "pct_sub"] export default function PercentageCalculator() { const t = useTranslations("tools.percentage-calculator"); const locale = useLocale(); + const comparisonText = scenarioCopy(locale); const [mode, setMode] = useState("pct_of"); // 空欄で着地すると結果カードが何も描かれず、道具が動くことが伝わらないまま離脱する。 // 代表値を初期表示し、最初の描画から結果を見せる(2026-08-22)。 @@ -93,17 +95,17 @@ export default function PercentageCalculator() { {/* Temporary comparison: no automatic storage, analytics payload, or server transfer. */}
-

{locale.startsWith("ja") ? "条件を比較" : "A / B"}

+

{comparisonText.heading}

- - {baseline && } - {baseline && } + + {baseline && } + {baseline && }
{baseline && (
A · {t(`mode.${baseline.mode}.title`)}
{fmt.format(baseline.value)}
B · {t(`mode.${mode}.title`)}
{result === null ? "—" : fmt.format(result)}
-
{locale.startsWith("ja") ? "差分 (B − A)" : "B − A"}
{result === null ? "—" : fmt.format(result - baseline.value)}
+
{comparisonText.difference}
{result === null ? "—" : fmt.format(result - baseline.value)}
)}
From 54211ba22e734233a26350c96e5c58d3c108bd21 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:46:26 +0900 Subject: [PATCH 014/164] feat: opt-in 7-day percentage draft storage with strict validation --- .../percentage-calculator/scenario-resume.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 site/src/tools/percentage-calculator/scenario-resume.ts diff --git a/site/src/tools/percentage-calculator/scenario-resume.ts b/site/src/tools/percentage-calculator/scenario-resume.ts new file mode 100644 index 00000000..dee93703 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-resume.ts @@ -0,0 +1,30 @@ +import type { PercentageMode } from "./scenario-comparison"; + +export const SCENARIO_STORAGE_KEY = "toolify_percentage_draft_v1"; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const VALID_MODES = new Set(["pct_of", "what_pct", "pct_change", "pct_add", "pct_sub"]); + +export type SavedPercentageScenario = { mode: PercentageMode; a: string; b: string; savedAt: number }; + +/** Only short numerical inputs, never free text or generated output. */ +export function parseSavedScenario(raw: string | null, now: number): SavedPercentageScenario | null { + if (!raw || raw.length > 512) return null; + try { + const d: unknown = JSON.parse(raw); + if (!d || typeof d !== "object") return null; + const o = d as Record; + if (typeof o.mode !== "string" || !VALID_MODES.has(o.mode as PercentageMode)) return null; + if (typeof o.a !== "string" || typeof o.b !== "string") return null; + if (o.a.length > 40 || o.b.length > 40) return null; + if (!/^[+\-\d.eE ]*$/.test(o.a) || !/^[+\-\d.eE ]*$/.test(o.b)) return null; + if (typeof o.savedAt !== "number" || !Number.isFinite(o.savedAt)) return null; + if (o.savedAt > now || now - o.savedAt > MAX_AGE_MS) return null; + return { mode: o.mode as PercentageMode, a: o.a, b: o.b, savedAt: o.savedAt }; + } catch { return null; } +} + +export function serializeSavedScenario(mode: PercentageMode, a: string, b: string, now: number): string | null { + if (a.length > 40 || b.length > 40) return null; + if (!/^[+\-\d.eE ]*$/.test(a) || !/^[+\-\d.eE ]*$/.test(b)) return null; + return JSON.stringify({ mode, a, b, savedAt: now }); +} From 1f2f5d618f03a883cffc63515361e83618a6e4c0 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:46:59 +0900 Subject: [PATCH 015/164] i18n: add 17-locale opt-in resume and privacy labels --- .../percentage-calculator/scenario-copy.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/site/src/tools/percentage-calculator/scenario-copy.ts b/site/src/tools/percentage-calculator/scenario-copy.ts index 1d28c485..df822f84 100644 --- a/site/src/tools/percentage-calculator/scenario-copy.ts +++ b/site/src/tools/percentage-calculator/scenario-copy.ts @@ -23,3 +23,28 @@ export function scenarioCopy(locale: string) { const labels = COPY[locale as keyof typeof COPY] ?? COPY.en; return { heading: labels[0], save: labels[1], restore: labels[2], clear: labels[3], difference: labels[4] }; } + +const RESUME_COPY: Record = { + en: ["Save for later", "Resume saved", "Delete saved", "Saved on this device for up to 7 days"], + ja: ["後で再開するため保存", "保存した条件を再開", "保存内容を削除", "この端末に最大7日間保存します"], + "zh-CN": ["保存以便稍后继续", "恢复已保存内容", "删除保存内容", "在本设备最多保存7天"], + "zh-TW": ["儲存以便稍後繼續", "還原已儲存內容", "刪除儲存內容", "在此裝置最多儲存7天"], + ko: ["나중에 계속하기 위해 저장", "저장한 내용 복원", "저장 내용 삭제", "이 기기에 최대 7일 저장"], + es: ["Guardar para después", "Recuperar guardado", "Eliminar guardado", "Se guarda en este dispositivo hasta 7 días"], + "pt-BR": ["Salvar para depois", "Retomar salvo", "Excluir salvo", "Armazenado neste dispositivo por até 7 dias"], + fr: ["Enregistrer pour plus tard", "Reprendre la sauvegarde", "Supprimer la sauvegarde", "Enregistré sur cet appareil pendant 7 jours maximum"], + de: ["Für später speichern", "Gespeichertes fortsetzen", "Gespeichertes löschen", "Bis zu 7 Tage auf diesem Gerät gespeichert"], + it: ["Salva per dopo", "Riprendi il salvataggio", "Elimina il salvataggio", "Salvato su questo dispositivo per massimo 7 giorni"], + ru: ["Сохранить на потом", "Восстановить сохранённое", "Удалить сохранённое", "Хранится на этом устройстве до 7 дней"], + ar: ["حفظ لوقت لاحق", "استعادة المحفوظ", "حذف المحفوظ", "يُحفظ على هذا الجهاز لمدة تصل إلى 7 أيام"], + hi: ["बाद के लिए सहेजें", "सहेजा हुआ पुनः खोलें", "सहेजा हुआ हटाएँ", "इस डिवाइस पर अधिकतम 7 दिनों तक सहेजा जाता है"], + id: ["Simpan untuk nanti", "Lanjutkan tersimpan", "Hapus tersimpan", "Disimpan di perangkat ini hingga 7 hari"], + th: ["บันทึกไว้ใช้ภายหลัง", "กลับไปยังที่บันทึก", "ลบข้อมูลที่บันทึก", "บันทึกในอุปกรณ์นี้สูงสุด 7 วัน"], + vi: ["Lưu để dùng sau", "Tiếp tục bản lưu", "Xóa bản lưu", "Lưu trên thiết bị này tối đa 7 ngày"], + tr: ["Sonrası için kaydet", "Kaydı sürdür", "Kaydı sil", "Bu cihazda en fazla 7 gün saklanır"], +}; + +export function resumeCopy(locale: string) { + const labels = RESUME_COPY[locale] ?? RESUME_COPY.en; + return { save: labels[0], restore: labels[1], clear: labels[2], disclosure: labels[3] }; +} From e03d06ee95347287713b568c6b38d1a2b4f4c378 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:47:21 +0900 Subject: [PATCH 016/164] feat: explicit local-only save, restore and delete for percentage tool --- .../tools/percentage-calculator/Component.tsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/site/src/tools/percentage-calculator/Component.tsx b/site/src/tools/percentage-calculator/Component.tsx index 7eeb970b..5c53c379 100644 --- a/site/src/tools/percentage-calculator/Component.tsx +++ b/site/src/tools/percentage-calculator/Component.tsx @@ -1,8 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { scenarioResult, type PercentageMode } from "./scenario-comparison"; -import { scenarioCopy } from "./scenario-copy"; +import { scenarioCopy, resumeCopy } from "./scenario-copy"; +import { SCENARIO_STORAGE_KEY, parseSavedScenario, serializeSavedScenario, type SavedPercentageScenario } from "./scenario-resume"; import { useLocale, useTranslations } from "next-intl"; import { useShareableState } from "@/lib/hooks/useShareableState"; @@ -13,12 +14,23 @@ export default function PercentageCalculator() { const t = useTranslations("tools.percentage-calculator"); const locale = useLocale(); const comparisonText = scenarioCopy(locale); + const resumeText = resumeCopy(locale); const [mode, setMode] = useState("pct_of"); // 空欄で着地すると結果カードが何も描かれず、道具が動くことが伝わらないまま離脱する。 // 代表値を初期表示し、最初の描画から結果を見せる(2026-08-22)。 const [a, setA] = useState("20"); const [b, setB] = useState("250"); const [baseline, setBaseline] = useState<{ mode: Mode; a: string; b: string; value: number } | null>(null); + const [saved, setSaved] = useState(null); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(SCENARIO_STORAGE_KEY); + const value = parseSavedScenario(raw, Date.now()); + setSaved(value); + if (raw && !value) window.localStorage.removeItem(SCENARIO_STORAGE_KEY); + } catch { /* Browser storage may be disabled; calculator remains functional. */ } + }, []); useShareableState("percentage-calculator", { mode, a, b }, (d) => { if (typeof d.mode === "string" && (MODES as string[]).includes(d.mode)) setMode(d.mode as Mode); @@ -93,6 +105,26 @@ export default function PercentageCalculator() {
{t("empty")}
)} +
+
+ + {saved && <> + + + } +
+

{resumeText.disclosure}

+
{/* Temporary comparison: no automatic storage, analytics payload, or server transfer. */}

{comparisonText.heading}

From d1ee822ed4579f26e67b72cda5a6498253913391 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Thu, 5 Nov 2026 18:47:43 +0900 Subject: [PATCH 017/164] test: percentage resume validates expiry, bounds and malformed storage --- .../scenario-resume.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 site/src/tools/percentage-calculator/scenario-resume.test.ts diff --git a/site/src/tools/percentage-calculator/scenario-resume.test.ts b/site/src/tools/percentage-calculator/scenario-resume.test.ts new file mode 100644 index 00000000..e5b3f810 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-resume.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { parseSavedScenario, serializeSavedScenario } from "./scenario-resume"; + +describe("percentage resume draft", () => { + const now = 1793900000000; + it("round trips a deliberately saved scenario", () => { + const saved = serializeSavedScenario("pct_change", "20", "250", now); + expect(parseSavedScenario(saved, now)).toEqual({ mode: "pct_change", a: "20", b: "250", savedAt: now }); + }); + it("rejects expired, future, malformed or unexpectedly large data", () => { + const saved = serializeSavedScenario("pct_of", "20", "250", now); + expect(parseSavedScenario(saved, now + 8 * 86400000)).toBeNull(); + expect(parseSavedScenario(saved, now - 1)).toBeNull(); + expect(parseSavedScenario("{bad", now)).toBeNull(); + expect(parseSavedScenario(JSON.stringify({ mode: "unexpected", a: "1", b: "2", savedAt: now }), now)).toBeNull(); + expect(parseSavedScenario("x".repeat(513), now)).toBeNull(); + expect(serializeSavedScenario("pct_of", "20', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'ja'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en', 'https://staging.example'), true); +}); From d7b470a1fce402bad14639822a2b477121b28ac9 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:19 +0900 Subject: [PATCH 085/164] integrate(growth): add site/src/components/tools/ToolSoftwareRecommendations.test.ts --- .../tools/ToolSoftwareRecommendations.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 site/src/components/tools/ToolSoftwareRecommendations.test.ts diff --git a/site/src/components/tools/ToolSoftwareRecommendations.test.ts b/site/src/components/tools/ToolSoftwareRecommendations.test.ts new file mode 100644 index 00000000..6fdec5f0 --- /dev/null +++ b/site/src/components/tools/ToolSoftwareRecommendations.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { SOFTWARE_RECOMMENDATIONS, availableRecommendations } from "@/lib/software/recommendations"; +import { AVAILABLE_SOFTWARE } from "@/lib/software/catalog"; +import { INDEXED_SLUGS, getTool } from "@/lib/tools/registry"; + +describe("tool -> available software recommendations", () => { + it("points only to products currently available", () => { + const available = new Set(AVAILABLE_SOFTWARE.map((product) => product.slug)); + for (const [tool, recommendations] of Object.entries(SOFTWARE_RECOMMENDATIONS)) { + expect(getTool(tool), `Unknown tool: ${tool}`).toBeDefined(); + expect(recommendations.length, tool).toBeGreaterThan(0); + expect(new Set(recommendations).size, tool).toBe(recommendations.length); + for (const slug of recommendations) expect(available.has(slug), `${tool} -> ${slug}`).toBe(true); + } + }); + + it("returns an empty list for tools without recommendations", () => { + expect(availableRecommendations("nonexistent-tool")).toEqual([]); + }); + + it("includes at least one already-indexable tool page", () => { + const indexedMatches = Object.keys(SOFTWARE_RECOMMENDATIONS).filter((slug) => INDEXED_SLUGS.has(slug)); + expect(indexedMatches).toContain("reverse-text-generator"); + expect(indexedMatches).toContain("character-frequency"); + }); + + it("routes CSV and JSON use cases to related free products", () => { + expect(SOFTWARE_RECOMMENDATIONS["csv-json-converter"]).toContain("csv-cleaner-pro"); + expect(SOFTWARE_RECOMMENDATIONS["json-formatter"]).toContain("json-studio"); + expect(SOFTWARE_RECOMMENDATIONS["duplicate-line-remover"]).toContain("text-batch-studio"); + }); +}); From faedd0a2a4ddc1462d4336b53bda123b18f01cb9 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:22 +0900 Subject: [PATCH 086/164] integrate(growth): add site/src/components/tools/ToolSoftwareRecommendations.tsx --- .../tools/ToolSoftwareRecommendations.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 site/src/components/tools/ToolSoftwareRecommendations.tsx diff --git a/site/src/components/tools/ToolSoftwareRecommendations.tsx b/site/src/components/tools/ToolSoftwareRecommendations.tsx new file mode 100644 index 00000000..30a089c2 --- /dev/null +++ b/site/src/components/tools/ToolSoftwareRecommendations.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Link } from "@/lib/i18n/navigation"; +import { trackSoftwareClick } from "@/lib/analytics/events"; +import { availableRecommendations } from "@/lib/software/recommendations"; +import { softwareCopy } from "@/lib/software/copy"; + +export function ToolSoftwareRecommendations({ slug, locale }: { slug: string; locale: string }) { + const products = availableRecommendations(slug); + if (!products.length) return null; + const copy = softwareCopy(locale); + return ( +
+

{copy.title}

+

{copy.privacy}

+
    + {products.map((product) => ( +
  • + trackSoftwareClick({ tool: slug, locale, destination: product.slug })} + className="inline-flex min-h-11 items-center rounded-lg border border-brand-600 px-3 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-brand-300 dark:hover:bg-slate-800" + > + {copy.open}: {product.name} + +
  • + ))} +
+
+ ); +} From 57b8caf0806b2f63e4faa6d1ca231be0861b96af Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:25 +0900 Subject: [PATCH 087/164] integrate(growth): add site/src/lib/software/recommendations.ts --- site/src/lib/software/recommendations.ts | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 site/src/lib/software/recommendations.ts diff --git a/site/src/lib/software/recommendations.ts b/site/src/lib/software/recommendations.ts new file mode 100644 index 00000000..019d0ed6 --- /dev/null +++ b/site/src/lib/software/recommendations.ts @@ -0,0 +1,25 @@ +/** + * Tool-to-software recommendations are a pure mapping so the policy can be + * checked without importing Next.js client components into a Node test runner. + * Keep only products that are actually available in the software catalog. + */ +import { AVAILABLE_SOFTWARE } from "@/lib/software/catalog"; + +export const SOFTWARE_RECOMMENDATIONS: Readonly> = { + "csv-json-converter": ["csv-cleaner-pro", "json-studio"], + "json-formatter": ["json-studio"], + "json-to-yaml-converter": ["json-studio"], + "duplicate-line-remover": ["text-batch-studio"], + "text-sorter": ["text-batch-studio"], + "text-replace": ["text-batch-studio"], + "word-counter": ["text-batch-studio"], + "character-frequency": ["text-batch-studio"], + "reverse-text-generator": ["text-batch-studio"], +}; + +export function availableRecommendations(toolSlug: string) { + const requested = SOFTWARE_RECOMMENDATIONS[toolSlug] ?? []; + return requested + .map((slug) => AVAILABLE_SOFTWARE.find((product) => product.slug === slug)) + .filter((product): product is (typeof AVAILABLE_SOFTWARE)[number] => Boolean(product)); +} From d540ba79f77b86c386cdaa6df83b14f18a26c42a Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:37 +0900 Subject: [PATCH 088/164] integrate(growth): preserve tested site/package.json --- site/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/package.json b/site/package.json index f8127deb..7dfa5f3b 100644 --- a/site/package.json +++ b/site/package.json @@ -22,7 +22,9 @@ "postbuild": "node scripts/check-built-titles.mjs && node scripts/check-built-canonical.mjs && node scripts/check-built-descriptions.mjs", "check:pressure": "node scripts/check-pressure-reference.mjs", "check:currency": "node scripts/check-currency-rate-freshness.mjs", - "audit:index-policy": "tsx scripts/report-index-policy.ts" + "audit:index-policy": "tsx scripts/report-index-policy.ts", + "audit:live-growth": "node scripts/check-live-growth.mjs", + "test:smoke-html": "node --test scripts/growth-html-checks.test.mjs" }, "dependencies": { "@types/qrcode": "^1.5.6", From 8734b495558dfac97be066118710188ee3cf8eec Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:42 +0900 Subject: [PATCH 089/164] integrate(growth): preserve tested site/src/components/software/SoftwareWorkbench.tsx --- site/src/components/software/SoftwareWorkbench.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/site/src/components/software/SoftwareWorkbench.tsx b/site/src/components/software/SoftwareWorkbench.tsx index 546d0ce4..c3a3760c 100644 --- a/site/src/components/software/SoftwareWorkbench.tsx +++ b/site/src/components/software/SoftwareWorkbench.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import { trackSoftwareProcess, trackSoftwareDownload } from "@/lib/analytics/events"; import { FREE_SOFTWARE_LIMITS, isWithinSoftwareLimits } from "@/lib/software/limits"; import { cleanCsv, cleanTextLines, formatJson, prepareSoftwareDownload } from "@/lib/software/processors"; @@ -13,8 +14,13 @@ export function SoftwareWorkbench({ slug, labels }: {slug: ActiveSlug; labels: { setError(""); setOutput(null); try { if (!isWithinSoftwareLimits(slug, input)) throw new Error(labels.limit); - setOutput(slug === "csv-cleaner-pro" ? cleanCsv(input) : slug === "json-studio" ? formatJson(input, minify) : cleanTextLines(input)); - } catch (e) { setError(e instanceof Error && e.message === labels.limit ? labels.limit : labels.error); } + const result = slug === "csv-cleaner-pro" ? cleanCsv(input) : slug === "json-studio" ? formatJson(input, minify) : cleanTextLines(input); + setOutput(result); + trackSoftwareProcess({ software: slug, success: true }); + } catch (e) { + trackSoftwareProcess({ software: slug, success: false }); + setError(e instanceof Error && e.message === labels.limit ? labels.limit : labels.error); + } }; const loadFile = async (file: File | undefined) => { if (!file) return; @@ -39,6 +45,7 @@ export function SoftwareWorkbench({ slug, labels }: {slug: ActiveSlug; labels: { const ext = slug === "csv-cleaner-pro" ? "csv" : slug === "json-studio" ? "json" : "txt"; const url = URL.createObjectURL(new Blob([prepareSoftwareDownload(output, ext)], {type: mime + ";charset=utf-8"})); const a = document.createElement("a"); a.href = url; a.download = "toolify365-output." + ext; document.body.appendChild(a); a.click(); a.remove(); + trackSoftwareDownload({ software: slug }); setTimeout(() => URL.revokeObjectURL(url), 1000); }; return
From e686aa2c9b8b6024d2ed8f4b7f1d6e3ab3773a75 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:03:59 +0900 Subject: [PATCH 090/164] integrate(growth+engagement): preserve recent tools and software CTA in ToolFrame --- site/src/components/tools/ToolFrame.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/components/tools/ToolFrame.tsx b/site/src/components/tools/ToolFrame.tsx index ddb33ec8..a01a09b0 100644 --- a/site/src/components/tools/ToolFrame.tsx +++ b/site/src/components/tools/ToolFrame.tsx @@ -7,6 +7,7 @@ import { SisterSiteCta } from "@/components/cross/SisterSiteCta"; import { ToolInteractionTracker } from "./ToolInteractionTracker"; import { RelatedToolLink } from "./RelatedToolLink"; import { ContinueWithRecent } from "./ContinueWithRecent"; +import { ToolSoftwareRecommendations } from "./ToolSoftwareRecommendations"; import { TrustBox } from "./TrustBox"; import { ShareBar } from "@/components/tools/ShareBar"; import { FavoriteButton } from "@/components/tools/FavoriteButton"; @@ -100,6 +101,8 @@ export function ToolFrame({ meta, title, description, related, children, article + +

{t("tool.howItWorks")}

{article} From 832d14ebc714e270942216df62b5b490a2adb16d Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:04:12 +0900 Subject: [PATCH 091/164] integrate(growth+engagement): retain scenario/recent and software GA4 events --- site/src/lib/analytics/events.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/site/src/lib/analytics/events.ts b/site/src/lib/analytics/events.ts index ce483d91..8250719d 100644 --- a/site/src/lib/analytics/events.ts +++ b/site/src/lib/analytics/events.ts @@ -164,3 +164,24 @@ export function trackScenarioAction(p: { action: p.action, }); } + +/** + * Transition from a free tool to currently available in-browser software. + * destination is a finite catalog slug; do not send user-generated content. + */ +export function trackSoftwareClick(p: ToolEventParams & { destination: string }): void { + emit("software_click", { + ...toParams(p), + destination: p.destination, + }); +} + +/** In-browser software usage: only catalog slug and success state, never user input. */ +export function trackSoftwareProcess(p: { software: string; success: boolean }): void { + emit("software_process", { software_slug: p.software, success: p.success }); +} + +/** In-browser generated file download; never send file names or contents. */ +export function trackSoftwareDownload(p: { software: string }): void { + emit("software_download", { software_slug: p.software }); +} From 2f7493f849d2f5e1c433b8885c1e454ea972d29e Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:05:46 +0900 Subject: [PATCH 092/164] docs(integration): document PR38+PR39 verification and release gates --- ...R40_INTEGRATION_VERIFICATION_2026-11-06.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md new file mode 100644 index 00000000..dcbf496a --- /dev/null +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -0,0 +1,46 @@ +# PR #40 — Toolify growth + engagement integration gate + +Date: 2026-11-06 JST. Integration from PR #39 head plus PR #38 changes. Original PRs and `main` are unchanged. + +## Integration proof (GitHub source readback) +- `site/src/components/tools/ToolFrame.tsx`: both `ContinueWithRecent` and `ToolSoftwareRecommendations` imported and rendered. +- `site/src/lib/analytics/events.ts`: `trackRecentToolClick`, `trackScenarioAction`, `trackSoftwareClick`, `trackSoftwareProcess`, `trackSoftwareDownload` exports retained. +- `site/package.json`: `test:smoke-html`, `audit:live-growth` retained. +- GitHub compare against `main`: 91 commits ahead, zero behind, 35 changed files at PR #40 creation. This is **not** evidence of conflict-free runtime behavior. +- Separate locally reconstructed copy of the PR #38 HTML helper/test: `node --test` = 3 passed, 0 failed under Node 22.16.0. This does not cover all of PR #40. + +## Mandatory local gate — no GitHub Actions +```bash +git fetch origin +git checkout integration/toolify-growth-engagement-20261106 +cd site +npm ci +npm run test:smoke-html +npm run typecheck +npm run test +npm run validate +npm run audit:seo +npm run audit:i18n +npm run audit:index-policy -- --json +npm run build +``` +Stop on any failure. Make necessary fixes **on the integration branch**, then rerun the full suite. + +## Functional and privacy checks +- Mobile/desktop en and ja pages: recent-tool controls and software CTA coexist without duplicated destinations or hidden primary tool. +- Percentage calculator: 2-scenario comparison, optional save/resume/clear, share/restore, malformed URL and expired drafts. +- ShareBar: no password/hidden value exposed in generic sharing, length cap enforced, event subscriptions behave correctly after back/forward. +- Software: CSV Cleaner Pro, JSON Studio, Text Batch Studio process and download correctly; invalid input and max-size errors handled without data upload. +- GA4: consent-aware events for recent click, scenario actions, software click/process/download. Ensure no user inputs, file contents or names are transmitted. +- Accessibility: keyboard/focus and screenreader link labels in both locales. + +## Deployment gate +Do **not** merge PR #38 and #39 separately after PR #40 merges. Review the combined PR and merge **only PR #40** once all checks pass, then close or supersede the original PRs as appropriate. Owner controls production deployment. +```bash +cd site +SITE_URL=https://toolify365.com npm run audit:live-growth +``` +Run this only after production deployment (the old site has no new CTA and correctly fails). Independently verify live GSC/Bing indexing; do not mass-index 17 locales without 28-day query evidence. + +## Current release result +**NO-GO**: full checkout, TypeScript/Vitest/Next build, cross-feature browser checks and production deploy not executed in this environment. Direct clone to private GitHub repository fails DNS resolution here. No search traffic or revenue improvement verified. From b94a261321c10390f39caed929dec5d4de3d9c27 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:08:22 +0900 Subject: [PATCH 093/164] fix(growth): put relevant free-software CTA before ad blocks --- site/src/components/tools/ToolFrame.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/tools/ToolFrame.tsx b/site/src/components/tools/ToolFrame.tsx index a01a09b0..79509efc 100644 --- a/site/src/components/tools/ToolFrame.tsx +++ b/site/src/components/tools/ToolFrame.tsx @@ -96,13 +96,14 @@ export function ToolFrame({ meta, title, description, related, children, article {/* Return-visit path: show up to three previously used tools without storing inputs. */} r.slug)} /> + {/* Intent-matched free software follows related/recent navigation, before monetization blocks. */} + + {/* Keep task-follow-up navigation immediately after results; retain monetization units below it. */} - -

{t("tool.howItWorks")}

{article} From 301985662cb63890a12c17631e4f92a591afa132 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:08:34 +0900 Subject: [PATCH 094/164] test(integration): guard software CTA location ahead of ads --- site/src/components/tools/ToolFrame.order.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/components/tools/ToolFrame.order.test.ts b/site/src/components/tools/ToolFrame.order.test.ts index 7d1415b6..9738908c 100644 --- a/site/src/components/tools/ToolFrame.order.test.ts +++ b/site/src/components/tools/ToolFrame.order.test.ts @@ -8,12 +8,14 @@ describe("tool result follow-up placement", () => { const workspace = source.indexOf(" 0"); const recent = source.indexOf(" i >= 0)).toBe(true); + expect([workspace, related, recent, software, resultAd, bannerAd].every((i) => i >= 0)).toBe(true); expect(workspace).toBeLessThan(related); expect(related).toBeLessThan(recent); - expect(recent).toBeLessThan(resultAd); + expect(recent).toBeLessThan(software); + expect(software).toBeLessThan(resultAd); expect(resultAd).toBeLessThan(bannerAd); }); it("retains the existing ad inventory", () => { From 91da8c935526e0dacdc369fd9eb9fb316e914a26 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:09:30 +0900 Subject: [PATCH 095/164] sync(integration): carry forward latest PR39 safeguard docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index 84b1bfef..a331253a 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -48,3 +48,16 @@ This file is a checkpoint, not a passing full-release certificate. - Added `share-input-policy.integration.test.ts` alongside policy unit tests. New source verified via GitHub readback. A reconstructed local copy of the pure `share-input-policy.ts` passed strict TypeScript compilation and **36 independent Node assertions**. The new Vitest source was **not** run as a full project test. - Corrected both sharing hook comments: state placed in `?s=` is **not strictly device-local**; URL history, HTTP requests, copied links and referrers can expose it. Arbitrary remaining free-text values are not guaranteed non-sensitive; a tool-by-tool privacy assessment remains required before release. - Run the full local release gate, plus manual tests that standard numeric sharing still works, sensitive autofilled values never enter `?s=`, older links open acceptably, and no analytics payload includes entered values. + +## 2026-11-06 security follow-up +- Fixed auto-sharing privacy exclusions to respect data-private or data-share="off" on enclosing field groups, not just on the input itself. +- Updated share-input-policy.integration.test.ts to assert the ancestor exclusion selector. GitHub readback confirmed the change. +- Full local test/build remains unavailable in the present execution environment; this update is not evidence of successful production deployment or GA4 uplift. + +## 2026-11-06: release blocker re-evaluation +- Verified every npm script invoked by `scripts/check-engagement-release.sh` exists in `site/package.json`. This establishes script-name consistency only; it does **not** execute the scripts. +- Six connector-readback consistency checks passed for release script names, ancestor privacy guard, input/textarea/select exclusion, URL length limits, lack of ShareBar polling and navigation-before-ad order. +- Fixed a fragile string-escaping expectation in `share-input-policy.integration.test.ts`. +- The generic URL restore path now ignores decoded field values unless they are strings or booleans, and skips non-integer or out-of-bounds mode button indices. Added source regression checks. +- `git ls-remote` still cannot resolve github.com in the working container, preventing a full checkout and project-level npm ci, Vitest, Next build and browser QA. +- Remains **NO-GO** until a full owner-accessible checkout passes all tests, browser/privacy/analytics checks, and an explicit production deploy. No live engagement improvement measured. From 980a62263d34a127c5ab71271b426a0e9ba987a3 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:09:33 +0900 Subject: [PATCH 096/164] sync(integration): carry forward latest PR39 safeguard site/src/lib/hooks/share-input-policy.integration.test.ts --- .../src/lib/hooks/share-input-policy.integration.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts index fb612573..a19b7e2c 100644 --- a/site/src/lib/hooks/share-input-policy.integration.test.ts +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -9,8 +9,15 @@ describe("share policy integration", () => { expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField)"); expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField)'); expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField)'); - expect(source).toContain('el.closest("[data-private], [data-share=\\\"off\\\"]")'); + expect(source).toContain('const privateField = !!el.closest('); + expect(source).toContain('data-share='); }); + it("ignores malformed decoded fields and out-of-range mode buttons", () => { + expect(source).toContain('typeof v !== "string" && typeof v !== "boolean"'); + expect(source).toContain('Number.isSafeInteger(i)'); + expect(source).toContain('i >= all.length'); + }); + it("retains payload size limits in both sharing hooks", () => { expect(source).toContain("isSharePayloadWithinLimit(raw, MAX_ENCODED)"); expect(source).toContain("isSharePayloadWithinLimit(encoded, MAX_ENCODED)"); From a2ff7527b0a0451c6a4edc192d15f73f714e0459 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:09:38 +0900 Subject: [PATCH 097/164] sync(integration): carry forward latest PR39 safeguard site/src/lib/hooks/useAutoShareableState.ts --- site/src/lib/hooks/useAutoShareableState.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/site/src/lib/hooks/useAutoShareableState.ts b/site/src/lib/hooks/useAutoShareableState.ts index b94d19d0..1e1f1562 100644 --- a/site/src/lib/hooks/useAutoShareableState.ts +++ b/site/src/lib/hooks/useAutoShareableState.ts @@ -132,6 +132,16 @@ function buttons(root: HTMLElement): HTMLButtonElement[] { return Array.from(root.querySelectorAll("button")); } +/** Only buttons within an active multi-button mode selector may be replayed from a URL. */ +function isModeButton(btn: HTMLButtonElement): boolean { + const parent = btn.parentElement; + if (!parent || btn.disabled || (btn.type === "submit" && btn.closest("form"))) return false; + const siblings = Array.from(parent.children).filter( + (el): el is HTMLButtonElement => el instanceof HTMLButtonElement, + ); + return siblings.length >= 2 && siblings.some((b) => b.className.includes(ACTIVE_CLASS)); +} + /** 現在の DOM 状態を構造キー→値のオブジェクトに落とす。 */ function snapshot(root: HTMLElement): Record { const seen = new Map(); @@ -144,7 +154,7 @@ function snapshot(root: HTMLElement): Record { } // 選択中モードボタンの index 一覧(カンマ区切り)。該当が無ければキー自体を作らない。 const active = buttons(root) - .map((b, i) => (b.className.includes(ACTIVE_CLASS) ? i : -1)) + .map((b, i) => (isModeButton(b) && b.className.includes(ACTIVE_CLASS) ? i : -1)) .filter((i) => i >= 0); if (active.length > 0) out.b = active.join(","); return out; @@ -178,7 +188,7 @@ export function useAutoShareableState( const k = keyOf(el, seen); const v = saved[k]; // `k in saved` では index 型が絞れないので値を取って判定する。 - if (v === undefined) continue; + if (typeof v !== "string" && typeof v !== "boolean") continue; if (only && !only(el)) continue; writeValue(el, v); } @@ -192,8 +202,9 @@ export function useAutoShareableState( const all = buttons(root); for (const raw of b.split(",")) { const i = Number(raw); + if (!Number.isSafeInteger(i) || i < 0 || i >= all.length) continue; const btn = all[i]; - if (btn && !btn.className.includes(ACTIVE_CLASS)) btn.click(); + if (btn && isModeButton(btn) && !btn.className.includes(ACTIVE_CLASS)) btn.click(); } } // 2nd: 切替で現れたフィールドを含めて全体を当てる。 From 7977929eb4a86e851c0e2d45be77d95ad5cb8975 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:10:04 +0900 Subject: [PATCH 098/164] sync(integration): refresh latest PR39 share restoration regression cases --- site/src/lib/hooks/share-input-policy.integration.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts index a19b7e2c..64e9c346 100644 --- a/site/src/lib/hooks/share-input-policy.integration.test.ts +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -18,6 +18,14 @@ describe("share policy integration", () => { expect(source).toContain('i >= all.length'); }); + it("replays URL button indices only within a mode-selector group", () => { + expect(source).toContain("function isModeButton(btn: HTMLButtonElement)"); + expect(source).toContain("siblings.length >= 2"); + expect(source).toContain("btn.closest(\"form\")"); + expect(source).toContain("btn && isModeButton(btn)"); + expect(source).toContain("isModeButton(b) && b.className.includes(ACTIVE_CLASS)"); + }); + it("retains payload size limits in both sharing hooks", () => { expect(source).toContain("isSharePayloadWithinLimit(raw, MAX_ENCODED)"); expect(source).toContain("isSharePayloadWithinLimit(encoded, MAX_ENCODED)"); From 4275c88a4bf27c25b14b9e4a644451269ec0cc4d Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:10:44 +0900 Subject: [PATCH 099/164] docs(integration): capture executed Node tests, CTA order and moving PR39 head --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index dcbf496a..d8308b56 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -44,3 +44,10 @@ Run this only after production deployment (the old site has no new CTA and corre ## Current release result **NO-GO**: full checkout, TypeScript/Vitest/Next build, cross-feature browser checks and production deploy not executed in this environment. Direct clone to private GitHub repository fails DNS resolution here. No search traffic or revenue improvement verified. + +## 2026-11-06 incremental integration QA +- The merged `ToolFrame` now presents contextual software CTAs **after** related/recent navigation but **before** advertising blocks. The corresponding `ToolFrame.order.test.ts` asserts this order. +- Actual Node 22.16.0 isolated execution of exact HTML helpers and tests from the integration branch: **3 passed / 0 failed**. The full Next/Vitest suite remains unrun because `git ls-remote` still cannot resolve `github.com` from the local container. +- PR #39 continued receiving changes while this integration was being prepared. Its updated `useAutoShareableState.ts` and sharing regression test have been copied into this branch; confirm fresh tree state again just before final merge because upstream may keep changing. +- Source-readback verified `ToolFrame` positions: workspace, recent navigation, software CTA, and ad blocks in that order; five analytics exports intact. This does not establish working browser behavior. +- Do not merge while PR #39 is actively evolving. Freeze both PR heads, recheck latest changed files, and run complete tests against the final integration tree. From 254cdb9624c5e45840c026506020d127007eede4 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:21:29 +0900 Subject: [PATCH 100/164] sync(integration): carry latest PR39 verification evidence --- docs/PR39_VERIFICATION_2026-11-05.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index a331253a..a498cf08 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -61,3 +61,11 @@ This file is a checkpoint, not a passing full-release certificate. - The generic URL restore path now ignores decoded field values unless they are strings or booleans, and skips non-integer or out-of-bounds mode button indices. Added source regression checks. - `git ls-remote` still cannot resolve github.com in the working container, preventing a full checkout and project-level npm ci, Vitest, Next build and browser QA. - Remains **NO-GO** until a full owner-accessible checkout passes all tests, browser/privacy/analytics checks, and an explicit production deploy. No live engagement improvement measured. + +## 2026-11-06 — URL-driven button activation risk +- Identified that auto-shared URLs previously could trigger clicks on arbitrary tool buttons by index, not just mode controls. +- Limited URL replay and recording of mode-button indices to sibling groups with at least two buttons and an existing active-mode styling marker; disabled and in-form submit controls cannot be replayed. +- Preserved triangle-calculator's non-form mode buttons, which have browser-default submit type but are not form submission controls. +- Added a source regression assertion in `share-input-policy.integration.test.ts`. GitHub readback confirmed code and test source; the project Vitest suite and browser QA **have not run**. +- Remaining test: valid triangle right/sss/sas links, malicious links pointing to action buttons, mode toggles, alternate tools with button-driven modes, mobile UI and sharing result links. +- Release stays NO-GO until the owner-accessible full build and manual QA pass. No performance or revenue lift has been demonstrated. From b2ce2c81ac9f8468ff49c76b7b9481365b1919fb Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:23:08 +0900 Subject: [PATCH 101/164] test(integration): include growth HTML tests in existing local release gate --- scripts/check-engagement-release.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/check-engagement-release.sh b/scripts/check-engagement-release.sh index 9dac4879..b82595fe 100644 --- a/scripts/check-engagement-release.sh +++ b/scripts/check-engagement-release.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash -# Release gate for PR #39. Does NOT deploy, merge, or run GitHub Actions. +# Combined release gate for PR #40 (growth + engagement). +# Does NOT deploy, merge, or run GitHub Actions. set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT/site" echo "[engagement] Node $(node --version), npm $(npm --version)" echo "[engagement] Installing from package-lock.json" npm ci +echo "[integration] Dependency-free HTML checks" +npm run test:smoke-html echo "[engagement] Type checking" npm run typecheck echo "[engagement] Vitest" @@ -20,4 +23,4 @@ echo "[engagement] Index eligibility policy audit" npm run audit:index-policy -- --json echo "[engagement] Next.js production build" npm run build -echo "[engagement] PASS: local static/test/build checks. Manual browser/GA4 QA and explicit deployment are still required." +echo "[integration] PASS: local test/static/build checks. Manual browser/GA4 QA and explicit deployment are still required." From 7b0a116b82b429d3d8926a5f43345c9fd5dc7bee Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 00:23:23 +0900 Subject: [PATCH 102/164] docs(integration): record one-command no-Actions full release gate --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index d8308b56..abc99c1c 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -51,3 +51,6 @@ Run this only after production deployment (the old site has no new CTA and corre - PR #39 continued receiving changes while this integration was being prepared. Its updated `useAutoShareableState.ts` and sharing regression test have been copied into this branch; confirm fresh tree state again just before final merge because upstream may keep changing. - Source-readback verified `ToolFrame` positions: workspace, recent navigation, software CTA, and ad blocks in that order; five analytics exports intact. This does not establish working browser behavior. - Do not merge while PR #39 is actively evolving. Freeze both PR heads, recheck latest changed files, and run complete tests against the final integration tree. + +## One-command combined local gate (2026-11-06) +The existing root `scripts/check-engagement-release.sh` now runs both PR #39's full checks and PR #38's dependency-free `test:smoke-html` before typecheck/Vitest/audits/build. On an owner-accessible checkout run `bash scripts/check-engagement-release.sh` from repository root; it uses `npm ci` and does not run GitHub Actions, merge or deploy. The shell script itself has not been run in this environment (private repo cannot be cloned). A passing run still requires real browser/GA4 checks before merging and owner-managed deployment. From 4556726bbada2ef4e7e7fa44f97ba26a695c001f Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:07:36 +0900 Subject: [PATCH 103/164] sync(integration): bring PR39 search safety site/src/lib/search/search-index-payload.test.ts --- .../lib/search/search-index-payload.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 site/src/lib/search/search-index-payload.test.ts diff --git a/site/src/lib/search/search-index-payload.test.ts b/site/src/lib/search/search-index-payload.test.ts new file mode 100644 index 00000000..5576b69f --- /dev/null +++ b/site/src/lib/search/search-index-payload.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { parseSearchIndexPayload } from "./search-index-payload"; + +const tool = { slug: "percentage-calculator", category: "math", title: "Percentage Calculator" }; + +describe("search index payload validation", () => { + it("accepts valid tools in order", () => { + expect(parseSearchIndexPayload({ tools: [tool, { ...tool, slug: "tip-calculator" }] })) + .toEqual([tool, { ...tool, slug: "tip-calculator" }]); + }); + it("fails closed for malformed payloads without crashing recommendations", () => { + for (const payload of [null, {}, [], { tools: null }, { tools: "bad" }, 7]) { + expect(parseSearchIndexPayload(payload)).toEqual([]); + } + }); + it("ignores malformed entries without losing valid recommendations", () => { + const data = { tools: [null, "invalid", [], { slug: "", title: "Empty", category: "math" }, { slug: "s", category: "math" }, tool] }; + expect(parseSearchIndexPayload(data)).toEqual([tool]); + }); +}); From c6035cfe5a4618023217175a40aa0e43b03d67f2 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:07:40 +0900 Subject: [PATCH 104/164] sync(integration): bring PR39 search safety site/src/lib/search/search-index-payload.ts --- site/src/lib/search/search-index-payload.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 site/src/lib/search/search-index-payload.ts diff --git a/site/src/lib/search/search-index-payload.ts b/site/src/lib/search/search-index-payload.ts new file mode 100644 index 00000000..b9601e55 --- /dev/null +++ b/site/src/lib/search/search-index-payload.ts @@ -0,0 +1,18 @@ +import type { SearchItem } from "./matcher"; + +/** Keep a broken or unexpected search-index response out of client navigation. */ +export function parseSearchIndexPayload(payload: unknown): SearchItem[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return []; + const tools = (payload as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return []; + return tools.filter((item): item is SearchItem => + !!item && + typeof item === "object" && + !Array.isArray(item) && + typeof item.slug === "string" && + item.slug.length > 0 && + typeof item.title === "string" && + item.title.length > 0 && + typeof item.category === "string" + ); +} From 5bd7bf031ee81d01be8f165d28e5d00cd61aad00 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:07:43 +0900 Subject: [PATCH 105/164] sync(integration): bring PR39 search safety site/src/lib/search/useSearchIndex.test.ts --- site/src/lib/search/useSearchIndex.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 site/src/lib/search/useSearchIndex.test.ts diff --git a/site/src/lib/search/useSearchIndex.test.ts b/site/src/lib/search/useSearchIndex.test.ts new file mode 100644 index 00000000..3f452306 --- /dev/null +++ b/site/src/lib/search/useSearchIndex.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./useSearchIndex.ts", import.meta.url), "utf8"); + +describe("locale-aware search index hook", () => { + it("never displays a previous locale's titles after a locale switch", () => { + expect(source).toContain("setLoaded({ locale, items: list })"); + expect(source).toContain("loaded?.locale === locale"); + expect(source).toContain("return enabled && loaded?.locale === locale ? loaded.items : null"); + }); + it("ignores malformed search index payloads", () => { + expect(source).toContain("parseSearchIndexPayload(payload)"); + expect(source).toContain('from "./search-index-payload"'); + }); +}); From c7991a31d779950ba980e535c13d8477351b6e9b Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:07:46 +0900 Subject: [PATCH 106/164] sync(integration): bring PR39 search safety site/src/lib/search/useSearchIndex.ts --- site/src/lib/search/useSearchIndex.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/site/src/lib/search/useSearchIndex.ts b/site/src/lib/search/useSearchIndex.ts index 9fa0de4b..18f2f414 100644 --- a/site/src/lib/search/useSearchIndex.ts +++ b/site/src/lib/search/useSearchIndex.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { SearchItem } from "./matcher"; +import { parseSearchIndexPayload } from "./search-index-payload"; /** * ロケール別の検索インデックス(/{locale}/search-index.json)を遅延取得する。 @@ -19,7 +20,7 @@ export function loadSearchIndex(locale: string): Promise { if (!p) { p = fetch(`/${locale}/search-index.json`) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) - .then((j: { tools?: SearchItem[] }) => j.tools ?? []) + .then((payload: unknown) => parseSearchIndexPayload(payload)) .catch(() => { cache.delete(locale); return [] as SearchItem[]; @@ -30,16 +31,18 @@ export function loadSearchIndex(locale: string): Promise { } export function useSearchIndex(locale: string, enabled: boolean): SearchItem[] | null { - const [items, setItems] = useState(null); + // Associate data with the locale that produced it to avoid flashing stale labels + // when a visitor switches languages while a different index is loading. + const [loaded, setLoaded] = useState<{ locale: string; items: SearchItem[] } | null>(null); useEffect(() => { if (!enabled) return; let alive = true; loadSearchIndex(locale).then((list) => { - if (alive) setItems(list); + if (alive) setLoaded({ locale, items: list }); }); return () => { alive = false; }; }, [locale, enabled]); - return items; + return enabled && loaded?.locale === locale ? loaded.items : null; } From d64cd5b3c802700b0336287af217b71b4879a5a6 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:08:23 +0900 Subject: [PATCH 107/164] test(integration): add dependency-free cross-PR UI and analytics regressions --- site/scripts/growth-integration.test.mjs | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 site/scripts/growth-integration.test.mjs diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs new file mode 100644 index 00000000..a22dff6c --- /dev/null +++ b/site/scripts/growth-integration.test.mjs @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); +const frame = read('../src/components/tools/ToolFrame.tsx'); +const events = read('../src/lib/analytics/events.ts'); +const workbench = read('../src/components/software/SoftwareWorkbench.tsx'); +const recent = read('../src/components/tools/ContinueWithRecent.tsx'); +const recs = read('../src/lib/software/recommendations.ts'); + +test('growth and engagement navigation coexist ahead of advertising', () => { + for (const name of ['ToolInteractionTracker', 'ContinueWithRecent', 'ToolSoftwareRecommendations', 'AdBelowResult']) { + assert.ok(frame.includes('<' + name), 'missing ' + name); + } + const order = [' frame.indexOf(s)); + assert.ok(order.every(i => i >= 0)); + assert.ok(order.every((v, i) => i === 0 || order[i - 1] < v), 'navigation must precede ads'); +}); + +test('all five integration analytics functions coexist', () => { + for (const name of ['trackRecentToolClick', 'trackScenarioAction', 'trackSoftwareClick', 'trackSoftwareProcess', 'trackSoftwareDownload']) { + assert.match(events, new RegExp('export function ' + name + '\\(')); + } + assert.ok(workbench.includes('trackSoftwareProcess(')); + assert.ok(workbench.includes('trackSoftwareDownload(')); + assert.ok(recent.includes('trackRecentToolClick(')); +}); + +test('catalog-gated software CTA mappings are present', () => { + assert.ok(recs.includes('AVAILABLE_SOFTWARE')); + assert.ok(recs.includes('availableRecommendations')); + assert.ok(frame.includes('slug={meta.slug} locale={locale}')); +}); From 252e923cf1f26ffcfdad878ceae935e94b63a27d Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:08:48 +0900 Subject: [PATCH 108/164] chore(integration): expose Node cross-PR verification command --- site/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/package.json b/site/package.json index 7dfa5f3b..aa99ab65 100644 --- a/site/package.json +++ b/site/package.json @@ -24,7 +24,8 @@ "check:currency": "node scripts/check-currency-rate-freshness.mjs", "audit:index-policy": "tsx scripts/report-index-policy.ts", "audit:live-growth": "node scripts/check-live-growth.mjs", - "test:smoke-html": "node --test scripts/growth-html-checks.test.mjs" + "test:smoke-html": "node --test scripts/growth-html-checks.test.mjs", + "test:integration": "node --test scripts/growth-integration.test.mjs" }, "dependencies": { "@types/qrcode": "^1.5.6", From 4ae19235055216bee522020d0ff158a468b94188 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:08:51 +0900 Subject: [PATCH 109/164] test(integration): run cross-PR regressions in local release gate --- scripts/check-engagement-release.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/check-engagement-release.sh b/scripts/check-engagement-release.sh index b82595fe..8b0f8b5f 100644 --- a/scripts/check-engagement-release.sh +++ b/scripts/check-engagement-release.sh @@ -9,6 +9,8 @@ echo "[engagement] Installing from package-lock.json" npm ci echo "[integration] Dependency-free HTML checks" npm run test:smoke-html +echo "[integration] Cross-PR regression checks" +npm run test:integration echo "[engagement] Type checking" npm run typecheck echo "[engagement] Vitest" From 83bb1c720289799ea83b9511ddba96a0bbda330c Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:09:45 +0900 Subject: [PATCH 110/164] sync(integration): update PR39 latest validated search data site/src/lib/search/search-index-payload.ts --- site/src/lib/search/search-index-payload.ts | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/site/src/lib/search/search-index-payload.ts b/site/src/lib/search/search-index-payload.ts index b9601e55..d80df5b8 100644 --- a/site/src/lib/search/search-index-payload.ts +++ b/site/src/lib/search/search-index-payload.ts @@ -1,18 +1,24 @@ import type { SearchItem } from "./matcher"; -/** Keep a broken or unexpected search-index response out of client navigation. */ +const VALID_CATEGORIES = new Set([ + "health", "math", "converter", "datetime", "text", "color", "finance", "image", +]); +const VALID_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Reject malformed index data before building navigation links or labels. */ export function parseSearchIndexPayload(payload: unknown): SearchItem[] { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return []; const tools = (payload as { tools?: unknown }).tools; if (!Array.isArray(tools)) return []; - return tools.filter((item): item is SearchItem => - !!item && - typeof item === "object" && - !Array.isArray(item) && - typeof item.slug === "string" && - item.slug.length > 0 && - typeof item.title === "string" && - item.title.length > 0 && - typeof item.category === "string" - ); + const unique = new Set(); + return tools.filter((item): item is SearchItem => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const data = item as Record; + if (typeof data.slug !== "string" || data.slug.length > 100 || !VALID_SLUG.test(data.slug)) return false; + if (typeof data.title !== "string" || !data.title.trim() || data.title.length > 200) return false; + if (typeof data.category !== "string" || !VALID_CATEGORIES.has(data.category)) return false; + if (unique.has(data.slug)) return false; + unique.add(data.slug); + return true; + }); } From 585f3493ab857b1e5404d9223dc965e69af13dbc Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:09:48 +0900 Subject: [PATCH 111/164] sync(integration): update PR39 latest validated search data site/src/lib/search/search-index-payload.test.ts --- site/src/lib/search/search-index-payload.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/site/src/lib/search/search-index-payload.test.ts b/site/src/lib/search/search-index-payload.test.ts index 5576b69f..6e867c21 100644 --- a/site/src/lib/search/search-index-payload.test.ts +++ b/site/src/lib/search/search-index-payload.test.ts @@ -13,6 +13,21 @@ describe("search index payload validation", () => { expect(parseSearchIndexPayload(payload)).toEqual([]); } }); + it("rejects unsafe links, unknown categories, duplicate slugs and oversized labels", () => { + const payload = { tools: [ + { ...tool, slug: "../secret" }, + { ...tool, slug: "other/path" }, + { ...tool, slug: "%2F%2Fevil" }, + { ...tool, category: "unknown" }, + { ...tool, title: " " }, + { ...tool, title: "X".repeat(201) }, + tool, + { ...tool, title: "Duplicate" }, + { ...tool, slug: "tip-calculator" }, + ] }; + expect(parseSearchIndexPayload(payload)) + .toEqual([tool, { ...tool, slug: "tip-calculator" }]); + }); it("ignores malformed entries without losing valid recommendations", () => { const data = { tools: [null, "invalid", [], { slug: "", title: "Empty", category: "math" }, { slug: "s", category: "math" }, tool] }; expect(parseSearchIndexPayload(data)).toEqual([tool]); From eec4a40dddcd71d9fbeb894bac5a79a7d3b472d4 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:09:53 +0900 Subject: [PATCH 112/164] sync(integration): update PR39 latest validated search data docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index a498cf08..e021e2ef 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -69,3 +69,18 @@ This file is a checkpoint, not a passing full-release certificate. - Added a source regression assertion in `share-input-policy.integration.test.ts`. GitHub readback confirmed code and test source; the project Vitest suite and browser QA **have not run**. - Remaining test: valid triangle right/sss/sas links, malicious links pointing to action buttons, mode toggles, alternate tools with button-driven modes, mobile UI and sharing result links. - Release stays NO-GO until the owner-accessible full build and manual QA pass. No performance or revenue lift has been demonstrated. + +## 2026-11-06 — Search-index localization and payload hardening +- `useSearchIndex` now keys fetched data by the requested locale and returns null while an index for the new locale is loading, rather than displaying stale-language titles. +- Extracted malformed search-index response handling into `site/src/lib/search/search-index-payload.ts`. Missing arrays and malformed entries are ignored; valid tool entries are preserved. +- Added unit tests for parsed payloads plus a source-level regression test for locale-scoped display. Reconstructed the exact parser in a standalone local TypeScript harness with a minimal type stub: strict compilation and 10 Node assertions passed. +- This is not a full Next.js app compile or UI-level language-switch test. No GitHub Actions, merge or deployment were performed. +- Full-repo checkout remains blocked by DNS from this container, confirmed again. Run `bash scripts/check-engagement-release.sh` in the owner environment and verify locale switches in browser before release. + +## 2026-11-06 — Search index link validation +- Strengthened the localized search-index parser used by recent-tool navigation: accept only hyphenated ASCII tool slugs (max 100 chars), known tool categories, nonempty localized titles (max 200 chars); drop duplicate slugs. +- Reject malformed path traversal, percent-encoded separators, unknown categories and oversized titles without hiding valid tool suggestions. +- Expanded `search-index-payload.test.ts` for malformed URL targets, duplicate slugs, and title/category validation. +- Reconstructed the exact pure parser in an isolated local TypeScript harness with a minimal compatible SearchItem type stub. `tsc --strict` passed and 15 Node assertions passed. +- **These are isolated checks, not the full repository Vitest/Next build**. No production deployment, mobile QA, or GA4 engagement uplift verified. +- Next gate: full checkout and `bash scripts/check-engagement-release.sh`, then check actual en/ja locale switching and malformed index behavior on mobile. From 5476c40d98333a78614e13953f7df5f94c6e2382 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:10:22 +0900 Subject: [PATCH 113/164] docs(integration): record updated PR39 search safety and local regression gate --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index abc99c1c..1d38dea9 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -54,3 +54,11 @@ Run this only after production deployment (the old site has no new CTA and corre ## One-command combined local gate (2026-11-06) The existing root `scripts/check-engagement-release.sh` now runs both PR #39's full checks and PR #38's dependency-free `test:smoke-html` before typecheck/Vitest/audits/build. On an owner-accessible checkout run `bash scripts/check-engagement-release.sh` from repository root; it uses `npm ci` and does not run GitHub Actions, merge or deploy. The shell script itself has not been run in this environment (private repo cannot be cloned). A passing run still requires real browser/GA4 checks before merging and owner-managed deployment. + +## 2026-11-06 morning sync (search safety + combined regression gate) +- PR #39 added `search-index-payload.ts` and tests plus `useSearchIndex` validation. These were ported to PR #40, then the latest stricter payload validation (known categories, safe slug pattern, title limits, deduplication) and expanded tests were resynced. +- Compared against category configuration: parser's eight categories match `ToolCategory`'s eight configured categories. +- Added `site/scripts/growth-integration.test.mjs` with three dependency-free checks: workspace/navigation/CTA/ad order, five GA4 exports and consumers, and catalog-gated recommendation wiring. +- Registered `npm run test:integration` and added it to `scripts/check-engagement-release.sh` along with `test:smoke-html`. +- GitHub connector readback verified all three tests are present, the new npm command is wired, the UI order is workspace → recent → software CTA → ad, and the five tracking functions are retained. **This is source readback, not a completed run of the full integration gate.** +- Direct clone still fails DNS from available execution environment. Avoid merging PR #40 until full npm install, Vitest, typecheck, browser/device checks and build run in an authorized checkout. From 45b51f9672af10835838ecfaf1d3cca0370f1bb2 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:22:43 +0900 Subject: [PATCH 114/164] fix(growth): prevent script hydration text from satisfying live CTA checks --- site/scripts/growth-html-checks.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/scripts/growth-html-checks.mjs b/site/scripts/growth-html-checks.mjs index f5b5ce4c..2535a384 100644 --- a/site/scripts/growth-html-checks.mjs +++ b/site/scripts/growth-html-checks.mjs @@ -10,8 +10,10 @@ export function canonicalHref(html) { return tag?.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1]?.replace(/&/g, '&') ?? null; } export function hasSoftwareAnchor(html, slug, locale, origin = 'https://toolify365.com') { + // Ignore serialized RSC/hydration script text and style content: only real HTML anchors count. + const markup = html.replace(/]*>[\s\S]*?<\/script>/gi, '').replace(/]*>[\s\S]*?<\/style>/gi, ''); const destination = `/software/${slug}`; - return [...html.matchAll(/]*>/gi)].some(([tag]) => { + return [...markup.matchAll(/]*>/gi)].some(([tag]) => { const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1]?.replace(/&/g, '&'); if (!href) return false; try { From 80da8ab5cd73f9c3143ceb3242d0bcecc88e3c41 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:22:54 +0900 Subject: [PATCH 115/164] test(growth): reject faux anchors inside hydration scripts and style blocks --- site/scripts/growth-html-checks.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/scripts/growth-html-checks.test.mjs b/site/scripts/growth-html-checks.test.mjs index 9af08b0f..4b6208d8 100644 --- a/site/scripts/growth-html-checks.test.mjs +++ b/site/scripts/growth-html-checks.test.mjs @@ -17,6 +17,8 @@ test('canonical regardless of attribute order', () => { test('CTA must be an anchor, not an inert script string', () => { assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), true); assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'ja'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); From 6dae305eccebfeb14ce1dce665b768b3d76fda9d Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 09:23:27 +0900 Subject: [PATCH 116/164] docs(integration): record full PR39 file parity and executable script-tag regression checks --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index 1d38dea9..63d9f125 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -62,3 +62,11 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Registered `npm run test:integration` and added it to `scripts/check-engagement-release.sh` along with `test:smoke-html`. - GitHub connector readback verified all three tests are present, the new npm command is wired, the UI order is workspace → recent → software CTA → ad, and the five tracking functions are retained. **This is source readback, not a completed run of the full integration gate.** - Direct clone still fails DNS from available execution environment. Avoid merging PR #40 until full npm install, Vitest, typecheck, browser/device checks and build run in an authorized checkout. + +## 2026-11-06 fresh PR39 parity and rendered HTML regression +- Compared latest PR #39 changes to integration PR #40: 7 highlighted files (sharing policy/hook, search-index validation/hook/tests and verification record) were fetched by the GitHub connector and all seven file contents matched exactly. +- Repeated local direct git clone probe: `Could not resolve host: github.com`; full repository checkout still not available in execution container. +- Ran Node.js 22.16.0 with a local copy of the exact growth HTML helper/test modules: **3 test groups passed, zero failed**. +- Identified a misleading success case: serialized HTML text inside hydration `'), false); }); test('canonical regardless of attribute order', () => { assert.equal(canonicalHref(''), 'https://toolify365.com/en/tools/foo'); assert.equal(canonicalHref(''), 'https://toolify365.com/en/tools/foo'); assert.equal(canonicalHref(''), null); + assert.equal(canonicalHref(''), '/right'); }); test('CTA must be an anchor, not an inert script string', () => { assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), true); assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'ja'), false); assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); From 6dcd4892d5a35241c194cc33e1121fe21b6db29e Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 11:24:56 +0900 Subject: [PATCH 127/164] docs(integration): record executed SEO smoke tests and latest PR39 parity --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index 867a4f42..fff27f20 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -77,3 +77,9 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Copied the GitHub HTML helper and its test cases into an isolated local Node.js 22.16.0 environment and executed `node --test`: **3 passed, 0 failed**. The test covers noindex, canonical, real CTA anchor/locale/domain and fake script/style elements. Test copies, not an actual full repository checkout. - Direct `git ls-remote https://github.com/appdevelopsk/toolify.git HEAD` again failed with `Could not resolve host: github.com`. No full `npm ci`, typecheck, complete Vitest, production build, visual QA or merge/deploy performed. - Current decision remains **NO-GO**, pending full local gate, manual QA and owner deployment. + +## 2026-11-06 11:21 JST: latest PR39 sync and HTML false-positive hardening +- Compared current PR39 and PR40 source: the only mismatched upstream file contents at review were `docs/PR39_VERIFICATION_2026-11-05.md` and `site/src/lib/search/search-index-payload.test.ts`; both synchronized. +- Fixed `site/scripts/growth-html-checks.mjs` to strip HTML comments, script, and style payloads for **all three** validators (`hasNoindex`, `canonicalHref`, and `hasSoftwareAnchor`). Added negative tests for fake SEO tags inside comments and hydration payloads. +- Ran a local exact-source recreation of the GitHub helper/test with Node v22.16.0: `node --test` **3 subtests passed, 0 failed**. Verified test execution, but full application runtime and connected GitHub checkout remain untested (DNS error). +- Continue to hold draft PR40 until `bash scripts/check-engagement-release.sh` passes from the integrated branch, plus browser QA and owner-controlled deployment. From 4a17e502152ed601ad5b4f0d879b9b6dc74bebde Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 11:25:20 +0900 Subject: [PATCH 128/164] sync(integration): trust-only engagement regression test --- .../ToolInteractionTracker.trust.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 site/src/components/tools/ToolInteractionTracker.trust.test.ts diff --git a/site/src/components/tools/ToolInteractionTracker.trust.test.ts b/site/src/components/tools/ToolInteractionTracker.trust.test.ts new file mode 100644 index 00000000..dec78171 --- /dev/null +++ b/site/src/components/tools/ToolInteractionTracker.trust.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./ToolInteractionTracker.tsx", import.meta.url), "utf8"); +const restore = readFileSync(new URL("../../lib/hooks/useAutoShareableState.ts", import.meta.url), "utf8"); + +describe("user-initiated tool engagement telemetry", () => { + it("does not count synthetic input/change restoration events as calculate", () => { + expect(source).toContain("function onInput(e: Event)"); + expect(source).toContain("if (!e.isTrusted || sent.current.calculate) return"); + expect(source).toContain('el.addEventListener("input", onInput)'); + expect(source).toContain('el.addEventListener("change", onInput)'); + expect(restore).toContain('el.dispatchEvent(new Event("input", { bubbles: true }))'); + }); + + it("does not count programmatic mode replay as preset or copy actions", () => { + expect(source).toContain("function onClick(e: Event)"); + expect(source).toContain("if (!e.isTrusted) return"); + expect(restore).toContain("btn.click()"); + }); +}); From 1c1db76ebd2b4fd1a130189f42a5a48571b7d284 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 11:25:29 +0900 Subject: [PATCH 129/164] sync(integration): user-trusted interaction event safeguard --- site/src/components/tools/ToolInteractionTracker.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/components/tools/ToolInteractionTracker.tsx b/site/src/components/tools/ToolInteractionTracker.tsx index 3a7c6d5c..801d30d2 100644 --- a/site/src/components/tools/ToolInteractionTracker.tsx +++ b/site/src/components/tools/ToolInteractionTracker.tsx @@ -72,8 +72,9 @@ export function ToolInteractionTracker({ }); } - function onInput() { - if (sent.current.calculate) return; + function onInput(e: Event) { + // URL restore uses synthetic input/change events, not real user actions. + if (!e.isTrusted || sent.current.calculate) return; const root = el as HTMLElement; if (root.querySelector("[data-calc-label]")) { if (settle !== undefined) window.clearTimeout(settle); @@ -98,6 +99,8 @@ export function ToolInteractionTracker({ } function onClick(e: Event) { + // Programmatic mode replay must not inflate GA4 user-action events. + if (!e.isTrusted) return; const target = e.target as HTMLElement | null; const btn = target?.closest?.("button"); if (!btn) return; From d9b4496e13cba146f2176cfc9642e5a8ce8d7978 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 11:25:50 +0900 Subject: [PATCH 130/164] test(integration): protect trusted-user-only engagement telemetry --- site/scripts/growth-integration.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs index a22dff6c..484c79b4 100644 --- a/site/scripts/growth-integration.test.mjs +++ b/site/scripts/growth-integration.test.mjs @@ -32,3 +32,11 @@ test('catalog-gated software CTA mappings are present', () => { assert.ok(recs.includes('availableRecommendations')); assert.ok(frame.includes('slug={meta.slug} locale={locale}')); }); + +test('synthetic restoration events cannot inflate active tool engagement', () => { + const tracker = read('../src/components/tools/ToolInteractionTracker.tsx'); + const restore = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(tracker.includes('if (!e.isTrusted || sent.current.calculate) return')); + assert.ok(tracker.includes('if (!e.isTrusted) return')); + assert.ok(restore.includes('dispatchEvent(new Event("input", { bubbles: true }))')); +}); From 8a1d94f354d57a42c06005e2be727afd48d887b4 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 12:02:30 +0900 Subject: [PATCH 131/164] sync(integration): refresh PR39 docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index 2414597e..24bd3316 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -98,3 +98,17 @@ This file is a checkpoint, not a passing full-release certificate. - Added a separate regression case that explicitly preserves a valid one-character slug; this checks that the validator is not over-restrictive. - Reproduced the parser logic in an isolated local TypeScript harness with a minimal compatible SearchItem type definition; `tsc --strict` passed and 10 grouped Node assertions passed, including normal, invalid, duplicate and short-slug scenarios. This is NOT a run of project Vitest or Next build. - Git CLI still cannot resolve github.com in the execution environment; continue requiring owner-accessible `bash scripts/check-engagement-release.sh`, browser QA and reviewed deployment before merging PR #39. + +## 2026-11-06 — GA4 synthetic restore measurement correction +- `ToolInteractionTracker.tsx` now ignores untrusted DOM input/change and click events when tracking `calculate`, `copy_result`, and `preset_click`. Auto shared-URL restore uses programmatic events and `.click()`, so those should not count as actual user engagement. +- Added `ToolInteractionTracker.trust.test.ts` to assert that the user-event boundary and event subscriptions remain in place. +- GitHub readback confirms 5 source consistency checks (trusted input/click guards, listener registration, synthetic restore dispatch and test assertions) passed. **This is static readback, not running Vitest, a browser, or a GA4 debugging session.** +- Browser acceptance: load an auto-shared URL, confirm no `calculate`/preset/copy event before user action; change a field manually and confirm one `calculate`; check consent denied/granted modes; verify no analytics values include user input. +- Release remains NO-GO pending full tests/build and manual owner QA, without GitHub Actions. + +## 2026-11-06 — Client-side route transitions and delayed GA4 events +- `ToolInteractionTracker` now resets one-shot `calculate` / `copy_result` / `preset_click` flags whenever the effect is recreated for a changed tool slug, locale or category. This avoids carrying dedup state from an earlier client-side route. +- Cancelled the delayed 50 ms calculation event on unmount and added a disposed guard. Without this, a queued timer could send an event after a tool was left. +- Added a corresponding `ToolInteractionTracker.trust.test.ts` source regression case and confirmed five source consistency checks by GitHub readback. +- This static verification is NOT a real Vitest run or GA4 DebugView test. Browser acceptance still needs navigate tool A → B without a full reload, operate B and verify its first calculate, then leave before the pending event and verify no late analytics event. +- Release stays draft/NO-GO; no full Next build, review, deployment or observed revenue/engagement improvement. From a881ff2cb269a9a8e329090feb995a26f1957886 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 12:02:34 +0900 Subject: [PATCH 132/164] sync(integration): refresh PR39 site/src/components/tools/ToolInteractionTracker.trust.test.ts --- .../tools/ToolInteractionTracker.trust.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/site/src/components/tools/ToolInteractionTracker.trust.test.ts b/site/src/components/tools/ToolInteractionTracker.trust.test.ts index dec78171..d25e5efe 100644 --- a/site/src/components/tools/ToolInteractionTracker.trust.test.ts +++ b/site/src/components/tools/ToolInteractionTracker.trust.test.ts @@ -13,6 +13,16 @@ describe("user-initiated tool engagement telemetry", () => { expect(restore).toContain('el.dispatchEvent(new Event("input", { bubbles: true }))'); }); + it("resets event dedup for client navigation and cancels delayed telemetry on unmount", () => { + expect(source).toContain("sent.current = { calculate: false, copy: false, preset: false }"); + expect(source).toContain("let calculateDelay: number | undefined"); + expect(source).toContain("let disposed = false"); + expect(source).toContain("if (disposed) return"); + expect(source).toContain("disposed = true"); + expect(source).toContain("window.clearTimeout(calculateDelay)"); + expect(source).toContain("}, [slug, locale, category])"); + }); + it("does not count programmatic mode replay as preset or copy actions", () => { expect(source).toContain("function onClick(e: Event)"); expect(source).toContain("if (!e.isTrusted) return"); From f81e40dab57aec4b27a94329d74cd9cd36c7feeb Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 12:02:36 +0900 Subject: [PATCH 133/164] sync(integration): refresh PR39 site/src/components/tools/ToolInteractionTracker.tsx --- site/src/components/tools/ToolInteractionTracker.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/site/src/components/tools/ToolInteractionTracker.tsx b/site/src/components/tools/ToolInteractionTracker.tsx index 801d30d2..72d37351 100644 --- a/site/src/components/tools/ToolInteractionTracker.tsx +++ b/site/src/components/tools/ToolInteractionTracker.tsx @@ -47,6 +47,8 @@ export function ToolInteractionTracker({ useEffect(() => { const el = ref.current; if (!el) return; + // A client-side route can reuse this mounted component for a different tool. + sent.current = { calculate: false, copy: false, preset: false }; // data-calc-label を持つツール(2026-10-02: pressure-converter / workdays-calculator、10-03: date-calculator)は // 「何を計算したか」(単位ペア・祝日プリセット等)を calculate の label に載せる。 @@ -54,8 +56,11 @@ export function ToolInteractionTracker({ // (LABEL_SETTLE_MS) またはタブを離れる時に1回だけ送る。新しいイベント名は作らない // = 全ツールで calculate は従来どおり1着地1回(重複発火しない)。 let settle: number | undefined; + let calculateDelay: number | undefined; + let disposed = false; function sendWithLabel() { + if (disposed) return; if (sent.current.calculate) return; sent.current.calculate = true; if (settle !== undefined) window.clearTimeout(settle); @@ -83,7 +88,8 @@ export function ToolInteractionTracker({ } sent.current.calculate = true; // 結果描画はツール側の再レンダー後なので、1 フレーム待ってから DOM を見る。 - window.setTimeout(() => { + calculateDelay = window.setTimeout(() => { + if (disposed) return; trackCalculate({ tool: slug, locale, @@ -130,8 +136,10 @@ export function ToolInteractionTracker({ el.addEventListener("click", onClick); document.addEventListener("visibilitychange", onHide); return () => { + disposed = true; document.removeEventListener("visibilitychange", onHide); if (settle !== undefined) window.clearTimeout(settle); + if (calculateDelay !== undefined) window.clearTimeout(calculateDelay); el.removeEventListener("input", onInput); el.removeEventListener("change", onInput); el.removeEventListener("click", onClick); From bc07617fd44ff77b9e2a342911eafa6f090bdf85 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 12:03:18 +0900 Subject: [PATCH 134/164] test(integration): guard engagement event reset and delayed cleanup --- site/scripts/growth-integration.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs index 484c79b4..86b0503c 100644 --- a/site/scripts/growth-integration.test.mjs +++ b/site/scripts/growth-integration.test.mjs @@ -38,5 +38,9 @@ test('synthetic restoration events cannot inflate active tool engagement', () => const restore = read('../src/lib/hooks/useAutoShareableState.ts'); assert.ok(tracker.includes('if (!e.isTrusted || sent.current.calculate) return')); assert.ok(tracker.includes('if (!e.isTrusted) return')); + assert.ok(tracker.includes('sent.current = { calculate: false, copy: false, preset: false }')); + assert.ok(tracker.includes('if (disposed) return')); + assert.ok(tracker.includes('window.clearTimeout(calculateDelay)')); + assert.ok(tracker.includes('}, [slug, locale, category])')); assert.ok(restore.includes('dispatchEvent(new Event("input", { bubbles: true }))')); }); From f80a966fc408f191beafdbdc1dbe80f79f7ced68 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 12:03:34 +0900 Subject: [PATCH 135/164] docs(integration): record latest PR39 parity and telemetry lifecycle assertions --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index fff27f20..46f5831a 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -83,3 +83,8 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Fixed `site/scripts/growth-html-checks.mjs` to strip HTML comments, script, and style payloads for **all three** validators (`hasNoindex`, `canonicalHref`, and `hasSoftwareAnchor`). Added negative tests for fake SEO tags inside comments and hydration payloads. - Ran a local exact-source recreation of the GitHub helper/test with Node v22.16.0: `node --test` **3 subtests passed, 0 failed**. Verified test execution, but full application runtime and connected GitHub checkout remain untested (DNS error). - Continue to hold draft PR40 until `bash scripts/check-engagement-release.sh` passes from the integrated branch, plus browser QA and owner-controlled deployment. + +## 2026-11-06 latest parity refresh +- Compared eleven candidate files from the latest PR39 head to the integration branch using exact GitHub blob SHA. Eight matched; synced three differing files: `docs/PR39_VERIFICATION_2026-11-05.md`, `ToolInteractionTracker.tsx`, and `ToolInteractionTracker.trust.test.ts`. +- The latest PR39 tracker now resets event deduplication on tool route change and cancels delayed tracking when the component unmounts. Updated dependency-free integration test assertions to keep those protections. +- The full Node/Next integrated release gate has **not** run in this environment and its results must not be inferred from source inspection. PR40 remains draft; owner-managed deploy pending. From 0fc3aa48de9921dd98341e045ef92926c52d7c15 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:34:55 +0900 Subject: [PATCH 136/164] sync(integration): latest PR39 safeguards docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index 24bd3316..fa9e46ca 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -112,3 +112,9 @@ This file is a checkpoint, not a passing full-release certificate. - Added a corresponding `ToolInteractionTracker.trust.test.ts` source regression case and confirmed five source consistency checks by GitHub readback. - This static verification is NOT a real Vitest run or GA4 DebugView test. Browser acceptance still needs navigate tool A → B without a full reload, operate B and verify its first calculate, then leave before the pending event and verify no late analytics event. - Release stays draft/NO-GO; no full Next build, review, deployment or observed revenue/engagement improvement. + +## 2026-11-06 — Name/id-based sensitive input protection +- Auto-generated `?s=` state formerly inspected input type, browser autocomplete and opt-out attributes, but did not check developer-assigned field `name` or `id`. Added an optional identifier parameter in `share-input-policy.ts` and wired `name` + `id` for input, textarea and select. +- Filters credential/personal-data hint names including `apiKey`, `privateKey`, `refresh_token`, `creditCard`, `cardNumber`, `billingEmail`, `contactPhone`, and `postalCode`; camelCase is normalized. Regular numeric field identifiers such as `amount`, `valueA`, `angleC` remain eligible. +- Added unit and integration test assertions. GitHub readback verifies the new source and test wiring. Reused the exact source pattern in a JavaScript regex check: **17 identifier cases passed** (12 excluded, 5 allowed). This check is limited to identifier matching, not full Vitest/React/Next behavior. +- Remaining risk: arbitrary free-text can still contain sensitive content even without sensitive field names. Full tool-by-tool privacy review remains a release blocker. Real browser/GA4/consent checks and full `bash scripts/check-engagement-release.sh` are not complete. From b2a1188b2b216caa6a70ec0b324324e4b38b7aa5 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:34:58 +0900 Subject: [PATCH 137/164] sync(integration): latest PR39 safeguards site/src/lib/hooks/share-input-policy.integration.test.ts --- site/src/lib/hooks/share-input-policy.integration.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts index 64e9c346..23b17a71 100644 --- a/site/src/lib/hooks/share-input-policy.integration.test.ts +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -6,11 +6,12 @@ const manual = readFileSync(new URL("./useShareableState.ts", import.meta.url), describe("share policy integration", () => { it("uses the PII-aware policy for inputs, textareas and selects", () => { - expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField)"); - expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField)'); - expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField)'); + expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField, identifier)"); + expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField, identifier)'); + expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier)'); expect(source).toContain('const privateField = !!el.closest('); expect(source).toContain('data-share='); + expect(source).toContain('const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`'); }); it("ignores malformed decoded fields and out-of-range mode buttons", () => { expect(source).toContain('typeof v !== "string" && typeof v !== "boolean"'); From 88e0d9eb397eab41e6713ff050adcd62ecc70f75 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:35:02 +0900 Subject: [PATCH 138/164] sync(integration): latest PR39 safeguards site/src/lib/hooks/share-input-policy.test.ts --- site/src/lib/hooks/share-input-policy.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/site/src/lib/hooks/share-input-policy.test.ts b/site/src/lib/hooks/share-input-policy.test.ts index 186a6789..9ceb148c 100644 --- a/site/src/lib/hooks/share-input-policy.test.ts +++ b/site/src/lib/hooks/share-input-policy.test.ts @@ -28,6 +28,19 @@ describe("generic share URL input policy", () => { expect(isShareableField("textarea", "street-address")).toBe(false); }); + it("excludes sensitive name/id identifiers even without autocomplete", () => { + for (const identifier of [ + "apiKey", "user-password", "refresh_token", "creditCard", + "cardNumber", "cvv", "billingEmail", "contactPhone", "postalCode", + "secret", "privateKey", "socialSecurity", + ]) { + expect(isShareableField("text", "off", false, identifier)).toBe(false); + } + for (const identifier of ["amount", "result", "valueA", "percentage", "angleC"]) { + expect(isShareableField("number", "off", false, identifier)).toBe(true); + } + }); + it("rejects oversized or invalid encoded payload sizes", () => { expect(isSharePayloadWithinLimit("abc", 3)).toBe(true); expect(isSharePayloadWithinLimit("abcd", 3)).toBe(false); From 6e2ce79a53ea37599b3520aaeeaa24b2efdd7f9f Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:35:05 +0900 Subject: [PATCH 139/164] sync(integration): latest PR39 safeguards site/src/lib/hooks/share-input-policy.ts --- site/src/lib/hooks/share-input-policy.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/lib/hooks/share-input-policy.ts b/site/src/lib/hooks/share-input-policy.ts index 9fb2d868..5c05c5bf 100644 --- a/site/src/lib/hooks/share-input-policy.ts +++ b/site/src/lib/hooks/share-input-policy.ts @@ -15,12 +15,17 @@ export function isShareableInputType(type: string): boolean { } /** Exclude explicitly sensitive/autofilled fields and opt-out controls. */ +/** Field identifiers are developer-controlled hints, not a complete PII classifier. */ +const SENSITIVE_IDENTIFIER = /(?:^|[-_])(?:password|passwd|secret|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|private[-_]?key|credit[-_]?card|card[-_]?number|cvv|cvc|ssn|social[-_]?security|email|phone|telephone|address|postal[-_]?code)(?:$|[-_])/i; + export function isShareableField( type: string, autocomplete = "", privateField = false, + identifier = "", ): boolean { - if (privateField || !isShareableInputType(type)) return false; + const normalizedIdentifier = identifier.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/\s+/g, "-"); + if (privateField || !isShareableInputType(type) || SENSITIVE_IDENTIFIER.test(normalizedIdentifier)) return false; const tokens = autocomplete.toLowerCase().trim().split(/\s+/); return !tokens.some((token) => SENSITIVE_AUTOCOMPLETE.test(token)); } From 1867acb53b6ac6226c1834d32d46b06b308bc1f8 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:35:27 +0900 Subject: [PATCH 140/164] sync(integration): latest PR39 safeguards site/src/lib/hooks/useAutoShareableState.ts --- site/src/lib/hooks/useAutoShareableState.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/lib/hooks/useAutoShareableState.ts b/site/src/lib/hooks/useAutoShareableState.ts index 0d868089..b8e0243f 100644 --- a/site/src/lib/hooks/useAutoShareableState.ts +++ b/site/src/lib/hooks/useAutoShareableState.ts @@ -48,13 +48,14 @@ function fields(root: HTMLElement): HTMLElement[] { ).filter((el) => { // Do not share fields explicitly marked private or browser-autofilled as PII. const privateField = !!el.closest("[data-private], [data-share=\"off\"]"); + const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`; if (el instanceof HTMLInputElement) { - return isShareableField(el.type, el.autocomplete, privateField); + return isShareableField(el.type, el.autocomplete, privateField, identifier); } if (el instanceof HTMLTextAreaElement) { - return isShareableField("textarea", el.autocomplete, privateField); + return isShareableField("textarea", el.autocomplete, privateField, identifier); } - return isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField); + return isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier); }); } From 48296ab1f020325babd600886c3fad4e2c6f80f0 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:36:44 +0900 Subject: [PATCH 141/164] test(integration): safeguard PII-aware generic share field exclusions --- site/scripts/growth-integration.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs index 86b0503c..40bf1060 100644 --- a/site/scripts/growth-integration.test.mjs +++ b/site/scripts/growth-integration.test.mjs @@ -44,3 +44,15 @@ test('synthetic restoration events cannot inflate active tool engagement', () => assert.ok(tracker.includes('}, [slug, locale, category])')); assert.ok(restore.includes('dispatchEvent(new Event("input", { bubbles: true }))')); }); + +test('automatic share URLs exclude sensitive named and autofilled fields', () => { + const policy = read('../src/lib/hooks/share-input-policy.ts'); + const hook = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(policy.includes('SENSITIVE_IDENTIFIER')); + assert.ok(policy.includes('SENSITIVE_AUTOCOMPLETE')); + assert.ok(policy.includes('SENSITIVE_IDENTIFIER.test(normalizedIdentifier)')); + assert.ok(hook.includes('const identifier =')); + assert.ok(hook.includes('isShareableField(el.type, el.autocomplete, privateField, identifier)')); + assert.ok(hook.includes('isShareableField("textarea", el.autocomplete, privateField, identifier)')); + assert.ok(hook.includes('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier)')); +}); From ed27dd3cd2a00aacc1195214773b96786a07ef30 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:37:14 +0900 Subject: [PATCH 142/164] docs(integration): capture latest sensitive-field policy and five integration guards --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index 46f5831a..c7c9eeaa 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -88,3 +88,9 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Compared eleven candidate files from the latest PR39 head to the integration branch using exact GitHub blob SHA. Eight matched; synced three differing files: `docs/PR39_VERIFICATION_2026-11-05.md`, `ToolInteractionTracker.tsx`, and `ToolInteractionTracker.trust.test.ts`. - The latest PR39 tracker now resets event deduplication on tool route change and cancels delayed tracking when the component unmounts. Updated dependency-free integration test assertions to keep those protections. - The full Node/Next integrated release gate has **not** run in this environment and its results must not be inferred from source inspection. PR40 remains draft; owner-managed deploy pending. + +## 2026-11-06 evening update: PR39 sensitive-field share policy sync +- Checked current PR39 vs integration PR40 across 13 candidate files. Eight exact blob SHAs matched; five newer files were brought forward: PR39 verification record, `share-input-policy.integration.test.ts`, `share-input-policy.test.ts`, `share-input-policy.ts`, and `useAutoShareableState.ts`. +- The updated share policy excludes password/email/tel and autofill-sensitive inputs, developer-labeled sensitive identifiers (including camelCase) and explicitly private fields. This is a defense-in-depth filter; it **does not guarantee** all free text is non-sensitive. +- Added a fifth dependency-free `growth-integration.test.mjs` check for policy constants and actual hook integration; the full suite has NOT been run in a checked-out app. +- Running full build requires source checkout, npm install, TypeScript/Vitest, SEO/i18n audits, device QA. Keep PR #40 Draft/NO-GO pending the full owner-accessible `bash scripts/check-engagement-release.sh` gate. From bd314223b72661fbeca7ae8762c3b3c3273e94c8 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:49:15 +0900 Subject: [PATCH 143/164] sync(integration): carry latest PR39 docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index fa9e46ca..eb44ecee 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -118,3 +118,10 @@ This file is a checkpoint, not a passing full-release certificate. - Filters credential/personal-data hint names including `apiKey`, `privateKey`, `refresh_token`, `creditCard`, `cardNumber`, `billingEmail`, `contactPhone`, and `postalCode`; camelCase is normalized. Regular numeric field identifiers such as `amount`, `valueA`, `angleC` remain eligible. - Added unit and integration test assertions. GitHub readback verifies the new source and test wiring. Reused the exact source pattern in a JavaScript regex check: **17 identifier cases passed** (12 excluded, 5 allowed). This check is limited to identifier matching, not full Vitest/React/Next behavior. - Remaining risk: arbitrary free-text can still contain sensitive content even without sensitive field names. Full tool-by-tool privacy review remains a release blocker. Real browser/GA4/consent checks and full `bash scripts/check-engagement-release.sh` are not complete. + +## 2026-11-06 — Oversized share URL stale-state protection +- In both manual and generic sharing hooks, when new input state exceeds the URL size cap, the previous `?s=` is now removed rather than silently left in the URL. This prevents an older result from being offered as if it represented the current calculator input. +- Both paths preserve `window.history.state` and dispatch `toolify:share-state-change` when removing a stale result URL, so the ShareBar "copy result link" control can disappear. +- Updated `ShareBar.events.test.ts` with regression expectations for both hooks. +- GitHub readback verified source/test presence of removal, history preservation and notification, but project-level Vitest, Next build and real browser behavior remain unverified. +- Browser acceptance: generate result URL, exceed size cap, confirm result-link button disappears while normal share stays available; shorten input, confirm result link regenerates; verify back/forward and client route navigation. From 780aecdc95cea908e6851cbb6470b4cfb75b323c Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:49:22 +0900 Subject: [PATCH 144/164] sync(integration): carry latest PR39 site/src/components/tools/ShareBar.events.test.ts --- site/src/components/tools/ShareBar.events.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/site/src/components/tools/ShareBar.events.test.ts b/site/src/components/tools/ShareBar.events.test.ts index 153e9097..75f5e0d1 100644 --- a/site/src/components/tools/ShareBar.events.test.ts +++ b/site/src/components/tools/ShareBar.events.test.ts @@ -13,6 +13,19 @@ describe("share URL controls", () => { expect(shareBar).not.toContain("setInterval(sync"); }); + it("clears previously shared result links when new inputs become too large", () => { + for (const file of [ + "../../lib/hooks/useShareableState.ts", + "../../lib/hooks/useAutoShareableState.ts", + ]) { + const source = get(file); + expect(source).toContain('stale.searchParams.has("s")'); + expect(source).toContain('stale.searchParams.delete("s")'); + expect(source).toContain('window.history.replaceState(window.history.state, "", stale.toString())'); + expect(source).toContain('window.dispatchEvent(new Event("toolify:share-state-change"))'); + } + }); + it("notifies ShareBar after both URL writer implementations update state", () => { for (const file of [ "../../lib/hooks/useShareableState.ts", From d2aca46fdabf8c5e45eb75af934718ee43141262 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:49:44 +0900 Subject: [PATCH 145/164] sync(integration): carry latest PR39 site/src/lib/hooks/useAutoShareableState.ts --- site/src/lib/hooks/useAutoShareableState.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/site/src/lib/hooks/useAutoShareableState.ts b/site/src/lib/hooks/useAutoShareableState.ts index b8e0243f..931e3dc2 100644 --- a/site/src/lib/hooks/useAutoShareableState.ts +++ b/site/src/lib/hooks/useAutoShareableState.ts @@ -49,13 +49,14 @@ function fields(root: HTMLElement): HTMLElement[] { // Do not share fields explicitly marked private or browser-autofilled as PII. const privateField = !!el.closest("[data-private], [data-share=\"off\"]"); const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`; + const explicitTextOptIn = el.getAttribute("data-share") === "on"; if (el instanceof HTMLInputElement) { - return isShareableField(el.type, el.autocomplete, privateField, identifier); + return isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn); } if (el instanceof HTMLTextAreaElement) { - return isShareableField("textarea", el.autocomplete, privateField, identifier); + return isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn); } - return isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier); + return isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn); }); } @@ -232,7 +233,16 @@ export function useAutoShareableState( timer = window.setTimeout(() => { try { const encoded = encodeState(snapshot(root)); - if (!isSharePayloadWithinLimit(encoded, MAX_ENCODED)) return; + if (!isSharePayloadWithinLimit(encoded, MAX_ENCODED)) { + // Do not retain a previously generated, now-stale shared result URL. + const stale = new URL(window.location.href); + if (stale.searchParams.has("s")) { + stale.searchParams.delete("s"); + window.history.replaceState(window.history.state, "", stale.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); + } + return; + } const url = new URL(window.location.href); url.searchParams.set("s", encoded); window.history.replaceState(window.history.state, "", url.toString()); From bc3f82f4d6e93a98b93e00d187a9b46f4f50c45c Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:49:53 +0900 Subject: [PATCH 146/164] sync(integration): carry latest PR39 site/src/lib/hooks/useShareableState.ts --- site/src/lib/hooks/useShareableState.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/site/src/lib/hooks/useShareableState.ts b/site/src/lib/hooks/useShareableState.ts index 5207c9f4..1a238fe9 100644 --- a/site/src/lib/hooks/useShareableState.ts +++ b/site/src/lib/hooks/useShareableState.ts @@ -76,7 +76,16 @@ export function useShareableState( if (prev === null || prev === serialized) return; try { const encoded = encodeState(JSON.parse(serialized) as object); - if (encoded.length > MAX_SHARED_STATE_LENGTH) return; + if (encoded.length > MAX_SHARED_STATE_LENGTH) { + // Do not let a stale result link represent a newer, unshareable value. + const stale = new URL(window.location.href); + if (stale.searchParams.has("s")) { + stale.searchParams.delete("s"); + window.history.replaceState(window.history.state, "", stale.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); + } + return; + } const url = new URL(window.location.href); url.searchParams.set("s", encoded); window.history.replaceState(window.history.state, "", url.toString()); From d87da8a938527b0b04506d6688b7681eb0b68663 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:50:55 +0900 Subject: [PATCH 147/164] fix(seo): inspect X-Robots-Tag HTTP header for unintended noindex --- site/scripts/growth-html-checks.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/scripts/growth-html-checks.mjs b/site/scripts/growth-html-checks.mjs index e0f98de5..425587a9 100644 --- a/site/scripts/growth-html-checks.mjs +++ b/site/scripts/growth-html-checks.mjs @@ -5,7 +5,9 @@ function renderedMarkup(html) { .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, ''); } -export function hasNoindex(html) { +export function hasNoindex(html, xRobotsTag = "") { + // X-Robots-Tag may be repeated/comma-delimited, and can target a specific crawler. + if (/(?:^|[,\s:])noindex(?:$|[,\s])/i.test(xRobotsTag)) return true; return [...renderedMarkup(html).matchAll(/]*>/gi)].some(([tag]) => { const name = tag.match(/\bname\s*=\s*["'](robots|googlebot)["']/i); const value = tag.match(/\bcontent\s*=\s*["']([^"']+)["']/i)?.[1] ?? ''; From d3311baa023672857a98d230374430bcc074127f Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:51:08 +0900 Subject: [PATCH 148/164] fix(seo): include HTTP robots directives in live growth smoke --- site/scripts/check-live-growth.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/scripts/check-live-growth.mjs b/site/scripts/check-live-growth.mjs index ae31f59f..96279800 100644 --- a/site/scripts/check-live-growth.mjs +++ b/site/scripts/check-live-growth.mjs @@ -40,7 +40,7 @@ async function get(path) { signal: AbortSignal.timeout(12000), }); const text = await response.text(); - return { status: response.status, text, url: response.url, contentType: response.headers.get("content-type") ?? "" }; + return { status: response.status, text, url: response.url, contentType: response.headers.get("content-type") ?? "", xRobotsTag: response.headers.get("x-robots-tag") ?? "" }; } for (const path of paths) { try { @@ -55,7 +55,7 @@ for (const path of paths) { if (product && !hasSoftwareAnchor(page.text, product, path.split("/")[1], base.origin)) { failures.push(`${path}: expected software CTA missing (${product})`); } - if (expectedIndexed.includes(path) && hasNoindex(page.text)) { + if (expectedIndexed.includes(path) && hasNoindex(page.text, page.xRobotsTag)) { failures.push(`${path}: indexed core tool unexpectedly noindex`); } if (path.includes("/tools/")) { From 80bcf7393d922671ca879c9297977f4b1318befd Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:51:22 +0900 Subject: [PATCH 149/164] test(seo): ensure HTTP X-Robots-Tag noindex is detected without false positives --- site/scripts/growth-html-checks.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/scripts/growth-html-checks.test.mjs b/site/scripts/growth-html-checks.test.mjs index 93ef47bf..d085727c 100644 --- a/site/scripts/growth-html-checks.test.mjs +++ b/site/scripts/growth-html-checks.test.mjs @@ -8,6 +8,10 @@ test('robots and googlebot directives independent of attribute order', () => { assert.equal(hasNoindex(''), false); assert.equal(hasNoindex(''), false); assert.equal(hasNoindex(''), false); + assert.equal(hasNoindex("", "noindex, nofollow"), true); + assert.equal(hasNoindex("", "googlebot: noindex, follow"), true); + assert.equal(hasNoindex("", "noindexing"), false); + assert.equal(hasNoindex("", "index, follow"), false); assert.equal(hasNoindex(''), false); assert.equal(hasNoindex(''), false); }); From 573c4aa7aee379763a8b8f4d3b380e2bd30f64d2 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:52:37 +0900 Subject: [PATCH 150/164] docs(integration): record robots HTTP header checks and isolated Node QA --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index c7c9eeaa..9e694b56 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -94,3 +94,10 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - The updated share policy excludes password/email/tel and autofill-sensitive inputs, developer-labeled sensitive identifiers (including camelCase) and explicitly private fields. This is a defense-in-depth filter; it **does not guarantee** all free text is non-sensitive. - Added a fifth dependency-free `growth-integration.test.mjs` check for policy constants and actual hook integration; the full suite has NOT been run in a checked-out app. - Running full build requires source checkout, npm install, TypeScript/Vitest, SEO/i18n audits, device QA. Keep PR #40 Draft/NO-GO pending the full owner-accessible `bash scripts/check-engagement-release.sh` gate. + +## 2026-11-06 — Latest PR #39 sync and HTTP indexing guard +- Current PR #39 had 13 candidate file differences against integrated PR #40; fetched and compared each file. Nine matched by SHA. Four were synchronized: `docs/PR39_VERIFICATION_2026-11-05.md`, `ShareBar.events.test.ts`, `useAutoShareableState.ts`, and `useShareableState.ts`. +- Added support for HTTP `X-Robots-Tag` noindex directives to `hasNoindex(html, xRobotsTag)`, and `check-live-growth.mjs` now includes the response header when checking intended indexable pages. +- Added tests for `noindex, nofollow`, `googlebot: noindex`, and non-matching `noindexing` / `index` values. The existing comment/hydration-anchor tests remain. +- Ran a *local reconstruction* of the updated helper with Node.js `node --test`: **3 subtest groups passed, 0 failed**. The reconstruction validates the detection logic but does not establish that the full integrated GitHub checkout/build has passed. +- Direct git clone still fails DNS (`Could not resolve host: github.com`). Keep this PR draft until full tests, browser verification, owner review and deployment. From 027fdad06a5de729c25c12cc69322a11c00f44c0 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:54:26 +0900 Subject: [PATCH 151/164] docs(integration): record limited 3-day GSC evidence, avoid unsupported SEO changes --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index 9e694b56..32aefaf0 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -101,3 +101,8 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Added tests for `noindex, nofollow`, `googlebot: noindex`, and non-matching `noindexing` / `index` values. The existing comment/hydration-anchor tests remain. - Ran a *local reconstruction* of the updated helper with Node.js `node --test`: **3 subtest groups passed, 0 failed**. The reconstruction validates the detection logic but does not establish that the full integrated GitHub checkout/build has passed. - Direct git clone still fails DNS (`Could not resolve host: github.com`). Keep this PR draft until full tests, browser verification, owner review and deployment. + +## GSC check — 2026-11-06 +- Connected Google Search Console property `https://toolify365.com/` confirmed; queried SMEPost GSC opportunity feed with minimum 1 impression. +- Accessible finalized **3-day** window: **2026-11-01 to 2026-11-03**. Tool returned **zero opportunity rows**. This does NOT mean organic traffic is zero or there are no SEO problems. +- The connected account is restricted to recent three days and does not permit a prior-period comparison. Without a 28-day page/query view, do not claim a recovery, ranking movement, or estimate revenue from SEO changes; leave index eligibility policy unchanged. From d8d7134f39b7adf1555d5287b8e9fd4ecdb9020a Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:57:18 +0900 Subject: [PATCH 152/164] sync(integration): PR39 recent fix docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index eb44ecee..124abe46 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -125,3 +125,11 @@ This file is a checkpoint, not a passing full-release certificate. - Updated `ShareBar.events.test.ts` with regression expectations for both hooks. - GitHub readback verified source/test presence of removal, history preservation and notification, but project-level Vitest, Next build and real browser behavior remain unverified. - Browser acceptance: generate result URL, exceed size cap, confirm result-link button disappears while normal share stays available; shorten input, confirm result link regenerates; verify back/forward and client route navigation. + +## 2026-11-06 — Private-by-default automatic free-text URL sharing +- Significant privacy model change: `useAutoShareableState` no longer serializes `text`, `search`, `url` or `textarea` fields into `?s=` automatically. A reviewed tool must explicitly mark such a field `data-share="on"` to permit its inclusion. +- Numeric fields, selects, checkboxes, dates and other non-free-text controls retain existing eligibility after private/type/autocomplete/name/id exclusions. Explicit privacy exclusions still override opt-in. +- Added pure policy and hook integration regression assertions and confirmed **7 GitHub source consistency checks** for policy, opt-in wiring and tests. These checks DO NOT constitute running Vitest, TypeScript project typecheck or Next.js build. +- Trade-off: text-processing tools may no longer preserve their entire text input when copying generic result links. This intentional reduction needs UX acceptance and tool-by-tool review before enabling any opt-in. Previously generated links can still be opened, but excluded text values are not restored by the generic hook. +- The separate manually registered `useShareableState` hook is not automatically covered by this change; its tool-specific state must be reviewed for sensitive content. +- Release remains NO-GO pending full owner-accessible build/test gate, text tool privacy review, mobile browser QA, analytics consent and actual deployment verification. From 20730f52eaf3da573c1b5e51bc4b65173803e019 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:57:21 +0900 Subject: [PATCH 153/164] sync(integration): PR39 recent fix site/src/lib/hooks/share-input-policy.integration.test.ts --- site/src/lib/hooks/share-input-policy.integration.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts index 23b17a71..09b2c654 100644 --- a/site/src/lib/hooks/share-input-policy.integration.test.ts +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -6,12 +6,13 @@ const manual = readFileSync(new URL("./useShareableState.ts", import.meta.url), describe("share policy integration", () => { it("uses the PII-aware policy for inputs, textareas and selects", () => { - expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField, identifier)"); - expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField, identifier)'); - expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier)'); + expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn)"); + expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn)'); + expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn)'); expect(source).toContain('const privateField = !!el.closest('); expect(source).toContain('data-share='); expect(source).toContain('const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`'); + expect(source).toContain('const explicitTextOptIn = el.getAttribute("data-share") === "on"'); }); it("ignores malformed decoded fields and out-of-range mode buttons", () => { expect(source).toContain('typeof v !== "string" && typeof v !== "boolean"'); From 2c28789cb8767cbbc60f2af9b2319e772d45288d Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:57:25 +0900 Subject: [PATCH 154/164] sync(integration): PR39 recent fix site/src/lib/hooks/share-input-policy.test.ts --- site/src/lib/hooks/share-input-policy.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/site/src/lib/hooks/share-input-policy.test.ts b/site/src/lib/hooks/share-input-policy.test.ts index 9ceb148c..d13e8245 100644 --- a/site/src/lib/hooks/share-input-policy.test.ts +++ b/site/src/lib/hooks/share-input-policy.test.ts @@ -22,19 +22,30 @@ describe("generic share URL input policy", () => { } expect(isShareableField("text", "off", true)).toBe(false); expect(isShareableField("number", "off", true)).toBe(false); - expect(isShareableField("text", "off")).toBe(true); + expect(isShareableField("text", "off")).toBe(false); expect(isShareableField("number")).toBe(true); expect(isShareableField("select", "country")).toBe(false); expect(isShareableField("textarea", "street-address")).toBe(false); }); + it("requires reviewed explicit opt-in for free-form text while retaining numeric fields", () => { + for (const type of ["text", "textarea", "search", "url"]) { + expect(isShareableField(type)).toBe(false); + expect(isShareableField(type, "off", false, "notes", true)).toBe(true); + } + expect(isShareableField("text", "email", false, "notes", true)).toBe(false); + expect(isShareableField("text", "off", true, "notes", true)).toBe(false); + expect(isShareableField("number")).toBe(true); + expect(isShareableField("checkbox")).toBe(true); + }); + it("excludes sensitive name/id identifiers even without autocomplete", () => { for (const identifier of [ "apiKey", "user-password", "refresh_token", "creditCard", "cardNumber", "cvv", "billingEmail", "contactPhone", "postalCode", "secret", "privateKey", "socialSecurity", ]) { - expect(isShareableField("text", "off", false, identifier)).toBe(false); + expect(isShareableField("text", "off", false, identifier, true)).toBe(false); } for (const identifier of ["amount", "result", "valueA", "percentage", "angleC"]) { expect(isShareableField("number", "off", false, identifier)).toBe(true); From 84136457f6a0c58210b84f250f9e1d94997935c6 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:57:28 +0900 Subject: [PATCH 155/164] sync(integration): PR39 recent fix site/src/lib/hooks/share-input-policy.ts --- site/src/lib/hooks/share-input-policy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/lib/hooks/share-input-policy.ts b/site/src/lib/hooks/share-input-policy.ts index 5c05c5bf..3ad2516f 100644 --- a/site/src/lib/hooks/share-input-policy.ts +++ b/site/src/lib/hooks/share-input-policy.ts @@ -23,7 +23,11 @@ export function isShareableField( autocomplete = "", privateField = false, identifier = "", + explicitTextOptIn = false, ): boolean { + // Free-form inputs are private by default. Opt-in belongs to reviewed tools. + const freeTextType = ["text", "search", "url", "textarea"].includes(type.toLowerCase()); + if (freeTextType && !explicitTextOptIn) return false; const normalizedIdentifier = identifier.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/\s+/g, "-"); if (privateField || !isShareableInputType(type) || SENSITIVE_IDENTIFIER.test(normalizedIdentifier)) return false; const tokens = autocomplete.toLowerCase().trim().split(/\s+/); From d75d622e58cb86c9c9b23c9937d85135227929fd Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:58:16 +0900 Subject: [PATCH 156/164] fix(test): update combined share policy assertions for explicit text opt-in --- site/scripts/growth-integration.test.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs index 40bf1060..970417ac 100644 --- a/site/scripts/growth-integration.test.mjs +++ b/site/scripts/growth-integration.test.mjs @@ -51,8 +51,11 @@ test('automatic share URLs exclude sensitive named and autofilled fields', () => assert.ok(policy.includes('SENSITIVE_IDENTIFIER')); assert.ok(policy.includes('SENSITIVE_AUTOCOMPLETE')); assert.ok(policy.includes('SENSITIVE_IDENTIFIER.test(normalizedIdentifier)')); + assert.ok(policy.includes('explicitTextOptIn = false')); + assert.ok(policy.includes('freeTextType && !explicitTextOptIn')); assert.ok(hook.includes('const identifier =')); - assert.ok(hook.includes('isShareableField(el.type, el.autocomplete, privateField, identifier)')); - assert.ok(hook.includes('isShareableField("textarea", el.autocomplete, privateField, identifier)')); - assert.ok(hook.includes('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier)')); + assert.ok(hook.includes('const explicitTextOptIn = el.getAttribute("data-share") === "on"')); + assert.ok(hook.includes('isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn)')); + assert.ok(hook.includes('isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn)')); + assert.ok(hook.includes('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn)')); }); From b06aa77fcbcfa2df2bfa7c3035394355a36f0bc8 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 19:59:00 +0900 Subject: [PATCH 157/164] docs(integration): record explicit-text sharing safety and test repair --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index 32aefaf0..c9bedd1a 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -106,3 +106,9 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - Connected Google Search Console property `https://toolify365.com/` confirmed; queried SMEPost GSC opportunity feed with minimum 1 impression. - Accessible finalized **3-day** window: **2026-11-01 to 2026-11-03**. Tool returned **zero opportunity rows**. This does NOT mean organic traffic is zero or there are no SEO problems. - The connected account is restricted to recent three days and does not permit a prior-period comparison. Without a 28-day page/query view, do not claim a recovery, ranking movement, or estimate revenue from SEO changes; leave index eligibility policy unchanged. + +## 2026-11-06 — Free-text share opt-in integration regression +- Compared 13 current PR39 candidate paths, synchronized four: PR39 verification record, `share-input-policy.integration.test.ts`, `share-input-policy.test.ts`, `share-input-policy.ts`. Remaining nine candidate file SHAs matched. +- The new PR39 policy defaults generic free-text fields (text, textarea, search, url) to **not** enter shared URLs unless a reviewed control explicitly declares `data-share="on"`; excludes sensitive identifiers/autocomplete and private fields even with opt-in. +- A real stale integration test was found: `site/scripts/growth-integration.test.mjs` still expected the old four-argument `isShareableField` calls. Updated its three hook assertions to use `explicitTextOptIn` as the fifth argument, and added opt-in policy guard assertions. Direct GitHub source readback confirmed all three expected call sites and policy checks match. +- This is **source assertion/readback evidence**, not a claim that Node/Vitest/Next production build was executed on the integrated checkout. PR #40 remains Draft/NO-GO until complete local gate plus QA pass. From 251b0700c87323de21ba1b6ce4197f1f6c30f647 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:09:53 +0900 Subject: [PATCH 158/164] sync(integration): refresh PR39 docs/PR39_VERIFICATION_2026-11-05.md --- docs/PR39_VERIFICATION_2026-11-05.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md index 124abe46..4b59f22d 100644 --- a/docs/PR39_VERIFICATION_2026-11-05.md +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -133,3 +133,9 @@ This file is a checkpoint, not a passing full-release certificate. - Trade-off: text-processing tools may no longer preserve their entire text input when copying generic result links. This intentional reduction needs UX acceptance and tool-by-tool review before enabling any opt-in. Previously generated links can still be opened, but excluded text values are not restored by the generic hook. - The separate manually registered `useShareableState` hook is not automatically covered by this change; its tool-specific state must be reviewed for sensitive content. - Release remains NO-GO pending full owner-accessible build/test gate, text tool privacy review, mobile browser QA, analytics consent and actual deployment verification. + +## 2026-11-06 — Untrusted shared URL value type and option checks +- Generic URL restore now validates decoded values against the target DOM input type before calling native setters or clicking checkbox/radio controls. +- Only boolean values may restore checkboxes; radio controls require string values that exactly match the target radio's own value; select controls require an option present in the current select. Numeric/range fields require a finite numeric string or an empty string. String values are limited to 1800 characters. +- Added unit and integration regression tests for the policy and hook wiring. GitHub source readback confirms **6 static consistency checks** (policy export, use, select/radio validation, tests). The Vitest tests have been added but **have not run**. +- Remaining concerns: browser behavior when options change dynamically, old share link compatibility, and manually registered `useShareableState` consumers. Full owner checkout QA, typecheck/build, consent verification and desktop/mobile tests remain release blockers. From 85c43138dc3da57a8319ee0d2b5aa7a7fcf6ee9a Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:09:58 +0900 Subject: [PATCH 159/164] sync(integration): refresh PR39 site/src/lib/hooks/share-input-policy.integration.test.ts --- site/src/lib/hooks/share-input-policy.integration.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts index 09b2c654..6c29eeef 100644 --- a/site/src/lib/hooks/share-input-policy.integration.test.ts +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -20,6 +20,12 @@ describe("share policy integration", () => { expect(source).toContain('i >= all.length'); }); + it("rejects invalid restored field types and unavailable option values", () => { + expect(source).toContain("isValidRestoredFieldValue(kind, v)"); + expect(source).toContain('option.value === v'); + expect(source).toContain('el.type === "radio" && el.value !== v'); + }); + it("replays URL button indices only within a mode-selector group", () => { expect(source).toContain("function isModeButton(btn: HTMLButtonElement)"); expect(source).toContain("siblings.length >= 2"); From 2139ad18e023b06a4e21a7d4ae543a240da8eb74 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:10:03 +0900 Subject: [PATCH 160/164] sync(integration): refresh PR39 site/src/lib/hooks/share-input-policy.test.ts --- site/src/lib/hooks/share-input-policy.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/site/src/lib/hooks/share-input-policy.test.ts b/site/src/lib/hooks/share-input-policy.test.ts index d13e8245..d64cdf6d 100644 --- a/site/src/lib/hooks/share-input-policy.test.ts +++ b/site/src/lib/hooks/share-input-policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isShareableInputType, isShareableField, isSharePayloadWithinLimit } from "./share-input-policy"; +import { isShareableInputType, isShareableField, isSharePayloadWithinLimit, isValidRestoredFieldValue } from "./share-input-policy"; describe("generic share URL input policy", () => { it("never serializes credentials or invisible inputs", () => { @@ -52,6 +52,21 @@ describe("generic share URL input policy", () => { } }); + it("validates decoded input types instead of coercing untrusted URL data", () => { + expect(isValidRestoredFieldValue("checkbox", true)).toBe(true); + expect(isValidRestoredFieldValue("checkbox", "true")).toBe(false); + expect(isValidRestoredFieldValue("radio", "mode-a")).toBe(true); + expect(isValidRestoredFieldValue("radio", false)).toBe(false); + for (const value of ["2.5", "-3", "1e3", ""]) { + expect(isValidRestoredFieldValue("number", value)).toBe(true); + } + for (const value of ["NaN", "Infinity", "12abc", true, 10, "1".repeat(1801)]) { + expect(isValidRestoredFieldValue("number", value)).toBe(false); + } + expect(isValidRestoredFieldValue("textarea", "text")).toBe(true); + expect(isValidRestoredFieldValue("textarea", null)).toBe(false); + }); + it("rejects oversized or invalid encoded payload sizes", () => { expect(isSharePayloadWithinLimit("abc", 3)).toBe(true); expect(isSharePayloadWithinLimit("abcd", 3)).toBe(false); From c58578782923df4f70965c9fb3278752eba01c39 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:10:05 +0900 Subject: [PATCH 161/164] sync(integration): refresh PR39 site/src/lib/hooks/share-input-policy.ts --- site/src/lib/hooks/share-input-policy.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/site/src/lib/hooks/share-input-policy.ts b/site/src/lib/hooks/share-input-policy.ts index 3ad2516f..68cb5582 100644 --- a/site/src/lib/hooks/share-input-policy.ts +++ b/site/src/lib/hooks/share-input-policy.ts @@ -37,3 +37,18 @@ export function isShareableField( export function isSharePayloadWithinLimit(encoded: string, limit: number): boolean { return limit > 0 && encoded.length <= limit; } + +/** + * URL state is untrusted. Do not coerce arbitrary JSON values into DOM controls. + * The caller additionally verifies select/radio values against current options. + */ +export function isValidRestoredFieldValue(type: string, value: unknown): value is string | boolean { + const normalizedType = type.toLowerCase(); + if (normalizedType === "checkbox") return typeof value === "boolean"; + if (normalizedType === "radio") return typeof value === "string" && value.length <= 1800; + if (typeof value !== "string" || value.length > 1800) return false; + if (normalizedType === "number" || normalizedType === "range") { + return value === "" || (value.trim() !== "" && Number.isFinite(Number(value))); + } + return true; +} From ff30cf3eb4c0087b7418359a965cdd604c77a14e Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:10:27 +0900 Subject: [PATCH 162/164] sync(integration): refresh PR39 site/src/lib/hooks/useAutoShareableState.ts --- site/src/lib/hooks/useAutoShareableState.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/lib/hooks/useAutoShareableState.ts b/site/src/lib/hooks/useAutoShareableState.ts index 931e3dc2..2fc96424 100644 --- a/site/src/lib/hooks/useAutoShareableState.ts +++ b/site/src/lib/hooks/useAutoShareableState.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, type RefObject } from "react"; -import { isShareableField, isSharePayloadWithinLimit } from "./share-input-policy"; +import { isShareableField, isSharePayloadWithinLimit, isValidRestoredFieldValue } from "./share-input-policy"; /** * ツール本体の DOM 入力を丸ごと ?s= に載せ、共有リンクから復元する。 @@ -192,6 +192,10 @@ export function useAutoShareableState( // `k in saved` では index 型が絞れないので値を取って判定する。 if (typeof v !== "string" && typeof v !== "boolean") continue; if (only && !only(el)) continue; + const kind = el instanceof HTMLInputElement ? el.type : el instanceof HTMLSelectElement ? "select" : "textarea"; + if (!isValidRestoredFieldValue(kind, v)) continue; + if (el instanceof HTMLSelectElement && !Array.from(el.options).some((option) => option.value === v)) continue; + if (el instanceof HTMLInputElement && el.type === "radio" && el.value !== v) continue; writeValue(el, v); } }; From 5ee2fcf88d51b7cdd72e42db5df0cd2eba19fde6 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:11:21 +0900 Subject: [PATCH 163/164] test(integration): guard untrusted URL restore and stale shared-state cleanup --- site/scripts/growth-integration.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs index 970417ac..094ab1ee 100644 --- a/site/scripts/growth-integration.test.mjs +++ b/site/scripts/growth-integration.test.mjs @@ -59,3 +59,15 @@ test('automatic share URLs exclude sensitive named and autofilled fields', () => assert.ok(hook.includes('isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn)')); assert.ok(hook.includes('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn)')); }); + +test('shared URL restoration validates untrusted input and clears stale oversized state', () => { + const policy = read('../src/lib/hooks/share-input-policy.ts'); + const hook = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(policy.includes('export function isValidRestoredFieldValue(')); + assert.ok(policy.includes('Number.isFinite(Number(value))')); + assert.ok(hook.includes('if (!isValidRestoredFieldValue(kind, v)) continue')); + assert.ok(hook.includes('Array.from(el.options).some((option) => option.value === v)')); + assert.ok(hook.includes('el.type === "radio" && el.value !== v')); + assert.ok(hook.includes('stale.searchParams.delete("s")')); + assert.ok(hook.includes('window.dispatchEvent(new Event("toolify:share-state-change"))')); +}); From 7072e686442aef718b91fd5478a37ff065eb9a3f Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 6 Nov 2026 20:12:04 +0900 Subject: [PATCH 164/164] docs(integration): record validated share restoration and source-checked regression coverage --- docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md index c9bedd1a..b6792082 100644 --- a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -112,3 +112,9 @@ The existing root `scripts/check-engagement-release.sh` now runs both PR #39's f - The new PR39 policy defaults generic free-text fields (text, textarea, search, url) to **not** enter shared URLs unless a reviewed control explicitly declares `data-share="on"`; excludes sensitive identifiers/autocomplete and private fields even with opt-in. - A real stale integration test was found: `site/scripts/growth-integration.test.mjs` still expected the old four-argument `isShareableField` calls. Updated its three hook assertions to use `explicitTextOptIn` as the fifth argument, and added opt-in policy guard assertions. Direct GitHub source readback confirmed all three expected call sites and policy checks match. - This is **source assertion/readback evidence**, not a claim that Node/Vitest/Next production build was executed on the integrated checkout. PR #40 remains Draft/NO-GO until complete local gate plus QA pass. + +## Latest PR39 restore validation sync +- Recompared 13 PR39 candidate files by exact GitHub SHA. Eight already matched, and five files were synchronized: verification record, `share-input-policy.integration.test.ts`, `share-input-policy.test.ts`, `share-input-policy.ts`, and `useAutoShareableState.ts`. +- The updated restore logic rejects invalid types, nonfinite numeric inputs, and select/radio values outside available options. When a new shared URL payload exceeds the allowed limit, the hook also removes a stale old `?s=` state rather than leaving an outdated result link. +- Added a sixth cross-PR Node integration test group in `site/scripts/growth-integration.test.mjs` for those restoration/cleanup contracts. Checked its six source assertions against the exact integrated source content via GitHub readback; all matched. +- **Not a full test run:** direct `git ls-remote` still fails `Could not resolve host: github.com`; full `npm ci`, Vitest, typecheck, Next production build, browser QA and release remain unverified. Keep PR #40 Draft.