Cluster C2: API Debugging
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c290657c..a4fe19e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,5 +63,16 @@ jobs: - name: Run Playwright Smoke run: npm run test:e2e:smoke + - name: Run Route Width Screenshot Matrix + run: npm run test:e2e:route-width + + - name: Upload Route Width Screenshots + if: always() + uses: actions/upload-artifact@v6 + with: + name: route-width-screenshots + path: test-results/route-width + if-no-files-found: error + - name: Run PWA Smoke run: npm run test:e2e:pwa diff --git a/docs/quality/input-intent-taxonomy.md b/docs/quality/input-intent-taxonomy.md new file mode 100644 index 00000000..f53ccf8e --- /dev/null +++ b/docs/quality/input-intent-taxonomy.md @@ -0,0 +1,15 @@ +# Input intent taxonomy + +Tool input sizing uses five stable intents: + +| Intent | Use | +| --- | --- | +| `scalar` | Numbers, colors, flags, enum-like controls, and other compact values. | +| `shortText` | URLs, names, expressions, and short free-form text. | +| `payload` | User-authored JSON, CSV, tokens, logs, code, and multiline data. | +| `workbench` | Dense editor or split-pane work areas. | +| `generatedOutput` | Read-only generated text or code with copy/export actions. | + +`Input`, `Textarea`, Monaco wrappers, and `TextOutputPanel` emit `data-input-intent` at runtime. The create-tool scaffold declares a payload input, a workbench container, and generated output explicitly. + +`tests/guards/input-intent-taxonomy.test.ts` parses every TSX file under `src` to inventory shared controls plus raw `input`, `textarea`, and `select` elements. Representative routes must declare an explicit intent. Remaining hardcoded height tokens are frozen by a stable file/tag/token hash, so a new unclassified size fails CI without depending on source line numbers. diff --git a/package-lock.json b/package-lock.json index 6bed2294..7fc71256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jq-wasm": "^1.1.0-jq-1.8.1", "js-beautify": "^1.15.4", "jsbarcode": "^3.12.3", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", @@ -9252,6 +9253,12 @@ "node": ">=6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/package.json b/package.json index fe923332..d9710c50 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "scripts": { "dev": "next dev", "analyze": "ANALYZE=true next build --webpack", - "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:llms-txt && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", + "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:llms-txt && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:route-width-inventory && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", "build:app": "next build", "build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:performance-budget:report && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:content-template:uniqueness && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run check:sitemap-export && npm run build:sw", "build": "npm run validate && npm run build:app && npm run build:post", @@ -60,6 +60,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e:smoke": "node scripts/e2e/run-playwright-smoke.js", + "test:e2e:first-load": "node scripts/e2e/run-playwright-smoke.js --first-load-only --first-load-artifacts", + "test:e2e:route-width": "node scripts/e2e/run-route-width-matrix.js", "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa", "check:i18n": "node scripts/gates/check-i18n.js", "check:i18n-qa": "node scripts/gates/check-i18n-qa.js", @@ -79,6 +81,8 @@ "check:sitemap-lastmod": "node scripts/gates/check-sitemap-lastmod.js", "generate:ia-baseline": "node scripts/generators/generate-ia-baseline.js", "check:ia-stability": "node scripts/gates/check-ia-stability.js", + "generate:route-width-inventory": "node scripts/generators/generate-route-width-inventory.js", + "check:route-width-inventory": "node scripts/generators/generate-route-width-inventory.js --check", "check:analytics-taxonomy": "node scripts/gates/check-analytics-taxonomy.js", "check:privacy-copy": "node scripts/gates/check-privacy-copy.js", "check:security-headers": "node scripts/gates/check-security-headers-config.js", @@ -147,6 +151,7 @@ "jq-wasm": "^1.1.0-jq-1.8.1", "js-beautify": "^1.15.4", "jsbarcode": "^3.12.3", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", diff --git a/public/_redirects b/public/_redirects index 127da197..1f7c6eef 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,5 +1,142 @@ /security.txt /.well-known/security.txt 301 /en / 301 +/json-formatter /en/json-formatter 301 +/xml-formatter /en/xml-formatter 301 +/sql-formatter /en/sql-formatter 301 +/javascript-formatter /en/javascript-formatter 301 +/javascript-minifier /en/javascript-minifier 301 +/html-minifier /en/html-minifier 301 +/html-encoder-decoder /en/html-encoder-decoder 301 +/html-css-beautifier /en/html-css-beautifier 301 +/html-formatter /en/html-formatter 301 +/yaml-json-converter /en/yaml-json-converter 301 +/markdown-preview /en/markdown-preview 301 +/html-to-markdown /en/html-to-markdown 301 +/json-to-typescript /en/json-to-typescript 301 +/css-minifier /en/css-minifier 301 +/svg-optimizer /en/svg-optimizer 301 +/jsonpath-playground /en/jsonpath-playground 301 +/openapi-viewer /en/openapi-viewer 301 +/json-diff-viewer /en/json-diff-viewer 301 +/csv-json-converter /en/csv-json-converter 301 +/base64-encode-decode /en/base64-encode-decode 301 +/base-encoding-converter /en/base-encoding-converter 301 +/url-encode-decode /en/url-encode-decode 301 +/jwt-decoder /en/jwt-decoder 301 +/jwt-workbench /en/jwt-workbench 301 +/hash-generator /en/hash-generator 301 +/md5-generator /en/md5-generator 301 +/text-diff-checker /en/text-diff-checker 301 +/multiple-whitespace-remover /en/multiple-whitespace-remover 301 +/letter-counter /en/letter-counter 301 +/bionic-reading-converter /en/bionic-reading-converter 301 +/google-fonts-pair-finder /en/google-fonts-pair-finder 301 +/text-to-handwriting-converter /en/text-to-handwriting-converter 301 +/code-to-image-converter /en/code-to-image-converter 301 +/image-base64 /en/image-base64 301 +/unix-timestamp /en/unix-timestamp 301 +/uuid-generator /en/uuid-generator 301 +/lorem-ipsum /en/lorem-ipsum 301 +/password-generator /en/password-generator 301 +/color-converter /en/color-converter 301 +/react-native-shadow-generator /en/react-native-shadow-generator 301 +/ai-color-palette-generator /en/ai-color-palette-generator 301 +/color-mixer /en/color-mixer 301 +/color-shades-generator /en/color-shades-generator 301 +/image-average-color-finder /en/image-average-color-finder 301 +/image-caption-generator /en/image-caption-generator 301 +/image-color-extractor /en/image-color-extractor 301 +/image-color-picker /en/image-color-picker 301 +/image-cropper /en/image-cropper 301 +/image-filters /en/image-filters 301 +/instagram-filters /en/instagram-filters 301 +/instagram-post-generator /en/instagram-post-generator 301 +/instagram-story-generator /en/instagram-story-generator 301 +/open-graph-meta-generator /en/open-graph-meta-generator 301 +/tweet-generator /en/tweet-generator 301 +/tweet-to-image-converter /en/tweet-to-image-converter 301 +/twitter-ad-revenue-generator /en/twitter-ad-revenue-generator 301 +/instagram-photo-downloader /en/instagram-photo-downloader 301 +/vimeo-thumbnail-grabber /en/vimeo-thumbnail-grabber 301 +/youtube-thumbnail-grabber /en/youtube-thumbnail-grabber 301 +/image-resizer /en/image-resizer 301 +/photo-censor /en/photo-censor 301 +/scanned-pdf-converter /en/scanned-pdf-converter 301 +/svg-blob-generator /en/svg-blob-generator 301 +/svg-pattern-generator /en/svg-pattern-generator 301 +/svg-stroke-to-fill-converter /en/svg-stroke-to-fill-converter 301 +/svg-to-png-converter /en/svg-to-png-converter 301 +/css-background-pattern-generator /en/css-background-pattern-generator 301 +/css-border-radius-generator /en/css-border-radius-generator 301 +/css-box-shadow-generator /en/css-box-shadow-generator 301 +/css-checkbox-generator /en/css-checkbox-generator 301 +/css-clip-path-generator /en/css-clip-path-generator 301 +/css-cubic-bezier-generator /en/css-cubic-bezier-generator 301 +/css-glassmorphism-generator /en/css-glassmorphism-generator 301 +/css-gradient-generator /en/css-gradient-generator 301 +/css-loader-generator /en/css-loader-generator 301 +/css-switch-generator /en/css-switch-generator 301 +/css-text-glitch-effect-generator /en/css-text-glitch-effect-generator 301 +/css-triangle-generator /en/css-triangle-generator 301 +/qr-code-generator /en/qr-code-generator 301 +/barcode-generator /en/barcode-generator 301 +/fake-iban-generator /en/fake-iban-generator 301 +/list-randomizer /en/list-randomizer 301 +/ascii-art-generator /en/ascii-art-generator 301 +/env-parser /en/env-parser 301 +/id-generator /en/id-generator 301 +/regex-tester /en/regex-tester 301 +/regex-generator /en/regex-generator 301 +/crontab-generator /en/crontab-generator 301 +/user-agent-parser /en/user-agent-parser 301 +/cron-visualizer /en/cron-visualizer 301 +/http-status-codes /en/http-status-codes 301 +/chmod-calculator /en/chmod-calculator 301 +/cidr-subnet-calculator /en/cidr-subnet-calculator 301 +/url-parser /en/url-parser 301 +/certificate-decoder /en/certificate-decoder 301 +/http-request-builder /en/http-request-builder 301 +/curl-to-code /en/curl-to-code 301 +/ndjson-formatter /en/ndjson-formatter 301 +/jwt-verifier /en/jwt-verifier 301 +/slugify-case-converter /en/slugify-case-converter 301 +/invisible-characters-detector /en/invisible-characters-detector 301 +/robots-txt-tester /en/robots-txt-tester 301 +/csp-parser /en/csp-parser 301 +/csv-diff /en/csv-diff 301 +/header-diff /en/header-diff 301 +/security-header-analyzer /en/security-header-analyzer 301 +/totp-generator /en/totp-generator 301 +/openapi-mock /en/openapi-mock 301 +/docker-run-to-compose /en/docker-run-to-compose 301 +/local-log-parser /en/local-log-parser 301 +/jq-playground /en/jq-playground 301 +/log-scrubber /en/log-scrubber 301 +/gzip-brotli-lab /en/gzip-brotli-lab 301 +/yaml-merge-patch-explorer /en/yaml-merge-patch-explorer 301 +/yq-playground /en/yq-playground 301 +/structured-data-visualizer /en/structured-data-visualizer 301 +/har-viewer-sanitizer /en/har-viewer-sanitizer 301 +/pipeline-builder /en/pipeline-builder 301 +/saml-decoder /en/saml-decoder 301 +/asn1-der-inspector /en/asn1-der-inspector 301 +/hex-bytes-workbench /en/hex-bytes-workbench 301 +/unicode-inspector /en/unicode-inspector 301 +/public-key-jwk-helper /en/public-key-jwk-helper 301 +/json-schema-workbench /en/json-schema-workbench 301 +/openapi-diff /en/openapi-diff 301 +/graphql-workbench /en/graphql-workbench 301 +/messagepack-inspector /en/messagepack-inspector 301 +/seo-metadata-workbench /en/seo-metadata-workbench 301 +/devops-yaml-validator /en/devops-yaml-validator 301 +/oauth-jwks-workbench /en/oauth-jwks-workbench 301 +/image-privacy-workbench /en/image-privacy-workbench 301 +/cron-expression-generator /en/crontab-generator 301 +/tls-chain-inspector /en/certificate-decoder 301 +/csv-to-json /en/csv-json-converter 301 +/web-api /en/web-api-network 301 +/design-media /en/images-svg-css 301 +/convert-encode /en/all-tools 301 /en/cron-expression-generator /en/crontab-generator 301 /zh-CN/cron-expression-generator /zh-CN/crontab-generator 301 /zh-TW/cron-expression-generator /zh-TW/crontab-generator 301 @@ -14,6 +151,13 @@ /ko/tls-chain-inspector /ko/certificate-decoder 301 /de/tls-chain-inspector /de/certificate-decoder 301 /fr/tls-chain-inspector /fr/certificate-decoder 301 +/en/csv-to-json /en/csv-json-converter 301 +/zh-CN/csv-to-json /zh-CN/csv-json-converter 301 +/zh-TW/csv-to-json /zh-TW/csv-json-converter 301 +/ja/csv-to-json /ja/csv-json-converter 301 +/ko/csv-to-json /ko/csv-json-converter 301 +/de/csv-to-json /de/csv-json-converter 301 +/fr/csv-to-json /fr/csv-json-converter 301 /en/web-api /en/web-api-network 301 /zh-CN/web-api /zh-CN/web-api-network 301 /zh-TW/web-api /zh-TW/web-api-network 301 diff --git a/public/runtime/theme-manifest-bootstrap.js b/public/runtime/theme-manifest-bootstrap.js index 9b2410c8..c9a19e82 100644 --- a/public/runtime/theme-manifest-bootstrap.js +++ b/public/runtime/theme-manifest-bootstrap.js @@ -6,14 +6,25 @@ var activeLang = locales.indexOf(seg) >= 0 ? seg : "en"; document.documentElement.setAttribute("lang", activeLang); - var t = localStorage.getItem("theme"); + var t = null; + try { + t = localStorage.getItem("theme"); + } catch {} + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) { - var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); - t = m ? m[1] : null; + try { + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); + t = m ? m[1] : null; + } catch {} } + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) t = "dark"; if (t === "system") { - t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + try { + t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } catch { + t = "dark"; + } } document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(t); diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index e263dc71..bb0d939f 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { existsSync, readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import axe from "axe-core"; @@ -47,9 +47,103 @@ const AXE_REVIEW_ROUTES = [ "/en/trust-center", "/en/install-app", ]; +const INPUT_INTENT_AUDIT_ROUTES = [ + { route: "/en/qr-code-generator", requiredIntents: ["shortText", "scalar"] }, + { route: "/en/base64-encode-decode", requiredIntents: ["payload", "generatedOutput"] }, + { route: "/en/regex-tester", requiredIntents: ["scalar", "shortText", "payload"] }, + { route: "/en/json-formatter", requiredIntents: ["workbench", "payload"] }, + { route: "/en/csv-json-converter", requiredIntents: ["workbench", "payload", "generatedOutput"] }, + { route: "/en/jwt-decoder", requiredIntents: ["workbench", "payload", "generatedOutput"] }, + { route: "/en/pipeline-builder", requiredIntents: ["workbench", "shortText", "payload", "generatedOutput"] }, +]; +const INPUT_INTENT_AUDIT_VIEWPORTS = [ + { width: 390, height: 844, mobile: true }, + { width: 1280, height: 900, mobile: false }, +]; const ALL_TOOLS_FILTER_INTERACTION_BUDGET_MS = 2000; const ALL_TOOLS_MOBILE_SCROLL_BUDGET_MS = 3500; const ALL_TOOLS_MOBILE_MAX_FRAME_DELTA_MS = 500; +const FIRST_LOAD_CLS_BUDGET = 0.1; +const FIRST_LOAD_SETTLE_MS = 2_600; +const FIRST_LOAD_ARTIFACT_DIR = path.resolve(process.cwd(), "output/first-load-audit"); +const FIRST_LOAD_AUDIT_CASES = [ + { + id: "root-preferred-locale", + route: "/", + expectedPathname: "/", + expectedLang: "en", + theme: "dark", + colorScheme: "dark", + preferredLocale: "zh-CN", + }, + { + id: "qr-light", + route: "/en/qr-code-generator", + expectedPathname: "/en/qr-code-generator", + expectedLang: "en", + theme: "light", + colorScheme: "dark", + warmReload: true, + }, + { + id: "json-dark", + route: "/en/json-formatter", + expectedPathname: "/en/json-formatter", + expectedLang: "en", + theme: "dark", + colorScheme: "light", + }, + { + id: "all-tools-system", + route: "/en/all-tools", + expectedPathname: "/en/all-tools", + expectedLang: "en", + theme: "system", + colorScheme: "light", + }, + { + id: "all-tools-personalized", + route: "/en/all-tools", + expectedPathname: "/en/all-tools", + expectedLang: "en", + theme: "dark", + colorScheme: "dark", + storage: { + "byteflow:tools:favorites": JSON.stringify([ + { toolKey: "json_formatter", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "base64_encode_decode", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "uuid_generator", updatedAt: "2026-07-01T00:00:00.000Z" }, + ]), + "byteflow:tools:recent": JSON.stringify([ + { toolKey: "qr_code_generator", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "json_formatter", updatedAt: "2026-07-01T00:00:00.000Z" }, + ]), + }, + }, + { + id: "qr-zh-cn-system", + route: "/zh-CN/qr-code-generator", + expectedPathname: "/zh-CN/qr-code-generator", + expectedLang: "zh-CN", + theme: "system", + colorScheme: "dark", + warmReload: true, + }, +]; +const FIRST_LOAD_AUDIT_PROFILES = [ + { + id: "mobile-390x844", + viewport: { width: 390, height: 844 }, + isMobile: true, + caseIds: FIRST_LOAD_AUDIT_CASES.map((testCase) => testCase.id), + }, + { + id: "desktop-1280x800", + viewport: { width: 1280, height: 800 }, + isMobile: false, + caseIds: ["root-preferred-locale", "all-tools-system", "qr-zh-cn-system"], + }, +]; const GENERATED_TOOL_INDEX_PATH = path.resolve(process.cwd(), "src/generated/tool-index.json"); function getExpectedToolCount() { @@ -111,6 +205,9 @@ function parseArgs(argv) { baseUrl: "", skipServer: false, includePwa: false, + firstLoadOnly: false, + writeFirstLoadArtifacts: false, + inputIntentsOnly: false, }; for (const arg of argv) { @@ -124,6 +221,21 @@ function parseArgs(argv) { continue; } + if (arg === "--first-load-only") { + args.firstLoadOnly = true; + continue; + } + + if (arg === "--first-load-artifacts") { + args.writeFirstLoadArtifacts = true; + continue; + } + + if (arg === "--input-intents-only") { + args.inputIntentsOnly = true; + continue; + } + if (arg.startsWith("--port=")) { const parsed = Number(arg.slice("--port=".length)); if (Number.isFinite(parsed) && parsed > 0) { @@ -226,6 +338,363 @@ async function assertRouteRenders(context, baseUrl, route) { await page.close(); } +function expectedResolvedTheme(testCase) { + return testCase.theme === "system" ? testCase.colorScheme : testCase.theme; +} + +async function installFirstLoadInstrumentation(context, testCase) { + await context.addInitScript(({ preferredLocale, storage, theme }) => { + try { + localStorage.setItem("theme", theme); + if (preferredLocale) { + localStorage.setItem("byteflow:preferred-locale", preferredLocale); + } + for (const [key, value] of Object.entries(storage || {})) { + localStorage.setItem(key, value); + } + } catch { + // The audit still validates the default path when storage is unavailable. + } + + const audit = { + layoutShiftSupported: false, + shifts: [], + themeChanges: [], + }; + window.__byteflowFirstLoadAudit = audit; + + const readTheme = () => ({ + className: document.documentElement?.className || "", + colorScheme: document.documentElement?.style.colorScheme || "", + }); + const recordTheme = () => audit.themeChanges.push({ + startTime: performance.now(), + ...readTheme(), + }); + const observeThemeRoot = () => { + if (!document.documentElement) return false; + recordTheme(); + new MutationObserver(recordTheme).observe(document.documentElement, { + attributeFilter: ["class", "style"], + attributes: true, + }); + return true; + }; + if (!observeThemeRoot()) { + const rootObserver = new MutationObserver(() => { + if (observeThemeRoot()) rootObserver.disconnect(); + }); + rootObserver.observe(document, { childList: true, subtree: true }); + } + + const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || []; + audit.layoutShiftSupported = supportedEntryTypes.includes("layout-shift"); + if (!audit.layoutShiftSupported) return; + + const copyRect = (rect) => ({ + height: rect.height, + width: rect.width, + x: rect.x, + y: rect.y, + }); + const describeNode = (node) => { + if (!(node instanceof Element)) return "unknown"; + if (node.id) return `#${node.id}`; + for (const attribute of [ + "data-all-tools-personalization", + "data-all-tools-card", + "data-navbar-controls-footprint", + "data-navbar-language-footprint", + "data-navbar-theme-footprint", + "data-tool-global-actions", + ]) { + if (node.hasAttribute(attribute)) return `[${attribute}]`; + } + const classes = Array.from(node.classList).slice(0, 3).join("."); + return `${node.tagName.toLowerCase()}${classes ? `.${classes}` : ""}`; + }; + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.hadRecentInput) continue; + audit.shifts.push({ + startTime: entry.startTime, + value: entry.value, + sources: (entry.sources || []).map((source) => ({ + currentRect: copyRect(source.currentRect), + node: describeNode(source.node), + previousRect: copyRect(source.previousRect), + })), + }); + } + }); + observer.observe({ type: "layout-shift", buffered: true }); + }, { + preferredLocale: testCase.preferredLocale || "", + storage: testCase.storage || {}, + theme: testCase.theme, + }); +} + +function calculateSessionWindowCls(entries) { + let currentScore = 0; + let currentWindowStart = 0; + let lastEntryTime = 0; + let maxScore = 0; + + for (const entry of [...entries].sort((left, right) => left.startTime - right.startTime)) { + const startsNewWindow = currentScore === 0 + || entry.startTime - lastEntryTime > 1_000 + || entry.startTime - currentWindowStart > 5_000; + if (startsNewWindow) { + currentScore = entry.value; + currentWindowStart = entry.startTime; + } else { + currentScore += entry.value; + } + lastEntryTime = entry.startTime; + maxScore = Math.max(maxScore, currentScore); + } + + return maxScore; +} + +async function capturePreHydrationState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + + try { + await page.route("**/*", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (request.resourceType() === "script" && pathname.startsWith("/_next/")) { + await route.abort(); + return; + } + await route.continue(); + }); + + const response = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded" }); + if (!response || !response.ok()) { + throw new Error(`Pre-hydration route ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.evaluate(() => document.fonts?.ready); + await page.waitForTimeout(100); + + return { + heading: (await page.locator("h1").first().textContent())?.replace(/\s+/g, " ").trim() || "", + screenshot: await page.screenshot({ animations: "disabled", fullPage: false }), + theme: await page.evaluate(() => ({ + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + })), + }; + } finally { + await context.close(); + } +} + +async function captureHydratedState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + const runtime = createRuntimeObserver(page, `First-load audit ${profile.id}/${testCase.id}`, baseUrl); + + try { + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await cdp.send("Network.emulateNetworkConditions", { + connectionType: "cellular4g", + downloadThroughput: (1.6 * 1024 * 1024) / 8, + latency: 100, + offline: false, + uploadThroughput: (750 * 1024) / 8, + }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + + const response = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!response || !response.ok()) { + throw new Error(`Hydrated route ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(FIRST_LOAD_SETTLE_MS); + await page.evaluate(() => document.fonts?.ready); + + const metrics = await page.evaluate(() => ({ + audit: window.__byteflowFirstLoadAudit, + firstContentfulPaint: performance.getEntriesByName("first-contentful-paint")[0]?.startTime ?? null, + heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "", + lang: document.documentElement.lang, + pathname: window.location.pathname, + theme: { + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + }, + })); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + runtime.assertClean(); + return { metrics, screenshot }; + } finally { + await context.close(); + } +} + +async function captureWarmReloadState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + const runtime = createRuntimeObserver(page, `Warm reload audit ${profile.id}/${testCase.id}`, baseUrl); + + try { + const initialResponse = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!initialResponse || !initialResponse.ok()) { + throw new Error(`Warm reload seed ${testCase.route} failed with ${initialResponse?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(200); + + const cdp = await context.newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + + const response = await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!response || !response.ok()) { + throw new Error(`Warm reload ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(FIRST_LOAD_SETTLE_MS); + await page.evaluate(() => document.fonts?.ready); + + const metrics = await page.evaluate(() => ({ + audit: window.__byteflowFirstLoadAudit, + firstContentfulPaint: performance.getEntriesByName("first-contentful-paint")[0]?.startTime ?? null, + heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "", + lang: document.documentElement.lang, + pathname: window.location.pathname, + theme: { + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + }, + })); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + runtime.assertClean(); + return { metrics, screenshot }; + } finally { + await context.close(); + } +} + +function assertFirstLoadResult(profile, testCase, before, after) { + const expectedTheme = expectedResolvedTheme(testCase); + const shifts = after.metrics.audit?.shifts || []; + const cls = calculateSessionWindowCls(shifts); + const label = `${profile.id}/${testCase.id}`; + const themeAtFirstContentfulPaint = [...(after.metrics.audit?.themeChanges || [])] + .reverse() + .find((entry) => entry.startTime <= after.metrics.firstContentfulPaint); + + if (!after.metrics.audit?.layoutShiftSupported) { + throw new Error(`${label} did not expose Chromium layout-shift entries.`); + } + if (cls >= FIRST_LOAD_CLS_BUDGET) { + throw new Error(`${label} CLS ${cls.toFixed(4)} exceeded the ${FIRST_LOAD_CLS_BUDGET} budget. Sources: ${JSON.stringify(shifts)}`); + } + if ( + after.metrics.firstContentfulPaint === null + || !themeAtFirstContentfulPaint?.className.split(/\s+/).includes(expectedTheme) + || themeAtFirstContentfulPaint.colorScheme !== expectedTheme + ) { + throw new Error(`${label} first contentful paint used ${JSON.stringify(themeAtFirstContentfulPaint)} instead of ${expectedTheme}.`); + } + if (!after.metrics.theme.className.split(/\s+/).includes(expectedTheme) || after.metrics.theme.colorScheme !== expectedTheme) { + throw new Error(`${label} settled theme used ${JSON.stringify(after.metrics.theme)} instead of ${expectedTheme}.`); + } + if (!before.theme.className.split(/\s+/).includes(expectedTheme) || before.theme.colorScheme !== expectedTheme) { + throw new Error(`${label} pre-hydration theme used ${JSON.stringify(before.theme)} instead of ${expectedTheme}.`); + } + if (after.metrics.pathname !== testCase.expectedPathname || after.metrics.lang !== testCase.expectedLang) { + throw new Error(`${label} settled at ${after.metrics.pathname} (${after.metrics.lang}), expected ${testCase.expectedPathname} (${testCase.expectedLang}).`); + } + if (!before.heading || before.heading !== after.metrics.heading) { + throw new Error(`${label} changed its first heading across hydration: ${JSON.stringify(before.heading)} -> ${JSON.stringify(after.metrics.heading)}.`); + } + if (before.screenshot.length < 2_000 || after.screenshot.length < 2_000) { + throw new Error(`${label} produced an unexpectedly empty first-load screenshot.`); + } + + return { + cls, + heading: after.metrics.heading, + lang: after.metrics.lang, + pathname: after.metrics.pathname, + shifts, + theme: after.metrics.theme, + themeAtFirstContentfulPaint, + }; +} + +async function writeFirstLoadCaseArtifacts(profile, testCase, before, after, warmAfter) { + await mkdir(FIRST_LOAD_ARTIFACT_DIR, { recursive: true }); + const prefix = `${profile.id}-${testCase.id}`; + await Promise.all([ + writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-before-hydration.png`), before.screenshot), + writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-after-hydration.png`), after.screenshot), + ...(warmAfter ? [writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-warm-reload.png`), warmAfter.screenshot)] : []), + ]); +} + +async function assertFirstLoadStabilityMatrix(browser, baseUrl, { writeArtifacts = false } = {}) { + const reports = []; + for (const profile of FIRST_LOAD_AUDIT_PROFILES) { + for (const caseId of profile.caseIds) { + const testCase = FIRST_LOAD_AUDIT_CASES.find((candidate) => candidate.id === caseId); + if (!testCase) throw new Error(`Unknown first-load audit case: ${caseId}`); + + const before = await capturePreHydrationState(browser, baseUrl, profile, testCase); + const after = await captureHydratedState(browser, baseUrl, profile, testCase); + const report = assertFirstLoadResult(profile, testCase, before, after); + reports.push({ caseId, profileId: profile.id, ...report }); + let warmAfter = null; + if (testCase.warmReload) { + const warmReloadCase = { ...testCase, id: `${testCase.id}-warm-reload` }; + warmAfter = await captureWarmReloadState(browser, baseUrl, profile, testCase); + const warmReport = assertFirstLoadResult(profile, warmReloadCase, before, warmAfter); + reports.push({ caseId: warmReloadCase.id, profileId: profile.id, ...warmReport }); + console.log(`[playwright-smoke] PASS warm reload: ${profile.id}/${testCase.id} CLS=${warmReport.cls.toFixed(4)}`); + } + if (writeArtifacts) { + await writeFirstLoadCaseArtifacts(profile, testCase, before, after, warmAfter); + } + console.log(`[playwright-smoke] PASS first load: ${profile.id}/${caseId} CLS=${report.cls.toFixed(4)}`); + } + } + + if (writeArtifacts) { + await writeFile( + path.join(FIRST_LOAD_ARTIFACT_DIR, "report.json"), + `${JSON.stringify(reports, null, 2)}\n`, + "utf8", + ); + } +} + async function assertHomeNavigation(context, baseUrl, locale) { const page = await context.newPage(); const runtime = createRuntimeObserver(page, `Navigation smoke for ${locale}`, baseUrl); @@ -689,6 +1158,93 @@ async function assertMobileReviewMatrix(browser, baseUrl) { } } +async function assertInputIntentSizingMatrix(browser, baseUrl) { + for (const viewport of INPUT_INTENT_AUDIT_VIEWPORTS) { + const context = await browser.newContext({ + serviceWorkers: "block", + viewport: { width: viewport.width, height: viewport.height }, + isMobile: viewport.mobile, + }); + + try { + for (const audit of INPUT_INTENT_AUDIT_ROUTES) { + const page = await context.newPage(); + const routeLabel = `${audit.route} input intent ${viewport.width}x${viewport.height}`; + const runtime = createRuntimeObserver(page, routeLabel, baseUrl); + + try { + await page.goto(`${baseUrl}${audit.route}`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + await page.locator("[data-input-intent]").first().waitFor({ state: "attached", timeout: 15_000 }); + + const measurements = await page.evaluate(({ mobile }) => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; + }; + const nodes = Array.from(document.querySelectorAll("[data-input-intent]")).filter(isVisible); + const counts = {}; + const issues = []; + + for (const element of nodes) { + const intent = element.getAttribute("data-input-intent"); + if (!intent) continue; + counts[intent] = (counts[intent] || 0) + 1; + + const rect = element.getBoundingClientRect(); + const tag = element.tagName.toLowerCase(); + const field = ["input", "textarea", "select"].includes(tag); + if (intent === "scalar" && field) { + const target = element.closest("label") || element; + const targetRect = target.getBoundingClientRect(); + const minimum = mobile ? 44 : 36; + if (targetRect.height + 0.5 < minimum || targetRect.width + 0.5 < minimum) { + issues.push(`${intent} ${tag}: ${Math.round(targetRect.width)}x${Math.round(targetRect.height)}`); + } + } + if (intent === "shortText" && tag === "input" && rect.height + 0.5 < (mobile ? 44 : 44)) { + issues.push(`${intent} input height: ${Math.round(rect.height)}px`); + } + if (intent === "shortText" && tag === "textarea") { + const style = window.getComputedStyle(element); + const resize = style.resize; + if (rect.height + 0.5 < 144) issues.push(`${intent} textarea height: ${Math.round(rect.height)}px (min-height ${style.minHeight}; ${element.className})`); + if (!resize.includes("vertical") && resize !== "both") issues.push(`${intent} textarea resize: ${resize}`); + } + if (["payload", "generatedOutput"].includes(intent) && ["textarea", "pre"].includes(tag) && rect.height + 0.5 < 256) { + issues.push(`${intent} ${tag} height: ${Math.round(rect.height)}px`); + } + } + + return { counts, issues }; + }, { mobile: viewport.mobile }); + + for (const intent of audit.requiredIntents) { + if (!measurements.counts[intent]) { + throw new Error(`${routeLabel} did not render required ${intent} input intent.`); + } + } + if (measurements.issues.length > 0) { + throw new Error(`${routeLabel} input sizing failed:\n- ${measurements.issues.join("\n- ")}`); + } + + await assertNoHorizontalOverflow(page, routeLabel); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + if (screenshot.byteLength < 5_000) { + throw new Error(`${routeLabel} sizing screenshot was unexpectedly blank (${screenshot.byteLength} bytes).`); + } + runtime.assertClean(); + } finally { + await page.close(); + } + } + } finally { + await context.close(); + } + } +} + async function assertAxeSeriousCriticalMatrix(browser, baseUrl) { const context = await browser.newContext({ serviceWorkers: "block" }); @@ -1014,6 +1570,93 @@ async function assertBase64PipelineSafeNavigationJourney(context, baseUrl) { await page.close(); } +async function assertDownloadedFile(download, expectedFilename, kind) { + if (download.suggestedFilename() !== expectedFilename) { + throw new Error(`Expected ${expectedFilename}, received ${download.suggestedFilename()}.`); + } + + const failure = await download.failure(); + if (failure) { + throw new Error(`${expectedFilename} download failed: ${failure}`); + } + + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error(`${expectedFilename} did not produce a downloadable file.`); + } + + const contents = await readFile(downloadPath); + if (contents.length === 0) { + throw new Error(`${expectedFilename} download was empty.`); + } + + if (kind === "png" && !contents.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + throw new Error(`${expectedFilename} did not contain a PNG signature.`); + } + if (kind === "svg" && !contents.toString("utf8").includes("
- + ) } diff --git a/src/app/[lang]/contact/page.tsx b/src/app/[lang]/contact/page.tsx index 9247c543..510da819 100644 --- a/src/app/[lang]/contact/page.tsx +++ b/src/app/[lang]/contact/page.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Github, Mail, MessageSquare, ExternalLink, ShieldCheck, Map, ThumbsUp, HeartHandshake } from "lucide-react" import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { StaticPageContainer } from "@/components/layout/page-container" const EMAIL_ADDRESS = "contact@byteflow.tools" const GITHUB_REPOSITORY_URL = "https://github.com/baixiangcpp/byteflow.tools" @@ -39,7 +40,7 @@ export default function ContactPage() { } return ( -{p.contact_intro}
@@ -106,6 +107,6 @@ export default function ContactPage() {Cluster C2: API Debugging
Cluster C3: Network & Security
Cluster C4: CSS & Design
Cluster C4: CSS & Design
Cluster C4: CSS & Design
{copy.badge}
Cluster C3: Network & Security
{description}
Cluster C6: Encoding & Hashing
Cluster C5: Image Processing
Cluster C5: Image Processing
Cluster C1: JSON Ecosystem
Cluster C1: JSON Ecosystem
Cluster C1: JSON Ecosystem
Cluster C6: Encoding & Hashing
Cluster C2: API Debugging
Cluster C4: CSS & Design
Cluster C2: API Debugging
+
{t.site.hero_subtitle}
diff --git a/src/app/[lang]/pricing/page.tsx b/src/app/[lang]/pricing/page.tsx index 6a43718f..34dc4f5f 100644 --- a/src/app/[lang]/pricing/page.tsx +++ b/src/app/[lang]/pricing/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { Check, Sparkles, ShieldCheck, LockKeyhole, ArrowUpRight, HeartHandshake, ServerCog, Mail } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" +import { StaticPageContainer } from "@/components/layout/page-container" export default function PricingPage() { const { t, lang } = useLang() @@ -40,7 +41,7 @@ export default function PricingPage() { ] return ( -{p.pricing_badge} @@ -140,6 +141,6 @@ export default function PricingPage() { {p.pricing_self_hosting_guide}
{p.privacy_last_updated}
@@ -91,6 +92,6 @@ export default function PrivacyPage() {{copy.badge}
Cluster C3: Network & Security
{copy.badge}
{p.support_badge}
Cluster C5: Image Processing
{p.terms_last_updated}
@@ -29,6 +30,6 @@ export default function TermsPage() { ))}Cluster C6: Encoding & Hashing
Cluster C1: JSON Ecosystem
{installInlineCopy.title}
{installInlineCopy.description}
@@ -104,9 +114,12 @@ function RoutePageChromeContent({ children, pathname }: RoutePageChromeProps) { ) : null} {shouldRenderFallbackRelatedTools && activeTool?.key ? ( -{data.description}
diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx index e623f4d2..f6d6dcd4 100644 --- a/src/components/layout/theme-toggle.tsx +++ b/src/components/layout/theme-toggle.tsx @@ -20,10 +20,14 @@ const THEME_OPTIONS: Array<{ value: ThemePreference; labelKey: "theme_light" | " { value: "system", labelKey: "theme_system" }, ] -export function ThemeToggle() { +export function ThemeToggle({ onReady }: { onReady?: () => void }) { const { theme, setTheme } = useThemePreference() const { t } = useLang() + React.useLayoutEffect(() => { + onReady?.() + }, [onReady]) + const handleSetTheme = (theme: ThemePreference) => { setTheme(theme) } diff --git a/src/components/layout/tool-favorite-control.tsx b/src/components/layout/tool-favorite-control.tsx index 9bbbcd40..d1699f56 100644 --- a/src/components/layout/tool-favorite-control.tsx +++ b/src/components/layout/tool-favorite-control.tsx @@ -43,12 +43,16 @@ export function ToolFavoriteControl({ toolKey }: ToolFavoriteControlProps) { variant="outline" size="sm" className="min-h-10 border-border bg-background shadow-xs" + aria-label={isFavorite ? removeLabel : addLabel} aria-pressed={isFavorite} title={isFavorite ? removeLabel : addLabel} onClick={() => setFavoriteToolKeys(toggleFavoriteToolKey(toolKey))} >- {body} -
-- - {cta} - -
-{model.content.intro}
diff --git a/src/core/seo/components/tool-content-template-modules/generated/de.json b/src/core/seo/components/tool-content-template-modules/generated/de.json index d59f70e7..230d4743 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/de.json +++ b/src/core/seo/components/tool-content-template-modules/generated/de.json @@ -1387,9 +1387,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "Generieren Sie QR-Codes mit benutzerdefinierten Größen und Farben. Als PNG oder SVG herunterladen. Diese Seite eignet sich, um Erzeugung direkt im Browser zu prüfen und vor Übergabe, Freigabe oder Teilen nachvollziehbare Ein- und Ausgaben festzuhalten.", + "intro": "Generieren Sie QR-Codes mit benutzerdefinierten Größen und Farben und laden Sie sie als PNG oder SVG herunter. PNG-, JPEG- und WebP-QR-Bilder lassen sich auch lokal im Browser dekodieren; Bildinhalte werden nicht hochgeladen. Bei unlesbaren Bildern oder fehlenden QR-Codes erscheint eine klare Rückmeldung.", "whatThisToolDoes": [ - "QR-Code-Generator aktualisiert das Ergebnis sofort auf Basis der aktuellen Eingabe, sodass sich die wichtigste Ausgabe zuerst prüfen lässt.", + "Der QR-Code-Generator aktualisiert die Vorschau sofort und kann Text aus lokal hochgeladenen QR-Bildern lesen.", "Der Schwerpunkt dieser Seite liegt auf folgendem Punkt: Generieren Sie QR-Codes mit benutzerdefinierten Größen und Farben. Als PNG oder SVG herunterladen.", "Wenn QR-Code-Generator in den Erzeugung-Ablauf eingebaut wird, fallen Unterschiede bei Format, Inhalt oder Parametern deutlich früher auf." ], diff --git a/src/core/seo/components/tool-content-template-modules/generated/fr.json b/src/core/seo/components/tool-content-template-modules/generated/fr.json index 3a44c80f..5eb784b2 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/fr.json +++ b/src/core/seo/components/tool-content-template-modules/generated/fr.json @@ -1387,9 +1387,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "Générez des QR codes avec des tailles et des couleurs personnalisées. Téléchargez au format PNG ou SVG. Cette page est utile pour valider la génération dans le navigateur et conserver des entrées/sorties reproductibles avant livraison, partage ou passation.", + "intro": "Générez des QR codes avec des tailles et des couleurs personnalisées, puis téléchargez-les en PNG ou SVG. Vous pouvez aussi décoder localement dans le navigateur des images QR PNG, JPEG ou WebP ; leur contenu n'est pas envoyé. Un message clair apparaît si l'image est illisible ou ne contient pas de QR code.", "whatThisToolDoes": [ - "Générateur de QR Code met à jour le résultat immédiatement à partir de l’entrée courante afin de vérifier d’abord la sortie principale.", + "Le Générateur de QR Code met à jour l’aperçu immédiatement et peut lire le texte d’images QR importées localement.", "Le point central de cette page est le suivant : Générez des QR codes avec des tailles et des couleurs personnalisées. Téléchargez au format PNG ou SVG.", "En intégrant Générateur de QR Code dans un flux de génération, les écarts de format, de contenu ou de paramètres apparaissent plus tôt." ], diff --git a/src/core/seo/components/tool-content-template-modules/generated/ja.json b/src/core/seo/components/tool-content-template-modules/generated/ja.json index 593aaf85..32a88f56 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/ja.json +++ b/src/core/seo/components/tool-content-template-modules/generated/ja.json @@ -1648,9 +1648,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "リンク、Wi-Fi オンボーディング、連絡先共有用のスキャン可能な QR コードを生成し、プロダクション印刷およびデジタル ワークフローに適した設定可能なエラー修正、マージン、サイズ、エクスポート形式の制御を行います。", + "intro": "リンク、Wi-Fi、連絡先共有用の QR コードを生成し、PNG または SVG として出力できます。アップロードした PNG、JPEG、WebP の QR 画像もブラウザ内だけで読み取れ、画像は送信されません。画像を読めない場合や QR コードがない場合は明確な案内を表示します。", "whatThisToolDoes": [ - "テキスト ペイロードを QR マトリックスに変換し、プレビュー出力をリアルタイムでレンダリングして迅速に検証します。", + "テキストを QR マトリックスに変換してプレビューし、ローカルにアップロードした QR 画像からテキストも読み取れます。", "エラー修正レベル、マージン間隔、PNG または SVG のダウンロード オプションなどの実用的なコントロールをサポートしています。", "パッケージ、スライド、ソーシャル カード、またはアプリ内画面でコードを公開する前に、チームがペイロードの整合性を検証するのに役立ちます。" ], diff --git a/src/core/seo/components/tool-content-template-modules/generated/ko.json b/src/core/seo/components/tool-content-template-modules/generated/ko.json index bd1054a4..ac9e02de 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/ko.json +++ b/src/core/seo/components/tool-content-template-modules/generated/ko.json @@ -1387,9 +1387,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "사용자 지정 크기와 색상으로 QR 코드를 생성합니다. PNG 또는 SVG로 다운로드하세요. 이 페이지는 브라우저 안에서 생성 결과를 빠르게 확인하고, 제출이나 공유, 인수인계 전에 재현 가능한 입출력 기록을 남길 때 적합합니다.", + "intro": "사용자 지정 크기와 색상으로 QR 코드를 생성하고 PNG 또는 SVG로 다운로드합니다. PNG, JPEG, WebP QR 이미지를 브라우저 안에서만 해독할 수도 있으며 이미지 내용은 업로드되지 않습니다. 이미지를 읽을 수 없거나 QR 코드를 찾지 못하면 명확한 안내를 표시합니다.", "whatThisToolDoes": [ - "QR 코드 생성기 은 현재 입력에 맞춰 결과를 즉시 갱신하므로 핵심 출력이 기대와 맞는지 먼저 확인할 수 있습니다.", + "QR 코드 생성기는 현재 입력에 맞춰 결과를 즉시 갱신하고 로컬 QR 이미지의 텍스트도 읽을 수 있습니다.", "이 페이지가 중점적으로 다루는 내용은 다음과 같습니다: 사용자 지정 크기와 색상으로 QR 코드를 생성합니다. PNG 또는 SVG로 다운로드하세요.", "QR 코드 생성기 을 생성 흐름에 넣으면 형식, 내용, 파라미터 차이를 더 이른 단계에서 발견할 수 있습니다." ], diff --git a/src/core/seo/components/tool-content-template-modules/generated/zh-CN.json b/src/core/seo/components/tool-content-template-modules/generated/zh-CN.json index 6d1b3cf3..aa5257a1 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/zh-CN.json +++ b/src/core/seo/components/tool-content-template-modules/generated/zh-CN.json @@ -1648,9 +1648,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "为链接、Wi-Fi 登录和联系人共享生成可扫描的 QR 码,并具有可配置的纠错、边距、大小和导出格式控件,适用于生产打印和数字工作流程。", + "intro": "为链接、Wi-Fi 登录和联系人共享生成可扫描的 QR 码,并具有可配置的纠错、边距、大小和导出格式控件。还可以在浏览器本地解码上传的 PNG、JPEG 或 WebP QR 图片,图片内容不会上传;无法读取图片或未找到二维码时会显示明确提示。", "whatThisToolDoes": [ - "它将文本有效负载转换为 QR 矩阵,并实时渲染预览输出以进行快速验证。", + "它将文本有效负载转换为 QR 矩阵并实时渲染预览,也可以从本地上传的二维码图片中读取文本。", "它支持实用控制,例如纠错级别、边距间距以及 PNG 或 SVG 的下载选项。", "它帮助团队在包装、幻灯片、社交卡或应用内界面中发布二维码前验证载荷内容是否完整。" ], diff --git a/src/core/seo/components/tool-content-template-modules/generated/zh-TW.json b/src/core/seo/components/tool-content-template-modules/generated/zh-TW.json index 27b71d78..16f3cbf8 100644 --- a/src/core/seo/components/tool-content-template-modules/generated/zh-TW.json +++ b/src/core/seo/components/tool-content-template-modules/generated/zh-TW.json @@ -1383,9 +1383,9 @@ "qr-code-generator": { "content": { "toolKey": "qr_code_generator", - "intro": "產生自訂大小和顏色的 QR Code。下載格式為 PNG 或 SVG。 這個頁面適合在瀏覽器中快速完成生成確認,並在提交、分享或交接前保留可重現的輸入與輸出紀錄。", + "intro": "產生自訂大小和顏色的 QR Code,並下載為 PNG 或 SVG。也可在瀏覽器本機解碼上傳的 PNG、JPEG 或 WebP QR 圖片;影像內容不會上傳,無法讀取或找不到 QR Code 時會顯示明確提示。", "whatThisToolDoes": [ - "QR Code 產生器 會依照目前輸入立即更新結果,方便先確認主要輸出是否符合預期。", + "QR Code 產生器會依照目前輸入立即更新結果,也能讀取本機上傳 QR 圖片中的文字。", "頁面描述聚焦在這件事上:產生自訂大小和顏色的 QR Code。下載格式為 PNG 或 SVG。", "把 QR Code 產生器 放進生成流程中,可以更早發現格式、內容或參數上的落差。" ], diff --git a/src/core/seo/components/tool-content-template-modules/top-templates.ts b/src/core/seo/components/tool-content-template-modules/top-templates.ts index b8493d6d..cfc63b28 100644 --- a/src/core/seo/components/tool-content-template-modules/top-templates.ts +++ b/src/core/seo/components/tool-content-template-modules/top-templates.ts @@ -735,11 +735,11 @@ export const TOP_TOOL_CONTENT_TEMPLATES: Record