diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md
index 836aa7b..9aa13fe 100644
--- a/.agents/AGENTS.md
+++ b/.agents/AGENTS.md
@@ -67,18 +67,25 @@ primitives. Components must consume **semantic** tokens.
---
-## 3. Dark Mode
-
-- Dark mode applies in two ways (both defined in `_color.less` via the
- `.theme-dark-primitives()` mixin):
- 1. Explicit selection: `:root[data-theme="dark"]` — set by the theme toggle; always wins.
- 2. OS preference: `@media (prefers-color-scheme: dark)` on `:root:not([data-theme="light"])` —
- a fallback for users who have not chosen a theme.
+## 3. Theming & Dark Mode
+
+- **`src/styles/tokens/_theme-contract.less` is the spec.** Read it before adding a
+ theme or a theme-dependent token. It lists the primitives a theme must supply,
+ what is derived (and must not be restated), and the contrast obligations a theme owes.
+- Themes are registered one selector per theme in `_color.less`:
+ `:root[data-theme="ember-dark"] { .theme-ember-dark(); }`. `light` and `dark` are
+ retained as the shipped aliases because they are already in users' cookies.
+- The OS-preference fallback guards on `:root:not([data-theme])` — the **absence** of
+ the attribute. Never `:not([data-theme="light"])`: that reads as "anything except
+ light", so any third theme would get the fallback layered over it.
- Do **not** add further `@media (prefers-color-scheme: …)` blocks anywhere else;
theme-dependent values belong in the primitive overrides in `_color.less`.
- Because theming happens at the **primitive** layer, a new semantic token defined
against primitives usually needs no dark override. If a token's light value is a
- literal (e.g. an rgba hairline), add its dark value to `.theme-dark-primitives()`.
+ literal (e.g. an rgba hairline), prefer *deriving* it from the polarity primitives
+ (`--theme-ink-rgb`, `--theme-is-dark`) or the accent (`--accent-rgb`) so every
+ present and future theme gets it for free — see the derived block at the end of
+ `_color.less`. Add a per-theme value only when it genuinely cannot be derived.
---
@@ -158,8 +165,11 @@ Use these before writing any new page-level CSS:
When a design element can't be expressed by an existing token:
1. Define the new token in `:root` in `src/styles/tokens/_color.less` (or the relevant
token file), referencing primitives wherever possible.
-2. If its value cannot derive from a primitive, add a dark value to
- `.theme-dark-primitives()` in the same file.
+2. If its value cannot reference a primitive directly, try to **derive** it in the
+ derived block at the end of `_color.less` — from the polarity primitives
+ (`--theme-ink-rgb`, `--theme-is-dark`) or the accent (`--accent-rgb`). A derived
+ token costs every future theme nothing. Only if that fails does it become a
+ per-theme value, which means adding it to **every** theme block.
3. Name it semantically (e.g. `--modal-overlay-background`), not by value.
4. Document it in a comment block near its peers.
5. Remove tokens that lose their last consumer — no dead tokens.
@@ -170,12 +180,40 @@ When a design element can't be expressed by an existing token:
- **Any text added to the UI** must use the `FormattedMessage` component with a specific `id`.
Never hardcode English (or any other language) strings directly into JSX.
-- You must add the translation string for the new `id` in **all four** language files:
- - `src/translations/en.json` — English
- - `src/translations/cn.json` — Chinese
- - `src/translations/nl.json` — Dutch
- - `src/translations/th.json` — Thai
-- **Every** language file must receive the new key. Omitting any language file is not acceptable.
+- **`en.json` is the reference.** Every other catalogue in `src/translations/` must carry
+ **exactly** the same key set — no missing keys, no stale extras.
+- **Add the new key to every file in `src/translations/`**, translated. Do not assume a
+ fixed list of languages: run `ls src/translations/` and cover what is actually there.
+ Everything except `translations.json` (the language registry) is a catalogue.
+ This rule used to name four specific files; `hi.json` was added later and fell
+ 432 keys behind precisely because the list was hardcoded here. Never re-introduce a
+ hardcoded list.
+- **Adding a new language** means creating its catalogue with a translation for
+ *every* key already in `en.json` — not just the keys for the feature being worked
+ on — and adding it to `src/translations/translations.json` so the picker offers it.
+ A partially populated catalogue is not acceptable: `react-intl` silently falls back
+ to the id, so gaps show up as raw dotted strings in the UI.
+- Leaving a value identical to English is only acceptable when the term genuinely does
+ not translate (proper nouns, `SAML`, `URL`). It is never a placeholder for
+ "translate this later".
+- **ICU placeholders and arguments must survive translation.** `{count}`, `{name}` and
+ the like are part of the contract; dropping one renders the wrong text with no error.
+ Plural *categories* may legitimately differ — Thai and Chinese have no grammatical
+ plural and use `other` alone.
+
+### Enforcement
+
+```bash
+npm run check-translations
+```
+
+Fails the build when any catalogue is missing keys, carries stale ones, drops an ICU
+placeholder, or when a `FormattedMessage` id used in the code is absent from `en.json`
+(which renders as a raw id — this check found `ccommon.` typo'd for `common.`).
+
+**Run it after adding or changing any UI text, and before opening a PR.** It derives the
+language list from the directory, so a newly added catalogue is covered automatically
+with no change to the script or to this document.
### Translation ID naming convention
diff --git a/package-lock.json b/package-lock.json
index 5dbd856..f1ad6a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@rsuite/icons": "^1.0.2",
"@types/react": "^18.0.28",
- "angles-javascript-client": "^1.0.43",
+ "angles-javascript-client": "^3.1.2",
"apexcharts": "^6.8.0",
"axios": "^1.13.3",
"base-64": "^1.0.0",
@@ -2137,9 +2137,9 @@
"license": "MIT"
},
"node_modules/angles-javascript-client": {
- "version": "1.0.43",
- "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-1.0.43.tgz",
- "integrity": "sha512-0y+xrDzaM29f/1ifzvjawIcnw3gW+5y0g7dH+GLFomN/uuVCDRjUVPkWXd+1pYNdlH6bqSmcE2QrrDGxHCvU1A==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-3.1.2.tgz",
+ "integrity": "sha512-yRjFmK+Xxq1bgVmrsJZN0LjKYJQmDHFq76IILLleYpix0TBCNSUNtlNM2/TQR9hJveOEXNa5z2W4fiSFDjEeWg==",
"license": "Apache-2.0",
"dependencies": {
"axios": "^1.13.2",
@@ -9523,9 +9523,9 @@
}
},
"angles-javascript-client": {
- "version": "1.0.43",
- "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-1.0.43.tgz",
- "integrity": "sha512-0y+xrDzaM29f/1ifzvjawIcnw3gW+5y0g7dH+GLFomN/uuVCDRjUVPkWXd+1pYNdlH6bqSmcE2QrrDGxHCvU1A==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-3.1.2.tgz",
+ "integrity": "sha512-yRjFmK+Xxq1bgVmrsJZN0LjKYJQmDHFq76IILLleYpix0TBCNSUNtlNM2/TQR9hJveOEXNa5z2W4fiSFDjEeWg==",
"requires": {
"axios": "^1.13.2",
"form-data": "^4.0.0",
diff --git a/package.json b/package.json
index 9253cf0..1256459 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@rsuite/icons": "^1.0.2",
"@types/react": "^18.0.28",
- "angles-javascript-client": "^1.0.43",
+ "angles-javascript-client": "^3.1.2",
"apexcharts": "^6.8.0",
"axios": "^1.13.3",
"base-64": "^1.0.0",
@@ -65,7 +65,8 @@
"build": "npm run build-css && next build --webpack",
"build-css": "lessc src/styles/index.less src/styles/main.css --js",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "check-translations": "node scripts/check-translations.js"
},
"eslintConfig": {
"extends": "react-app"
diff --git a/scripts/check-translations.js b/scripts/check-translations.js
new file mode 100644
index 0000000..a33a348
--- /dev/null
+++ b/scripts/check-translations.js
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+/* eslint-disable no-console */
+// ============================================================================
+// Translation completeness check
+// ----------------------------------------------------------------------------
+// `en.json` is the reference. Every other file in src/translations/ must carry
+// exactly the same key set, and every message id referenced in the code must
+// exist in the reference.
+//
+// npm run check-translations
+//
+// Reports four kinds of problem:
+// MISSING a key in en.json that a locale does not have → untranslated UI
+// EXTRA a key a locale has that en.json does not → stale, dead weight
+// UNDEFINED an id used in code but absent from en.json → renders as raw id
+// PLACEHOLDER a translation that dropped an ICU argument → renders wrong
+// UNTRANSLATED a value byte-identical to English → likely a placeholder
+//
+// The locale list is derived from the directory, so adding a language file is
+// enough to bring it under the check — nothing here needs updating.
+// ============================================================================
+
+const fs = require('fs');
+const path = require('path');
+
+const TRANSLATIONS_DIR = path.join(__dirname, '..', 'src', 'translations');
+const SRC_DIR = path.join(__dirname, '..', 'src');
+const REFERENCE = 'en';
+
+// `translations.json` is the language registry, not a message catalogue.
+const NOT_A_CATALOGUE = new Set(['translations.json']);
+
+// Values legitimately identical across languages: proper nouns, symbols,
+// numbers, and the language names in the picker.
+const SAME_IS_FINE = /^[\W\d_]*$/;
+const PROPER_NOUNS = new Set([
+ 'Angles', 'Jira', 'Zephyr', 'Xray', 'OIDC', 'SAML', 'LDAP', 'Okta', 'JSON',
+ 'CSV', 'URL', 'API', 'ID', 'UUID', 'Git', 'CI', 'QA', 'Sepia', 'Slate',
+]);
+
+const readCatalogue = (file) => {
+ const full = path.join(TRANSLATIONS_DIR, file);
+ try {
+ return JSON.parse(fs.readFileSync(full, 'utf8'));
+ } catch (err) {
+ console.error(`\n ${file} is not valid JSON: ${err.message}`);
+ process.exit(1);
+ return null;
+ }
+};
+
+const walk = (dir, out = []) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== 'translations' && entry.name !== 'node_modules') walk(full, out);
+ } else if (/\.(js|jsx)$/.test(entry.name)) {
+ out.push(full);
+ }
+ }
+ return out;
+};
+
+// Ids referenced from code. Template literals and variables are skipped —
+// they cannot be resolved statically, so they are the author's responsibility.
+const collectUsedIds = () => {
+ const ids = new Set();
+ const patterns = [
+ /FormattedMessage[^>]*?\bid=["']([^"']+)["']/gs,
+ /formatMessage\(\s*\{\s*id:\s*["']([^"']+)["']/g,
+ ];
+ for (const file of walk(SRC_DIR)) {
+ const source = fs.readFileSync(file, 'utf8');
+ for (const pattern of patterns) {
+ for (const match of source.matchAll(pattern)) {
+ if (!/[${]/.test(match[1])) ids.add(match[1]);
+ }
+ }
+ }
+ return ids;
+};
+
+const localeFiles = fs.readdirSync(TRANSLATIONS_DIR)
+ .filter((f) => f.endsWith('.json') && !NOT_A_CATALOGUE.has(f));
+
+const reference = readCatalogue(`${REFERENCE}.json`);
+const referenceKeys = Object.keys(reference);
+let failed = false;
+
+console.log(`Reference: ${REFERENCE}.json (${referenceKeys.length} keys)\n`);
+
+// ── Ids used in code but not defined ────────────────────────────────────────
+const undefinedIds = [...collectUsedIds()].filter((id) => !(id in reference)).sort();
+if (undefinedIds.length) {
+ failed = true;
+ console.log(`UNDEFINED — used in code, missing from ${REFERENCE}.json (${undefinedIds.length}):`);
+ undefinedIds.forEach((id) => console.log(` ${id}`));
+ console.log();
+}
+
+// ── Per-locale completeness ─────────────────────────────────────────────────
+for (const file of localeFiles.sort()) {
+ const code = path.basename(file, '.json');
+ if (code === REFERENCE) continue;
+
+ const catalogue = readCatalogue(file);
+ const missing = referenceKeys.filter((k) => !(k in catalogue));
+ const extra = Object.keys(catalogue).filter((k) => !(k in reference));
+
+ // ICU arguments must survive translation: `{count}` dropped from a string
+ // renders the wrong text with no error. Only leading argument names are
+ // compared — plural categories legitimately differ, since Thai and Chinese
+ // have no grammatical plural and use `other` alone.
+ const args = (s) => new Set(
+ [...s.matchAll(/\{\s*(\w+)\s*(?:,\s*(?:plural|select|number|date|time)\b)?\s*[,}]/g)]
+ .map((m) => m[1]),
+ );
+ const droppedArgs = Object.keys(catalogue).filter((k) => {
+ if (!(k in reference)) return false;
+ const expected = args(reference[k]);
+ const actual = args(catalogue[k]);
+ return [...expected].some((a) => !actual.has(a));
+ });
+ const untranslated = Object.keys(catalogue).filter((k) => {
+ const value = catalogue[k];
+ return k in reference && value === reference[k]
+ && !SAME_IS_FINE.test(value) && !PROPER_NOUNS.has(value.trim());
+ });
+
+ const problems = missing.length + extra.length + droppedArgs.length;
+ if (problems === 0 && untranslated.length === 0) {
+ console.log(` ${code} complete (${Object.keys(catalogue).length} keys)`);
+ continue;
+ }
+ if (problems > 0) failed = true;
+
+ console.log(` ${code} ${missing.length} missing, ${extra.length} extra`
+ + (droppedArgs.length ? `, ${droppedArgs.length} dropped placeholders` : '')
+ + (untranslated.length ? `, ${untranslated.length} same as English` : ''));
+
+ const preview = (label, keys) => {
+ if (!keys.length) return;
+ console.log(` ${label}:`);
+ keys.slice(0, 10).forEach((k) => console.log(` ${k}`));
+ if (keys.length > 10) console.log(` … and ${keys.length - 10} more`);
+ };
+ preview('missing', missing);
+ preview('extra (stale — remove)', extra);
+ preview('dropped an ICU placeholder', droppedArgs);
+ // Same-as-English is a warning, not a failure: some strings genuinely match.
+ preview('same as English (check)', untranslated);
+}
+
+console.log();
+if (failed) {
+ console.log('Translation check FAILED. Every locale must carry every key in '
+ + `${REFERENCE}.json — see .agents/AGENTS.md §10.`);
+ process.exit(1);
+}
+console.log('Translation check passed.');
diff --git a/src/app/admin/custom-fields/page.js b/src/app/admin/custom-fields/page.js
new file mode 100644
index 0000000..9c63b9e
--- /dev/null
+++ b/src/app/admin/custom-fields/page.js
@@ -0,0 +1,8 @@
+'use client';
+
+import React from 'react';
+import AdminCustomFieldsPage from '../../../components/pages/admin-custom-fields/AdminCustomFieldsPage';
+
+export default function Page() {
+ return ;
+}
diff --git a/src/app/layout.js b/src/app/layout.js
index c72f71b..c75c2aa 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -2,10 +2,23 @@ import 'rsuite/dist/rsuite.min.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../styles/main.css';
+import { Saira_Condensed as SairaCondensed } from 'next/font/google';
+
import Providers from './providers';
import Shell from '../components/layout/Shell';
import RuntimeConfigScript from './runtime-config-script';
+// Wordmark face for the brand logo. `next/font` self-hosts the file at build
+// time, so there is no runtime request to Google — no third-party dependency on
+// the critical path and nothing to allow through a CSP. Exposed as a CSS
+// variable so only `.brand-wordmark` uses it; body text keeps the system stack.
+const brandFont = SairaCondensed({
+ subsets: ['latin'],
+ weight: ['600'],
+ display: 'swap',
+ variable: '--font-brand',
+});
+
export const metadata = {
title: 'Angles',
description: 'Angles UI',
@@ -17,7 +30,7 @@ export const dynamic = 'force-dynamic';
export default function RootLayout({ children }) {
return (
-
+
diff --git a/src/app/manual-test-cases/[caseId]/page.js b/src/app/manual-test-cases/[caseId]/page.js
new file mode 100644
index 0000000..f9bed79
--- /dev/null
+++ b/src/app/manual-test-cases/[caseId]/page.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { useParams } from 'next/navigation';
+import ManualTestCaseDetailPage from '../../../components/pages/manual-test-cases/ManualTestCaseDetailPage';
+
+export default function Page() {
+ const params = useParams();
+ return ;
+}
diff --git a/src/app/manual-test-cases/[caseId]/version/[version]/page.js b/src/app/manual-test-cases/[caseId]/version/[version]/page.js
new file mode 100644
index 0000000..974a3b9
--- /dev/null
+++ b/src/app/manual-test-cases/[caseId]/version/[version]/page.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { useParams } from 'next/navigation';
+import ManualTestCaseVersionPage from '../../../../../components/pages/manual-test-case-version/ManualTestCaseVersionPage';
+
+export default function Page() {
+ const params = useParams();
+ return ;
+}
diff --git a/src/app/manual-test-cases/layout.js b/src/app/manual-test-cases/layout.js
new file mode 100644
index 0000000..f25e04e
--- /dev/null
+++ b/src/app/manual-test-cases/layout.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { ManualTestingGuard } from '../../components/common/FeatureGuard';
+
+// Applied at the segment level so nested routes (a case, a version, a run) are gated by
+// the same toggle without each page having to remember to guard itself.
+export default function Layout({ children }) {
+ return {children} ;
+}
diff --git a/src/app/manual-test-cases/page.js b/src/app/manual-test-cases/page.js
new file mode 100644
index 0000000..a19665e
--- /dev/null
+++ b/src/app/manual-test-cases/page.js
@@ -0,0 +1,8 @@
+'use client';
+
+import React from 'react';
+import ManualTestCasesPage from '../../components/pages/manual-test-cases/ManualTestCasesPage';
+
+export default function Page() {
+ return ;
+}
diff --git a/src/app/manual-test-runs/[runId]/page.js b/src/app/manual-test-runs/[runId]/page.js
new file mode 100644
index 0000000..b4d8216
--- /dev/null
+++ b/src/app/manual-test-runs/[runId]/page.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { useParams } from 'next/navigation';
+import ManualTestRunDetailPage from '../../../components/pages/manual-test-runs/ManualTestRunDetailPage';
+
+export default function Page() {
+ const params = useParams();
+ return ;
+}
diff --git a/src/app/manual-test-runs/layout.js b/src/app/manual-test-runs/layout.js
new file mode 100644
index 0000000..f25e04e
--- /dev/null
+++ b/src/app/manual-test-runs/layout.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { ManualTestingGuard } from '../../components/common/FeatureGuard';
+
+// Applied at the segment level so nested routes (a case, a version, a run) are gated by
+// the same toggle without each page having to remember to guard itself.
+export default function Layout({ children }) {
+ return {children} ;
+}
diff --git a/src/app/manual-test-runs/page.js b/src/app/manual-test-runs/page.js
new file mode 100644
index 0000000..5f1991f
--- /dev/null
+++ b/src/app/manual-test-runs/page.js
@@ -0,0 +1,8 @@
+'use client';
+
+import React from 'react';
+import ManualTestRunsPage from '../../components/pages/manual-test-runs/ManualTestRunsPage';
+
+export default function Page() {
+ return ;
+}
diff --git a/src/app/providers.js b/src/app/providers.js
index 7e6f877..8aa0025 100644
--- a/src/app/providers.js
+++ b/src/app/providers.js
@@ -6,6 +6,7 @@ import { IntlProvider } from 'react-intl';
import Cookies from 'js-cookie';
import { Provider } from 'react-redux';
import store from '../redux/store';
+import { applyTheme } from '../utility/Themes';
import { CurrentScreenshotProvider } from '../context/CurrentScreenshotContext';
import { ExecutionStateProvider } from '../context/ExecutionStateContext';
import { AuthProvider } from '../context/AuthContext';
@@ -40,11 +41,13 @@ export default function Providers({ children }) {
useEffect(() => {
// Only pin an explicit theme when the user has chosen one. With no
// cookie we leave `data-theme` unset so the CSS follows the OS
- // preference (prefers-color-scheme) — see src/styles/tokens.less.
- const theme = Cookies.get('theme');
- if (theme) {
- document.documentElement.setAttribute('data-theme', theme);
- }
+ // preference (prefers-color-scheme) — see tokens/_color.less.
+ //
+ // `applyTheme` validates against the registry: an unrecognised cookie
+ // (a theme that was renamed or removed, or a hand-edited value) leaves
+ // the attribute off rather than writing an id that matches no selector,
+ // which would render the page with no theme's primitives at all.
+ applyTheme(Cookies.get('theme'));
}, []);
if (!messages) {
diff --git a/src/app/shared-steps/layout.js b/src/app/shared-steps/layout.js
new file mode 100644
index 0000000..f25e04e
--- /dev/null
+++ b/src/app/shared-steps/layout.js
@@ -0,0 +1,10 @@
+'use client';
+
+import React from 'react';
+import { ManualTestingGuard } from '../../components/common/FeatureGuard';
+
+// Applied at the segment level so nested routes (a case, a version, a run) are gated by
+// the same toggle without each page having to remember to guard itself.
+export default function Layout({ children }) {
+ return {children} ;
+}
diff --git a/src/app/shared-steps/page.js b/src/app/shared-steps/page.js
new file mode 100644
index 0000000..6ef033b
--- /dev/null
+++ b/src/app/shared-steps/page.js
@@ -0,0 +1,8 @@
+'use client';
+
+import React from 'react';
+import SharedStepsPage from '../../components/pages/shared-steps/SharedStepsPage';
+
+export default function Page() {
+ return ;
+}
diff --git a/src/components/common/BrandLogo.js b/src/components/common/BrandLogo.js
new file mode 100644
index 0000000..abc5706
--- /dev/null
+++ b/src/components/common/BrandLogo.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+// ============================================================================
+// Brand logo
+// ----------------------------------------------------------------------------
+// Drawn as inline SVG rather than shipped as a PNG so it can follow the theme.
+// The previous `angles-icon.png` / `angles-text-logo.png` pair was a fixed
+// orange, which was right while Ember was the only palette but clashes now that
+// half the themes accent on blue or green — the wordmark ended up the only
+// orange element on a Slate or Clay page, reading as a stray asset rather than
+// a brand anchor.
+//
+// Colour comes from tokens, so a new theme needs no new asset:
+//
+// --color-primary the lit top face, the dimmed left face, wordmark
+// --color-primary-hover the shaded right face
+// currentColor the outline, inherited from the surrounding text
+//
+// `brandMonochrome` renders the whole mark in currentColor instead. That is
+// what the accessible themes use: on Clay the accent is Okabe-Ito blue, and a
+// two-tone mark competes with the status colours it is trying to stay out of
+// the way of.
+// ============================================================================
+
+// Defaults are declared as parameter defaults rather than `BrandLogo.defaultProps`:
+// defaultProps is deprecated for function components and is not applied by this
+// build, which silently dropped the wordmark and the aria-label.
+const BrandLogo = ({ showText = true, className = undefined, title = 'Angles' }) => (
+
+
+ {/* Isometric cube: top face lit with the accent, left face pale, right
+ face the accent's shaded variant. Drawn on a 100x108 grid so the
+ three faces meet exactly without seams. */}
+
+
+
+
+
+ {showText && (
+
+ {/* Wordmark set as a single text node in the brand face.
+
+ The viewBox is measured from the painted pixels, not guessed, and
+ it must account for the STROKE as well as the glyphs. With the
+ baseline at y=0, the outlined wordmark paints 40 units above it,
+ 11.5 below (the descender of 'g' plus half the stroke), and 126.25
+ wide from 0.75 left of the origin. The box wraps that with ~1.5
+ units of margin.
+
+ A centred stroke extends half its width beyond the glyph edge, so a
+ box fitted to the glyphs alone clips the outline — which is exactly
+ what happened when the stroke grew to 2.6 against a box sized when
+ it was much thinner.
+
+ That tight box is what keeps the wordmark optically centred against
+ the cube. The two SVGs are centred as boxes by `align-items: center`,
+ so padding baked into one viewBox and not the other shows up as a
+ vertical offset — the previous `0 0 300 64` box carried 15 units of
+ slack below the ink, which pushed the text visibly high.
+
+ No `textLength`: forcing the run to 292 units stretched this face to
+ more than twice its natural width. */}
+
+ Angles
+
+
+ )}
+
+);
+
+BrandLogo.propTypes = {
+ // The wordmark is hidden when the sidebar is collapsed to an icon rail.
+ showText: PropTypes.bool,
+ className: PropTypes.string,
+ title: PropTypes.string,
+};
+
+export default BrandLogo;
diff --git a/src/components/common/FeatureGuard.js b/src/components/common/FeatureGuard.js
new file mode 100644
index 0000000..0d01fed
--- /dev/null
+++ b/src/components/common/FeatureGuard.js
@@ -0,0 +1,52 @@
+'use client';
+
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Loader } from 'rsuite';
+import { useAuth } from '../../context/AuthContext';
+
+/**
+ * Gates a page behind an admin-controlled feature toggle.
+ *
+ * The toggle arrives with the auth config, so nothing is rendered until it has loaded -
+ * without that wait a disabled page would flash on screen before disappearing. When the
+ * feature is off the page is replaced with an explanation rather than redirected away,
+ * so a bookmarked or shared link says why it no longer works.
+ *
+ * This is presentation only: the API rejects the underlying routes independently, which
+ * is what actually makes the feature inaccessible.
+ */
+const FeatureGuard = function ({ enabled, loaded, children }) {
+ if (!loaded) {
+ return (
+
+
+
+ );
+ }
+
+ if (!enabled) {
+ return (
+
+
+
+ );
+ }
+
+ return children;
+};
+
+/**
+ * Convenience wrapper for the manual testing feature, which gates the test cases, test
+ * runs and shared steps pages as one unit.
+ */
+export const ManualTestingGuard = function ({ children }) {
+ const { manualTestingEnabled, featuresLoaded } = useAuth();
+ return (
+
+ {children}
+
+ );
+};
+
+export default FeatureGuard;
diff --git a/src/components/common/ThemeSwatch.js b/src/components/common/ThemeSwatch.js
new file mode 100644
index 0000000..16189b3
--- /dev/null
+++ b/src/components/common/ThemeSwatch.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+// ============================================================================
+// Theme swatch
+// ----------------------------------------------------------------------------
+// A three-chip preview of a theme, for the theme picker.
+//
+// The colours are NOT listed here. The swatch carries `data-theme`, and because
+// the theme registry in `_color.less` matches that attribute unqualified (not
+// `:root[data-theme=...]`), the element becomes an island of that theme's
+// primitives; custom properties inherit, so the chips inside resolve the named
+// theme's tokens rather than the active one's. A theme therefore gets a preview
+// the moment it is registered — there is no second copy of its palette here to
+// fall out of step with the first.
+//
+// The three chips are the values that actually tell the themes apart:
+//
+// surface --main-panel-background the surface you look at all day
+// accent --master-color-3 the single most distinguishing value
+// text --main-panel-font-color what separates the contrast pair
+//
+// Status colours are deliberately left out: they are near-identical across
+// themes by design, so they would add width without adding information.
+//
+// Decorative, not informative — the row already names the theme in words, so
+// the swatch is hidden from assistive tech rather than given a label of its own.
+// ============================================================================
+const ThemeSwatch = ({ themeId }) => (
+
+
+
+
+
+);
+
+ThemeSwatch.propTypes = {
+ // Must match a registry selector in `_color.less` — i.e. a `THEMES` id.
+ themeId: PropTypes.string.isRequired,
+};
+
+export default ThemeSwatch;
diff --git a/src/components/features/attachment-upload/AttachmentUpload.js b/src/components/features/attachment-upload/AttachmentUpload.js
new file mode 100644
index 0000000..9210df2
--- /dev/null
+++ b/src/components/features/attachment-upload/AttachmentUpload.js
@@ -0,0 +1,211 @@
+import React, { useState, useEffect } from 'react';
+import axios from 'axios';
+import { Uploader, Loader, Message, useToaster, IconButton, Whisper, Tooltip } from 'rsuite';
+import TrashIcon from '@rsuite/icons/Trash';
+import CopyIcon from '@rsuite/icons/Copy';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { AttachmentRequests } from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+
+/*
+ * Drop zone plus thumbnail strip for a test case step, shared step or execution.
+ *
+ * `owner` is the { testCaseId | sharedStepId | executionId } pair the API keys storage on.
+ * Uploads go straight to the server rather than being held until save, because the markdown
+ * reference a QA pastes into a step has to resolve to a real attachment id immediately.
+ */
+/*
+ * An for an endpoint that requires the session cookie.
+ *
+ * A plain is a separate browser request that carries no credentials to another
+ * origin, so it comes back 401 and renders as a broken image - the API and the UI are not
+ * the same origin in any real deployment. Fetching through axios (which is configured with
+ * withCredentials) and handing the a blob URL is what makes the picture appear.
+ *
+ * Screenshots avoid this by embedding base64 in their JSON, but an attachment list would
+ * carry every image in full on every read, so attachments stay URL-addressed and load here.
+ */
+const AuthenticatedImage = ({ url, alt, className }) => {
+ const [objectUrl, setObjectUrl] = useState(undefined);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let revoked = false;
+ let created;
+ if (!url) return undefined;
+ setFailed(false);
+ axios.get(url, { responseType: 'blob' })
+ .then((response) => {
+ if (revoked) return;
+ created = URL.createObjectURL(response.data);
+ setObjectUrl(created);
+ })
+ .catch(() => { if (!revoked) setFailed(true); });
+ return () => {
+ revoked = true;
+ // Blob URLs are held by the document until revoked, so a list that re-renders
+ // would otherwise leak one per image per render.
+ if (created) URL.revokeObjectURL(created);
+ };
+ }, [url]);
+
+ if (failed) {
+ return (
+
+
+
+ );
+ }
+ if (!objectUrl) return ;
+ return ;
+};
+
+const AttachmentUpload = ({ owner, attachments, onChange, readOnly }) => {
+ const intl = useIntl();
+ const toaster = useToaster();
+ const [uploading, setUploading] = useState(false);
+ const attachmentRequests = new AttachmentRequests(axios);
+
+ // The API stores references, so a case read back gives id strings while a freshly
+ // uploaded one gives the whole document. Both are normalised to { _id, originalName }
+ // so the list renders either way - the thumbnail only needs the id.
+ const list = (attachments || [])
+ .map((attachment) => (typeof attachment === 'string' ? { _id: attachment } : attachment))
+ .filter((attachment) => attachment && attachment._id);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const handleUpload = async (file) => {
+ setUploading(true);
+ try {
+ // blobFile is the underlying File; RSuite wraps it in its own descriptor.
+ const uploaded = await attachmentRequests
+ .uploadAttachment(file.blobFile, owner, file.name);
+ onChange([...(attachments || []), uploaded]);
+ } catch (error) {
+ pushError(error, 'app.manual.attachments.upload-error');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleDelete = async (attachment) => {
+ try {
+ await attachmentRequests.deleteAttachment(attachment._id);
+ onChange((attachments || []).filter((item) => {
+ const id = typeof item === 'string' ? item : item && item._id;
+ return id !== attachment._id;
+ }));
+ } catch (error) {
+ // A 409 here is expected and meaningful: a frozen version still references the
+ // image, so it cannot be removed without rewriting history.
+ pushError(error, 'app.manual.attachments.delete-error');
+ }
+ };
+
+ // Opening the raw URL in a new tab hits the same 401 as an would, so the bytes
+ // are fetched with credentials and handed to the tab as a blob.
+ const openFullSize = async (attachment) => {
+ try {
+ const response = await axios.get(
+ attachmentRequests.getAttachmentUrl(attachment._id),
+ { responseType: 'blob' },
+ );
+ const url = URL.createObjectURL(response.data);
+ window.open(url, '_blank', 'noopener,noreferrer');
+ // Revoked once the new tab has had a chance to load it; revoking immediately
+ // races the open and shows an empty tab.
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ } catch (error) {
+ pushError(error, 'app.manual.attachments.load-error');
+ }
+ };
+
+ const copyReference = (attachment) => {
+ const reference = ``;
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(reference);
+ toaster.push(
+
+
+ ,
+ { placement: 'topEnd' },
+ );
+ }
+ };
+
+ return (
+
+ {!readOnly && (
+
{
+ const pending = fileList[fileList.length - 1];
+ if (pending) handleUpload(pending);
+ }}
+ draggable
+ >
+
+ {uploading ?
+ : }
+
+
+ )}
+ {list.length > 0 && (
+
+ {list.map((attachment) => (
+
+
openFullSize(attachment)}
+ title={attachment.originalName || ''}
+ >
+
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={() => copyReference(attachment)}
+ />
+
+ {!readOnly && (
+ }
+ onClick={() => handleDelete(attachment)}
+ />
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default AttachmentUpload;
diff --git a/src/components/features/attachment-upload/styles.less b/src/components/features/attachment-upload/styles.less
new file mode 100644
index 0000000..d3de594
--- /dev/null
+++ b/src/components/features/attachment-upload/styles.less
@@ -0,0 +1,76 @@
+// ── Attachment upload ────────────────────────────────────────────────────────
+// Drop zone plus thumbnail strip, used by the step editor and the execution view.
+.attachment-upload {
+ .attachment-upload-zone {
+ padding: var(--space-3);
+ border: 1px dashed var(--border-color);
+ border-radius: var(--radius-sm);
+ text-align: center;
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-sm);
+ background: var(--sub-panel-background);
+ }
+
+ .attachment-upload-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+ }
+
+ .attachment-upload-item {
+ position: relative;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ padding: var(--space-1);
+ background: var(--sub-panel-background);
+
+ img {
+ display: block;
+ max-width: 96px;
+ max-height: 96px;
+ border-radius: var(--radius-xs);
+ }
+
+ // The thumbnail is a button so it can open the full image through an authenticated
+ // fetch; it must not look like one.
+ .attachment-upload-open {
+ display: block;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ }
+
+ // Reserves the thumbnail's footprint while the image is being fetched, so a list of
+ // attachments does not jump as each one arrives.
+ .attachment-upload-loading {
+ display: block;
+ width: 96px;
+ height: 96px;
+ border-radius: var(--radius-xs);
+ background: var(--main-panel-background);
+ }
+
+ .attachment-upload-broken {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 96px;
+ height: 96px;
+ padding: var(--space-1);
+ border-radius: var(--radius-xs);
+ background: var(--main-panel-background);
+ color: var(--fail-color);
+ font-size: var(--font-size-xs);
+ text-align: center;
+ }
+
+ .attachment-upload-item-actions {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-1);
+ margin-top: var(--space-1);
+ }
+ }
+}
diff --git a/src/components/features/change-history/ChangeHistory.js b/src/components/features/change-history/ChangeHistory.js
new file mode 100644
index 0000000..128d322
--- /dev/null
+++ b/src/components/features/change-history/ChangeHistory.js
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Loader, Tag } from 'rsuite';
+import Moment from 'react-moment';
+import { FormattedMessage } from 'react-intl';
+
+// The audit actions the API can record. Kept as a lookup so an unrecognised action still
+// renders (as its raw value) rather than showing nothing.
+const ACTION_LABELS = {
+ CREATE: 'app.manual.history.action.create',
+ UPDATE: 'app.manual.history.action.update',
+ DELETE: 'app.manual.history.action.delete',
+ CLONE: 'app.manual.history.action.clone',
+ STATUS_CHANGE: 'app.manual.history.action.status-change',
+ SHARED_STEP_UPDATE: 'app.manual.history.action.shared-step-update',
+ ARCHIVE: 'app.manual.history.action.archive',
+};
+
+// A change value can be a string, number, boolean, date, array or an object (a step
+// summary). Rendered as readable text rather than raw JSON wherever possible.
+const formatValue = (value) => {
+ if (value === undefined || value === null || value === '') return '—';
+ if (Array.isArray(value)) return value.join(', ');
+ if (typeof value === 'object') {
+ return Object.entries(value)
+ .map(([key, entry]) => `${key}: ${entry}`)
+ .join(' · ');
+ }
+ return String(value);
+};
+
+/**
+ * Timeline of who changed what on a test case or shared step.
+ *
+ * Distinct from the version list: a version is the content a tester saw, while this says
+ * who changed it and why - including a status change that burns no version, and a case
+ * re-versioned because somebody edited a shared step it includes.
+ */
+const ChangeHistory = ({ entries, loading }) => {
+ if (loading) {
+ return (
+
+ } />
+
+ );
+ }
+
+ if (!entries || entries.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+ChangeHistory.propTypes = {
+ entries: PropTypes.array,
+ loading: PropTypes.bool,
+};
+
+export default ChangeHistory;
diff --git a/src/components/features/change-history/styles.less b/src/components/features/change-history/styles.less
new file mode 100644
index 0000000..d8977b1
--- /dev/null
+++ b/src/components/features/change-history/styles.less
@@ -0,0 +1,85 @@
+// ── Change history timeline ──────────────────────────────────────────────────
+// Audit trail for a manual test case or shared step: who changed what, and why.
+.change-history {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ .change-history-entry {
+ padding: var(--space-3) 0;
+ border-bottom: 1px solid var(--border-subtle);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ .change-history-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ }
+
+ .change-history-action {
+ background: var(--sub-panel-background);
+ color: var(--main-panel-font-color);
+ font-size: var(--font-size-xs);
+ }
+
+ .change-history-version,
+ .change-history-date {
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ }
+
+ .change-history-author {
+ font-weight: var(--font-weight-medium);
+ }
+
+ .change-history-comment {
+ margin-top: var(--space-2);
+ color: var(--main-panel-font-color);
+ }
+
+ .change-history-caused-by {
+ margin-top: var(--space-1);
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-sm);
+ font-style: italic;
+ }
+
+ .change-history-changes {
+ list-style: none;
+ margin: var(--space-2) 0 0;
+ padding: 0;
+
+ li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-2);
+ padding: var(--space-1) 0;
+ font-size: var(--font-size-sm);
+ }
+ }
+
+ .change-history-field {
+ font-weight: var(--font-weight-medium);
+ color: var(--main-panel-font-color);
+ }
+
+ // The old value reads as removed, the new as added, without inventing new colours.
+ .change-history-from {
+ color: var(--fail-color);
+ text-decoration: line-through;
+ }
+
+ .change-history-to {
+ color: var(--pass-color);
+ }
+
+ .change-history-arrow {
+ color: var(--modal-secondary-font-color);
+ }
+}
diff --git a/src/components/features/custom-field-form/CustomFieldForm.js b/src/components/features/custom-field-form/CustomFieldForm.js
new file mode 100644
index 0000000..4360a70
--- /dev/null
+++ b/src/components/features/custom-field-form/CustomFieldForm.js
@@ -0,0 +1,132 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Input, InputNumber, Toggle, SelectPicker, TagPicker, DatePicker } from 'rsuite';
+import { FormattedMessage } from 'react-intl';
+
+/**
+ * Renders the inputs for a team's configured custom fields.
+ *
+ * The definitions come from the API rather than being hard-coded, so an admin adding a
+ * field makes it appear here with no UI change. Archived fields are rendered only when the
+ * case already holds a value for one - they are on their way out, so they should not be
+ * offered for new input, but an existing value still has to be visible and editable.
+ */
+const CustomFieldForm = ({ definitions, values, onChange, users, disabled }) => {
+ const setValue = (key, value) => {
+ onChange({ ...(values || {}), [key]: value });
+ };
+
+ const visible = (definitions || []).filter((definition) => !definition.archived
+ || (values && values[definition.key] !== undefined && values[definition.key] !== null));
+
+ if (visible.length === 0) return null;
+
+ const renderInput = (definition) => {
+ const value = values ? values[definition.key] : undefined;
+ const common = { disabled, block: true };
+
+ switch (definition.type) {
+ case 'textarea':
+ return (
+ setValue(definition.key, next)}
+ disabled={disabled}
+ />
+ );
+ case 'number':
+ return (
+ setValue(definition.key, next === '' ? null : Number(next))}
+ disabled={disabled}
+ />
+ );
+ case 'boolean':
+ return (
+ setValue(definition.key, next)}
+ disabled={disabled}
+ />
+ );
+ case 'date':
+ return (
+ setValue(definition.key, next ? next.toISOString() : null)}
+ disabled={disabled}
+ block
+ />
+ );
+ case 'select':
+ return (
+ ({ label: option, value: option }))}
+ value={value === undefined ? null : value}
+ onChange={(next) => setValue(definition.key, next)}
+ {...common}
+ />
+ );
+ case 'multiselect':
+ return (
+ ({ label: option, value: option }))}
+ value={Array.isArray(value) ? value : []}
+ onChange={(next) => setValue(definition.key, next)}
+ {...common}
+ />
+ );
+ case 'user':
+ return (
+ ({ label: user.username, value: user._id }))}
+ value={value === undefined ? null : value}
+ onChange={(next) => setValue(definition.key, next)}
+ {...common}
+ />
+ );
+ default:
+ return (
+ setValue(definition.key, next)}
+ disabled={disabled}
+ />
+ );
+ }
+ };
+
+ return (
+
+ {visible.map((definition) => (
+
+
+ {definition.label}
+ {definition.required && * }
+ {definition.archived && (
+
+
+
+ )}
+
+
+ {renderInput(definition)}
+
+
+ ))}
+
+ );
+};
+
+CustomFieldForm.propTypes = {
+ definitions: PropTypes.array,
+ values: PropTypes.object,
+ onChange: PropTypes.func,
+ users: PropTypes.array,
+ disabled: PropTypes.bool,
+};
+
+export default CustomFieldForm;
diff --git a/src/components/features/custom-field-form/styles.less b/src/components/features/custom-field-form/styles.less
new file mode 100644
index 0000000..afd2bc5
--- /dev/null
+++ b/src/components/features/custom-field-form/styles.less
@@ -0,0 +1,33 @@
+// ── Custom field form ────────────────────────────────────────────────────────
+// Inputs generated from the team's configured field definitions.
+.custom-field-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+
+ .custom-field-row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ .custom-field-label {
+ color: var(--main-panel-font-color);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ }
+
+ .custom-field-required {
+ margin-left: var(--space-1);
+ color: var(--fail-color);
+ }
+
+ // An archived field only appears when a value already exists for it, so the marker
+ // explains why an author is seeing a field that is no longer offered.
+ .custom-field-archived {
+ margin-left: var(--space-2);
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-style: italic;
+ }
+}
diff --git a/src/components/features/folder-tree/FolderTree.js b/src/components/features/folder-tree/FolderTree.js
new file mode 100644
index 0000000..ecca807
--- /dev/null
+++ b/src/components/features/folder-tree/FolderTree.js
@@ -0,0 +1,256 @@
+import React, { useState } from 'react';
+import { Tree, IconButton, Whisper, Tooltip, Modal, Button, Form, Input, Loader } from 'rsuite';
+import FolderFillIcon from '@rsuite/icons/FolderFill';
+import PlusIcon from '@rsuite/icons/Plus';
+import EditIcon from '@rsuite/icons/Edit';
+import TrashIcon from '@rsuite/icons/Trash';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { CASE_MIME } from '../test-case-suites/dragTypes';
+
+/*
+Folder tree for manual test cases.
+
+The API already returns the tree nested with a per-folder count, so this renders what it is
+given rather than reassembling it. A synthetic "All test cases" root sits above the real
+folders: cases that have never been filed have to stay reachable, and a tree with no way
+back to everything is a trap.
+
+Selecting a folder reports its id upward; the synthetic entries report undefined (all) and
+'none' (unfiled), which is what the list endpoint expects.
+ */
+export const ALL_FOLDERS = '__all__';
+export const UNFILED = 'none';
+
+const FolderTree = ({
+ folders, unfiledCount, totalCount, value, onSelect, onCreate, onRename, onDelete, loading,
+ onDropCase,
+}) => {
+ const intl = useIntl();
+ const [dialog, setDialog] = useState(undefined);
+ const [name, setName] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [dropTarget, setDropTarget] = useState(undefined);
+
+ // Only a dragged test case is a valid drop; anything else dragged over the tree (a
+ // file, selected text) has to keep its default browser behaviour.
+ const isCaseDrag = (event) => event.dataTransfer.types.includes(CASE_MIME);
+
+ const handleDragOver = (event, key) => {
+ if (!onDropCase || !isCaseDrag(event)) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setDropTarget(key);
+ };
+
+ const handleDrop = (event, key) => {
+ if (!onDropCase || !isCaseDrag(event)) return;
+ event.preventDefault();
+ // Stop the drop bubbling to an ancestor folder row, which would otherwise file
+ // the case into the parent as well as the folder actually aimed at.
+ event.stopPropagation();
+ setDropTarget(undefined);
+ const caseId = event.dataTransfer.getData(CASE_MIME);
+ if (caseId) onDropCase(caseId, key === UNFILED ? null : key);
+ };
+
+ // Shared by every droppable row, real or synthetic.
+ const dropProps = (key) => ({
+ onDragOver: (event) => handleDragOver(event, key),
+ onDragLeave: () => setDropTarget(undefined),
+ onDrop: (event) => handleDrop(event, key),
+ });
+
+ // RSuite's Tree wants a uniform node shape, so the two synthetic rows are mapped into
+ // the same shape as a real folder rather than rendered separately.
+ const toNode = (folder) => ({
+ value: folder._id,
+ label: folder.name,
+ count: folder.testCaseCount,
+ folder,
+ children: (folder.children || []).map(toNode),
+ });
+
+ const data = [
+ {
+ value: ALL_FOLDERS,
+ label: intl.formatMessage({ id: 'app.manual.folders.all' }),
+ count: totalCount,
+ synthetic: true,
+ },
+ {
+ value: UNFILED,
+ label: intl.formatMessage({ id: 'app.manual.folders.unfiled' }),
+ count: unfiledCount,
+ synthetic: true,
+ },
+ ...(folders || []).map(toNode),
+ ];
+
+ const openDialog = (mode, folder) => {
+ setDialog({ mode, folder });
+ setName(mode === 'rename' && folder ? folder.name : '');
+ };
+
+ const closeDialog = () => {
+ setDialog(undefined);
+ setName('');
+ };
+
+ const submit = async () => {
+ if (!dialog || !name.trim()) return;
+ setBusy(true);
+ try {
+ if (dialog.mode === 'rename') {
+ await onRename(dialog.folder, name.trim());
+ } else {
+ // A null folder on "create" means a new root; otherwise it nests under the
+ // folder whose + was clicked, which is how depth is added.
+ await onCreate(dialog.folder ? dialog.folder._id : null, name.trim());
+ }
+ closeDialog();
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const renderNode = (node) => {
+ if (node.synthetic) {
+ // "All test cases" is a view, not a place - there is nothing to file into, so
+ // only the unfiled row accepts a drop (it means "take this out of its folder").
+ const droppable = node.value === UNFILED;
+ return (
+
+ {node.label}
+ {node.count}
+
+ );
+ }
+ return (
+
+
+ {node.label}
+ {node.count}
+
+ }
+ >
+ }
+ onClick={(event) => { event.stopPropagation(); openDialog('create', node.folder); }}
+ />
+
+ }
+ >
+ }
+ onClick={(event) => { event.stopPropagation(); openDialog('rename', node.folder); }}
+ />
+
+ }
+ >
+ }
+ onClick={(event) => { event.stopPropagation(); onDelete(node.folder); }}
+ />
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ }
+ >
+ }
+ onClick={() => openDialog('create', undefined)}
+ />
+
+
+
+ {loading ?
: (
+
onSelect(node.value)}
+ />
+ )}
+
+
+
+
+
+
+
+
+ {dialog && dialog.mode === 'create' && dialog.folder && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default FolderTree;
diff --git a/src/components/features/folder-tree/styles.less b/src/components/features/folder-tree/styles.less
new file mode 100644
index 0000000..0eb7805
--- /dev/null
+++ b/src/components/features/folder-tree/styles.less
@@ -0,0 +1,50 @@
+// ── Folder tree ──────────────────────────────────────────────────────────────
+// Sidebar tree for organising manual test cases.
+.folder-tree {
+ .folder-tree-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-2);
+ }
+
+ .folder-tree-node {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ }
+
+ .folder-tree-icon {
+ color: var(--sub-panel-icon-color);
+ }
+
+ .folder-tree-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .folder-tree-count {
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-xs);
+ }
+
+ // Row actions stay out of the way until the row is hovered, so a deep tree reads as
+ // structure rather than as a wall of buttons.
+ .folder-tree-actions {
+ display: none;
+ gap: var(--space-1);
+ }
+
+ .rs-tree-node:hover .folder-tree-actions {
+ display: inline-flex;
+ }
+
+ .folder-tree-parent-hint {
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-2);
+ }
+}
diff --git a/src/components/features/manual-step-editor/ManualStepEditor.js b/src/components/features/manual-step-editor/ManualStepEditor.js
new file mode 100644
index 0000000..2dc0658
--- /dev/null
+++ b/src/components/features/manual-step-editor/ManualStepEditor.js
@@ -0,0 +1,308 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Input, IconButton, Button, SelectPicker, Tag, Whisper, Tooltip } from 'rsuite';
+import TrashIcon from '@rsuite/icons/Trash';
+import PlusIcon from '@rsuite/icons/Plus';
+import AttachmentIcon from '@rsuite/icons/Attachment';
+import { FormattedMessage, useIntl } from 'react-intl';
+import AttachmentUpload from '../attachment-upload/AttachmentUpload';
+
+/**
+ * Ordered editor for a test case's steps.
+ *
+ * A step is either literal (action + expected result) or an inclusion of a shared step, in
+ * which case its own text is ignored and the shared step's steps take its place when the
+ * case is read expanded or frozen into a version. The two are rendered differently so an
+ * author can see at a glance which steps they actually control here.
+ *
+ * Action and expected sit side by side on one row: a step is a single "do this, expect
+ * that" pair, and stacking them made a ten-step case scroll for no reason. Attachments are
+ * collapsed behind a toggle per step, because most steps have none and an always-visible
+ * drop zone on every row would bury the text the author is actually writing.
+ *
+ * Reordering is drag-and-drop using the native HTML5 API rather than a library - the list
+ * is a single flat column, which is the case the native API handles well, and it avoids a
+ * dependency for one interaction.
+ */
+const ManualStepEditor = ({ steps, onChange, sharedSteps, readOnly, attachmentOwner }) => {
+ const intl = useIntl();
+ const [dragIndex, setDragIndex] = useState(undefined);
+ const [dropIndex, setDropIndex] = useState(undefined);
+ // Which rows have their attachment panel open. Kept by index rather than id because a
+ // step has no id until it is saved.
+ const [openAttachments, setOpenAttachments] = useState({});
+
+ const sharedStepOptions = (sharedSteps || []).map((sharedStep) => ({
+ label: `${sharedStep.name} (v${sharedStep.version})`,
+ value: sharedStep._id,
+ }));
+
+ const updateStep = (index, changes) => {
+ const next = steps.map((step, i) => (i === index ? { ...step, ...changes } : step));
+ onChange(next);
+ };
+
+ const addStep = () => {
+ onChange([...(steps || []), { action: '', expected: '' }]);
+ };
+
+ const addSharedStep = () => {
+ onChange([...(steps || []), { action: '', sharedStep: null }]);
+ };
+
+ const removeStep = (index) => {
+ onChange(steps.filter((step, i) => i !== index));
+ };
+
+ const toggleAttachments = (index) => {
+ setOpenAttachments((current) => ({ ...current, [index]: !current[index] }));
+ };
+
+ // Removing the dragged row and re-inserting it is what makes a drag across several
+ // positions land where the marker showed, rather than swapping two neighbours.
+ const moveStep = (from, to) => {
+ if (from === to || to === undefined || from === undefined) return;
+ const next = [...steps];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ const handleDragStart = (index) => (event) => {
+ setDragIndex(index);
+ // Firefox ignores a drag that sets no data.
+ event.dataTransfer.effectAllowed = 'move';
+ try { event.dataTransfer.setData('text/plain', String(index)); } catch (error) { /* noop */ }
+ };
+
+ const handleDragOver = (index) => (event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = (index) => (event) => {
+ event.preventDefault();
+ moveStep(dragIndex, index);
+ setDragIndex(undefined);
+ setDropIndex(undefined);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(undefined);
+ setDropIndex(undefined);
+ };
+
+ if (readOnly) {
+ return (
+
+ {(steps || []).map((step, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
+
{step.action}
+
{step.expected}
+
+ {step.data && (
+
+
+
+
+ {step.data}
+
+ )}
+ {(step.attachments || []).length > 0 && (
+
{}}
+ readOnly
+ />
+ )}
+ {/* An expanded step says where it came from, so a reader can tell
+ which content is owned by this case and which by a shared step. */}
+ {step.sharedStepRef && (
+
+
+
+ )}
+ {step.unresolvedSharedStep && (
+
+
+
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(steps || []).map((step, index) => {
+ const classes = ['manual-step'];
+ if (dragIndex === index) classes.push('manual-step-dragging');
+ if (dropIndex === index && dragIndex !== index) classes.push('manual-step-drop-target');
+ return (
+
+
+ {index + 1}
+
+
+
+ {/* `sharedStep` being present at all (even null) marks this row as an
+ inclusion, so an author can add one before choosing which. */}
+ {'sharedStep' in step ? (
+
+
+
+
+
updateStep(index, { sharedStep: value })}
+ placeholder={intl.formatMessage({ id: 'app.manual.step-editor.shared-step-placeholder' })}
+ block
+ cleanable={false}
+ />
+
+
+
+
+ ) : (
+ <>
+
+ updateStep(index, { action: value })}
+ placeholder={intl.formatMessage({ id: 'app.manual.step-editor.action-placeholder' })}
+ />
+ updateStep(index, { expected: value })}
+ placeholder={intl.formatMessage({ id: 'app.manual.step-editor.expected-placeholder' })}
+ />
+
+
updateStep(index, { data: value })}
+ placeholder={intl.formatMessage({ id: 'app.manual.step-editor.data-placeholder' })}
+ />
+ {(openAttachments[index] || (step.attachments || []).length > 0) && (
+
+ {attachmentOwner ? (
+
updateStep(index, { attachments })}
+ />
+ ) : (
+ // Uploads are keyed on the owning entity, so one
+ // that has not been saved yet has nothing to key on.
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+ {!('sharedStep' in step) && (
+
+
+
+ )}
+ >
+ }
+ onClick={() => toggleAttachments(index)}
+ aria-label={intl.formatMessage({ id: 'app.manual.step-editor.attachments' })}
+ />
+
+ )}
+ }
+ onClick={() => removeStep(index)}
+ aria-label={intl.formatMessage({ id: 'app.manual.step-editor.remove' })}
+ />
+
+
+ );
+ })}
+
+
+ {(!steps || steps.length === 0) && (
+
+
+
+ )}
+
+
+ }>
+
+
+ }>
+
+
+
+
+
+
+
+ );
+};
+
+ManualStepEditor.propTypes = {
+ steps: PropTypes.array,
+ onChange: PropTypes.func,
+ sharedSteps: PropTypes.array,
+ readOnly: PropTypes.bool,
+ attachmentOwner: PropTypes.object,
+};
+
+export default ManualStepEditor;
diff --git a/src/components/features/manual-step-editor/styles.less b/src/components/features/manual-step-editor/styles.less
new file mode 100644
index 0000000..826252b
--- /dev/null
+++ b/src/components/features/manual-step-editor/styles.less
@@ -0,0 +1,212 @@
+// ── Manual step editor ───────────────────────────────────────────────────────
+// Ordered list of a test case's steps. A step is either literal or an inclusion of a
+// shared step; the two look different so an author can see which content they own here.
+//
+// Action and expected sit side by side: a step is one "do this, expect that" pair, and
+// stacking them made a long case scroll for no reason.
+.manual-step-editor {
+ // Column headings, so the two inputs on each row are labelled once rather than carrying
+ // a label each.
+ .manual-step-heading {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: 0 var(--space-3) var(--space-1);
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ text-transform: uppercase;
+ }
+
+ .manual-step-heading-number {
+ flex: 0 0 auto;
+ min-width: var(--space-5);
+ }
+
+ .manual-step-heading-action,
+ .manual-step-heading-expected {
+ flex: 1 1 50%;
+ min-width: 0;
+ }
+
+ .manual-step-heading-controls {
+ flex: 0 0 auto;
+ width: 64px;
+ }
+
+ .manual-step-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .manual-step {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ margin-bottom: var(--space-2);
+ background: var(--sub-panel-background);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ // The whole row is the drag handle, so the cursor has to say so everywhere on it.
+ cursor: grab;
+ transition: opacity var(--transition-fast), border-color var(--transition-fast);
+ }
+
+ // The row being dragged fades so the gap it will leave is legible.
+ .manual-step-dragging {
+ opacity: 0.4;
+ cursor: grabbing;
+ }
+
+ // Where the row will land. A top border rather than a filled background: the marker has
+ // to read as a position between rows, not as another selected row.
+ .manual-step-drop-target {
+ border-top: 2px solid var(--button-background);
+ }
+
+ .manual-step-handle {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: var(--space-5);
+ padding-top: var(--space-1);
+ color: var(--modal-secondary-font-color);
+ }
+
+ .manual-step-number {
+ font-weight: var(--font-weight-medium);
+ }
+
+ .manual-step-controls {
+ flex: 0 0 auto;
+ display: flex;
+ gap: var(--space-1);
+ width: 64px;
+ justify-content: flex-end;
+ }
+
+ .manual-step-body {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+ }
+
+ // The side-by-side pair.
+ .manual-step-row {
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+ }
+
+ .manual-step-action-input,
+ .manual-step-expected-input {
+ flex: 1 1 50%;
+ min-width: 0;
+ }
+
+ .manual-step-attachments {
+ padding-top: var(--space-1);
+ }
+
+ .manual-step-label {
+ display: inline-block;
+ margin-right: var(--space-2);
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ text-transform: uppercase;
+ }
+
+ .manual-step-action {
+ color: var(--main-panel-font-color);
+ white-space: pre-wrap;
+ }
+
+ .manual-step-expected,
+ .manual-step-data {
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-sm);
+ white-space: pre-wrap;
+ }
+
+ .manual-step-shared-tag {
+ align-self: flex-start;
+ background: var(--sub-panel-background);
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ }
+
+ // An inclusion whose shared step has gone is kept rather than dropped, so it has to be
+ // visibly flagged - otherwise the case silently reads as one step short.
+ .manual-step-unresolved-tag {
+ align-self: flex-start;
+ background: var(--sub-panel-background);
+ color: var(--error-color);
+ font-size: var(--font-size-xs);
+ }
+
+ .manual-step-editor-actions {
+ display: flex;
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+ }
+}
+
+// Narrow screens have no room for two columns, so the pair stacks. The editor stays
+// usable rather than squeezing two textareas into half a phone width each.
+@media (max-width: 768px) {
+ .manual-step-editor {
+ .manual-step-row {
+ flex-direction: column;
+ }
+
+ .manual-step-heading-expected {
+ display: none;
+ }
+ }
+}
+
+.manual-step-editor-readonly {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ counter-reset: manual-step;
+
+ .manual-step {
+ padding: var(--space-3);
+ border-bottom: 1px solid var(--border-subtle);
+ counter-increment: manual-step;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &::before {
+ content: counter(manual-step);
+ display: inline-block;
+ min-width: var(--space-5);
+ color: var(--modal-secondary-font-color);
+ font-weight: var(--font-weight-medium);
+ }
+ }
+
+ // The read-only view shows the same pairing, so a frozen version reads the way the
+ // editor looked when it was written.
+ .manual-step-row {
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+ }
+
+ .manual-step-action,
+ .manual-step-expected {
+ flex: 1 1 50%;
+ min-width: 0;
+ white-space: pre-wrap;
+ }
+}
diff --git a/src/components/features/screenshot-view/baseline/BaselineImageView.js b/src/components/features/screenshot-view/baseline/BaselineImageView.js
index 0cf5a5c..97387b5 100644
--- a/src/components/features/screenshot-view/baseline/BaselineImageView.js
+++ b/src/components/features/screenshot-view/baseline/BaselineImageView.js
@@ -381,7 +381,7 @@ const BaselineImageView = (props) => {
type="warning"
message={(
-
+
)}
/>
diff --git a/src/components/features/screenshot-view/styles.less b/src/components/features/screenshot-view/styles.less
index cfc75a7..9a59969 100644
--- a/src/components/features/screenshot-view/styles.less
+++ b/src/components/features/screenshot-view/styles.less
@@ -236,7 +236,7 @@
.find-image-match-box {
position: absolute;
- border: 2px solid #ff00ff;
+ border: 2px solid var(--overlay-match-color);
z-index: 10;
pointer-events: none;
@@ -244,8 +244,8 @@
position: absolute;
top: -1.4em;
left: 0;
- background: #ff00ff;
- color: #ffffff;
+ background: var(--overlay-match-color);
+ color: var(--overlay-marker-font-color);
font-size: var(--font-size-sm, 12px);
padding: 0 var(--space-1);
white-space: nowrap;
@@ -303,8 +303,8 @@
line-height: 22px;
text-align: center;
border-radius: 50%;
- background: #ff9800;
- color: #ffffff;
+ background: var(--overlay-suggestion-color);
+ color: var(--overlay-marker-font-color);
font-weight: var(--font-weight-bold);
cursor: pointer;
z-index: 11;
diff --git a/src/components/features/test-case-suites/TestCaseSuites.js b/src/components/features/test-case-suites/TestCaseSuites.js
new file mode 100644
index 0000000..0b5faaa
--- /dev/null
+++ b/src/components/features/test-case-suites/TestCaseSuites.js
@@ -0,0 +1,254 @@
+import React, { useState } from 'react';
+import Link from 'next/link';
+import { Table, Tag, IconButton } from 'rsuite';
+import FolderFillIcon from '@rsuite/icons/FolderFill';
+import ArrowDownIcon from '@rsuite/icons/ArrowDown';
+import ArrowRightIcon from '@rsuite/icons/ArrowRight';
+import { FormattedMessage } from 'react-intl';
+import { UNFILED } from '../folder-tree/FolderTree';
+import { CASE_MIME } from './dragTypes';
+
+const { Column, HeaderCell, Cell } = Table;
+
+/*
+Test cases grouped into one table per folder, mirroring the folder tree.
+
+A single flat table cannot show where a case lives, which matters once cases are filed:
+the tree says a folder holds twelve cases, and the list beside it has to make the same
+claim visibly. Each folder that has cases on the current page becomes its own section.
+
+Grouping is done over the page the API returned rather than over the whole team. The list
+endpoint sorts by folder before recency for exactly this reason, so a page arrives as
+whole folders instead of an interleaved slice that would repeat the same heading on
+several pages.
+ */
+
+/*
+Orders sections the way the tree reads top to bottom.
+
+Sections are keyed by folder id, but the folders arrive nested, so a depth-first walk of
+the tree gives the order; anything left over (a folder the tree no longer lists) is
+appended rather than dropped, and unfiled cases sort last.
+ */
+export const orderSections = (folders, sectionKeys) => {
+ const ordered = [];
+ const walk = (nodes) => {
+ (nodes || []).forEach((node) => {
+ ordered.push(node._id.toString());
+ walk(node.children);
+ });
+ };
+ walk(folders);
+ const known = ordered.filter((id) => sectionKeys.includes(id));
+ const leftovers = sectionKeys.filter((key) => key !== UNFILED && !ordered.includes(key));
+ return [...known, ...leftovers, ...(sectionKeys.includes(UNFILED) ? [UNFILED] : [])];
+};
+
+/*
+"Checkout / Refunds" rather than just "Refunds".
+
+A section heading is read without the tree's indentation to lean on, so a bare leaf name
+is ambiguous the moment two folders share one.
+ */
+export const buildFolderLabel = (folder, byId) => {
+ const names = (folder.path || [])
+ .map((ancestorId) => byId.get(ancestorId.toString()))
+ .filter(Boolean)
+ .map((ancestor) => ancestor.name);
+ return [...names, folder.name].join(' / ');
+};
+
+/*
+How deep a section sits in the tree, so the card can be indented to match.
+
+The heading already spells the ancestry out ("Checkout / Refunds"), but reading a path
+is not the same as seeing one: indentation makes a sub-folder look subordinate at a
+glance instead of on inspection. Unfiled and unknown sections have no ancestry and sit
+at the root.
+ */
+export const folderDepth = (key, byId) => {
+ if (key === UNFILED) return 0;
+ const folder = byId.get(key);
+ return folder && folder.path ? folder.path.length : 0;
+};
+
+// Flattens the nested tree into a lookup, so a section can resolve its own ancestors.
+export const indexFolders = (folders) => {
+ const byId = new Map();
+ const walk = (nodes) => {
+ (nodes || []).forEach((node) => {
+ byId.set(node._id.toString(), node);
+ walk(node.children);
+ });
+ };
+ walk(folders);
+ return byId;
+};
+
+const TestCaseSuites = ({
+ testCases, folders, onOpen, onMoveCase, collapsedIds, onToggleCollapse,
+}) => {
+ const [dropTarget, setDropTarget] = useState(undefined);
+ const byId = indexFolders(folders);
+
+ // Group the page by folder. An unfiled case has folder null, which cannot key an
+ // object, so it gets the same sentinel the tree and the API already use.
+ const grouped = {};
+ (testCases || []).forEach((testCase) => {
+ const key = testCase.folder ? testCase.folder.toString() : UNFILED;
+ if (!grouped[key]) grouped[key] = [];
+ grouped[key].push(testCase);
+ });
+
+ const sectionKeys = orderSections(folders, Object.keys(grouped));
+
+ const labelFor = (key) => {
+ if (key === UNFILED) {
+ return ;
+ }
+ const folder = byId.get(key);
+ // A folder the tree has not loaded yet still has cases to show, so the section is
+ // rendered with a neutral heading rather than being hidden.
+ return folder ? buildFolderLabel(folder, byId) : ;
+ };
+
+ const handleDragStart = (event, testCase) => {
+ event.dataTransfer.setData(CASE_MIME, testCase._id);
+ // Some browsers only expose a drag as valid once text/plain is also set.
+ event.dataTransfer.setData('text/plain', testCase.title || testCase._id);
+ event.dataTransfer.effectAllowed = 'move';
+ };
+
+ const isCaseDrag = (event) => event.dataTransfer.types.includes(CASE_MIME);
+
+ const handleDragOver = (event, key) => {
+ if (!isCaseDrag(event)) return;
+ // Only preventDefault marks this a valid drop target.
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setDropTarget(key);
+ };
+
+ const handleDrop = (event, key) => {
+ if (!isCaseDrag(event)) return;
+ event.preventDefault();
+ setDropTarget(undefined);
+ const caseId = event.dataTransfer.getData(CASE_MIME);
+ if (!caseId) return;
+ // Dropping a case back into the folder it already sits in is a no-op, not a
+ // request the server needs to hear about.
+ const dragged = (testCases || []).find((testCase) => testCase._id === caseId);
+ const currentKey = dragged && dragged.folder ? dragged.folder.toString() : UNFILED;
+ if (currentKey === key) return;
+ onMoveCase(caseId, key === UNFILED ? null : key);
+ };
+
+ return (
+
+ {sectionKeys.map((key) => {
+ const collapsed = collapsedIds.includes(key);
+ const rows = grouped[key] || [];
+ return (
+
handleDragOver(event, key)}
+ onDragLeave={() => setDropTarget(undefined)}
+ onDrop={(event) => handleDrop(event, key)}
+ >
+
onToggleCollapse(key)}
+ role="button"
+ aria-expanded={!collapsed}
+ tabIndex={0}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') onToggleCollapse(key);
+ }}
+ >
+
:
}
+ />
+ {key !== UNFILED &&
}
+
{labelFor(key)}
+
{rows.length}
+
+
+ {!collapsed && (
+
+
onOpen(row._id)}
+ >
+
+
+
+ {(row) => (
+ handleDragStart(event, row)}
+ // The row is a link; without this the browser
+ // drags the href instead of starting a move.
+ onClick={(event) => event.stopPropagation()}
+ >
+ ⠿
+
+ {row.title}
+
+
+ )}
+ |
+
+
+
+
+ {(row) => (
+
+
+
+ )}
+ |
+
+
+
+
+ {(row) => (
+
+ )}
+ |
+
+
+
+ {(row) => `v${row.version}`} |
+
+
+
+ {(row) => (row.updatedBy ? row.updatedBy.username : '—')} |
+
+
+
+ {(row) => (row.steps ? row.steps.length : 0)} |
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default TestCaseSuites;
diff --git a/src/components/features/test-case-suites/dragTypes.js b/src/components/features/test-case-suites/dragTypes.js
new file mode 100644
index 0000000..a826279
--- /dev/null
+++ b/src/components/features/test-case-suites/dragTypes.js
@@ -0,0 +1,12 @@
+/*
+The MIME type a manual test case is dragged with.
+
+Lives on its own because both the folder tree (a drop target) and the suite list (the drag
+source and also a drop target) need it. Importing it from either component would make the
+two import each other, which is a cycle a bundler resolves by handing one of them
+`undefined` at module-evaluation time.
+
+A custom type rather than text/plain so text dragged from anywhere else - or a case dragged
+out of the browser entirely - is never mistaken for a move.
+ */
+export const CASE_MIME = 'application/x-angles-test-case';
diff --git a/src/components/features/version-badge/VersionBadge.js b/src/components/features/version-badge/VersionBadge.js
new file mode 100644
index 0000000..a6c78b9
--- /dev/null
+++ b/src/components/features/version-badge/VersionBadge.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Tag } from 'rsuite';
+import { FormattedMessage } from 'react-intl';
+
+/**
+ * Shows which version of a test case is being looked at, and how far behind the latest it
+ * is. Displayed anywhere a bound version is rendered - a run's execution view, or a
+ * historical version - because "v3" alone does not tell a QA whether what they are reading
+ * is current.
+ */
+const VersionBadge = ({ version, latestVersion, onClick }) => {
+ if (!version) return null;
+ const behind = latestVersion && latestVersion > version ? latestVersion - version : 0;
+ const isCurrent = behind === 0;
+
+ return (
+
+
+
+ {!isCurrent && (
+
+
+
+ )}
+
+
+ );
+};
+
+VersionBadge.propTypes = {
+ version: PropTypes.number,
+ latestVersion: PropTypes.number,
+ onClick: PropTypes.func,
+};
+
+export default VersionBadge;
diff --git a/src/components/features/version-badge/styles.less b/src/components/features/version-badge/styles.less
new file mode 100644
index 0000000..633092f
--- /dev/null
+++ b/src/components/features/version-badge/styles.less
@@ -0,0 +1,24 @@
+// ── Version badge ────────────────────────────────────────────────────────────
+// Marks which frozen version is on screen, and flags when it trails the latest.
+.version-badge {
+ display: inline-flex;
+ align-items: center;
+
+ .version-badge-tag {
+ background: var(--sub-panel-background);
+ color: var(--main-panel-font-color);
+ font-size: var(--font-size-xs);
+ border-radius: var(--radius-pill);
+ }
+
+ // A version behind the latest is worth noticing, so it borrows the skipped hue
+ // rather than a new colour - it is informational, not an error.
+ .version-badge-tag-behind {
+ color: var(--skipped-color);
+ }
+
+ .version-badge-behind {
+ margin-left: var(--space-1);
+ opacity: 0.85;
+ }
+}
diff --git a/src/components/features/version-diff/VersionDiff.js b/src/components/features/version-diff/VersionDiff.js
new file mode 100644
index 0000000..db8a6cf
--- /dev/null
+++ b/src/components/features/version-diff/VersionDiff.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import { Tag } from 'rsuite';
+import { FormattedMessage } from 'react-intl';
+
+/*
+ * Side-by-side comparison of two frozen test case versions.
+ *
+ * The diff is computed here rather than server-side because both versions are already
+ * loaded for display - asking the API to diff them would be a third round trip for data
+ * the page is holding. Steps are compared positionally and reported as one row per
+ * position, so an inserted step shows every later position as changed; that is honest
+ * about what the tester would have seen rather than pretending to track step identity
+ * across an edit.
+ */
+const FIELDS = ['title', 'description', 'preconditions', 'priority', 'tags'];
+
+const formatValue = (value) => {
+ if (value === undefined || value === null || value === '') return null;
+ if (Array.isArray(value)) return value.join(', ');
+ if (typeof value === 'object') return JSON.stringify(value);
+ return String(value);
+};
+
+const sameValue = (left, right) => formatValue(left) === formatValue(right);
+
+const VersionDiff = ({ left, right }) => {
+ if (!left || !right) return null;
+
+ const changedFields = FIELDS.filter((field) => !sameValue(left[field], right[field]));
+
+ const leftSteps = left.steps || [];
+ const rightSteps = right.steps || [];
+ const stepCount = Math.max(leftSteps.length, rightSteps.length);
+ const stepRows = [];
+ for (let index = 0; index < stepCount; index += 1) {
+ const leftStep = leftSteps[index];
+ const rightStep = rightSteps[index];
+ const changed = !leftStep || !rightStep
+ || !sameValue(leftStep.action, rightStep.action)
+ || !sameValue(leftStep.expected, rightStep.expected)
+ || !sameValue(leftStep.data, rightStep.data);
+ if (changed) stepRows.push({ index, leftStep, rightStep });
+ }
+
+ const leftFields = left.customFields || {};
+ const rightFields = right.customFields || {};
+ const customKeys = Array.from(new Set([
+ ...Object.keys(leftFields), ...Object.keys(rightFields),
+ ])).filter((key) => !sameValue(leftFields[key], rightFields[key]));
+
+ const unchanged = changedFields.length === 0
+ && stepRows.length === 0 && customKeys.length === 0;
+
+ if (unchanged) {
+ return (
+
+
+
+ );
+ }
+
+ const renderCell = (value) => {
+ const formatted = formatValue(value);
+ if (formatted === null) {
+ return (
+
+
+
+ );
+ }
+ return formatted;
+ };
+
+ return (
+
+
+
+ →
+
+
+
+
+
+
+
+
+
+
+
+
+ {changedFields.map((field) => (
+
+
+ {renderCell(left[field])}
+ {renderCell(right[field])}
+
+ ))}
+ {customKeys.map((key) => (
+
+ {key}
+ {renderCell(leftFields[key])}
+ {renderCell(rightFields[key])}
+
+ ))}
+ {stepRows.map(({ index, leftStep, rightStep }) => (
+
+
+
+
+
+ {leftStep ? (
+ <>
+ {renderCell(leftStep.action)}
+ {renderCell(leftStep.expected)}
+ >
+ ) : renderCell(null)}
+
+
+ {rightStep ? (
+ <>
+ {renderCell(rightStep.action)}
+ {renderCell(rightStep.expected)}
+ >
+ ) : renderCell(null)}
+
+
+ ))}
+
+
+
+ );
+};
+
+export default VersionDiff;
diff --git a/src/components/features/version-diff/styles.less b/src/components/features/version-diff/styles.less
new file mode 100644
index 0000000..e5110ad
--- /dev/null
+++ b/src/components/features/version-diff/styles.less
@@ -0,0 +1,54 @@
+// ── Version diff ─────────────────────────────────────────────────────────────
+// Side-by-side comparison of two frozen test case versions.
+.version-diff {
+ .version-diff-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+ }
+
+ .version-diff-arrow {
+ color: var(--main-panel-secondary-font-color);
+ }
+
+ .version-diff-table {
+ width: 100%;
+ border-collapse: collapse;
+
+ th,
+ td {
+ text-align: left;
+ padding: var(--space-2);
+ border-bottom: 1px solid var(--border-subtle);
+ vertical-align: top;
+ font-size: var(--font-size-sm);
+ }
+
+ th {
+ color: var(--main-panel-secondary-font-color);
+ font-weight: var(--font-weight-medium);
+ }
+ }
+
+ // The two sides borrow the fail/pass hues to read as removed/added without
+ // introducing a diff-specific palette.
+ .version-diff-before {
+ color: var(--fail-color);
+ }
+
+ .version-diff-after {
+ color: var(--pass-color);
+ }
+
+ .version-diff-expected {
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-xs);
+ margin-top: var(--space-1);
+ }
+
+ .version-diff-empty {
+ color: var(--main-panel-secondary-font-color);
+ font-style: italic;
+ }
+}
diff --git a/src/components/layout/Shell.js b/src/components/layout/Shell.js
index 274fb1c..868ab78 100644
--- a/src/components/layout/Shell.js
+++ b/src/components/layout/Shell.js
@@ -29,6 +29,7 @@ import AngleRightIcon from '@rsuite/icons/legacy/AngleRight';
import Image from '@rsuite/icons/Image';
import BarChart from '@rsuite/icons/BarChart';
import DocPass from '@rsuite/icons/DocPass';
+import TaskIcon from '@rsuite/icons/Task';
import InfoOutline from '@rsuite/icons/InfoOutline';
import GlobalIcon from '@rsuite/icons/Global';
import AdminIcon from '@rsuite/icons/Admin';
@@ -38,6 +39,9 @@ import ExitIcon from '@rsuite/icons/Exit';
import { CgDarkMode } from 'react-icons/cg';
import translations from '../../translations/translations.json';
+import BrandLogo from '../common/BrandLogo';
+import ThemeSwatch from '../common/ThemeSwatch';
+import { applyTheme, getThemesByPolarity } from '../../utility/Themes';
import { storeCurrentTeam, storeTeams, storeTeamsError } from '../../redux/teamActions';
import { storeEnvironments } from '../../redux/environmentActions';
import { clearCurrentErrorMessage, clearCurrentInfoMessage, clearCurrentLoaderMessage } from '../../redux/notificationActions';
@@ -59,7 +63,7 @@ const Shell = function (props) {
const teamRequests = new TeamRequests(axios);
const environmentRequests = new EnvironmentRequests(axios);
const [expand, setExpand] = useState(true);
- const { user, logout, isLoading } = useAuth();
+ const { user, logout, isLoading, manualTestingEnabled } = useAuth();
const intl = useIntl();
const {
@@ -156,6 +160,15 @@ const Shell = function (props) {
if (teamId) {
if (!currentTeam || teamId !== currentTeam._id) {
changeCurrentTeam(teamId);
+ } else {
+ // The param has been applied, so drop it. It is a one-shot deep
+ // link, not a source of truth: left in place it re-asserts itself
+ // on every render of this effect, so picking a different team in
+ // the header would be immediately reverted to the URL's team.
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('teamId');
+ const query = params.toString();
+ router.replace(query ? `${pathname}?${query}` : pathname);
}
} else if (Cookies.get('teamId')) {
if (!currentTeam || Cookies.get('teamId') !== currentTeam._id) {
@@ -164,17 +177,16 @@ const Shell = function (props) {
} else if (teams && teams.length > 0) {
changeCurrentTeam(teams[0]._id);
}
- }, [teams, currentTeam, searchParams]);
+ }, [teams, currentTeam, searchParams, pathname, router]);
const setLanguage = (languageCode) => {
Cookies.set('language', languageCode);
window.location.reload(); // Force reload to apply language change
};
- const setTheme = (theme) => {
- const rootElement = document.documentElement;
- rootElement.setAttribute('data-theme', theme);
- Cookies.set('theme', theme);
+ const setTheme = (themeId) => {
+ applyTheme(themeId);
+ Cookies.set('theme', themeId);
};
const toggleMenu = () => {
@@ -198,8 +210,7 @@ const Shell = function (props) {
-
-
+
@@ -219,6 +230,19 @@ const Shell = function (props) {
/>
+ {manualTestingEnabled && (
+ } title={ }>
+
+
+
+
+
+
+
+
+
+
+ )}
} href="/screenshot-library">
} title={ }>
+
)}
} href="/about">
@@ -266,12 +291,77 @@ const Shell = function (props) {
+ {/* Team is global state (a single `currentTeam` in Redux, persisted
+ to a cookie by `changeCurrentTeam`), so it belongs with the other
+ global controls rather than repeated as a per-page filter. Pages
+ that are team-scoped read it straight from the store. */}
+ {user && teams && teams.length > 0 && (
+ }
+ title={(
+ // Wrapped rather than passed as a bare string: a text
+ // node cannot be ellipsised inside the toggle's flex
+ // row, so a long team name would clip mid-glyph.
+
+ {/* The team name alone does not say what the menu
+ selects, so the label is always shown and the
+ name reads as its current value. */}
+
+
+
+
+ {currentTeam
+ ? currentTeam.name
+ : intl.formatMessage({ id: 'nav.team.none' })}
+
+
+ )}
+ >
+ {teams.map((team) => (
+ changeCurrentTeam(team._id)}
+ >
+ {team.name}
+
+ ))}
+
+ )}
} title={ }>
{translations.map((translation, index) => ( setLanguage(translation.code)}>{translation.text} ))}
} title={ }>
- setTheme('light')}>
- setTheme('dark')}>
+ {/* Grouped by polarity so the list stays scannable as themes
+ are added; the headings are labels, not selectable items. */}
+ {['light', 'dark'].map((polarity) => (
+
+
+
+
+ {getThemesByPolarity(polarity).map((theme) => (
+ setTheme(theme.id)}
+ >
+ {/* Swatch first so it forms a fixed-width
+ leading column: down a twelve-row list a
+ straight edge of chips scans far faster
+ than chips ragged behind names of very
+ different lengths. */}
+
+
+
+
+
+ ))}
+
+ ))}
{user && (
} title={user.username || intl.formatMessage({ id: 'nav.profile' })}>
diff --git a/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js
new file mode 100644
index 0000000..8bf2382
--- /dev/null
+++ b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js
@@ -0,0 +1,366 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import { connect } from 'react-redux';
+import {
+ Table, Button, IconButton, Whisper, Tooltip, Loader, Message, useToaster, Modal, Form,
+ Input, SelectPicker, TagInput, Toggle, InputNumber, Tag, Panel,
+} from 'rsuite';
+import TableColumnIcon from '@rsuite/icons/TableColumn';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { CustomFieldRequests, CustomFieldTypes, CustomFieldScopes } from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import ConfirmModal from '../../common/ConfirmModal';
+
+const { Column, HeaderCell, Cell } = Table;
+
+// Only these two need an options list; the rest take free input.
+const OPTION_BACKED_TYPES = [CustomFieldTypes.SELECT, CustomFieldTypes.MULTISELECT];
+
+// Mirrors the API's key validation - lowercase letters, numbers and underscores,
+// must start with a lowercase letter, max 40 characters.
+const KEY_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
+
+const emptyDefinition = {
+ key: '', label: '', type: CustomFieldTypes.TEXT, options: [],
+ required: false, appliesTo: CustomFieldScopes.TESTCASE, order: 0,
+};
+
+const AdminCustomFieldsPage = function (props) {
+ const { currentTeam } = props;
+ const intl = useIntl();
+ const toaster = useToaster();
+
+ const [definitions, setDefinitions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [editing, setEditing] = useState(undefined);
+ const [saving, setSaving] = useState(false);
+ const [deleteState, setDeleteState] = useState({ open: false, definition: undefined });
+
+ const customFieldRequests = new CustomFieldRequests(axios);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadDefinitions = useCallback(async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setLoading(true);
+ try {
+ // Archived fields are included so an admin can see what is still rendering on
+ // existing cases; they are visually separated in the table.
+ const response = await customFieldRequests.getCustomFields(currentTeam._id, true);
+ setDefinitions(response.customFields || []);
+ } catch (error) {
+ pushError(error, 'page.admin-custom-fields.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [currentTeam]);
+
+ useEffect(() => { loadDefinitions(); }, [loadDefinitions]);
+
+ const typeOptions = Object.values(CustomFieldTypes).map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.custom-field.type.${value}` }),
+ value,
+ }));
+ const scopeOptions = Object.values(CustomFieldScopes).map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.custom-field.scope.${value}` }),
+ value,
+ }));
+
+ const keyInvalid = !!editing && !editing._id && !KEY_PATTERN.test(editing.key || '');
+
+ const handleSave = async () => {
+ if (!editing || keyInvalid) return;
+ setSaving(true);
+ try {
+ const payload = {
+ key: editing.key,
+ label: editing.label,
+ type: editing.type,
+ options: OPTION_BACKED_TYPES.includes(editing.type) ? editing.options : [],
+ required: !!editing.required,
+ appliesTo: editing.appliesTo,
+ order: Number(editing.order) || 0,
+ };
+ if (editing._id) {
+ // key is the stable storage key values are held against, so it is not
+ // editable after creation - changing it would orphan every stored value.
+ const { key, ...updatable } = payload;
+ await customFieldRequests.updateCustomField(editing._id, updatable);
+ } else {
+ await customFieldRequests.createCustomField({
+ ...payload, team: currentTeam._id,
+ });
+ }
+ toaster.push(
+
+ {intl.formatMessage({
+ id: editing._id
+ ? 'page.admin-custom-fields.toast.updated'
+ : 'page.admin-custom-fields.toast.created',
+ })}
+ ,
+ { placement: 'topEnd' },
+ );
+ setEditing(undefined);
+ loadDefinitions();
+ } catch (error) {
+ pushError(error, 'page.admin-custom-fields.toast.save-error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ const { definition } = deleteState;
+ setDeleteState({ open: false, definition: undefined });
+ if (!definition) return;
+ try {
+ const response = await customFieldRequests.deleteCustomField(definition._id);
+ toaster.push(
+
+
+ ,
+ { placement: 'topEnd' },
+ );
+ loadDefinitions();
+ } catch (error) {
+ pushError(error, 'page.admin-custom-fields.toast.delete-error');
+ }
+ };
+
+ if (!currentTeam || !currentTeam._id) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={() => setEditing({ ...emptyDefinition })}
+ aria-label={intl.formatMessage({ id: 'page.admin-custom-fields.button.add' })}
+ />
+
+
+
+
+
+
+ {loading ? : (
+
+
+
+
+ {(row) => (
+
+ {row.label}
+ {row.archived && (
+
+
+
+ )}
+
+ )}
+ |
+
+
+
+ |
+
+
+
+
+ {(row) => }
+ |
+
+
+
+
+ {(row) => (
+
+ )}
+ |
+
+
+
+
+ {(row) => }
+ |
+
+
+
+
+ {(row) => (
+
+ setEditing({ ...row })}>
+
+
+ {!row.archived && (
+ <>
+ |
+ setDeleteState({ open: true, definition: row })}
+ >
+
+
+ >
+ )}
+
+ )}
+ |
+
+
+ )}
+
+
+
setEditing(undefined)}>
+
+
+
+
+
+
+ {editing && (
+
+
+ setEditing({ ...editing, label: value })}
+ />
+
+
+
+ setEditing({
+ ...editing,
+ key: value.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 40),
+ })}
+ />
+ {keyInvalid ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ setEditing({ ...editing, type: value })}
+ />
+
+ {OPTION_BACKED_TYPES.includes(editing.type) && (
+
+
+ setEditing({ ...editing, options: value })}
+ />
+
+ )}
+
+
+ setEditing({ ...editing, appliesTo: value })}
+ />
+
+
+
+ setEditing({ ...editing, order: value })}
+ />
+
+
+
+ setEditing({ ...editing, required: value })}
+ />
+
+
+
+
+
+ )}
+
+
+ setEditing(undefined)}>
+
+
+
+
+
+
+
+
+
setDeleteState({ open: false, definition: undefined })}
+ />
+
+ );
+};
+
+const mapStateToProps = (state) => ({ currentTeam: state.teamsReducer.currentTeam });
+
+export default connect(mapStateToProps)(AdminCustomFieldsPage);
diff --git a/src/components/pages/admin-custom-fields/styles.less b/src/components/pages/admin-custom-fields/styles.less
new file mode 100644
index 0000000..7e1a341
--- /dev/null
+++ b/src/components/pages/admin-custom-fields/styles.less
@@ -0,0 +1,8 @@
+// ── Admin: custom fields ─────────────────────────────────────────────────────
+.admin-custom-fields-page {
+ .admin-custom-fields-archived {
+ margin-left: var(--space-2);
+ font-size: var(--font-size-xs);
+ color: var(--skipped-color);
+ }
+}
diff --git a/src/components/pages/admin-settings/AdminSettingsPage.js b/src/components/pages/admin-settings/AdminSettingsPage.js
index 286eaa7..cbab697 100644
--- a/src/components/pages/admin-settings/AdminSettingsPage.js
+++ b/src/components/pages/admin-settings/AdminSettingsPage.js
@@ -40,6 +40,10 @@ export default function AdminSettingsPage() {
const intl = useIntl();
const [localAuthEnabled, setLocalAuthEnabled] = useState(true);
+ // Feature toggles live in their own settings document and save independently of
+ // the auth settings, so turning a feature off never rewrites the provider list.
+ const [manualTestingEnabled, setManualTestingEnabled] = useState(true);
+ const [savingFeatures, setSavingFeatures] = useState(false);
const [providers, setProviders] = useState([]);
// Per-provider configuration outcome returned by a save: a provider can be enabled
// but fail to build its strategy (unreachable issuer, unparseable certificate).
@@ -68,9 +72,15 @@ export default function AdminSettingsPage() {
const fetchConfig = async () => {
setLoading(true);
try {
- const response = await axios.get('/settings/auth');
- if (response.data) {
- applyResponse(response.data);
+ const [authResponse, featureResponse] = await Promise.all([
+ axios.get('/settings/auth'),
+ axios.get('/settings/features'),
+ ]);
+ if (authResponse.data) {
+ applyResponse(authResponse.data);
+ }
+ if (featureResponse.data) {
+ setManualTestingEnabled(featureResponse.data.manualTestingEnabled !== false);
}
} catch (error) {
toaster.push({intl.formatMessage({ id: 'page.admin.settings.toast.fetch-error' })} , { placement: 'topEnd' });
@@ -112,6 +122,30 @@ export default function AdminSettingsPage() {
const handleSaveLocalAuth = () => persist(localAuthEnabled, providers);
+ // Disabling a feature hides its navigation and makes its API routes respond 404. The
+ // data is left untouched, so re-enabling restores everything exactly as it was.
+ const handleSaveFeatures = async () => {
+ setSavingFeatures(true);
+ try {
+ const response = await axios.put('/settings/features', { manualTestingEnabled });
+ if (response.data) {
+ setManualTestingEnabled(response.data.manualTestingEnabled !== false);
+ }
+ toaster.push({intl.formatMessage({ id: 'page.admin.settings.toast.save-success' })} , { placement: 'topEnd' });
+ } catch (error) {
+ const detail = error.response?.data?.errors?.[0]?.msg
+ || error.response?.data?.error;
+ toaster.push(
+
+ {detail || intl.formatMessage({ id: 'page.admin.settings.toast.save-error' })}
+ ,
+ { placement: 'topEnd' },
+ );
+ } finally {
+ setSavingFeatures(false);
+ }
+ };
+
const handleSaveProvider = async (provider) => {
const exists = providers.some((candidate) => candidate.id === provider.id);
const nextProviders = exists
@@ -168,6 +202,37 @@ export default function AdminSettingsPage() {
return (
+ }
+ bordered
+ className="page-panel"
+ >
+ {loading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
}
bordered
diff --git a/src/components/pages/admin-users/AdminUsersPage.js b/src/components/pages/admin-users/AdminUsersPage.js
index 744b184..b953552 100644
--- a/src/components/pages/admin-users/AdminUsersPage.js
+++ b/src/components/pages/admin-users/AdminUsersPage.js
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
-import { Container, Content, Panel, Table, Button, Modal, Form, ButtonToolbar, Message, useToaster, SelectPicker, TagPicker } from 'rsuite';
+import { Container, Content, Panel, Table, Button, IconButton, Whisper, Tooltip, Modal, Form, Message, useToaster, SelectPicker, TagPicker } from 'rsuite';
+import MemberIcon from '@rsuite/icons/Member';
import { useAuth } from '../../../context/AuthContext';
import { useRouter } from 'next/navigation';
import { FormattedMessage, useIntl } from 'react-intl';
@@ -142,59 +143,72 @@ export default function AdminUsersPage() {
}
+ header={(
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={() => handleOpenModal()}
+ aria-label={intl.formatMessage({ id: 'page.admin.users.button.add-user' })}
+ />
+
+
+ )}
bordered
className="page-panel"
>
-
- handleOpenModal()}>
-
-
-
-
-
+
|
-
+
|
-
+
{rowData => intl.formatMessage({ id: 'page.admin.users.table.teams-assigned' }, { count: rowData.teams ? rowData.teams.length : 0 })}
|
-
+
{rowData => (
- handleOpenModal(rowData)}
>
-
+
|
- handleDeleteUser(rowData._id)}
>
-
+
)}
|
diff --git a/src/components/pages/dashboard/DashboardPage.js b/src/components/pages/dashboard/DashboardPage.js
index c8cdf6c..32ece2a 100644
--- a/src/components/pages/dashboard/DashboardPage.js
+++ b/src/components/pages/dashboard/DashboardPage.js
@@ -35,14 +35,13 @@ import update from 'immutability-helper';
import queryString from 'query-string';
import { connect } from 'react-redux';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
-import Cookies from 'js-cookie';
import { useAuth } from '../../../context/AuthContext';
-import { storeCurrentTeam } from '../../../redux/teamActions';
import BuildsTable from './BuildsTable';
import { getDateRangesPicker, getDurationAsString } from '../../../utility/TimeUtilities';
import ExecutionBarChart from './charts/ExecutionBarChart';
import BuildExecutionPieChart from './charts/BuildExecutionPieChart';
import ConfirmModal from '../../common/ConfirmModal';
+import { ALL_EXECUTION_TYPES, getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities';
const generateFilterMenuData = function (environments, components) {
const data = [];
@@ -120,7 +119,7 @@ const DashboardPage = function (props) {
const { user } = useAuth();
const intl = useIntl();
const toaster = useToaster();
- const { currentTeam, teams, environments, saveCurrentTeam, builds: reduxBuilds } = props;
+ const { currentTeam, environments, builds: reduxBuilds } = props;
// query values
// searchParams.get('startDate') ...
@@ -138,7 +137,8 @@ const DashboardPage = function (props) {
const [startDate, setStartDate] = useState(queryStartDate ? moment(queryStartDate) : moment().subtract(90, 'days'));
const [endDate, setEndDate] = useState(queryEndDate ? moment(queryEndDate) : moment());
const { afterToday } = DateRangePicker;
- const [selectedTeamId, setSelectedTeamId] = useState(undefined);
+ // undefined = both types, which is what every pre-3.0 dashboard showed.
+ const [executionType, setExecutionType] = useState(undefined);
// filtering values
const [selectedBuilds, setSelectedBuilds] = useState({});
@@ -154,6 +154,7 @@ const DashboardPage = function (props) {
const limitValues = [10, 15, 25, 50].map(
(item) => ({ label: item, value: item }),
);
+ const executionTypeValues = getExecutionTypeOptions(intl);
const addIndexToBuilds = (buildsToIndex, skip) => {
buildsToIndex.forEach((build, index) => {
build.index = index + skip + 1;
@@ -174,6 +175,7 @@ const DashboardPage = function (props) {
limit,
startDate,
endDate,
+ executionType,
)
.then(({
builds: retrievedBuilds,
@@ -198,13 +200,7 @@ const DashboardPage = function (props) {
);
}
}, [currentTeam, limit, filteredEnvironments,
- filteredComponents, startDate, endDate, activePage, searchParams]);
-
- useEffect(() => {
- if (currentTeam) {
- setSelectedTeamId(currentTeam._id);
- }
- }, [currentTeam]);
+ filteredComponents, startDate, endDate, activePage, searchParams, executionType]);
useEffect(() => {
setFilteredValues([]);
@@ -217,7 +213,7 @@ const DashboardPage = function (props) {
useEffect(() => {
setActivePage(1);
}, [filteredEnvironments, filteredComponents, currentTeam,
- startDate, endDate, limit]);
+ startDate, endDate, limit, executionType]);
const toggleSelectedBuild = (build) => {
const updatedBuilds = update(
@@ -286,20 +282,6 @@ const DashboardPage = function (props) {
setSelectedBuilds({});
};
- const getTeam = (teamId) => {
- if (teams && Array.isArray(teams)) {
- return teams.find((team) => team._id === teamId);
- }
- return undefined;
- };
-
- const handleTeamChange = (teamId) => {
- if (teamId !== undefined) {
- saveCurrentTeam(getTeam(teamId));
- Cookies.set('teamId', teamId, { expires: 365 });
- }
- };
-
const handleLimitChange = (newLimit) => {
if (newLimit) {
setLimit(newLimit);
@@ -307,6 +289,11 @@ const DashboardPage = function (props) {
}
};
+ const handleExecutionTypeChange = (value) => {
+ setExecutionType(toExecutionTypeParam(value));
+ setActivePage(1);
+ };
+
// eslint-disable-next-line no-shadow
const renderIconButton = (props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
@@ -416,22 +403,6 @@ const DashboardPage = function (props) {
top={20}
>
-
- )}
- cleanable={false}
- appearance="subtle"
- data={teams.map((team) => ({ label: team.name, value: team._id }))}
- value={selectedTeamId}
- onChange={(value) => {
- if (value) {
- handleTeamChange(value);
- }
- }}
- />
+
+ )}
+ data={executionTypeValues}
+ appearance="default"
+ cleanable={false}
+ style={{ width: 200 }}
+ value={executionType === undefined ? ALL_EXECUTION_TYPES : executionType}
+ searchable={false}
+ onChange={handleExecutionTypeChange}
+ />
({
currentTeam: state.teamsReducer.currentTeam,
- teams: state.teamsReducer.teams,
environments: state.environmentsReducer.environments,
builds: state.buildReducer.builds,
});
-const mapDispatchToProps = (dispatch) => ({
- saveCurrentTeam: (selectedTeam) => dispatch(storeCurrentTeam(selectedTeam)),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage);
+export default connect(mapStateToProps)(DashboardPage);
diff --git a/src/components/pages/login/LoginPage.js b/src/components/pages/login/LoginPage.js
index d0e4f1e..0c8b692 100644
--- a/src/components/pages/login/LoginPage.js
+++ b/src/components/pages/login/LoginPage.js
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '../../../context/AuthContext';
+import BrandLogo from '../../common/BrandLogo';
import { Panel, Form, ButtonToolbar, Button, Message, SelectPicker } from 'rsuite';
import { FormattedMessage, useIntl } from 'react-intl';
@@ -111,8 +112,7 @@ export default function LoginPage() {
-
-
+
} bordered className="login-panel">
{error && {error} }
diff --git a/src/components/pages/login/styles.less b/src/components/pages/login/styles.less
index 9858812..dd0fd67 100644
--- a/src/components/pages/login/styles.less
+++ b/src/components/pages/login/styles.less
@@ -17,8 +17,11 @@
// Brand logo classes are shared with the sidebar (components.less).
.login-header {
- text-align: center;
- height: 60px;
+ // Centres the inline-flex brand mark. The old fixed 60px height was
+ // sized for the PNG pair and clipped the taller SVG wordmark.
+ display: flex;
+ justify-content: center;
+ padding: var(--space-2) 0;
}
.rs-input {
diff --git a/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js b/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js
new file mode 100644
index 0000000..893ead1
--- /dev/null
+++ b/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js
@@ -0,0 +1,181 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import Link from 'next/link';
+import { Loader, Message, useToaster, SelectPicker, Panel, Tag } from 'rsuite';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { ManualTestCaseRequests } from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import ManualStepEditor from '../../features/manual-step-editor/ManualStepEditor';
+import CustomFieldForm from '../../features/custom-field-form/CustomFieldForm';
+import VersionBadge from '../../features/version-badge/VersionBadge';
+import VersionDiff from '../../features/version-diff/VersionDiff';
+
+/*
+ * Read-only view of one frozen version, with an optional diff against another.
+ *
+ * Everything here renders from the version document rather than the live case: that is the
+ * whole point of the frozen collection, and re-reading the head would defeat it. The step
+ * editor and custom field form are reused in readOnly mode so a version renders exactly the
+ * way the case does, including the field labels that were in force at the time.
+ */
+const ManualTestCaseVersionPage = function ({ caseId, version }) {
+ const intl = useIntl();
+ const toaster = useToaster();
+
+ const [frozen, setFrozen] = useState(undefined);
+ const [versions, setVersions] = useState([]);
+ const [compareTo, setCompareTo] = useState(undefined);
+ const [compareVersion, setCompareVersion] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+
+ const manualTestCaseRequests = new ManualTestCaseRequests(axios);
+ const versionNumber = Number(version);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [frozenResponse, versionsResponse] = await Promise.all([
+ manualTestCaseRequests.getVersion(caseId, versionNumber),
+ manualTestCaseRequests.getVersions(caseId),
+ ]);
+ setFrozen(frozenResponse);
+ setVersions(versionsResponse.versions || []);
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-version.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [caseId, versionNumber]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleCompare = async (value) => {
+ setCompareTo(value);
+ if (!value) {
+ setCompareVersion(undefined);
+ return;
+ }
+ try {
+ const other = await manualTestCaseRequests.getVersion(caseId, value);
+ setCompareVersion(other);
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-version.toast.compare-error');
+ setCompareVersion(undefined);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!frozen) {
+ return (
+
+
+
+ );
+ }
+
+ const latestVersion = versions.length > 0
+ ? Math.max(...versions.map((entry) => entry.version)) : versionNumber;
+
+ const compareOptions = versions
+ .filter((entry) => entry.version !== versionNumber)
+ .map((entry) => ({
+ label: intl.formatMessage(
+ { id: 'page.manual-test-case-detail.version-option' },
+ { version: entry.version },
+ ),
+ value: entry.version,
+ }));
+
+ // The diff always reads oldest-to-newest regardless of which side was picked, so
+ // "before" and "after" mean what they say.
+ const [diffLeft, diffRight] = compareVersion && compareVersion.version < frozen.version
+ ? [compareVersion, frozen] : [frozen, compareVersion];
+
+ return (
+
+
+ {frozen.title}
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ data={compareOptions}
+ value={compareTo}
+ searchable={false}
+ style={{ width: 260 }}
+ onChange={handleCompare}
+ />
+
+
+ {compareVersion && (
+
+
+
+ )}
+
+
+
}>
+
+
+ {frozen.description || '-'}
+
+
+
+ {frozen.preconditions || '-'}
+
+
+
+
+ {frozen.priority
+ ?
+ : '-'}
+
+
+
+
+
+ {(frozen.tags || []).map((tag) => {tag} )}
+
+
+
{}}
+ disabled
+ />
+
+
+ }>
+ {}} readOnly />
+
+
+ );
+};
+
+export default ManualTestCaseVersionPage;
diff --git a/src/components/pages/manual-test-case-version/styles.less b/src/components/pages/manual-test-case-version/styles.less
new file mode 100644
index 0000000..ac57551
--- /dev/null
+++ b/src/components/pages/manual-test-case-version/styles.less
@@ -0,0 +1,6 @@
+// ── Manual test case: frozen version view ────────────────────────────────────
+.manual-test-case-version-page {
+ .detail-row {
+ margin-bottom: var(--space-2);
+ }
+}
diff --git a/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js
new file mode 100644
index 0000000..901869e
--- /dev/null
+++ b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js
@@ -0,0 +1,527 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import { connect } from 'react-redux';
+import { useRouter } from 'next/navigation';
+import {
+ Input, SelectPicker, TagInput, IconButton, Whisper, Tooltip, Loader, Nav, Message,
+ useToaster, Panel,
+} from 'rsuite';
+import CloneIcon from '@rsuite/icons/Copy';
+import TrashIcon from '@rsuite/icons/Trash';
+import SaveIcon from '@rsuite/icons/Save';
+import { FormattedMessage, useIntl } from 'react-intl';
+import {
+ ManualTestCaseRequests,
+ SharedStepRequests,
+ CustomFieldRequests,
+ ManualFolderRequests,
+} from 'angles-javascript-client';
+import ManualStepEditor from '../../features/manual-step-editor/ManualStepEditor';
+import CustomFieldForm from '../../features/custom-field-form/CustomFieldForm';
+import ChangeHistory from '../../features/change-history/ChangeHistory';
+import VersionBadge from '../../features/version-badge/VersionBadge';
+import ConfirmModal from '../../common/ConfirmModal';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+
+const STATUS_VALUES = ['DRAFT', 'ACTIVE', 'DEPRECATED'];
+const PRIORITY_VALUES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
+
+function ManualTestCaseDetailPage(props) {
+ const { caseId, currentTeam } = props;
+ const router = useRouter();
+ const toaster = useToaster();
+ const intl = useIntl();
+
+ const [testCase, setTestCase] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [sharedSteps, setSharedSteps] = useState([]);
+ const [fieldDefinitions, setFieldDefinitions] = useState([]);
+ const [versions, setVersions] = useState([]);
+ const [folderOptions, setFolderOptions] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [selectedVersion, setSelectedVersion] = useState(null);
+ const [activeTab, setActiveTab] = useState('details');
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ // Inline save feedback. A toast alone is easy to miss - it is a small transient corner
+ // popup - and it cannot say whether the save burned a version, which is the thing an
+ // author most needs to know about a manual test case.
+ const [saveResult, setSaveResult] = useState(undefined);
+ const [historyLoading, setHistoryLoading] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ const manualTestCaseRequests = new ManualTestCaseRequests(axios);
+ const sharedStepRequests = new SharedStepRequests(axios);
+ const customFieldRequests = new CustomFieldRequests(axios);
+
+ const statusOptions = STATUS_VALUES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.status.${value.toLowerCase()}` }),
+ value,
+ }));
+ const priorityOptions = PRIORITY_VALUES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.priority.${value.toLowerCase()}` }),
+ value,
+ }));
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadTestCase = useCallback(async () => {
+ setLoading(true);
+ try {
+ const found = await manualTestCaseRequests.getTestCase(caseId);
+ setTestCase(found);
+ setDraft({
+ title: found.title,
+ description: found.description || '',
+ preconditions: found.preconditions || '',
+ status: found.status,
+ priority: found.priority,
+ tags: found.tags || [],
+ folder: found.folder || null,
+ steps: found.steps || [],
+ customFields: found.customFields || {},
+ });
+ const teamId = found.team && found.team._id ? found.team._id : found.team;
+ const manualFolderRequests = new ManualFolderRequests(axios);
+ const [sharedStepResponse, customFieldResponse, versionResponse, folderResponse] = await Promise.all([
+ sharedStepRequests.getSharedSteps(teamId),
+ customFieldRequests.getCustomFields(teamId),
+ manualTestCaseRequests.getVersions(caseId),
+ manualFolderRequests.getFolders(teamId),
+ ]);
+ setSharedSteps(sharedStepResponse.sharedSteps || []);
+ setFieldDefinitions(customFieldResponse.customFields || []);
+ setVersions(versionResponse.versions || []);
+ setFolderOptions(flattenFolders(folderResponse.folders));
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-detail.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [caseId]);
+
+ useEffect(() => {
+ loadTestCase();
+ }, [loadTestCase]);
+
+ // History is fetched only when its tab is opened - it is paginated and unbounded, and
+ // most visits to this page are to read or edit the case, not to audit it.
+ useEffect(() => {
+ if (activeTab !== 'history' || history.length > 0) return;
+ const loadHistory = async () => {
+ setHistoryLoading(true);
+ try {
+ const response = await manualTestCaseRequests.getHistory(caseId, 50);
+ setHistory(response.history || []);
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-detail.toast.history-error');
+ } finally {
+ setHistoryLoading(false);
+ }
+ };
+ loadHistory();
+ }, [activeTab, caseId]);
+
+ // The editor holds whole attachment documents so it can render thumbnails, but the API
+ // stores references - sending the objects makes mongoose try to cast one to an ObjectId
+ // and fail. Ids are what goes on the wire.
+ const toAttachmentIds = (attachments) => (attachments || [])
+ .map((attachment) => (attachment && attachment._id ? attachment._id : attachment))
+ .filter(Boolean);
+
+ // A shared step inclusion has no action of its own - the shared step's contents are
+ // expanded in its place - so the empty string the editor holds for the disabled input
+ // is stripped rather than sent as content.
+ const toPayload = (current) => ({
+ ...current,
+ steps: (current.steps || []).map((step) => {
+ const normalised = { ...step, attachments: toAttachmentIds(step.attachments) };
+ if (!('sharedStep' in normalised)) return normalised;
+ const { action, expected, ...rest } = normalised;
+ return action ? { ...rest, action, expected } : rest;
+ }),
+ });
+
+ // The picker is a flat list with indentation rather than a nested control: a case
+ // belongs to exactly one folder, and a full tree widget for a single choice is more
+ // interaction than the decision needs.
+ const flattenFolders = (nodes, depth = 0) => (nodes || []).reduce((all, node) => [
+ ...all,
+ { label: `${'\u00a0\u00a0'.repeat(depth)}${node.name}`, value: node._id },
+ ...flattenFolders(node.children, depth + 1),
+ ], []);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setSaveResult(undefined);
+ const previousVersion = testCase ? testCase.version : undefined;
+ try {
+ const saved = await manualTestCaseRequests.updateTestCase(caseId, toPayload(draft));
+ setTestCase(saved);
+ // The version may or may not have moved - only a content change burns one - so
+ // the list is refetched rather than assumed.
+ const versionResponse = await manualTestCaseRequests.getVersions(caseId);
+ setVersions(versionResponse.versions || []);
+ setHistory([]);
+
+ // Saying which of the two happened matters: "saved as v4" and "saved, still v3"
+ // are both successes, but they mean different things to whoever reads the
+ // history or runs a test against this case later.
+ const versionBurned = saved.version !== previousVersion;
+ const message = versionBurned
+ ? intl.formatMessage(
+ { id: 'page.manual-test-case-detail.toast.saved-new-version' },
+ { version: saved.version },
+ )
+ : intl.formatMessage({ id: 'page.manual-test-case-detail.toast.saved-no-change' });
+ setSaveResult({ type: 'success', message });
+ toaster.push(
+
+ {message}
+ ,
+ { placement: 'topEnd' },
+ );
+ } catch (error) {
+ // The API's own message is far more useful than a generic failure - it names
+ // the offending custom field, the missing shared step, or the invalid value.
+ const message = getApiErrorMessage(
+ error,
+ intl.formatMessage({ id: 'page.manual-test-case-detail.toast.save-error' }),
+ );
+ setSaveResult({ type: 'error', message });
+ toaster.push(
+
+ {message}
+ ,
+ { placement: 'topEnd' },
+ );
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Any further edit makes the previous save result stale, so it is dismissed rather
+ // than left sitting above a form that has since changed.
+ const updateDraft = (changes) => {
+ setSaveResult(undefined);
+ setDraft((current) => ({ ...current, ...changes }));
+ };
+
+ const handleClone = async () => {
+ try {
+ const clone = await manualTestCaseRequests.cloneTestCase(caseId);
+ router.push(`/manual-test-cases/${clone._id}`);
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-detail.toast.clone-error');
+ }
+ };
+
+ const handleDelete = async () => {
+ setConfirmDelete(false);
+ try {
+ await manualTestCaseRequests.deleteTestCase(caseId);
+ router.push('/manual-test-cases');
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-detail.toast.delete-error');
+ }
+ };
+
+ const handleViewVersion = async (version) => {
+ if (!version) {
+ setSelectedVersion(null);
+ return;
+ }
+ try {
+ const frozen = await manualTestCaseRequests.getVersion(caseId, version);
+ setSelectedVersion(frozen);
+ } catch (error) {
+ pushError(error, 'page.manual-test-case-detail.toast.version-error');
+ }
+ };
+
+ if (loading || !draft) {
+ return (
+
+ );
+ }
+
+ const versionOptions = versions.map((version) => ({
+ label: intl.formatMessage(
+ { id: 'page.manual-test-case-detail.version-option' },
+ { version: version.version },
+ ),
+ value: version.version,
+ }));
+
+ return (
+
+
+
+
{testCase.title}
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={handleClone}
+ aria-label={intl.formatMessage({ id: 'page.manual-test-case-detail.clone' })}
+ />
+
+
+
+
+ )}
+ >
+ }
+ onClick={() => setConfirmDelete(true)}
+ aria-label={intl.formatMessage({ id: 'page.manual-test-case-detail.delete' })}
+ />
+
+
+
+
+ )}
+ >
+ }
+ onClick={handleSave}
+ loading={saving}
+ aria-label={intl.formatMessage({ id: 'page.manual-test-case-detail.save' })}
+ />
+
+
+
+
+ {saveResult && (
+
+ {saveResult.message}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'details' && (
+
+
+
+
+
+ updateDraft({ title: value })}
+ />
+
+
+
+
+
+ updateDraft({ description: value })}
+ />
+
+
+
+
+
+ updateDraft({ preconditions: value })}
+ />
+
+
+
+
+
+
+
updateDraft({ status: value })}
+ cleanable={false}
+ block
+ />
+ {/* Required custom fields only bite once a case leaves DRAFT, so
+ say so before an author hits a validation error on save. */}
+
+
+
+
+
+
+
+
+ updateDraft({ priority: value })}
+ cleanable={false}
+ block
+ />
+
+
+
+
+
+
+ updateDraft({ folder: value || null })}
+ placeholder={intl.formatMessage({ id: 'page.manual-test-case-detail.folder-root' })}
+ block
+ />
+
+
+
+
+
+ updateDraft({ tags: value })}
+ block
+ />
+
+
+ {fieldDefinitions.length > 0 && (
+
}
+ className="manual-test-case-custom-fields"
+ >
+
updateDraft({ customFields: values })}
+ />
+
+ )}
+
+ )}
+
+ {activeTab === 'steps' && (
+
+ updateDraft({ steps })}
+ sharedSteps={sharedSteps}
+ attachmentOwner={{ testCaseId: caseId }}
+ />
+
+ )}
+
+ {activeTab === 'versions' && (
+
+
+
+
+ {selectedVersion ? (
+ <>
+ {/* Reading a frozen version, not the live case: this is exactly
+ what an execution bound to it renders. */}
+
+
+ {selectedVersion.title}
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ {activeTab === 'history' && (
+
+
+
+ )}
+
+
+
setConfirmDelete(false)}
+ />
+
+ );
+}
+
+const mapStateToProps = (state) => ({
+ currentTeam: state.teamsReducer.currentTeam,
+});
+
+export default connect(mapStateToProps)(ManualTestCaseDetailPage);
diff --git a/src/components/pages/manual-test-cases/ManualTestCasesPage.js b/src/components/pages/manual-test-cases/ManualTestCasesPage.js
new file mode 100644
index 0000000..8c3e4cc
--- /dev/null
+++ b/src/components/pages/manual-test-cases/ManualTestCasesPage.js
@@ -0,0 +1,342 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import { connect } from 'react-redux';
+import { useRouter } from 'next/navigation';
+import {
+ Input, InputGroup, TagPicker, IconButton, Whisper, Tooltip, Loader, Pagination, Message,
+ useToaster, Panel,
+} from 'rsuite';
+import SearchIcon from '@rsuite/icons/Search';
+import TaskIcon from '@rsuite/icons/Task';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { ManualTestCaseRequests, ManualFolderRequests } from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import FolderTree, { ALL_FOLDERS, UNFILED } from '../../features/folder-tree/FolderTree';
+import TestCaseSuites from '../../features/test-case-suites/TestCaseSuites';
+import ConfirmModal from '../../common/ConfirmModal';
+
+const STATUS_VALUES = ['DRAFT', 'ACTIVE', 'DEPRECATED'];
+const PRIORITY_VALUES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
+const PAGE_LIMIT = 25;
+
+function ManualTestCasesPage(props) {
+ const { currentTeam } = props;
+ const router = useRouter();
+ const toaster = useToaster();
+ const intl = useIntl();
+
+ const [testCases, setTestCases] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState([]);
+ const [priorityFilter, setPriorityFilter] = useState([]);
+ const [creating, setCreating] = useState(false);
+ const [folders, setFolders] = useState([]);
+ const [unfiledCount, setUnfiledCount] = useState(0);
+ const [foldersLoading, setFoldersLoading] = useState(false);
+ // ALL_FOLDERS shows everything; a real id narrows to that branch.
+ const [selectedFolder, setSelectedFolder] = useState(ALL_FOLDERS);
+ const [deleteFolderState, setDeleteFolderState] = useState({ open: false, folder: undefined });
+ // Folder sections the user has collapsed. Kept by id so collapsing survives a reload
+ // of the list, which happens on every filter change and after every move.
+ const [collapsedIds, setCollapsedIds] = useState([]);
+
+ const manualTestCaseRequests = new ManualTestCaseRequests(axios);
+ const manualFolderRequests = new ManualFolderRequests(axios);
+
+ const statusOptions = STATUS_VALUES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.status.${value.toLowerCase()}` }),
+ value,
+ }));
+ const priorityOptions = PRIORITY_VALUES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.priority.${value.toLowerCase()}` }),
+ value,
+ }));
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadTestCases = useCallback(async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setLoading(true);
+ try {
+ const response = await manualTestCaseRequests.getTestCases(currentTeam._id, {
+ search: search || undefined,
+ // A branch shows everything beneath it - otherwise a parent folder looks
+ // empty while its sub-folders hold all the cases.
+ folder: selectedFolder === ALL_FOLDERS ? undefined : selectedFolder,
+ includeSubFolders: selectedFolder !== ALL_FOLDERS && selectedFolder !== UNFILED,
+ status: statusFilter,
+ priority: priorityFilter,
+ limit: PAGE_LIMIT,
+ skip: (page - 1) * PAGE_LIMIT,
+ });
+ setTestCases(response.testCases || []);
+ setTotal(response.metrics ? response.metrics.totalTestCases : 0);
+ } catch (error) {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: 'page.manual-test-cases.toast.fetch-error' }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [currentTeam, search, statusFilter, priorityFilter, page, selectedFolder]);
+
+ useEffect(() => {
+ loadTestCases();
+ }, [loadTestCases]);
+
+ // Any filter change invalidates the current page - staying on page 4 of a narrower
+ // result set would show an empty table rather than the matches.
+ useEffect(() => {
+ setPage(1);
+ }, [search, statusFilter, priorityFilter, currentTeam, selectedFolder]);
+
+ const handleCreate = async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setCreating(true);
+ try {
+ // Created as an empty draft and opened for editing, rather than asking for a
+ // title up front - authoring is incremental and a draft need not be complete.
+ const created = await manualTestCaseRequests.createTestCase({
+ team: currentTeam._id,
+ title: intl.formatMessage({ id: 'page.manual-test-cases.new-case-title' }),
+ });
+ router.push(`/manual-test-cases/${created._id}`);
+ } catch (error) {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: 'page.manual-test-cases.toast.create-error' }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ setCreating(false);
+ }
+ };
+
+ const loadFolders = useCallback(async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setFoldersLoading(true);
+ try {
+ const response = await manualFolderRequests.getFolders(currentTeam._id);
+ setFolders(response.folders || []);
+ setUnfiledCount(response.unfiledCount || 0);
+ } catch (error) {
+ pushError(error, 'page.manual-test-cases.toast.folders-error');
+ } finally {
+ setFoldersLoading(false);
+ }
+ }, [currentTeam]);
+
+ useEffect(() => { loadFolders(); }, [loadFolders]);
+
+ const handleCreateFolder = async (parent, name) => {
+ try {
+ await manualFolderRequests.createFolder({
+ team: currentTeam._id, name, parent,
+ });
+ loadFolders();
+ } catch (error) {
+ // A duplicate sibling name comes back as a 409 with a message naming it.
+ pushError(error, 'page.manual-test-cases.toast.folder-create-error');
+ }
+ };
+
+ const handleRenameFolder = async (folder, name) => {
+ try {
+ await manualFolderRequests.updateFolder(folder._id, { name });
+ loadFolders();
+ } catch (error) {
+ pushError(error, 'page.manual-test-cases.toast.folder-rename-error');
+ }
+ };
+
+ const handleDeleteFolder = async () => {
+ const { folder } = deleteFolderState;
+ setDeleteFolderState({ open: false, folder: undefined });
+ if (!folder) return;
+ try {
+ await manualFolderRequests.deleteFolder(folder._id);
+ if (selectedFolder === folder._id) setSelectedFolder(ALL_FOLDERS);
+ loadFolders();
+ } catch (error) {
+ // 409 while the folder still holds anything - the API names the counts, which
+ // is more useful than a generic refusal.
+ pushError(error, 'page.manual-test-cases.toast.folder-delete-error');
+ }
+ };
+
+ /*
+ Files a test case into a folder by drag and drop.
+
+ Filing is organisation rather than content, so the API records it in the change
+ history without burning a new test case version - dragging a case between folders
+ must not make every bound execution point at a stale version.
+
+ The list and the tree are both reloaded afterwards: the case moves between sections,
+ and the per-folder counts in the tree change with it.
+ */
+ const handleMoveCase = async (caseId, folder) => {
+ try {
+ await manualFolderRequests.moveTestCases({ testCaseIds: [caseId], folder });
+ await Promise.all([loadTestCases(), loadFolders()]);
+ toaster.push(
+
+
+ ,
+ { placement: 'topEnd' },
+ );
+ } catch (error) {
+ pushError(error, 'page.manual-test-cases.toast.move-error');
+ }
+ };
+
+ const toggleCollapse = (key) => {
+ setCollapsedIds((current) => (current.includes(key)
+ ? current.filter((id) => id !== key)
+ : [...current, key]));
+ };
+
+ if (!currentTeam || !currentTeam._id) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ setDeleteFolderState({ open: true, folder })}
+ onDropCase={handleMoveCase}
+ />
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={handleCreate}
+ loading={creating}
+ aria-label={intl.formatMessage({ id: 'page.manual-test-cases.create' })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ } />
+
+ ) : (
+ <>
+ router.push(`/manual-test-cases/${id}`)}
+ onMoveCase={handleMoveCase}
+ collapsedIds={collapsedIds}
+ onToggleCollapse={toggleCollapse}
+ />
+
+ {testCases.length === 0 && (
+
+
+
+ )}
+
+ {total > PAGE_LIMIT && (
+
+ )}
+ >
+ )}
+
+
+
+
setDeleteFolderState({ open: false, folder: undefined })}
+ />
+
+ );
+}
+
+const mapStateToProps = (state) => ({
+ currentTeam: state.teamsReducer.currentTeam,
+});
+
+export default connect(mapStateToProps)(ManualTestCasesPage);
diff --git a/src/components/pages/manual-test-cases/styles.less b/src/components/pages/manual-test-cases/styles.less
new file mode 100644
index 0000000..b295c5c
--- /dev/null
+++ b/src/components/pages/manual-test-cases/styles.less
@@ -0,0 +1,240 @@
+// ── Manual test cases ────────────────────────────────────────────────────────
+.manual-test-cases-page {
+ .manual-test-cases-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ align-items: center;
+ }
+
+ .manual-test-cases-search {
+ max-width: 320px;
+ }
+
+ .manual-test-cases-pagination {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: var(--space-3);
+ }
+}
+
+// Status pills reuse the shared result hues rather than inventing new ones: DRAFT reads as
+// not-yet-run, ACTIVE as good, DEPRECATED as retired.
+.manual-status-tag {
+ background: var(--sub-panel-background);
+ font-size: var(--font-size-xs);
+
+ &.manual-status-draft {
+ color: var(--skipped-color);
+ }
+
+ &.manual-status-active {
+ color: var(--pass-color);
+ }
+
+ &.manual-status-deprecated {
+ color: var(--modal-secondary-font-color);
+ }
+}
+
+.manual-test-case-detail-page {
+ .manual-test-case-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ }
+
+ .manual-test-case-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: var(--space-3);
+ }
+
+ .manual-test-case-custom-fields {
+ margin-top: var(--space-4);
+ background: var(--sub-panel-background);
+ border-radius: var(--radius-md);
+ }
+
+ .manual-version-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding-bottom: var(--space-3);
+ }
+
+ .manual-version-title {
+ font-weight: var(--font-weight-medium);
+ color: var(--main-panel-font-color);
+ }
+}
+
+// Folder tree beside the case list. The tree is a fixed rail so the table keeps a stable
+// width as folders are expanded.
+.manual-test-cases-page {
+ .manual-test-cases-layout {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-4);
+ }
+
+ .manual-test-cases-tree {
+ flex: 0 0 280px;
+ max-height: 70vh;
+ overflow-y: auto;
+ }
+
+ .manual-test-cases-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+}
+
+@media (max-width: 900px) {
+ .manual-test-cases-page .manual-test-cases-layout {
+ flex-direction: column;
+ }
+
+ .manual-test-cases-page .manual-test-cases-tree {
+ flex: 1 1 auto;
+ width: 100%;
+ }
+}
+
+// ── Grouped suites ───────────────────────────────────────────────────────────
+// One table per folder, mirroring the tree. The drop states are deliberately loud:
+// a drag has no undo, so the target has to be unambiguous before the pointer is released.
+.test-case-suites {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+// Each folder is one card: the heading is the card's top bar and the table is its
+// body, so an expanded folder reads as a container holding its cases.
+//
+// Previously the heading was bare text on the page background and the table began
+// immediately below at full width, with its own strong header row. Two stacked
+// full-width blocks with nothing enclosing them read as siblings, and because the
+// table header is a solid accent bar it out-weighed the folder name above it — so
+// the table looked like the start of something new rather than the contents of the
+// folder it belongs to.
+.test-case-suite {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--sub-panel-background);
+ // Clips the table's square corners to the card's rounded ones; without it the
+ // accent header row overruns the border it is supposed to sit inside.
+ overflow: hidden;
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+
+ &.test-case-suite-drop {
+ border-color: var(--pass-color);
+ box-shadow: 0 0 0 1px var(--pass-color);
+ }
+}
+
+// A collapsed folder is a closed box: one bar, nothing below it. Dropping the
+// border-radius clamp lets the header keep the card's full rounding on all four
+// corners rather than only the top two.
+.test-case-suite-collapsed .test-case-suite-header {
+ border-bottom-color: transparent;
+}
+
+.test-case-suite-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+ // The bar is the card's own surface, distinct from the table body beneath it, so
+ // the heading reads as a lid on the rows rather than as floating text.
+ background: var(--main-panel-background);
+ // Separates lid from body when expanded; transparent (not removed) when collapsed
+ // so the header does not change height as it toggles.
+ border-bottom: 1px solid var(--border-color);
+ transition: background var(--transition-fast), border-color var(--transition-fast);
+
+ &:hover {
+ background: var(--surface-raised);
+ }
+}
+
+// A rail down the open card in the folder's own accent. It starts at the heading and
+// runs the height of the rows, which is what ties the two together at a glance: the
+// eye follows one continuous edge from the folder name to the last case in it.
+.test-case-suite-expanded {
+ border-left: 3px solid var(--color-primary);
+}
+
+// The table sits inside the card, so it must not carry its own outer border on top
+// of the card's.
+.test-case-suite-body {
+ .rs-table {
+ border: 0;
+ }
+
+ // The app-wide convention paints a table header in the accent, which is right for a
+ // table that IS the panel's content. Here the table is nested inside a folder card
+ // that already has a heading of its own, so the accent bar competed with it — and
+ // won, because a solid fill out-weighs text. That is what made the rows look like
+ // the start of a new section instead of the contents of the folder above them.
+ //
+ // Quietened to a subordinate header: same structure, no longer shouting over the
+ // folder that owns it. Scoped to this component, so tables elsewhere keep the
+ // convention.
+ .rs-table-row-header .rs-table-cell-content {
+ background-color: var(--sub-panel-background);
+ color: var(--main-panel-secondary-font-color);
+ opacity: 1;
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+
+ // Without the accent fill the header needs an edge, or it merges into the first row.
+ .rs-table-row-header {
+ border-bottom: 1px solid var(--border-subtle);
+ }
+}
+
+.test-case-suite-icon {
+ color: var(--modal-secondary-font-color);
+}
+
+.test-case-suite-title {
+ font-weight: var(--font-weight-medium);
+ color: var(--main-panel-font-color);
+}
+
+.test-case-suite-count {
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+}
+
+// The grip is the affordance that says a row can be dragged at all; without it the
+// only discoverable way to move a case is to find the folder field on the detail page.
+.test-case-suite-row {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.test-case-suite-grip {
+ color: var(--modal-secondary-font-color);
+ font-size: var(--font-size-xs);
+ letter-spacing: -2px;
+}
+
+// A folder in the tree lit up as the pending destination.
+.folder-tree-node-drop {
+ outline: 1px solid var(--pass-color);
+ border-radius: var(--radius-sm);
+ background: var(--sub-panel-background);
+}
diff --git a/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js b/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js
new file mode 100644
index 0000000..3ee5d96
--- /dev/null
+++ b/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js
@@ -0,0 +1,369 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import Link from 'next/link';
+import {
+ Loader, Message, useToaster, Panel, Button, Input, SelectPicker, Tag, Nav, ButtonGroup,
+} from 'rsuite';
+import { FormattedMessage, useIntl } from 'react-intl';
+import {
+ ManualTestRunRequests, ManualTestCaseRequests, ManualRunStates,
+ ManualCaseResultStates, ManualStepResultStates,
+} from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import VersionBadge from '../../features/version-badge/VersionBadge';
+import AttachmentUpload from '../../features/attachment-upload/AttachmentUpload';
+
+/*
+ * Execution view: works through one test case at a time, recording a result per step.
+ *
+ * Everything a tester sees comes from the *bound version*, never the live case. A case
+ * edited mid-run does not shift under them - that binding is fixed at run creation, and the
+ * version badge shows when the bound version has fallen behind the latest, linking to the
+ * diff so the tester can see what changed without the run changing.
+ */
+const STEP_STATES = Object.values(ManualStepResultStates);
+const CASE_STATES = Object.values(ManualCaseResultStates)
+ .filter((state) => state !== 'NOT_RUN' && state !== 'IN_PROGRESS');
+
+const ManualTestRunDetailPage = function ({ runId }) {
+ const intl = useIntl();
+ const toaster = useToaster();
+
+ const [run, setRun] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+ const [activeCaseId, setActiveCaseId] = useState(undefined);
+ const [frozen, setFrozen] = useState(undefined);
+ const [frozenLoading, setFrozenLoading] = useState(false);
+ const [latestVersion, setLatestVersion] = useState(undefined);
+ const [stepResults, setStepResults] = useState([]);
+ const [caseStatus, setCaseStatus] = useState(undefined);
+ const [notes, setNotes] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ const manualTestRunRequests = new ManualTestRunRequests(axios);
+ const manualTestCaseRequests = new ManualTestCaseRequests(axios);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadRun = useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await manualTestRunRequests.getTestRun(runId, true);
+ setRun(response);
+ if (!activeCaseId && response.testCases && response.testCases.length > 0) {
+ setActiveCaseId(response.testCases[0].testCase._id
+ || response.testCases[0].testCase);
+ }
+ } catch (error) {
+ pushError(error, 'page.manual-test-run-detail.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [runId]);
+
+ useEffect(() => { loadRun(); }, [loadRun]);
+
+ const activeEntry = run && run.testCases
+ ? run.testCases.find((entry) => (entry.testCase._id || entry.testCase) === activeCaseId)
+ : undefined;
+
+ // Loads the frozen version this entry is bound to, plus the case's version list purely
+ // to tell the tester whether they are behind.
+ useEffect(() => {
+ const loadFrozen = async () => {
+ if (!activeEntry) return;
+ setFrozenLoading(true);
+ try {
+ const caseId = activeEntry.testCase._id || activeEntry.testCase;
+ const [version, versions] = await Promise.all([
+ manualTestCaseRequests.getVersion(caseId, activeEntry.versionNumber),
+ manualTestCaseRequests.getVersions(caseId),
+ ]);
+ setFrozen(version);
+ setLatestVersion((versions.versions || [])
+ .reduce((max, entry) => Math.max(max, entry.version), 0));
+ const existing = activeEntry.stepResults || [];
+ setStepResults((version.steps || []).map((step, index) => {
+ const previous = existing.find((result) => result.stepId === step._id)
+ || existing[index];
+ return {
+ stepId: step._id,
+ status: previous ? previous.status : ManualStepResultStates.NOT_RUN,
+ actual: previous ? previous.actual : '',
+ attachments: previous ? previous.attachments || [] : [],
+ };
+ }));
+ setCaseStatus(activeEntry.status && activeEntry.status !== 'NOT_RUN'
+ ? activeEntry.status : undefined);
+ setNotes(activeEntry.notes || '');
+ } catch (error) {
+ pushError(error, 'page.manual-test-run-detail.toast.version-error');
+ setFrozen(undefined);
+ } finally {
+ setFrozenLoading(false);
+ }
+ };
+ loadFrozen();
+ }, [activeCaseId, run]);
+
+ const updateStepResult = (index, changes) => {
+ setStepResults((current) => current.map(
+ (result, position) => (position === index ? { ...result, ...changes } : result),
+ ));
+ };
+
+ // A case defaults to the worst step outcome, which is what a tester means by "this
+ // failed" without having to set it twice. They can still override it explicitly.
+ const derivedCaseStatus = () => {
+ if (caseStatus) return caseStatus;
+ if (stepResults.some((result) => result.status === 'FAIL')) return 'FAIL';
+ if (stepResults.some((result) => result.status === 'BLOCKED')) return 'BLOCKED';
+ if (stepResults.some((result) => result.status === 'SKIPPED')) return 'SKIPPED';
+ if (stepResults.length > 0
+ && stepResults.every((result) => result.status === 'PASS')) return 'PASS';
+ return undefined;
+ };
+
+ const handleRecord = async () => {
+ const status = derivedCaseStatus();
+ if (!activeEntry || !status) return;
+ setSaving(true);
+ try {
+ const caseId = activeEntry.testCase._id || activeEntry.testCase;
+ await manualTestRunRequests.recordResult(runId, caseId, {
+ status,
+ notes,
+ stepResults: stepResults.map((result) => ({
+ stepId: result.stepId,
+ status: result.status,
+ actual: result.actual,
+ attachments: (result.attachments || []).map(
+ (attachment) => attachment._id || attachment,
+ ),
+ })),
+ });
+ toaster.push(
+
+
+ ,
+ { placement: 'topEnd' },
+ );
+ loadRun();
+ } catch (error) {
+ pushError(error, 'page.manual-test-run-detail.toast.record-error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleRebind = async () => {
+ if (!activeEntry) return;
+ try {
+ const caseId = activeEntry.testCase._id || activeEntry.testCase;
+ await manualTestRunRequests.rebindTestCase(runId, caseId);
+ loadRun();
+ } catch (error) {
+ // 409 when the entry already has a result - re-pointing it would attach a
+ // recorded execution to content it was never run against.
+ pushError(error, 'page.manual-test-run-detail.toast.rebind-error');
+ }
+ };
+
+ const handleRunStatus = async (status) => {
+ try {
+ await manualTestRunRequests.updateStatus(runId, status);
+ loadRun();
+ } catch (error) {
+ pushError(error, 'page.manual-test-run-detail.toast.status-error');
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!run) {
+ return (
+
+
+
+ );
+ }
+
+ const stepStateOptions = STEP_STATES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.step-status.${value.toLowerCase()}` }),
+ value,
+ }));
+ const caseStateOptions = CASE_STATES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.case-status.${value.toLowerCase()}` }),
+ value,
+ }));
+ const activeCaseIdValue = activeEntry
+ ? (activeEntry.testCase._id || activeEntry.testCase) : undefined;
+ const isBehind = latestVersion && activeEntry && latestVersion > activeEntry.versionNumber;
+ const canRebind = isBehind && activeEntry
+ && (!activeEntry.status || activeEntry.status === 'NOT_RUN');
+
+ return (
+
+
+ {run.name}
+
+
+
+
+ {run.status !== ManualRunStates.IN_PROGRESS
+ && run.status !== ManualRunStates.COMPLETED && (
+ handleRunStatus(ManualRunStates.IN_PROGRESS)}>
+
+
+ )}
+ {run.status !== ManualRunStates.COMPLETED && (
+ handleRunStatus(ManualRunStates.COMPLETED)}>
+
+
+ )}
+
+ {run.build && (
+
+
+
+ )}
+
+
+
+
+
+ {(run.testCases || []).map((entry) => {
+ const caseId = entry.testCase._id || entry.testCase;
+ const status = (entry.status || 'NOT_RUN').toLowerCase();
+ return (
+
+ ●
+
+ {entry.testCase.title || caseId}
+
+
+ );
+ })}
+
+
+
+
+ {frozenLoading &&
}
+ {!frozenLoading && frozen && activeEntry && (
+
+
+ {frozen.title}
+
+ {isBehind && (
+
+
+
+ )}
+ {canRebind && (
+
+
+
+ )}
+
+
+ {frozen.preconditions && (
+
+
+
+
+
{frozen.preconditions}
+
+ )}
+
+ {(frozen.steps || []).map((step, index) => (
+
+
+ {index + 1}
+ updateStepResult(index, { status: value })}
+ />
+
+
{step.action}
+ {step.expected && (
+
+
+ {': '}
+ {step.expected}
+
+ )}
+
updateStepResult(index, { actual: value })}
+ />
+
updateStepResult(index, { attachments })}
+ readOnly={!activeEntry.execution}
+ />
+
+ ))}
+
+
+ }
+ data={caseStateOptions}
+ value={derivedCaseStatus()}
+ searchable={false}
+ style={{ width: 220 }}
+ onChange={setCaseStatus}
+ />
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default ManualTestRunDetailPage;
diff --git a/src/components/pages/manual-test-runs/ManualTestRunsPage.js b/src/components/pages/manual-test-runs/ManualTestRunsPage.js
new file mode 100644
index 0000000..3b30ee2
--- /dev/null
+++ b/src/components/pages/manual-test-runs/ManualTestRunsPage.js
@@ -0,0 +1,329 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import { connect } from 'react-redux';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import {
+ Table, Button, IconButton, Whisper, Tooltip, Loader, Message, useToaster, Modal, Form,
+ Input, SelectPicker, CheckPicker, Tag, Pagination, Panel,
+} from 'rsuite';
+import PlayOutlineIcon from '@rsuite/icons/PlayOutline';
+import { FormattedMessage, useIntl } from 'react-intl';
+import {
+ ManualTestRunRequests, ManualTestCaseRequests, ManualRunStates,
+} from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import ConfirmModal from '../../common/ConfirmModal';
+
+const { Column, HeaderCell, Cell } = Table;
+
+const PAGE_LIMIT = 25;
+const RUN_STATES = Object.values(ManualRunStates);
+
+const ManualTestRunsPage = function (props) {
+ const { currentTeam, environments } = props;
+ const intl = useIntl();
+ const toaster = useToaster();
+ const router = useRouter();
+
+ const [runs, setRuns] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [statusFilter, setStatusFilter] = useState([]);
+ const [creating, setCreating] = useState(undefined);
+ const [saving, setSaving] = useState(false);
+ const [availableCases, setAvailableCases] = useState([]);
+ const [deleteState, setDeleteState] = useState({ open: false, run: undefined });
+
+ const manualTestRunRequests = new ManualTestRunRequests(axios);
+ const manualTestCaseRequests = new ManualTestCaseRequests(axios);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadRuns = useCallback(async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setLoading(true);
+ try {
+ const response = await manualTestRunRequests.getTestRuns(
+ currentTeam._id,
+ statusFilter.length > 0 ? statusFilter : undefined,
+ undefined,
+ PAGE_LIMIT,
+ (page - 1) * PAGE_LIMIT,
+ );
+ setRuns(response.testRuns || []);
+ setTotal(response.metrics ? response.metrics.totalTestRuns : 0);
+ } catch (error) {
+ pushError(error, 'page.manual-test-runs.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [currentTeam, statusFilter, page]);
+
+ useEffect(() => { loadRuns(); }, [loadRuns]);
+ useEffect(() => { setPage(1); }, [statusFilter, currentTeam]);
+
+ // Only ACTIVE cases can go into a run - the API rejects DEPRECATED ones, so offering
+ // them would produce an error the QA could not act on.
+ const openWizard = async () => {
+ setCreating({ name: '', environment: undefined, component: undefined, testCaseIds: [] });
+ try {
+ const response = await manualTestCaseRequests.getTestCases(currentTeam._id, {
+ status: ['ACTIVE'], limit: 200,
+ });
+ setAvailableCases(response.testCases || []);
+ } catch (error) {
+ pushError(error, 'page.manual-test-runs.toast.cases-error');
+ setAvailableCases([]);
+ }
+ };
+
+ const handleCreate = async () => {
+ if (!creating) return;
+ setSaving(true);
+ try {
+ const created = await manualTestRunRequests.createTestRun({
+ name: creating.name,
+ team: currentTeam._id,
+ environment: creating.environment,
+ component: creating.component,
+ testCaseIds: creating.testCaseIds,
+ });
+ setCreating(undefined);
+ router.push(`/manual-test-runs/${created._id}`);
+ } catch (error) {
+ pushError(error, 'page.manual-test-runs.toast.create-error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ const { run } = deleteState;
+ setDeleteState({ open: false, run: undefined });
+ if (!run) return;
+ try {
+ await manualTestRunRequests.deleteTestRun(run._id);
+ loadRuns();
+ } catch (error) {
+ pushError(error, 'page.manual-test-runs.toast.delete-error');
+ }
+ };
+
+ if (!currentTeam || !currentTeam._id) {
+ return (
+
+ );
+ }
+
+ const statusOptions = RUN_STATES.map((value) => ({
+ label: intl.formatMessage({ id: `app.manual.run-status.${value.toLowerCase()}` }),
+ value,
+ }));
+ const environmentOptions = (environments || [])
+ .map((environment) => ({ label: environment.name, value: environment.name }));
+ const componentOptions = (currentTeam.components || [])
+ .map((component) => ({ label: component.name, value: component._id }));
+ const caseOptions = availableCases
+ .map((testCase) => ({ label: testCase.title, value: testCase._id }));
+
+ const progressOf = (run) => {
+ const cases = run.testCases || [];
+ const done = cases.filter((entry) => entry.status && entry.status !== 'NOT_RUN'
+ && entry.status !== 'IN_PROGRESS').length;
+ return `${done}/${cases.length}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={openWizard}
+ aria-label={intl.formatMessage({ id: 'page.manual-test-runs.button.add' })}
+ />
+
+
+
+
+ }
+ data={statusOptions}
+ value={statusFilter}
+ searchable={false}
+ className="manual-test-runs-status-filter"
+ onChange={setStatusFilter}
+ />
+
+
+ {loading ? : (
+ <>
+
+
+
+
+ {(row) => (
+ {row.name}
+ )}
+ |
+
+
+
+
+ {(row) => (
+
+
+
+ )}
+ |
+
+
+
+ {(row) => progressOf(row)} |
+
+
+
+
+ {(row) => (row.environment ? row.environment.name : '-')}
+ |
+
+
+
+
+ {(row) => (
+ setDeleteState({ open: true, run: row })}
+ >
+
+
+ )}
+ |
+
+
+ {total > PAGE_LIMIT && (
+
+ )}
+ >
+ )}
+
+
+
setCreating(undefined)}>
+
+
+
+
+ {creating && (
+
+
+ setCreating({ ...creating, name: value })}
+ />
+
+
+
+ setCreating({ ...creating, environment: value })}
+ />
+
+
+
+ setCreating({ ...creating, component: value })}
+ />
+
+
+
+
+
+
+ setCreating({ ...creating, testCaseIds: value })}
+ />
+
+
+
+
+
+ )}
+
+
+ setCreating(undefined)}>
+
+
+
+
+
+
+
+
+
setDeleteState({ open: false, run: undefined })}
+ />
+
+ );
+};
+
+const mapStateToProps = (state) => ({
+ currentTeam: state.teamsReducer.currentTeam,
+ environments: state.environmentsReducer.environments,
+});
+
+export default connect(mapStateToProps)(ManualTestRunsPage);
diff --git a/src/components/pages/manual-test-runs/styles.less b/src/components/pages/manual-test-runs/styles.less
new file mode 100644
index 0000000..4f4812e
--- /dev/null
+++ b/src/components/pages/manual-test-runs/styles.less
@@ -0,0 +1,75 @@
+// ── Manual test runs ─────────────────────────────────────────────────────────
+.manual-test-runs-page {
+ .rs-tag {
+ font-size: var(--font-size-xs);
+ }
+
+ .manual-test-runs-status-filter {
+ width: 260px;
+ }
+}
+
+// Execution view: case list on the left, the bound version's steps on the right.
+.manual-test-run-detail-page {
+ .manual-test-run-detail-body {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-4);
+ }
+
+ .manual-test-run-case-list {
+ flex: 0 0 260px;
+
+ .manual-test-run-case-title {
+ margin-left: var(--space-2);
+ }
+ }
+
+ .manual-test-run-execution {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .manual-test-run-step {
+ padding: var(--space-3);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ margin-bottom: var(--space-3);
+ background: var(--sub-panel-background);
+ }
+
+ .manual-test-run-step-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+ }
+
+ .manual-test-run-step-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--radius-pill);
+ background: var(--main-panel-background);
+ font-size: var(--font-size-xs);
+ }
+
+ .manual-test-run-step-action {
+ margin-bottom: var(--space-1);
+ }
+
+ .manual-test-run-step-expected {
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-2);
+ }
+
+ .manual-test-run-outcome {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ align-items: flex-start;
+ }
+}
diff --git a/src/components/pages/metrics/MetricsPage.js b/src/components/pages/metrics/MetricsPage.js
index 323f703..2307b42 100644
--- a/src/components/pages/metrics/MetricsPage.js
+++ b/src/components/pages/metrics/MetricsPage.js
@@ -6,8 +6,6 @@ import { connect } from 'react-redux';
import { FormattedMessage, useIntl } from 'react-intl';
import queryString from 'query-string';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
-import Cookies from 'js-cookie';
-import { storeCurrentTeam } from '../../../redux/teamActions';
import { MetricRequests } from 'angles-javascript-client';
import {
Affix,
@@ -29,6 +27,7 @@ import { getPaletteColor, getPlatformLabel } from '../../../utility/ChartConfig'
import { getDateRangesPicker } from '../../../utility/TimeUtilities';
import ExecutionMetricsResultsBarChart from './charts/ExecutionMetricsResultsBarChart';
import PhaseMetricsResultsBarChart from './charts/PhaseMetricsResultsBarChart';
+import { ALL_EXECUTION_TYPES, getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities';
const MetricsPage = function (props) {
const router = useRouter();
@@ -36,7 +35,7 @@ const MetricsPage = function (props) {
const pathname = usePathname();
const intl = useIntl();
// const query = queryString.parse(location.search);
- const { teams, currentTeam, saveCurrentTeam } = props;
+ const { teams, currentTeam } = props;
const {
component,
grouping,
@@ -49,6 +48,8 @@ const MetricsPage = function (props) {
const [selectedTeam, setSelectedTeam] = useState(currentTeam?._id || undefined);
const [selectedComponent, setSelectedComponent] = useState(component || 'any');
const [key, setKey] = useState('execution');
+ // undefined = both types, which is what every pre-3.0 metrics view showed.
+ const [executionType, setExecutionType] = useState(undefined);
const [metrics, setMetrics] = useState({});
const [platformColors, setPlatformColors] = useState({});
const metricRequests = new MetricRequests(axios);
@@ -85,11 +86,12 @@ const MetricsPage = function (props) {
return result;
};
- const getMetrics = (teamId, componentId, fromDate, toDate, groupingId) => {
+ const getMetrics = (teamId, componentId, fromDate, toDate, groupingId, executionTypeId) => {
if (metrics && Object.keys(metrics).length > 0) {
setMetrics(undefined);
}
- metricRequests.getPhaseMetrics(teamId, componentId, fromDate, toDate, groupingId)
+ metricRequests
+ .getPhaseMetrics(teamId, componentId, fromDate, toDate, groupingId, executionTypeId)
.then((returnedMetrics) => {
setMetrics(returnedMetrics);
setPlatformColors(getPlatformArrayColors(returnedMetrics));
@@ -103,18 +105,25 @@ const MetricsPage = function (props) {
const retrieveMetrics = () => {
if (endDate && startDate && selectedTeam) {
if (selectedComponent === 'any') {
- getMetrics(selectedTeam, undefined, startDate, endDate, groupingPeriod);
+ getMetrics(selectedTeam, undefined, startDate, endDate, groupingPeriod, executionType);
} else {
- getMetrics(selectedTeam, selectedComponent, startDate, endDate, groupingPeriod);
+ getMetrics(
+ selectedTeam, selectedComponent, startDate, endDate, groupingPeriod, executionType,
+ );
}
}
};
+ // The team now comes from the header picker, so this effect is the only path
+ // by which the page changes team. Resetting the component filter is part of
+ // that: component ids belong to a team, so one carried across a team change
+ // would filter the metrics by something the new team does not have.
useEffect(() => {
- if (currentTeam) {
+ if (currentTeam && currentTeam._id !== selectedTeam) {
setSelectedTeam(currentTeam._id);
+ setSelectedComponent('any');
}
- }, [currentTeam]);
+ }, [currentTeam, selectedTeam]);
useEffect(() => {
retrieveMetrics();
@@ -124,33 +133,6 @@ const MetricsPage = function (props) {
setGroupingPeriod(groupingValue);
};
- const getTeam = (teamId) => {
- if (teams && Array.isArray(teams)) {
- return teams.find((team) => team._id === teamId);
- }
- return undefined;
- };
-
- const handleTeamChange = (teamId) => {
- changeCurrentTeam(teamId);
- setSelectedTeam(teamId);
- setSelectedComponent('any');
- // Keep the teamId query param in sync with the newly selected team.
- // The Shell drives currentTeam from the URL, so a stale teamId here would
- // make it revert us back to the previous team as soon as currentTeam changes.
- const params = new URLSearchParams(searchParams.toString());
- params.set('teamId', teamId);
- params.set('component', 'any');
- router.replace(`${pathname}?${params.toString()}`);
- };
-
- const changeCurrentTeam = (teamId) => {
- if (teamId !== undefined) {
- saveCurrentTeam(getTeam(teamId));
- Cookies.set('teamId', teamId, { expires: 365 });
- }
- };
-
const handleComponentChange = (componentId) => {
setSelectedComponent(componentId);
};
@@ -189,19 +171,6 @@ const MetricsPage = function (props) {
top={20}
>
- }
- appearance="subtle"
- data={teams.map((team) => ({ label: team.name, value: team._id }))}
- value={selectedTeam}
- onChange={(value) => {
- if (value) {
- handleTeamChange(value);
- }
- }}
- />
+ }
+ cleanable={false}
+ searchable={false}
+ appearance="subtle"
+ data={getExecutionTypeOptions(intl)}
+ value={executionType === undefined ? ALL_EXECUTION_TYPES : executionType}
+ onChange={(value) => {
+ setExecutionType(toExecutionTypeParam(value));
+ }}
+ />
{ onSubmit(); }}>
@@ -370,8 +350,4 @@ const mapStateToProps = (state) => ({
teams: state.teamsReducer.teams,
});
-const mapDispatchToProps = (dispatch) => ({
- saveCurrentTeam: (selectedTeam) => dispatch(storeCurrentTeam(selectedTeam)),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(MetricsPage);
+export default connect(mapStateToProps)(MetricsPage);
diff --git a/src/components/pages/shared-steps/SharedStepsPage.js b/src/components/pages/shared-steps/SharedStepsPage.js
new file mode 100644
index 0000000..d2c8d6f
--- /dev/null
+++ b/src/components/pages/shared-steps/SharedStepsPage.js
@@ -0,0 +1,383 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import axios from 'axios';
+import { connect } from 'react-redux';
+import {
+ Table, Input, InputGroup, Button, IconButton, Whisper, Tooltip, Loader, Message,
+ useToaster, Modal, Form, Drawer, Tag, Panel,
+} from 'rsuite';
+import SearchIcon from '@rsuite/icons/Search';
+import CombinationIcon from '@rsuite/icons/Combination';
+import { FormattedMessage, useIntl } from 'react-intl';
+import Link from 'next/link';
+import { SharedStepRequests } from 'angles-javascript-client';
+import { getApiErrorMessage } from '../../../utility/ApiUtilities';
+import ManualStepEditor from '../../features/manual-step-editor/ManualStepEditor';
+import ChangeHistory from '../../features/change-history/ChangeHistory';
+import ConfirmModal from '../../common/ConfirmModal';
+
+const { Column, HeaderCell, Cell } = Table;
+
+const SharedStepsPage = function (props) {
+ const { currentTeam } = props;
+ const intl = useIntl();
+ const toaster = useToaster();
+
+ const [sharedSteps, setSharedSteps] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState('');
+ const [editing, setEditing] = useState(undefined);
+ const [saving, setSaving] = useState(false);
+ const [usage, setUsage] = useState(undefined);
+ const [usageLoading, setUsageLoading] = useState(false);
+ const [history, setHistory] = useState(undefined);
+ const [historyLoading, setHistoryLoading] = useState(false);
+ const [deleteState, setDeleteState] = useState({ open: false, sharedStep: undefined });
+ const [cascadeWarning, setCascadeWarning] = useState(undefined);
+
+ const sharedStepRequests = new SharedStepRequests(axios);
+
+ const pushError = (error, fallbackId) => {
+ toaster.push(
+
+ {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))}
+ ,
+ { placement: 'topEnd' },
+ );
+ };
+
+ const loadSharedSteps = useCallback(async () => {
+ if (!currentTeam || !currentTeam._id) return;
+ setLoading(true);
+ try {
+ const response = await sharedStepRequests
+ .getSharedSteps(currentTeam._id, search || undefined);
+ setSharedSteps(response.sharedSteps || []);
+ } catch (error) {
+ pushError(error, 'page.shared-steps.toast.fetch-error');
+ } finally {
+ setLoading(false);
+ }
+ }, [currentTeam, search]);
+
+ useEffect(() => { loadSharedSteps(); }, [loadSharedSteps]);
+
+ // Editing a shared step re-versions every case that includes it, so the author is told
+ // how many that is before they commit rather than after.
+ const openEditor = async (sharedStep) => {
+ setEditing(sharedStep ? { ...sharedStep } : { name: '', description: '', steps: [] });
+ setCascadeWarning(undefined);
+ if (sharedStep && sharedStep._id) {
+ try {
+ const response = await sharedStepRequests.getUsage(sharedStep._id);
+ const count = response.metrics ? response.metrics.totalTestCases : 0;
+ if (count > 0) setCascadeWarning(count);
+ } catch (error) {
+ // A failed usage lookup must not block editing - the warning is advisory.
+ setCascadeWarning(undefined);
+ }
+ }
+ };
+
+ const handleSave = async () => {
+ if (!editing) return;
+ setSaving(true);
+ try {
+ const payload = {
+ name: editing.name,
+ description: editing.description,
+ steps: (editing.steps || []).map((step, index) => ({
+ order: index + 1,
+ action: step.action,
+ expected: step.expected,
+ data: step.data,
+ // The editor holds whole attachment documents so it can render
+ // thumbnails; the API stores references.
+ attachments: (step.attachments || [])
+ .map((attachment) => (attachment && attachment._id
+ ? attachment._id : attachment))
+ .filter(Boolean),
+ })),
+ };
+ if (editing._id) {
+ const response = await sharedStepRequests.updateSharedStep(editing._id, payload);
+ const cascaded = response && response.cascade
+ ? response.cascade.testCasesVersioned : 0;
+ // A cascade is the notable outcome - it re-versioned other people's test
+ // cases - so it is reported instead of the plain confirmation, not as well.
+ toaster.push(
+
+ {cascaded > 0
+ ? intl.formatMessage(
+ { id: 'page.shared-steps.toast.cascaded' }, { count: cascaded },
+ )
+ : intl.formatMessage({ id: 'page.shared-steps.toast.saved' })}
+ ,
+ { placement: 'topEnd' },
+ );
+ } else {
+ await sharedStepRequests.createSharedStep({ ...payload, team: currentTeam._id });
+ toaster.push(
+
+ {intl.formatMessage({ id: 'page.shared-steps.toast.created' })}
+ ,
+ { placement: 'topEnd' },
+ );
+ }
+ setEditing(undefined);
+ loadSharedSteps();
+ } catch (error) {
+ pushError(error, 'page.shared-steps.toast.save-error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ const { sharedStep } = deleteState;
+ setDeleteState({ open: false, sharedStep: undefined });
+ if (!sharedStep) return;
+ try {
+ await sharedStepRequests.deleteSharedStep(sharedStep._id);
+ loadSharedSteps();
+ } catch (error) {
+ // 409 when the step is still referenced - the message from the API names the count.
+ pushError(error, 'page.shared-steps.toast.delete-error');
+ }
+ };
+
+ const showUsage = async (sharedStep) => {
+ setUsage({ sharedStep, testCases: [] });
+ setUsageLoading(true);
+ try {
+ const response = await sharedStepRequests.getUsage(sharedStep._id);
+ setUsage({ sharedStep, testCases: response.testCases || [] });
+ } catch (error) {
+ pushError(error, 'page.shared-steps.toast.usage-error');
+ setUsage(undefined);
+ } finally {
+ setUsageLoading(false);
+ }
+ };
+
+ const showHistory = async (sharedStep) => {
+ setHistory({ sharedStep, entries: [] });
+ setHistoryLoading(true);
+ try {
+ const response = await sharedStepRequests.getHistory(sharedStep._id);
+ setHistory({ sharedStep, entries: response.history || [] });
+ } catch (error) {
+ pushError(error, 'page.shared-steps.toast.history-error');
+ setHistory(undefined);
+ } finally {
+ setHistoryLoading(false);
+ }
+ };
+
+ if (!currentTeam || !currentTeam._id) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ }
+ onClick={() => openEditor(undefined)}
+ aria-label={intl.formatMessage({ id: 'page.shared-steps.button.add' })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading ? : (
+
+
+
+ |
+
+
+
+ |
+
+
+
+ {(row) => ((row.steps || []).length)} |
+
+
+
+ {(row) => {`v${row.version}`} } |
+
+
+
+
+ {(row) => (
+
+ openEditor(row)}>
+
+
+ |
+ showUsage(row)}>
+
+
+ |
+ showHistory(row)}>
+
+
+ |
+ setDeleteState({ open: true, sharedStep: row })}
+ >
+
+
+
+ )}
+ |
+
+
+ )}
+
+
+
setEditing(undefined)}>
+
+
+
+
+
+
+ {editing && (
+ <>
+ {cascadeWarning > 0 && (
+
+
+
+ )}
+
+
+ setEditing({ ...editing, name: value })}
+ />
+
+
+
+ setEditing({ ...editing, description: value })}
+ />
+
+
+ setEditing({ ...editing, steps })}
+ attachmentOwner={editing._id
+ ? { sharedStepId: editing._id } : undefined}
+ />
+ >
+ )}
+
+
+ setEditing(undefined)}>
+
+
+
+
+
+
+
+
+
setUsage(undefined)}>
+
+
+
+
+
+
+ {usageLoading && }
+ {usage && !usageLoading && usage.testCases.length === 0 && (
+
+
+
+ )}
+ {usage && !usageLoading && usage.testCases.map((testCase) => (
+
+ {testCase.title}
+ {`v${testCase.version}`}
+
+ ))}
+
+
+
+
setHistory(undefined)}>
+
+
+
+
+
+
+
+
+
+
+
setDeleteState({ open: false, sharedStep: undefined })}
+ />
+
+ );
+};
+
+const mapStateToProps = (state) => ({ currentTeam: state.teamsReducer.currentTeam });
+
+export default connect(mapStateToProps)(SharedStepsPage);
diff --git a/src/components/pages/shared-steps/styles.less b/src/components/pages/shared-steps/styles.less
new file mode 100644
index 0000000..bc90dd2
--- /dev/null
+++ b/src/components/pages/shared-steps/styles.less
@@ -0,0 +1,15 @@
+// ── Shared steps ─────────────────────────────────────────────────────────────
+.shared-steps-page {
+ .shared-steps-search {
+ max-width: 320px;
+ }
+
+ .shared-steps-usage-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: var(--space-2) 0;
+ border-bottom: 1px solid var(--border-subtle);
+ }
+}
diff --git a/src/components/pages/team-settings/TeamSettingsPage.js b/src/components/pages/team-settings/TeamSettingsPage.js
index 546b153..bd7d9ac 100644
--- a/src/components/pages/team-settings/TeamSettingsPage.js
+++ b/src/components/pages/team-settings/TeamSettingsPage.js
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { connect } from 'react-redux';
-import Cookies from 'js-cookie';
import { Container, Content, Panel, Form, ButtonToolbar, Button, Message, useToaster, SelectPicker, TagInput, Tag, Divider } from 'rsuite';
import { useAuth } from '../../../context/AuthContext';
import { useRouter } from 'next/navigation';
@@ -35,10 +34,25 @@ function TeamSettingsPage(props) {
}
}, [user, isLoading, router]);
+ // Seeds the edit target ONCE, then leaves it alone. The guard is
+ // `selectedTeamId === undefined` — "nothing chosen yet" — not a comparison
+ // against `currentTeam`.
+ //
+ // Deliberately not kept in sync with the header afterwards, in either
+ // direction: which team you are configuring is a separate question from
+ // which team the app is scoped to. Wanting to rename Team B or add a
+ // component to it says nothing about wanting to leave Team A's dashboards
+ // and test runs. An earlier version followed `currentTeam` here, which
+ // meant a header switch silently swapped the form out from under whatever
+ // was being edited.
+ //
+ // The header is still the sensible starting point, so it is what the page
+ // opens on.
useEffect(() => {
- if (!selectedTeamId && currentTeam && currentTeam._id) {
+ if (selectedTeamId !== undefined) return;
+ if (currentTeam && currentTeam._id) {
setSelectedTeamId(currentTeam._id);
- } else if (!selectedTeamId && teams && teams.length > 0) {
+ } else if (teams && teams.length > 0) {
setSelectedTeamId(teams[0]._id);
}
}, [teams, currentTeam, selectedTeamId]);
@@ -74,14 +88,19 @@ function TeamSettingsPage(props) {
}
};
+ // Kept as an in-page control, unlike the filters on Dashboard and Metrics.
+ // Here the team is the object being edited — renamed, its components
+ // changed — not a filter over the page's content, so it belongs in the form
+ // next to the fields it governs.
+ //
+ // Purely local: it does NOT write `currentTeam` or the `teamId` cookie.
+ // Editing another team's configuration is not a statement that you want to
+ // work in that team, and it used to be treated as one — opening this page
+ // and picking Team B to rename it would move the whole app, for a year,
+ // onto Team B. Switching context stays the header picker's job.
const handleTeamChange = (teamId) => {
if (!teamId) return;
setSelectedTeamId(teamId);
- const team = teams.find((t) => t._id === teamId);
- if (team) {
- saveCurrentTeam(team);
- Cookies.set('teamId', teamId, { expires: 365 });
- }
};
const handleSaveName = async () => {
@@ -90,6 +109,15 @@ function TeamSettingsPage(props) {
await axios.put(`/team/${selectedTeamId}`, { name: nameInput });
toaster.push({intl.formatMessage({ id: 'page.team-settings.toast.name-update-success' })} , { placement: 'topEnd' });
await refreshTeams();
+ // The one case that legitimately touches global state — and it
+ // updates the selected team's NAME, never the selection. The header
+ // renders `currentTeam.name` from its own copy in the store, which
+ // `refreshTeams` does not touch, so renaming the team you are
+ // currently scoped to would otherwise leave the old name in the
+ // header until a reload.
+ if (currentTeam && currentTeam._id === selectedTeamId) {
+ saveCurrentTeam({ ...currentTeam, name: nameInput });
+ }
await fetchTeamDetails(selectedTeamId);
} catch (error) {
toaster.push({error.response?.data?.errors?.[0]?.msg || intl.formatMessage({ id: 'page.team-settings.toast.name-update-error' })} , { placement: 'topEnd' });
@@ -133,6 +161,10 @@ function TeamSettingsPage(props) {
) : (
+ {/* "Team to configure", not "Team": this control no longer
+ mirrors the header picker, so a bare label would read as
+ the current team and make an edit to a different one look
+ like it had switched the whole app. */}
({ label: team.name, value: team._id }))}
@@ -142,6 +174,7 @@ function TeamSettingsPage(props) {
searchable={false}
block
/>
+
diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js
index f3a295e..14ad868 100644
--- a/src/context/AuthContext.js
+++ b/src/context/AuthContext.js
@@ -24,6 +24,13 @@ const normaliseUser = (userData) => {
return userData;
};
+// Feature toggles are admin-controlled and arrive with the auth config. A toggle is only
+// off when the API says so: while the config is still loading, and if it fails to load
+// entirely, a feature is treated as enabled so nothing flickers out of the navigation.
+const isFeatureEnabled = (config, feature) => (
+ config?.features?.[feature] !== false
+);
+
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [authConfig, setAuthConfig] = useState(null);
@@ -35,8 +42,10 @@ export const AuthProvider = ({ children }) => {
setAuthConfig(response.data);
} catch (error) {
console.error('Failed to load auth config', error);
- // Default config fallback: local login only, so the page stays usable.
- setAuthConfig({ localAuthEnabled: true, providers: [] });
+ // Default config fallback: local login only, so the page stays usable. Features
+ // default to on for the same reason - a config fetch failure must not make the
+ // app look like an admin had disabled half of it.
+ setAuthConfig({ localAuthEnabled: true, providers: [], features: {} });
}
};
@@ -102,6 +111,10 @@ export const AuthProvider = ({ children }) => {
value={{
user,
authConfig,
+ // False until the config has actually been read, so a guard can tell
+ // "not loaded yet" from "an admin turned this off".
+ featuresLoaded: authConfig !== null,
+ manualTestingEnabled: isFeatureEnabled(authConfig, 'manualTestingEnabled'),
isLoading,
login,
loginWithProvider,
diff --git a/src/styles/components.less b/src/styles/components.less
index 941163e..30e939a 100644
--- a/src/styles/components.less
+++ b/src/styles/components.less
@@ -129,6 +129,41 @@
background-color: var(--skipped-color);
}
+// ── Status symbols (accessible themes) ───────────────────────────────────────
+// Four statuses cannot be separated by colour alone for someone with colour
+// vision deficiency. Measured against the shipped palette, PASS and FAIL sit at
+// 1.18:1 under simulated deuteranopia — effectively identical, where 3:1 is the
+// bar for non-text. Re-picking hues does not fix it: even the Okabe-Ito palette,
+// designed for dichromats, tops out near 1.2:1 on its closest pair once four
+// categories have to be told apart.
+//
+// So the accessible themes encode status twice: colour, plus a symbol that
+// survives having no colour perception at all (and survives greyscale printing).
+// This is opt-in per theme rather than global, because the other eight themes
+// were signed off on their current appearance.
+//
+// The symbol is decorative — the visible label already carries the meaning for
+// a screen reader, so `aria-hidden` keeps it out of the accessibility tree.
+:root[data-theme='clay-light'],
+:root[data-theme='clay-dark'] {
+ .status-pass::before,
+ .status-fail::before,
+ .status-error::before,
+ .status-skipped::before {
+ display: inline-block;
+ width: 1em;
+ margin-right: var(--space-1);
+ font-weight: var(--font-weight-bold);
+ text-align: center;
+ speak: never;
+ }
+
+ .status-pass::before { content: '\2713'; } // check
+ .status-fail::before { content: '\2717'; } // ballot X
+ .status-error::before { content: '\0021'; } // exclamation
+ .status-skipped::before { content: '\2013'; } // en dash
+}
+
// ── Inline alerts ────────────────────────────────────────────────────────────
// Themed replacement for the old Bootstrap `.alert` blocks — loading and
// empty/error states inside page content.
@@ -153,6 +188,10 @@
border-left-color: var(--fail-color);
}
+.app-alert-success {
+ border-left-color: var(--pass-color);
+}
+
// ── Generic page chrome ──────────────────────────────────────────────────────
// Shared by the settings/about/not-found style pages so each page's LESS only
// carries what is genuinely page-specific.
@@ -170,10 +209,23 @@
}
}
+// Title on the left, action affordances pushed to the trailing edge, so a panel's
+// primary action always sits in the same place rather than trailing the title.
.page-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
color: var(--main-panel-font-color);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-2xl);
+
+ // The title carries the section-title bottom margin; the header row owns the
+ // spacing instead so the action button stays vertically centred against it.
+ .page-section-title {
+ margin-bottom: 0;
+ }
}
.page-section-title {
@@ -216,7 +268,18 @@
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
-// Row action links (tables, lists).
+// Row action links (tables, lists). These are usually elements — an action
+// is not a navigation — so the native control chrome is stripped to make them read
+// as links rather than as tiny boxed buttons.
+.link-action,
+.link-danger {
+ padding: 0;
+ border: none;
+ background: none;
+ font: inherit;
+ cursor: pointer;
+}
+
.link-action {
color: var(--link-color-1);
@@ -328,17 +391,115 @@
}
// ── Brand logo (sidebar + login) ─────────────────────────────────────────────
-.brand-logo-icon {
- height: 50px;
- display: inline;
- margin-top: -3px;
+// ── Brand logo ───────────────────────────────────────────────────────────────
+// Inline SVG (components/common/BrandLogo.js) rather than a PNG, so the mark
+// follows the theme's accent instead of staying orange on every palette. See
+// that file for why. Colour lives here; geometry lives in the component.
+.brand-logo {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ // The outline inherits this, so the mark sits on whatever surface it is
+ // placed on without a per-context override.
+ color: var(--main-panel-font-color);
}
+.brand-logo-icon {
+ height: 44px;
+ width: auto;
+ flex: 0 0 auto;
+}
+
+// The viewBox wraps the painted wordmark including its outline (see
+// BrandLogo.js), so this is the height of the whole mark, box and all.
+//
+// 53.2px = 48.4 x 1.1. The 48.4 keeps the glyphs at the size they were when
+// the box was 50 units tall (44 x 55/50) — the box grew to 55 units to stop
+// clipping the stroke, and without that correction the same 44px would have
+// silently shrunk the letters. The 1.1 is the requested 10% increase on top.
.brand-logo-text {
- height: 55px;
- padding-left: 6px;
- vertical-align: middle;
- display: inline;
+ height: 53.2px;
+ width: auto;
+ flex: 0 0 auto;
+}
+
+// Cube faces, lit from above-left: the top takes the full accent, the right
+// face its shaded variant, and the left face the accent again at low opacity so
+// it reads as a dim plane of the SAME material.
+//
+// Opacity rather than a second colour token on purpose — `--master-font-color-2`
+// (the on-accent tone) was tried first and is near-black on Slate Dark, which
+// turned one face into a hole. Fading the accent over whatever surface the mark
+// sits on keeps the shading consistent in every theme.
+.brand-cube-top {
+ fill: var(--color-primary);
+}
+
+.brand-cube-right {
+ fill: var(--color-primary-hover);
+}
+
+.brand-cube-left {
+ fill: var(--color-primary);
+ opacity: 0.35;
+}
+
+.brand-cube-edge {
+ stroke: currentColor;
+ stroke-width: 3;
+ stroke-linejoin: round;
+ opacity: 0.55;
+}
+
+// Saira Condensed, self-hosted via next/font (see app/layout.js) and reached
+// through `--font-brand`. Only the wordmark uses it — body text stays on the
+// system stack, per the rule against introducing new font families for UI text.
+// The fallback is a condensed system face so a font failure degrades to
+// something with roughly the right proportions rather than to a wide default.
+.brand-wordmark {
+ fill: var(--color-primary);
+ font-family: var(--font-brand), 'Arial Narrow', 'Helvetica Neue', sans-serif;
+ font-size: 52px;
+ font-weight: 600;
+ letter-spacing: 0.005em;
+ // The original wordmark carried a heavy dark outline; it is what makes the
+ // mark read on a mid-tone surface like Sepia or Clay, where a flat accent
+ // fill would otherwise sit too close to the panel. `paint-order` keeps the
+ // stroke behind the fill so it thickens the letter's edge rather than
+ // eating into its interior.
+ stroke: currentColor;
+ // In user units, not pixels: the viewBox is 50 units tall rendered at 44px,
+ // so this scales to ~2.3 CSS px and stays proportional at any logo size.
+ // The stroke is drawn centred on the outline, so half of it eats into the
+ // glyph and the counters in 'g' and 'e' close first — that is the limit to
+ // watch when changing this.
+ stroke-width: 2.6;
+ paint-order: stroke fill;
+ stroke-linejoin: round;
+}
+
+// Accessible themes render the mark in one tone. On Clay the accent is the
+// Okabe-Ito blue also used for PASS, and a two-tone logo competes with the
+// status colours it should be staying clear of.
+:root[data-theme='clay-light'] .brand-logo,
+:root[data-theme='clay-dark'] .brand-logo {
+ .brand-cube-top,
+ .brand-cube-right,
+ .brand-wordmark {
+ fill: currentColor;
+ }
+
+ .brand-cube-right {
+ opacity: 0.72;
+ }
+
+ .brand-cube-left {
+ fill: transparent;
+ }
+
+ .brand-cube-edge {
+ opacity: 1;
+ }
}
// ── Password requirements checklist (PasswordRequirements component) ─────────
@@ -454,6 +615,36 @@
}
}
+ // `appearance="subtle"` navs (the test-case detail tabs) are a different
+ // rsuite skin from `.rs-nav-tabs` above: the active tab is marked by an
+ // underline bar rather than a filled tab. The accent itself now comes from
+ // `--rs-navs-selected` in the variable bridge (rsuite-overrides.less); what
+ // stays here is this skin's own weight and inactive-label treatment.
+ .rs-nav-subtle {
+ .rs-nav-item {
+ color: var(--inactive-tab-font-color);
+
+ &:hover,
+ &:focus {
+ color: var(--color-primary);
+ }
+
+ &.rs-nav-item-active {
+ color: var(--color-primary);
+ font-weight: var(--font-weight-semibold);
+
+ // The underline bar under the active tab.
+ &::before {
+ background-color: var(--color-primary);
+ }
+ }
+ }
+
+ .rs-nav-bar {
+ border-top-color: var(--border-subtle);
+ }
+ }
+
.rs-tab-panel {
border-radius: var(--radius-lg);
background: var(--tabs-overall-background);
diff --git a/src/styles/index.less b/src/styles/index.less
index 60fc349..a34387e 100644
--- a/src/styles/index.less
+++ b/src/styles/index.less
@@ -12,8 +12,10 @@
// pages/* → page- and feature-specific styles (co-located with components)
// ============================================================================
-// Design tokens — colour must load first (surface/accent scales reference it),
-// then the non-colour scales.
+// Design tokens — the theme contract documents what a theme must supply and
+// derives what it should not; colour loads next (surface/accent scales
+// reference it), then the non-colour scales.
+@import 'tokens/_theme-contract.less';
@import 'tokens/_color.less';
@import 'tokens/_space.less';
@import 'tokens/_radius.less';
@@ -32,6 +34,11 @@
@import '../components/pages/admin-settings/styles.less';
@import '../components/pages/dashboard/styles.less';
@import '../components/pages/login/styles.less';
+@import '../components/pages/manual-test-cases/styles.less';
+@import '../components/pages/manual-test-case-version/styles.less';
+@import '../components/pages/manual-test-runs/styles.less';
+@import '../components/pages/shared-steps/styles.less';
+@import '../components/pages/admin-custom-fields/styles.less';
@import '../components/pages/metrics/styles.less';
@import '../components/pages/screenshot-library/styles.less';
@import '../components/pages/team-settings/styles.less';
@@ -40,7 +47,14 @@
@import '../components/pages/user-settings/styles.less';
// ── Co-located feature styles (components/features/*) ────────────────────────
+@import '../components/features/attachment-upload/styles.less';
+@import '../components/features/change-history/styles.less';
+@import '../components/features/custom-field-form/styles.less';
+@import '../components/features/folder-tree/styles.less';
@import '../components/features/execution-timeline/styles.less';
+@import '../components/features/manual-step-editor/styles.less';
@import '../components/features/results-bar/styles.less';
@import '../components/features/screenshot-view/styles.less';
+@import '../components/features/version-badge/styles.less';
+@import '../components/features/version-diff/styles.less';
@import '../components/features/test-suite/styles.less';
diff --git a/src/styles/layout.less b/src/styles/layout.less
index 6f153db..bb9d0ab 100644
--- a/src/styles/layout.less
+++ b/src/styles/layout.less
@@ -41,7 +41,14 @@
min-height: 100vh;
}
+ // Centred rather than left to the text baseline: the logo is an inline-flex
+ // whose height changes with the sidebar state (the wordmark is dropped when
+ // collapsed), and on a baseline-aligned inline element that shrinking box
+ // dragged the cube ~4.6px upwards as the menu closed. Centring pins the cube
+ // to the middle of the header in both states.
.sidebar-header {
+ display: flex;
+ align-items: center;
padding: 3px 2px 3px 5px;
height: 60px;
overflow: hidden;
@@ -170,6 +177,85 @@
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
+}
+
+// Menus opened from the right-hand navbar are placed `bottom-start` by rsuite,
+// i.e. anchored to their toggle's LEFT edge, so a menu wider than its toggle
+// grows rightwards and off-screen. The theme picker is the first menu wide
+// enough to hit this (its group headings are wider than the theme names).
+// Re-anchor to the toggle's right edge for the right-aligned navbar only.
+.main-container .rs-navbar .rs-navbar-right .rs-dropdown-menu {
+ left: auto;
+ right: 0;
+}
+
+// The team menu's toggle shows the selected team's name, which is user data and
+// can be long. Cap it so a verbose team name cannot push the language, theme and
+// profile menus off the header; the full name is still visible in the open menu.
+//
+// Targeted at `> .rs-navbar-item` deliberately: inside a Navbar, rsuite renders
+// the Nav.Menu toggle as `.rs-navbar-item` (NOT `.rs-dropdown-toggle`), and it
+// copies the component's className onto both the wrapper and the dropdown ``
+// — so an unqualified `.nav-team-menu` rule would truncate the open menu's items
+// as well as the toggle.
+.main-container .rs-navbar .nav-team-menu > .rs-navbar-item {
+ display: inline-flex;
+ align-items: center;
+ max-width: 260px;
+
+ // The label is the only part allowed to shrink: the icon and the caret keep
+ // their intrinsic size, so a long name ellipsises between them instead of
+ // pushing the caret out of the toggle. `min-width: 0` is required for a flex
+ // item to shrink below its content width at all.
+ > .rs-navbar-item-caret,
+ > .rs-navbar-item-icon {
+ flex: 0 0 auto;
+ }
+
+ .nav-team-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ min-width: 0;
+ }
+
+ // "Team" is the caption; the team name is the value. Muted and smaller so
+ // the pair reads as one control rather than two competing words, and never
+ // shrinking so the name is what gives way when space runs out.
+ //
+ // `!important` is required, not preference: the navbar rules above force
+ // `color: inherit !important` on every descendant of `.rs-navbar-item` to
+ // stop rsuite colouring its own children, and that blanket rule would
+ // otherwise flatten this caption to the same tone as the value.
+ .nav-team-label {
+ flex: 0 0 auto;
+ color: var(--nav-muted-font-color) !important;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-normal);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+
+ // Punctuation, not wording: kept out of the `nav.team` string so the
+ // catalogues hold the label alone. Locales that punctuate differently
+ // (a space before the colon in French, a full-width one in CJK) can then
+ // override it here rather than each catalogue smuggling in a glyph.
+ // `letter-spacing` above adds a trailing gap after the colon, so the
+ // margin cancels it to keep the spacing even on both sides.
+ &::after {
+ content: ':';
+ margin-right: -0.06em;
+ }
+ }
+
+ .nav-team-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.rs-dropdown-menu {
.rs-dropdown-item {
color: var(--nav-font-color) !important;
@@ -183,6 +269,104 @@
}
}
+ // ── Theme picker rows ───────────────────────────────────────────────────
+ // Swatch + name on one baseline-aligned row. The swatch is a fixed-width
+ // leading column so the chips form a straight edge down the list.
+ .nav-theme-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ }
+
+ .nav-theme-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .theme-swatch {
+ display: inline-flex;
+ flex: 0 0 auto;
+ gap: 2px;
+ }
+
+ // The chips are the one place in a dropdown item where background colour
+ // IS the content, so they have to opt out of the blanket
+ // `.rs-dropdown-item * { background-color: transparent !important }`
+ // above. Matching `!important` at greater specificity is the only way to
+ // beat it; the alternative — narrowing that rule to exclude the swatch —
+ // would put the exception far from the thing it exists for.
+ .theme-swatch-chip {
+ width: 10px;
+ height: 14px;
+ border-radius: var(--radius-xs);
+
+ // Without a hairline a light theme's near-white surface chip is
+ // invisible against a light menu, and a dark theme's text chip against
+ // a dark one.
+ //
+ // Built from `--theme-ink-rgb` — a primitive — rather than the derived
+ // `--border-subtle`, for the same reason the fills below use
+ // primitives: `--border-subtle` is computed once at `:root` and
+ // inherited already-resolved, so it would carry the ACTIVE theme's ink
+ // polarity onto every swatch. Read locally, the hairline inverts with
+ // the theme the chip is previewing, which is what keeps it visible on
+ // a light chip and a dark one alike.
+ border: 1px solid rgba(var(--theme-ink-rgb), 0.25);
+ }
+
+ // These read PRIMITIVES, not the semantic tokens they correspond to
+ // (`--main-panel-background` / `--main-panel-font-color`), and that is
+ // load-bearing rather than incidental.
+ //
+ // Only the primitive layer is per-theme. The semantic layer is declared
+ // once, at `:root`, as `var()` references to the primitives — so it is
+ // resolved at the root and *inherited already-computed* by everything
+ // below. A nested `data-theme` island overrides the primitives locally,
+ // but nothing re-derives the semantic tokens from them, so a swatch asking
+ // for `--main-panel-background` gets the ACTIVE theme's value, not its own.
+ // That is exactly what happened first time round: all twelve rows previewed
+ // the same surface and text, and only the accent (a primitive) varied.
+ //
+ // The pairs are identities in the semantic layer above
+ // (`--main-panel-background: var(--master-color-2)`), so the chips show the
+ // same colours the panel does — just resolved a layer lower down where the
+ // theme actually applies.
+ .theme-swatch-chip-surface {
+ background-color: var(--master-color-2) !important;
+ }
+
+ .theme-swatch-chip-accent {
+ background-color: var(--master-color-3) !important;
+ }
+
+ .theme-swatch-chip-text {
+ background-color: var(--master-font-color-1) !important;
+ }
+
+ // Section heading inside a nav menu (the theme picker groups themes into
+ // Light / Dark). A label, not an option: no hover, no pointer, and muted
+ // so it reads as structure rather than a disabled choice.
+ .nav-menu-group-label {
+ padding: var(--space-2) var(--space-4) var(--space-1);
+ color: var(--main-panel-secondary-font-color);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: default;
+ pointer-events: none;
+ }
+
+ // Divide each group from the one above. Keyed on "a label with any label
+ // before it" via the general sibling combinator: rsuite renders menu items
+ // as `` too, so `:first-of-type` cannot tell a heading from an option.
+ .nav-menu-group-label ~ .nav-menu-group-label {
+ border-top: 1px solid var(--border-subtle);
+ margin-top: var(--space-1);
+ }
+
// Hover / focus / active. RSuite marks the current item with its own
// `-item-focus` / `-menu-item-focus` classes (default light-blue bg), so we
// must cover those too or the selected row keeps RSuite's colours.
diff --git a/src/styles/rsuite-overrides.less b/src/styles/rsuite-overrides.less
index 2a7786d..8f82f45 100644
--- a/src/styles/rsuite-overrides.less
+++ b/src/styles/rsuite-overrides.less
@@ -6,6 +6,270 @@
// components live in `components.less`.
// ============================================================================
+// ── RSuite variable bridge ───────────────────────────────────────────────────
+// RSuite drives every widget's colour through its own `--rs-*` custom
+// properties, and ships a full dark palette for them under `.rs-theme-dark`.
+// This app never sets that class — it themes via `[data-theme]` on :root — so
+// unless a variable is remapped here, the widget keeps rsuite's *light* default
+// in every theme (`--rs-bg-card` stays #fff, `--rs-text-primary` stays
+// near-black, and so on) and disappears against a dark surface.
+//
+// Pointing rsuite's variables at the Angles semantic tokens fixes all themes at
+// once, because the tokens themselves theme at the primitive layer. Prefer
+// adding a mapping here over writing a per-class rule below: a variable covers
+// every widget that reads it, including ones this app has not styled by hand.
+//
+// Only the variables whose defaults actually clash are remapped; anything not
+// listed keeps rsuite's own value deliberately.
+:root {
+ // ── Surfaces ─────────────────────────────────────────────────────────────
+ // `--rs-bg-overlay` is the surface behind floating chrome: it is what paints
+ // Drawer bodies (`.rs-drawer-content`) and Popovers, neither of which has a
+ // variable of its own.
+ --rs-bg-card: var(--main-panel-background);
+ --rs-bg-overlay: var(--select-picker-background);
+ --rs-bg-well: var(--surface-sunken);
+ --rs-bg-active: var(--color-primary);
+ --rs-bg-backdrop: var(--modal-overlay-background);
+ --rs-state-hover-bg: var(--sub-panel-background);
+
+ // ── Text ─────────────────────────────────────────────────────────────────
+ --rs-text-primary: var(--main-panel-font-color);
+ --rs-text-secondary: var(--main-panel-secondary-font-color);
+ --rs-text-tertiary: var(--sub-panel-secondary-font-color);
+ --rs-text-heading: var(--main-panel-font-color);
+ --rs-text-inverse: var(--color-primary-contrast);
+ --rs-text-disabled: var(--icon-disabled-color);
+ --rs-text-active: var(--color-primary);
+ --rs-text-link: var(--link-color-1);
+ --rs-text-link-hover: var(--color-primary-hover);
+ --rs-text-link-active: var(--color-primary-hover);
+
+ // ── Borders, dividers & focus ────────────────────────────────────────────
+ // Divider and Table both resolve from these rather than owning a colour.
+ --rs-border-primary: var(--border-color);
+ --rs-border-secondary: var(--border-subtle);
+ --rs-divider-border: var(--border-color);
+ --rs-color-focus-ring: var(--focus-ring);
+ --rs-state-focus-shadow: 0 0 0 3px var(--focus-ring);
+ --rs-state-focus-shadow-slim: 0 0 0 2px var(--focus-ring);
+ --rs-state-focus-outline: 3px solid var(--focus-ring);
+
+ // ── Status ───────────────────────────────────────────────────────────────
+ // Align rsuite's semantic states with the app's result-status hues so
+ // Message, Steps and form validation agree with the rest of the UI.
+ --rs-state-success: var(--pass-color);
+ --rs-state-error: var(--fail-color);
+ --rs-state-warning: var(--error-color);
+ --rs-state-info: var(--skipped-color);
+
+ // ── Buttons ──────────────────────────────────────────────────────────────
+ // Subtle buttons are the icon actions in panel headers and detail toolbars.
+ // At rest they read as plain icons in the panel's own font colour; on hover
+ // and while pressed they take the ember accent, matching how `.btn-primary`
+ // and the row-action links signal interactivity. The hover surface stays the
+ // sub-panel tone so the accent comes from the glyph rather than a filled
+ // block. The same variables colour other subtle-styled widgets (input-group
+ // buttons, uploader triggers, subtle pickers).
+ --rs-btn-subtle-text: var(--main-panel-font-color);
+ --rs-btn-subtle-hover-text: var(--color-primary);
+ --rs-btn-subtle-hover-bg: var(--sub-panel-background);
+ --rs-btn-subtle-active-text: var(--color-primary-hover);
+ --rs-btn-subtle-active-bg: var(--sub-panel-background);
+ --rs-btn-subtle-disabled-text: var(--icon-disabled-color);
+
+ // Default (secondary) buttons — rsuite's grey ramp reads as disabled on a
+ // dark panel.
+ --rs-btn-default-bg: var(--button-deactivated-background);
+ --rs-btn-default-text: var(--button-deactivated-font-color);
+ --rs-btn-default-hover-bg: var(--sub-panel-background);
+ --rs-btn-default-hover-text: var(--main-panel-font-color);
+ --rs-btn-default-active-bg: var(--sub-panel-background);
+ --rs-btn-default-active-text: var(--color-primary);
+ --rs-btn-default-disabled-bg: var(--button-deactivated-background);
+ --rs-btn-default-disabled-text: var(--icon-disabled-color);
+
+ // Primary / ghost / link buttons follow the ember accent rather than
+ // rsuite's blue.
+ --rs-btn-primary-bg: var(--button-background);
+ --rs-btn-primary-text: var(--button-font-color);
+ --rs-btn-primary-hover-bg: var(--button-hover-background);
+ --rs-btn-primary-active-bg: var(--color-primary-hover);
+ --rs-btn-ghost-border: var(--color-primary);
+ --rs-btn-ghost-text: var(--color-primary);
+ --rs-btn-ghost-hover-border: var(--color-primary-hover);
+ --rs-btn-ghost-hover-text: var(--color-primary-hover);
+ --rs-btn-link-text: var(--link-color-1);
+ --rs-btn-link-hover-text: var(--color-primary-hover);
+ --rs-btn-link-active-text: var(--color-primary-hover);
+
+ // ── Widgets with no per-class rule of their own ──────────────────────────
+ // Tooltip's default is a near-black chip that vanishes on a dark surface.
+ --rs-tooltip-bg: var(--sub-panel-background);
+ --rs-tooltip-text: var(--sub-panel-font-color);
+ --rs-tag-bg: var(--sub-panel-background);
+ --rs-toggle-bg: var(--master-color-2-relative);
+ --rs-toggle-hover-bg: var(--border-color);
+ --rs-toggle-checked-bg: var(--color-primary);
+ --rs-toggle-checked-hover-bg: var(--color-primary-hover);
+ --rs-toggle-disabled-bg: var(--button-deactivated-background);
+ --rs-checkbox-border: var(--border-color);
+ --rs-checkbox-checked-bg: var(--color-primary);
+ --rs-checkbox-disabled-bg: var(--button-deactivated-background);
+ --rs-radio-border: var(--border-color);
+ --rs-radio-checked-bg: var(--color-primary);
+ --rs-radio-disabled-bg: var(--button-deactivated-background);
+ --rs-breadcrumb-item-active-text: var(--main-panel-font-color);
+ --rs-timeline-indicator-bg: var(--border-color);
+ --rs-timeline-indicator-active-bg: var(--color-primary);
+ --rs-uploader-dnd-bg: var(--sub-panel-background);
+ --rs-uploader-dnd-border: var(--border-color);
+ --rs-uploader-dnd-hover-border: var(--color-primary);
+ --rs-uploader-item-bg: var(--border-color);
+ --rs-uploader-item-hover-bg: var(--sub-panel-background);
+ --rs-loader-ring: var(--border-subtle);
+ --rs-loader-rotor: var(--color-primary);
+ // Progress bars default to rsuite's blue. Not used in the app today, but
+ // mapped so the first one added inherits the theme rather than the vendor
+ // palette. Success/fail keep the app's own result hues.
+ --rs-progress-bg: var(--border-color);
+ --rs-progress-bar: var(--color-primary);
+ --rs-progress-bar-success: var(--pass-color);
+ --rs-progress-bar-fail: var(--fail-color);
+
+ // Table sort indicator and scrollbars.
+ --rs-table-sort: var(--color-primary);
+ --rs-table-border-color: var(--border-subtle);
+ --rs-table-scrollbar-track: var(--sub-panel-background);
+ --rs-table-scrollbar-thumb: var(--border-color);
+ --rs-table-scrollbar-thumb-active: var(--color-primary);
+ --rs-scrollbar-thumb-bg: var(--border-color);
+ --rs-scrollbar-thumb-hover-bg: var(--master-color-2-relative);
+
+ // Picker internals (selected value text, multi-select count badge).
+ --rs-picker-value: var(--color-primary);
+ --rs-picker-count-bg: var(--color-primary);
+ --rs-picker-count-text: var(--color-primary-contrast);
+
+ // Inputs. `.rs-input` already resolves its surface, border and text from
+ // these three plus `--rs-border-primary`/`--rs-text-primary` above, so the
+ // rule below only needs to add the app's radius and focus treatment.
+ --rs-input-bg: var(--body-background);
+ --rs-input-focus-border: var(--color-primary);
+ --rs-input-disabled-bg: var(--main-panel-background);
+
+ // Form validation messages.
+ --rs-form-errormessage-bg: var(--sub-panel-background);
+ --rs-form-errormessage-text: var(--fail-color);
+
+ // Navs / tabs — the selected marker (the underline bar on `appearance
+ // ="subtle"` navs and the active label) defaults to rsuite's blue.
+ --rs-navs-selected: var(--color-primary);
+ --rs-navs-text: var(--inactive-tab-font-color);
+ --rs-navs-text-hover: var(--color-primary);
+ --rs-navs-tab-border: var(--border-color);
+ --rs-navs-subtle-border: var(--border-subtle);
+
+ // Calendar popup (DatePicker / DateRangePicker).
+ --rs-calendar-today-bg: var(--color-primary);
+ --rs-calendar-today-text: var(--color-primary-contrast);
+ --rs-calendar-date-selected-text: var(--color-primary-contrast);
+ --rs-calendar-cell-selected-hover-bg: var(--color-primary-hover);
+ --rs-calendar-range-bg: var(--sub-panel-background);
+ --rs-calendar-time-unit-bg: var(--sub-panel-background);
+
+ // ── Message ──────────────────────────────────────────────────────────────
+ // rsuite fills each variant's inner container with a pale tint of its own
+ // palette (a 60% wash of e.g. `--rs-red-100`) and sets the body text to
+ // `--rs-text-primary`. Those tints are light-theme values with no dark
+ // counterpart here, so the near-white fill survived into dark mode while the
+ // themed text turned near-white with it — the alert became unreadable.
+ //
+ // Instead of tinting, give every variant the sub-panel surface and carry the
+ // meaning in the icon and the left stripe added below. That reads correctly
+ // on any theme because the tokens theme together, and it matches how
+ // `.app-alert` already styles the app's own inline alerts.
+ --rs-message-info-bg: var(--sub-panel-background);
+ --rs-message-info-border: var(--skipped-color);
+ --rs-message-info-icon: var(--skipped-color);
+ --rs-message-info-text: var(--sub-panel-font-color);
+ --rs-message-info-header: var(--sub-panel-font-color);
+
+ --rs-message-success-bg: var(--sub-panel-background);
+ --rs-message-success-border: var(--pass-color);
+ --rs-message-success-icon: var(--pass-color);
+ --rs-message-success-text: var(--sub-panel-font-color);
+ --rs-message-success-header: var(--sub-panel-font-color);
+
+ --rs-message-warning-bg: var(--sub-panel-background);
+ --rs-message-warning-border: var(--error-color);
+ --rs-message-warning-icon: var(--error-color);
+ --rs-message-warning-text: var(--sub-panel-font-color);
+ --rs-message-warning-header: var(--sub-panel-font-color);
+
+ --rs-message-error-bg: var(--sub-panel-background);
+ --rs-message-error-border: var(--fail-color);
+ --rs-message-error-icon: var(--fail-color);
+ --rs-message-error-text: var(--sub-panel-font-color);
+ --rs-message-error-header: var(--sub-panel-font-color);
+}
+
+// ── Message ─────────────────────────────────────────────────────────────────
+// The variant fills are mapped in the bridge above, but rsuite also paints the
+// *outer* `.rs-message-{variant}` element a literal `#fff` with no variable
+// behind it, which framed every alert in white on a dark surface. Clear it so
+// the themed container is the only painted surface.
+//
+// rsuite only draws the variant's accent border when the `bordered` prop is
+// set, which this app never passes. Carry the status hue as a left stripe
+// instead, matching `.app-alert`, so the variant stays readable without
+// depending on the tinted fill that was removed.
+.rs-message {
+ background-color: transparent;
+ border-left: 4px solid var(--border-color);
+
+ // The container is the painted surface. It must fill the outer element
+ // completely: the outer background is cleared above, so any strip it does
+ // not cover shows the page through the alert as a dark band along the edge.
+ .rs-message-container {
+ border-radius: 0;
+ height: 100%;
+ }
+}
+
+.rs-message-info {
+ background-color: transparent;
+ border-left-color: var(--skipped-color);
+}
+
+.rs-message-success {
+ background-color: transparent;
+ border-left-color: var(--pass-color);
+}
+
+.rs-message-warning {
+ background-color: transparent;
+ border-left-color: var(--error-color);
+}
+
+.rs-message-error {
+ background-color: transparent;
+ border-left-color: var(--fail-color);
+}
+
+// rsuite drives the subtle button's colour through the variables above, but its
+// focus-visible ring is a hard-coded blue box-shadow. Match it to the app's
+// focus ring so keyboard focus reads the same as every other control.
+.rs-btn-subtle {
+ transition: color var(--transition-fast), background-color var(--transition-fast);
+
+ &:focus-visible {
+ color: var(--color-primary);
+ box-shadow: 0 0 0 3px var(--focus-ring);
+ outline: none;
+ }
+}
+
// ── Table ───────────────────────────────────────────────────────────────────
.rs-table {
background-color: transparent;
@@ -29,6 +293,18 @@
font-size: var(--font-size-sm);
}
+ // On hover rsuite paints the header's cells with `--rs-bg-card`. That is
+ // themed by the bridge above, but it is the *panel* surface rather than
+ // the header's accent fill, so it still sits visibly behind our
+ // `.rs-table-cell-content`. The header is not interactive — keep the
+ // cells transparent so the content layer stays the only painted surface.
+ &:hover {
+ .rs-table-cell-group,
+ .rs-table-cell {
+ background-color: transparent;
+ }
+ }
+
.rs-table-cell-first {
border-top-left-radius: var(--radius-md);
@@ -67,6 +343,18 @@
font-size: var(--font-size-sm);
}
+ // rsuite tints a hovered row with `--rs-state-hover-bg`. The bridge above
+ // themes that variable, so the tint now tracks the theme — but these
+ // tables are not row-clickable, so any highlight signals an interaction
+ // that does not exist. Hold the resting background instead.
+ .rs-table-row:hover {
+ &,
+ .rs-table-cell-group,
+ .rs-table-cell {
+ background-color: var(--main-table-body-background);
+ }
+ }
+
.rs-table-row:last-child {
.rs-table-cell-first {
border-bottom-left-radius: var(--radius-md);
@@ -145,9 +433,10 @@
}
// ── Modals ───────────────────────────────────────────────────────────────────
-// RSuite ships a light-only stylesheet, so modal chrome must be themed to track
-// dark/light. Applies to every RSuite dialog (add/edit user, generate token,
-// change password) as well as ConfirmModal.
+// Modal chrome resolves from `--rs-bg-overlay` and `--rs-text-heading`, both
+// bridged above, so only the surfaces that need the *modal* tone rather than the
+// generic overlay tone are set here. Applies to every RSuite dialog (add/edit
+// user, generate token, change password) as well as ConfirmModal.
.rs-modal-content {
background-color: var(--modal-background);
color: var(--modal-font-color);
@@ -158,32 +447,12 @@
background-color: var(--modal-background);
}
-.rs-modal-title {
- color: var(--modal-font-color);
-}
-
-.rs-modal-body {
- color: var(--modal-font-color);
-}
-
-.rs-modal-header .rs-modal-header-close {
- color: var(--modal-secondary-font-color);
-}
-
// ── Form controls ────────────────────────────────────────────────────────────
-// Themes labels, inputs and input groups everywhere (modals, filters, search).
-.rs-form-control-label {
- color: var(--main-panel-font-color);
-}
-
-.rs-form-help-text {
- color: var(--sub-panel-secondary-font-color);
-}
-
+// Labels, help text, inputs and input groups everywhere (modals, filters,
+// search). Colour comes from the bridge — `.rs-input` already reads
+// `--rs-input-bg`, `--rs-border-primary` and `--rs-text-primary` — so what is
+// left here is the app's radius and focus ring.
.rs-input {
- background-color: var(--body-background);
- color: var(--main-panel-font-color);
- border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
@@ -193,15 +462,11 @@
&:focus,
&:focus-visible {
- background-color: var(--body-background);
- color: var(--main-panel-font-color);
- border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
}
&:disabled {
- background-color: var(--main-panel-background);
color: var(--sub-panel-secondary-font-color);
}
}
diff --git a/src/styles/tokens/_color.less b/src/styles/tokens/_color.less
index 93777a9..8fd9f5a 100644
--- a/src/styles/tokens/_color.less
+++ b/src/styles/tokens/_color.less
@@ -12,12 +12,27 @@
// semantic layer is declared once against the primitives; switching theme just
// overrides the primitives.
//
+// The primitives a theme must supply — and the contrast relationships it owes —
+// are specified in `_theme-contract.less`. Read that before adding a theme.
+//
// Light is the default (`:root`). Dark applies on explicit selection
// (`html[data-theme="dark"]`) or OS preference when the user has not chosen light.
// ============================================================================
-// ── Dark primitive overrides (shared by both dark triggers) ─────────────────
-.theme-dark-primitives() {
+// ── Theme: Ember Dark ───────────────────────────────────────────────────────
+// The reference implementation of the theme contract. Every entry is a
+// REQUIRED primitive; nothing derived is restated.
+//
+// Still a mixin rather than a bare rule set because it is applied at two
+// selectors — the explicit choice and the OS-preference fallback — and LESS
+// cannot alias a rule set to two selectors without duplicating it. A theme is
+// still authored as one block; only the two `.theme-ember-dark()` call sites
+// below know how it is triggered.
+.theme-ember-dark() {
+ // Polarity — drives the derived hairline, scrim and elevation tokens.
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
// Warm near-black neutrals (from the logo outline)
--master-color-1: hsl(30, 8%, 17%);
--master-color-2: hsl(24, 9%, 12%);
@@ -26,32 +41,402 @@
// Ember accent — brighter on dark for contrast
--master-color-3: var(--orange-400);
--master-color-3-relative: var(--orange-500);
+ --accent-rgb: 246, 173, 85; // = --orange-400, for the derived focus ring
// Cream / sand text on dark
--master-font-color-1: hsl(40, 30%, 93%);
--master-font-color-2: hsl(40, 33%, 97%);
--master-font-color-3: hsl(38, 12%, 76%);
+ // Muted neutral — darker than the light value so disabled chrome recedes
+ // into the dark panel instead of glowing above the accent.
+ --master-color-muted: hsl(30, 6%, 45%);
+
// Status — brighter variants so they read on a dark surface
--pass-color: #6cc07e;
--fail-color: #ff7b85;
--error-color: #f9c65b;
--skipped-color: #38bdf8;
- // Focus ring on dark
- --focus-ring: rgba(246, 173, 85, 0.5);
+ // NOTE: --focus-ring, --border-subtle and --modal-overlay-background are
+ // NOT set here. They derive from the polarity and accent primitives above
+ // (see `_theme-contract.less` §2), so a theme never restates them.
- // Elevation: shadows read poorly on dark → drop shadow, use a hairline border
- --card-shadow: none;
- --card-border-color: rgba(255, 255, 255, 0.08);
-
- // Tokens whose light value is a literal (cannot inherit via a primitive)
- --border-subtle: rgba(255, 255, 255, 0.12);
+ // Timeline dot inverts against the surface it sits on.
--timeline-dot-background: var(--master-color-1);
+
+ // Chart series for a dark ground — see `.chart-series-dark()` below. Kept
+ // out of the theme's own list because it is a property of the *polarity*,
+ // not of Ember & Sand: any dark theme wants this ramp.
+ .chart-series-dark();
}
-:root {
- // ── Primitive palette (light) ───────────────────────────────────────────
+// ── Theme: Slate Light ──────────────────────────────────────────────────────
+// A cool, high-contrast alternative to Ember & Sand: neutral blue-grey
+// surfaces, white sub-panels, a blue accent. For people who find the warm
+// palette distracting.
+.theme-slate-light() {
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
+ --master-color-1: hsl(215, 24%, 98%);
+ --master-color-2: hsl(215, 18%, 93%);
+ --master-color-2-relative: hsl(215, 16%, 52%);
+
+ --master-font-color-1: hsl(215, 28%, 17%);
+ --master-font-color-2: hsl(0, 0%, 100%);
+ --master-font-color-3: hsl(215, 14%, 38%);
+
+ --master-color-3: hsl(211, 80%, 43%);
+ --master-color-3-relative: hsl(211, 84%, 35%);
+ --accent-rgb: 20, 105, 198;
+
+ // Lighter than the accent: on a light theme "less prominent" means nearer
+ // the panel, so muted sits above the accent in lightness, not below.
+ --master-color-muted: hsl(215, 12%, 54%);
+
+ --pass-color: hsl(140, 42%, 38%);
+ --fail-color: hsl(355, 72%, 50%);
+ --error-color: hsl(38, 88%, 38%);
+ --skipped-color: hsl(199, 89%, 42%);
+
+ .chart-series-light();
+}
+
+// ── Theme: Sepia ────────────────────────────────────────────────────────────
+// Warm and low-glare, for long reading sessions: paper-toned surfaces, a
+// muted terracotta accent, less contrast between panel and sub-panel than the
+// other light themes.
+.theme-sepia() {
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
+ --master-color-1: hsl(38, 48%, 95%);
+ --master-color-2: hsl(38, 42%, 89%);
+ --master-color-2-relative: hsl(34, 28%, 46%);
+
+ --master-font-color-1: hsl(25, 30%, 20%);
+ --master-font-color-2: hsl(38, 44%, 98%);
+ --master-font-color-3: hsl(28, 18%, 38%);
+
+ --master-color-3: hsl(18, 62%, 38%);
+ --master-color-3-relative: hsl(18, 64%, 30%);
+ --accent-rgb: 157, 78, 37;
+
+ --master-color-muted: hsl(28, 14%, 50%);
+
+ --pass-color: hsl(140, 42%, 38%);
+ --fail-color: hsl(355, 72%, 50%);
+ --error-color: hsl(38, 88%, 37%);
+ --skipped-color: hsl(199, 89%, 41%);
+
+ .chart-series-light();
+}
+
+// ── Theme: Slate Dark ───────────────────────────────────────────────────────
+// The cool counterpart to Ember Dark: blue-grey surfaces and a sky accent
+// instead of warm near-black and orange.
+.theme-slate-dark() {
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
+ --master-color-1: hsl(215, 25%, 18%);
+ --master-color-2: hsl(215, 28%, 12%);
+ // 43% rather than 42%: at 42% the border lands on 2.99:1 against the panel,
+ // a hair under the 3:1 the contract asks of non-text boundaries.
+ --master-color-2-relative: hsl(215, 15%, 43%);
+
+ --master-font-color-1: hsl(210, 20%, 93%);
+ --master-font-color-2: hsl(215, 28%, 10%);
+ --master-font-color-3: hsl(212, 14%, 74%);
+
+ --master-color-3: hsl(199, 85%, 62%);
+ --master-color-3-relative: hsl(199, 85%, 54%);
+ --accent-rgb: 74, 190, 240;
+
+ --master-color-muted: hsl(215, 12%, 45%);
+
+ --pass-color: hsl(140, 45%, 60%);
+ --fail-color: hsl(355, 80%, 68%);
+ --error-color: hsl(40, 90%, 62%);
+ --skipped-color: hsl(199, 90%, 66%);
+
+ .chart-series-dark();
+}
+
+// ── Theme: Midnight ─────────────────────────────────────────────────────────
+// Near-black and maximum contrast, for dim rooms and OLED screens. Pure
+// neutral greys so the amber accent is the only colour in the chrome.
+.theme-midnight() {
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
+ --master-color-1: hsl(0, 0%, 11%);
+ --master-color-2: hsl(0, 0%, 5%);
+ --master-color-2-relative: hsl(0, 0%, 45%);
+
+ --master-font-color-1: hsl(0, 0%, 100%);
+ --master-font-color-2: hsl(0, 0%, 4%);
+ --master-font-color-3: hsl(0, 0%, 78%);
+
+ --master-color-3: hsl(35, 100%, 66%);
+ --master-color-3-relative: hsl(35, 100%, 58%);
+ --accent-rgb: 255, 186, 82;
+
+ --master-color-muted: hsl(0, 0%, 42%);
+
+ --pass-color: hsl(140, 45%, 60%);
+ --fail-color: hsl(355, 80%, 68%);
+ --error-color: hsl(40, 90%, 62%);
+ --skipped-color: hsl(199, 90%, 66%);
+
+ .chart-series-dark();
+}
+
+// ── Theme: Forest Light ─────────────────────────────────────────────────────
+// Muted sage surfaces with a deep green accent. The only green in the set and
+// the furthest from Ember, Slate and Sepia — low saturation so it reads calm
+// rather than clinical.
+.theme-forest-light() {
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
+ --master-color-1: hsl(120, 20%, 93%);
+ --master-color-2: hsl(120, 15%, 84%);
+ --master-color-2-relative: hsl(125, 20%, 42%);
+
+ --master-font-color-1: hsl(150, 30%, 14%);
+ --master-font-color-2: hsl(120, 30%, 99%);
+ --master-font-color-3: hsl(140, 12%, 32%);
+
+ --master-color-3: hsl(157, 58%, 29%);
+ --master-color-3-relative: hsl(157, 60%, 22%);
+ --accent-rgb: 31, 117, 82;
+
+ // 52% rather than 54%: the sage panel is light enough that 54% lands at
+ // 2.91:1, just under the 3:1 the contract asks for.
+ --master-color-muted: hsl(120, 12%, 45%);
+
+ --pass-color: hsl(140, 42%, 38%);
+ --fail-color: hsl(355, 72%, 50%);
+ // A step darker than the other light themes for the same reason.
+ --error-color: hsl(38, 88%, 35%);
+ --skipped-color: hsl(199, 89%, 38%);
+
+ .chart-series-light();
+}
+
+// ── Theme: Forest Dark ──────────────────────────────────────────────────────
+// The night-time counterpart to Forest Light: deep pine surfaces, a bright
+// spring-green accent.
+.theme-forest-dark() {
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
+ --master-color-1: hsl(150, 16%, 15%);
+ --master-color-2: hsl(155, 20%, 9%);
+ --master-color-2-relative: hsl(150, 12%, 44%);
+
+ --master-font-color-1: hsl(120, 20%, 94%);
+ --master-font-color-2: hsl(155, 25%, 8%);
+ --master-font-color-3: hsl(140, 12%, 74%);
+
+ --master-color-3: hsl(150, 55%, 60%);
+ --master-color-3-relative: hsl(150, 55%, 52%);
+ --accent-rgb: 105, 217, 155;
+
+ --master-color-muted: hsl(150, 10%, 44%);
+
+ --pass-color: hsl(140, 45%, 60%);
+ --fail-color: hsl(355, 80%, 68%);
+ --error-color: hsl(40, 90%, 62%);
+ --skipped-color: hsl(199, 90%, 66%);
+
+ .chart-series-dark();
+}
+
+// ── Theme: High Contrast Light ──────────────────────────────────────────────
+// For low vision and bright sunlight. Pure white ground, black text, a deep
+// blue accent — every pairing clears AAA (7:1) rather than the AA the contract
+// requires. Panel and sub-panel are BOTH pure white on purpose: surfaces are
+// separated by borders here, not by tone, which is what keeps contrast maximal.
+.theme-contrast-light() {
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
+ --master-color-1: hsl(0, 0%, 100%);
+ --master-color-2: hsl(0, 0%, 100%);
+ --master-color-2-relative: hsl(0, 0%, 40%);
+
+ --master-font-color-1: hsl(0, 0%, 0%);
+ --master-font-color-2: hsl(0, 0%, 100%);
+ --master-font-color-3: hsl(0, 0%, 26%);
+
+ --master-color-3: hsl(220, 90%, 32%);
+ --master-color-3-relative: hsl(220, 92%, 24%);
+ --accent-rgb: 13, 71, 155;
+
+ --master-color-muted: hsl(0, 0%, 46%);
+
+ --pass-color: hsl(140, 42%, 38%);
+ --fail-color: hsl(355, 72%, 50%);
+ --error-color: hsl(38, 88%, 41%);
+ --skipped-color: hsl(199, 89%, 42%);
+
+ .chart-series-light();
+}
+
+// ── Theme: High Contrast Dark ───────────────────────────────────────────────
+// True black with white text and an amber accent. Distinct from Midnight,
+// which is a near-black *aesthetic*: this one pushes every pairing to AAA and
+// uses a fully saturated accent for maximum separation.
+.theme-contrast-dark() {
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
+ --master-color-1: hsl(0, 0%, 7%);
+ --master-color-2: hsl(0, 0%, 0%);
+ --master-color-2-relative: hsl(0, 0%, 55%);
+
+ --master-font-color-1: hsl(0, 0%, 100%);
+ --master-font-color-2: hsl(0, 0%, 0%);
+ --master-font-color-3: hsl(0, 0%, 88%);
+
+ --master-color-3: hsl(45, 100%, 55%);
+ --master-color-3-relative: hsl(45, 100%, 46%);
+ --accent-rgb: 255, 209, 26;
+
+ --master-color-muted: hsl(0, 0%, 52%);
+
+ --pass-color: hsl(140, 45%, 60%);
+ --fail-color: hsl(355, 80%, 68%);
+ --error-color: hsl(40, 90%, 62%);
+ --skipped-color: hsl(199, 90%, 66%);
+
+ .chart-series-dark();
+}
+
+// ── Theme: Clay (accessible) ────────────────────────────────────────────────
+// Built for colour vision deficiency. Two things make it different from simply
+// picking nicer colours:
+//
+// 1. The status ramp uses the Okabe-Ito palette — blue / vermillion / amber /
+// purple. There is deliberately NO green-versus-red pair anywhere, because
+// that is the distinction ~6% of men cannot make.
+// 2. Colour is not the only channel. `.theme-colour-blind` (components.less)
+// adds a symbol before every status label, because measurement shows four
+// statuses CANNOT be separated by colour alone: even with an optimal
+// palette the closest pair sits near 1.2:1 under simulated deuteranopia,
+// where 3:1 is the bar. The symbol carries the meaning; the colour
+// reinforces it for everyone else.
+//
+// The panel is also the darkest of the light themes (L 78%), which keeps the
+// Okabe-Ito hues clear of the surface without pushing them to near-black.
+.theme-clay-light() {
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
+ --master-color-1: hsl(32, 30%, 88%);
+ --master-color-2: hsl(30, 22%, 78%);
+ --master-color-2-relative: hsl(28, 18%, 42%);
+
+ --master-font-color-1: hsl(25, 25%, 12%);
+ --master-font-color-2: hsl(40, 30%, 97%);
+ --master-font-color-3: hsl(28, 14%, 30%);
+
+ // Okabe-Ito blue — safe against every form of colour blindness.
+ --master-color-3: hsl(202, 100%, 27%);
+ --master-color-3-relative: hsl(202, 100%, 20%);
+ --accent-rgb: 0, 97, 137;
+
+ --master-color-muted: hsl(28, 12%, 42%);
+
+ // Okabe-Ito. Note pass is BLUE, not green: the green/red pair is exactly
+ // what dichromats cannot resolve, so the convention is traded for clarity.
+ --pass-color: #006189;
+ --fail-color: #b33e00;
+ --error-color: #8a6100;
+ --skipped-color: #7a3d9e;
+
+ .chart-series-light();
+}
+
+// ── Theme: Clay Dark (accessible) ───────────────────────────────────────────
+// The dark counterpart, on the same Okabe-Ito principles and with the same
+// symbol reinforcement.
+.theme-clay-dark() {
+ --theme-ink-rgb: 255, 255, 255;
+ --theme-is-dark: 1;
+
+ --master-color-1: hsl(30, 12%, 19%);
+ --master-color-2: hsl(30, 10%, 13%);
+ --master-color-2-relative: hsl(30, 10%, 46%);
+
+ --master-font-color-1: hsl(40, 25%, 94%);
+ --master-font-color-2: hsl(30, 15%, 10%);
+ --master-font-color-3: hsl(35, 12%, 76%);
+
+ --master-color-3: hsl(202, 76%, 63%);
+ --master-color-3-relative: hsl(202, 76%, 54%);
+ --accent-rgb: 86, 180, 233;
+
+ --master-color-muted: hsl(30, 8%, 44%);
+
+ // Okabe-Ito, lifted for a dark ground.
+ --pass-color: #56b4e9;
+ --fail-color: #e69f00;
+ --error-color: #f0e442;
+ --skipped-color: #cc79a7;
+
+ .chart-series-dark();
+}
+
+// ── Categorical chart ramps (per polarity, not per theme) ───────────────────
+// Non-status series colours. Each ramp is tuned for the ground it sits on: the
+// light values fall below 3:1 on a dark panel and vice versa. A new theme picks
+// the ramp matching its polarity instead of authoring ten hues.
+// Amber and lime are a step darker than their siblings in the light ramp: at
+// the shade the others use they drop below 3:1 on the pale panel.
+.chart-series-light() {
+ --chart-series-1: #4c43e5; // indigo
+ --chart-series-2: #0b7a70; // teal
+ --chart-series-3: #af5109; // amber
+ --chart-series-4: #912fea; // violet
+ --chart-series-5: #d41b44; // rose
+ --chart-series-6: #077792; // cyan
+ --chart-series-7: #49760e; // lime
+ --chart-series-8: #2362eb; // blue
+ --chart-series-9: #cd226e; // pink
+ --chart-series-10: #bd470a; // orange
+}
+
+.chart-series-dark() {
+ --chart-series-1: #818cf8; // indigo
+ --chart-series-2: #2dd4bf; // teal
+ --chart-series-3: #fbbf24; // amber
+ --chart-series-4: #c084fc; // violet
+ --chart-series-5: #fb7185; // rose
+ --chart-series-6: #22d3ee; // cyan
+ --chart-series-7: #a3e635; // lime
+ --chart-series-8: #60a5fa; // blue
+ --chart-series-9: #f472b6; // pink
+ --chart-series-10: #fb923c; // orange
+}
+
+// ── Theme: Ember Light ──────────────────────────────────────────────────────
+// The default palette. Extracted into a mixin like every other theme so it can
+// be applied somewhere other than `:root` — the picker's swatches need it, and
+// `light` is the one theme with no registry selector of its own (`:root` already
+// carries it, so the selector below only has to out-rank the OS-preference
+// fallback). Without this a `data-theme="light"` swatch would inherit the active
+// theme and preview the wrong colours on every dark theme.
+.theme-ember-light() {
+ // ── Primitive palette (light — the default theme) ───────────────────────
+ // Polarity: drives the derived hairline, scrim and elevation tokens so no
+ // theme has to restate them. See `_theme-contract.less`.
+ --theme-ink-rgb: 0, 0, 0;
+ --theme-is-dark: 0;
+
// Warm sand / cream neutrals
--master-color-1: hsl(40, 30%, 97%);
--master-color-2: hsl(40, 22%, 92%);
@@ -62,7 +447,10 @@
--master-font-color-2: hsl(40, 33%, 98%);
--master-font-color-3: hsl(30, 6%, 40%);
- --master-icon-color: hsl(210, 100%, 60%);
+ // Muted neutral for inert/disabled chrome. Tuned per theme to stay dimmer
+ // than the ember accent against the panel, so a disabled control never
+ // out-weighs an enabled one.
+ --master-color-muted: hsl(30, 5%, 52%);
// Ember orange ramp (brand primary — sampled from the logo)
--orange-300: #fbd38d;
@@ -74,6 +462,7 @@
// primitive so every token pointing at it flips in lockstep.
--master-color-3: var(--orange-500);
--master-color-3-relative: var(--orange-600);
+ --accent-rgb: 237, 137, 54; // = --orange-500, for the derived focus ring
// Status primitives (theme-independent hues; dark tunes brightness above).
--pass-color: #5fad6f;
@@ -81,8 +470,32 @@
--error-color: #f5b83f;
--skipped-color: #0ea5e9;
- // Focus ring
- --focus-ring: rgba(237, 137, 54, 0.45);
+ // ── Categorical chart palette ───────────────────────────────────────────
+ // Series colours for non-status breakdowns (platform / feature
+ // distribution), assigned by first-seen order so a given platform keeps its
+ // colour across renders. Distinct from the status hues on purpose: these
+ // carry no pass/fail meaning.
+ //
+ // Consumed from JS via `CATEGORICAL_PALETTE` in `utility/ChartConfig.js`;
+ // ApexCharts resolves `var()` in its `colors` array, so these theme like
+ // everything else.
+ .chart-series-light();
+}
+
+:root {
+ .theme-ember-light();
+
+
+ // ── Screenshot overlay markers ──────────────────────────────────────────
+ // Annotations drawn *on top of* user screenshots (template-match boxes,
+ // suggested ignore regions). They must stay legible over arbitrary image
+ // content rather than over an app surface, so they deliberately sit outside
+ // the Ember & Sand palette and do NOT change between themes — the image
+ // underneath does not change either. Magenta is chosen because it almost
+ // never occurs in UI screenshots.
+ --overlay-match-color: #ff00ff;
+ --overlay-suggestion-color: #ff9800;
+ --overlay-marker-font-color: #ffffff;
// ── Semantic: brand / interaction ───────────────────────────────────────
--color-primary: var(--master-color-3);
@@ -109,13 +522,13 @@
--main-panel-background: var(--master-color-2);
--main-panel-font-color: var(--master-font-color-1);
--main-panel-secondary-font-color: var(--master-font-color-3);
- --main-panel-icon-color: var(--master-icon-color);
+ --main-panel-icon-color: var(--color-primary);
// Sub panels
--sub-panel-background: var(--master-color-1);
--sub-panel-font-color: var(--master-font-color-1);
--sub-panel-secondary-font-color: var(--master-font-color-3);
- --sub-panel-icon-color: var(--master-icon-color);
+ --sub-panel-icon-color: var(--color-primary);
// Main tables
--main-table-header-background: var(--master-color-3);
@@ -162,6 +575,8 @@
--modal-background: var(--master-color-2);
--modal-font-color: var(--master-font-color-1);
--modal-secondary-font-color: var(--master-font-color-3);
+ // --modal-overlay-background is DERIVED from polarity — see the derived
+ // block at the end of this file.
// Screenshot viewer
--screenshot-border-color: var(--master-color-2-relative);
@@ -171,30 +586,163 @@
// Borders & dividers
--border-color: var(--master-color-2-relative);
- --border-subtle: rgba(0, 0, 0, 0.12);
+ // --border-subtle is DERIVED from polarity — see the derived block below.
// Icons & status accents
- --icon-action-color: var(--master-icon-color);
- --icon-disabled-color: hsl(0, 0%, 55%);
- --info-color: var(--master-icon-color);
+ // Actionable icons take the brand accent, matching how `.btn-primary`,
+ // `.link-action` and the rsuite subtle-button hover already signal
+ // interactivity. They previously shared a fixed saturated blue
+ // (`--master-icon-color`) that belonged to no palette and had no dark
+ // value, so it neither themed nor matched anything around it.
+ --icon-action-color: var(--color-primary);
+ // Disabled icons are deliberately *not* the accent — they sit on the
+ // neutral ramp so they read as inert. This needs its own primitive
+ // (`--master-color-muted`) rather than borrowing one of the others: a
+ // disabled icon must stay *dimmer than the ember accent* in both themes,
+ // and no existing primitive does that on both sides. The secondary text
+ // tone is lighter than the accent on dark; the mid-sand tone is far too
+ // light on light. See `.theme-ember-dark()` for the dark value.
+ --icon-disabled-color: var(--master-color-muted);
+ // Informational accent (the `.app-alert-info` stripe). Uses the same hue as
+ // the "skipped" result status, which is the app's existing neutral-info
+ // colour and carries dark-mode tuning already.
+ --info-color: var(--skipped-color);
// Timeline dot (inverts with theme — see dark mixin)
--timeline-dot-background: var(--master-font-color-2);
--timeline-dot-border: var(--border-color);
- // Elevation companion (light: real shadow, transparent border; dark flips both)
- --card-border-color: transparent;
}
-// ── Dark theme triggers ─────────────────────────────────────────────────────
-// 1. Explicit user selection wins over everything.
-:root[data-theme='dark'] {
- .theme-dark-primitives();
+// ── Theme registry ──────────────────────────────────────────────────────────
+// One selector per theme. Adding a theme means adding its primitives block
+// above and one rule here — nothing else in the stylesheet changes.
+//
+// The selectors are deliberately NOT qualified with `:root`. Unqualified, they
+// still match the root element exactly as before — but they also match any
+// nested element carrying `data-theme`, and because custom properties inherit,
+// such an element becomes a live island of that theme's primitives. That is
+// what lets the theme picker's swatches paint themselves from the theme they
+// name (see `.theme-swatch` in `layout.less`) instead of restating its colours
+// in a second place that can drift.
+//
+// Re-adding `:root` here would silently blank every swatch: the primitives
+// would stop resolving inside the menu and the chips would inherit the current
+// theme's, so all twelve rows would preview identically.
+//
+// `light` and `dark` are the *shipped* names and must keep working: they are
+// already stored in users' `theme` cookies. `ember-light` / `ember-dark` are
+// aliases so themes can be named by family as more are added.
+//
+// (`:root` itself carries Ember Light, so on the root `[data-theme='light']`
+// only has to stop a dark OS preference from applying — see the fallback
+// below. It still needs a rule of its own for the nested case: a swatch is not
+// the root, so it inherits the active theme unless something re-applies Ember
+// Light to it.)
+[data-theme='light'],
+[data-theme='ember-light'] {
+ .theme-ember-light();
+}
+
+[data-theme='dark'],
+[data-theme='ember-dark'] {
+ .theme-ember-dark();
+}
+
+[data-theme='slate-light'] {
+ .theme-slate-light();
+}
+
+[data-theme='sepia'] {
+ .theme-sepia();
}
-// 2. Follow the OS preference when the user has not explicitly chosen light.
+[data-theme='slate-dark'] {
+ .theme-slate-dark();
+}
+
+[data-theme='midnight'] {
+ .theme-midnight();
+}
+
+[data-theme='forest-light'] {
+ .theme-forest-light();
+}
+
+[data-theme='forest-dark'] {
+ .theme-forest-dark();
+}
+
+[data-theme='contrast-light'] {
+ .theme-contrast-light();
+}
+
+[data-theme='contrast-dark'] {
+ .theme-contrast-dark();
+}
+
+// Accessible pair — see `.theme-clay-light()` for why these differ in kind
+// from the others rather than only in colour.
+[data-theme='clay-light'] {
+ .theme-clay-light();
+}
+
+[data-theme='clay-dark'] {
+ .theme-clay-dark();
+}
+
+// ── OS-preference fallback ──────────────────────────────────────────────────
+// Applies only when the user has expressed NO preference at all. The guard is
+// `:not([data-theme])` — the *absence* of the attribute — rather than
+// `:not([data-theme='light'])`.
+//
+// That older guard read "anything except light", which was fine while `light`
+// and `dark` were the only values: it meant "no cookie, or dark". With a third
+// theme it silently breaks, because a user on `sepia` or `slate-light` also
+// matches "not light" and would get Ember Dark's primitives layered over their
+// chosen theme on any machine set to dark mode. Keying on the attribute's
+// absence keeps an explicit choice — any explicit choice — authoritative.
@media (prefers-color-scheme: dark) {
- :root:not([data-theme='light']) {
- .theme-dark-primitives();
+ :root:not([data-theme]) {
+ .theme-ember-dark();
}
}
+
+// ── Derived tokens ──────────────────────────────────────────────────────────
+// Computed from the polarity and accent primitives every theme supplies, so no
+// theme restates them (and none can get them subtly wrong). Declared AFTER the
+// theme triggers, but custom properties are late-bound — these re-resolve
+// whenever a trigger changes the primitives they read, in any order.
+//
+// See `_theme-contract.less` §2. `color-mix()` would say this more directly,
+// but the browserslist targets predate it, hence the channel splitting.
+:root {
+ // Hairline border: the ink channel at low alpha, inverting with polarity.
+ --border-subtle: rgba(var(--theme-ink-rgb), 0.12);
+
+ // Focus ring: the theme's accent at partial alpha.
+ --focus-ring: rgba(var(--accent-rgb), 0.45);
+
+ // Dialog scrim: always a black wash (a light scrim washes the page out
+ // rather than pushing it back), but deeper on a dark theme because the page
+ // behind is already low-luminance.
+ --modal-overlay-background: rgba(0, 0, 0, calc(0.4 + 0.2 * var(--theme-is-dark)));
+
+ // Elevation: a light theme lifts surfaces with a real shadow and no border;
+ // a dark theme drops the shadow (it reads as mud) and uses a hairline
+ // instead. Both fade out via the polarity flag rather than being switched
+ // per theme, so the pair always stays consistent:
+ // light (0) → shadow at full strength, border fully transparent
+ // dark (1) → shadow fully transparent, border at 0.08
+ --card-shadow: 0 1px 3px rgba(16, 24, 40, calc(0.10 - 0.10 * var(--theme-is-dark))),
+ 0 1px 2px rgba(16, 24, 40, calc(0.06 - 0.06 * var(--theme-is-dark)));
+ --card-border-color: rgba(var(--theme-ink-rgb), calc(0.08 * var(--theme-is-dark)));
+
+ // Secondary text inside the nav. `--nav-font-color` and
+ // `--main-panel-secondary-font-color` happen to resolve to the same value in
+ // every shipped theme, so neither can express "quieter than the nav's own
+ // text". Derived from the ink channel instead: 0.65 alpha reads as a caption
+ // against the nav background on either polarity, and any future theme gets
+ // it without adding a value.
+ --nav-muted-font-color: rgba(var(--theme-ink-rgb), 0.65);
+}
diff --git a/src/styles/tokens/_elevation.less b/src/styles/tokens/_elevation.less
index ec7408c..1c26f18 100644
--- a/src/styles/tokens/_elevation.less
+++ b/src/styles/tokens/_elevation.less
@@ -1,19 +1,19 @@
// ============================================================================
// Design tokens — Elevation
// ----------------------------------------------------------------------------
-// Subtle depth. In LIGHT mode surfaces lift with a soft shadow; in DARK mode
-// shadows read poorly, so `_color.less` overrides `--card-shadow: none` and
-// `--card-border-color` to a hairline instead. Style raised surfaces with BOTH
-// `box-shadow: var(--card-shadow)` and `border: 1px solid var(--card-border-color)`
-// and they will look right in either theme.
+// Subtle depth. On a LIGHT theme surfaces lift with a soft shadow; on a DARK
+// one shadows read as mud, so the shadow fades out and a hairline border takes
+// over. Style raised surfaces with BOTH `box-shadow: var(--card-shadow)` and
+// `border: 1px solid var(--card-border-color)` and they will look right in
+// either theme.
+//
+// The semantic pair (`--card-shadow` / `--card-border-color`) is DERIVED from
+// the theme's polarity flag in `_color.less`, so a theme never sets either.
+// The raw ramp below is theme-independent.
// ============================================================================
:root {
--shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.10), 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md: 0 4px 8px -2px rgba(16, 24, 40, 0.10), 0 2px 4px -2px rgba(16, 24, 40, 0.06);
--shadow-lg: 0 12px 16px -4px rgba(16, 24, 40, 0.10), 0 4px 6px -2px rgba(16, 24, 40, 0.05);
-
- // Semantic elevation for cards/panels (dark flips these in _color.less).
- --card-shadow: var(--shadow-sm);
- // --card-border-color is defined in _color.less (transparent light / hairline dark)
}
diff --git a/src/styles/tokens/_theme-contract.less b/src/styles/tokens/_theme-contract.less
new file mode 100644
index 0000000..6564851
--- /dev/null
+++ b/src/styles/tokens/_theme-contract.less
@@ -0,0 +1,220 @@
+// ============================================================================
+// Design tokens — Theme contract
+// ----------------------------------------------------------------------------
+// THE ONE THING TO KNOW: a theme supplies primitives. Nothing else.
+//
+// The colour system has two layers. The ~80 *semantic* tokens in `_color.less`
+// (`--main-panel-background`, `--button-font-color`, …) are declared exactly
+// once, against primitives. Components consume only those. A theme never
+// touches them — it redefines the small set of primitives underneath, and every
+// semantic token re-resolves because CSS custom properties are late-bound.
+//
+// So adding a theme is not an audit of 80 tokens. It is filling in the list
+// below. This file defines that list, documents what each entry owes, and
+// derives everything that can be derived so a theme does not have to supply it.
+//
+// ── Adding a theme ─────────────────────────────────────────────────────────
+// 1. Copy `.theme-contract-template()` at the bottom of this file.
+// 2. Fill in every REQUIRED primitive. There are 14.
+// 3. Override an OPTIONAL primitive only where the derived default is wrong.
+// 4. Check the CONTRAST OBLIGATIONS below — they are what makes a theme
+// usable rather than merely applied.
+//
+// A missing REQUIRED primitive does not fail loudly: the token silently keeps
+// the previous theme's value, which is how a half-applied theme happens. Run
+// the checklist rather than trusting the page to look wrong.
+// ============================================================================
+
+
+// ── 1. REQUIRED primitives ──────────────────────────────────────────────────
+// Every theme must define all 14. Grouped by the decision each one represents.
+//
+// SURFACES — three tones, ordered by prominence. In a light theme these run
+// light→dark; in a dark theme dark→light. What matters is that they stay
+// *distinguishable from each other*, since panels nest inside panels.
+//
+// --master-color-1 Sub-panel / inset surface (nested cards, table
+// bodies, input fields, tooltips).
+// --master-color-2 Main panel & page background. The dominant tone.
+// --master-color-2-relative Mid-tone: borders, dividers, inactive tabs,
+// screenshot chrome. Sits between surface and text.
+//
+// TEXT — three weights against the surfaces above.
+//
+// --master-font-color-1 Primary body text. Carries the AAA obligation.
+// --master-font-color-2 Text *on the accent* (button labels, table
+// headers). Contrast is owed against the accent,
+// NOT against the page.
+// --master-font-color-3 Secondary/muted text (help text, timestamps).
+//
+// ACCENT — the brand action colour and its pressed state.
+//
+// --master-color-3 Primary accent: buttons, links, active states,
+// focus. One accent per theme.
+// --master-color-3-relative Hover/active variant of the accent.
+//
+// INERT — chrome that must read as unavailable.
+//
+// --master-color-muted Disabled icons and controls. MUST be visually
+// dimmer than the accent against the panel, or a
+// disabled control out-weighs an enabled one.
+// Needs its own value precisely because no other
+// primitive holds that relationship in every
+// theme (see the note in `_color.less`).
+//
+// STATUS — result hues. Keep the semantic mapping (green=pass, red=fail,
+// amber=error, blue=skipped); tune only lightness/saturation for the ground.
+//
+// --pass-color --fail-color --error-color --skipped-color
+//
+// POLARITY — whether this theme sits light or dark. Drives the derived
+// hairlines and shadows in section 2; nothing else needs to branch on it.
+//
+// --theme-ink-rgb "0, 0, 0" on a light theme, "255, 255, 255" on
+// a dark one. The channel that hairlines and
+// scrims are mixed from.
+// --theme-is-dark 0 or 1. Currently only selects the elevation
+// strategy (shadow vs hairline).
+
+
+// ── 2. DERIVED tokens — do NOT redefine these per theme ────────────────────
+// Each of these used to be a hand-written literal in *every* theme, which meant
+// a new theme had to restate it and could silently get it wrong. They are now
+// computed once from the required primitives above, in the derived block at the
+// end of `_color.less`:
+//
+// --border-subtle ink channel at 0.12 alpha; inverts with polarity
+// --focus-ring the theme's accent at 0.45 alpha
+// --modal-overlay-background black wash, deepened on dark themes
+// --card-shadow fades to none as --theme-is-dark goes 0 → 1
+// --card-border-color fades in as --theme-is-dark goes 0 → 1
+//
+// This file is documentation only — it declares no rules. The derivations live
+// next to the primitives they read so there is one place to look.
+//
+// `color-mix()` would express these more directly, but the browserslist targets
+// include Chrome 109 and iOS Safari 11, which predate it — hence the RGB
+// channel-splitting, which works everywhere.
+
+
+// ── 3. CONTRAST OBLIGATIONS ────────────────────────────────────────────────
+// A theme that resolves is not the same as a theme that works. These are the
+// relationships to verify — the failures below are ones this codebase has
+// actually shipped, not hypotheticals.
+//
+// TEXT (WCAG AA, 4.5:1 minimum; the shipped themes hold AAA / 7:1)
+// --master-font-color-1 vs --master-color-2 ← primary text on panel
+// --master-font-color-1 vs --master-color-1 ← primary text on sub-panel
+// --master-font-color-3 vs --master-color-2 ← secondary text on panel
+// status colours vs --master-color-2 ← .status-* are TEXT
+//
+// NON-TEXT (3:1 minimum — boundaries and state, not glyphs)
+// --master-color-2-relative vs --master-color-2 ← borders must be seen
+// --master-color-muted vs --master-color-2 ← disabled still legible
+// --chart-series-N vs --main-panel-background (all 10)
+//
+// ORDERING (not a ratio — a relationship that must hold either way round)
+// --master-color-muted must read as LESS PROMINENT than --master-color-3
+// against the panel, or a disabled control out-weighs an enabled one.
+//
+// Prominence is distance from the panel, not absolute luminance, so the
+// direction flips with polarity:
+// • dark theme → muted must be DARKER than the accent (nearer the panel)
+// • light theme → muted must be LIGHTER than the accent (nearer the panel)
+// Checking "muted has lower luminance" unconditionally is wrong: it holds
+// on dark themes and inverts on light ones. A token can also pass its 3:1
+// and still be more prominent than the accent, so this is a separate check.
+// This relationship inverted in dark mode when `--icon-disabled-color` was
+// pointed at the secondary text tone.
+//
+// KNOWN SHORTFALLS in the Ember themes, measured — recorded so a new theme
+// is not judged against a false baseline. New themes should do better where
+// they can; these are not licence to ship a worse palette.
+//
+// • ON-ACCENT TEXT is the big one. `--master-font-color-2` (cream) on
+// `--master-color-3` (ember) measures 2.46:1 light / 1.80:1 dark, well
+// under AA. That pair is every primary button label — including Login.
+// Fixing it means dark text on the orange, which is a brand decision, so
+// it is recorded rather than silently changed. A NEW theme has no such
+// constraint: pick an on-accent colour that clears 4.5:1, which usually
+// means dark text on a mid-tone accent.
+// • --master-color-3 (ember) sits at ~2.2:1 on the light panel. It is the
+// brand accent and is used for icon affordance, so light-theme icons are
+// below the 3:1 non-text bar.
+// • Ember Light also fails the ORDERING rule above, as a consequence: the
+// accent is so light (relative luminance 0.36) that no muted grey can be
+// both lighter than it AND reach 3:1 on the pale panel — "lighter than
+// the accent" there means very nearly panel-coloured. Disabled icons in
+// Ember Light therefore read slightly heavier than enabled ones. The fix
+// is a darker light-theme accent, i.e. the same brand decision as above.
+// Slate Light and Sepia both satisfy the rule, so it is not a limitation
+// of the rule itself.
+// • --master-color-2-relative sits at 1.33:1 light / 2.32:1 dark against
+// the panel, so borders are fainter than the bar wants in both.
+// • The light theme's status colours (2.3–2.5:1) are tuned for the pale
+// sub-panel they usually sit on rather than for maximum contrast.
+
+
+// ── 4. NOT part of the contract ────────────────────────────────────────────
+// Deliberately fixed across every theme. A theme must NOT redefine these.
+//
+// --orange-300 … --orange-600 Brand ramp. Ember & Sand's own identity; a
+// theme picks its accent via --master-color-3
+// and may point it at this ramp or elsewhere.
+//
+// --overlay-match-color Screenshot annotations. These are drawn over
+// --overlay-suggestion-color arbitrary user screenshots, not over an app
+// --overlay-marker-font-color surface — the image underneath does not
+// change with the theme, so neither do they.
+//
+// --chart-series-1 … -10 Categorical chart series. Not per-theme, but
+// per *polarity*: the light ramp is tuned for a
+// pale ground and the dark ramp for a dark one.
+// A new theme inherits whichever matches its
+// polarity rather than authoring ten hues.
+
+
+// ── 5. Template ─────────────────────────────────────────────────────────────
+// Copy this block, rename it, fill in the values, then register it in the
+// theme registry in `_color.less`:
+//
+// :root[data-theme=''] { .theme-(); }
+//
+// That is the whole wiring. A light theme also needs `.chart-series-light()`
+// (a dark one `.chart-series-dark()`) as its last line — see the shipped
+// `.theme-ember-dark()` for a worked example.
+//
+// Only a theme meant to be the OS-preference fallback belongs in the
+// `@media (prefers-color-scheme: …)` block, and that guard keys on the
+// *absence* of `data-theme` so an explicit choice always wins.
+//
+// .theme-() {
+// // Polarity
+// --theme-ink-rgb: 0, 0, 0; // 255, 255, 255 on a dark theme
+// --theme-is-dark: 0; // 1 on a dark theme
+//
+// // Surfaces
+// --master-color-1: …; // inset / nested
+// --master-color-2: …; // dominant panel + page
+// --master-color-2-relative: …; // borders, dividers, mid-tone
+//
+// // Text
+// --master-font-color-1: …; // primary
+// --master-font-color-2: …; // on-accent
+// --master-font-color-3: …; // secondary
+//
+// // Accent (--accent-rgb must be the same colour as --master-color-3,
+// // as bare "r, g, b" channels, so --focus-ring can derive)
+// --master-color-3: …;
+// --master-color-3-relative: …;
+// --accent-rgb: …, …, …;
+//
+// // Inert — must read dimmer than the accent on --master-color-2
+// --master-color-muted: …;
+//
+// // Status
+// --pass-color: …;
+// --fail-color: …;
+// --error-color: …;
+// --skipped-color: …;
+// }
diff --git a/src/translations/cn.json b/src/translations/cn.json
index 7b022aa..f612bea 100644
--- a/src/translations/cn.json
+++ b/src/translations/cn.json
@@ -1,515 +1,811 @@
{
- "nav.dashboard": "儀表板",
- "nav.execution-metrics": "指標",
- "nav.screenshot-library": "螢幕截圖庫",
- "nav.language": "語言",
- "nav.theme": "主題色調",
- "nav.theme.light": "淺色",
- "nav.theme.dark": "深色",
- "nav.about": "關於 Angles",
- "nav.team-settings": "团队设置",
- "nav.admin": "管理",
- "nav.admin.users": "用户",
- "nav.admin.settings": "设置",
- "nav.profile": "个人资料",
- "nav.user-settings": "设置",
- "nav.logout": "退出登录",
- "app.utils.duration.seconds": "{seconds} 秒",
- "app.utils.duration.minutes": "{minutes} 分鐘",
- "app.utils.duration.hours": "{hours} 小時",
- "app.utils.duration.not-started": "未啟動",
- "app.utils.date-range-picker.number-of-days": "過去 {days} 天",
- "app.retrieving-teams": "正在檢索團隊...",
- "app.retrieving-teams-error": "出了點問題 [{teamsError}]",
- "app.result.pass": "通過",
- "app.result.fail": "失敗",
- "app.result.skipped": "已跳過",
- "app.result.error": "錯誤",
- "app.result.total": "總計",
- "page.home.bread-crumb": "首頁",
- "page.dashboard.message.retrieving-builds": "正在擷取 {teamName} 的版本...",
- "page.dashboard.filters.label.team": "團隊",
- "page.dashboard.filters.label.period": "期間",
- "page.dashboard.filters.label.display-limit": "顯示限制",
- "page.dashboard.filters.label.environments": "環境",
- "page.dashboard.filters.label.components": "元件",
- "page.dashboard.panel.total-test-runs": "測試運行總數",
- "page.dashboard.panel.total-test-executions": "測試執行總數",
- "page.dashboard.panel.total-execution-time": "總執行時間",
- "page.dashboard.chart.barchart.pass": "透過",
- "page.dashboard.chart.barchart.skipped": "已跳過",
- "page.dashboard.chart.barchart.error": "錯誤",
- "page.dashboard.chart.barchart.fail": "失敗",
- "page.dashboard.chart.barchart.execution-time-seconds": "執行時間(秒)",
- "page.dashboard.chart.barchart.yaxis.number-of-tests": "測試數量",
- "page.dashboard.builds-table.header.build-details": "建構的詳細資料",
- "page.dashboard.builds-table.header.status": "状态",
- "page.dashboard.builds-table.header.date-time": "日期/時間",
- "page.dashboard.builds-table.header.result": "結果",
- "page.dashboard.builds-table.header.flags": "标记",
- "page.dashboard.builds-table.not-available": "不适用",
- "page.dashboard.builds-table.artifacts.title": "构件",
- "page.dashboard.builds-table.artifacts.count": "{count, plural, other {# 个构件}}",
- "page.dashboard.builds-table.keep.enabled": "已启用保留标记 - 此测试运行不会被清理",
- "page.dashboard.builds-table.keep.disabled": "未启用保留标记 - 此测试运行可能在清理时被删除",
- "page.dashboard.menu.compare-test-runs": "比較測試運行",
- "page.dashboard.menu.toggle-keep-flag": "切換保留標誌",
- "page.dashboard.menu.clear-selection": "清除選擇",
- "page.dashboard.menu.delete-build": "刪除構建",
- "page.dashboard.menu.one-select-message": "請選擇至少一項測試執行",
- "page.dashboard.confirm.delete-build.title": "刪除構建",
- "page.dashboard.confirm.delete-build.message": "您確定要刪除選中的 {count} 個構建嗎?此操作無法撤銷,將永久刪除所有相關的執行記錄和截圖。",
- "page.dashboard.confirm.delete-build.cancel": "取消",
- "page.dashboard.toast.delete-build-success": "已成功刪除 {count} 個構建",
- "page.dashboard.toast.delete-build-error": "刪除構建失敗",
- "page.dashboard.execution-barchart.execution-time-seconds": "執行時間(秒)",
- "page.dashboard.execution-bar-chart.title": "測試運行",
- "page.dashboard.execution-pie-chart.title": "測試運行總體狀態",
- "page.metrics.bread-crumb": "指標",
- "page.metrics.filters.labels.team": "團隊",
- "page.metrics.filters.labels.component": "元件",
- "page.metrics.filters.labels.period": "期間",
- "page.metrics.filters.labels.group-by-period": "分組依據",
- "page.metrics.filters.button.retrieve-metrics": "檢索指標",
- "page.metrics.tab.execution-metrics": "執行指標",
- "page.metrics.tab.platform-metrics": "平台指標",
- "page.metrics.execution-metrics-summary.label.period": "期間",
- "page.metrics.execution-metrics-summary.label.number-of-tests": "測試數量",
- "page.metrics.execution-metrics-summary.label.number-of-runs": "測試運行次數",
- "page.metrics.execution-metrics-summary.label.number-of-executions": "執行次數",
- "page.metrics.execution-metrics-summary.label.result": "結果",
- "page.metrics.execution-metrics-summary.label.duration": "持續時間",
- "page.metrics.execution-metrics-bar-chart.title": "按結果分組的執行指標",
- "page.metrics.execution-metrics-bar-chart.yaxis-title": "執行次數",
- "page.metrics.execution-metrics-phase-bar-chart.title": "按階段分組的執行指標",
- "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "執行次數",
- "page.metrics.platform-distribution-pie-chart.title": "按平台劃分的總體執行分佈",
- "page.metrics.platform-distribution-bar-chart.title": "依平台執行分佈",
- "page.metrics.platform-distribution-bar-chart.yaxis-title": "平台",
- "page.metrics.platform-distribution-bar-chart.xaxis-title": "執行次數",
- "page.metrics.platform-metrics-summary.label.platform": "平台",
- "page.metrics.platform-metrics-summary.label.version": "版本",
- "page.metrics.platform-metrics-summary.label.device": "裝置",
- "page.metrics.platform-metrics-summary.label.browser": "瀏覽器",
- "page.metrics.platform-metrics-summary.label.total": "總計",
- "page.metrics.platform-metrics-summary.label.result": "結果",
- "page.metrics.platform-metrics-summary.label.duration": "持續時間",
- "page.metrics.metrics-test-table.label.unique-testcases": "每個階段的唯一測試案例",
- "page.metrics.metrics-test-table.label.phase": "階段",
- "page.metrics.metrics-test-table.label.test": "測試",
- "page.metrics.metrics-test-table.no-tests-found": "找不到測試",
- "page.test-run.bread-crumb": "測試運行",
- "page.test-run.steps-header.start": "開始",
- "page.test-run.steps-header.duration": "持續時間",
- "page.test-run.steps-header.end": "結束",
- "page.test-run.menu.enable-keep-flag": "啟用保留標誌",
- "page.test-run.menu.disable-keep-flag": "停用保留標誌",
- "page.test-run.menu.download-report": "下載 html 格式的報表",
- "page.test-run.menu.delete-build": "刪除構建",
- "page.test-run.confirm.delete-build.title": "刪除構建",
- "page.test-run.confirm.delete-build.message": "您確定要刪除此構建嗎?此操作無法撤銷,將永久刪除所有相關的執行記錄和截圖。",
- "page.test-run.confirm.delete-build.cancel": "取消",
- "page.test-run.toast.delete-build-success": "構建已成功刪除",
- "page.test-run.toast.delete-build-error": "刪除構建失敗",
- "page.test-run.icons.environment.whisper": "環境",
- "page.test-run.icons.team.whisper": "團隊",
- "page.test-run.icons.component.whisper": "元件",
- "page.test-run.icons.phase.whisper": "測試階段",
- "page.test-run.icons.artifacts.whisper": "與此測試運行相關的工件數量。",
- "page.test-run.icons.keep-lock.whisper": "鎖定時,測試運行不會被夜間 cronjob 清理。",
- "page.test-run.icons.screenshots.whisper": "此測試運行有 {numberOfScreenshots} 個螢幕截圖",
- "page.test-run.icons.download-report.whisper": "下載此測試執行的 HTML 報表。",
- "page.test-run.header.status.whisper": "狀態:{status}",
- "page.test-run.header.execution-count": "{count, plural, other {# 個執行}}",
- "page.test-run.header.fact.not-set": "未設定",
- "page.test-run.header.artifacts-title": "構件",
- "page.test-run.suites.title": "測試套件",
- "page.test-run.loading": "正在取得建置詳細資訊。",
- "page.test-run.error.load-failed": "無法取得建置詳細資訊。請重新整理頁面再試一次,並確認建置 ID 是否有效。",
- "page.test-run.execution-pie-chart.title": "總體執行指標",
- "page.test-run.feature-distribution-pie-chart.title": "特徵分佈指標",
- "page.test-run.execution-pie-chart.pass-rate": "通過率",
- "page.test-run.feature-distribution-pie-chart.unspecified": "未指定",
- "page.test-run.feature-distribution-pie-chart.executions": "執行次數",
- "page.test-run-compare.bread-crumb": "比較測試運行",
- "page.test-run-compare.header.number": "#",
- "page.test-run-compare.header.test-name": "測試名稱",
- "page.test-run-compare.detail.start-date": "開始日期",
- "page.test-run-compare.detail.environment": "環境",
- "page.test-run-compare.cell.not-available": "無",
- "page.test-run-compare.cell.multiple-executions": "多次執行",
- "page.test-run-compare.popover.details-title": "詳細資訊",
- "page.test-run-compare.link.execution-history": "查看 {testName} 的執行歷史",
- "page.test-run-compare.details-popover.header.property": "屬性",
- "page.test-run-compare.details-popover.header.value": "值",
- "page.test-run-compare.details-popover.property.status": "狀態",
- "page.test-run-compare.details-popover.property.duration": "持續時間",
- "page.test-run-compare.details-popover.property.platform": "平台",
- "page.test-run-compare.details-popover.property.failing-step": "失敗步驟",
- "page.test-run-compare.loading": "正在取得測試運行詳細資訊以產生比較檢視...",
- "page.test-run-compare.section.artifacts": "構件版本",
- "page.test-run-compare.artifacts.count": "{count, plural, other {# 個構件}}",
- "page.test-run-compare.section.compare-matrix": "測試比較矩陣",
- "page.test-run-compare.toolbar.status-filter": "依狀態篩選",
- "page.test-run-compare.suite.tests": "{count, plural, other {# 個測試}}",
- "page.test-run-compare.suite.rollup": "{total} 個測試中有 {passed} 個通過",
- "page.test-run-compare.header.run-summary": "{total} 個測試中有 {passed} 個通過、{failed} 個失敗、{errors} 個錯誤",
- "page.test-run-compare.cell.status-changed": "狀態與上一次運行不同",
- "page.test-run-compare.warning.many-runs": "正在比較 {count} 個測試運行 — 請使用下方的篩選器聚焦於差異。",
- "page.screenshot-library.bread-crumb": "螢幕截圖庫",
- "page.screenshot-library.search-options.label.view": "檢視",
- "page.screenshot-library.search-options.label.tag": "標籤",
- "page.screenshot-library.search-options.label.or": "或",
- "page.screenshot-library.search-options.label.period": "期間",
- "page.screenshot-library.search-options.label.platform": "平台",
- "page.screenshot-library.search-options.button.search-screenshots": "搜尋螢幕截圖",
- "page.screenshot-library.search-options.dropdown.yesterday": "昨天",
- "page.screenshot-library.search-options.dropdown.last-7-days": "過去 7 天",
- "page.screenshot-library.search-options.dropdown.last-14-days": "過去 14 天",
- "page.screenshot-library.search-options.dropdown.last-30-days": "過去 30 天",
- "page.screenshot-library.search-options.dropdown.last-3-months": "過去 3 個月",
- "page.screenshot-library.search-options.dropdown.last-6-months": "過去 6 個月",
- "page.test-execution-history.bread-crumb": "測試執行歷史記錄",
- "page.test-execution-history.header.suite": "套件",
- "page.test-execution-history.messages.retrieving-executions": "檢索測試執行",
- "page.test-execution-history.charts.execution-pie-chart.title": "總體執行指標",
- "page.test-execution-history.filter.hint": "在下方圖表中選擇狀態以篩選此頁面。",
- "page.test-execution-history.filter.showing": "顯示",
- "page.test-execution-history.filter.active": "({count, plural, other {# 個執行}})",
- "page.test-execution-history.filter.no-results": "沒有符合所選狀態的執行。",
- "page.test-execution-history.charts.execution-timeline-chart.title": "執行時間",
- "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "執行時間(秒)",
- "page.test-execution-history.charts.execution-platform-bar-chart.title": "按平台劃分的執行次數",
- "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "平台",
- "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "執行次數",
- "page.about.bread-crumb": "關於",
- "page.about.header": "關於角度",
- "page.about.about-angles": "Angles 是一個開源儀表板,可用於存儲來自不同框架的自動測試運行的結果和截圖。",
- "page.about.about-api": "透過提供明確定義的 {apiLink},任何框架都可以適應將其測試結果存儲在Angles中,使用提供的客戶端之一(或直接使用API)。",
- "page.about.about-github": "有關 Angles 的更多信息,請訪問頁面:{githubLink}",
- "page.introduction.bread-crumb": "簡介",
- "page.introduction.header": "歡迎來到角度",
- "page.introduction.description": "您似乎尚未設定任何團隊或環境。\n\n這可以透過使用 {apiLink} 來完成。",
- "page.introduction.api-link-text": "Swagger API",
- "page.introduction.description-alternative": "或您也可以使用 {githubLink} 開始。",
- "page.introduction.github-link-text": "Github 頁面",
- "page.not-found.bread-crumb": "找不到",
- "page.not-found.header": "找不到页面",
- "page.not-found.description": "哎呀,糟糕,我們無法找到您要找的東西。點選 {homeLink} 返回主頁。",
- "page.not-found.home-link-text": "此處",
- "common.component.suite-table.header.suite": "套件",
- "common.component.suite-table.header.test": "測試",
- "common.component.suite-table.header.duration": "持續時間",
- "common.component.suite-table.header.total": "總計",
- "common.component.suite-table.header.collapse-all": "折疊所有測試案例和操作",
- "common.component.suite-table.header.expand-all": "展開所有測試案例",
- "common.component.suite-table.header.expand-all-actions": "展開所有測試案例和操作",
- "common.component.suite-table.icon.test-history": "查看測試的執行歷史記錄",
- "common.component.suite-table.header.show-screenshots": "顯示步驟截圖",
- "common.component.suite-table.header.hide-screenshots": "隱藏步驟截圖",
- "common.component.suite-table.header.execution-count": "{count, plural, other {# 個測試}}",
- "common.component.suite-table.execution.action-count": "{count, plural, other {# 個動作}}",
- "common.component.suite-table.action.step-count": "{count, plural, other {# 個步驟}}",
- "common.component.screenshot-view.header": "螢幕截圖檢視器",
- "common.component.screenshot-view.tabs.enable-alert": "要啟用「歷史記錄」和「與基線比較」選項卡,請在上傳螢幕截圖時提供視圖和平台詳細資訊",
- "common.component.screenshot-view.tabs.label.image": "螢幕截圖",
- "common.component.screenshot-view.tabs.label.history": "歷史記錄",
- "common.component.screenshot-view.tabs.label.overlay-with-baseline": "與基線疊加",
- "common.component.screenshot-view.tabs.label.side-by-side": "與基線並排",
- "common.component.screenshot-view.tabs.label.find-image": "在圖片中尋找",
- "common.component.screenshot-view.tabs.baseline.label.algorithm": "比較演算法",
- "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "像素",
- "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "結構相似性(SSIM)",
- "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "將此變更區域加入為忽略區塊",
- "common.component.screenshot-view.tabs.find-image.message.intro": "上傳較小的圖片(例如按鈕、標誌或遊戲素材),以在目前的螢幕截圖中定位它。",
- "common.component.screenshot-view.tabs.find-image.label.template": "範本圖片",
- "common.component.screenshot-view.tabs.find-image.label.min-confidence": "最低信賴度",
- "common.component.screenshot-view.tabs.find-image.label.max-matches": "最大匹配數",
- "common.component.screenshot-view.tabs.find-image.label.grayscale": "忽略顏色差異(灰階)",
- "common.component.screenshot-view.tabs.find-image.button.find": "在螢幕截圖中尋找",
- "common.component.screenshot-view.tabs.find-image.message.searching": "正在搜尋範本圖片,這可能需要幾秒鐘。",
- "common.component.screenshot-view.tabs.find-image.message.no-matches": "未找到高於信賴度門檻的匹配。",
- "common.component.screenshot-view.tabs.find-image.label.matches": "匹配",
- "common.component.screenshot-view.tabs.find-image.label.position": "位置",
- "common.component.screenshot-view.tabs.find-image.label.size": "尺寸",
- "common.component.screenshot-view.tabs.find-image.label.confidence": "信賴度",
- "common.component.screenshot-view.tabs.find-image.label.scale": "縮放比例",
- "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "製作基線圖片",
- "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "這是基線圖片",
- "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "產生動態基線",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "刪除螢幕截圖",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "僅適用於未配置為基線的動態基線。",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "正在檢索螢幕截圖",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "無法擷取圖片。請重新整理頁面並重試。",
- "common.component.screenshot-view.tabs.history.message.loading-history": "正在載入歷史記錄...",
- "common.component.screenshot-view.tabs.history.label.baseline": "基線螢幕截圖",
- "common.component.screenshot-view.tabs.history.label.view": "檢視",
- "common.component.screenshot-view.tabs.history.label.date": "日期",
- "common.component.screenshot-view.tabs.history.label.resolution": "解析度",
- "common.component.screenshot-view.tabs.history.label.platform": "平台",
- "common.component.screenshot-view.tabs.history.label.device": "裝置",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "目前影像(包括基線)",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image": "目前影像",
- "common.component.screenshot-view.tabs.side-by-side.label.baseline": "基線",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "正在檢索基線螢幕截圖。",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "無法擷取基線影像。請重新整理頁面並重試。",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "正在檢索螢幕截圖。",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "無法擷取圖片。請重新整理頁面並重試。",
- "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "目前影像(包括基線)",
- "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "基線比較",
- "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "與基線重做比較。",
- "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "編輯忽略區塊",
- "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "儲存忽略區塊",
- "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "取消更改",
- "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "尚未為此視圖和 deviceName 或瀏覽器組合選擇基線。要選擇基線,請導航至要作為基線的圖片然後單擊\"製作基線圖像\"按鈕",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "正在載入基線比較",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "無法擷取基線比較。",
- "common.component.screenshot-details-table.label.view": "檢視",
- "common.component.screenshot-details-table.label.view-link": "其他平台",
- "common.component.screenshot-details-table.label.view-link-description": "查找視圖 {view} 的所有最新螢幕截圖,按平台分組。",
- "common.component.screenshot-details-table.label.no-view": "未提供視圖",
- "common.component.screenshot-details-table.label.date": "日期",
- "common.component.screenshot-details-table.label.baseline-date": "基準日期",
- "common.component.screenshot-details-table.label.mismatch": "不符",
- "common.component.screenshot-details-table.label.algorithm": "演算法",
- "common.component.screenshot-details-table.label.ssim-score": "SSIM 分數",
- "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (分析時間: {time} ms)",
- "common.component.screenshot-details-table.label.resolution": "解析度",
- "common.component.screenshot-details-table.label.platform": "平台",
- "common.component.screenshot-details-table.label.no-platform": "未提供平台",
- "common.component.screenshot-details-table.label.platform-version": "平台版本",
- "common.component.screenshot-details-table.label.device": "裝置",
- "common.component.screenshot-details-table.label.browser": "瀏覽器",
- "common.component.screenshot-details-table.label.no-browser": "未提供瀏覽器",
- "common.component.screenshot-details-table.label.browser-version": "瀏覽器版本",
- "common.component.screenshot-details-table.label.no-browser-version": "未提供瀏覽器版本",
- "common.component.screenshot-details-table.label.baseline": "基線",
- "common.component.screenshot-details-table.label.tags": "標籤",
- "common.component.screenshot-details-table.label.tags-description": "查找帶有標籤 \"{tag}\" 的所有最新螢幕截圖,按視圖分組。",
- "common.yes": "是",
- "common.no": "否",
- "common.button.ok": "确定",
- "page.login.title": "登录到 Angles",
- "page.login.error.invalid-credentials": "用户名或密码无效",
- "page.login.label.username": "用户名",
- "page.login.label.password": "密码",
- "page.login.button.login": "登录",
- "page.login.divider.or": "或",
- "page.login.label.sign-in-with": "登录方式",
- "page.login.source.local": "本地账户",
- "page.login.button.sso": "使用 {name} 继续",
- "page.login.error.sso-failed": "单点登录失败。请重试或联系管理员。",
- "page.login.error.no-methods": "没有可用的登录方式。请联系管理员。",
- "page.user-settings.header": "用户设置",
- "page.user-settings.api-tokens.title": "API 令牌",
- "page.user-settings.api-tokens.description": "个人 API 令牌的作用与普通密码类似,但允许您以编程方式向 API 进行身份验证。",
- "page.user-settings.button.generate-new-token": "生成新令牌",
- "page.user-settings.table.name": "名称",
- "page.user-settings.table.created": "创建时间",
- "page.user-settings.table.expires": "到期",
- "page.user-settings.table.action": "操作",
- "page.user-settings.table.revoke": "撤销",
- "page.user-settings.modal.token-generated": "令牌已生成",
- "page.user-settings.modal.generate-token": "生成新的 API 令牌",
- "page.user-settings.modal.success-label": "成功!",
- "page.user-settings.modal.success": "您的新令牌已生成。",
- "page.user-settings.modal.warning": "请立即复制此令牌。您将无法再次看到它!",
- "page.user-settings.modal.token-name": "令牌名称",
- "page.user-settings.modal.token-placeholder": "例如:CI/CD Pipeline",
- "page.user-settings.modal.expiration": "到期时间",
- "page.user-settings.modal.custom-expiration": "自定义到期时间(天)",
- "page.user-settings.button.done": "完成",
- "page.user-settings.button.generate": "生成",
- "page.user-settings.button.cancel": "取消",
- "page.user-settings.confirm.revoke.title": "撤销令牌",
- "page.user-settings.confirm.revoke.message": "您确定要撤销此令牌吗?使用此令牌的任何脚本或应用程序将立即失去访问权限。",
- "page.user-settings.table.expires-never": "从不",
- "page.user-settings.modal.token-label": "令牌",
- "page.user-settings.modal.button.copy": "复制",
- "page.user-settings.expiry.1-day": "1天",
- "page.user-settings.expiry.1-week": "1周",
- "page.user-settings.expiry.1-month": "1个月",
- "page.user-settings.expiry.1-year": "1年",
- "page.user-settings.expiry.custom": "自定义",
- "page.user-settings.toast.fetch-tokens-error": "获取令牌失败",
- "page.user-settings.toast.token-name-required": "令牌名称为必填项",
- "page.user-settings.toast.expiry-required": "需要有效的到期天数",
- "page.user-settings.toast.generate-token-error": "生成令牌失败",
- "page.user-settings.toast.revoke-success": "令牌已成功撤销",
- "page.user-settings.toast.revoke-error": "撤销令牌失败",
- "page.user-settings.toast.copy-success": "令牌已复制到剪贴板",
- "password.policy.min-length": "长度至少为 10 个字符",
- "password.policy.max-length": "长度不超过 100 个字符",
- "password.policy.letter": "至少包含一个字母",
- "password.policy.uppercase": "至少包含一个大写字母",
- "password.policy.special": "至少包含一个特殊字符",
- "password.policy.invalid": "密码不符合安全要求",
- "page.user-settings.password.title": "密码",
- "page.user-settings.password.description": "更改用于登录 Angles 的密码。",
- "page.user-settings.password.button.change": "更改密码",
- "page.user-settings.password.modal.title": "更改密码",
- "page.user-settings.password.modal.current": "当前密码",
- "page.user-settings.password.modal.new": "新密码",
- "page.user-settings.password.modal.confirm": "确认新密码",
- "page.user-settings.password.modal.mismatch": "密码不匹配",
- "page.user-settings.password.modal.submit": "更新密码",
- "page.user-settings.password.modal.success": "您的密码已更新,请在下次登录时使用。",
- "page.user-settings.password.toast.current-required": "当前密码为必填项",
- "page.user-settings.password.toast.mismatch": "新密码与确认密码不匹配",
- "page.user-settings.password.toast.success": "密码更新成功",
- "page.user-settings.password.toast.error": "密码更新失败",
- "page.admin.settings.header": "认证设置",
- "page.admin.settings.loading": "正在加载设置...",
- "page.admin.settings.local-auth.title": "本地认证",
- "page.admin.settings.local-auth.enable": "启用本地认证",
- "page.admin.settings.local-auth.help": "允许用户使用用户名和密码登录。",
- "page.admin.settings.button.save": "保存设置",
- "page.admin.users.header": "用户管理",
- "page.admin.users.button.add-user": "添加用户",
- "page.admin.users.table.username": "用户名",
- "page.admin.users.table.role": "角色",
- "page.admin.users.table.teams": "团队",
- "page.admin.users.table.action": "操作",
- "page.admin.users.table.edit": "编辑",
- "page.admin.users.table.delete": "删除",
- "page.admin.users.modal.edit-user": "编辑用户",
- "page.admin.users.modal.add-user": "添加用户",
- "page.admin.users.modal.password-help": "(留空以保持不变)",
- "page.admin.users.modal.user-type": "用户类型",
- "page.admin.users.modal.team-access": "团队权限",
- "page.admin.users.button.save": "保存",
- "page.admin.users.button.cancel": "取消",
- "page.admin.users.confirm.delete.title": "删除用户",
- "page.admin.users.confirm.delete.message": "您确定要删除此用户吗?此操作无法撤销。",
- "page.admin.users.user-type.admin": "管理员",
- "page.admin.users.user-type.team_lead": "团队负责人",
- "page.admin.users.user-type.user": "用户",
- "page.admin.users.modal.password-label": "密码",
- "page.admin.users.table.teams-assigned": "已分配 {count} 个团队",
- "page.admin.users.toast.fetch-error": "获取用户失败",
- "page.admin.users.toast.update-success": "用户更新成功",
- "page.admin.users.toast.create-success": "用户创建成功",
- "page.admin.users.toast.save-error": "保存用户失败",
- "page.admin.users.toast.delete-success": "用户删除成功",
- "page.admin.users.toast.delete-error": "删除用户失败",
- "page.admin.settings.toast.fetch-error": "获取设置失败",
- "page.admin.settings.toast.save-success": "设置已成功保存",
- "page.admin.settings.toast.save-error": "保存设置失败",
- "page.admin.settings.providers.title": "身份提供商",
- "page.admin.settings.providers.help": "配置单点登录提供商。更改立即生效,无需重启。",
- "page.admin.settings.providers.empty": "尚未配置任何身份提供商。",
- "page.admin.settings.providers.table.name": "名称",
- "page.admin.settings.providers.table.id": "ID",
- "page.admin.settings.providers.table.type": "类型",
- "page.admin.settings.providers.table.enabled": "已启用",
- "page.admin.settings.providers.table.status": "状态",
- "page.admin.settings.button.add-provider": "添加提供商",
- "page.admin.settings.button.save-provider": "保存提供商",
- "page.admin.settings.confirm.delete.title": "删除提供商",
- "page.admin.settings.confirm.delete.message": "确定要删除此身份提供商吗?通过它登录的用户将失去访问权限。",
- "page.admin.settings.provider.modal.add": "添加身份提供商",
- "page.admin.settings.provider.modal.edit": "编辑身份提供商",
- "page.admin.settings.provider.name": "显示名称",
- "page.admin.settings.provider.name.help": "显示在登录按钮上,例如“公司 SSO”。",
- "page.admin.settings.provider.id": "提供商 ID",
- "page.admin.settings.provider.id.help": "2-32 个小写字母、数字或连字符。它是回调 URL 的一部分,之后无法更改。",
- "page.admin.settings.provider.id.help-locked": "该 ID 是在身份提供商处注册的回调 URL 的一部分,无法更改。",
- "page.admin.settings.provider.type": "提供商类型",
- "page.admin.settings.provider.type.oidc": "OIDC",
- "page.admin.settings.provider.type.saml": "SAML 2.0",
- "page.admin.settings.provider.type.ldap": "LDAP / AD",
- "page.admin.settings.provider.type.help-locked": "创建后无法更改提供商类型。",
- "page.admin.settings.provider.enabled": "已启用",
- "page.admin.settings.provider.enabled.help": "在登录页面上提供此提供商。",
- "page.admin.settings.provider.callback-url": "重定向 URI",
- "page.admin.settings.provider.acs-url": "ACS(回调)URL",
- "page.admin.settings.provider.callback-url.help": "请在您的身份提供商处注册此 URL。",
- "page.admin.settings.provider.status.ready": "就绪",
- "page.admin.settings.provider.status.error": "错误",
- "page.admin.settings.provider.status.enabled": "已启用",
- "page.admin.settings.provider.status.disabled": "已禁用",
- "page.admin.settings.provider.default-role": "默认角色",
- "page.admin.settings.provider.default-role.deny": "拒绝访问",
- "page.admin.settings.provider.default-role.help": "当没有任何映射匹配时授予的角色。拒绝访问是更安全的默认设置。",
- "page.admin.settings.provider.secret.help-set": "已配置一个值。留空以保留该值,或输入新值以替换它。",
- "page.admin.settings.provider.secret.help-unset": "保存后将无法再次查看此值。",
- "page.admin.settings.provider.error.id": "提供商 ID 必须为 2-32 个小写字母、数字或连字符。",
- "page.admin.settings.provider.error.duplicate-id": "另一个提供商已使用此 ID。",
- "page.admin.settings.provider.error.name": "必须填写显示名称。",
- "page.admin.settings.provider.error.mapping-value": "每个角色映射都需要一个组值。",
- "page.admin.settings.oidc.title": "OIDC 配置",
- "page.admin.settings.oidc.issuer": "Issuer URL",
- "page.admin.settings.oidc.issuer.help": "端点和签名密钥通过 issuer 的 .well-known/openid-configuration 自动发现。",
- "page.admin.settings.oidc.client-id": "客户端 ID",
- "page.admin.settings.oidc.client-secret": "客户端密钥",
- "page.admin.settings.oidc.scopes": "作用域",
- "page.admin.settings.oidc.scopes.help": "以空格分隔。始终会添加 “openid”。Okta 和 Keycloak 接受 “groups”;Google 则拒绝该作用域。",
- "page.admin.settings.oidc.groups-claim": "组声明",
- "page.admin.settings.oidc.groups-claim.help": "包含组成员身份的声明 — Okta 使用 “groups”,Keycloak 通常使用 “roles”。Entra ID 返回组 GUID。",
- "page.admin.settings.oidc.username-claim": "用户名声明",
- "page.admin.settings.oidc.username-claim.help": "用作 Angles 用户名的声明。依次回退到 email、preferred_username 和 sub。",
- "page.admin.settings.saml.title": "SAML 2.0 配置",
- "page.admin.settings.saml.metadata-url": "SP 元数据 URL",
- "page.admin.settings.saml.metadata-url.help": "将其导入您的身份提供商,无需手动输入各项值。",
- "page.admin.settings.saml.entry-point": "IdP SSO URL",
- "page.admin.settings.saml.entry-point.help": "浏览器将被重定向到的身份提供商 URL。",
- "page.admin.settings.saml.idp-cert": "IdP 签名证书",
- "page.admin.settings.saml.idp-cert.help": "必填 — 它是断言可被信任的唯一依据。可包含或不包含 PEM 头部行。",
- "page.admin.settings.saml.issuer": "SP 实体 ID",
- "page.admin.settings.saml.issuer.help": "本服务提供商的实体 ID,需与在身份提供商处注册的一致。",
- "page.admin.settings.saml.private-key": "SP 私钥",
- "page.admin.settings.saml.private-key.help": "仅当身份提供商要求签名的认证请求时才需要。留空以保留已存储的密钥。",
- "page.admin.settings.saml.signature-algorithm": "签名算法",
- "page.admin.settings.saml.identifier-format": "Name ID 格式",
- "page.admin.settings.saml.username-attribute": "用户名属性",
- "page.admin.settings.saml.username-attribute.help": "包含用户名的断言属性。留空则使用 Name ID。",
- "page.admin.settings.saml.groups-attribute": "组属性",
- "page.admin.settings.saml.response-signed": "要求签名的响应",
- "page.admin.settings.saml.response-signed.help": "对于仅签名断言的身份提供商请关闭此项。断言签名始终是必需的。",
- "page.admin.settings.saml.allow-unsolicited": "允许 IdP 发起的登录",
- "page.admin.settings.saml.allow-unsolicited.help": "默认关闭:未经请求的断言没有可关联的请求,因而失去了重放保护。",
- "page.admin.settings.ldap.title": "LDAP / Active Directory 配置",
- "page.admin.settings.ldap.url": "目录 URL",
- "page.admin.settings.ldap.url.help": "请使用 ldaps://,或启用 StartTLS 的 ldap://。普通 ldap:// 连接会被拒绝,因为密码将以明文传输。",
- "page.admin.settings.ldap.bind-dn": "Bind DN",
- "page.admin.settings.ldap.bind-dn.help": "用于查找用户的服务账户。留空则进行匿名搜索。",
- "page.admin.settings.ldap.bind-credentials": "Bind 密码",
- "page.admin.settings.ldap.search-base": "用户搜索基准",
- "page.admin.settings.ldap.search-base.help": "例如:ou=users,dc=example,dc=com",
- "page.admin.settings.ldap.search-filter": "用户搜索过滤器",
- "page.admin.settings.ldap.search-filter.help": "{username} 会被替换为提交的用户名。Active Directory 通常使用 (sAMAccountName={username})。",
- "page.admin.settings.ldap.username-attribute": "用户名属性",
- "page.admin.settings.ldap.username-attribute.help": "用作 Angles 用户名的属性 — “uid”,在 Active Directory 上则为 “sAMAccountName”。",
- "page.admin.settings.ldap.group-search-base": "组搜索基准",
- "page.admin.settings.ldap.group-search-base.help": "留空则从用户的 memberOf 属性读取组成员身份,这也是 Active Directory 通常的暴露方式。",
- "page.admin.settings.ldap.group-search-filter": "组搜索过滤器",
- "page.admin.settings.ldap.group-name-attribute": "组名属性",
- "page.admin.settings.ldap.group-name-attribute.help": "完整 DN 会被简化为该属性,因此请将角色映射到诸如 “Angles Admins” 的名称。",
- "page.admin.settings.ldap.start-tls": "使用 StartTLS",
- "page.admin.settings.ldap.start-tls.help": "将普通 ldap:// 连接升级为 TLS。",
- "page.admin.settings.ldap.tls-reject-unauthorized": "验证 TLS 证书",
- "page.admin.settings.ldap.tls-reject-unauthorized.help": "关闭此项会使连接极易被拦截。仅适用于使用自签名证书的实验环境。",
- "page.admin.settings.role-mappings.title": "组到角色的映射",
- "page.admin.settings.role-mappings.help": "将提供商返回的组映射到 Angles 角色。匹配时不区分大小写,匹配多条映射的用户将获得权限最高的角色(管理员 > 团队负责人 > 用户)。",
- "page.admin.settings.role-mappings.empty": "尚无映射。若没有映射,访问权限将取决于下方的默认角色。",
- "page.admin.settings.role-mappings.add": "添加映射",
- "page.admin.settings.role-mappings.remove": "删除映射",
- "page.admin.settings.role-mappings.value-placeholder": "组名称或 ID",
- "page.team-settings.header": "团队设置",
- "page.team-settings.label.team": "团队",
- "page.team-settings.section.details": "团队详情",
- "page.team-settings.label.name": "团队名称",
- "page.team-settings.button.save-name": "保存名称",
- "page.team-settings.section.components": "组件",
- "page.team-settings.components.help": "组件可让您按功能区域对测试覆盖率进行分组。",
- "page.team-settings.label.existing-components": "现有组件",
- "page.team-settings.label.add-components": "添加组件",
- "page.team-settings.components.add-placeholder": "输入组件名称并按 Enter 键",
- "page.team-settings.button.add-components": "添加组件",
- "page.team-settings.no-team-access": "您尚未获得任何团队的访问权限。请联系管理员。",
- "page.team-settings.toast.fetch-error": "获取团队详情失败",
- "page.team-settings.toast.name-update-success": "团队名称更新成功",
- "page.team-settings.toast.name-update-error": "更新团队名称失败",
- "page.team-settings.toast.components-update-success": "组件添加成功",
- "page.team-settings.toast.components-update-error": "添加组件失败"
+ "nav.dashboard": "儀表板",
+ "nav.execution-metrics": "指標",
+ "nav.screenshot-library": "螢幕截圖庫",
+ "nav.language": "語言",
+ "nav.theme": "主題色調",
+ "nav.theme.light": "淺色",
+ "nav.theme.dark": "深色",
+ "nav.theme.group.light": "淺色主題",
+ "nav.theme.group.dark": "深色主題",
+ "nav.theme.slate-light": "板岩淺色",
+ "nav.theme.sepia": "復古棕",
+ "nav.theme.slate-dark": "板岩深色",
+ "nav.theme.midnight": "午夜",
+ "nav.theme.forest-light": "森林淺色",
+ "nav.theme.contrast-light": "高對比淺色",
+ "nav.theme.forest-dark": "森林深色",
+ "nav.theme.contrast-dark": "高對比深色",
+ "nav.theme.clay-light": "陶土(色盲友好)",
+ "nav.theme.clay-dark": "陶土深色(色盲友好)",
+ "nav.about": "關於 Angles",
+ "nav.team-settings": "团队设置",
+ "nav.admin": "管理",
+ "nav.admin.users": "用户",
+ "nav.admin.settings": "设置",
+ "nav.profile": "个人资料",
+ "nav.user-settings": "设置",
+ "nav.logout": "退出登录",
+ "app.utils.duration.seconds": "{seconds} 秒",
+ "app.utils.duration.minutes": "{minutes} 分鐘",
+ "app.utils.duration.hours": "{hours} 小時",
+ "app.utils.duration.not-started": "未啟動",
+ "app.utils.date-range-picker.number-of-days": "過去 {days} 天",
+ "app.retrieving-teams": "正在檢索團隊...",
+ "app.retrieving-teams-error": "出了點問題 [{teamsError}]",
+ "app.result.pass": "通過",
+ "app.result.fail": "失敗",
+ "app.result.skipped": "已跳過",
+ "app.result.error": "錯誤",
+ "app.result.total": "總計",
+ "page.home.bread-crumb": "首頁",
+ "page.dashboard.message.retrieving-builds": "正在擷取 {teamName} 的版本...",
+ "page.dashboard.filters.label.team": "團隊",
+ "page.dashboard.filters.label.period": "期間",
+ "page.dashboard.filters.label.display-limit": "顯示限制",
+ "page.dashboard.filters.label.environments": "環境",
+ "page.dashboard.filters.label.components": "元件",
+ "page.dashboard.panel.total-test-runs": "測試運行總數",
+ "page.dashboard.panel.total-test-executions": "測試執行總數",
+ "page.dashboard.panel.total-execution-time": "總執行時間",
+ "page.dashboard.chart.barchart.pass": "透過",
+ "page.dashboard.chart.barchart.skipped": "已跳過",
+ "page.dashboard.chart.barchart.error": "錯誤",
+ "page.dashboard.chart.barchart.fail": "失敗",
+ "page.dashboard.chart.barchart.execution-time-seconds": "執行時間(秒)",
+ "page.dashboard.chart.barchart.yaxis.number-of-tests": "測試數量",
+ "page.dashboard.builds-table.header.build-details": "建構的詳細資料",
+ "page.dashboard.builds-table.header.status": "状态",
+ "page.dashboard.builds-table.header.date-time": "日期/時間",
+ "page.dashboard.builds-table.header.result": "結果",
+ "page.dashboard.builds-table.header.flags": "标记",
+ "page.dashboard.builds-table.not-available": "不适用",
+ "page.dashboard.builds-table.artifacts.title": "构件",
+ "page.dashboard.builds-table.artifacts.count": "{count, plural, other {# 个构件}}",
+ "page.dashboard.builds-table.keep.enabled": "已启用保留标记 - 此测试运行不会被清理",
+ "page.dashboard.builds-table.keep.disabled": "未启用保留标记 - 此测试运行可能在清理时被删除",
+ "page.dashboard.menu.compare-test-runs": "比較測試運行",
+ "page.dashboard.menu.toggle-keep-flag": "切換保留標誌",
+ "page.dashboard.menu.clear-selection": "清除選擇",
+ "page.dashboard.menu.delete-build": "刪除構建",
+ "page.dashboard.menu.one-select-message": "請選擇至少一項測試執行",
+ "page.dashboard.confirm.delete-build.title": "刪除構建",
+ "page.dashboard.confirm.delete-build.message": "您確定要刪除選中的 {count} 個構建嗎?此操作無法撤銷,將永久刪除所有相關的執行記錄和截圖。",
+ "page.dashboard.confirm.delete-build.cancel": "取消",
+ "page.dashboard.toast.delete-build-success": "已成功刪除 {count} 個構建",
+ "page.dashboard.toast.delete-build-error": "刪除構建失敗",
+ "page.dashboard.execution-barchart.execution-time-seconds": "執行時間(秒)",
+ "page.dashboard.execution-bar-chart.title": "測試運行",
+ "page.dashboard.execution-pie-chart.title": "測試運行總體狀態",
+ "page.metrics.bread-crumb": "指標",
+ "page.metrics.filters.labels.team": "團隊",
+ "page.metrics.filters.labels.component": "元件",
+ "page.metrics.filters.labels.period": "期間",
+ "page.metrics.filters.labels.group-by-period": "分組依據",
+ "page.metrics.filters.button.retrieve-metrics": "檢索指標",
+ "page.metrics.tab.execution-metrics": "執行指標",
+ "page.metrics.tab.platform-metrics": "平台指標",
+ "page.metrics.execution-metrics-summary.label.period": "期間",
+ "page.metrics.execution-metrics-summary.label.number-of-tests": "測試數量",
+ "page.metrics.execution-metrics-summary.label.number-of-runs": "測試運行次數",
+ "page.metrics.execution-metrics-summary.label.number-of-executions": "執行次數",
+ "page.metrics.execution-metrics-summary.label.result": "結果",
+ "page.metrics.execution-metrics-summary.label.duration": "持續時間",
+ "page.metrics.execution-metrics-bar-chart.title": "按結果分組的執行指標",
+ "page.metrics.execution-metrics-bar-chart.yaxis-title": "執行次數",
+ "page.metrics.execution-metrics-phase-bar-chart.title": "按階段分組的執行指標",
+ "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "執行次數",
+ "page.metrics.platform-distribution-pie-chart.title": "按平台劃分的總體執行分佈",
+ "page.metrics.platform-distribution-bar-chart.title": "依平台執行分佈",
+ "page.metrics.platform-distribution-bar-chart.yaxis-title": "平台",
+ "page.metrics.platform-distribution-bar-chart.xaxis-title": "執行次數",
+ "page.metrics.platform-metrics-summary.label.platform": "平台",
+ "page.metrics.platform-metrics-summary.label.version": "版本",
+ "page.metrics.platform-metrics-summary.label.device": "裝置",
+ "page.metrics.platform-metrics-summary.label.browser": "瀏覽器",
+ "page.metrics.platform-metrics-summary.label.total": "總計",
+ "page.metrics.platform-metrics-summary.label.result": "結果",
+ "page.metrics.platform-metrics-summary.label.duration": "持續時間",
+ "page.metrics.metrics-test-table.label.unique-testcases": "每個階段的唯一測試案例",
+ "page.metrics.metrics-test-table.label.phase": "階段",
+ "page.metrics.metrics-test-table.label.test": "測試",
+ "page.metrics.metrics-test-table.no-tests-found": "找不到測試",
+ "page.test-run.bread-crumb": "測試運行",
+ "page.test-run.steps-header.start": "開始",
+ "page.test-run.steps-header.duration": "持續時間",
+ "page.test-run.steps-header.end": "結束",
+ "page.test-run.menu.enable-keep-flag": "啟用保留標誌",
+ "page.test-run.menu.disable-keep-flag": "停用保留標誌",
+ "page.test-run.menu.download-report": "下載 html 格式的報表",
+ "page.test-run.menu.delete-build": "刪除構建",
+ "page.test-run.confirm.delete-build.title": "刪除構建",
+ "page.test-run.confirm.delete-build.message": "您確定要刪除此構建嗎?此操作無法撤銷,將永久刪除所有相關的執行記錄和截圖。",
+ "page.test-run.confirm.delete-build.cancel": "取消",
+ "page.test-run.toast.delete-build-success": "構建已成功刪除",
+ "page.test-run.toast.delete-build-error": "刪除構建失敗",
+ "page.test-run.icons.environment.whisper": "環境",
+ "page.test-run.icons.team.whisper": "團隊",
+ "page.test-run.icons.component.whisper": "元件",
+ "page.test-run.icons.phase.whisper": "測試階段",
+ "page.test-run.icons.artifacts.whisper": "與此測試運行相關的工件數量。",
+ "page.test-run.icons.keep-lock.whisper": "鎖定時,測試運行不會被夜間 cronjob 清理。",
+ "page.test-run.icons.screenshots.whisper": "此測試運行有 {numberOfScreenshots} 個螢幕截圖",
+ "page.test-run.icons.download-report.whisper": "下載此測試執行的 HTML 報表。",
+ "page.test-run.header.status.whisper": "狀態:{status}",
+ "page.test-run.header.execution-count": "{count, plural, other {# 個執行}}",
+ "page.test-run.header.fact.not-set": "未設定",
+ "page.test-run.header.artifacts-title": "構件",
+ "page.test-run.suites.title": "測試套件",
+ "page.test-run.loading": "正在取得建置詳細資訊。",
+ "page.test-run.error.load-failed": "無法取得建置詳細資訊。請重新整理頁面再試一次,並確認建置 ID 是否有效。",
+ "page.test-run.execution-pie-chart.title": "總體執行指標",
+ "page.test-run.feature-distribution-pie-chart.title": "特徵分佈指標",
+ "page.test-run.execution-pie-chart.pass-rate": "通過率",
+ "page.test-run.feature-distribution-pie-chart.unspecified": "未指定",
+ "page.test-run.feature-distribution-pie-chart.executions": "執行次數",
+ "page.test-run-compare.bread-crumb": "比較測試運行",
+ "page.test-run-compare.header.number": "#",
+ "page.test-run-compare.header.test-name": "測試名稱",
+ "page.test-run-compare.detail.start-date": "開始日期",
+ "page.test-run-compare.detail.environment": "環境",
+ "page.test-run-compare.cell.not-available": "無",
+ "page.test-run-compare.cell.multiple-executions": "多次執行",
+ "page.test-run-compare.popover.details-title": "詳細資訊",
+ "page.test-run-compare.link.execution-history": "查看 {testName} 的執行歷史",
+ "page.test-run-compare.details-popover.header.property": "屬性",
+ "page.test-run-compare.details-popover.header.value": "值",
+ "page.test-run-compare.details-popover.property.status": "狀態",
+ "page.test-run-compare.details-popover.property.duration": "持續時間",
+ "page.test-run-compare.details-popover.property.platform": "平台",
+ "page.test-run-compare.details-popover.property.failing-step": "失敗步驟",
+ "page.test-run-compare.loading": "正在取得測試運行詳細資訊以產生比較檢視...",
+ "page.test-run-compare.section.artifacts": "構件版本",
+ "page.test-run-compare.artifacts.count": "{count, plural, other {# 個構件}}",
+ "page.test-run-compare.section.compare-matrix": "測試比較矩陣",
+ "page.test-run-compare.toolbar.status-filter": "依狀態篩選",
+ "page.test-run-compare.suite.tests": "{count, plural, other {# 個測試}}",
+ "page.test-run-compare.suite.rollup": "{total} 個測試中有 {passed} 個通過",
+ "page.test-run-compare.header.run-summary": "{total} 個測試中有 {passed} 個通過、{failed} 個失敗、{errors} 個錯誤",
+ "page.test-run-compare.cell.status-changed": "狀態與上一次運行不同",
+ "page.test-run-compare.warning.many-runs": "正在比較 {count} 個測試運行 — 請使用下方的篩選器聚焦於差異。",
+ "page.screenshot-library.bread-crumb": "螢幕截圖庫",
+ "page.screenshot-library.search-options.label.view": "檢視",
+ "page.screenshot-library.search-options.label.tag": "標籤",
+ "page.screenshot-library.search-options.label.or": "或",
+ "page.screenshot-library.search-options.label.period": "期間",
+ "page.screenshot-library.search-options.label.platform": "平台",
+ "page.screenshot-library.search-options.button.search-screenshots": "搜尋螢幕截圖",
+ "page.screenshot-library.search-options.dropdown.yesterday": "昨天",
+ "page.screenshot-library.search-options.dropdown.last-7-days": "過去 7 天",
+ "page.screenshot-library.search-options.dropdown.last-14-days": "過去 14 天",
+ "page.screenshot-library.search-options.dropdown.last-30-days": "過去 30 天",
+ "page.screenshot-library.search-options.dropdown.last-3-months": "過去 3 個月",
+ "page.screenshot-library.search-options.dropdown.last-6-months": "過去 6 個月",
+ "page.test-execution-history.bread-crumb": "測試執行歷史記錄",
+ "page.test-execution-history.header.suite": "套件",
+ "page.test-execution-history.messages.retrieving-executions": "檢索測試執行",
+ "page.test-execution-history.charts.execution-pie-chart.title": "總體執行指標",
+ "page.test-execution-history.filter.hint": "在下方圖表中選擇狀態以篩選此頁面。",
+ "page.test-execution-history.filter.showing": "顯示",
+ "page.test-execution-history.filter.active": "({count, plural, other {# 個執行}})",
+ "page.test-execution-history.filter.no-results": "沒有符合所選狀態的執行。",
+ "page.test-execution-history.charts.execution-timeline-chart.title": "執行時間",
+ "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "執行時間(秒)",
+ "page.test-execution-history.charts.execution-platform-bar-chart.title": "按平台劃分的執行次數",
+ "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "平台",
+ "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "執行次數",
+ "page.about.bread-crumb": "關於",
+ "page.about.header": "關於角度",
+ "page.about.about-angles": "Angles 是一個開源儀表板,可用於存儲來自不同框架的自動測試運行的結果和截圖。",
+ "page.about.about-api": "透過提供明確定義的 {apiLink},任何框架都可以適應將其測試結果存儲在Angles中,使用提供的客戶端之一(或直接使用API)。",
+ "page.about.about-github": "有關 Angles 的更多信息,請訪問頁面:{githubLink}",
+ "page.introduction.bread-crumb": "簡介",
+ "page.introduction.header": "歡迎來到角度",
+ "page.introduction.description": "您似乎尚未設定任何團隊或環境。\n\n這可以透過使用 {apiLink} 來完成。",
+ "page.introduction.api-link-text": "Swagger API",
+ "page.introduction.description-alternative": "或您也可以使用 {githubLink} 開始。",
+ "page.introduction.github-link-text": "Github 頁面",
+ "page.not-found.bread-crumb": "找不到",
+ "page.not-found.header": "找不到页面",
+ "page.not-found.description": "哎呀,糟糕,我們無法找到您要找的東西。點選 {homeLink} 返回主頁。",
+ "page.not-found.home-link-text": "此處",
+ "common.component.suite-table.header.suite": "套件",
+ "common.component.suite-table.header.test": "測試",
+ "common.component.suite-table.header.duration": "持續時間",
+ "common.component.suite-table.header.total": "總計",
+ "common.component.suite-table.header.collapse-all": "折疊所有測試案例和操作",
+ "common.component.suite-table.header.expand-all": "展開所有測試案例",
+ "common.component.suite-table.header.expand-all-actions": "展開所有測試案例和操作",
+ "common.component.suite-table.icon.test-history": "查看測試的執行歷史記錄",
+ "common.component.suite-table.header.show-screenshots": "顯示步驟截圖",
+ "common.component.suite-table.header.hide-screenshots": "隱藏步驟截圖",
+ "common.component.suite-table.header.execution-count": "{count, plural, other {# 個測試}}",
+ "common.component.suite-table.execution.action-count": "{count, plural, other {# 個動作}}",
+ "common.component.suite-table.action.step-count": "{count, plural, other {# 個步驟}}",
+ "common.component.screenshot-view.header": "螢幕截圖檢視器",
+ "common.component.screenshot-view.tabs.enable-alert": "要啟用「歷史記錄」和「與基線比較」選項卡,請在上傳螢幕截圖時提供視圖和平台詳細資訊",
+ "common.component.screenshot-view.tabs.label.image": "螢幕截圖",
+ "common.component.screenshot-view.tabs.label.history": "歷史記錄",
+ "common.component.screenshot-view.tabs.label.overlay-with-baseline": "與基線疊加",
+ "common.component.screenshot-view.tabs.label.side-by-side": "與基線並排",
+ "common.component.screenshot-view.tabs.label.find-image": "在圖片中尋找",
+ "common.component.screenshot-view.tabs.baseline.label.algorithm": "比較演算法",
+ "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "像素",
+ "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "結構相似性(SSIM)",
+ "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "將此變更區域加入為忽略區塊",
+ "common.component.screenshot-view.tabs.find-image.message.intro": "上傳較小的圖片(例如按鈕、標誌或遊戲素材),以在目前的螢幕截圖中定位它。",
+ "common.component.screenshot-view.tabs.find-image.label.template": "範本圖片",
+ "common.component.screenshot-view.tabs.find-image.label.min-confidence": "最低信賴度",
+ "common.component.screenshot-view.tabs.find-image.label.max-matches": "最大匹配數",
+ "common.component.screenshot-view.tabs.find-image.label.grayscale": "忽略顏色差異(灰階)",
+ "common.component.screenshot-view.tabs.find-image.button.find": "在螢幕截圖中尋找",
+ "common.component.screenshot-view.tabs.find-image.message.searching": "正在搜尋範本圖片,這可能需要幾秒鐘。",
+ "common.component.screenshot-view.tabs.find-image.message.no-matches": "未找到高於信賴度門檻的匹配。",
+ "common.component.screenshot-view.tabs.find-image.label.matches": "匹配",
+ "common.component.screenshot-view.tabs.find-image.label.position": "位置",
+ "common.component.screenshot-view.tabs.find-image.label.size": "尺寸",
+ "common.component.screenshot-view.tabs.find-image.label.confidence": "信賴度",
+ "common.component.screenshot-view.tabs.find-image.label.scale": "縮放比例",
+ "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "製作基線圖片",
+ "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "這是基線圖片",
+ "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "產生動態基線",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "刪除螢幕截圖",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "僅適用於未配置為基線的動態基線。",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "正在檢索螢幕截圖",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "無法擷取圖片。請重新整理頁面並重試。",
+ "common.component.screenshot-view.tabs.history.message.loading-history": "正在載入歷史記錄...",
+ "common.component.screenshot-view.tabs.history.label.baseline": "基線螢幕截圖",
+ "common.component.screenshot-view.tabs.history.label.view": "檢視",
+ "common.component.screenshot-view.tabs.history.label.date": "日期",
+ "common.component.screenshot-view.tabs.history.label.resolution": "解析度",
+ "common.component.screenshot-view.tabs.history.label.platform": "平台",
+ "common.component.screenshot-view.tabs.history.label.device": "裝置",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "目前影像(包括基線)",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image": "目前影像",
+ "common.component.screenshot-view.tabs.side-by-side.label.baseline": "基線",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "正在檢索基線螢幕截圖。",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "無法擷取基線影像。請重新整理頁面並重試。",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "正在檢索螢幕截圖。",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "無法擷取圖片。請重新整理頁面並重試。",
+ "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "目前影像(包括基線)",
+ "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "基線比較",
+ "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "與基線重做比較。",
+ "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "編輯忽略區塊",
+ "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "儲存忽略區塊",
+ "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "取消更改",
+ "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "尚未為此視圖和 deviceName 或瀏覽器組合選擇基線。要選擇基線,請導航至要作為基線的圖片然後單擊\"製作基線圖像\"按鈕",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "正在載入基線比較",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "無法擷取基線比較。",
+ "common.component.screenshot-details-table.label.view": "檢視",
+ "common.component.screenshot-details-table.label.view-link": "其他平台",
+ "common.component.screenshot-details-table.label.view-link-description": "查找視圖 {view} 的所有最新螢幕截圖,按平台分組。",
+ "common.component.screenshot-details-table.label.no-view": "未提供視圖",
+ "common.component.screenshot-details-table.label.date": "日期",
+ "common.component.screenshot-details-table.label.baseline-date": "基準日期",
+ "common.component.screenshot-details-table.label.mismatch": "不符",
+ "common.component.screenshot-details-table.label.algorithm": "演算法",
+ "common.component.screenshot-details-table.label.ssim-score": "SSIM 分數",
+ "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (分析時間: {time} ms)",
+ "common.component.screenshot-details-table.label.resolution": "解析度",
+ "common.component.screenshot-details-table.label.platform": "平台",
+ "common.component.screenshot-details-table.label.no-platform": "未提供平台",
+ "common.component.screenshot-details-table.label.platform-version": "平台版本",
+ "common.component.screenshot-details-table.label.device": "裝置",
+ "common.component.screenshot-details-table.label.browser": "瀏覽器",
+ "common.component.screenshot-details-table.label.no-browser": "未提供瀏覽器",
+ "common.component.screenshot-details-table.label.browser-version": "瀏覽器版本",
+ "common.component.screenshot-details-table.label.no-browser-version": "未提供瀏覽器版本",
+ "common.component.screenshot-details-table.label.baseline": "基線",
+ "common.component.screenshot-details-table.label.tags": "標籤",
+ "common.component.screenshot-details-table.label.tags-description": "查找帶有標籤 \"{tag}\" 的所有最新螢幕截圖,按視圖分組。",
+ "common.yes": "是",
+ "common.no": "否",
+ "common.button.ok": "确定",
+ "page.login.title": "登录到 Angles",
+ "page.login.error.invalid-credentials": "用户名或密码无效",
+ "page.login.label.username": "用户名",
+ "page.login.label.password": "密码",
+ "page.login.button.login": "登录",
+ "page.login.divider.or": "或",
+ "page.login.label.sign-in-with": "登录方式",
+ "page.login.source.local": "本地账户",
+ "page.login.button.sso": "使用 {name} 继续",
+ "page.login.error.sso-failed": "单点登录失败。请重试或联系管理员。",
+ "page.login.error.no-methods": "没有可用的登录方式。请联系管理员。",
+ "page.user-settings.header": "用户设置",
+ "page.user-settings.api-tokens.title": "API 令牌",
+ "page.user-settings.api-tokens.description": "个人 API 令牌的作用与普通密码类似,但允许您以编程方式向 API 进行身份验证。",
+ "page.user-settings.button.generate-new-token": "生成新令牌",
+ "page.user-settings.table.name": "名称",
+ "page.user-settings.table.created": "创建时间",
+ "page.user-settings.table.expires": "到期",
+ "page.user-settings.table.action": "操作",
+ "page.user-settings.table.revoke": "撤销",
+ "page.user-settings.modal.token-generated": "令牌已生成",
+ "page.user-settings.modal.generate-token": "生成新的 API 令牌",
+ "page.user-settings.modal.success-label": "成功!",
+ "page.user-settings.modal.success": "您的新令牌已生成。",
+ "page.user-settings.modal.warning": "请立即复制此令牌。您将无法再次看到它!",
+ "page.user-settings.modal.token-name": "令牌名称",
+ "page.user-settings.modal.token-placeholder": "例如:CI/CD Pipeline",
+ "page.user-settings.modal.expiration": "到期时间",
+ "page.user-settings.modal.custom-expiration": "自定义到期时间(天)",
+ "page.user-settings.button.done": "完成",
+ "page.user-settings.button.generate": "生成",
+ "page.user-settings.button.cancel": "取消",
+ "page.user-settings.confirm.revoke.title": "撤销令牌",
+ "page.user-settings.confirm.revoke.message": "您确定要撤销此令牌吗?使用此令牌的任何脚本或应用程序将立即失去访问权限。",
+ "page.user-settings.table.expires-never": "从不",
+ "page.user-settings.modal.token-label": "令牌",
+ "page.user-settings.modal.button.copy": "复制",
+ "page.user-settings.expiry.1-day": "1天",
+ "page.user-settings.expiry.1-week": "1周",
+ "page.user-settings.expiry.1-month": "1个月",
+ "page.user-settings.expiry.1-year": "1年",
+ "page.user-settings.expiry.custom": "自定义",
+ "page.user-settings.toast.fetch-tokens-error": "获取令牌失败",
+ "page.user-settings.toast.token-name-required": "令牌名称为必填项",
+ "page.user-settings.toast.expiry-required": "需要有效的到期天数",
+ "page.user-settings.toast.generate-token-error": "生成令牌失败",
+ "page.user-settings.toast.revoke-success": "令牌已成功撤销",
+ "page.user-settings.toast.revoke-error": "撤销令牌失败",
+ "page.user-settings.toast.copy-success": "令牌已复制到剪贴板",
+ "password.policy.min-length": "长度至少为 10 个字符",
+ "password.policy.max-length": "长度不超过 100 个字符",
+ "password.policy.letter": "至少包含一个字母",
+ "password.policy.uppercase": "至少包含一个大写字母",
+ "password.policy.special": "至少包含一个特殊字符",
+ "password.policy.invalid": "密码不符合安全要求",
+ "page.user-settings.password.title": "密码",
+ "page.user-settings.password.description": "更改用于登录 Angles 的密码。",
+ "page.user-settings.password.button.change": "更改密码",
+ "page.user-settings.password.modal.title": "更改密码",
+ "page.user-settings.password.modal.current": "当前密码",
+ "page.user-settings.password.modal.new": "新密码",
+ "page.user-settings.password.modal.confirm": "确认新密码",
+ "page.user-settings.password.modal.mismatch": "密码不匹配",
+ "page.user-settings.password.modal.submit": "更新密码",
+ "page.user-settings.password.modal.success": "您的密码已更新,请在下次登录时使用。",
+ "page.user-settings.password.toast.current-required": "当前密码为必填项",
+ "page.user-settings.password.toast.mismatch": "新密码与确认密码不匹配",
+ "page.user-settings.password.toast.success": "密码更新成功",
+ "page.user-settings.password.toast.error": "密码更新失败",
+ "page.admin.settings.header": "认证设置",
+ "page.admin.settings.loading": "正在加载设置...",
+ "page.admin.settings.local-auth.title": "本地认证",
+ "page.admin.settings.local-auth.enable": "启用本地认证",
+ "page.admin.settings.local-auth.help": "允许用户使用用户名和密码登录。",
+ "page.admin.settings.button.save": "保存设置",
+ "page.admin.users.header": "用户管理",
+ "page.admin.users.button.add-user": "添加用户",
+ "page.admin.users.table.username": "用户名",
+ "page.admin.users.table.role": "角色",
+ "page.admin.users.table.teams": "团队",
+ "page.admin.users.table.action": "操作",
+ "page.admin.users.table.edit": "编辑",
+ "page.admin.users.table.delete": "删除",
+ "page.admin.users.modal.edit-user": "编辑用户",
+ "page.admin.users.modal.add-user": "添加用户",
+ "page.admin.users.modal.password-help": "(留空以保持不变)",
+ "page.admin.users.modal.user-type": "用户类型",
+ "page.admin.users.modal.team-access": "团队权限",
+ "page.admin.users.button.save": "保存",
+ "page.admin.users.button.cancel": "取消",
+ "page.admin.users.confirm.delete.title": "删除用户",
+ "page.admin.users.confirm.delete.message": "您确定要删除此用户吗?此操作无法撤销。",
+ "page.admin.users.user-type.admin": "管理员",
+ "page.admin.users.user-type.team_lead": "团队负责人",
+ "page.admin.users.user-type.user": "用户",
+ "page.admin.users.modal.password-label": "密码",
+ "page.admin.users.table.teams-assigned": "已分配 {count} 个团队",
+ "page.admin.users.toast.fetch-error": "获取用户失败",
+ "page.admin.users.toast.update-success": "用户更新成功",
+ "page.admin.users.toast.create-success": "用户创建成功",
+ "page.admin.users.toast.save-error": "保存用户失败",
+ "page.admin.users.toast.delete-success": "用户删除成功",
+ "page.admin.users.toast.delete-error": "删除用户失败",
+ "page.admin.settings.toast.fetch-error": "获取设置失败",
+ "page.admin.settings.toast.save-success": "设置已成功保存",
+ "page.admin.settings.toast.save-error": "保存设置失败",
+ "page.admin.settings.providers.title": "身份提供商",
+ "page.admin.settings.providers.help": "配置单点登录提供商。更改立即生效,无需重启。",
+ "page.admin.settings.providers.empty": "尚未配置任何身份提供商。",
+ "page.admin.settings.providers.table.name": "名称",
+ "page.admin.settings.providers.table.id": "ID",
+ "page.admin.settings.providers.table.type": "类型",
+ "page.admin.settings.providers.table.enabled": "已启用",
+ "page.admin.settings.providers.table.status": "状态",
+ "page.admin.settings.button.add-provider": "添加提供商",
+ "page.admin.settings.button.save-provider": "保存提供商",
+ "page.admin.settings.confirm.delete.title": "删除提供商",
+ "page.admin.settings.confirm.delete.message": "确定要删除此身份提供商吗?通过它登录的用户将失去访问权限。",
+ "page.admin.settings.provider.modal.add": "添加身份提供商",
+ "page.admin.settings.provider.modal.edit": "编辑身份提供商",
+ "page.admin.settings.provider.name": "显示名称",
+ "page.admin.settings.provider.name.help": "显示在登录按钮上,例如“公司 SSO”。",
+ "page.admin.settings.provider.id": "提供商 ID",
+ "page.admin.settings.provider.id.help": "2-32 个小写字母、数字或连字符。它是回调 URL 的一部分,之后无法更改。",
+ "page.admin.settings.provider.id.help-locked": "该 ID 是在身份提供商处注册的回调 URL 的一部分,无法更改。",
+ "page.admin.settings.provider.type": "提供商类型",
+ "page.admin.settings.provider.type.oidc": "OIDC",
+ "page.admin.settings.provider.type.saml": "SAML 2.0",
+ "page.admin.settings.provider.type.ldap": "LDAP / AD",
+ "page.admin.settings.provider.type.help-locked": "创建后无法更改提供商类型。",
+ "page.admin.settings.provider.enabled": "已启用",
+ "page.admin.settings.provider.enabled.help": "在登录页面上提供此提供商。",
+ "page.admin.settings.provider.callback-url": "重定向 URI",
+ "page.admin.settings.provider.acs-url": "ACS(回调)URL",
+ "page.admin.settings.provider.callback-url.help": "请在您的身份提供商处注册此 URL。",
+ "page.admin.settings.provider.status.ready": "就绪",
+ "page.admin.settings.provider.status.error": "错误",
+ "page.admin.settings.provider.status.enabled": "已启用",
+ "page.admin.settings.provider.status.disabled": "已禁用",
+ "page.admin.settings.provider.default-role": "默认角色",
+ "page.admin.settings.provider.default-role.deny": "拒绝访问",
+ "page.admin.settings.provider.default-role.help": "当没有任何映射匹配时授予的角色。拒绝访问是更安全的默认设置。",
+ "page.admin.settings.provider.secret.help-set": "已配置一个值。留空以保留该值,或输入新值以替换它。",
+ "page.admin.settings.provider.secret.help-unset": "保存后将无法再次查看此值。",
+ "page.admin.settings.provider.error.id": "提供商 ID 必须为 2-32 个小写字母、数字或连字符。",
+ "page.admin.settings.provider.error.duplicate-id": "另一个提供商已使用此 ID。",
+ "page.admin.settings.provider.error.name": "必须填写显示名称。",
+ "page.admin.settings.provider.error.mapping-value": "每个角色映射都需要一个组值。",
+ "page.admin.settings.oidc.title": "OIDC 配置",
+ "page.admin.settings.oidc.issuer": "Issuer URL",
+ "page.admin.settings.oidc.issuer.help": "端点和签名密钥通过 issuer 的 .well-known/openid-configuration 自动发现。",
+ "page.admin.settings.oidc.client-id": "客户端 ID",
+ "page.admin.settings.oidc.client-secret": "客户端密钥",
+ "page.admin.settings.oidc.scopes": "作用域",
+ "page.admin.settings.oidc.scopes.help": "以空格分隔。始终会添加 “openid”。Okta 和 Keycloak 接受 “groups”;Google 则拒绝该作用域。",
+ "page.admin.settings.oidc.groups-claim": "组声明",
+ "page.admin.settings.oidc.groups-claim.help": "包含组成员身份的声明 — Okta 使用 “groups”,Keycloak 通常使用 “roles”。Entra ID 返回组 GUID。",
+ "page.admin.settings.oidc.username-claim": "用户名声明",
+ "page.admin.settings.oidc.username-claim.help": "用作 Angles 用户名的声明。依次回退到 email、preferred_username 和 sub。",
+ "page.admin.settings.saml.title": "SAML 2.0 配置",
+ "page.admin.settings.saml.metadata-url": "SP 元数据 URL",
+ "page.admin.settings.saml.metadata-url.help": "将其导入您的身份提供商,无需手动输入各项值。",
+ "page.admin.settings.saml.entry-point": "IdP SSO URL",
+ "page.admin.settings.saml.entry-point.help": "浏览器将被重定向到的身份提供商 URL。",
+ "page.admin.settings.saml.idp-cert": "IdP 签名证书",
+ "page.admin.settings.saml.idp-cert.help": "必填 — 它是断言可被信任的唯一依据。可包含或不包含 PEM 头部行。",
+ "page.admin.settings.saml.issuer": "SP 实体 ID",
+ "page.admin.settings.saml.issuer.help": "本服务提供商的实体 ID,需与在身份提供商处注册的一致。",
+ "page.admin.settings.saml.private-key": "SP 私钥",
+ "page.admin.settings.saml.private-key.help": "仅当身份提供商要求签名的认证请求时才需要。留空以保留已存储的密钥。",
+ "page.admin.settings.saml.signature-algorithm": "签名算法",
+ "page.admin.settings.saml.identifier-format": "Name ID 格式",
+ "page.admin.settings.saml.username-attribute": "用户名属性",
+ "page.admin.settings.saml.username-attribute.help": "包含用户名的断言属性。留空则使用 Name ID。",
+ "page.admin.settings.saml.groups-attribute": "组属性",
+ "page.admin.settings.saml.response-signed": "要求签名的响应",
+ "page.admin.settings.saml.response-signed.help": "对于仅签名断言的身份提供商请关闭此项。断言签名始终是必需的。",
+ "page.admin.settings.saml.allow-unsolicited": "允许 IdP 发起的登录",
+ "page.admin.settings.saml.allow-unsolicited.help": "默认关闭:未经请求的断言没有可关联的请求,因而失去了重放保护。",
+ "page.admin.settings.ldap.title": "LDAP / Active Directory 配置",
+ "page.admin.settings.ldap.url": "目录 URL",
+ "page.admin.settings.ldap.url.help": "请使用 ldaps://,或启用 StartTLS 的 ldap://。普通 ldap:// 连接会被拒绝,因为密码将以明文传输。",
+ "page.admin.settings.ldap.bind-dn": "Bind DN",
+ "page.admin.settings.ldap.bind-dn.help": "用于查找用户的服务账户。留空则进行匿名搜索。",
+ "page.admin.settings.ldap.bind-credentials": "Bind 密码",
+ "page.admin.settings.ldap.search-base": "用户搜索基准",
+ "page.admin.settings.ldap.search-base.help": "例如:ou=users,dc=example,dc=com",
+ "page.admin.settings.ldap.search-filter": "用户搜索过滤器",
+ "page.admin.settings.ldap.search-filter.help": "{username} 会被替换为提交的用户名。Active Directory 通常使用 (sAMAccountName={username})。",
+ "page.admin.settings.ldap.username-attribute": "用户名属性",
+ "page.admin.settings.ldap.username-attribute.help": "用作 Angles 用户名的属性 — “uid”,在 Active Directory 上则为 “sAMAccountName”。",
+ "page.admin.settings.ldap.group-search-base": "组搜索基准",
+ "page.admin.settings.ldap.group-search-base.help": "留空则从用户的 memberOf 属性读取组成员身份,这也是 Active Directory 通常的暴露方式。",
+ "page.admin.settings.ldap.group-search-filter": "组搜索过滤器",
+ "page.admin.settings.ldap.group-name-attribute": "组名属性",
+ "page.admin.settings.ldap.group-name-attribute.help": "完整 DN 会被简化为该属性,因此请将角色映射到诸如 “Angles Admins” 的名称。",
+ "page.admin.settings.ldap.start-tls": "使用 StartTLS",
+ "page.admin.settings.ldap.start-tls.help": "将普通 ldap:// 连接升级为 TLS。",
+ "page.admin.settings.ldap.tls-reject-unauthorized": "验证 TLS 证书",
+ "page.admin.settings.ldap.tls-reject-unauthorized.help": "关闭此项会使连接极易被拦截。仅适用于使用自签名证书的实验环境。",
+ "page.admin.settings.role-mappings.title": "组到角色的映射",
+ "page.admin.settings.role-mappings.help": "将提供商返回的组映射到 Angles 角色。匹配时不区分大小写,匹配多条映射的用户将获得权限最高的角色(管理员 > 团队负责人 > 用户)。",
+ "page.admin.settings.role-mappings.empty": "尚无映射。若没有映射,访问权限将取决于下方的默认角色。",
+ "page.admin.settings.role-mappings.add": "添加映射",
+ "page.admin.settings.role-mappings.remove": "删除映射",
+ "page.admin.settings.role-mappings.value-placeholder": "组名称或 ID",
+ "page.team-settings.header": "团队设置",
+ "page.team-settings.label.team": "要配置的团队",
+ "page.team-settings.label.team.help": "此设置仅影响您在此处编辑的团队,不会更改顶部菜单中所选的团队。",
+ "page.team-settings.section.details": "团队详情",
+ "page.team-settings.label.name": "团队名称",
+ "page.team-settings.button.save-name": "保存名称",
+ "page.team-settings.section.components": "组件",
+ "page.team-settings.components.help": "组件可让您按功能区域对测试覆盖率进行分组。",
+ "page.team-settings.label.existing-components": "现有组件",
+ "page.team-settings.label.add-components": "添加组件",
+ "page.team-settings.components.add-placeholder": "输入组件名称并按 Enter 键",
+ "page.team-settings.button.add-components": "添加组件",
+ "page.team-settings.no-team-access": "您尚未获得任何团队的访问权限。请联系管理员。",
+ "page.team-settings.toast.fetch-error": "获取团队详情失败",
+ "page.team-settings.toast.name-update-success": "团队名称更新成功",
+ "page.team-settings.toast.name-update-error": "更新团队名称失败",
+ "page.team-settings.toast.components-update-success": "组件添加成功",
+ "page.team-settings.toast.components-update-error": "添加组件失败",
+ "nav.manual-test-cases": "手动测试用例",
+ "app.manual.status.draft": "草稿",
+ "app.manual.status.active": "启用",
+ "app.manual.status.deprecated": "已弃用",
+ "app.manual.priority.low": "低",
+ "app.manual.priority.medium": "中",
+ "app.manual.priority.high": "高",
+ "app.manual.priority.critical": "紧急",
+ "app.manual.cancel": "取消",
+ "app.manual.version-badge.version": "v{version}",
+ "app.manual.version-badge.behind": "· 落后最新 {count} 个版本",
+ "app.manual.history.loading": "正在加载历史...",
+ "app.manual.history.empty": "尚无变更记录。",
+ "app.manual.history.unknown-author": "未知用户",
+ "app.manual.history.caused-by": "由共享步骤“{name}”的更新引起",
+ "app.manual.history.action.create": "已创建",
+ "app.manual.history.action.update": "已更新",
+ "app.manual.history.action.delete": "已删除",
+ "app.manual.history.action.clone": "已克隆",
+ "app.manual.history.action.status-change": "状态已更改",
+ "app.manual.history.action.shared-step-update": "共享步骤已更新",
+ "app.manual.history.action.archive": "已归档",
+ "app.manual.step-editor.expected": "预期结果",
+ "app.manual.step-editor.data": "测试数据",
+ "app.manual.step-editor.shared-step": "共享步骤",
+ "app.manual.step-editor.shared-step-placeholder": "选择共享步骤",
+ "app.manual.step-editor.shared-step-help": "运行用例或保存版本时,其步骤将替换此步骤。",
+ "app.manual.step-editor.from-shared-step": "来自共享步骤 (v{version})",
+ "app.manual.step-editor.unresolved": "共享步骤已不存在",
+ "app.manual.step-editor.action-placeholder": "测试人员应执行什么操作?",
+ "app.manual.step-editor.expected-placeholder": "预期会发生什么?",
+ "app.manual.step-editor.data-placeholder": "测试数据(可选)",
+ "app.manual.step-editor.add-step": "添加步骤",
+ "app.manual.step-editor.add-shared-step": "引用共享步骤",
+ "app.manual.step-editor.remove": "删除步骤",
+ "app.manual.step-editor.empty": "尚无步骤,请在下方添加。",
+ "app.manual.custom-field.archived": "(已归档)",
+ "page.manual-test-cases": "手动测试用例",
+ "page.manual-test-cases.create": "新建测试用例",
+ "page.manual-test-cases.new-case-title": "未命名测试用例",
+ "page.manual-test-cases.loading": "正在加载测试用例...",
+ "page.manual-test-cases.empty": "没有符合这些筛选条件的测试用例。",
+ "page.manual-test-cases.no-team": "请从页面顶部的菜单中选择一个团队,以查看其手动测试用例。",
+ "page.manual-test-cases.filters.search": "搜索标题或描述",
+ "page.manual-test-cases.filters.status": "状态",
+ "page.manual-test-cases.filters.priority": "优先级",
+ "page.manual-test-cases.table.title": "标题",
+ "page.manual-test-cases.table.status": "状态",
+ "page.manual-test-cases.table.priority": "优先级",
+ "page.manual-test-cases.table.version": "版本",
+ "page.manual-test-cases.table.updated-by": "更新者",
+ "page.manual-test-cases.table.steps": "步骤数",
+ "page.manual-test-cases.toast.fetch-error": "无法加载测试用例。",
+ "page.manual-test-cases.toast.create-error": "无法创建测试用例。",
+ "page.manual-test-case-detail.loading": "正在加载测试用例...",
+ "page.manual-test-case-detail.save": "保存",
+ "page.manual-test-case-detail.clone": "克隆",
+ "page.manual-test-case-detail.delete": "删除",
+ "page.manual-test-case-detail.tab.details": "详情",
+ "page.manual-test-case-detail.tab.steps": "步骤",
+ "page.manual-test-case-detail.tab.versions": "版本",
+ "page.manual-test-case-detail.tab.history": "历史记录",
+ "page.manual-test-case-detail.field.title": "标题",
+ "page.manual-test-case-detail.field.description": "描述",
+ "page.manual-test-case-detail.field.preconditions": "前置条件",
+ "page.manual-test-case-detail.field.status": "状态",
+ "page.manual-test-case-detail.field.priority": "优先级",
+ "page.manual-test-case-detail.field.tags": "标签",
+ "page.manual-test-case-detail.status-help": "必填自定义字段仅在用例离开“草稿”状态后强制校验。",
+ "page.manual-test-case-detail.custom-fields": "自定义字段",
+ "page.manual-test-case-detail.select-version": "选择版本",
+ "page.manual-test-case-detail.version-option": "版本 {version}",
+ "page.manual-test-case-detail.versions-help": "选择一个版本,查看执行时测试人员所看到的内容。",
+ "page.manual-test-case-detail.delete-confirm-title": "删除测试用例",
+ "page.manual-test-case-detail.delete-confirm-message": "这将删除该测试用例及其全部版本历史,且无法撤销。",
+ "page.manual-test-case-detail.toast.fetch-error": "无法加载测试用例。",
+ "page.manual-test-case-detail.toast.save-error": "无法保存测试用例。",
+ "page.manual-test-case-detail.toast.clone-error": "无法克隆测试用例。",
+ "page.manual-test-case-detail.toast.delete-error": "无法删除测试用例。",
+ "page.manual-test-case-detail.toast.version-error": "无法加载该版本。",
+ "page.manual-test-case-detail.toast.history-error": "无法加载变更历史。",
+ "app.execution-type.all": "全部",
+ "app.execution-type.automated": "自动化",
+ "app.execution-type.manual": "手动",
+ "page.dashboard.filters.label.execution-type": "类型",
+ "page.metrics.filters.labels.execution-type": "类型",
+ "app.manual.save": "保存",
+ "app.manual.delete": "删除",
+ "app.manual.edit": "编辑",
+ "app.manual.actions": "操作",
+ "app.manual.loading": "加载中...",
+ "app.manual.yes": "是",
+ "app.manual.no": "否",
+ "app.manual.no-team": "请从页面顶部的菜单中选择一个团队以继续。",
+ "app.manual.field.description": "描述",
+ "app.manual.field.preconditions": "前置条件",
+ "app.manual.field.priority": "优先级",
+ "app.manual.field.tags": "标签",
+ "app.manual.field.title": "标题",
+ "app.manual.field.expected": "预期结果",
+ "app.manual.field.steps": "步骤",
+ "app.manual.attachments.drop-hint": "将图片拖放到此处,或点击选择",
+ "app.manual.attachments.uploading": "上传中...",
+ "app.manual.attachments.upload-error": "无法上传附件。",
+ "app.manual.attachments.delete-error": "无法删除附件。它可能被某个已冻结版本引用。",
+ "app.manual.attachments.copy-reference": "复制 Markdown 引用",
+ "app.manual.attachments.reference-copied": "引用已复制到剪贴板。",
+ "app.manual.version-diff.identical": "这两个版本的内容完全相同。",
+ "app.manual.version-diff.field": "字段",
+ "app.manual.version-diff.before": "变更前",
+ "app.manual.version-diff.after": "变更后",
+ "app.manual.version-diff.empty": "(空)",
+ "app.manual.version-diff.step": "步骤 {number}",
+ "app.manual.custom-field.type.text": "文本",
+ "app.manual.custom-field.type.textarea": "长文本",
+ "app.manual.custom-field.type.number": "数字",
+ "app.manual.custom-field.type.date": "日期",
+ "app.manual.custom-field.type.boolean": "是 / 否",
+ "app.manual.custom-field.type.select": "单选",
+ "app.manual.custom-field.type.multiselect": "多选",
+ "app.manual.custom-field.type.user": "用户",
+ "app.manual.custom-field.scope.testcase": "测试用例",
+ "app.manual.custom-field.scope.execution": "执行",
+ "app.manual.custom-field.scope.both": "两者",
+ "app.manual.run-status.planned": "已计划",
+ "app.manual.run-status.in_progress": "进行中",
+ "app.manual.run-status.completed": "已完成",
+ "app.manual.run-status.cancelled": "已取消",
+ "app.manual.case-status.pass": "通过",
+ "app.manual.case-status.fail": "失败",
+ "app.manual.case-status.error": "错误",
+ "app.manual.case-status.skipped": "已跳过",
+ "app.manual.case-status.blocked": "被阻塞",
+ "app.manual.case-status.not_run": "未执行",
+ "app.manual.case-status.in_progress": "进行中",
+ "app.manual.step-status.not_run": "未执行",
+ "app.manual.step-status.pass": "通过",
+ "app.manual.step-status.fail": "失败",
+ "app.manual.step-status.blocked": "被阻塞",
+ "app.manual.step-status.skipped": "已跳过",
+ "nav.manual-testing": "手动测试",
+ "nav.manual-test-runs": "测试执行",
+ "nav.shared-steps": "共享步骤",
+ "nav.admin.custom-fields": "自定义字段",
+ "page.admin-custom-fields.title": "自定义字段",
+ "page.admin-custom-fields.help": "为该团队的每个手动测试用例显示的额外字段。仅当用例离开草稿状态时才强制填写必填字段。",
+ "page.admin-custom-fields.button.add": "添加字段",
+ "page.admin-custom-fields.archived": "已归档",
+ "page.admin-custom-fields.header.label": "标签",
+ "page.admin-custom-fields.header.key": "键",
+ "page.admin-custom-fields.header.type": "类型",
+ "page.admin-custom-fields.header.required": "必填",
+ "page.admin-custom-fields.header.applies-to": "适用于",
+ "page.admin-custom-fields.modal.add-title": "添加自定义字段",
+ "page.admin-custom-fields.modal.edit-title": "编辑自定义字段",
+ "page.admin-custom-fields.form.label": "标签",
+ "page.admin-custom-fields.form.key": "键",
+ "page.admin-custom-fields.form.key-help": "小写字母、数字和下划线。字段创建后无法更改。",
+ "page.admin-custom-fields.form.key-invalid": "必须以小写字母开头,且只能包含小写字母、数字和下划线(最多40个字符)。",
+ "page.admin-custom-fields.form.type": "类型",
+ "page.admin-custom-fields.form.options": "选项",
+ "page.admin-custom-fields.form.applies-to": "适用于",
+ "page.admin-custom-fields.form.order": "显示顺序",
+ "page.admin-custom-fields.form.required": "必填",
+ "page.admin-custom-fields.form.required-help": "仅当测试用例离开草稿状态时才强制填写。",
+ "page.admin-custom-fields.delete-confirm-title": "删除自定义字段",
+ "page.admin-custom-fields.delete-confirm-message": "如果仍有测试用例保存该字段的值,它将被归档而不是删除,以保证现有内容仍可读。",
+ "page.admin-custom-fields.toast.fetch-error": "无法加载自定义字段。",
+ "page.admin-custom-fields.toast.save-error": "无法保存自定义字段。",
+ "page.admin-custom-fields.toast.delete-error": "无法删除自定义字段。",
+ "page.admin-custom-fields.toast.archived": "该字段正在使用中,因此已被归档而非删除。",
+ "page.admin-custom-fields.toast.deleted": "自定义字段已删除。",
+ "page.shared-steps.title": "共享步骤",
+ "page.shared-steps.help": "可被多个测试用例引用的可复用步骤序列。编辑其中一个会为引用它的每个测试用例创建新版本。",
+ "page.shared-steps.button.add": "添加共享步骤",
+ "page.shared-steps.search-placeholder": "搜索共享步骤",
+ "page.shared-steps.header.name": "名称",
+ "page.shared-steps.header.description": "描述",
+ "page.shared-steps.header.steps": "步骤",
+ "page.shared-steps.header.version": "版本",
+ "page.shared-steps.action.usage": "使用情况",
+ "page.shared-steps.action.history": "历史记录",
+ "page.shared-steps.modal.add-title": "添加共享步骤",
+ "page.shared-steps.modal.edit-title": "编辑共享步骤",
+ "page.shared-steps.form.name": "名称",
+ "page.shared-steps.form.description": "描述",
+ "page.shared-steps.cascade-warning": "有 {count} 个测试用例引用了该共享步骤。保存将为每个用例创建新版本。",
+ "page.shared-steps.usage.title": "使用该共享步骤的测试用例",
+ "page.shared-steps.usage.empty": "暂无测试用例引用该共享步骤。",
+ "page.shared-steps.history.title": "共享步骤历史记录",
+ "page.shared-steps.delete-confirm-title": "删除共享步骤",
+ "page.shared-steps.delete-confirm-message": "仍被测试用例引用的共享步骤无法删除。",
+ "page.shared-steps.toast.fetch-error": "无法加载共享步骤。",
+ "page.shared-steps.toast.save-error": "无法保存共享步骤。",
+ "page.shared-steps.toast.delete-error": "无法删除共享步骤。",
+ "page.shared-steps.toast.usage-error": "无法加载使用情况列表。",
+ "page.shared-steps.toast.history-error": "无法加载历史记录。",
+ "page.shared-steps.toast.cascaded": "已为 {count} 个测试用例创建新版本。",
+ "page.manual-test-runs.title": "手动测试执行",
+ "page.manual-test-runs.button.add": "新建测试执行",
+ "page.manual-test-runs.button.create": "创建执行",
+ "page.manual-test-runs.filter.status": "状态",
+ "page.manual-test-runs.header.name": "名称",
+ "page.manual-test-runs.header.status": "状态",
+ "page.manual-test-runs.header.progress": "进度",
+ "page.manual-test-runs.header.environment": "环境",
+ "page.manual-test-runs.modal.title": "新建测试执行",
+ "page.manual-test-runs.form.name": "名称",
+ "page.manual-test-runs.form.environment": "环境",
+ "page.manual-test-runs.form.component": "组件",
+ "page.manual-test-runs.form.component-help": "一次执行只覆盖单个组件。无法添加其他组件的测试用例。",
+ "page.manual-test-runs.form.test-cases": "测试用例",
+ "page.manual-test-runs.form.test-cases-help": "每个用例都会绑定到其当前版本,之后的修改不会影响本次执行。",
+ "page.manual-test-runs.delete-confirm-title": "删除测试执行",
+ "page.manual-test-runs.delete-confirm-message": "这将删除该执行及其记录的结果。",
+ "page.manual-test-runs.toast.fetch-error": "无法加载测试执行。",
+ "page.manual-test-runs.toast.cases-error": "无法加载可用的测试用例。",
+ "page.manual-test-runs.toast.create-error": "无法创建测试执行。",
+ "page.manual-test-runs.toast.delete-error": "无法删除测试执行。",
+ "page.manual-test-run-detail.not-found": "未找到该测试执行。",
+ "page.manual-test-run-detail.button.start": "开始执行",
+ "page.manual-test-run-detail.button.complete": "完成执行",
+ "page.manual-test-run-detail.button.record": "记录结果",
+ "page.manual-test-run-detail.button.rebind": "更新到最新版本",
+ "page.manual-test-run-detail.case-result": "用例结果",
+ "page.manual-test-run-detail.actual-placeholder": "实际发生了什么?",
+ "page.manual-test-run-detail.notes-placeholder": "备注(可选)",
+ "page.manual-test-run-detail.view-build": "查看构建",
+ "page.manual-test-run-detail.view-version": "查看正在测试的版本",
+ "page.manual-test-run-detail.toast.fetch-error": "无法加载测试执行。",
+ "page.manual-test-run-detail.toast.version-error": "无法加载正在测试的版本。",
+ "page.manual-test-run-detail.toast.record-error": "无法记录结果。",
+ "page.manual-test-run-detail.toast.recorded": "结果已记录。",
+ "page.manual-test-run-detail.toast.rebind-error": "无法将该用例更新到最新版本。已有结果的用例无法更新。",
+ "page.manual-test-run-detail.toast.status-error": "无法更改执行状态。",
+ "page.manual-test-case-version.not-found": "未找到该版本。",
+ "page.manual-test-case-version.back-to-case": "返回测试用例",
+ "page.manual-test-case-version.frozen-notice": "这是一个已冻结的版本。它准确显示当时编写的内容,且永不改变。",
+ "page.manual-test-case-version.compare-with": "比较版本",
+ "page.manual-test-case-version.toast.fetch-error": "无法加载该版本。",
+ "page.manual-test-case-version.toast.compare-error": "无法加载用于比较的版本。",
+ "page.manual-test-case-detail.toast.saved-new-version": "已保存为版本 {version}。",
+ "page.manual-test-case-detail.toast.saved-no-change": "已保存。内容未发生变化,因此未创建新版本。",
+ "page.shared-steps.toast.saved": "共享步骤已保存。",
+ "page.shared-steps.toast.created": "共享步骤已创建。",
+ "page.admin-custom-fields.toast.created": "自定义字段已创建。",
+ "page.admin-custom-fields.toast.updated": "自定义字段已更新。",
+ "app.manual.folders.title": "文件夹",
+ "app.manual.folders.all": "全部测试用例",
+ "app.manual.folders.unfiled": "未归入文件夹",
+ "app.manual.folders.add-root": "添加顶层文件夹",
+ "app.manual.folders.add-sub": "添加子文件夹",
+ "app.manual.folders.rename": "重命名",
+ "app.manual.folders.delete": "删除",
+ "app.manual.folders.name": "文件夹名称",
+ "app.manual.folders.create-title": "新建文件夹",
+ "app.manual.folders.rename-title": "重命名文件夹",
+ "app.manual.folders.creating-under": "在“{name}”中创建。",
+ "app.manual.folders.delete-confirm-title": "删除文件夹",
+ "app.manual.folders.delete-confirm-message": "只有空文件夹才能被删除。请先移动或删除其中的内容。",
+ "page.manual-test-cases.toast.folders-error": "无法加载文件夹。",
+ "page.manual-test-cases.toast.folder-create-error": "无法创建文件夹。",
+ "page.manual-test-cases.toast.folder-rename-error": "无法重命名文件夹。",
+ "page.manual-test-cases.toast.folder-delete-error": "无法删除文件夹。",
+ "page.manual-test-case-detail.field.folder": "文件夹",
+ "page.manual-test-case-detail.folder-root": "未归入文件夹",
+ "app.manual.history.action.move": "移动到文件夹",
+ "app.manual.step-editor.action": "操作",
+ "app.manual.step-editor.attachments": "附件",
+ "app.manual.step-editor.attachments-need-save": "请先保存测试用例,然后才能为其步骤添加图片。",
+ "app.manual.step-editor.drag-handle": "拖动以重新排序",
+ "app.manual.step-editor.drag-help": "拖动步骤即可更改顺序。",
+ "app.manual.attachments.load-error": "无法加载图片。",
+ "app.manual.folders.unknown": "其他文件夹",
+ "page.manual-test-cases.toast.move-success": "测试用例已移动。",
+ "page.manual-test-cases.toast.move-error": "无法移动测试用例。",
+ "page.admin.settings.features.header": "功能管理",
+ "page.admin.settings.features.help": "为此实例启用或禁用可选功能。更改立即生效 — 无需重启。",
+ "page.admin.settings.features.manual-testing": "启用测试用例管理",
+ "page.admin.settings.features.manual-testing.help": "手动测试用例、测试执行和共享步骤。禁用后,这些页面将被隐藏且其数据无法访问,但不会删除任何内容 — 重新启用即可恢复。",
+ "app.feature.disabled": "此实例未启用该功能。如需访问,请联系管理员。",
+ "nav.team": "团队",
+ "nav.team.none": "未选择"
}
diff --git a/src/translations/en.json b/src/translations/en.json
index ea0b60b..846096c 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -1,515 +1,811 @@
{
- "nav.dashboard": "Dashboard",
- "nav.execution-metrics": "Metrics",
- "nav.screenshot-library": "Screenshot Library",
- "nav.language": "Language",
- "nav.theme": "Theme",
- "nav.theme.light": "Light",
- "nav.theme.dark": "Dark",
- "nav.about": "About Angles",
- "nav.team-settings": "Team Settings",
- "nav.admin": "Administration",
- "nav.admin.users": "Users",
- "nav.admin.settings": "Settings",
- "nav.profile": "Profile",
- "nav.user-settings": "Settings",
- "nav.logout": "Logout",
- "app.utils.duration.seconds": "{seconds} seconds",
- "app.utils.duration.minutes": "{minutes} minutes",
- "app.utils.duration.hours": "{hours} hours",
- "app.utils.duration.not-started": "Not started",
- "app.utils.date-range-picker.number-of-days": "Last {days} days",
- "app.retrieving-teams": "Retrieving teams...",
- "app.retrieving-teams-error": "Something went wrong [{teamsError}]",
- "app.result.pass": "Pass",
- "app.result.fail": "Fail",
- "app.result.skipped": "Skipped",
- "app.result.error": "Error",
- "app.result.total": "Total",
- "page.home.bread-crumb": "Home",
- "page.dashboard.message.retrieving-builds": "Retrieving builds for {teamName}...",
- "page.dashboard.filters.label.team": "Team",
- "page.dashboard.filters.label.period": "Period",
- "page.dashboard.filters.label.display-limit": "Display Limit",
- "page.dashboard.filters.label.environments": "Environments",
- "page.dashboard.filters.label.components": "Components",
- "page.dashboard.panel.total-test-runs": "Total Test Runs",
- "page.dashboard.panel.total-test-executions": "Total Test Executions",
- "page.dashboard.panel.total-execution-time": "Total Execution Time",
- "page.dashboard.chart.barchart.pass": "Pass",
- "page.dashboard.chart.barchart.skipped": "Skipped",
- "page.dashboard.chart.barchart.error": "Error",
- "page.dashboard.chart.barchart.fail": "Fail",
- "page.dashboard.chart.barchart.execution-time-seconds": "Execution Time (Seconds)",
- "page.dashboard.chart.barchart.yaxis.number-of-tests": "Number of Tests",
- "page.dashboard.builds-table.header.build-details": "Build Details",
- "page.dashboard.builds-table.header.status": "Status",
- "page.dashboard.builds-table.header.date-time": "Date/Time",
- "page.dashboard.builds-table.header.result": "Result",
- "page.dashboard.builds-table.header.flags": "Flags",
- "page.dashboard.builds-table.not-available": "N/A",
- "page.dashboard.builds-table.artifacts.title": "Artifacts",
- "page.dashboard.builds-table.artifacts.count": "{count, plural, one {# artifact} other {# artifacts}}",
- "page.dashboard.builds-table.keep.enabled": "Keep flag enabled - this test run is protected from clean-up",
- "page.dashboard.builds-table.keep.disabled": "Keep flag disabled - this test run may be removed during clean-up",
- "page.dashboard.menu.compare-test-runs": "Compare Test Runs",
- "page.dashboard.menu.toggle-keep-flag": "Toggle Keep Flag",
- "page.dashboard.menu.clear-selection": "Clear Selection",
- "page.dashboard.menu.delete-build": "Delete Build",
- "page.dashboard.menu.one-select-message": "Please select at least one test run",
- "page.dashboard.confirm.delete-build.title": "Delete Build",
- "page.dashboard.confirm.delete-build.message": "Are you sure you want to delete {count} selected build(s)? This action cannot be undone and will permanently remove all associated executions and screenshots.",
- "page.dashboard.confirm.delete-build.cancel": "Cancel",
- "page.dashboard.toast.delete-build-success": "{count} build(s) deleted successfully",
- "page.dashboard.toast.delete-build-error": "Failed to delete build",
- "page.dashboard.execution-barchart.execution-time-seconds": "ExecutionTime (Seconds)",
- "page.dashboard.execution-bar-chart.title": "Test Runs",
- "page.dashboard.execution-pie-chart.title": "Overall Status of Test Runs",
- "page.metrics.bread-crumb": "Metrics",
- "page.metrics.filters.labels.team": "Team",
- "page.metrics.filters.labels.component": "Component",
- "page.metrics.filters.labels.period": "Period",
- "page.metrics.filters.labels.group-by-period": "Group by",
- "page.metrics.filters.button.retrieve-metrics": "Retrieve Metrics",
- "page.metrics.tab.execution-metrics": "Execution Metrics",
- "page.metrics.tab.platform-metrics": "Platform Metrics",
- "page.metrics.execution-metrics-summary.label.period": "Period",
- "page.metrics.execution-metrics-summary.label.number-of-tests": "Number of tests",
- "page.metrics.execution-metrics-summary.label.number-of-runs": "Number of test runs",
- "page.metrics.execution-metrics-summary.label.number-of-executions": "Number of executions",
- "page.metrics.execution-metrics-summary.label.result": "Result",
- "page.metrics.execution-metrics-summary.label.duration": "Duration",
- "page.metrics.execution-metrics-bar-chart.title": "Execution metrics grouped by result",
- "page.metrics.execution-metrics-bar-chart.yaxis-title": "Number of executions",
- "page.metrics.execution-metrics-phase-bar-chart.title": "Execution metrics grouped by phase",
- "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "Number of executions",
- "page.metrics.platform-distribution-pie-chart.title": "Overall executions distribution by platform",
- "page.metrics.platform-distribution-bar-chart.title": "Executions distribution by platform",
- "page.metrics.platform-distribution-bar-chart.yaxis-title": "Platforms",
- "page.metrics.platform-distribution-bar-chart.xaxis-title": "Number of executions",
- "page.metrics.platform-metrics-summary.label.platform": "Platform",
- "page.metrics.platform-metrics-summary.label.version": "Version",
- "page.metrics.platform-metrics-summary.label.device": "Device",
- "page.metrics.platform-metrics-summary.label.browser": "Browser",
- "page.metrics.platform-metrics-summary.label.total": "Total",
- "page.metrics.platform-metrics-summary.label.result": "Result",
- "page.metrics.platform-metrics-summary.label.duration": "Duration",
- "page.metrics.metrics-test-table.label.unique-testcases": "Unique test cases per phase",
- "page.metrics.metrics-test-table.label.phase": "Phase",
- "page.metrics.metrics-test-table.label.test": "Test",
- "page.metrics.metrics-test-table.no-tests-found": "No tests found",
- "page.test-run.bread-crumb": "Test Run",
- "page.test-run.steps-header.start": "Start",
- "page.test-run.steps-header.duration": "Duration",
- "page.test-run.steps-header.end": "End",
- "page.test-run.menu.enable-keep-flag": "Enable the keep flag",
- "page.test-run.menu.disable-keep-flag": "Disable the keep flag",
- "page.test-run.menu.download-report": "Download report as html",
- "page.test-run.menu.delete-build": "Delete Build",
- "page.test-run.confirm.delete-build.title": "Delete Build",
- "page.test-run.confirm.delete-build.message": "Are you sure you want to delete this build? This action cannot be undone and will permanently remove all associated executions and screenshots.",
- "page.test-run.confirm.delete-build.cancel": "Cancel",
- "page.test-run.toast.delete-build-success": "Build deleted successfully",
- "page.test-run.toast.delete-build-error": "Failed to delete build",
- "page.test-run.icons.environment.whisper": "Environment",
- "page.test-run.icons.team.whisper": "Team",
- "page.test-run.icons.component.whisper": "Component",
- "page.test-run.icons.phase.whisper": "Test Phase",
- "page.test-run.icons.artifacts.whisper": "Number of artifacts associated with this test run.",
- "page.test-run.icons.keep-lock.whisper": "When locked a test run will not be cleaned up by the nightly cronjob.",
- "page.test-run.icons.screenshots.whisper": "This test run has {numberOfScreenshots} screenshots",
- "page.test-run.icons.download-report.whisper": "Download the HTML report for this test run.",
- "page.test-run.header.status.whisper": "Status: {status}",
- "page.test-run.header.execution-count": "{count, plural, one {# execution} other {# executions}}",
- "page.test-run.header.fact.not-set": "Not set",
- "page.test-run.header.artifacts-title": "Artifacts",
- "page.test-run.suites.title": "Test Suites",
- "page.test-run.loading": "Retrieving build details.",
- "page.test-run.error.load-failed": "Unable to retrieve build details. Please refresh the page and try again and/or check if the build id is valid.",
- "page.test-run.execution-pie-chart.title": "Overall execution metrics",
- "page.test-run.feature-distribution-pie-chart.title": "Feature distribution metrics",
- "page.test-run.execution-pie-chart.pass-rate": "Pass rate",
- "page.test-run.feature-distribution-pie-chart.unspecified": "Unspecified",
- "page.test-run.feature-distribution-pie-chart.executions": "Executions",
- "page.test-run-compare.bread-crumb": "Compare Test Runs",
- "page.test-run-compare.header.number": "#",
- "page.test-run-compare.header.test-name": "Test Name",
- "page.test-run-compare.detail.start-date": "Start Date",
- "page.test-run-compare.detail.environment": "Environment",
- "page.test-run-compare.cell.not-available": "N/A",
- "page.test-run-compare.cell.multiple-executions": "Multiple Executions",
- "page.test-run-compare.popover.details-title": "Details",
- "page.test-run-compare.link.execution-history": "See execution history for {testName}",
- "page.test-run-compare.details-popover.header.property": "Property",
- "page.test-run-compare.details-popover.header.value": "Value",
- "page.test-run-compare.details-popover.property.status": "Status",
- "page.test-run-compare.details-popover.property.duration": "Duration",
- "page.test-run-compare.details-popover.property.platform": "Platform",
- "page.test-run-compare.details-popover.property.failing-step": "Failing Step",
- "page.test-run-compare.loading": "Retrieving test run details to generate the comparison view...",
- "page.test-run-compare.section.artifacts": "Artifact Versions",
- "page.test-run-compare.artifacts.count": "{count, plural, one {# artifact} other {# artifacts}}",
- "page.test-run-compare.section.compare-matrix": "Test Compare Matrix",
- "page.test-run-compare.toolbar.status-filter": "Filter by status",
- "page.test-run-compare.suite.tests": "{count, plural, one {# test} other {# tests}}",
- "page.test-run-compare.suite.rollup": "{passed} of {total} passed",
- "page.test-run-compare.header.run-summary": "{passed} of {total} passed, {failed} failed, {errors} with errors",
- "page.test-run-compare.cell.status-changed": "Changed from previous run",
- "page.test-run-compare.warning.many-runs": "Comparing {count} test runs — use the filters below to focus on the differences.",
- "page.screenshot-library.bread-crumb": "Screenshot Library",
- "page.screenshot-library.search-options.label.view": "View",
- "page.screenshot-library.search-options.label.tag": "Tag",
- "page.screenshot-library.search-options.label.or": "OR",
- "page.screenshot-library.search-options.label.period": "Period",
- "page.screenshot-library.search-options.label.platform": "Platform",
- "page.screenshot-library.search-options.button.search-screenshots": "Search Screenshots",
- "page.screenshot-library.search-options.dropdown.yesterday": "Yesterday",
- "page.screenshot-library.search-options.dropdown.last-7-days": "Last 7 days",
- "page.screenshot-library.search-options.dropdown.last-14-days": "Last 14 days",
- "page.screenshot-library.search-options.dropdown.last-30-days": "Last 30 days",
- "page.screenshot-library.search-options.dropdown.last-3-months": "Last 3 months",
- "page.screenshot-library.search-options.dropdown.last-6-months": "Last 6 months",
- "page.test-execution-history.bread-crumb": "Test Execution History",
- "page.test-execution-history.header.suite": "Suite",
- "page.test-execution-history.messages.retrieving-executions": "Retrieving Test Executions",
- "page.test-execution-history.charts.execution-pie-chart.title": "Overall Execution Metrics",
- "page.test-execution-history.filter.hint": "Select a status in the chart below to filter this page.",
- "page.test-execution-history.filter.showing": "Showing",
- "page.test-execution-history.filter.active": "({count, plural, one {# execution} other {# executions}})",
- "page.test-execution-history.filter.no-results": "No executions match the selected status.",
- "page.test-execution-history.charts.execution-timeline-chart.title": "Execution Times",
- "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "Execution Time (seconds)",
- "page.test-execution-history.charts.execution-platform-bar-chart.title": "Executions by Platform",
- "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "Platforms",
- "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "Number of executions",
- "page.about.bread-crumb": "About",
- "page.about.header": "About Angles",
- "page.about.about-angles": "Angles is an open-source dashboard which can be used to store results and screenshots for automated test runs from various frameworks.",
- "page.about.about-api": "By providing a clearly defined {apiLink} any framework can be adapted to store its test result in Angles, using one of the clients provided (or by using the API directly)",
- "page.about.about-github": "For more information about Angles go to page: {githubLink}",
- "page.introduction.bread-crumb": "Introduction",
- "page.introduction.header": "Welcome to Angles",
- "page.introduction.description": "It looks like you haven't setup any teams or environments.\n\nThis can be done by using {apiLink}.",
- "page.introduction.api-link-text": "the Swagger API",
- "page.introduction.description-alternative": "Or alternatively you can use {githubLink} to get started.",
- "page.introduction.github-link-text": "the Github page",
- "page.not-found.bread-crumb": "Not found",
- "page.not-found.header": "Page Not Found",
- "page.not-found.description": "Oops, we were unable to find the \"Angle\" you were looking for. Click {homeLink} to go back to home page.",
- "page.not-found.home-link-text": "here",
- "common.component.suite-table.header.suite": "Suite",
- "common.component.suite-table.header.test": "Test",
- "common.component.suite-table.header.duration": "Duration",
- "common.component.suite-table.header.total": "Total",
- "common.component.suite-table.header.collapse-all": "Collapse all test cases and actions",
- "common.component.suite-table.header.expand-all": "Expand all test cases",
- "common.component.suite-table.header.expand-all-actions": "Expand all test cases and actions",
- "common.component.suite-table.icon.test-history": "See execution history for test",
- "common.component.suite-table.header.show-screenshots": "Show step screenshots",
- "common.component.suite-table.header.hide-screenshots": "Hide step screenshots",
- "common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
- "common.component.suite-table.execution.action-count": "{count, plural, one {# action} other {# actions}}",
- "common.component.suite-table.action.step-count": "{count, plural, one {# step} other {# steps}}",
- "common.component.screenshot-view.header": "Screenshot Viewer",
- "common.component.screenshot-view.tabs.enable-alert": "To enable the \"History\" and \"Compare with Baseline\" tabs please provide a view and platform details when uploading the screenshots",
- "common.component.screenshot-view.tabs.label.image": "Screenshot",
- "common.component.screenshot-view.tabs.label.history": "History",
- "common.component.screenshot-view.tabs.label.overlay-with-baseline": "Overlay with baseline",
- "common.component.screenshot-view.tabs.label.side-by-side": "Side by side with baseline",
- "common.component.screenshot-view.tabs.label.find-image": "Find in image",
- "common.component.screenshot-view.tabs.baseline.label.algorithm": "Comparison algorithm",
- "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "Pixel",
- "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "Structural (SSIM)",
- "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "Add this change region as an ignore box",
- "common.component.screenshot-view.tabs.find-image.message.intro": "Upload a smaller image (e.g. a button, logo or game asset) to locate it within the current screenshot.",
- "common.component.screenshot-view.tabs.find-image.label.template": "Template image",
- "common.component.screenshot-view.tabs.find-image.label.min-confidence": "Minimum confidence",
- "common.component.screenshot-view.tabs.find-image.label.max-matches": "Maximum matches",
- "common.component.screenshot-view.tabs.find-image.label.grayscale": "Ignore colour differences (grayscale)",
- "common.component.screenshot-view.tabs.find-image.button.find": "Find in screenshot",
- "common.component.screenshot-view.tabs.find-image.message.searching": "Searching for the template image. This can take a few seconds.",
- "common.component.screenshot-view.tabs.find-image.message.no-matches": "No matches found above the confidence threshold.",
- "common.component.screenshot-view.tabs.find-image.label.matches": "Match",
- "common.component.screenshot-view.tabs.find-image.label.position": "Position",
- "common.component.screenshot-view.tabs.find-image.label.size": "Size",
- "common.component.screenshot-view.tabs.find-image.label.confidence": "Confidence",
- "common.component.screenshot-view.tabs.find-image.label.scale": "Scale",
- "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "Make baseline image",
- "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "This is the baseline image",
- "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "Generate dynamic baseline",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "Delete screenshot",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "Only available for dynamic baselines that are not configured as a baseline.",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "Retrieving screenshot",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "Unable to retrieve image. Please refresh the page and try again.",
- "common.component.screenshot-view.tabs.history.message.loading-history": "Loading history...",
- "common.component.screenshot-view.tabs.history.label.baseline": "Baseline Screenshot",
- "common.component.screenshot-view.tabs.history.label.view": "View",
- "common.component.screenshot-view.tabs.history.label.date": "Date",
- "common.component.screenshot-view.tabs.history.label.resolution": "Resolution",
- "common.component.screenshot-view.tabs.history.label.platform": "Platform",
- "common.component.screenshot-view.tabs.history.label.device": "Device",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "Current Image (and Baseline)",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image": "Current Image",
- "common.component.screenshot-view.tabs.side-by-side.label.baseline": "Baseline",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "Retrieving baseline screenshot.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "Unable to retrieve baseline image.Please refresh the page and try again.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "Retrieving screenshot.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "Unable to retrieve image. Please refresh the page and try again.",
- "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "Current Image (and Baseline)",
- "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "Baseline Compare",
- "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "Redo compare against baseline.",
- "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "Edit ignore blocks",
- "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "Save ignore blocks",
- "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "Cancel changes",
- "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "No Baseline selected yet for this view and deviceName or browser combination. To select a baseline, navigate to the image you want as a baseline and click on the \"Make Baseline Image\" button",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "Loading baseline compare",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "Failed to retrieve baseline compare.",
- "common.component.screenshot-details-table.label.view": "View",
- "common.component.screenshot-details-table.label.view-link": "other platforms",
- "common.component.screenshot-details-table.label.view-link-description": "Find all the latest screenshots for view {view}, grouped by platform.",
- "common.component.screenshot-details-table.label.no-view": "No view provided",
- "common.component.screenshot-details-table.label.date": "Date",
- "common.component.screenshot-details-table.label.baseline-date": "Baseline Date",
- "common.component.screenshot-details-table.label.mismatch": "Mismatch",
- "common.component.screenshot-details-table.label.algorithm": "Algorithm",
- "common.component.screenshot-details-table.label.ssim-score": "SSIM score",
- "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (analysis time: {time} ms)",
- "common.component.screenshot-details-table.label.resolution": "Resolution",
- "common.component.screenshot-details-table.label.platform": "Platform",
- "common.component.screenshot-details-table.label.no-platform": "No platform provided",
- "common.component.screenshot-details-table.label.platform-version": "Platform version",
- "common.component.screenshot-details-table.label.device": "Device",
- "common.component.screenshot-details-table.label.browser": "Browser",
- "common.component.screenshot-details-table.label.no-browser": "No browser provided",
- "common.component.screenshot-details-table.label.browser-version": "Browser version",
- "common.component.screenshot-details-table.label.no-browser-version": "No browser version provided",
- "common.component.screenshot-details-table.label.baseline": "Baseline",
- "common.component.screenshot-details-table.label.tags": "Tags",
- "common.component.screenshot-details-table.label.tags-description": "Find all the latest screenshots with tag \"{tag}\", grouped by view.",
- "common.yes": "Yes",
- "common.no": "No",
- "common.button.ok": "OK",
- "page.login.title": "Login to Angles",
- "page.login.error.invalid-credentials": "Invalid username or password",
- "page.login.label.username": "Username",
- "page.login.label.password": "Password",
- "page.login.button.login": "Login",
- "page.login.divider.or": "OR",
- "page.login.label.sign-in-with": "Sign in with",
- "page.login.source.local": "Local account",
- "page.login.button.sso": "Continue with {name}",
- "page.login.error.sso-failed": "Single sign-on failed. Please try again or contact your administrator.",
- "page.login.error.no-methods": "No sign-in methods are available. Please contact your administrator.",
- "page.user-settings.header": "User Settings",
- "page.user-settings.api-tokens.title": "API Tokens",
- "page.user-settings.api-tokens.description": "Personal API tokens function like ordinary passwords but allow you to authenticate with the API programmatically.",
- "page.user-settings.button.generate-new-token": "Generate New Token",
- "page.user-settings.table.name": "Name",
- "page.user-settings.table.created": "Created",
- "page.user-settings.table.expires": "Expires",
- "page.user-settings.table.action": "Action",
- "page.user-settings.table.revoke": "Revoke",
- "page.user-settings.modal.token-generated": "Token Generated",
- "page.user-settings.modal.generate-token": "Generate New API Token",
- "page.user-settings.modal.success-label": "Success!",
- "page.user-settings.modal.success": "Your new token has been generated.",
- "page.user-settings.modal.warning": "Please copy this token now. You will not be able to see it again!",
- "page.user-settings.modal.token-name": "Token Name",
- "page.user-settings.modal.token-placeholder": "e.g., CI/CD Pipeline",
- "page.user-settings.modal.expiration": "Expiration",
- "page.user-settings.modal.custom-expiration": "Custom Expiration (Days)",
- "page.user-settings.button.done": "Done",
- "page.user-settings.button.generate": "Generate",
- "page.user-settings.button.cancel": "Cancel",
- "page.user-settings.confirm.revoke.title": "Revoke Token",
- "page.user-settings.confirm.revoke.message": "Are you sure you want to revoke this token? Any scripts or applications using this token will immediately lose access.",
- "page.user-settings.table.expires-never": "Never",
- "page.user-settings.modal.token-label": "Token",
- "page.user-settings.modal.button.copy": "Copy",
- "page.user-settings.expiry.1-day": "1 Day",
- "page.user-settings.expiry.1-week": "1 Week",
- "page.user-settings.expiry.1-month": "1 Month",
- "page.user-settings.expiry.1-year": "1 Year",
- "page.user-settings.expiry.custom": "Custom",
- "page.user-settings.toast.fetch-tokens-error": "Failed to fetch tokens",
- "page.user-settings.toast.token-name-required": "Token name is mandatory",
- "page.user-settings.toast.expiry-required": "Valid expiry days are required",
- "page.user-settings.toast.generate-token-error": "Failed to generate token",
- "page.user-settings.toast.revoke-success": "Token revoked successfully",
- "page.user-settings.toast.revoke-error": "Failed to revoke token",
- "page.user-settings.toast.copy-success": "Token copied to clipboard",
- "password.policy.min-length": "Be at least 10 characters long",
- "password.policy.max-length": "Be at most 100 characters long",
- "password.policy.letter": "Contain at least one letter",
- "password.policy.uppercase": "Contain at least one uppercase letter",
- "password.policy.special": "Contain at least one special character",
- "password.policy.invalid": "Password does not meet the security requirements",
- "page.user-settings.password.title": "Password",
- "page.user-settings.password.description": "Change the password you use to sign in to Angles.",
- "page.user-settings.password.button.change": "Change Password",
- "page.user-settings.password.modal.title": "Change Password",
- "page.user-settings.password.modal.current": "Current Password",
- "page.user-settings.password.modal.new": "New Password",
- "page.user-settings.password.modal.confirm": "Confirm New Password",
- "page.user-settings.password.modal.mismatch": "Passwords do not match",
- "page.user-settings.password.modal.submit": "Update Password",
- "page.user-settings.password.modal.success": "Your password has been updated. Use it the next time you sign in.",
- "page.user-settings.password.toast.current-required": "Current password is mandatory",
- "page.user-settings.password.toast.mismatch": "New password and confirmation do not match",
- "page.user-settings.password.toast.success": "Password updated successfully",
- "page.user-settings.password.toast.error": "Failed to update password",
- "page.admin.settings.header": "Authentication Settings",
- "page.admin.settings.loading": "Loading settings...",
- "page.admin.settings.local-auth.title": "Local Authentication",
- "page.admin.settings.local-auth.enable": "Enable Local Authentication",
- "page.admin.settings.local-auth.help": "Allow users to log in with a username and password.",
- "page.admin.settings.button.save": "Save Settings",
- "page.admin.users.header": "User Management",
- "page.admin.users.button.add-user": "Add User",
- "page.admin.users.table.username": "Username",
- "page.admin.users.table.role": "Role",
- "page.admin.users.table.teams": "Teams",
- "page.admin.users.table.action": "Action",
- "page.admin.users.table.edit": "Edit",
- "page.admin.users.table.delete": "Delete",
- "page.admin.users.modal.edit-user": "Edit User",
- "page.admin.users.modal.add-user": "Add User",
- "page.admin.users.modal.password-help": "(Leave blank to keep unchanged)",
- "page.admin.users.modal.user-type": "User Type",
- "page.admin.users.modal.team-access": "Team Access",
- "page.admin.users.button.save": "Save",
- "page.admin.users.button.cancel": "Cancel",
- "page.admin.users.confirm.delete.title": "Delete User",
- "page.admin.users.confirm.delete.message": "Are you sure you want to delete this user? This action cannot be undone.",
- "page.admin.users.user-type.admin": "Admin",
- "page.admin.users.user-type.team_lead": "Team Lead",
- "page.admin.users.user-type.user": "User",
- "page.admin.users.modal.password-label": "Password",
- "page.admin.users.table.teams-assigned": "{count} teams assigned",
- "page.admin.users.toast.fetch-error": "Failed to fetch users",
- "page.admin.users.toast.update-success": "User updated successfully",
- "page.admin.users.toast.create-success": "User created successfully",
- "page.admin.users.toast.save-error": "Failed to save user",
- "page.admin.users.toast.delete-success": "User deleted successfully",
- "page.admin.users.toast.delete-error": "Failed to delete user",
- "page.admin.settings.toast.fetch-error": "Failed to fetch settings",
- "page.admin.settings.toast.save-success": "Settings saved successfully",
- "page.admin.settings.toast.save-error": "Failed to save settings",
- "page.admin.settings.providers.title": "Identity Providers",
- "page.admin.settings.providers.help": "Configure single sign-on providers. Changes take effect immediately — no restart is required.",
- "page.admin.settings.providers.empty": "No identity providers are configured yet.",
- "page.admin.settings.providers.table.name": "Name",
- "page.admin.settings.providers.table.id": "ID",
- "page.admin.settings.providers.table.type": "Type",
- "page.admin.settings.providers.table.enabled": "Enabled",
- "page.admin.settings.providers.table.status": "Status",
- "page.admin.settings.button.add-provider": "Add Provider",
- "page.admin.settings.button.save-provider": "Save Provider",
- "page.admin.settings.confirm.delete.title": "Delete Provider",
- "page.admin.settings.confirm.delete.message": "Are you sure you want to delete this identity provider? Users who sign in through it will lose access.",
- "page.admin.settings.provider.modal.add": "Add Identity Provider",
- "page.admin.settings.provider.modal.edit": "Edit Identity Provider",
- "page.admin.settings.provider.name": "Display Name",
- "page.admin.settings.provider.name.help": "Shown on the login button, for example \"Company SSO\".",
- "page.admin.settings.provider.id": "Provider ID",
- "page.admin.settings.provider.id.help": "2-32 lowercase letters, numbers or hyphens. Forms part of the callback URL and cannot be changed later.",
- "page.admin.settings.provider.id.help-locked": "The ID forms part of the callback URL registered with the identity provider and cannot be changed.",
- "page.admin.settings.provider.type": "Provider Type",
- "page.admin.settings.provider.type.oidc": "OIDC",
- "page.admin.settings.provider.type.saml": "SAML 2.0",
- "page.admin.settings.provider.type.ldap": "LDAP / AD",
- "page.admin.settings.provider.type.help-locked": "The provider type cannot be changed after it has been created.",
- "page.admin.settings.provider.enabled": "Enabled",
- "page.admin.settings.provider.enabled.help": "Offer this provider on the login page.",
- "page.admin.settings.provider.callback-url": "Redirect URI",
- "page.admin.settings.provider.acs-url": "ACS (Callback) URL",
- "page.admin.settings.provider.callback-url.help": "Register this URL with your identity provider.",
- "page.admin.settings.provider.status.ready": "Ready",
- "page.admin.settings.provider.status.error": "Error",
- "page.admin.settings.provider.status.enabled": "Enabled",
- "page.admin.settings.provider.status.disabled": "Disabled",
- "page.admin.settings.provider.default-role": "Default Role",
- "page.admin.settings.provider.default-role.deny": "Deny access",
- "page.admin.settings.provider.default-role.help": "Role granted when no mapping matches. Deny access is the safer default.",
- "page.admin.settings.provider.secret.help-set": "A value is configured. Leave blank to keep it, or enter a new value to replace it.",
- "page.admin.settings.provider.secret.help-unset": "Once saved this value cannot be viewed again.",
- "page.admin.settings.provider.error.id": "The provider ID must be 2-32 lowercase letters, numbers or hyphens.",
- "page.admin.settings.provider.error.duplicate-id": "Another provider already uses this ID.",
- "page.admin.settings.provider.error.name": "A display name is required.",
- "page.admin.settings.provider.error.mapping-value": "Every role mapping needs a group value.",
- "page.admin.settings.oidc.title": "OIDC Configuration",
- "page.admin.settings.oidc.issuer": "Issuer URL",
- "page.admin.settings.oidc.issuer.help": "Endpoints and signing keys are discovered from the issuer’s .well-known/openid-configuration.",
- "page.admin.settings.oidc.client-id": "Client ID",
- "page.admin.settings.oidc.client-secret": "Client Secret",
- "page.admin.settings.oidc.scopes": "Scopes",
- "page.admin.settings.oidc.scopes.help": "Space-separated. \"openid\" is always added. Okta and Keycloak accept \"groups\"; Google rejects it.",
- "page.admin.settings.oidc.groups-claim": "Groups Claim",
- "page.admin.settings.oidc.groups-claim.help": "Claim holding group membership — \"groups\" for Okta, often \"roles\" for Keycloak. Entra ID reports group GUIDs.",
- "page.admin.settings.oidc.username-claim": "Username Claim",
- "page.admin.settings.oidc.username-claim.help": "Claim used as the Angles username. Falls back to email, preferred_username, then sub.",
- "page.admin.settings.saml.title": "SAML 2.0 Configuration",
- "page.admin.settings.saml.metadata-url": "SP Metadata URL",
- "page.admin.settings.saml.metadata-url.help": "Import this into your identity provider instead of entering values by hand.",
- "page.admin.settings.saml.entry-point": "IdP SSO URL",
- "page.admin.settings.saml.entry-point.help": "The identity provider URL the browser is redirected to.",
- "page.admin.settings.saml.idp-cert": "IdP Signing Certificate",
- "page.admin.settings.saml.idp-cert.help": "Required — it is the only reason an assertion can be trusted. Accepted with or without PEM header lines.",
- "page.admin.settings.saml.issuer": "SP Entity ID",
- "page.admin.settings.saml.issuer.help": "This service provider’s entity ID, as registered with the identity provider.",
- "page.admin.settings.saml.private-key": "SP Private Key",
- "page.admin.settings.saml.private-key.help": "Only needed when the identity provider requires signed authentication requests. Leave blank to keep the stored key.",
- "page.admin.settings.saml.signature-algorithm": "Signature Algorithm",
- "page.admin.settings.saml.identifier-format": "Name ID Format",
- "page.admin.settings.saml.username-attribute": "Username Attribute",
- "page.admin.settings.saml.username-attribute.help": "Assertion attribute holding the username. Leave blank to use the Name ID.",
- "page.admin.settings.saml.groups-attribute": "Groups Attribute",
- "page.admin.settings.saml.response-signed": "Require Signed Response",
- "page.admin.settings.saml.response-signed.help": "Turn off for identity providers that sign only the assertion. Assertion signatures are always required.",
- "page.admin.settings.saml.allow-unsolicited": "Allow IdP-Initiated Login",
- "page.admin.settings.saml.allow-unsolicited.help": "Off by default: an unsolicited assertion has no request to correlate against, which removes its replay protection.",
- "page.admin.settings.ldap.title": "LDAP / Active Directory Configuration",
- "page.admin.settings.ldap.url": "Directory URL",
- "page.admin.settings.ldap.url.help": "Use ldaps://, or ldap:// with StartTLS. A plain ldap:// connection is refused because passwords would be sent in clear text.",
- "page.admin.settings.ldap.bind-dn": "Bind DN",
- "page.admin.settings.ldap.bind-dn.help": "Service account used to look up the user. Leave blank for an anonymous search.",
- "page.admin.settings.ldap.bind-credentials": "Bind Password",
- "page.admin.settings.ldap.search-base": "User Search Base",
- "page.admin.settings.ldap.search-base.help": "For example: ou=users,dc=example,dc=com",
- "page.admin.settings.ldap.search-filter": "User Search Filter",
- "page.admin.settings.ldap.search-filter.help": "{username} is replaced with the submitted username. Active Directory usually uses (sAMAccountName={username}).",
- "page.admin.settings.ldap.username-attribute": "Username Attribute",
- "page.admin.settings.ldap.username-attribute.help": "Attribute used as the Angles username — \"uid\", or \"sAMAccountName\" on Active Directory.",
- "page.admin.settings.ldap.group-search-base": "Group Search Base",
- "page.admin.settings.ldap.group-search-base.help": "Leave blank to read group membership from the user’s memberOf attribute, as Active Directory usually exposes it.",
- "page.admin.settings.ldap.group-search-filter": "Group Search Filter",
- "page.admin.settings.ldap.group-name-attribute": "Group Name Attribute",
- "page.admin.settings.ldap.group-name-attribute.help": "Full DNs are reduced to this attribute, so map roles to names such as \"Angles Admins\".",
- "page.admin.settings.ldap.start-tls": "Use StartTLS",
- "page.admin.settings.ldap.start-tls.help": "Upgrade a plain ldap:// connection to TLS.",
- "page.admin.settings.ldap.tls-reject-unauthorized": "Verify TLS Certificate",
- "page.admin.settings.ldap.tls-reject-unauthorized.help": "Turning this off makes the connection trivially interceptable. Only for self-signed lab setups.",
- "page.admin.settings.role-mappings.title": "Group to Role Mapping",
- "page.admin.settings.role-mappings.help": "Map groups reported by the provider onto Angles roles. Matching is case-insensitive, and a user matching several mappings is granted the strongest role (Admin > Team Lead > User).",
- "page.admin.settings.role-mappings.empty": "No mappings yet. Without one, access depends on the default role below.",
- "page.admin.settings.role-mappings.add": "Add Mapping",
- "page.admin.settings.role-mappings.remove": "Remove mapping",
- "page.admin.settings.role-mappings.value-placeholder": "Group name or ID",
- "page.team-settings.header": "Team Settings",
- "page.team-settings.label.team": "Team",
- "page.team-settings.section.details": "Team Details",
- "page.team-settings.label.name": "Team Name",
- "page.team-settings.button.save-name": "Save Name",
- "page.team-settings.section.components": "Components",
- "page.team-settings.components.help": "Components let you group your test coverage by area of functionality.",
- "page.team-settings.label.existing-components": "Existing Components",
- "page.team-settings.label.add-components": "Add Components",
- "page.team-settings.components.add-placeholder": "Type a component name and press enter",
- "page.team-settings.button.add-components": "Add Components",
- "page.team-settings.no-team-access": "You have not been granted access to any teams yet. Contact an administrator.",
- "page.team-settings.toast.fetch-error": "Failed to fetch team details",
- "page.team-settings.toast.name-update-success": "Team name updated successfully",
- "page.team-settings.toast.name-update-error": "Failed to update team name",
- "page.team-settings.toast.components-update-success": "Components added successfully",
- "page.team-settings.toast.components-update-error": "Failed to add components"
+ "nav.dashboard": "Dashboard",
+ "nav.execution-metrics": "Metrics",
+ "nav.screenshot-library": "Screenshot Library",
+ "nav.language": "Language",
+ "nav.theme": "Theme",
+ "nav.theme.light": "Light",
+ "nav.theme.dark": "Dark",
+ "nav.theme.group.light": "Light themes",
+ "nav.theme.group.dark": "Dark themes",
+ "nav.theme.slate-light": "Slate Light",
+ "nav.theme.sepia": "Sepia",
+ "nav.theme.slate-dark": "Slate Dark",
+ "nav.theme.midnight": "Midnight",
+ "nav.theme.forest-light": "Forest Light",
+ "nav.theme.contrast-light": "High Contrast Light",
+ "nav.theme.forest-dark": "Forest Dark",
+ "nav.theme.contrast-dark": "High Contrast Dark",
+ "nav.theme.clay-light": "Clay (colour-blind safe)",
+ "nav.theme.clay-dark": "Clay Dark (colour-blind safe)",
+ "nav.about": "About Angles",
+ "nav.team-settings": "Team Settings",
+ "nav.admin": "Administration",
+ "nav.admin.users": "Users",
+ "nav.admin.settings": "Settings",
+ "nav.profile": "Profile",
+ "nav.user-settings": "Settings",
+ "nav.logout": "Logout",
+ "app.utils.duration.seconds": "{seconds} seconds",
+ "app.utils.duration.minutes": "{minutes} minutes",
+ "app.utils.duration.hours": "{hours} hours",
+ "app.utils.duration.not-started": "Not started",
+ "app.utils.date-range-picker.number-of-days": "Last {days} days",
+ "app.retrieving-teams": "Retrieving teams...",
+ "app.retrieving-teams-error": "Something went wrong [{teamsError}]",
+ "app.result.pass": "Pass",
+ "app.result.fail": "Fail",
+ "app.result.skipped": "Skipped",
+ "app.result.error": "Error",
+ "app.result.total": "Total",
+ "page.home.bread-crumb": "Home",
+ "page.dashboard.message.retrieving-builds": "Retrieving builds for {teamName}...",
+ "page.dashboard.filters.label.team": "Team",
+ "page.dashboard.filters.label.period": "Period",
+ "page.dashboard.filters.label.display-limit": "Display Limit",
+ "page.dashboard.filters.label.environments": "Environments",
+ "page.dashboard.filters.label.components": "Components",
+ "page.dashboard.panel.total-test-runs": "Total Test Runs",
+ "page.dashboard.panel.total-test-executions": "Total Test Executions",
+ "page.dashboard.panel.total-execution-time": "Total Execution Time",
+ "page.dashboard.chart.barchart.pass": "Pass",
+ "page.dashboard.chart.barchart.skipped": "Skipped",
+ "page.dashboard.chart.barchart.error": "Error",
+ "page.dashboard.chart.barchart.fail": "Fail",
+ "page.dashboard.chart.barchart.execution-time-seconds": "Execution Time (Seconds)",
+ "page.dashboard.chart.barchart.yaxis.number-of-tests": "Number of Tests",
+ "page.dashboard.builds-table.header.build-details": "Build Details",
+ "page.dashboard.builds-table.header.status": "Status",
+ "page.dashboard.builds-table.header.date-time": "Date/Time",
+ "page.dashboard.builds-table.header.result": "Result",
+ "page.dashboard.builds-table.header.flags": "Flags",
+ "page.dashboard.builds-table.not-available": "N/A",
+ "page.dashboard.builds-table.artifacts.title": "Artifacts",
+ "page.dashboard.builds-table.artifacts.count": "{count, plural, one {# artifact} other {# artifacts}}",
+ "page.dashboard.builds-table.keep.enabled": "Keep flag enabled - this test run is protected from clean-up",
+ "page.dashboard.builds-table.keep.disabled": "Keep flag disabled - this test run may be removed during clean-up",
+ "page.dashboard.menu.compare-test-runs": "Compare Test Runs",
+ "page.dashboard.menu.toggle-keep-flag": "Toggle Keep Flag",
+ "page.dashboard.menu.clear-selection": "Clear Selection",
+ "page.dashboard.menu.delete-build": "Delete Build",
+ "page.dashboard.menu.one-select-message": "Please select at least one test run",
+ "page.dashboard.confirm.delete-build.title": "Delete Build",
+ "page.dashboard.confirm.delete-build.message": "Are you sure you want to delete {count} selected build(s)? This action cannot be undone and will permanently remove all associated executions and screenshots.",
+ "page.dashboard.confirm.delete-build.cancel": "Cancel",
+ "page.dashboard.toast.delete-build-success": "{count} build(s) deleted successfully",
+ "page.dashboard.toast.delete-build-error": "Failed to delete build",
+ "page.dashboard.execution-barchart.execution-time-seconds": "ExecutionTime (Seconds)",
+ "page.dashboard.execution-bar-chart.title": "Test Runs",
+ "page.dashboard.execution-pie-chart.title": "Overall Status of Test Runs",
+ "page.metrics.bread-crumb": "Metrics",
+ "page.metrics.filters.labels.team": "Team",
+ "page.metrics.filters.labels.component": "Component",
+ "page.metrics.filters.labels.period": "Period",
+ "page.metrics.filters.labels.group-by-period": "Group by",
+ "page.metrics.filters.button.retrieve-metrics": "Retrieve Metrics",
+ "page.metrics.tab.execution-metrics": "Execution Metrics",
+ "page.metrics.tab.platform-metrics": "Platform Metrics",
+ "page.metrics.execution-metrics-summary.label.period": "Period",
+ "page.metrics.execution-metrics-summary.label.number-of-tests": "Number of tests",
+ "page.metrics.execution-metrics-summary.label.number-of-runs": "Number of test runs",
+ "page.metrics.execution-metrics-summary.label.number-of-executions": "Number of executions",
+ "page.metrics.execution-metrics-summary.label.result": "Result",
+ "page.metrics.execution-metrics-summary.label.duration": "Duration",
+ "page.metrics.execution-metrics-bar-chart.title": "Execution metrics grouped by result",
+ "page.metrics.execution-metrics-bar-chart.yaxis-title": "Number of executions",
+ "page.metrics.execution-metrics-phase-bar-chart.title": "Execution metrics grouped by phase",
+ "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "Number of executions",
+ "page.metrics.platform-distribution-pie-chart.title": "Overall executions distribution by platform",
+ "page.metrics.platform-distribution-bar-chart.title": "Executions distribution by platform",
+ "page.metrics.platform-distribution-bar-chart.yaxis-title": "Platforms",
+ "page.metrics.platform-distribution-bar-chart.xaxis-title": "Number of executions",
+ "page.metrics.platform-metrics-summary.label.platform": "Platform",
+ "page.metrics.platform-metrics-summary.label.version": "Version",
+ "page.metrics.platform-metrics-summary.label.device": "Device",
+ "page.metrics.platform-metrics-summary.label.browser": "Browser",
+ "page.metrics.platform-metrics-summary.label.total": "Total",
+ "page.metrics.platform-metrics-summary.label.result": "Result",
+ "page.metrics.platform-metrics-summary.label.duration": "Duration",
+ "page.metrics.metrics-test-table.label.unique-testcases": "Unique test cases per phase",
+ "page.metrics.metrics-test-table.label.phase": "Phase",
+ "page.metrics.metrics-test-table.label.test": "Test",
+ "page.metrics.metrics-test-table.no-tests-found": "No tests found",
+ "page.test-run.bread-crumb": "Test Run",
+ "page.test-run.steps-header.start": "Start",
+ "page.test-run.steps-header.duration": "Duration",
+ "page.test-run.steps-header.end": "End",
+ "page.test-run.menu.enable-keep-flag": "Enable the keep flag",
+ "page.test-run.menu.disable-keep-flag": "Disable the keep flag",
+ "page.test-run.menu.download-report": "Download report as html",
+ "page.test-run.menu.delete-build": "Delete Build",
+ "page.test-run.confirm.delete-build.title": "Delete Build",
+ "page.test-run.confirm.delete-build.message": "Are you sure you want to delete this build? This action cannot be undone and will permanently remove all associated executions and screenshots.",
+ "page.test-run.confirm.delete-build.cancel": "Cancel",
+ "page.test-run.toast.delete-build-success": "Build deleted successfully",
+ "page.test-run.toast.delete-build-error": "Failed to delete build",
+ "page.test-run.icons.environment.whisper": "Environment",
+ "page.test-run.icons.team.whisper": "Team",
+ "page.test-run.icons.component.whisper": "Component",
+ "page.test-run.icons.phase.whisper": "Test Phase",
+ "page.test-run.icons.artifacts.whisper": "Number of artifacts associated with this test run.",
+ "page.test-run.icons.keep-lock.whisper": "When locked a test run will not be cleaned up by the nightly cronjob.",
+ "page.test-run.icons.screenshots.whisper": "This test run has {numberOfScreenshots} screenshots",
+ "page.test-run.icons.download-report.whisper": "Download the HTML report for this test run.",
+ "page.test-run.header.status.whisper": "Status: {status}",
+ "page.test-run.header.execution-count": "{count, plural, one {# execution} other {# executions}}",
+ "page.test-run.header.fact.not-set": "Not set",
+ "page.test-run.header.artifacts-title": "Artifacts",
+ "page.test-run.suites.title": "Test Suites",
+ "page.test-run.loading": "Retrieving build details.",
+ "page.test-run.error.load-failed": "Unable to retrieve build details. Please refresh the page and try again and/or check if the build id is valid.",
+ "page.test-run.execution-pie-chart.title": "Overall execution metrics",
+ "page.test-run.feature-distribution-pie-chart.title": "Feature distribution metrics",
+ "page.test-run.execution-pie-chart.pass-rate": "Pass rate",
+ "page.test-run.feature-distribution-pie-chart.unspecified": "Unspecified",
+ "page.test-run.feature-distribution-pie-chart.executions": "Executions",
+ "page.test-run-compare.bread-crumb": "Compare Test Runs",
+ "page.test-run-compare.header.number": "#",
+ "page.test-run-compare.header.test-name": "Test Name",
+ "page.test-run-compare.detail.start-date": "Start Date",
+ "page.test-run-compare.detail.environment": "Environment",
+ "page.test-run-compare.cell.not-available": "N/A",
+ "page.test-run-compare.cell.multiple-executions": "Multiple Executions",
+ "page.test-run-compare.popover.details-title": "Details",
+ "page.test-run-compare.link.execution-history": "See execution history for {testName}",
+ "page.test-run-compare.details-popover.header.property": "Property",
+ "page.test-run-compare.details-popover.header.value": "Value",
+ "page.test-run-compare.details-popover.property.status": "Status",
+ "page.test-run-compare.details-popover.property.duration": "Duration",
+ "page.test-run-compare.details-popover.property.platform": "Platform",
+ "page.test-run-compare.details-popover.property.failing-step": "Failing Step",
+ "page.test-run-compare.loading": "Retrieving test run details to generate the comparison view...",
+ "page.test-run-compare.section.artifacts": "Artifact Versions",
+ "page.test-run-compare.artifacts.count": "{count, plural, one {# artifact} other {# artifacts}}",
+ "page.test-run-compare.section.compare-matrix": "Test Compare Matrix",
+ "page.test-run-compare.toolbar.status-filter": "Filter by status",
+ "page.test-run-compare.suite.tests": "{count, plural, one {# test} other {# tests}}",
+ "page.test-run-compare.suite.rollup": "{passed} of {total} passed",
+ "page.test-run-compare.header.run-summary": "{passed} of {total} passed, {failed} failed, {errors} with errors",
+ "page.test-run-compare.cell.status-changed": "Changed from previous run",
+ "page.test-run-compare.warning.many-runs": "Comparing {count} test runs — use the filters below to focus on the differences.",
+ "page.screenshot-library.bread-crumb": "Screenshot Library",
+ "page.screenshot-library.search-options.label.view": "View",
+ "page.screenshot-library.search-options.label.tag": "Tag",
+ "page.screenshot-library.search-options.label.or": "OR",
+ "page.screenshot-library.search-options.label.period": "Period",
+ "page.screenshot-library.search-options.label.platform": "Platform",
+ "page.screenshot-library.search-options.button.search-screenshots": "Search Screenshots",
+ "page.screenshot-library.search-options.dropdown.yesterday": "Yesterday",
+ "page.screenshot-library.search-options.dropdown.last-7-days": "Last 7 days",
+ "page.screenshot-library.search-options.dropdown.last-14-days": "Last 14 days",
+ "page.screenshot-library.search-options.dropdown.last-30-days": "Last 30 days",
+ "page.screenshot-library.search-options.dropdown.last-3-months": "Last 3 months",
+ "page.screenshot-library.search-options.dropdown.last-6-months": "Last 6 months",
+ "page.test-execution-history.bread-crumb": "Test Execution History",
+ "page.test-execution-history.header.suite": "Suite",
+ "page.test-execution-history.messages.retrieving-executions": "Retrieving Test Executions",
+ "page.test-execution-history.charts.execution-pie-chart.title": "Overall Execution Metrics",
+ "page.test-execution-history.filter.hint": "Select a status in the chart below to filter this page.",
+ "page.test-execution-history.filter.showing": "Showing",
+ "page.test-execution-history.filter.active": "({count, plural, one {# execution} other {# executions}})",
+ "page.test-execution-history.filter.no-results": "No executions match the selected status.",
+ "page.test-execution-history.charts.execution-timeline-chart.title": "Execution Times",
+ "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "Execution Time (seconds)",
+ "page.test-execution-history.charts.execution-platform-bar-chart.title": "Executions by Platform",
+ "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "Platforms",
+ "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "Number of executions",
+ "page.about.bread-crumb": "About",
+ "page.about.header": "About Angles",
+ "page.about.about-angles": "Angles is an open-source dashboard which can be used to store results and screenshots for automated test runs from various frameworks.",
+ "page.about.about-api": "By providing a clearly defined {apiLink} any framework can be adapted to store its test result in Angles, using one of the clients provided (or by using the API directly)",
+ "page.about.about-github": "For more information about Angles go to page: {githubLink}",
+ "page.introduction.bread-crumb": "Introduction",
+ "page.introduction.header": "Welcome to Angles",
+ "page.introduction.description": "It looks like you haven't setup any teams or environments.\n\nThis can be done by using {apiLink}.",
+ "page.introduction.api-link-text": "the Swagger API",
+ "page.introduction.description-alternative": "Or alternatively you can use {githubLink} to get started.",
+ "page.introduction.github-link-text": "the Github page",
+ "page.not-found.bread-crumb": "Not found",
+ "page.not-found.header": "Page Not Found",
+ "page.not-found.description": "Oops, we were unable to find the \"Angle\" you were looking for. Click {homeLink} to go back to home page.",
+ "page.not-found.home-link-text": "here",
+ "common.component.suite-table.header.suite": "Suite",
+ "common.component.suite-table.header.test": "Test",
+ "common.component.suite-table.header.duration": "Duration",
+ "common.component.suite-table.header.total": "Total",
+ "common.component.suite-table.header.collapse-all": "Collapse all test cases and actions",
+ "common.component.suite-table.header.expand-all": "Expand all test cases",
+ "common.component.suite-table.header.expand-all-actions": "Expand all test cases and actions",
+ "common.component.suite-table.icon.test-history": "See execution history for test",
+ "common.component.suite-table.header.show-screenshots": "Show step screenshots",
+ "common.component.suite-table.header.hide-screenshots": "Hide step screenshots",
+ "common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
+ "common.component.suite-table.execution.action-count": "{count, plural, one {# action} other {# actions}}",
+ "common.component.suite-table.action.step-count": "{count, plural, one {# step} other {# steps}}",
+ "common.component.screenshot-view.header": "Screenshot Viewer",
+ "common.component.screenshot-view.tabs.enable-alert": "To enable the \"History\" and \"Compare with Baseline\" tabs please provide a view and platform details when uploading the screenshots",
+ "common.component.screenshot-view.tabs.label.image": "Screenshot",
+ "common.component.screenshot-view.tabs.label.history": "History",
+ "common.component.screenshot-view.tabs.label.overlay-with-baseline": "Overlay with baseline",
+ "common.component.screenshot-view.tabs.label.side-by-side": "Side by side with baseline",
+ "common.component.screenshot-view.tabs.label.find-image": "Find in image",
+ "common.component.screenshot-view.tabs.baseline.label.algorithm": "Comparison algorithm",
+ "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "Pixel",
+ "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "Structural (SSIM)",
+ "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "Add this change region as an ignore box",
+ "common.component.screenshot-view.tabs.find-image.message.intro": "Upload a smaller image (e.g. a button, logo or game asset) to locate it within the current screenshot.",
+ "common.component.screenshot-view.tabs.find-image.label.template": "Template image",
+ "common.component.screenshot-view.tabs.find-image.label.min-confidence": "Minimum confidence",
+ "common.component.screenshot-view.tabs.find-image.label.max-matches": "Maximum matches",
+ "common.component.screenshot-view.tabs.find-image.label.grayscale": "Ignore colour differences (grayscale)",
+ "common.component.screenshot-view.tabs.find-image.button.find": "Find in screenshot",
+ "common.component.screenshot-view.tabs.find-image.message.searching": "Searching for the template image. This can take a few seconds.",
+ "common.component.screenshot-view.tabs.find-image.message.no-matches": "No matches found above the confidence threshold.",
+ "common.component.screenshot-view.tabs.find-image.label.matches": "Match",
+ "common.component.screenshot-view.tabs.find-image.label.position": "Position",
+ "common.component.screenshot-view.tabs.find-image.label.size": "Size",
+ "common.component.screenshot-view.tabs.find-image.label.confidence": "Confidence",
+ "common.component.screenshot-view.tabs.find-image.label.scale": "Scale",
+ "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "Make baseline image",
+ "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "This is the baseline image",
+ "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "Generate dynamic baseline",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "Delete screenshot",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "Only available for dynamic baselines that are not configured as a baseline.",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "Retrieving screenshot",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "Unable to retrieve image. Please refresh the page and try again.",
+ "common.component.screenshot-view.tabs.history.message.loading-history": "Loading history...",
+ "common.component.screenshot-view.tabs.history.label.baseline": "Baseline Screenshot",
+ "common.component.screenshot-view.tabs.history.label.view": "View",
+ "common.component.screenshot-view.tabs.history.label.date": "Date",
+ "common.component.screenshot-view.tabs.history.label.resolution": "Resolution",
+ "common.component.screenshot-view.tabs.history.label.platform": "Platform",
+ "common.component.screenshot-view.tabs.history.label.device": "Device",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "Current Image (and Baseline)",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image": "Current Image",
+ "common.component.screenshot-view.tabs.side-by-side.label.baseline": "Baseline",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "Retrieving baseline screenshot.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "Unable to retrieve baseline image.Please refresh the page and try again.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "Retrieving screenshot.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "Unable to retrieve image. Please refresh the page and try again.",
+ "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "Current Image (and Baseline)",
+ "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "Baseline Compare",
+ "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "Redo compare against baseline.",
+ "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "Edit ignore blocks",
+ "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "Save ignore blocks",
+ "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "Cancel changes",
+ "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "No Baseline selected yet for this view and deviceName or browser combination. To select a baseline, navigate to the image you want as a baseline and click on the \"Make Baseline Image\" button",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "Loading baseline compare",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "Failed to retrieve baseline compare.",
+ "common.component.screenshot-details-table.label.view": "View",
+ "common.component.screenshot-details-table.label.view-link": "other platforms",
+ "common.component.screenshot-details-table.label.view-link-description": "Find all the latest screenshots for view {view}, grouped by platform.",
+ "common.component.screenshot-details-table.label.no-view": "No view provided",
+ "common.component.screenshot-details-table.label.date": "Date",
+ "common.component.screenshot-details-table.label.baseline-date": "Baseline Date",
+ "common.component.screenshot-details-table.label.mismatch": "Mismatch",
+ "common.component.screenshot-details-table.label.algorithm": "Algorithm",
+ "common.component.screenshot-details-table.label.ssim-score": "SSIM score",
+ "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (analysis time: {time} ms)",
+ "common.component.screenshot-details-table.label.resolution": "Resolution",
+ "common.component.screenshot-details-table.label.platform": "Platform",
+ "common.component.screenshot-details-table.label.no-platform": "No platform provided",
+ "common.component.screenshot-details-table.label.platform-version": "Platform version",
+ "common.component.screenshot-details-table.label.device": "Device",
+ "common.component.screenshot-details-table.label.browser": "Browser",
+ "common.component.screenshot-details-table.label.no-browser": "No browser provided",
+ "common.component.screenshot-details-table.label.browser-version": "Browser version",
+ "common.component.screenshot-details-table.label.no-browser-version": "No browser version provided",
+ "common.component.screenshot-details-table.label.baseline": "Baseline",
+ "common.component.screenshot-details-table.label.tags": "Tags",
+ "common.component.screenshot-details-table.label.tags-description": "Find all the latest screenshots with tag \"{tag}\", grouped by view.",
+ "common.yes": "Yes",
+ "common.no": "No",
+ "common.button.ok": "OK",
+ "page.login.title": "Login to Angles",
+ "page.login.error.invalid-credentials": "Invalid username or password",
+ "page.login.label.username": "Username",
+ "page.login.label.password": "Password",
+ "page.login.button.login": "Login",
+ "page.login.divider.or": "OR",
+ "page.login.label.sign-in-with": "Sign in with",
+ "page.login.source.local": "Local account",
+ "page.login.button.sso": "Continue with {name}",
+ "page.login.error.sso-failed": "Single sign-on failed. Please try again or contact your administrator.",
+ "page.login.error.no-methods": "No sign-in methods are available. Please contact your administrator.",
+ "page.user-settings.header": "User Settings",
+ "page.user-settings.api-tokens.title": "API Tokens",
+ "page.user-settings.api-tokens.description": "Personal API tokens function like ordinary passwords but allow you to authenticate with the API programmatically.",
+ "page.user-settings.button.generate-new-token": "Generate New Token",
+ "page.user-settings.table.name": "Name",
+ "page.user-settings.table.created": "Created",
+ "page.user-settings.table.expires": "Expires",
+ "page.user-settings.table.action": "Action",
+ "page.user-settings.table.revoke": "Revoke",
+ "page.user-settings.modal.token-generated": "Token Generated",
+ "page.user-settings.modal.generate-token": "Generate New API Token",
+ "page.user-settings.modal.success-label": "Success!",
+ "page.user-settings.modal.success": "Your new token has been generated.",
+ "page.user-settings.modal.warning": "Please copy this token now. You will not be able to see it again!",
+ "page.user-settings.modal.token-name": "Token Name",
+ "page.user-settings.modal.token-placeholder": "e.g., CI/CD Pipeline",
+ "page.user-settings.modal.expiration": "Expiration",
+ "page.user-settings.modal.custom-expiration": "Custom Expiration (Days)",
+ "page.user-settings.button.done": "Done",
+ "page.user-settings.button.generate": "Generate",
+ "page.user-settings.button.cancel": "Cancel",
+ "page.user-settings.confirm.revoke.title": "Revoke Token",
+ "page.user-settings.confirm.revoke.message": "Are you sure you want to revoke this token? Any scripts or applications using this token will immediately lose access.",
+ "page.user-settings.table.expires-never": "Never",
+ "page.user-settings.modal.token-label": "Token",
+ "page.user-settings.modal.button.copy": "Copy",
+ "page.user-settings.expiry.1-day": "1 Day",
+ "page.user-settings.expiry.1-week": "1 Week",
+ "page.user-settings.expiry.1-month": "1 Month",
+ "page.user-settings.expiry.1-year": "1 Year",
+ "page.user-settings.expiry.custom": "Custom",
+ "page.user-settings.toast.fetch-tokens-error": "Failed to fetch tokens",
+ "page.user-settings.toast.token-name-required": "Token name is mandatory",
+ "page.user-settings.toast.expiry-required": "Valid expiry days are required",
+ "page.user-settings.toast.generate-token-error": "Failed to generate token",
+ "page.user-settings.toast.revoke-success": "Token revoked successfully",
+ "page.user-settings.toast.revoke-error": "Failed to revoke token",
+ "page.user-settings.toast.copy-success": "Token copied to clipboard",
+ "password.policy.min-length": "Be at least 10 characters long",
+ "password.policy.max-length": "Be at most 100 characters long",
+ "password.policy.letter": "Contain at least one letter",
+ "password.policy.uppercase": "Contain at least one uppercase letter",
+ "password.policy.special": "Contain at least one special character",
+ "password.policy.invalid": "Password does not meet the security requirements",
+ "page.user-settings.password.title": "Password",
+ "page.user-settings.password.description": "Change the password you use to sign in to Angles.",
+ "page.user-settings.password.button.change": "Change Password",
+ "page.user-settings.password.modal.title": "Change Password",
+ "page.user-settings.password.modal.current": "Current Password",
+ "page.user-settings.password.modal.new": "New Password",
+ "page.user-settings.password.modal.confirm": "Confirm New Password",
+ "page.user-settings.password.modal.mismatch": "Passwords do not match",
+ "page.user-settings.password.modal.submit": "Update Password",
+ "page.user-settings.password.modal.success": "Your password has been updated. Use it the next time you sign in.",
+ "page.user-settings.password.toast.current-required": "Current password is mandatory",
+ "page.user-settings.password.toast.mismatch": "New password and confirmation do not match",
+ "page.user-settings.password.toast.success": "Password updated successfully",
+ "page.user-settings.password.toast.error": "Failed to update password",
+ "page.admin.settings.header": "Authentication Settings",
+ "page.admin.settings.loading": "Loading settings...",
+ "page.admin.settings.local-auth.title": "Local Authentication",
+ "page.admin.settings.local-auth.enable": "Enable Local Authentication",
+ "page.admin.settings.local-auth.help": "Allow users to log in with a username and password.",
+ "page.admin.settings.button.save": "Save Settings",
+ "page.admin.users.header": "User Management",
+ "page.admin.users.button.add-user": "Add User",
+ "page.admin.users.table.username": "Username",
+ "page.admin.users.table.role": "Role",
+ "page.admin.users.table.teams": "Teams",
+ "page.admin.users.table.action": "Action",
+ "page.admin.users.table.edit": "Edit",
+ "page.admin.users.table.delete": "Delete",
+ "page.admin.users.modal.edit-user": "Edit User",
+ "page.admin.users.modal.add-user": "Add User",
+ "page.admin.users.modal.password-help": "(Leave blank to keep unchanged)",
+ "page.admin.users.modal.user-type": "User Type",
+ "page.admin.users.modal.team-access": "Team Access",
+ "page.admin.users.button.save": "Save",
+ "page.admin.users.button.cancel": "Cancel",
+ "page.admin.users.confirm.delete.title": "Delete User",
+ "page.admin.users.confirm.delete.message": "Are you sure you want to delete this user? This action cannot be undone.",
+ "page.admin.users.user-type.admin": "Admin",
+ "page.admin.users.user-type.team_lead": "Team Lead",
+ "page.admin.users.user-type.user": "User",
+ "page.admin.users.modal.password-label": "Password",
+ "page.admin.users.table.teams-assigned": "{count} teams assigned",
+ "page.admin.users.toast.fetch-error": "Failed to fetch users",
+ "page.admin.users.toast.update-success": "User updated successfully",
+ "page.admin.users.toast.create-success": "User created successfully",
+ "page.admin.users.toast.save-error": "Failed to save user",
+ "page.admin.users.toast.delete-success": "User deleted successfully",
+ "page.admin.users.toast.delete-error": "Failed to delete user",
+ "page.admin.settings.toast.fetch-error": "Failed to fetch settings",
+ "page.admin.settings.toast.save-success": "Settings saved successfully",
+ "page.admin.settings.toast.save-error": "Failed to save settings",
+ "page.admin.settings.providers.title": "Identity Providers",
+ "page.admin.settings.providers.help": "Configure single sign-on providers. Changes take effect immediately — no restart is required.",
+ "page.admin.settings.providers.empty": "No identity providers are configured yet.",
+ "page.admin.settings.providers.table.name": "Name",
+ "page.admin.settings.providers.table.id": "ID",
+ "page.admin.settings.providers.table.type": "Type",
+ "page.admin.settings.providers.table.enabled": "Enabled",
+ "page.admin.settings.providers.table.status": "Status",
+ "page.admin.settings.button.add-provider": "Add Provider",
+ "page.admin.settings.button.save-provider": "Save Provider",
+ "page.admin.settings.confirm.delete.title": "Delete Provider",
+ "page.admin.settings.confirm.delete.message": "Are you sure you want to delete this identity provider? Users who sign in through it will lose access.",
+ "page.admin.settings.provider.modal.add": "Add Identity Provider",
+ "page.admin.settings.provider.modal.edit": "Edit Identity Provider",
+ "page.admin.settings.provider.name": "Display Name",
+ "page.admin.settings.provider.name.help": "Shown on the login button, for example \"Company SSO\".",
+ "page.admin.settings.provider.id": "Provider ID",
+ "page.admin.settings.provider.id.help": "2-32 lowercase letters, numbers or hyphens. Forms part of the callback URL and cannot be changed later.",
+ "page.admin.settings.provider.id.help-locked": "The ID forms part of the callback URL registered with the identity provider and cannot be changed.",
+ "page.admin.settings.provider.type": "Provider Type",
+ "page.admin.settings.provider.type.oidc": "OIDC",
+ "page.admin.settings.provider.type.saml": "SAML 2.0",
+ "page.admin.settings.provider.type.ldap": "LDAP / AD",
+ "page.admin.settings.provider.type.help-locked": "The provider type cannot be changed after it has been created.",
+ "page.admin.settings.provider.enabled": "Enabled",
+ "page.admin.settings.provider.enabled.help": "Offer this provider on the login page.",
+ "page.admin.settings.provider.callback-url": "Redirect URI",
+ "page.admin.settings.provider.acs-url": "ACS (Callback) URL",
+ "page.admin.settings.provider.callback-url.help": "Register this URL with your identity provider.",
+ "page.admin.settings.provider.status.ready": "Ready",
+ "page.admin.settings.provider.status.error": "Error",
+ "page.admin.settings.provider.status.enabled": "Enabled",
+ "page.admin.settings.provider.status.disabled": "Disabled",
+ "page.admin.settings.provider.default-role": "Default Role",
+ "page.admin.settings.provider.default-role.deny": "Deny access",
+ "page.admin.settings.provider.default-role.help": "Role granted when no mapping matches. Deny access is the safer default.",
+ "page.admin.settings.provider.secret.help-set": "A value is configured. Leave blank to keep it, or enter a new value to replace it.",
+ "page.admin.settings.provider.secret.help-unset": "Once saved this value cannot be viewed again.",
+ "page.admin.settings.provider.error.id": "The provider ID must be 2-32 lowercase letters, numbers or hyphens.",
+ "page.admin.settings.provider.error.duplicate-id": "Another provider already uses this ID.",
+ "page.admin.settings.provider.error.name": "A display name is required.",
+ "page.admin.settings.provider.error.mapping-value": "Every role mapping needs a group value.",
+ "page.admin.settings.oidc.title": "OIDC Configuration",
+ "page.admin.settings.oidc.issuer": "Issuer URL",
+ "page.admin.settings.oidc.issuer.help": "Endpoints and signing keys are discovered from the issuer’s .well-known/openid-configuration.",
+ "page.admin.settings.oidc.client-id": "Client ID",
+ "page.admin.settings.oidc.client-secret": "Client Secret",
+ "page.admin.settings.oidc.scopes": "Scopes",
+ "page.admin.settings.oidc.scopes.help": "Space-separated. \"openid\" is always added. Okta and Keycloak accept \"groups\"; Google rejects it.",
+ "page.admin.settings.oidc.groups-claim": "Groups Claim",
+ "page.admin.settings.oidc.groups-claim.help": "Claim holding group membership — \"groups\" for Okta, often \"roles\" for Keycloak. Entra ID reports group GUIDs.",
+ "page.admin.settings.oidc.username-claim": "Username Claim",
+ "page.admin.settings.oidc.username-claim.help": "Claim used as the Angles username. Falls back to email, preferred_username, then sub.",
+ "page.admin.settings.saml.title": "SAML 2.0 Configuration",
+ "page.admin.settings.saml.metadata-url": "SP Metadata URL",
+ "page.admin.settings.saml.metadata-url.help": "Import this into your identity provider instead of entering values by hand.",
+ "page.admin.settings.saml.entry-point": "IdP SSO URL",
+ "page.admin.settings.saml.entry-point.help": "The identity provider URL the browser is redirected to.",
+ "page.admin.settings.saml.idp-cert": "IdP Signing Certificate",
+ "page.admin.settings.saml.idp-cert.help": "Required — it is the only reason an assertion can be trusted. Accepted with or without PEM header lines.",
+ "page.admin.settings.saml.issuer": "SP Entity ID",
+ "page.admin.settings.saml.issuer.help": "This service provider’s entity ID, as registered with the identity provider.",
+ "page.admin.settings.saml.private-key": "SP Private Key",
+ "page.admin.settings.saml.private-key.help": "Only needed when the identity provider requires signed authentication requests. Leave blank to keep the stored key.",
+ "page.admin.settings.saml.signature-algorithm": "Signature Algorithm",
+ "page.admin.settings.saml.identifier-format": "Name ID Format",
+ "page.admin.settings.saml.username-attribute": "Username Attribute",
+ "page.admin.settings.saml.username-attribute.help": "Assertion attribute holding the username. Leave blank to use the Name ID.",
+ "page.admin.settings.saml.groups-attribute": "Groups Attribute",
+ "page.admin.settings.saml.response-signed": "Require Signed Response",
+ "page.admin.settings.saml.response-signed.help": "Turn off for identity providers that sign only the assertion. Assertion signatures are always required.",
+ "page.admin.settings.saml.allow-unsolicited": "Allow IdP-Initiated Login",
+ "page.admin.settings.saml.allow-unsolicited.help": "Off by default: an unsolicited assertion has no request to correlate against, which removes its replay protection.",
+ "page.admin.settings.ldap.title": "LDAP / Active Directory Configuration",
+ "page.admin.settings.ldap.url": "Directory URL",
+ "page.admin.settings.ldap.url.help": "Use ldaps://, or ldap:// with StartTLS. A plain ldap:// connection is refused because passwords would be sent in clear text.",
+ "page.admin.settings.ldap.bind-dn": "Bind DN",
+ "page.admin.settings.ldap.bind-dn.help": "Service account used to look up the user. Leave blank for an anonymous search.",
+ "page.admin.settings.ldap.bind-credentials": "Bind Password",
+ "page.admin.settings.ldap.search-base": "User Search Base",
+ "page.admin.settings.ldap.search-base.help": "For example: ou=users,dc=example,dc=com",
+ "page.admin.settings.ldap.search-filter": "User Search Filter",
+ "page.admin.settings.ldap.search-filter.help": "{username} is replaced with the submitted username. Active Directory usually uses (sAMAccountName={username}).",
+ "page.admin.settings.ldap.username-attribute": "Username Attribute",
+ "page.admin.settings.ldap.username-attribute.help": "Attribute used as the Angles username — \"uid\", or \"sAMAccountName\" on Active Directory.",
+ "page.admin.settings.ldap.group-search-base": "Group Search Base",
+ "page.admin.settings.ldap.group-search-base.help": "Leave blank to read group membership from the user’s memberOf attribute, as Active Directory usually exposes it.",
+ "page.admin.settings.ldap.group-search-filter": "Group Search Filter",
+ "page.admin.settings.ldap.group-name-attribute": "Group Name Attribute",
+ "page.admin.settings.ldap.group-name-attribute.help": "Full DNs are reduced to this attribute, so map roles to names such as \"Angles Admins\".",
+ "page.admin.settings.ldap.start-tls": "Use StartTLS",
+ "page.admin.settings.ldap.start-tls.help": "Upgrade a plain ldap:// connection to TLS.",
+ "page.admin.settings.ldap.tls-reject-unauthorized": "Verify TLS Certificate",
+ "page.admin.settings.ldap.tls-reject-unauthorized.help": "Turning this off makes the connection trivially interceptable. Only for self-signed lab setups.",
+ "page.admin.settings.role-mappings.title": "Group to Role Mapping",
+ "page.admin.settings.role-mappings.help": "Map groups reported by the provider onto Angles roles. Matching is case-insensitive, and a user matching several mappings is granted the strongest role (Admin > Team Lead > User).",
+ "page.admin.settings.role-mappings.empty": "No mappings yet. Without one, access depends on the default role below.",
+ "page.admin.settings.role-mappings.add": "Add Mapping",
+ "page.admin.settings.role-mappings.remove": "Remove mapping",
+ "page.admin.settings.role-mappings.value-placeholder": "Group name or ID",
+ "page.team-settings.header": "Team Settings",
+ "page.team-settings.label.team": "Team to configure",
+ "page.team-settings.label.team.help": "Changing this only affects which team you are editing here. It does not change the team selected in the top menu.",
+ "page.team-settings.section.details": "Team Details",
+ "page.team-settings.label.name": "Team Name",
+ "page.team-settings.button.save-name": "Save Name",
+ "page.team-settings.section.components": "Components",
+ "page.team-settings.components.help": "Components let you group your test coverage by area of functionality.",
+ "page.team-settings.label.existing-components": "Existing Components",
+ "page.team-settings.label.add-components": "Add Components",
+ "page.team-settings.components.add-placeholder": "Type a component name and press enter",
+ "page.team-settings.button.add-components": "Add Components",
+ "page.team-settings.no-team-access": "You have not been granted access to any teams yet. Contact an administrator.",
+ "page.team-settings.toast.fetch-error": "Failed to fetch team details",
+ "page.team-settings.toast.name-update-success": "Team name updated successfully",
+ "page.team-settings.toast.name-update-error": "Failed to update team name",
+ "page.team-settings.toast.components-update-success": "Components added successfully",
+ "page.team-settings.toast.components-update-error": "Failed to add components",
+ "nav.manual-test-cases": "Manual Test Cases",
+ "app.manual.status.draft": "Draft",
+ "app.manual.status.active": "Active",
+ "app.manual.status.deprecated": "Deprecated",
+ "app.manual.priority.low": "Low",
+ "app.manual.priority.medium": "Medium",
+ "app.manual.priority.high": "High",
+ "app.manual.priority.critical": "Critical",
+ "app.manual.cancel": "Cancel",
+ "app.manual.version-badge.version": "v{version}",
+ "app.manual.version-badge.behind": "· {count} behind latest",
+ "app.manual.history.loading": "Loading history...",
+ "app.manual.history.empty": "No changes recorded yet.",
+ "app.manual.history.unknown-author": "Unknown user",
+ "app.manual.history.caused-by": "Caused by an update to shared step \"{name}\"",
+ "app.manual.history.action.create": "Created",
+ "app.manual.history.action.update": "Updated",
+ "app.manual.history.action.delete": "Deleted",
+ "app.manual.history.action.clone": "Cloned",
+ "app.manual.history.action.status-change": "Status changed",
+ "app.manual.history.action.shared-step-update": "Shared step updated",
+ "app.manual.history.action.archive": "Archived",
+ "app.manual.step-editor.expected": "Expected",
+ "app.manual.step-editor.data": "Data",
+ "app.manual.step-editor.shared-step": "Shared step",
+ "app.manual.step-editor.shared-step-placeholder": "Select a shared step",
+ "app.manual.step-editor.shared-step-help": "Its steps replace this one when the test case is run or a version is saved.",
+ "app.manual.step-editor.from-shared-step": "From shared step (v{version})",
+ "app.manual.step-editor.unresolved": "Shared step no longer exists",
+ "app.manual.step-editor.action-placeholder": "What should the tester do?",
+ "app.manual.step-editor.expected-placeholder": "What should happen?",
+ "app.manual.step-editor.data-placeholder": "Test data (optional)",
+ "app.manual.step-editor.add-step": "Add step",
+ "app.manual.step-editor.add-shared-step": "Include shared step",
+ "app.manual.step-editor.remove": "Remove step",
+ "app.manual.step-editor.empty": "No steps yet. Add the first one below.",
+ "app.manual.custom-field.archived": "(archived)",
+ "page.manual-test-cases": "Manual Test Cases",
+ "page.manual-test-cases.create": "New test case",
+ "page.manual-test-cases.new-case-title": "Untitled test case",
+ "page.manual-test-cases.loading": "Loading test cases...",
+ "page.manual-test-cases.empty": "No test cases match these filters.",
+ "page.manual-test-cases.no-team": "Select a team from the menu at the top of the page to see its manual test cases.",
+ "page.manual-test-cases.filters.search": "Search title or description",
+ "page.manual-test-cases.filters.status": "Status",
+ "page.manual-test-cases.filters.priority": "Priority",
+ "page.manual-test-cases.table.title": "Title",
+ "page.manual-test-cases.table.status": "Status",
+ "page.manual-test-cases.table.priority": "Priority",
+ "page.manual-test-cases.table.version": "Version",
+ "page.manual-test-cases.table.updated-by": "Updated by",
+ "page.manual-test-cases.table.steps": "Steps",
+ "page.manual-test-cases.toast.fetch-error": "Could not load the test cases.",
+ "page.manual-test-cases.toast.create-error": "Could not create the test case.",
+ "page.manual-test-case-detail.loading": "Loading test case...",
+ "page.manual-test-case-detail.save": "Save",
+ "page.manual-test-case-detail.clone": "Clone",
+ "page.manual-test-case-detail.delete": "Delete",
+ "page.manual-test-case-detail.tab.details": "Details",
+ "page.manual-test-case-detail.tab.steps": "Steps",
+ "page.manual-test-case-detail.tab.versions": "Versions",
+ "page.manual-test-case-detail.tab.history": "History",
+ "page.manual-test-case-detail.field.title": "Title",
+ "page.manual-test-case-detail.field.description": "Description",
+ "page.manual-test-case-detail.field.preconditions": "Preconditions",
+ "page.manual-test-case-detail.field.status": "Status",
+ "page.manual-test-case-detail.field.priority": "Priority",
+ "page.manual-test-case-detail.field.tags": "Tags",
+ "page.manual-test-case-detail.status-help": "Required custom fields are only enforced once a case leaves Draft.",
+ "page.manual-test-case-detail.custom-fields": "Custom fields",
+ "page.manual-test-case-detail.select-version": "Select a version",
+ "page.manual-test-case-detail.version-option": "Version {version}",
+ "page.manual-test-case-detail.versions-help": "Select a version to see exactly what a tester saw when it was executed.",
+ "page.manual-test-case-detail.delete-confirm-title": "Delete test case",
+ "page.manual-test-case-detail.delete-confirm-message": "This removes the test case and its whole version history. This cannot be undone.",
+ "page.manual-test-case-detail.toast.fetch-error": "Could not load the test case.",
+ "page.manual-test-case-detail.toast.save-error": "Could not save the test case.",
+ "page.manual-test-case-detail.toast.clone-error": "Could not clone the test case.",
+ "page.manual-test-case-detail.toast.delete-error": "Could not delete the test case.",
+ "page.manual-test-case-detail.toast.version-error": "Could not load that version.",
+ "page.manual-test-case-detail.toast.history-error": "Could not load the change history.",
+ "app.execution-type.all": "All",
+ "app.execution-type.automated": "Automated",
+ "app.execution-type.manual": "Manual",
+ "page.dashboard.filters.label.execution-type": "Type",
+ "page.metrics.filters.labels.execution-type": "Type",
+ "app.manual.save": "Save",
+ "app.manual.delete": "Delete",
+ "app.manual.edit": "Edit",
+ "app.manual.actions": "Actions",
+ "app.manual.loading": "Loading...",
+ "app.manual.yes": "Yes",
+ "app.manual.no": "No",
+ "app.manual.no-team": "Select a team from the menu at the top of the page to continue.",
+ "app.manual.field.description": "Description",
+ "app.manual.field.preconditions": "Preconditions",
+ "app.manual.field.priority": "Priority",
+ "app.manual.field.tags": "Tags",
+ "app.manual.field.title": "Title",
+ "app.manual.field.expected": "Expected",
+ "app.manual.field.steps": "Steps",
+ "app.manual.attachments.drop-hint": "Drop an image here, or click to choose one",
+ "app.manual.attachments.uploading": "Uploading...",
+ "app.manual.attachments.upload-error": "Could not upload the attachment.",
+ "app.manual.attachments.delete-error": "Could not delete the attachment. It may be referenced by a frozen version.",
+ "app.manual.attachments.copy-reference": "Copy markdown reference",
+ "app.manual.attachments.reference-copied": "Reference copied to the clipboard.",
+ "app.manual.version-diff.identical": "These two versions have identical content.",
+ "app.manual.version-diff.field": "Field",
+ "app.manual.version-diff.before": "Before",
+ "app.manual.version-diff.after": "After",
+ "app.manual.version-diff.empty": "(empty)",
+ "app.manual.version-diff.step": "Step {number}",
+ "app.manual.custom-field.type.text": "Text",
+ "app.manual.custom-field.type.textarea": "Long text",
+ "app.manual.custom-field.type.number": "Number",
+ "app.manual.custom-field.type.date": "Date",
+ "app.manual.custom-field.type.boolean": "Yes / No",
+ "app.manual.custom-field.type.select": "Single select",
+ "app.manual.custom-field.type.multiselect": "Multi select",
+ "app.manual.custom-field.type.user": "User",
+ "app.manual.custom-field.scope.testcase": "Test cases",
+ "app.manual.custom-field.scope.execution": "Executions",
+ "app.manual.custom-field.scope.both": "Both",
+ "app.manual.run-status.planned": "Planned",
+ "app.manual.run-status.in_progress": "In progress",
+ "app.manual.run-status.completed": "Completed",
+ "app.manual.run-status.cancelled": "Cancelled",
+ "app.manual.case-status.pass": "Pass",
+ "app.manual.case-status.fail": "Fail",
+ "app.manual.case-status.error": "Error",
+ "app.manual.case-status.skipped": "Skipped",
+ "app.manual.case-status.blocked": "Blocked",
+ "app.manual.case-status.not_run": "Not run",
+ "app.manual.case-status.in_progress": "In progress",
+ "app.manual.step-status.not_run": "Not run",
+ "app.manual.step-status.pass": "Pass",
+ "app.manual.step-status.fail": "Fail",
+ "app.manual.step-status.blocked": "Blocked",
+ "app.manual.step-status.skipped": "Skipped",
+ "nav.manual-testing": "Manual testing",
+ "nav.manual-test-runs": "Test runs",
+ "nav.shared-steps": "Shared steps",
+ "nav.admin.custom-fields": "Custom fields",
+ "page.admin-custom-fields.title": "Custom fields",
+ "page.admin-custom-fields.help": "Extra fields shown on every manual test case for this team. Required fields are only enforced once a case leaves draft.",
+ "page.admin-custom-fields.button.add": "Add field",
+ "page.admin-custom-fields.archived": "Archived",
+ "page.admin-custom-fields.header.label": "Label",
+ "page.admin-custom-fields.header.key": "Key",
+ "page.admin-custom-fields.header.type": "Type",
+ "page.admin-custom-fields.header.required": "Required",
+ "page.admin-custom-fields.header.applies-to": "Applies to",
+ "page.admin-custom-fields.modal.add-title": "Add custom field",
+ "page.admin-custom-fields.modal.edit-title": "Edit custom field",
+ "page.admin-custom-fields.form.label": "Label",
+ "page.admin-custom-fields.form.key": "Key",
+ "page.admin-custom-fields.form.key-help": "Lowercase letters, numbers and underscores. Cannot be changed once the field is created.",
+ "page.admin-custom-fields.form.key-invalid": "Must start with a lowercase letter and contain only lowercase letters, numbers and underscores (max 40 characters).",
+ "page.admin-custom-fields.form.type": "Type",
+ "page.admin-custom-fields.form.options": "Options",
+ "page.admin-custom-fields.form.applies-to": "Applies to",
+ "page.admin-custom-fields.form.order": "Display order",
+ "page.admin-custom-fields.form.required": "Required",
+ "page.admin-custom-fields.form.required-help": "Only enforced when a test case leaves draft.",
+ "page.admin-custom-fields.delete-confirm-title": "Delete custom field",
+ "page.admin-custom-fields.delete-confirm-message": "If any test case still holds a value for this field it will be archived rather than deleted, so existing content stays readable.",
+ "page.admin-custom-fields.toast.fetch-error": "Could not load the custom fields.",
+ "page.admin-custom-fields.toast.save-error": "Could not save the custom field.",
+ "page.admin-custom-fields.toast.delete-error": "Could not delete the custom field.",
+ "page.admin-custom-fields.toast.archived": "The field is in use, so it was archived rather than deleted.",
+ "page.admin-custom-fields.toast.deleted": "Custom field deleted.",
+ "page.shared-steps.title": "Shared steps",
+ "page.shared-steps.help": "Re-usable step sequences that many test cases can include. Editing one creates a new version of every test case that includes it.",
+ "page.shared-steps.button.add": "Add shared step",
+ "page.shared-steps.search-placeholder": "Search shared steps",
+ "page.shared-steps.header.name": "Name",
+ "page.shared-steps.header.description": "Description",
+ "page.shared-steps.header.steps": "Steps",
+ "page.shared-steps.header.version": "Version",
+ "page.shared-steps.action.usage": "Usage",
+ "page.shared-steps.action.history": "History",
+ "page.shared-steps.modal.add-title": "Add shared step",
+ "page.shared-steps.modal.edit-title": "Edit shared step",
+ "page.shared-steps.form.name": "Name",
+ "page.shared-steps.form.description": "Description",
+ "page.shared-steps.cascade-warning": "{count} test cases include this shared step. Saving will create a new version of each of them.",
+ "page.shared-steps.usage.title": "Test cases using this shared step",
+ "page.shared-steps.usage.empty": "No test cases include this shared step yet.",
+ "page.shared-steps.history.title": "Shared step history",
+ "page.shared-steps.delete-confirm-title": "Delete shared step",
+ "page.shared-steps.delete-confirm-message": "A shared step that is still referenced by a test case cannot be deleted.",
+ "page.shared-steps.toast.fetch-error": "Could not load the shared steps.",
+ "page.shared-steps.toast.save-error": "Could not save the shared step.",
+ "page.shared-steps.toast.delete-error": "Could not delete the shared step.",
+ "page.shared-steps.toast.usage-error": "Could not load the usage list.",
+ "page.shared-steps.toast.history-error": "Could not load the history.",
+ "page.shared-steps.toast.cascaded": "{count} test cases were given a new version.",
+ "page.manual-test-runs.title": "Manual test runs",
+ "page.manual-test-runs.button.add": "New test run",
+ "page.manual-test-runs.button.create": "Create run",
+ "page.manual-test-runs.filter.status": "Status",
+ "page.manual-test-runs.header.name": "Name",
+ "page.manual-test-runs.header.status": "Status",
+ "page.manual-test-runs.header.progress": "Progress",
+ "page.manual-test-runs.header.environment": "Environment",
+ "page.manual-test-runs.modal.title": "New test run",
+ "page.manual-test-runs.form.name": "Name",
+ "page.manual-test-runs.form.environment": "Environment",
+ "page.manual-test-runs.form.component": "Component",
+ "page.manual-test-runs.form.component-help": "A run covers a single component. Test cases from another component cannot be added.",
+ "page.manual-test-runs.form.test-cases": "Test cases",
+ "page.manual-test-runs.form.test-cases-help": "Each case is bound to its current version, so later edits will not change this run.",
+ "page.manual-test-runs.delete-confirm-title": "Delete test run",
+ "page.manual-test-runs.delete-confirm-message": "This removes the run and the results recorded against it.",
+ "page.manual-test-runs.toast.fetch-error": "Could not load the test runs.",
+ "page.manual-test-runs.toast.cases-error": "Could not load the available test cases.",
+ "page.manual-test-runs.toast.create-error": "Could not create the test run.",
+ "page.manual-test-runs.toast.delete-error": "Could not delete the test run.",
+ "page.manual-test-run-detail.not-found": "That test run could not be found.",
+ "page.manual-test-run-detail.button.start": "Start run",
+ "page.manual-test-run-detail.button.complete": "Complete run",
+ "page.manual-test-run-detail.button.record": "Record result",
+ "page.manual-test-run-detail.button.rebind": "Move to latest version",
+ "page.manual-test-run-detail.case-result": "Case result",
+ "page.manual-test-run-detail.actual-placeholder": "What actually happened?",
+ "page.manual-test-run-detail.notes-placeholder": "Notes (optional)",
+ "page.manual-test-run-detail.view-build": "View build",
+ "page.manual-test-run-detail.view-version": "View the version being tested",
+ "page.manual-test-run-detail.toast.fetch-error": "Could not load the test run.",
+ "page.manual-test-run-detail.toast.version-error": "Could not load the version being tested.",
+ "page.manual-test-run-detail.toast.record-error": "Could not record the result.",
+ "page.manual-test-run-detail.toast.recorded": "Result recorded.",
+ "page.manual-test-run-detail.toast.rebind-error": "Could not move this case to the latest version. A case that already has a result cannot be moved.",
+ "page.manual-test-run-detail.toast.status-error": "Could not change the run status.",
+ "page.manual-test-case-version.not-found": "That version could not be found.",
+ "page.manual-test-case-version.back-to-case": "Back to the test case",
+ "page.manual-test-case-version.frozen-notice": "This is a frozen version. It shows exactly what was authored at the time and never changes.",
+ "page.manual-test-case-version.compare-with": "Compare with",
+ "page.manual-test-case-version.toast.fetch-error": "Could not load that version.",
+ "page.manual-test-case-version.toast.compare-error": "Could not load the version to compare against.",
+ "page.manual-test-case-detail.toast.saved-new-version": "Saved as version {version}.",
+ "page.manual-test-case-detail.toast.saved-no-change": "Saved. The content did not change, so no new version was created.",
+ "page.shared-steps.toast.saved": "Shared step saved.",
+ "page.shared-steps.toast.created": "Shared step created.",
+ "page.admin-custom-fields.toast.created": "Custom field created.",
+ "page.admin-custom-fields.toast.updated": "Custom field updated.",
+ "app.manual.folders.title": "Folders",
+ "app.manual.folders.all": "All test cases",
+ "app.manual.folders.unfiled": "Not in a folder",
+ "app.manual.folders.add-root": "Add a top-level folder",
+ "app.manual.folders.add-sub": "Add a sub-folder",
+ "app.manual.folders.rename": "Rename",
+ "app.manual.folders.delete": "Delete",
+ "app.manual.folders.name": "Folder name",
+ "app.manual.folders.create-title": "New folder",
+ "app.manual.folders.rename-title": "Rename folder",
+ "app.manual.folders.creating-under": "Creating inside \"{name}\".",
+ "app.manual.folders.delete-confirm-title": "Delete folder",
+ "app.manual.folders.delete-confirm-message": "A folder can only be deleted once it is empty. Move or delete anything inside it first.",
+ "page.manual-test-cases.toast.folders-error": "Could not load the folders.",
+ "page.manual-test-cases.toast.folder-create-error": "Could not create the folder.",
+ "page.manual-test-cases.toast.folder-rename-error": "Could not rename the folder.",
+ "page.manual-test-cases.toast.folder-delete-error": "Could not delete the folder.",
+ "page.manual-test-case-detail.field.folder": "Folder",
+ "page.manual-test-case-detail.folder-root": "Not in a folder",
+ "app.manual.history.action.move": "Moved to a folder",
+ "app.manual.step-editor.action": "Action",
+ "app.manual.step-editor.attachments": "Attachments",
+ "app.manual.step-editor.attachments-need-save": "Save the test case first, then images can be attached to its steps.",
+ "app.manual.step-editor.drag-handle": "Drag to reorder",
+ "app.manual.step-editor.drag-help": "Drag a step to change its order.",
+ "app.manual.attachments.load-error": "Image could not be loaded.",
+ "app.manual.folders.unknown": "Other folder",
+ "page.manual-test-cases.toast.move-success": "Test case moved.",
+ "page.manual-test-cases.toast.move-error": "Could not move the test case.",
+ "page.admin.settings.features.header": "Feature Management",
+ "page.admin.settings.features.help": "Turn optional features on or off for this instance. Changes take effect immediately — no restart is required.",
+ "page.admin.settings.features.manual-testing": "Enable Test Case Management",
+ "page.admin.settings.features.manual-testing.help": "Manual test cases, test runs and shared steps. When disabled these pages are hidden and their data cannot be reached, but nothing is deleted — re-enabling restores everything.",
+ "app.feature.disabled": "This feature is not enabled on this instance. Contact an administrator if you need access to it.",
+ "nav.team": "Team",
+ "nav.team.none": "None selected"
}
diff --git a/src/translations/hi.json b/src/translations/hi.json
index 947cbf1..0465c76 100644
--- a/src/translations/hi.json
+++ b/src/translations/hi.json
@@ -1,381 +1,811 @@
{
- "nav.dashboard": "डैशबोर्ड",
- "nav.execution-metrics": "मेट्रिक्स",
- "nav.screenshot-library": "स्क्रीनशॉट लाइब्रेरी",
- "nav.language": "भाषा",
- "nav.theme": "थीम",
- "nav.theme.light": "लाइट",
- "nav.theme.dark": "डार्क",
- "nav.about": "Angles के बारे में",
- "nav.team-settings": "टीम सेटिंग्स",
- "nav.admin": "प्रशासन",
- "nav.admin.users": "उपयोगकर्ता",
- "nav.admin.settings": "सेटिंग्स",
- "nav.profile": "प्रोफ़ाइल",
- "nav.user-settings": "सेटिंग्स",
- "nav.logout": "लॉग आउट",
- "app.utils.duration.seconds": "{seconds} सेकंड",
- "app.utils.duration.minutes": "{minutes} मिनट",
- "app.utils.duration.hours": "{hours} घंटे",
- "app.utils.duration.not-started": "शुरू नहीं हुआ",
- "app.utils.date-range-picker.number-of-days": "पिछले {days} दिन",
- "app.retrieving-teams": "टीमें प्राप्त की जा रही हैं...",
- "app.retrieving-teams-error": "कुछ गलत हो गया [{teamsError}]",
- "app.result.pass": "पास",
- "app.result.fail": "फेल",
- "app.result.skipped": "छोड़ा गया",
- "app.result.error": "त्रुटि",
- "app.result.total": "कुल",
- "page.home.bread-crumb": "होम",
- "page.dashboard.message.retrieving-builds": "{teamName} के लिए बिल्ड प्राप्त किए जा रहे हैं...",
- "page.dashboard.filters.label.team": "टीम",
- "page.dashboard.filters.label.period": "अवधि",
- "page.dashboard.filters.label.display-limit": "प्रदर्शन सीमा",
- "page.dashboard.filters.label.environments": "वातावरण",
- "page.dashboard.filters.label.components": "घटक",
- "page.dashboard.panel.total-test-runs": "कुल टेस्ट रन",
- "page.dashboard.panel.total-test-executions": "कुल टेस्ट निष्पादन",
- "page.dashboard.panel.total-execution-time": "कुल निष्पादन समय",
- "page.dashboard.chart.barchart.pass": "पास",
- "page.dashboard.chart.barchart.skipped": "छोड़ा गया",
- "page.dashboard.chart.barchart.error": "त्रुटि",
- "page.dashboard.chart.barchart.fail": "फेल",
- "page.dashboard.chart.barchart.execution-time-seconds": "निष्पादन समय (सेकंड)",
- "page.dashboard.chart.barchart.yaxis.number-of-tests": "परीक्षणों की संख्या",
- "page.dashboard.builds-table.header.build-details": "बिल्ड विवरण",
- "page.dashboard.builds-table.header.date-time": "दिनांक/समय",
- "page.dashboard.builds-table.header.component": "घटक",
- "page.dashboard.builds-table.header.environment": "वातावरण",
- "page.dashboard.builds-table.header.result": "परिणाम",
- "page.dashboard.menu.compare-test-runs": "टेस्ट रन की तुलना करें",
- "page.dashboard.menu.toggle-keep-flag": "कीप फ्लैग टॉगल करें",
- "page.dashboard.menu.clear-selection": "चयन साफ़ करें",
- "page.dashboard.menu.delete-build": "बिल्ड हटाएँ",
- "page.dashboard.menu.one-select-message": "कृपया कम से कम एक टेस्ट रन चुनें",
- "page.dashboard.confirm.delete-build.title": "बिल्ड हटाएँ",
- "page.dashboard.confirm.delete-build.message": "क्या आप वाकई {count} चयनित बिल्ड हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और सभी संबंधित निष्पादन और स्क्रीनशॉट स्थायी रूप से हटा दिए जाएँगे।",
- "page.dashboard.confirm.delete-build.cancel": "रद्द करें",
- "page.dashboard.toast.delete-build-success": "{count} बिल्ड सफलतापूर्वक हटाए गए",
- "page.dashboard.toast.delete-build-error": "बिल्ड हटाने में विफल",
- "page.dashboard.execution-barchart.execution-time-seconds": "निष्पादन समय (सेकंड)",
- "page.dashboard.execution-bar-chart.title": "टेस्ट रन",
- "page.dashboard.execution-pie-chart.title": "टेस्ट रन की समग्र स्थिति",
- "page.metrics.bread-crumb": "मेट्रिक्स",
- "page.metrics.filters.labels.team": "टीम",
- "page.metrics.filters.labels.component": "घटक",
- "page.metrics.filters.labels.period": "अवधि",
- "page.metrics.filters.labels.group-by-period": "इसके अनुसार समूहित करें",
- "page.metrics.filters.button.retrieve-metrics": "मेट्रिक्स प्राप्त करें",
- "page.metrics.tab.execution-metrics": "निष्पादन मेट्रिक्स",
- "page.metrics.tab.platform-metrics": "प्लेटफ़ॉर्म मेट्रिक्स",
- "page.metrics.execution-metrics-summary.label.period": "अवधि",
- "page.metrics.execution-metrics-summary.label.number-of-tests": "टेस्ट की संख्या",
- "page.metrics.execution-metrics-summary.label.number-of-runs": "टेस्ट रन की संख्या",
- "page.metrics.execution-metrics-summary.label.number-of-executions": "निष्पादनों की संख्या",
- "page.metrics.execution-metrics-summary.label.result": "परिणाम",
- "page.metrics.execution-metrics-summary.label.duration": "अवधि",
- "page.metrics.execution-metrics-bar-chart.title": "परिणाम के अनुसार समूहित निष्पादन मेट्रिक्स",
- "page.metrics.execution-metrics-bar-chart.yaxis-title": "निष्पादनों की संख्या",
- "page.metrics.execution-metrics-phase-bar-chart.title": "चरण के अनुसार समूहित निष्पादन मेट्रिक्स",
- "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "निष्पादनों की संख्या",
- "page.metrics.platform-distribution-pie-chart.title": "प्लेटफ़ॉर्म के अनुसार समग्र निष्पादन वितरण",
- "page.metrics.platform-distribution-bar-chart.title": "प्लेटफ़ॉर्म के अनुसार निष्पादन वितरण",
- "page.metrics.platform-distribution-bar-chart.yaxis-title": "प्लेटफ़ॉर्म",
- "page.metrics.platform-distribution-bar-chart.xaxis-title": "निष्पादनों की संख्या",
- "page.metrics.platform-metrics-summary.label.platform": "प्लेटफ़ॉर्म",
- "page.metrics.platform-metrics-summary.label.version": "संस्करण",
- "page.metrics.platform-metrics-summary.label.device": "डिवाइस",
- "page.metrics.platform-metrics-summary.label.total": "कुल",
- "page.metrics.platform-metrics-summary.label.result": "परिणाम",
- "page.metrics.platform-metrics-summary.label.duration": "अवधि",
- "page.metrics.metrics-test-table.label.unique-testcases": "प्रति चरण अद्वितीय टेस्ट केस",
- "page.metrics.metrics-test-table.label.phase": "चरण",
- "page.metrics.metrics-test-table.label.test": "टेस्ट",
- "page.metrics.metrics-test-table.no-tests-found": "कोई टेस्ट नहीं मिला",
- "page.test-run.bread-crumb": "टेस्ट रन",
- "page.test-run.steps-header.start": "प्रारंभ",
- "page.test-run.steps-header.duration": "अवधि",
- "page.test-run.steps-header.end": "समाप्त",
- "page.test-run.menu.enable-keep-flag": "कीप फ्लैग सक्षम करें",
- "page.test-run.menu.disable-keep-flag": "कीप फ्लैग अक्षम करें",
- "page.test-run.menu.download-report": "रिपोर्ट को HTML के रूप में डाउनलोड करें",
- "page.test-run.menu.delete-build": "बिल्ड हटाएँ",
- "page.test-run.confirm.delete-build.title": "बिल्ड हटाएँ",
- "page.test-run.confirm.delete-build.message": "क्या आप वाकई इस बिल्ड को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और सभी संबंधित निष्पादन और स्क्रीनशॉट स्थायी रूप से हटा दिए जाएँगे।",
- "page.test-run.confirm.delete-build.cancel": "रद्द करें",
- "page.test-run.toast.delete-build-success": "बिल्ड सफलतापूर्वक हटाया गया",
- "page.test-run.toast.delete-build-error": "बिल्ड हटाने में विफल",
- "page.test-run.icons.environment.whisper": "वातावरण",
- "page.test-run.icons.team.whisper": "टीम",
- "page.test-run.icons.component.whisper": "घटक",
- "page.test-run.icons.phase.whisper": "टेस्ट चरण",
- "page.test-run.icons.artifacts.whisper": "इस टेस्ट रन से संबंधित आर्टिफैक्ट्स की संख्या।",
- "page.test-run.icons.keep-lock.whisper": "लॉक होने पर टेस्ट रन को रात्रिकालीन क्रॉनजॉब द्वारा साफ़ नहीं किया जाएगा।",
- "page.test-run.icons.screenshots.whisper": "इस टेस्ट रन में {numberOfScreenshots} स्क्रीनशॉट हैं",
- "page.test-run.icons.download-report.whisper": "इस टेस्ट रन के लिए HTML रिपोर्ट डाउनलोड करें।",
- "page.test-run.execution-pie-chart.title": "समग्र निष्पादन मेट्रिक्स",
- "page.test-run.feature-distribution-pie-chart.title": "फ़ीचर वितरण मेट्रिक्स",
- "page.test-run-compare.bread-crumb": "टेस्ट रन की तुलना करें",
- "page.screenshot-library.bread-crumb": "स्क्रीनशॉट लाइब्रेरी",
- "page.screenshot-library.search-options.label.view": "व्यू",
- "page.screenshot-library.search-options.label.tag": "टैग",
- "page.screenshot-library.search-options.label.or": "या",
- "page.screenshot-library.search-options.label.period": "अवधि",
- "page.screenshot-library.search-options.label.platform": "प्लेटफ़ॉर्म",
- "page.screenshot-library.search-options.button.search-screenshots": "स्क्रीनशॉट खोजें",
- "page.screenshot-library.search-options.dropdown.yesterday": "कल",
- "page.screenshot-library.search-options.dropdown.last-7-days": "पिछले 7 दिन",
- "page.screenshot-library.search-options.dropdown.last-14-days": "पिछले 14 दिन",
- "page.screenshot-library.search-options.dropdown.last-30-days": "पिछले 30 दिन",
- "page.screenshot-library.search-options.dropdown.last-3-months": "पिछले 3 महीने",
- "page.screenshot-library.search-options.dropdown.last-6-months": "पिछले 6 महीने",
- "page.test-execution-history.bread-crumb": "टेस्ट निष्पादन इतिहास",
- "page.test-execution-history.header.suite": "सूट",
- "page.test-execution-history.messages.retrieving-executions": "टेस्ट निष्पादन प्राप्त किए जा रहे हैं",
- "page.test-execution-history.charts.execution-pie-chart.title": "समग्र निष्पादन मेट्रिक्स",
- "page.test-execution-history.charts.execution-timeline-chart.title": "निष्पादन समय",
- "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "निष्पादन समय (सेकंड)",
- "page.test-execution-history.charts.execution-platform-bar-chart.title": "प्लेटफ़ॉर्म के अनुसार निष्पादन",
- "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "प्लेटफ़ॉर्म",
- "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "निष्पादनों की संख्या",
- "page.about.bread-crumb": "परिचय",
- "page.about.header": "Angles के बारे में",
- "page.about.about-angles": "Angles एक ओपन-सोर्स डैशबोर्ड है जिसका उपयोग विभिन्न फ्रेमवर्क से स्वचालित टेस्ट रन के परिणाम और स्क्रीनशॉट संग्रहीत करने के लिए किया जा सकता है।",
- "page.about.about-api": "एक स्पष्ट रूप से परिभाषित {apiLink} प्रदान करके, किसी भी फ्रेमवर्क को प्रदान किए गए क्लाइंट में से किसी एक का उपयोग करके (या सीधे API का उपयोग करके) Angles में अपने टेस्ट परिणाम संग्रहीत करने के लिए अनुकूलित किया जा सकता है",
- "page.about.about-github": "Angles के बारे में अधिक जानकारी के लिए इस पृष्ठ पर जाएँ: {githubLink}",
- "page.introduction.bread-crumb": "परिचय",
- "page.introduction.header": "Angles में आपका स्वागत है",
- "page.introduction.description": "ऐसा लगता है कि आपने कोई टीम या वातावरण सेटअप नहीं किया है।\n\nयह {apiLink} का उपयोग करके किया जा सकता है।",
- "page.introduction.api-link-text": "Swagger API",
- "page.introduction.description-alternative": "या वैकल्पिक रूप से आप शुरू करने के लिए {githubLink} का उपयोग कर सकते हैं।",
- "page.introduction.github-link-text": "Github पृष्ठ",
- "page.not-found.bread-crumb": "नहीं मिला",
- "page.not-found.header": "पृष्ठ नहीं मिला",
- "page.not-found.description": "ओह, हम आपके द्वारा खोजा गया \"Angle\" नहीं ढूँढ सके। होम पेज पर वापस जाने के लिए {homeLink} पर क्लिक करें।",
- "page.not-found.home-link-text": "यहाँ",
- "common.component.suite-table.header.suite": "सूट",
- "common.component.suite-table.header.test": "टेस्ट",
- "common.component.suite-table.header.duration": "अवधि",
- "common.component.suite-table.header.total": "कुल",
- "common.component.suite-table.header.collapse-all": "सभी टेस्ट केस और क्रियाएँ संक्षिप्त करें",
- "common.component.suite-table.header.expand-all": "सभी टेस्ट केस विस्तृत करें",
- "common.component.suite-table.header.expand-all-actions": "सभी टेस्ट केस और क्रियाएँ विस्तृत करें",
- "common.component.suite-table.icon.test-history": "टेस्ट के लिए निष्पादन इतिहास देखें",
- "common.component.suite-table.header.show-screenshots": "चरण स्क्रीनशॉट दिखाएँ",
- "common.component.suite-table.header.hide-screenshots": "चरण स्क्रीनशॉट छिपाएँ",
- "common.component.screenshot-view.header": "स्क्रीनशॉट व्यूअर",
- "common.component.screenshot-view.tabs.enable-alert": "\"इतिहास\" और \"बेसलाइन से तुलना\" टैब सक्षम करने के लिए, कृपया स्क्रीनशॉट अपलोड करते समय व्यू और प्लेटफ़ॉर्म विवरण प्रदान करें",
- "common.component.screenshot-view.tabs.label.image": "स्क्रीनशॉट",
- "common.component.screenshot-view.tabs.label.history": "इतिहास",
- "common.component.screenshot-view.tabs.label.overlay-with-baseline": "बेसलाइन के साथ ओवरले",
- "common.component.screenshot-view.tabs.label.side-by-side": "बेसलाइन के साथ साथ-साथ",
- "common.component.screenshot-view.tabs.label.find-image": "छवि में खोजें",
- "common.component.screenshot-view.tabs.baseline.label.algorithm": "तुलना एल्गोरिथ्म",
- "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "पिक्सेल",
- "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "संरचनात्मक (SSIM)",
- "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "इस परिवर्तित क्षेत्र को अनदेखा बॉक्स के रूप में जोड़ें",
- "common.component.screenshot-view.tabs.find-image.message.intro": "वर्तमान स्क्रीनशॉट में खोजने के लिए एक छोटी छवि (जैसे बटन, लोगो या गेम एसेट) अपलोड करें।",
- "common.component.screenshot-view.tabs.find-image.label.template": "टेम्पलेट छवि",
- "common.component.screenshot-view.tabs.find-image.label.min-confidence": "न्यूनतम विश्वसनीयता",
- "common.component.screenshot-view.tabs.find-image.label.max-matches": "अधिकतम मिलान",
- "common.component.screenshot-view.tabs.find-image.label.grayscale": "रंग अंतर अनदेखा करें (ग्रेस्केल)",
- "common.component.screenshot-view.tabs.find-image.button.find": "स्क्रीनशॉट में खोजें",
- "common.component.screenshot-view.tabs.find-image.message.searching": "टेम्पलेट छवि खोजी जा रही है। इसमें कुछ सेकंड लग सकते हैं।",
- "common.component.screenshot-view.tabs.find-image.message.no-matches": "विश्वसनीयता सीमा से ऊपर कोई मिलान नहीं मिला।",
- "common.component.screenshot-view.tabs.find-image.label.matches": "मिलान",
- "common.component.screenshot-view.tabs.find-image.label.position": "स्थिति",
- "common.component.screenshot-view.tabs.find-image.label.size": "आकार",
- "common.component.screenshot-view.tabs.find-image.label.confidence": "विश्वसनीयता",
- "common.component.screenshot-view.tabs.find-image.label.scale": "स्केल",
- "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "बेसलाइन इमेज बनाएँ",
- "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "यह बेसलाइन इमेज है",
- "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "डायनेमिक बेसलाइन जनरेट करें",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "स्क्रीनशॉट हटाएँ",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "केवल उन डायनेमिक बेसलाइन के लिए उपलब्ध है जो बेसलाइन के रूप में कॉन्फ़िगर नहीं की गई हैं।",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "स्क्रीनशॉट प्राप्त किया जा रहा है",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
- "common.component.screenshot-view.tabs.history.message.loading-history": "इतिहास लोड हो रहा है...",
- "common.component.screenshot-view.tabs.history.label.baseline": "बेसलाइन स्क्रीनशॉट",
- "common.component.screenshot-view.tabs.history.label.view": "व्यू",
- "common.component.screenshot-view.tabs.history.label.date": "दिनांक",
- "common.component.screenshot-view.tabs.history.label.resolution": "रिज़ॉल्यूशन",
- "common.component.screenshot-view.tabs.history.label.platform": "प्लेटफ़ॉर्म",
- "common.component.screenshot-view.tabs.history.label.device": "डिवाइस",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "वर्तमान इमेज (और बेसलाइन)",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image": "वर्तमान इमेज",
- "common.component.screenshot-view.tabs.side-by-side.label.baseline": "बेसलाइन",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "बेसलाइन स्क्रीनशॉट प्राप्त किया जा रहा है।",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "बेसलाइन इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "स्क्रीनशॉट प्राप्त किया जा रहा है।",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
- "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "वर्तमान इमेज (और बेसलाइन)",
- "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "बेसलाइन तुलना",
- "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "बेसलाइन के विरुद्ध पुनः तुलना करें।",
- "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "अनदेखा किए जाने वाले ब्लॉक संपादित करें",
- "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "अनदेखा किए जाने वाले ब्लॉक सहेजें",
- "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "परिवर्तन रद्द करें",
- "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "इस व्यू और deviceName या ब्राउज़र संयोजन के लिए अभी तक कोई बेसलाइन नहीं चुनी गई है। बेसलाइन चुनने के लिए, उस इमेज पर जाएँ जिसे आप बेसलाइन बनाना चाहते हैं और \"बेसलाइन इमेज बनाएँ\" बटन पर क्लिक करें",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "बेसलाइन तुलना लोड हो रही है",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "बेसलाइन तुलना प्राप्त करने में विफल।",
- "common.component.screenshot-details-table.label.view": "व्यू",
- "common.component.screenshot-details-table.label.view-link": "अन्य प्लेटफ़ॉर्म",
- "common.component.screenshot-details-table.label.view-link-description": "व्यू {view} के लिए सभी नवीनतम स्क्रीनशॉट खोजें, प्लेटफ़ॉर्म के अनुसार समूहित।",
- "common.component.screenshot-details-table.label.no-view": "कोई व्यू प्रदान नहीं किया गया",
- "common.component.screenshot-details-table.label.date": "दिनांक",
- "common.component.screenshot-details-table.label.baseline-date": "बेसलाइन दिनांक",
- "common.component.screenshot-details-table.label.mismatch": "बेमेल",
- "common.component.screenshot-details-table.label.algorithm": "एल्गोरिथ्म",
- "common.component.screenshot-details-table.label.ssim-score": "SSIM स्कोर",
- "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (विश्लेषण समय: {time} ms)",
- "common.component.screenshot-details-table.label.resolution": "रिज़ॉल्यूशन",
- "common.component.screenshot-details-table.label.platform": "प्लेटफ़ॉर्म",
- "common.component.screenshot-details-table.label.no-platform": "कोई प्लेटफ़ॉर्म प्रदान नहीं किया गया",
- "common.component.screenshot-details-table.label.platform-version": "प्लेटफ़ॉर्म संस्करण",
- "common.component.screenshot-details-table.label.device": "डिवाइस",
- "common.component.screenshot-details-table.label.browser": "ब्राउज़र",
- "common.component.screenshot-details-table.label.no-browser": "कोई ब्राउज़र प्रदान नहीं किया गया",
- "common.component.screenshot-details-table.label.browser-version": "ब्राउज़र संस्करण",
- "common.component.screenshot-details-table.label.no-browser-version": "कोई ब्राउज़र संस्करण प्रदान नहीं किया गया",
- "common.component.screenshot-details-table.label.baseline": "बेसलाइन",
- "common.component.screenshot-details-table.label.tags": "टैग",
- "common.component.screenshot-details-table.label.tags-description": "टैग \"{tag}\" वाले सभी नवीनतम स्क्रीनशॉट खोजें, व्यू के अनुसार समूहित।",
- "common.yes": "हाँ",
- "common.no": "नहीं",
- "common.button.ok": "ठीक है",
- "page.login.title": "Angles में लॉगिन करें",
- "page.login.error.invalid-credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड",
- "page.login.label.username": "उपयोगकर्ता नाम",
- "page.login.label.password": "पासवर्ड",
- "page.login.button.login": "लॉगिन",
- "page.login.divider.or": "या",
- "page.login.button.okta": "OKTA से लॉगिन करें",
- "page.user-settings.header": "उपयोगकर्ता सेटिंग्स",
- "page.user-settings.api-tokens.title": "API टोकन",
- "page.user-settings.api-tokens.description": "व्यक्तिगत API टोकन सामान्य पासवर्ड की तरह काम करते हैं लेकिन आपको प्रोग्रामेटिक रूप से API के साथ प्रमाणित करने की अनुमति देते हैं।",
- "page.user-settings.button.generate-new-token": "नया टोकन जनरेट करें",
- "page.user-settings.table.name": "नाम",
- "page.user-settings.table.created": "बनाया गया",
- "page.user-settings.table.expires": "समाप्ति",
- "page.user-settings.table.action": "क्रिया",
- "page.user-settings.table.revoke": "रद्द करें",
- "page.user-settings.modal.token-generated": "टोकन जनरेट हुआ",
- "page.user-settings.modal.generate-token": "नया API टोकन जनरेट करें",
- "page.user-settings.modal.success-label": "सफलता!",
- "page.user-settings.modal.success": "आपका नया टोकन जनरेट हो गया है।",
- "page.user-settings.modal.warning": "कृपया इस टोकन को अभी कॉपी करें। आप इसे दोबारा नहीं देख पाएँगे!",
- "page.user-settings.modal.token-name": "टोकन नाम",
- "page.user-settings.modal.token-placeholder": "उदा., CI/CD पाइपलाइन",
- "page.user-settings.modal.expiration": "समाप्ति",
- "page.user-settings.modal.custom-expiration": "कस्टम समाप्ति (दिन)",
- "page.user-settings.button.done": "पूर्ण",
- "page.user-settings.button.generate": "जनरेट करें",
- "page.user-settings.button.cancel": "रद्द करें",
- "page.user-settings.confirm.revoke.title": "टोकन रद्द करें",
- "page.user-settings.confirm.revoke.message": "क्या आप वाकई इस टोकन को रद्द करना चाहते हैं? इस टोकन का उपयोग करने वाली कोई भी स्क्रिप्ट या एप्लिकेशन तुरंत पहुँच खो देगी।",
- "page.user-settings.table.expires-never": "कभी नहीं",
- "page.user-settings.modal.token-label": "टोकन",
- "page.user-settings.modal.button.copy": "कॉपी करें",
- "page.user-settings.expiry.1-day": "1 दिन",
- "page.user-settings.expiry.1-week": "1 सप्ताह",
- "page.user-settings.expiry.1-month": "1 महीना",
- "page.user-settings.expiry.1-year": "1 वर्ष",
- "page.user-settings.expiry.custom": "कस्टम",
- "page.user-settings.toast.fetch-tokens-error": "टोकन प्राप्त करने में विफल",
- "page.user-settings.toast.token-name-required": "टोकन नाम अनिवार्य है",
- "page.user-settings.toast.expiry-required": "मान्य समाप्ति दिन आवश्यक हैं",
- "page.user-settings.toast.generate-token-error": "टोकन जनरेट करने में विफल",
- "page.user-settings.toast.revoke-success": "टोकन सफलतापूर्वक रद्द किया गया",
- "page.user-settings.toast.revoke-error": "टोकन रद्द करने में विफल",
- "page.user-settings.toast.copy-success": "टोकन क्लिपबोर्ड पर कॉपी किया गया",
- "password.policy.min-length": "कम से कम 10 वर्ण लंबा हो",
- "password.policy.max-length": "अधिकतम 100 वर्ण लंबा हो",
- "password.policy.letter": "कम से कम एक अक्षर हो",
- "password.policy.uppercase": "कम से कम एक बड़ा अक्षर हो",
- "password.policy.special": "कम से कम एक विशेष वर्ण हो",
- "password.policy.invalid": "पासवर्ड सुरक्षा आवश्यकताओं को पूरा नहीं करता",
- "page.user-settings.password.title": "पासवर्ड",
- "page.user-settings.password.description": "Angles में साइन इन करने के लिए उपयोग किया जाने वाला पासवर्ड बदलें।",
- "page.user-settings.password.button.change": "पासवर्ड बदलें",
- "page.user-settings.password.modal.title": "पासवर्ड बदलें",
- "page.user-settings.password.modal.current": "वर्तमान पासवर्ड",
- "page.user-settings.password.modal.new": "नया पासवर्ड",
- "page.user-settings.password.modal.confirm": "नए पासवर्ड की पुष्टि करें",
- "page.user-settings.password.modal.mismatch": "पासवर्ड मेल नहीं खाते",
- "page.user-settings.password.modal.submit": "पासवर्ड अपडेट करें",
- "page.user-settings.password.modal.success": "आपका पासवर्ड अपडेट हो गया है। अगली बार साइन इन करते समय इसका उपयोग करें।",
- "page.user-settings.password.toast.current-required": "वर्तमान पासवर्ड अनिवार्य है",
- "page.user-settings.password.toast.mismatch": "नया पासवर्ड और पुष्टि मेल नहीं खाते",
- "page.user-settings.password.toast.success": "पासवर्ड सफलतापूर्वक अपडेट किया गया",
- "page.user-settings.password.toast.error": "पासवर्ड अपडेट करने में विफल",
- "page.admin.settings.header": "प्रमाणीकरण सेटिंग्स",
- "page.admin.settings.loading": "सेटिंग्स लोड हो रही हैं...",
- "page.admin.settings.local-auth.title": "स्थानीय प्रमाणीकरण",
- "page.admin.settings.local-auth.enable": "स्थानीय प्रमाणीकरण सक्षम करें",
- "page.admin.settings.local-auth.help": "उपयोगकर्ताओं को उपयोगकर्ता नाम और पासवर्ड से लॉग इन करने की अनुमति दें।",
- "page.admin.settings.okta-auth.title": "OKTA प्रमाणीकरण",
- "page.admin.settings.okta-auth.enable": "OKTA सक्षम करें",
- "page.admin.settings.okta-auth.help": "उपयोगकर्ताओं को OKTA सिंगल साइन-ऑन का उपयोग करके लॉग इन करने की अनुमति दें।",
- "page.admin.settings.okta.domain": "OKTA डोमेन",
- "page.admin.settings.okta.client-id": "OKTA क्लाइंट ID",
- "page.admin.settings.okta.client-secret": "OKTA क्लाइंट सीक्रेट",
- "page.admin.settings.okta.client-secret.help-set": "एक क्लाइंट सीक्रेट कॉन्फ़िगर किया गया है। इसे बनाए रखने के लिए रिक्त छोड़ें, या इसे बदलने के लिए एक नया मान दर्ज करें।",
- "page.admin.settings.okta.client-secret.help-unset": "OKTA क्लाइंट सीक्रेट दर्ज करें। एक बार सहेजने के बाद इसे फिर से नहीं देखा जा सकता।",
- "page.admin.settings.okta.issuer": "OKTA जारीकर्ता",
- "page.admin.settings.okta.groups-title": "समूह से भूमिका मैपिंग",
- "page.admin.settings.okta.groups-help": "OKTA समूहों को Angles भूमिकाओं से मैप करें। उपयोगकर्ताओं को वह उच्चतम-विशेषाधिकार भूमिका दी जाती है जिससे वे मेल खाते हैं (एडमिन > टीम लीड > उपयोगकर्ता)। उस मैपिंग को अक्षम करने के लिए किसी फ़ील्ड को रिक्त छोड़ें।",
- "page.admin.settings.okta.admin-group": "एडमिन समूह",
- "page.admin.settings.okta.admin-group.help": "इस OKTA समूह के सदस्यों को एडमिन भूमिका दी जाती है।",
- "page.admin.settings.okta.team-lead-group": "टीम लीड समूह",
- "page.admin.settings.okta.team-lead-group.help": "इस OKTA समूह के सदस्यों को टीम लीड भूमिका दी जाती है। उपयोगकर्ता प्रबंधन में उनके द्वारा नेतृत्व की जाने वाली टीमें असाइन करें।",
- "page.admin.settings.okta.user-group": "उपयोगकर्ता समूह",
- "page.admin.settings.okta.user-group.help": "इस OKTA समूह के सदस्यों को मानक उपयोगकर्ता भूमिका दी जाती है।",
- "page.admin.settings.button.save": "सेटिंग्स सहेजें",
- "page.admin.users.header": "उपयोगकर्ता प्रबंधन",
- "page.admin.users.button.add-user": "उपयोगकर्ता जोड़ें",
- "page.admin.users.table.username": "उपयोगकर्ता नाम",
- "page.admin.users.table.role": "भूमिका",
- "page.admin.users.table.teams": "टीमें",
- "page.admin.users.table.action": "क्रिया",
- "page.admin.users.table.edit": "संपादित करें",
- "page.admin.users.table.delete": "हटाएँ",
- "page.admin.users.modal.edit-user": "उपयोगकर्ता संपादित करें",
- "page.admin.users.modal.add-user": "उपयोगकर्ता जोड़ें",
- "page.admin.users.modal.password-help": "(अपरिवर्तित रखने के लिए रिक्त छोड़ें)",
- "page.admin.users.modal.user-type": "उपयोगकर्ता प्रकार",
- "page.admin.users.modal.team-access": "टीम पहुँच",
- "page.admin.users.button.save": "सहेजें",
- "page.admin.users.button.cancel": "रद्द करें",
- "page.admin.users.confirm.delete.title": "उपयोगकर्ता हटाएँ",
- "page.admin.users.confirm.delete.message": "क्या आप वाकई इस उपयोगकर्ता को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
- "page.admin.users.user-type.admin": "एडमिन",
- "page.admin.users.user-type.team_lead": "टीम लीड",
- "page.admin.users.user-type.user": "उपयोगकर्ता",
- "page.admin.users.modal.password-label": "पासवर्ड",
- "page.admin.users.table.teams-assigned": "{count} टीमें असाइन की गईं",
- "page.admin.users.toast.fetch-error": "उपयोगकर्ता प्राप्त करने में विफल",
- "page.admin.users.toast.update-success": "उपयोगकर्ता सफलतापूर्वक अपडेट किया गया",
- "page.admin.users.toast.create-success": "उपयोगकर्ता सफलतापूर्वक बनाया गया",
- "page.admin.users.toast.save-error": "उपयोगकर्ता सहेजने में विफल",
- "page.admin.users.toast.delete-success": "उपयोगकर्ता सफलतापूर्वक हटाया गया",
- "page.admin.users.toast.delete-error": "उपयोगकर्ता हटाने में विफल",
- "page.admin.settings.toast.fetch-error": "सेटिंग्स प्राप्त करने में विफल",
- "page.admin.settings.toast.save-success": "सेटिंग्स सफलतापूर्वक सहेजी गईं",
- "page.admin.settings.toast.save-error": "सेटिंग्स सहेजने में विफल",
- "page.team-settings.header": "टीम सेटिंग्स",
- "page.team-settings.label.team": "टीम",
- "page.team-settings.section.details": "टीम विवरण",
- "page.team-settings.label.name": "टीम नाम",
- "page.team-settings.button.save-name": "नाम सहेजें",
- "page.team-settings.section.components": "घटक",
- "page.team-settings.components.help": "घटक आपको कार्यक्षमता के क्षेत्र के अनुसार अपने टेस्ट कवरेज को समूहित करने देते हैं।",
- "page.team-settings.label.existing-components": "मौजूदा घटक",
- "page.team-settings.label.add-components": "घटक जोड़ें",
- "page.team-settings.components.add-placeholder": "घटक का नाम टाइप करें और एंटर दबाएँ",
- "page.team-settings.button.add-components": "घटक जोड़ें",
- "page.team-settings.no-team-access": "आपको अभी तक किसी टीम तक पहुँच नहीं दी गई है। किसी प्रशासक से संपर्क करें।",
- "page.team-settings.toast.fetch-error": "टीम विवरण प्राप्त करने में विफल",
- "page.team-settings.toast.name-update-success": "टीम नाम सफलतापूर्वक अपडेट किया गया",
- "page.team-settings.toast.name-update-error": "टीम नाम अपडेट करने में विफल",
- "page.team-settings.toast.components-update-success": "घटक सफलतापूर्वक जोड़े गए",
- "page.team-settings.toast.components-update-error": "घटक जोड़ने में विफल"
+ "nav.dashboard": "डैशबोर्ड",
+ "nav.execution-metrics": "मेट्रिक्स",
+ "nav.screenshot-library": "स्क्रीनशॉट लाइब्रेरी",
+ "nav.language": "भाषा",
+ "nav.theme": "थीम",
+ "nav.theme.light": "लाइट",
+ "nav.theme.dark": "डार्क",
+ "nav.theme.group.light": "लाइट थीम",
+ "nav.theme.group.dark": "डार्क थीम",
+ "nav.theme.slate-light": "स्लेट लाइट",
+ "nav.theme.sepia": "सेपिया",
+ "nav.theme.slate-dark": "स्लेट डार्क",
+ "nav.theme.midnight": "मिडनाइट",
+ "nav.theme.forest-light": "फ़ॉरेस्ट लाइट",
+ "nav.theme.contrast-light": "उच्च कंट्रास्ट लाइट",
+ "nav.theme.forest-dark": "फ़ॉरेस्ट डार्क",
+ "nav.theme.contrast-dark": "उच्च कंट्रास्ट डार्क",
+ "nav.theme.clay-light": "क्ले (वर्णांधता-अनुकूल)",
+ "nav.theme.clay-dark": "क्ले डार्क (वर्णांधता-अनुकूल)",
+ "nav.about": "Angles के बारे में",
+ "nav.team-settings": "टीम सेटिंग्स",
+ "nav.admin": "प्रशासन",
+ "nav.admin.users": "उपयोगकर्ता",
+ "nav.admin.settings": "सेटिंग्स",
+ "nav.profile": "प्रोफ़ाइल",
+ "nav.user-settings": "सेटिंग्स",
+ "nav.logout": "लॉग आउट",
+ "app.utils.duration.seconds": "{seconds} सेकंड",
+ "app.utils.duration.minutes": "{minutes} मिनट",
+ "app.utils.duration.hours": "{hours} घंटे",
+ "app.utils.duration.not-started": "शुरू नहीं हुआ",
+ "app.utils.date-range-picker.number-of-days": "पिछले {days} दिन",
+ "app.retrieving-teams": "टीमें प्राप्त की जा रही हैं...",
+ "app.retrieving-teams-error": "कुछ गलत हो गया [{teamsError}]",
+ "app.result.pass": "पास",
+ "app.result.fail": "फेल",
+ "app.result.skipped": "छोड़ा गया",
+ "app.result.error": "त्रुटि",
+ "app.result.total": "कुल",
+ "page.home.bread-crumb": "होम",
+ "page.dashboard.message.retrieving-builds": "{teamName} के लिए बिल्ड प्राप्त किए जा रहे हैं...",
+ "page.dashboard.filters.label.team": "टीम",
+ "page.dashboard.filters.label.period": "अवधि",
+ "page.dashboard.filters.label.display-limit": "प्रदर्शन सीमा",
+ "page.dashboard.filters.label.environments": "वातावरण",
+ "page.dashboard.filters.label.components": "घटक",
+ "page.dashboard.panel.total-test-runs": "कुल टेस्ट रन",
+ "page.dashboard.panel.total-test-executions": "कुल टेस्ट निष्पादन",
+ "page.dashboard.panel.total-execution-time": "कुल निष्पादन समय",
+ "page.dashboard.chart.barchart.pass": "पास",
+ "page.dashboard.chart.barchart.skipped": "छोड़ा गया",
+ "page.dashboard.chart.barchart.error": "त्रुटि",
+ "page.dashboard.chart.barchart.fail": "फेल",
+ "page.dashboard.chart.barchart.execution-time-seconds": "निष्पादन समय (सेकंड)",
+ "page.dashboard.chart.barchart.yaxis.number-of-tests": "परीक्षणों की संख्या",
+ "page.dashboard.builds-table.header.build-details": "बिल्ड विवरण",
+ "page.dashboard.builds-table.header.status": "स्थिति",
+ "page.dashboard.builds-table.header.date-time": "दिनांक/समय",
+ "page.dashboard.builds-table.header.result": "परिणाम",
+ "page.dashboard.builds-table.header.flags": "फ़्लैग",
+ "page.dashboard.builds-table.not-available": "लागू नहीं",
+ "page.dashboard.builds-table.artifacts.title": "आर्टिफ़ैक्ट",
+ "page.dashboard.builds-table.artifacts.count": "{count, plural, one {# आर्टिफ़ैक्ट} other {# आर्टिफ़ैक्ट}}",
+ "page.dashboard.builds-table.keep.enabled": "कीप फ़्लैग सक्षम है - यह टेस्ट रन क्लीन-अप से सुरक्षित है",
+ "page.dashboard.builds-table.keep.disabled": "कीप फ़्लैग अक्षम है - यह टेस्ट रन क्लीन-अप के दौरान हटाया जा सकता है",
+ "page.dashboard.menu.compare-test-runs": "टेस्ट रन की तुलना करें",
+ "page.dashboard.menu.toggle-keep-flag": "कीप फ्लैग टॉगल करें",
+ "page.dashboard.menu.clear-selection": "चयन साफ़ करें",
+ "page.dashboard.menu.delete-build": "बिल्ड हटाएँ",
+ "page.dashboard.menu.one-select-message": "कृपया कम से कम एक टेस्ट रन चुनें",
+ "page.dashboard.confirm.delete-build.title": "बिल्ड हटाएँ",
+ "page.dashboard.confirm.delete-build.message": "क्या आप वाकई {count} चयनित बिल्ड हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और सभी संबंधित निष्पादन और स्क्रीनशॉट स्थायी रूप से हटा दिए जाएँगे।",
+ "page.dashboard.confirm.delete-build.cancel": "रद्द करें",
+ "page.dashboard.toast.delete-build-success": "{count} बिल्ड सफलतापूर्वक हटाए गए",
+ "page.dashboard.toast.delete-build-error": "बिल्ड हटाने में विफल",
+ "page.dashboard.execution-barchart.execution-time-seconds": "निष्पादन समय (सेकंड)",
+ "page.dashboard.execution-bar-chart.title": "टेस्ट रन",
+ "page.dashboard.execution-pie-chart.title": "टेस्ट रन की समग्र स्थिति",
+ "page.metrics.bread-crumb": "मेट्रिक्स",
+ "page.metrics.filters.labels.team": "टीम",
+ "page.metrics.filters.labels.component": "घटक",
+ "page.metrics.filters.labels.period": "अवधि",
+ "page.metrics.filters.labels.group-by-period": "इसके अनुसार समूहित करें",
+ "page.metrics.filters.button.retrieve-metrics": "मेट्रिक्स प्राप्त करें",
+ "page.metrics.tab.execution-metrics": "निष्पादन मेट्रिक्स",
+ "page.metrics.tab.platform-metrics": "प्लेटफ़ॉर्म मेट्रिक्स",
+ "page.metrics.execution-metrics-summary.label.period": "अवधि",
+ "page.metrics.execution-metrics-summary.label.number-of-tests": "टेस्ट की संख्या",
+ "page.metrics.execution-metrics-summary.label.number-of-runs": "टेस्ट रन की संख्या",
+ "page.metrics.execution-metrics-summary.label.number-of-executions": "निष्पादनों की संख्या",
+ "page.metrics.execution-metrics-summary.label.result": "परिणाम",
+ "page.metrics.execution-metrics-summary.label.duration": "अवधि",
+ "page.metrics.execution-metrics-bar-chart.title": "परिणाम के अनुसार समूहित निष्पादन मेट्रिक्स",
+ "page.metrics.execution-metrics-bar-chart.yaxis-title": "निष्पादनों की संख्या",
+ "page.metrics.execution-metrics-phase-bar-chart.title": "चरण के अनुसार समूहित निष्पादन मेट्रिक्स",
+ "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "निष्पादनों की संख्या",
+ "page.metrics.platform-distribution-pie-chart.title": "प्लेटफ़ॉर्म के अनुसार समग्र निष्पादन वितरण",
+ "page.metrics.platform-distribution-bar-chart.title": "प्लेटफ़ॉर्म के अनुसार निष्पादन वितरण",
+ "page.metrics.platform-distribution-bar-chart.yaxis-title": "प्लेटफ़ॉर्म",
+ "page.metrics.platform-distribution-bar-chart.xaxis-title": "निष्पादनों की संख्या",
+ "page.metrics.platform-metrics-summary.label.platform": "प्लेटफ़ॉर्म",
+ "page.metrics.platform-metrics-summary.label.version": "संस्करण",
+ "page.metrics.platform-metrics-summary.label.device": "डिवाइस",
+ "page.metrics.platform-metrics-summary.label.browser": "ब्राउज़र",
+ "page.metrics.platform-metrics-summary.label.total": "कुल",
+ "page.metrics.platform-metrics-summary.label.result": "परिणाम",
+ "page.metrics.platform-metrics-summary.label.duration": "अवधि",
+ "page.metrics.metrics-test-table.label.unique-testcases": "प्रति चरण अद्वितीय टेस्ट केस",
+ "page.metrics.metrics-test-table.label.phase": "चरण",
+ "page.metrics.metrics-test-table.label.test": "टेस्ट",
+ "page.metrics.metrics-test-table.no-tests-found": "कोई टेस्ट नहीं मिला",
+ "page.test-run.bread-crumb": "टेस्ट रन",
+ "page.test-run.steps-header.start": "प्रारंभ",
+ "page.test-run.steps-header.duration": "अवधि",
+ "page.test-run.steps-header.end": "समाप्त",
+ "page.test-run.menu.enable-keep-flag": "कीप फ्लैग सक्षम करें",
+ "page.test-run.menu.disable-keep-flag": "कीप फ्लैग अक्षम करें",
+ "page.test-run.menu.download-report": "रिपोर्ट को HTML के रूप में डाउनलोड करें",
+ "page.test-run.menu.delete-build": "बिल्ड हटाएँ",
+ "page.test-run.confirm.delete-build.title": "बिल्ड हटाएँ",
+ "page.test-run.confirm.delete-build.message": "क्या आप वाकई इस बिल्ड को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती और सभी संबंधित निष्पादन और स्क्रीनशॉट स्थायी रूप से हटा दिए जाएँगे।",
+ "page.test-run.confirm.delete-build.cancel": "रद्द करें",
+ "page.test-run.toast.delete-build-success": "बिल्ड सफलतापूर्वक हटाया गया",
+ "page.test-run.toast.delete-build-error": "बिल्ड हटाने में विफल",
+ "page.test-run.icons.environment.whisper": "वातावरण",
+ "page.test-run.icons.team.whisper": "टीम",
+ "page.test-run.icons.component.whisper": "घटक",
+ "page.test-run.icons.phase.whisper": "टेस्ट चरण",
+ "page.test-run.icons.artifacts.whisper": "इस टेस्ट रन से संबंधित आर्टिफैक्ट्स की संख्या।",
+ "page.test-run.icons.keep-lock.whisper": "लॉक होने पर टेस्ट रन को रात्रिकालीन क्रॉनजॉब द्वारा साफ़ नहीं किया जाएगा।",
+ "page.test-run.icons.screenshots.whisper": "इस टेस्ट रन में {numberOfScreenshots} स्क्रीनशॉट हैं",
+ "page.test-run.icons.download-report.whisper": "इस टेस्ट रन के लिए HTML रिपोर्ट डाउनलोड करें।",
+ "page.test-run.header.status.whisper": "स्थिति: {status}",
+ "page.test-run.header.execution-count": "{count, plural, one {# निष्पादन} other {# निष्पादन}}",
+ "page.test-run.header.fact.not-set": "सेट नहीं",
+ "page.test-run.header.artifacts-title": "आर्टिफ़ैक्ट",
+ "page.test-run.suites.title": "टेस्ट सूट",
+ "page.test-run.loading": "बिल्ड विवरण प्राप्त किए जा रहे हैं।",
+ "page.test-run.error.load-failed": "बिल्ड विवरण प्राप्त नहीं हो सके। कृपया पृष्ठ रीफ़्रेश करके पुनः प्रयास करें और/या जाँचें कि बिल्ड आईडी मान्य है।",
+ "page.test-run.execution-pie-chart.title": "समग्र निष्पादन मेट्रिक्स",
+ "page.test-run.feature-distribution-pie-chart.title": "फ़ीचर वितरण मेट्रिक्स",
+ "page.test-run.execution-pie-chart.pass-rate": "पास दर",
+ "page.test-run.feature-distribution-pie-chart.unspecified": "अनिर्दिष्ट",
+ "page.test-run.feature-distribution-pie-chart.executions": "निष्पादन",
+ "page.test-run-compare.bread-crumb": "टेस्ट रन की तुलना करें",
+ "page.test-run-compare.header.number": "#",
+ "page.test-run-compare.header.test-name": "टेस्ट नाम",
+ "page.test-run-compare.detail.start-date": "प्रारंभ तिथि",
+ "page.test-run-compare.detail.environment": "एनवायरनमेंट",
+ "page.test-run-compare.cell.not-available": "लागू नहीं",
+ "page.test-run-compare.cell.multiple-executions": "एकाधिक निष्पादन",
+ "page.test-run-compare.popover.details-title": "विवरण",
+ "page.test-run-compare.link.execution-history": "{testName} का निष्पादन इतिहास देखें",
+ "page.test-run-compare.details-popover.header.property": "गुण",
+ "page.test-run-compare.details-popover.header.value": "मान",
+ "page.test-run-compare.details-popover.property.status": "स्थिति",
+ "page.test-run-compare.details-popover.property.duration": "अवधि",
+ "page.test-run-compare.details-popover.property.platform": "प्लेटफ़ॉर्म",
+ "page.test-run-compare.details-popover.property.failing-step": "विफल चरण",
+ "page.test-run-compare.loading": "तुलना दृश्य बनाने के लिए टेस्ट रन विवरण प्राप्त किए जा रहे हैं...",
+ "page.test-run-compare.section.artifacts": "आर्टिफ़ैक्ट संस्करण",
+ "page.test-run-compare.artifacts.count": "{count, plural, one {# आर्टिफ़ैक्ट} other {# आर्टिफ़ैक्ट}}",
+ "page.test-run-compare.section.compare-matrix": "टेस्ट तुलना मैट्रिक्स",
+ "page.test-run-compare.toolbar.status-filter": "स्थिति के अनुसार फ़िल्टर करें",
+ "page.test-run-compare.suite.tests": "{count, plural, one {# टेस्ट} other {# टेस्ट}}",
+ "page.test-run-compare.suite.rollup": "{total} में से {passed} पास",
+ "page.test-run-compare.header.run-summary": "{total} में से {passed} पास, {failed} विफल, {errors} त्रुटि सहित",
+ "page.test-run-compare.cell.status-changed": "पिछले रन से बदला गया",
+ "page.test-run-compare.warning.many-runs": "{count} टेस्ट रन की तुलना की जा रही है — अंतर पर ध्यान केंद्रित करने के लिए नीचे दिए फ़िल्टर का उपयोग करें।",
+ "page.screenshot-library.bread-crumb": "स्क्रीनशॉट लाइब्रेरी",
+ "page.screenshot-library.search-options.label.view": "व्यू",
+ "page.screenshot-library.search-options.label.tag": "टैग",
+ "page.screenshot-library.search-options.label.or": "या",
+ "page.screenshot-library.search-options.label.period": "अवधि",
+ "page.screenshot-library.search-options.label.platform": "प्लेटफ़ॉर्म",
+ "page.screenshot-library.search-options.button.search-screenshots": "स्क्रीनशॉट खोजें",
+ "page.screenshot-library.search-options.dropdown.yesterday": "कल",
+ "page.screenshot-library.search-options.dropdown.last-7-days": "पिछले 7 दिन",
+ "page.screenshot-library.search-options.dropdown.last-14-days": "पिछले 14 दिन",
+ "page.screenshot-library.search-options.dropdown.last-30-days": "पिछले 30 दिन",
+ "page.screenshot-library.search-options.dropdown.last-3-months": "पिछले 3 महीने",
+ "page.screenshot-library.search-options.dropdown.last-6-months": "पिछले 6 महीने",
+ "page.test-execution-history.bread-crumb": "टेस्ट निष्पादन इतिहास",
+ "page.test-execution-history.header.suite": "सूट",
+ "page.test-execution-history.messages.retrieving-executions": "टेस्ट निष्पादन प्राप्त किए जा रहे हैं",
+ "page.test-execution-history.charts.execution-pie-chart.title": "समग्र निष्पादन मेट्रिक्स",
+ "page.test-execution-history.filter.hint": "इस पृष्ठ को फ़िल्टर करने के लिए नीचे दिए चार्ट में कोई स्थिति चुनें।",
+ "page.test-execution-history.filter.showing": "दिखाया जा रहा है",
+ "page.test-execution-history.filter.active": "({count, plural, one {# निष्पादन} other {# निष्पादन}})",
+ "page.test-execution-history.filter.no-results": "चयनित स्थिति से कोई निष्पादन मेल नहीं खाता।",
+ "page.test-execution-history.charts.execution-timeline-chart.title": "निष्पादन समय",
+ "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "निष्पादन समय (सेकंड)",
+ "page.test-execution-history.charts.execution-platform-bar-chart.title": "प्लेटफ़ॉर्म के अनुसार निष्पादन",
+ "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "प्लेटफ़ॉर्म",
+ "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "निष्पादनों की संख्या",
+ "page.about.bread-crumb": "परिचय",
+ "page.about.header": "Angles के बारे में",
+ "page.about.about-angles": "Angles एक ओपन-सोर्स डैशबोर्ड है जिसका उपयोग विभिन्न फ्रेमवर्क से स्वचालित टेस्ट रन के परिणाम और स्क्रीनशॉट संग्रहीत करने के लिए किया जा सकता है।",
+ "page.about.about-api": "एक स्पष्ट रूप से परिभाषित {apiLink} प्रदान करके, किसी भी फ्रेमवर्क को प्रदान किए गए क्लाइंट में से किसी एक का उपयोग करके (या सीधे API का उपयोग करके) Angles में अपने टेस्ट परिणाम संग्रहीत करने के लिए अनुकूलित किया जा सकता है",
+ "page.about.about-github": "Angles के बारे में अधिक जानकारी के लिए इस पृष्ठ पर जाएँ: {githubLink}",
+ "page.introduction.bread-crumb": "परिचय",
+ "page.introduction.header": "Angles में आपका स्वागत है",
+ "page.introduction.description": "ऐसा लगता है कि आपने कोई टीम या वातावरण सेटअप नहीं किया है।\n\nयह {apiLink} का उपयोग करके किया जा सकता है।",
+ "page.introduction.api-link-text": "Swagger API",
+ "page.introduction.description-alternative": "या वैकल्पिक रूप से आप शुरू करने के लिए {githubLink} का उपयोग कर सकते हैं।",
+ "page.introduction.github-link-text": "Github पृष्ठ",
+ "page.not-found.bread-crumb": "नहीं मिला",
+ "page.not-found.header": "पृष्ठ नहीं मिला",
+ "page.not-found.description": "ओह, हम आपके द्वारा खोजा गया \"Angle\" नहीं ढूँढ सके। होम पेज पर वापस जाने के लिए {homeLink} पर क्लिक करें।",
+ "page.not-found.home-link-text": "यहाँ",
+ "common.component.suite-table.header.suite": "सूट",
+ "common.component.suite-table.header.test": "टेस्ट",
+ "common.component.suite-table.header.duration": "अवधि",
+ "common.component.suite-table.header.total": "कुल",
+ "common.component.suite-table.header.collapse-all": "सभी टेस्ट केस और क्रियाएँ संक्षिप्त करें",
+ "common.component.suite-table.header.expand-all": "सभी टेस्ट केस विस्तृत करें",
+ "common.component.suite-table.header.expand-all-actions": "सभी टेस्ट केस और क्रियाएँ विस्तृत करें",
+ "common.component.suite-table.icon.test-history": "टेस्ट के लिए निष्पादन इतिहास देखें",
+ "common.component.suite-table.header.show-screenshots": "चरण स्क्रीनशॉट दिखाएँ",
+ "common.component.suite-table.header.hide-screenshots": "चरण स्क्रीनशॉट छिपाएँ",
+ "common.component.suite-table.header.execution-count": "{count, plural, one {# टेस्ट} other {# टेस्ट}}",
+ "common.component.suite-table.execution.action-count": "{count, plural, one {# क्रिया} other {# क्रियाएँ}}",
+ "common.component.suite-table.action.step-count": "{count, plural, one {# चरण} other {# चरण}}",
+ "common.component.screenshot-view.header": "स्क्रीनशॉट व्यूअर",
+ "common.component.screenshot-view.tabs.enable-alert": "\"इतिहास\" और \"बेसलाइन से तुलना\" टैब सक्षम करने के लिए, कृपया स्क्रीनशॉट अपलोड करते समय व्यू और प्लेटफ़ॉर्म विवरण प्रदान करें",
+ "common.component.screenshot-view.tabs.label.image": "स्क्रीनशॉट",
+ "common.component.screenshot-view.tabs.label.history": "इतिहास",
+ "common.component.screenshot-view.tabs.label.overlay-with-baseline": "बेसलाइन के साथ ओवरले",
+ "common.component.screenshot-view.tabs.label.side-by-side": "बेसलाइन के साथ साथ-साथ",
+ "common.component.screenshot-view.tabs.label.find-image": "छवि में खोजें",
+ "common.component.screenshot-view.tabs.baseline.label.algorithm": "तुलना एल्गोरिथ्म",
+ "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "पिक्सेल",
+ "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "संरचनात्मक (SSIM)",
+ "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "इस परिवर्तित क्षेत्र को अनदेखा बॉक्स के रूप में जोड़ें",
+ "common.component.screenshot-view.tabs.find-image.message.intro": "वर्तमान स्क्रीनशॉट में खोजने के लिए एक छोटी छवि (जैसे बटन, लोगो या गेम एसेट) अपलोड करें।",
+ "common.component.screenshot-view.tabs.find-image.label.template": "टेम्पलेट छवि",
+ "common.component.screenshot-view.tabs.find-image.label.min-confidence": "न्यूनतम विश्वसनीयता",
+ "common.component.screenshot-view.tabs.find-image.label.max-matches": "अधिकतम मिलान",
+ "common.component.screenshot-view.tabs.find-image.label.grayscale": "रंग अंतर अनदेखा करें (ग्रेस्केल)",
+ "common.component.screenshot-view.tabs.find-image.button.find": "स्क्रीनशॉट में खोजें",
+ "common.component.screenshot-view.tabs.find-image.message.searching": "टेम्पलेट छवि खोजी जा रही है। इसमें कुछ सेकंड लग सकते हैं।",
+ "common.component.screenshot-view.tabs.find-image.message.no-matches": "विश्वसनीयता सीमा से ऊपर कोई मिलान नहीं मिला।",
+ "common.component.screenshot-view.tabs.find-image.label.matches": "मिलान",
+ "common.component.screenshot-view.tabs.find-image.label.position": "स्थिति",
+ "common.component.screenshot-view.tabs.find-image.label.size": "आकार",
+ "common.component.screenshot-view.tabs.find-image.label.confidence": "विश्वसनीयता",
+ "common.component.screenshot-view.tabs.find-image.label.scale": "स्केल",
+ "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "बेसलाइन इमेज बनाएँ",
+ "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "यह बेसलाइन इमेज है",
+ "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "डायनेमिक बेसलाइन जनरेट करें",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "स्क्रीनशॉट हटाएँ",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "केवल उन डायनेमिक बेसलाइन के लिए उपलब्ध है जो बेसलाइन के रूप में कॉन्फ़िगर नहीं की गई हैं।",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "स्क्रीनशॉट प्राप्त किया जा रहा है",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
+ "common.component.screenshot-view.tabs.history.message.loading-history": "इतिहास लोड हो रहा है...",
+ "common.component.screenshot-view.tabs.history.label.baseline": "बेसलाइन स्क्रीनशॉट",
+ "common.component.screenshot-view.tabs.history.label.view": "व्यू",
+ "common.component.screenshot-view.tabs.history.label.date": "दिनांक",
+ "common.component.screenshot-view.tabs.history.label.resolution": "रिज़ॉल्यूशन",
+ "common.component.screenshot-view.tabs.history.label.platform": "प्लेटफ़ॉर्म",
+ "common.component.screenshot-view.tabs.history.label.device": "डिवाइस",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "वर्तमान इमेज (और बेसलाइन)",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image": "वर्तमान इमेज",
+ "common.component.screenshot-view.tabs.side-by-side.label.baseline": "बेसलाइन",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "बेसलाइन स्क्रीनशॉट प्राप्त किया जा रहा है।",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "बेसलाइन इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "स्क्रीनशॉट प्राप्त किया जा रहा है।",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "इमेज प्राप्त करने में असमर्थ। कृपया पृष्ठ को रीफ़्रेश करें और पुनः प्रयास करें।",
+ "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "वर्तमान इमेज (और बेसलाइन)",
+ "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "बेसलाइन तुलना",
+ "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "बेसलाइन के विरुद्ध पुनः तुलना करें।",
+ "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "अनदेखा किए जाने वाले ब्लॉक संपादित करें",
+ "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "अनदेखा किए जाने वाले ब्लॉक सहेजें",
+ "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "परिवर्तन रद्द करें",
+ "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "इस व्यू और deviceName या ब्राउज़र संयोजन के लिए अभी तक कोई बेसलाइन नहीं चुनी गई है। बेसलाइन चुनने के लिए, उस इमेज पर जाएँ जिसे आप बेसलाइन बनाना चाहते हैं और \"बेसलाइन इमेज बनाएँ\" बटन पर क्लिक करें",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "बेसलाइन तुलना लोड हो रही है",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "बेसलाइन तुलना प्राप्त करने में विफल।",
+ "common.component.screenshot-details-table.label.view": "व्यू",
+ "common.component.screenshot-details-table.label.view-link": "अन्य प्लेटफ़ॉर्म",
+ "common.component.screenshot-details-table.label.view-link-description": "व्यू {view} के लिए सभी नवीनतम स्क्रीनशॉट खोजें, प्लेटफ़ॉर्म के अनुसार समूहित।",
+ "common.component.screenshot-details-table.label.no-view": "कोई व्यू प्रदान नहीं किया गया",
+ "common.component.screenshot-details-table.label.date": "दिनांक",
+ "common.component.screenshot-details-table.label.baseline-date": "बेसलाइन दिनांक",
+ "common.component.screenshot-details-table.label.mismatch": "बेमेल",
+ "common.component.screenshot-details-table.label.algorithm": "एल्गोरिथ्म",
+ "common.component.screenshot-details-table.label.ssim-score": "SSIM स्कोर",
+ "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (विश्लेषण समय: {time} ms)",
+ "common.component.screenshot-details-table.label.resolution": "रिज़ॉल्यूशन",
+ "common.component.screenshot-details-table.label.platform": "प्लेटफ़ॉर्म",
+ "common.component.screenshot-details-table.label.no-platform": "कोई प्लेटफ़ॉर्म प्रदान नहीं किया गया",
+ "common.component.screenshot-details-table.label.platform-version": "प्लेटफ़ॉर्म संस्करण",
+ "common.component.screenshot-details-table.label.device": "डिवाइस",
+ "common.component.screenshot-details-table.label.browser": "ब्राउज़र",
+ "common.component.screenshot-details-table.label.no-browser": "कोई ब्राउज़र प्रदान नहीं किया गया",
+ "common.component.screenshot-details-table.label.browser-version": "ब्राउज़र संस्करण",
+ "common.component.screenshot-details-table.label.no-browser-version": "कोई ब्राउज़र संस्करण प्रदान नहीं किया गया",
+ "common.component.screenshot-details-table.label.baseline": "बेसलाइन",
+ "common.component.screenshot-details-table.label.tags": "टैग",
+ "common.component.screenshot-details-table.label.tags-description": "टैग \"{tag}\" वाले सभी नवीनतम स्क्रीनशॉट खोजें, व्यू के अनुसार समूहित।",
+ "common.yes": "हाँ",
+ "common.no": "नहीं",
+ "common.button.ok": "ठीक है",
+ "page.login.title": "Angles में लॉगिन करें",
+ "page.login.error.invalid-credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड",
+ "page.login.label.username": "उपयोगकर्ता नाम",
+ "page.login.label.password": "पासवर्ड",
+ "page.login.button.login": "लॉगिन",
+ "page.login.divider.or": "या",
+ "page.login.label.sign-in-with": "इसके साथ साइन इन करें",
+ "page.login.source.local": "स्थानीय खाता",
+ "page.login.button.sso": "{name} के साथ जारी रखें",
+ "page.login.error.sso-failed": "सिंगल साइन-ऑन विफल रहा। कृपया पुनः प्रयास करें या अपने व्यवस्थापक से संपर्क करें।",
+ "page.login.error.no-methods": "कोई साइन-इन विधि उपलब्ध नहीं है। कृपया अपने व्यवस्थापक से संपर्क करें।",
+ "page.user-settings.header": "उपयोगकर्ता सेटिंग्स",
+ "page.user-settings.api-tokens.title": "API टोकन",
+ "page.user-settings.api-tokens.description": "व्यक्तिगत API टोकन सामान्य पासवर्ड की तरह काम करते हैं लेकिन आपको प्रोग्रामेटिक रूप से API के साथ प्रमाणित करने की अनुमति देते हैं।",
+ "page.user-settings.button.generate-new-token": "नया टोकन जनरेट करें",
+ "page.user-settings.table.name": "नाम",
+ "page.user-settings.table.created": "बनाया गया",
+ "page.user-settings.table.expires": "समाप्ति",
+ "page.user-settings.table.action": "क्रिया",
+ "page.user-settings.table.revoke": "रद्द करें",
+ "page.user-settings.modal.token-generated": "टोकन जनरेट हुआ",
+ "page.user-settings.modal.generate-token": "नया API टोकन जनरेट करें",
+ "page.user-settings.modal.success-label": "सफलता!",
+ "page.user-settings.modal.success": "आपका नया टोकन जनरेट हो गया है।",
+ "page.user-settings.modal.warning": "कृपया इस टोकन को अभी कॉपी करें। आप इसे दोबारा नहीं देख पाएँगे!",
+ "page.user-settings.modal.token-name": "टोकन नाम",
+ "page.user-settings.modal.token-placeholder": "उदा., CI/CD पाइपलाइन",
+ "page.user-settings.modal.expiration": "समाप्ति",
+ "page.user-settings.modal.custom-expiration": "कस्टम समाप्ति (दिन)",
+ "page.user-settings.button.done": "पूर्ण",
+ "page.user-settings.button.generate": "जनरेट करें",
+ "page.user-settings.button.cancel": "रद्द करें",
+ "page.user-settings.confirm.revoke.title": "टोकन रद्द करें",
+ "page.user-settings.confirm.revoke.message": "क्या आप वाकई इस टोकन को रद्द करना चाहते हैं? इस टोकन का उपयोग करने वाली कोई भी स्क्रिप्ट या एप्लिकेशन तुरंत पहुँच खो देगी।",
+ "page.user-settings.table.expires-never": "कभी नहीं",
+ "page.user-settings.modal.token-label": "टोकन",
+ "page.user-settings.modal.button.copy": "कॉपी करें",
+ "page.user-settings.expiry.1-day": "1 दिन",
+ "page.user-settings.expiry.1-week": "1 सप्ताह",
+ "page.user-settings.expiry.1-month": "1 महीना",
+ "page.user-settings.expiry.1-year": "1 वर्ष",
+ "page.user-settings.expiry.custom": "कस्टम",
+ "page.user-settings.toast.fetch-tokens-error": "टोकन प्राप्त करने में विफल",
+ "page.user-settings.toast.token-name-required": "टोकन नाम अनिवार्य है",
+ "page.user-settings.toast.expiry-required": "मान्य समाप्ति दिन आवश्यक हैं",
+ "page.user-settings.toast.generate-token-error": "टोकन जनरेट करने में विफल",
+ "page.user-settings.toast.revoke-success": "टोकन सफलतापूर्वक रद्द किया गया",
+ "page.user-settings.toast.revoke-error": "टोकन रद्द करने में विफल",
+ "page.user-settings.toast.copy-success": "टोकन क्लिपबोर्ड पर कॉपी किया गया",
+ "password.policy.min-length": "कम से कम 10 वर्ण लंबा हो",
+ "password.policy.max-length": "अधिकतम 100 वर्ण लंबा हो",
+ "password.policy.letter": "कम से कम एक अक्षर हो",
+ "password.policy.uppercase": "कम से कम एक बड़ा अक्षर हो",
+ "password.policy.special": "कम से कम एक विशेष वर्ण हो",
+ "password.policy.invalid": "पासवर्ड सुरक्षा आवश्यकताओं को पूरा नहीं करता",
+ "page.user-settings.password.title": "पासवर्ड",
+ "page.user-settings.password.description": "Angles में साइन इन करने के लिए उपयोग किया जाने वाला पासवर्ड बदलें।",
+ "page.user-settings.password.button.change": "पासवर्ड बदलें",
+ "page.user-settings.password.modal.title": "पासवर्ड बदलें",
+ "page.user-settings.password.modal.current": "वर्तमान पासवर्ड",
+ "page.user-settings.password.modal.new": "नया पासवर्ड",
+ "page.user-settings.password.modal.confirm": "नए पासवर्ड की पुष्टि करें",
+ "page.user-settings.password.modal.mismatch": "पासवर्ड मेल नहीं खाते",
+ "page.user-settings.password.modal.submit": "पासवर्ड अपडेट करें",
+ "page.user-settings.password.modal.success": "आपका पासवर्ड अपडेट हो गया है। अगली बार साइन इन करते समय इसका उपयोग करें।",
+ "page.user-settings.password.toast.current-required": "वर्तमान पासवर्ड अनिवार्य है",
+ "page.user-settings.password.toast.mismatch": "नया पासवर्ड और पुष्टि मेल नहीं खाते",
+ "page.user-settings.password.toast.success": "पासवर्ड सफलतापूर्वक अपडेट किया गया",
+ "page.user-settings.password.toast.error": "पासवर्ड अपडेट करने में विफल",
+ "page.admin.settings.header": "प्रमाणीकरण सेटिंग्स",
+ "page.admin.settings.loading": "सेटिंग्स लोड हो रही हैं...",
+ "page.admin.settings.local-auth.title": "स्थानीय प्रमाणीकरण",
+ "page.admin.settings.local-auth.enable": "स्थानीय प्रमाणीकरण सक्षम करें",
+ "page.admin.settings.local-auth.help": "उपयोगकर्ताओं को उपयोगकर्ता नाम और पासवर्ड से लॉग इन करने की अनुमति दें।",
+ "page.admin.settings.button.save": "सेटिंग्स सहेजें",
+ "page.admin.users.header": "उपयोगकर्ता प्रबंधन",
+ "page.admin.users.button.add-user": "उपयोगकर्ता जोड़ें",
+ "page.admin.users.table.username": "उपयोगकर्ता नाम",
+ "page.admin.users.table.role": "भूमिका",
+ "page.admin.users.table.teams": "टीमें",
+ "page.admin.users.table.action": "क्रिया",
+ "page.admin.users.table.edit": "संपादित करें",
+ "page.admin.users.table.delete": "हटाएँ",
+ "page.admin.users.modal.edit-user": "उपयोगकर्ता संपादित करें",
+ "page.admin.users.modal.add-user": "उपयोगकर्ता जोड़ें",
+ "page.admin.users.modal.password-help": "(अपरिवर्तित रखने के लिए रिक्त छोड़ें)",
+ "page.admin.users.modal.user-type": "उपयोगकर्ता प्रकार",
+ "page.admin.users.modal.team-access": "टीम पहुँच",
+ "page.admin.users.button.save": "सहेजें",
+ "page.admin.users.button.cancel": "रद्द करें",
+ "page.admin.users.confirm.delete.title": "उपयोगकर्ता हटाएँ",
+ "page.admin.users.confirm.delete.message": "क्या आप वाकई इस उपयोगकर्ता को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
+ "page.admin.users.user-type.admin": "एडमिन",
+ "page.admin.users.user-type.team_lead": "टीम लीड",
+ "page.admin.users.user-type.user": "उपयोगकर्ता",
+ "page.admin.users.modal.password-label": "पासवर्ड",
+ "page.admin.users.table.teams-assigned": "{count} टीमें असाइन की गईं",
+ "page.admin.users.toast.fetch-error": "उपयोगकर्ता प्राप्त करने में विफल",
+ "page.admin.users.toast.update-success": "उपयोगकर्ता सफलतापूर्वक अपडेट किया गया",
+ "page.admin.users.toast.create-success": "उपयोगकर्ता सफलतापूर्वक बनाया गया",
+ "page.admin.users.toast.save-error": "उपयोगकर्ता सहेजने में विफल",
+ "page.admin.users.toast.delete-success": "उपयोगकर्ता सफलतापूर्वक हटाया गया",
+ "page.admin.users.toast.delete-error": "उपयोगकर्ता हटाने में विफल",
+ "page.admin.settings.toast.fetch-error": "सेटिंग्स प्राप्त करने में विफल",
+ "page.admin.settings.toast.save-success": "सेटिंग्स सफलतापूर्वक सहेजी गईं",
+ "page.admin.settings.toast.save-error": "सेटिंग्स सहेजने में विफल",
+ "page.admin.settings.providers.title": "पहचान प्रदाता",
+ "page.admin.settings.providers.help": "सिंगल साइन-ऑन प्रदाता कॉन्फ़िगर करें। परिवर्तन तुरंत प्रभावी होते हैं — पुनरारंभ की आवश्यकता नहीं है।",
+ "page.admin.settings.providers.empty": "अभी तक कोई पहचान प्रदाता कॉन्फ़िगर नहीं किया गया है।",
+ "page.admin.settings.providers.table.name": "नाम",
+ "page.admin.settings.providers.table.id": "आईडी",
+ "page.admin.settings.providers.table.type": "प्रकार",
+ "page.admin.settings.providers.table.enabled": "सक्षम",
+ "page.admin.settings.providers.table.status": "स्थिति",
+ "page.admin.settings.button.add-provider": "प्रदाता जोड़ें",
+ "page.admin.settings.button.save-provider": "प्रदाता सहेजें",
+ "page.admin.settings.confirm.delete.title": "प्रदाता हटाएँ",
+ "page.admin.settings.confirm.delete.message": "क्या आप वाकई इस पहचान प्रदाता को हटाना चाहते हैं? इसके माध्यम से साइन इन करने वाले उपयोगकर्ताओं की पहुँच समाप्त हो जाएगी।",
+ "page.admin.settings.provider.modal.add": "पहचान प्रदाता जोड़ें",
+ "page.admin.settings.provider.modal.edit": "पहचान प्रदाता संपादित करें",
+ "page.admin.settings.provider.name": "प्रदर्शित नाम",
+ "page.admin.settings.provider.name.help": "लॉगिन बटन पर दिखाया जाता है, उदाहरण के लिए \"Company SSO\"।",
+ "page.admin.settings.provider.id": "प्रदाता आईडी",
+ "page.admin.settings.provider.id.help": "2-32 लोअरकेस अक्षर, अंक या हाइफ़न। यह कॉलबैक URL का हिस्सा बनता है और बाद में बदला नहीं जा सकता।",
+ "page.admin.settings.provider.id.help-locked": "यह आईडी पहचान प्रदाता के साथ पंजीकृत कॉलबैक URL का हिस्सा है और बदली नहीं जा सकती।",
+ "page.admin.settings.provider.type": "प्रदाता प्रकार",
+ "page.admin.settings.provider.type.oidc": "OIDC",
+ "page.admin.settings.provider.type.saml": "SAML 2.0",
+ "page.admin.settings.provider.type.ldap": "LDAP / AD",
+ "page.admin.settings.provider.type.help-locked": "बनाए जाने के बाद प्रदाता प्रकार बदला नहीं जा सकता।",
+ "page.admin.settings.provider.enabled": "सक्षम",
+ "page.admin.settings.provider.enabled.help": "लॉगिन पृष्ठ पर यह प्रदाता प्रस्तुत करें।",
+ "page.admin.settings.provider.callback-url": "रीडायरेक्ट URI",
+ "page.admin.settings.provider.acs-url": "ACS (कॉलबैक) URL",
+ "page.admin.settings.provider.callback-url.help": "इस URL को अपने पहचान प्रदाता के साथ पंजीकृत करें।",
+ "page.admin.settings.provider.status.ready": "तैयार",
+ "page.admin.settings.provider.status.error": "त्रुटि",
+ "page.admin.settings.provider.status.enabled": "सक्षम",
+ "page.admin.settings.provider.status.disabled": "अक्षम",
+ "page.admin.settings.provider.default-role": "डिफ़ॉल्ट भूमिका",
+ "page.admin.settings.provider.default-role.deny": "पहुँच अस्वीकार करें",
+ "page.admin.settings.provider.default-role.help": "जब कोई मैपिंग मेल नहीं खाती तब दी जाने वाली भूमिका। पहुँच अस्वीकार करना सुरक्षित डिफ़ॉल्ट है।",
+ "page.admin.settings.provider.secret.help-set": "एक मान कॉन्फ़िगर किया गया है। इसे बनाए रखने के लिए रिक्त छोड़ें, या बदलने के लिए नया मान दर्ज करें।",
+ "page.admin.settings.provider.secret.help-unset": "सहेजे जाने के बाद यह मान दोबारा नहीं देखा जा सकता।",
+ "page.admin.settings.provider.error.id": "प्रदाता आईडी 2-32 लोअरकेस अक्षर, अंक या हाइफ़न होनी चाहिए।",
+ "page.admin.settings.provider.error.duplicate-id": "यह आईडी पहले से किसी अन्य प्रदाता द्वारा उपयोग में है।",
+ "page.admin.settings.provider.error.name": "प्रदर्शित नाम आवश्यक है।",
+ "page.admin.settings.provider.error.mapping-value": "प्रत्येक भूमिका मैपिंग के लिए एक समूह मान आवश्यक है।",
+ "page.admin.settings.oidc.title": "OIDC कॉन्फ़िगरेशन",
+ "page.admin.settings.oidc.issuer": "इश्यूअर URL",
+ "page.admin.settings.oidc.issuer.help": "एंडपॉइंट और साइनिंग कुंजियाँ इश्यूअर के .well-known/openid-configuration से खोजी जाती हैं।",
+ "page.admin.settings.oidc.client-id": "क्लाइंट आईडी",
+ "page.admin.settings.oidc.client-secret": "क्लाइंट सीक्रेट",
+ "page.admin.settings.oidc.scopes": "स्कोप",
+ "page.admin.settings.oidc.scopes.help": "स्पेस से अलग किए गए। \"openid\" हमेशा जोड़ा जाता है। Okta और Keycloak \"groups\" स्वीकार करते हैं; Google इसे अस्वीकार करता है।",
+ "page.admin.settings.oidc.groups-claim": "ग्रुप्स क्लेम",
+ "page.admin.settings.oidc.groups-claim.help": "समूह सदस्यता रखने वाला क्लेम — Okta के लिए \"groups\", Keycloak के लिए अक्सर \"roles\"। Entra ID समूह GUID देता है।",
+ "page.admin.settings.oidc.username-claim": "यूज़रनेम क्लेम",
+ "page.admin.settings.oidc.username-claim.help": "Angles यूज़रनेम के रूप में उपयोग किया जाने वाला क्लेम। विकल्प के रूप में email, preferred_username, फिर sub का उपयोग होता है।",
+ "page.admin.settings.saml.title": "SAML 2.0 कॉन्फ़िगरेशन",
+ "page.admin.settings.saml.metadata-url": "SP मेटाडेटा URL",
+ "page.admin.settings.saml.metadata-url.help": "मान हाथ से दर्ज करने के बजाय इसे अपने पहचान प्रदाता में आयात करें।",
+ "page.admin.settings.saml.entry-point": "IdP SSO URL",
+ "page.admin.settings.saml.entry-point.help": "वह पहचान प्रदाता URL जिस पर ब्राउज़र को रीडायरेक्ट किया जाता है।",
+ "page.admin.settings.saml.idp-cert": "IdP साइनिंग प्रमाणपत्र",
+ "page.admin.settings.saml.idp-cert.help": "आवश्यक — केवल इसी कारण किसी एसर्शन पर भरोसा किया जा सकता है। PEM हेडर पंक्तियों के साथ या बिना स्वीकार किया जाता है।",
+ "page.admin.settings.saml.issuer": "SP एंटिटी आईडी",
+ "page.admin.settings.saml.issuer.help": "इस सेवा प्रदाता की एंटिटी आईडी, जैसा कि पहचान प्रदाता के साथ पंजीकृत है।",
+ "page.admin.settings.saml.private-key": "SP निजी कुंजी",
+ "page.admin.settings.saml.private-key.help": "केवल तब आवश्यक जब पहचान प्रदाता को हस्ताक्षरित प्रमाणीकरण अनुरोध चाहिए। संग्रहीत कुंजी बनाए रखने के लिए रिक्त छोड़ें।",
+ "page.admin.settings.saml.signature-algorithm": "हस्ताक्षर एल्गोरिदम",
+ "page.admin.settings.saml.identifier-format": "Name ID प्रारूप",
+ "page.admin.settings.saml.username-attribute": "यूज़रनेम विशेषता",
+ "page.admin.settings.saml.username-attribute.help": "यूज़रनेम रखने वाली एसर्शन विशेषता। Name ID उपयोग करने के लिए रिक्त छोड़ें।",
+ "page.admin.settings.saml.groups-attribute": "ग्रुप्स विशेषता",
+ "page.admin.settings.saml.response-signed": "हस्ताक्षरित प्रतिक्रिया आवश्यक करें",
+ "page.admin.settings.saml.response-signed.help": "उन पहचान प्रदाताओं के लिए बंद करें जो केवल एसर्शन पर हस्ताक्षर करते हैं। एसर्शन हस्ताक्षर हमेशा आवश्यक होते हैं।",
+ "page.admin.settings.saml.allow-unsolicited": "IdP-आरंभित लॉगिन की अनुमति दें",
+ "page.admin.settings.saml.allow-unsolicited.help": "डिफ़ॉल्ट रूप से बंद: अनचाहे एसर्शन से मिलान करने के लिए कोई अनुरोध नहीं होता, जिससे उसकी रीप्ले सुरक्षा समाप्त हो जाती है।",
+ "page.admin.settings.ldap.title": "LDAP / Active Directory कॉन्फ़िगरेशन",
+ "page.admin.settings.ldap.url": "डायरेक्टरी URL",
+ "page.admin.settings.ldap.url.help": "ldaps:// का उपयोग करें, या StartTLS के साथ ldap://। सादा ldap:// कनेक्शन अस्वीकार किया जाता है क्योंकि पासवर्ड स्पष्ट पाठ में भेजे जाएँगे।",
+ "page.admin.settings.ldap.bind-dn": "बाइंड DN",
+ "page.admin.settings.ldap.bind-dn.help": "उपयोगकर्ता को खोजने के लिए उपयोग किया जाने वाला सेवा खाता। अनाम खोज के लिए रिक्त छोड़ें।",
+ "page.admin.settings.ldap.bind-credentials": "बाइंड पासवर्ड",
+ "page.admin.settings.ldap.search-base": "उपयोगकर्ता खोज आधार",
+ "page.admin.settings.ldap.search-base.help": "उदाहरण के लिए: ou=users,dc=example,dc=com",
+ "page.admin.settings.ldap.search-filter": "उपयोगकर्ता खोज फ़िल्टर",
+ "page.admin.settings.ldap.search-filter.help": "{username} को प्रस्तुत यूज़रनेम से बदल दिया जाता है। Active Directory आमतौर पर (sAMAccountName={username}) का उपयोग करता है।",
+ "page.admin.settings.ldap.username-attribute": "यूज़रनेम विशेषता",
+ "page.admin.settings.ldap.username-attribute.help": "Angles यूज़रनेम के रूप में उपयोग की जाने वाली विशेषता — \"uid\", या Active Directory पर \"sAMAccountName\"।",
+ "page.admin.settings.ldap.group-search-base": "समूह खोज आधार",
+ "page.admin.settings.ldap.group-search-base.help": "उपयोगकर्ता की memberOf विशेषता से समूह सदस्यता पढ़ने के लिए रिक्त छोड़ें, जैसा कि Active Directory आमतौर पर उपलब्ध कराता है।",
+ "page.admin.settings.ldap.group-search-filter": "समूह खोज फ़िल्टर",
+ "page.admin.settings.ldap.group-name-attribute": "समूह नाम विशेषता",
+ "page.admin.settings.ldap.group-name-attribute.help": "पूर्ण DN को इस विशेषता तक घटाया जाता है, इसलिए भूमिकाओं को \"Angles Admins\" जैसे नामों से मैप करें।",
+ "page.admin.settings.ldap.start-tls": "StartTLS का उपयोग करें",
+ "page.admin.settings.ldap.start-tls.help": "सादे ldap:// कनेक्शन को TLS में अपग्रेड करें।",
+ "page.admin.settings.ldap.tls-reject-unauthorized": "TLS प्रमाणपत्र सत्यापित करें",
+ "page.admin.settings.ldap.tls-reject-unauthorized.help": "इसे बंद करने से कनेक्शन आसानी से बीच में पकड़ा जा सकता है। केवल स्व-हस्ताक्षरित लैब सेटअप के लिए।",
+ "page.admin.settings.role-mappings.title": "समूह से भूमिका मैपिंग",
+ "page.admin.settings.role-mappings.help": "प्रदाता द्वारा दिए गए समूहों को Angles भूमिकाओं पर मैप करें। मिलान केस-असंवेदनशील है, और कई मैपिंग से मेल खाने वाले उपयोगकर्ता को सबसे मज़बूत भूमिका दी जाती है (Admin > Team Lead > User)।",
+ "page.admin.settings.role-mappings.empty": "अभी कोई मैपिंग नहीं। इसके बिना, पहुँच नीचे दी गई डिफ़ॉल्ट भूमिका पर निर्भर करती है।",
+ "page.admin.settings.role-mappings.add": "मैपिंग जोड़ें",
+ "page.admin.settings.role-mappings.remove": "मैपिंग हटाएँ",
+ "page.admin.settings.role-mappings.value-placeholder": "समूह नाम या आईडी",
+ "page.team-settings.header": "टीम सेटिंग्स",
+ "page.team-settings.label.team": "कॉन्फ़िगर करने के लिए टीम",
+ "page.team-settings.label.team.help": "इससे केवल यह बदलता है कि आप यहाँ कौन-सी टीम संपादित कर रहे हैं। यह शीर्ष मेनू में चुनी गई टीम को नहीं बदलता।",
+ "page.team-settings.section.details": "टीम विवरण",
+ "page.team-settings.label.name": "टीम नाम",
+ "page.team-settings.button.save-name": "नाम सहेजें",
+ "page.team-settings.section.components": "घटक",
+ "page.team-settings.components.help": "घटक आपको कार्यक्षमता के क्षेत्र के अनुसार अपने टेस्ट कवरेज को समूहित करने देते हैं।",
+ "page.team-settings.label.existing-components": "मौजूदा घटक",
+ "page.team-settings.label.add-components": "घटक जोड़ें",
+ "page.team-settings.components.add-placeholder": "घटक का नाम टाइप करें और एंटर दबाएँ",
+ "page.team-settings.button.add-components": "घटक जोड़ें",
+ "page.team-settings.no-team-access": "आपको अभी तक किसी टीम तक पहुँच नहीं दी गई है। किसी प्रशासक से संपर्क करें।",
+ "page.team-settings.toast.fetch-error": "टीम विवरण प्राप्त करने में विफल",
+ "page.team-settings.toast.name-update-success": "टीम नाम सफलतापूर्वक अपडेट किया गया",
+ "page.team-settings.toast.name-update-error": "टीम नाम अपडेट करने में विफल",
+ "page.team-settings.toast.components-update-success": "घटक सफलतापूर्वक जोड़े गए",
+ "page.team-settings.toast.components-update-error": "घटक जोड़ने में विफल",
+ "nav.manual-test-cases": "मैन्युअल टेस्ट केस",
+ "app.manual.status.draft": "ड्राफ़्ट",
+ "app.manual.status.active": "सक्रिय",
+ "app.manual.status.deprecated": "अप्रचलित",
+ "app.manual.priority.low": "निम्न",
+ "app.manual.priority.medium": "मध्यम",
+ "app.manual.priority.high": "उच्च",
+ "app.manual.priority.critical": "गंभीर",
+ "app.manual.cancel": "रद्द करें",
+ "app.manual.version-badge.version": "v{version}",
+ "app.manual.version-badge.behind": "· नवीनतम से {count} पीछे",
+ "app.manual.history.loading": "इतिहास लोड हो रहा है...",
+ "app.manual.history.empty": "अभी तक कोई परिवर्तन दर्ज नहीं किया गया।",
+ "app.manual.history.unknown-author": "अज्ञात उपयोगकर्ता",
+ "app.manual.history.caused-by": "साझा चरण \"{name}\" में अपडेट के कारण",
+ "app.manual.history.action.create": "बनाया गया",
+ "app.manual.history.action.update": "अपडेट किया गया",
+ "app.manual.history.action.delete": "हटाया गया",
+ "app.manual.history.action.clone": "क्लोन किया गया",
+ "app.manual.history.action.status-change": "स्थिति बदली गई",
+ "app.manual.history.action.shared-step-update": "साझा चरण अपडेट किया गया",
+ "app.manual.history.action.archive": "संग्रहित किया गया",
+ "app.manual.step-editor.expected": "अपेक्षित",
+ "app.manual.step-editor.data": "डेटा",
+ "app.manual.step-editor.shared-step": "साझा चरण",
+ "app.manual.step-editor.shared-step-placeholder": "एक साझा चरण चुनें",
+ "app.manual.step-editor.shared-step-help": "टेस्ट केस चलाने या संस्करण सहेजे जाने पर इसके चरण इस चरण की जगह ले लेते हैं।",
+ "app.manual.step-editor.from-shared-step": "साझा चरण से (v{version})",
+ "app.manual.step-editor.unresolved": "साझा चरण अब मौजूद नहीं है",
+ "app.manual.step-editor.action-placeholder": "परीक्षक को क्या करना चाहिए?",
+ "app.manual.step-editor.expected-placeholder": "क्या होना चाहिए?",
+ "app.manual.step-editor.data-placeholder": "टेस्ट डेटा (वैकल्पिक)",
+ "app.manual.step-editor.add-step": "चरण जोड़ें",
+ "app.manual.step-editor.add-shared-step": "साझा चरण शामिल करें",
+ "app.manual.step-editor.remove": "चरण हटाएँ",
+ "app.manual.step-editor.empty": "अभी कोई चरण नहीं। नीचे पहला चरण जोड़ें।",
+ "app.manual.custom-field.archived": "(संग्रहित)",
+ "page.manual-test-cases": "मैन्युअल टेस्ट केस",
+ "page.manual-test-cases.create": "नया टेस्ट केस",
+ "page.manual-test-cases.new-case-title": "बिना शीर्षक का टेस्ट केस",
+ "page.manual-test-cases.loading": "टेस्ट केस लोड हो रहे हैं...",
+ "page.manual-test-cases.empty": "इन फ़िल्टर से कोई टेस्ट केस मेल नहीं खाता।",
+ "page.manual-test-cases.no-team": "इसके मैन्युअल टेस्ट केस देखने के लिए पृष्ठ के शीर्ष पर स्थित मेनू से एक टीम चुनें।",
+ "page.manual-test-cases.filters.search": "शीर्षक या विवरण खोजें",
+ "page.manual-test-cases.filters.status": "स्थिति",
+ "page.manual-test-cases.filters.priority": "प्राथमिकता",
+ "page.manual-test-cases.table.title": "शीर्षक",
+ "page.manual-test-cases.table.status": "स्थिति",
+ "page.manual-test-cases.table.priority": "प्राथमिकता",
+ "page.manual-test-cases.table.version": "संस्करण",
+ "page.manual-test-cases.table.updated-by": "अपडेट करने वाला",
+ "page.manual-test-cases.table.steps": "चरण",
+ "page.manual-test-cases.toast.fetch-error": "टेस्ट केस लोड नहीं हो सके।",
+ "page.manual-test-cases.toast.create-error": "टेस्ट केस बनाया नहीं जा सका।",
+ "page.manual-test-case-detail.loading": "टेस्ट केस लोड हो रहा है...",
+ "page.manual-test-case-detail.save": "सहेजें",
+ "page.manual-test-case-detail.clone": "क्लोन करें",
+ "page.manual-test-case-detail.delete": "हटाएँ",
+ "page.manual-test-case-detail.tab.details": "विवरण",
+ "page.manual-test-case-detail.tab.steps": "चरण",
+ "page.manual-test-case-detail.tab.versions": "संस्करण",
+ "page.manual-test-case-detail.tab.history": "इतिहास",
+ "page.manual-test-case-detail.field.title": "शीर्षक",
+ "page.manual-test-case-detail.field.description": "विवरण",
+ "page.manual-test-case-detail.field.preconditions": "पूर्व-शर्तें",
+ "page.manual-test-case-detail.field.status": "स्थिति",
+ "page.manual-test-case-detail.field.priority": "प्राथमिकता",
+ "page.manual-test-case-detail.field.tags": "टैग",
+ "page.manual-test-case-detail.status-help": "आवश्यक कस्टम फ़ील्ड तभी लागू होते हैं जब कोई केस ड्राफ़्ट से बाहर निकलता है।",
+ "page.manual-test-case-detail.custom-fields": "कस्टम फ़ील्ड",
+ "page.manual-test-case-detail.select-version": "एक संस्करण चुनें",
+ "page.manual-test-case-detail.version-option": "संस्करण {version}",
+ "page.manual-test-case-detail.versions-help": "यह देखने के लिए कोई संस्करण चुनें कि निष्पादन के समय परीक्षक ने वास्तव में क्या देखा था।",
+ "page.manual-test-case-detail.delete-confirm-title": "टेस्ट केस हटाएँ",
+ "page.manual-test-case-detail.delete-confirm-message": "इससे टेस्ट केस और उसका पूरा संस्करण इतिहास हट जाएगा। इसे पूर्ववत नहीं किया जा सकता।",
+ "page.manual-test-case-detail.toast.fetch-error": "टेस्ट केस लोड नहीं हो सका।",
+ "page.manual-test-case-detail.toast.save-error": "टेस्ट केस सहेजा नहीं जा सका।",
+ "page.manual-test-case-detail.toast.clone-error": "टेस्ट केस क्लोन नहीं किया जा सका।",
+ "page.manual-test-case-detail.toast.delete-error": "टेस्ट केस हटाया नहीं जा सका।",
+ "page.manual-test-case-detail.toast.version-error": "वह संस्करण लोड नहीं हो सका।",
+ "page.manual-test-case-detail.toast.history-error": "परिवर्तन इतिहास लोड नहीं हो सका।",
+ "app.execution-type.all": "सभी",
+ "app.execution-type.automated": "स्वचालित",
+ "app.execution-type.manual": "मैन्युअल",
+ "page.dashboard.filters.label.execution-type": "प्रकार",
+ "page.metrics.filters.labels.execution-type": "प्रकार",
+ "app.manual.save": "सहेजें",
+ "app.manual.delete": "हटाएँ",
+ "app.manual.edit": "संपादित करें",
+ "app.manual.actions": "क्रियाएँ",
+ "app.manual.loading": "लोड हो रहा है...",
+ "app.manual.yes": "हाँ",
+ "app.manual.no": "नहीं",
+ "app.manual.no-team": "जारी रखने के लिए पृष्ठ के शीर्ष पर स्थित मेनू से एक टीम चुनें।",
+ "app.manual.field.description": "विवरण",
+ "app.manual.field.preconditions": "पूर्व-शर्तें",
+ "app.manual.field.priority": "प्राथमिकता",
+ "app.manual.field.tags": "टैग",
+ "app.manual.field.title": "शीर्षक",
+ "app.manual.field.expected": "अपेक्षित",
+ "app.manual.field.steps": "चरण",
+ "app.manual.attachments.drop-hint": "यहाँ कोई छवि छोड़ें, या चुनने के लिए क्लिक करें",
+ "app.manual.attachments.uploading": "अपलोड हो रहा है...",
+ "app.manual.attachments.upload-error": "अनुलग्नक अपलोड नहीं हो सका।",
+ "app.manual.attachments.delete-error": "अनुलग्नक हटाया नहीं जा सका। संभव है किसी फ़्रोज़न संस्करण में इसका संदर्भ हो।",
+ "app.manual.attachments.copy-reference": "मार्कडाउन संदर्भ कॉपी करें",
+ "app.manual.attachments.reference-copied": "संदर्भ क्लिपबोर्ड पर कॉपी किया गया।",
+ "app.manual.version-diff.identical": "इन दोनों संस्करणों की सामग्री समान है।",
+ "app.manual.version-diff.field": "फ़ील्ड",
+ "app.manual.version-diff.before": "पहले",
+ "app.manual.version-diff.after": "बाद में",
+ "app.manual.version-diff.empty": "(रिक्त)",
+ "app.manual.version-diff.step": "चरण {number}",
+ "app.manual.custom-field.type.text": "टेक्स्ट",
+ "app.manual.custom-field.type.textarea": "लंबा टेक्स्ट",
+ "app.manual.custom-field.type.number": "संख्या",
+ "app.manual.custom-field.type.date": "तिथि",
+ "app.manual.custom-field.type.boolean": "हाँ / नहीं",
+ "app.manual.custom-field.type.select": "एकल चयन",
+ "app.manual.custom-field.type.multiselect": "बहु चयन",
+ "app.manual.custom-field.type.user": "उपयोगकर्ता",
+ "app.manual.custom-field.scope.testcase": "टेस्ट केस",
+ "app.manual.custom-field.scope.execution": "निष्पादन",
+ "app.manual.custom-field.scope.both": "दोनों",
+ "app.manual.run-status.planned": "नियोजित",
+ "app.manual.run-status.in_progress": "प्रगति पर",
+ "app.manual.run-status.completed": "पूर्ण",
+ "app.manual.run-status.cancelled": "रद्द",
+ "app.manual.case-status.pass": "पास",
+ "app.manual.case-status.fail": "विफल",
+ "app.manual.case-status.error": "त्रुटि",
+ "app.manual.case-status.skipped": "छोड़ा गया",
+ "app.manual.case-status.blocked": "अवरुद्ध",
+ "app.manual.case-status.not_run": "नहीं चलाया गया",
+ "app.manual.case-status.in_progress": "प्रगति पर",
+ "app.manual.step-status.not_run": "नहीं चलाया गया",
+ "app.manual.step-status.pass": "पास",
+ "app.manual.step-status.fail": "विफल",
+ "app.manual.step-status.blocked": "अवरुद्ध",
+ "app.manual.step-status.skipped": "छोड़ा गया",
+ "nav.manual-testing": "मैन्युअल परीक्षण",
+ "nav.manual-test-runs": "टेस्ट रन",
+ "nav.shared-steps": "साझा चरण",
+ "nav.admin.custom-fields": "कस्टम फ़ील्ड",
+ "page.admin-custom-fields.title": "कस्टम फ़ील्ड",
+ "page.admin-custom-fields.help": "इस टीम के हर मैन्युअल टेस्ट केस पर दिखाए जाने वाले अतिरिक्त फ़ील्ड। आवश्यक फ़ील्ड तभी लागू होते हैं जब कोई केस ड्राफ़्ट से बाहर निकलता है।",
+ "page.admin-custom-fields.button.add": "फ़ील्ड जोड़ें",
+ "page.admin-custom-fields.archived": "संग्रहित",
+ "page.admin-custom-fields.header.label": "लेबल",
+ "page.admin-custom-fields.header.key": "कुंजी",
+ "page.admin-custom-fields.header.type": "प्रकार",
+ "page.admin-custom-fields.header.required": "आवश्यक",
+ "page.admin-custom-fields.header.applies-to": "लागू होता है",
+ "page.admin-custom-fields.modal.add-title": "कस्टम फ़ील्ड जोड़ें",
+ "page.admin-custom-fields.modal.edit-title": "कस्टम फ़ील्ड संपादित करें",
+ "page.admin-custom-fields.form.label": "लेबल",
+ "page.admin-custom-fields.form.key": "कुंजी",
+ "page.admin-custom-fields.form.key-help": "लोअरकेस अक्षर, अंक और अंडरस्कोर। फ़ील्ड बनने के बाद बदला नहीं जा सकता।",
+ "page.admin-custom-fields.form.key-invalid": "लोअरकेस अक्षर से शुरू होना चाहिए और इसमें केवल लोअरकेस अक्षर, अंक और अंडरस्कोर हों (अधिकतम 40 वर्ण)।",
+ "page.admin-custom-fields.form.type": "प्रकार",
+ "page.admin-custom-fields.form.options": "विकल्प",
+ "page.admin-custom-fields.form.applies-to": "लागू होता है",
+ "page.admin-custom-fields.form.order": "प्रदर्शन क्रम",
+ "page.admin-custom-fields.form.required": "आवश्यक",
+ "page.admin-custom-fields.form.required-help": "केवल तब लागू जब कोई टेस्ट केस ड्राफ़्ट से बाहर निकलता है।",
+ "page.admin-custom-fields.delete-confirm-title": "कस्टम फ़ील्ड हटाएँ",
+ "page.admin-custom-fields.delete-confirm-message": "यदि किसी टेस्ट केस में अब भी इस फ़ील्ड का मान है तो इसे हटाने के बजाय संग्रहित किया जाएगा, ताकि मौजूदा सामग्री पढ़ी जा सके।",
+ "page.admin-custom-fields.toast.fetch-error": "कस्टम फ़ील्ड लोड नहीं हो सके।",
+ "page.admin-custom-fields.toast.save-error": "कस्टम फ़ील्ड सहेजा नहीं जा सका।",
+ "page.admin-custom-fields.toast.delete-error": "कस्टम फ़ील्ड हटाया नहीं जा सका।",
+ "page.admin-custom-fields.toast.archived": "यह फ़ील्ड उपयोग में है, इसलिए इसे हटाने के बजाय संग्रहित किया गया।",
+ "page.admin-custom-fields.toast.deleted": "कस्टम फ़ील्ड हटाया गया।",
+ "page.shared-steps.title": "साझा चरण",
+ "page.shared-steps.help": "पुन: उपयोग योग्य चरण अनुक्रम जिन्हें कई टेस्ट केस शामिल कर सकते हैं। किसी एक को संपादित करने पर उसे शामिल करने वाले हर टेस्ट केस का नया संस्करण बनता है।",
+ "page.shared-steps.button.add": "साझा चरण जोड़ें",
+ "page.shared-steps.search-placeholder": "साझा चरण खोजें",
+ "page.shared-steps.header.name": "नाम",
+ "page.shared-steps.header.description": "विवरण",
+ "page.shared-steps.header.steps": "चरण",
+ "page.shared-steps.header.version": "संस्करण",
+ "page.shared-steps.action.usage": "उपयोग",
+ "page.shared-steps.action.history": "इतिहास",
+ "page.shared-steps.modal.add-title": "साझा चरण जोड़ें",
+ "page.shared-steps.modal.edit-title": "साझा चरण संपादित करें",
+ "page.shared-steps.form.name": "नाम",
+ "page.shared-steps.form.description": "विवरण",
+ "page.shared-steps.cascade-warning": "{count} टेस्ट केस इस साझा चरण को शामिल करते हैं। सहेजने पर उनमें से प्रत्येक का नया संस्करण बनेगा।",
+ "page.shared-steps.usage.title": "इस साझा चरण का उपयोग करने वाले टेस्ट केस",
+ "page.shared-steps.usage.empty": "अभी तक कोई टेस्ट केस इस साझा चरण को शामिल नहीं करता।",
+ "page.shared-steps.history.title": "साझा चरण इतिहास",
+ "page.shared-steps.delete-confirm-title": "साझा चरण हटाएँ",
+ "page.shared-steps.delete-confirm-message": "जिस साझा चरण का संदर्भ अब भी किसी टेस्ट केस में है, उसे हटाया नहीं जा सकता।",
+ "page.shared-steps.toast.fetch-error": "साझा चरण लोड नहीं हो सके।",
+ "page.shared-steps.toast.save-error": "साझा चरण सहेजा नहीं जा सका।",
+ "page.shared-steps.toast.delete-error": "साझा चरण हटाया नहीं जा सका।",
+ "page.shared-steps.toast.usage-error": "उपयोग सूची लोड नहीं हो सकी।",
+ "page.shared-steps.toast.history-error": "इतिहास लोड नहीं हो सका।",
+ "page.shared-steps.toast.cascaded": "{count} टेस्ट केस को नया संस्करण दिया गया।",
+ "page.manual-test-runs.title": "मैन्युअल टेस्ट रन",
+ "page.manual-test-runs.button.add": "नया टेस्ट रन",
+ "page.manual-test-runs.button.create": "रन बनाएँ",
+ "page.manual-test-runs.filter.status": "स्थिति",
+ "page.manual-test-runs.header.name": "नाम",
+ "page.manual-test-runs.header.status": "स्थिति",
+ "page.manual-test-runs.header.progress": "प्रगति",
+ "page.manual-test-runs.header.environment": "एनवायरनमेंट",
+ "page.manual-test-runs.modal.title": "नया टेस्ट रन",
+ "page.manual-test-runs.form.name": "नाम",
+ "page.manual-test-runs.form.environment": "एनवायरनमेंट",
+ "page.manual-test-runs.form.component": "कंपोनेंट",
+ "page.manual-test-runs.form.component-help": "एक रन केवल एक कंपोनेंट को कवर करता है। किसी अन्य कंपोनेंट के टेस्ट केस नहीं जोड़े जा सकते।",
+ "page.manual-test-runs.form.test-cases": "टेस्ट केस",
+ "page.manual-test-runs.form.test-cases-help": "प्रत्येक केस उसके वर्तमान संस्करण से बंधा होता है, इसलिए बाद के संपादन इस रन को नहीं बदलेंगे।",
+ "page.manual-test-runs.delete-confirm-title": "टेस्ट रन हटाएँ",
+ "page.manual-test-runs.delete-confirm-message": "इससे रन और उसके विरुद्ध दर्ज किए गए परिणाम हट जाएँगे।",
+ "page.manual-test-runs.toast.fetch-error": "टेस्ट रन लोड नहीं हो सके।",
+ "page.manual-test-runs.toast.cases-error": "उपलब्ध टेस्ट केस लोड नहीं हो सके।",
+ "page.manual-test-runs.toast.create-error": "टेस्ट रन बनाया नहीं जा सका।",
+ "page.manual-test-runs.toast.delete-error": "टेस्ट रन हटाया नहीं जा सका।",
+ "page.manual-test-run-detail.not-found": "वह टेस्ट रन नहीं मिल सका।",
+ "page.manual-test-run-detail.button.start": "रन शुरू करें",
+ "page.manual-test-run-detail.button.complete": "रन पूर्ण करें",
+ "page.manual-test-run-detail.button.record": "परिणाम दर्ज करें",
+ "page.manual-test-run-detail.button.rebind": "नवीनतम संस्करण पर ले जाएँ",
+ "page.manual-test-run-detail.case-result": "केस परिणाम",
+ "page.manual-test-run-detail.actual-placeholder": "वास्तव में क्या हुआ?",
+ "page.manual-test-run-detail.notes-placeholder": "टिप्पणियाँ (वैकल्पिक)",
+ "page.manual-test-run-detail.view-build": "बिल्ड देखें",
+ "page.manual-test-run-detail.view-version": "परीक्षण किया जा रहा संस्करण देखें",
+ "page.manual-test-run-detail.toast.fetch-error": "टेस्ट रन लोड नहीं हो सका।",
+ "page.manual-test-run-detail.toast.version-error": "परीक्षण किया जा रहा संस्करण लोड नहीं हो सका।",
+ "page.manual-test-run-detail.toast.record-error": "परिणाम दर्ज नहीं किया जा सका।",
+ "page.manual-test-run-detail.toast.recorded": "परिणाम दर्ज किया गया।",
+ "page.manual-test-run-detail.toast.rebind-error": "इस केस को नवीनतम संस्करण पर नहीं ले जाया जा सका। जिस केस का परिणाम पहले से दर्ज है उसे नहीं ले जाया जा सकता।",
+ "page.manual-test-run-detail.toast.status-error": "रन की स्थिति बदली नहीं जा सकी।",
+ "page.manual-test-case-version.not-found": "वह संस्करण नहीं मिल सका।",
+ "page.manual-test-case-version.back-to-case": "टेस्ट केस पर वापस जाएँ",
+ "page.manual-test-case-version.frozen-notice": "यह एक फ़्रोज़न संस्करण है। यह ठीक वही दिखाता है जो उस समय लिखा गया था और कभी नहीं बदलता।",
+ "page.manual-test-case-version.compare-with": "इससे तुलना करें",
+ "page.manual-test-case-version.toast.fetch-error": "वह संस्करण लोड नहीं हो सका।",
+ "page.manual-test-case-version.toast.compare-error": "तुलना के लिए संस्करण लोड नहीं हो सका।",
+ "page.manual-test-case-detail.toast.saved-new-version": "संस्करण {version} के रूप में सहेजा गया।",
+ "page.manual-test-case-detail.toast.saved-no-change": "सहेजा गया। सामग्री नहीं बदली, इसलिए कोई नया संस्करण नहीं बनाया गया।",
+ "page.shared-steps.toast.saved": "साझा चरण सहेजा गया।",
+ "page.shared-steps.toast.created": "साझा चरण बनाया गया।",
+ "page.admin-custom-fields.toast.created": "कस्टम फ़ील्ड बनाया गया।",
+ "page.admin-custom-fields.toast.updated": "कस्टम फ़ील्ड अपडेट किया गया।",
+ "app.manual.folders.title": "फ़ोल्डर",
+ "app.manual.folders.all": "सभी टेस्ट केस",
+ "app.manual.folders.unfiled": "किसी फ़ोल्डर में नहीं",
+ "app.manual.folders.add-root": "शीर्ष-स्तरीय फ़ोल्डर जोड़ें",
+ "app.manual.folders.add-sub": "उप-फ़ोल्डर जोड़ें",
+ "app.manual.folders.rename": "नाम बदलें",
+ "app.manual.folders.delete": "हटाएँ",
+ "app.manual.folders.name": "फ़ोल्डर नाम",
+ "app.manual.folders.create-title": "नया फ़ोल्डर",
+ "app.manual.folders.rename-title": "फ़ोल्डर का नाम बदलें",
+ "app.manual.folders.creating-under": "\"{name}\" के अंदर बनाया जा रहा है।",
+ "app.manual.folders.delete-confirm-title": "फ़ोल्डर हटाएँ",
+ "app.manual.folders.delete-confirm-message": "फ़ोल्डर खाली होने पर ही हटाया जा सकता है। पहले उसके अंदर की सामग्री हटाएँ या कहीं और ले जाएँ।",
+ "page.manual-test-cases.toast.folders-error": "फ़ोल्डर लोड नहीं हो सके।",
+ "page.manual-test-cases.toast.folder-create-error": "फ़ोल्डर बनाया नहीं जा सका।",
+ "page.manual-test-cases.toast.folder-rename-error": "फ़ोल्डर का नाम बदला नहीं जा सका।",
+ "page.manual-test-cases.toast.folder-delete-error": "फ़ोल्डर हटाया नहीं जा सका।",
+ "page.manual-test-case-detail.field.folder": "फ़ोल्डर",
+ "page.manual-test-case-detail.folder-root": "किसी फ़ोल्डर में नहीं",
+ "app.manual.history.action.move": "फ़ोल्डर में ले जाया गया",
+ "app.manual.step-editor.action": "क्रिया",
+ "app.manual.step-editor.attachments": "अनुलग्नक",
+ "app.manual.step-editor.attachments-need-save": "पहले टेस्ट केस सहेजें, उसके बाद उसके चरणों में छवियाँ जोड़ी जा सकती हैं।",
+ "app.manual.step-editor.drag-handle": "क्रम बदलने के लिए खींचें",
+ "app.manual.step-editor.drag-help": "क्रम बदलने के लिए किसी चरण को खींचें।",
+ "app.manual.attachments.load-error": "छवि लोड नहीं हो सकी।",
+ "app.manual.folders.unknown": "अन्य फ़ोल्डर",
+ "page.manual-test-cases.toast.move-success": "टेस्ट केस स्थानांतरित किया गया।",
+ "page.manual-test-cases.toast.move-error": "टेस्ट केस स्थानांतरित नहीं किया जा सका।",
+ "page.admin.settings.features.header": "सुविधा प्रबंधन",
+ "page.admin.settings.features.help": "इस इंस्टेंस के लिए वैकल्पिक सुविधाएँ चालू या बंद करें। परिवर्तन तुरंत प्रभावी होते हैं — पुनरारंभ की आवश्यकता नहीं है।",
+ "page.admin.settings.features.manual-testing": "टेस्ट केस प्रबंधन सक्षम करें",
+ "page.admin.settings.features.manual-testing.help": "मैन्युअल टेस्ट केस, टेस्ट रन और साझा चरण। अक्षम होने पर ये पृष्ठ छिपे रहते हैं और उनका डेटा पहुँच से बाहर हो जाता है, लेकिन कुछ भी हटाया नहीं जाता — पुनः सक्षम करने पर सब कुछ वापस आ जाता है।",
+ "app.feature.disabled": "यह सुविधा इस इंस्टेंस पर सक्षम नहीं है। यदि आपको इस तक पहुँच चाहिए तो किसी व्यवस्थापक से संपर्क करें।",
+ "nav.team": "टीम",
+ "nav.team.none": "कोई चयन नहीं"
}
diff --git a/src/translations/nl.json b/src/translations/nl.json
index 614515e..f91ff78 100644
--- a/src/translations/nl.json
+++ b/src/translations/nl.json
@@ -1,515 +1,811 @@
{
- "nav.dashboard": "Dashboard",
- "nav.execution-metrics": "Statistieken ",
- "nav.screenshot-library": "Schermafbeeldingen",
- "nav.language": "Taal",
- "nav.theme": "Thema",
- "nav.theme.light": "Licht",
- "nav.theme.dark": "Donker",
- "nav.about": "Over Angles",
- "nav.team-settings": "Team Instellingen",
- "nav.admin": "Beheer",
- "nav.admin.users": "Gebruikers",
- "nav.admin.settings": "Instellingen",
- "nav.profile": "Profiel",
- "nav.user-settings": "Instellingen",
- "nav.logout": "Uitloggen",
- "app.utils.duration.seconds": "{seconds} seconden",
- "app.utils.duration.minutes": "{minutes} minuten",
- "app.utils.duration.hours": "{hours} uren",
- "app.utils.duration.not-started": "Nog niet begonnen",
- "app.utils.date-range-picker.number-of-days": "Laatste {days} dagen",
- "app.retrieving-teams": "Teams aan het ophalen...",
- "app.retrieving-teams-error": "Er is iets misgegaan [{teamsError}]",
- "app.result.pass": "Geslaagd",
- "app.result.fail": "Mislukt",
- "app.result.skipped": "Overgeslagen",
- "app.result.error": "Fout",
- "app.result.total": "Totaal",
- "page.home.bread-crumb": "Home",
- "page.dashboard.message.retrieving-builds": "Testuitvoeringen aan het ophalen voor {teamName}...",
- "page.dashboard.filters.label.team": "Team",
- "page.dashboard.filters.label.period": "Periode",
- "page.dashboard.filters.label.display-limit": "Weergave limiet",
- "page.dashboard.filters.label.environments": "Omgeving",
- "page.dashboard.filters.label.components": "Onderdelen",
- "page.dashboard.panel.total-test-runs": "Totaal aantal testuitvoeringen",
- "page.dashboard.panel.total-test-executions": "Totaal aantal tests",
- "page.dashboard.panel.total-execution-time": "Totale uitvoertijd",
- "page.dashboard.chart.barchart.pass": "Geslaagd",
- "page.dashboard.chart.barchart.skipped": "Overgeslagen",
- "page.dashboard.chart.barchart.error": "Fout",
- "page.dashboard.chart.barchart.fail": "Mislukt",
- "page.dashboard.chart.barchart.execution-time-seconds": "Executie tijd (seconden)",
- "page.dashboard.chart.barchart.yaxis.number-of-tests": "Aantal tests",
- "page.dashboard.builds-table.header.build-details": "Details van testuitvoering",
- "page.dashboard.builds-table.header.status": "Status",
- "page.dashboard.builds-table.header.date-time": "Datum/Tijd",
- "page.dashboard.builds-table.header.result": "Resultaat",
- "page.dashboard.builds-table.header.flags": "Markeringen",
- "page.dashboard.builds-table.not-available": "N.v.t.",
- "page.dashboard.builds-table.artifacts.title": "Artefacten",
- "page.dashboard.builds-table.artifacts.count": "{count, plural, one {# artefact} other {# artefacten}}",
- "page.dashboard.builds-table.keep.enabled": "Bewaarmarkering ingeschakeld - deze testrun wordt niet opgeruimd",
- "page.dashboard.builds-table.keep.disabled": "Bewaarmarkering uitgeschakeld - deze testrun kan tijdens het opruimen worden verwijderd",
- "page.dashboard.menu.compare-test-runs": "Vergelijk testuitvoeringen",
- "page.dashboard.menu.toggle-keep-flag": "Schakel houd vlag om",
- "page.dashboard.menu.clear-selection": "Verwijder selectie",
- "page.dashboard.menu.delete-build": "Build verwijderen",
- "page.dashboard.menu.one-select-message": "Selecteer tenminste 1 testuitvoering",
- "page.dashboard.confirm.delete-build.title": "Build verwijderen",
- "page.dashboard.confirm.delete-build.message": "Weet u zeker dat u de {count} geselecteerde build(s) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt en verwijdert permanent alle bijbehorende uitvoeringen en screenshots.",
- "page.dashboard.confirm.delete-build.cancel": "Annuleren",
- "page.dashboard.toast.delete-build-success": "{count} build(s) succesvol verwijderd",
- "page.dashboard.toast.delete-build-error": "Verwijderen van build mislukt",
- "page.dashboard.execution-barchart.execution-time-seconds": "Uitvoertijd (seconden)",
- "page.dashboard.execution-bar-chart.title": "Testuitvoeringen",
- "page.dashboard.execution-pie-chart.title": "Algemene status overzicht testuitvoeringen",
- "page.metrics.bread-crumb": "Statistieken",
- "page.metrics.filters.labels.team": "Team",
- "page.metrics.filters.labels.component": "Onderdeel",
- "page.metrics.filters.labels.period": "Periode",
- "page.metrics.filters.labels.group-by-period": "Weergave periode",
- "page.metrics.filters.button.retrieve-metrics": "Statistieken ophalen",
- "page.metrics.tab.execution-metrics": "Executie statistieken",
- "page.metrics.tab.platform-metrics": "Platform statistieken",
- "page.metrics.execution-metrics-summary.label.period": "Periode",
- "page.metrics.execution-metrics-summary.label.number-of-tests": "Aantal test gevallen",
- "page.metrics.execution-metrics-summary.label.number-of-runs": "Aantal testuitvoeringen",
- "page.metrics.execution-metrics-summary.label.number-of-executions": "Aantal test executies",
- "page.metrics.execution-metrics-summary.label.result": "Resultaat",
- "page.metrics.execution-metrics-summary.label.duration": "Looptijd",
- "page.metrics.execution-metrics-bar-chart.title": "Executie statistieken per resultaat",
- "page.metrics.execution-metrics-bar-chart.yaxis-title": "Aantal executies",
- "page.metrics.execution-metrics-phase-bar-chart.title": "Executie statistieken per fase",
- "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "Aantal executies",
- "page.metrics.platform-distribution-pie-chart.title": "Executies per platform",
- "page.metrics.platform-distribution-bar-chart.title": "Executies per platform",
- "page.metrics.platform-distribution-bar-chart.yaxis-title": "Platforms",
- "page.metrics.platform-distribution-bar-chart.xaxis-title": "Aantal executies",
- "page.metrics.platform-metrics-summary.label.platform": "Platform",
- "page.metrics.platform-metrics-summary.label.version": "Versie",
- "page.metrics.platform-metrics-summary.label.device": "Aparaat",
- "page.metrics.platform-metrics-summary.label.browser": "Browser",
- "page.metrics.platform-metrics-summary.label.total": "Totaal",
- "page.metrics.platform-metrics-summary.label.result": "Resultaat",
- "page.metrics.platform-metrics-summary.label.duration": "Looptijd",
- "page.metrics.metrics-test-table.label.unique-testcases": "Unieke test gevallen per fase",
- "page.metrics.metrics-test-table.label.phase": "Fase",
- "page.metrics.metrics-test-table.label.test": "Test",
- "page.metrics.metrics-test-table.no-tests-found": "Geen testgevallen gevonden",
- "page.test-run.bread-crumb": "Testuitvoering",
- "page.test-run.steps-header.start": "Start",
- "page.test-run.steps-header.duration": "Looptijd",
- "page.test-run.steps-header.end": "Einde",
- "page.test-run.menu.enable-keep-flag": "Schakel de \"houd\" vlag in",
- "page.test-run.menu.disable-keep-flag": "Schakel de \"houd\" vlag uit",
- "page.test-run.menu.download-report": "Raport downloaden als html",
- "page.test-run.menu.delete-build": "Build verwijderen",
- "page.test-run.confirm.delete-build.title": "Build verwijderen",
- "page.test-run.confirm.delete-build.message": "Weet u zeker dat u deze build wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt en verwijdert permanent alle bijbehorende uitvoeringen en screenshots.",
- "page.test-run.confirm.delete-build.cancel": "Annuleren",
- "page.test-run.toast.delete-build-success": "Build succesvol verwijderd",
- "page.test-run.toast.delete-build-error": "Verwijderen van build mislukt",
- "page.test-run.icons.environment.whisper": "Omgeving",
- "page.test-run.icons.team.whisper": "Team",
- "page.test-run.icons.component.whisper": "Onderdeel",
- "page.test-run.icons.phase.whisper": "Test Fase",
- "page.test-run.icons.artifacts.whisper": "Aantal artefacten dat aan deze testuitvoering is gekoppeld.",
- "page.test-run.icons.keep-lock.whisper": "Indien vergrendeld, wordt een test run niet opgeschoond door de nachtelijke cronjob.",
- "page.test-run.icons.screenshots.whisper": "Deze uitvoering heeft {numberOfScreenshots} schermafbeeldingen",
- "page.test-run.icons.download-report.whisper": "Download het HTML-rapport voor deze test run",
- "page.test-run.header.status.whisper": "Status: {status}",
- "page.test-run.header.execution-count": "{count, plural, one {# uitvoering} other {# uitvoeringen}}",
- "page.test-run.header.fact.not-set": "Niet ingesteld",
- "page.test-run.header.artifacts-title": "Artefacten",
- "page.test-run.suites.title": "Testsuites",
- "page.test-run.loading": "Bezig met ophalen van buildgegevens.",
- "page.test-run.error.load-failed": "Kan de buildgegevens niet ophalen. Vernieuw de pagina en probeer het opnieuw en/of controleer of het build-id geldig is.",
- "page.test-run.execution-pie-chart.title": "Executie statistiek",
- "page.test-run.feature-distribution-pie-chart.title": "Componenten statistiek",
- "page.test-run.execution-pie-chart.pass-rate": "Slaagpercentage",
- "page.test-run.feature-distribution-pie-chart.unspecified": "Niet opgegeven",
- "page.test-run.feature-distribution-pie-chart.executions": "Uitvoeringen",
- "page.test-run-compare.bread-crumb": "Vergelijk testuitvoeringen",
- "page.test-run-compare.header.number": "#",
- "page.test-run-compare.header.test-name": "Testnaam",
- "page.test-run-compare.detail.start-date": "Startdatum",
- "page.test-run-compare.detail.environment": "Omgeving",
- "page.test-run-compare.cell.not-available": "N.v.t.",
- "page.test-run-compare.cell.multiple-executions": "Meerdere uitvoeringen",
- "page.test-run-compare.popover.details-title": "Details",
- "page.test-run-compare.link.execution-history": "Bekijk uitvoeringsgeschiedenis voor {testName}",
- "page.test-run-compare.details-popover.header.property": "Eigenschap",
- "page.test-run-compare.details-popover.header.value": "Waarde",
- "page.test-run-compare.details-popover.property.status": "Status",
- "page.test-run-compare.details-popover.property.duration": "Duur",
- "page.test-run-compare.details-popover.property.platform": "Platform",
- "page.test-run-compare.details-popover.property.failing-step": "Mislukte stap",
- "page.test-run-compare.loading": "Details van testuitvoeringen ophalen om de vergelijking te genereren...",
- "page.test-run-compare.section.artifacts": "Artifactversies",
- "page.test-run-compare.artifacts.count": "{count, plural, one {# artifact} other {# artifacten}}",
- "page.test-run-compare.section.compare-matrix": "Testvergelijkingsmatrix",
- "page.test-run-compare.toolbar.status-filter": "Filteren op status",
- "page.test-run-compare.suite.tests": "{count, plural, one {# test} other {# tests}}",
- "page.test-run-compare.suite.rollup": "{passed} van {total} geslaagd",
- "page.test-run-compare.header.run-summary": "{passed} van {total} geslaagd, {failed} mislukt, {errors} met fouten",
- "page.test-run-compare.cell.status-changed": "Gewijzigd ten opzichte van de vorige uitvoering",
- "page.test-run-compare.warning.many-runs": "Je vergelijkt {count} testuitvoeringen — gebruik de filters hieronder om op de verschillen te focussen.",
- "page.screenshot-library.bread-crumb": "Schermafbeeldingen bibliotheek",
- "page.screenshot-library.search-options.label.view": "Naam",
- "page.screenshot-library.search-options.label.tag": "Label",
- "page.screenshot-library.search-options.label.or": "of",
- "page.screenshot-library.search-options.label.period": "Periode",
- "page.screenshot-library.search-options.label.platform": "Platform",
- "page.screenshot-library.search-options.button.search-screenshots": "Zoek schermafbeeldingen",
- "page.screenshot-library.search-options.dropdown.yesterday": "Gisteren",
- "page.screenshot-library.search-options.dropdown.last-7-days": "Laatste 7 dagen",
- "page.screenshot-library.search-options.dropdown.last-14-days": "Laatste 14 dagen",
- "page.screenshot-library.search-options.dropdown.last-30-days": "Laatste 30 dagen",
- "page.screenshot-library.search-options.dropdown.last-3-months": "Laatste 3 maanden",
- "page.screenshot-library.search-options.dropdown.last-6-months": "Laatste 6 maanden",
- "page.test-execution-history.bread-crumb": "Geschiedenis van testuitvoering",
- "page.test-execution-history.header.suite": "Test pak",
- "page.test-execution-history.messages.retrieving-executions": "Test executies aan het ophalen",
- "page.test-execution-history.charts.execution-pie-chart.title": "Algemene uitvoeringsstatistieken",
- "page.test-execution-history.filter.hint": "Selecteer een status in de grafiek hieronder om deze pagina te filteren.",
- "page.test-execution-history.filter.showing": "Toont",
- "page.test-execution-history.filter.active": "({count, plural, one {# uitvoering} other {# uitvoeringen}})",
- "page.test-execution-history.filter.no-results": "Geen uitvoeringen komen overeen met de geselecteerde status.",
- "page.test-execution-history.charts.execution-timeline-chart.title": "Executie tijden",
- "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "Executie tijd (seconden)",
- "page.test-execution-history.charts.execution-platform-bar-chart.title": "Uitvoeringen per platform",
- "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "Platforms",
- "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "Aantal uitvoeringen",
- "page.about.bread-crumb": "Over Angles",
- "page.about.header": "Over Angles",
- "page.about.about-angles": "Angles is een open-source dashboard wat gebruikt kan worden om test resultaten en schermafbeeldingen op te slaan voor geautomatiseerd testen van meerdere frameworks.",
- "page.about.about-api": "Via het duidelijk omschreven {apiLink} van Angles kan elke geautomatiseerde test framework aangepast worden om zijn resultaten in Angles op te slaan",
- "page.about.about-github": "Voor meer information over Angles kun je terecht op onze pagine: {githubLink}",
- "page.introduction.bread-crumb": "Inleiding",
- "page.introduction.header": "Welkom bij Angles",
- "page.introduction.description": "Het lijkt erop dat u geen teams of omgevingen heeft ingesteld.\n\nDit kunt u doen door {apiLink} te gebruiken.",
- "page.introduction.api-link-text": "de Swagger-API",
- "page.introduction.description-alternative": "Of u kunt ook {githubLink} gebruiken om aan de slag te gaan.",
- "page.introduction.github-link-text": "de Github-pagina",
- "page.not-found.bread-crumb": "Pagina niet gevonden",
- "page.not-found.header": "Pagina niet gevonden",
- "page.not-found.description": "Oeps, we hebben de pagina die je aan het zoeken was niet kunnen vinden. Klik {homeLink} om terug te gaan naar de home pagina.",
- "page.not-found.home-link-text": "hier",
- "common.component.suite-table.header.suite": "Test Pak",
- "common.component.suite-table.header.test": "Test",
- "common.component.suite-table.header.duration": "Looptijd",
- "common.component.suite-table.header.total": "Totaal",
- "common.component.suite-table.header.collapse-all": "Sluit alle testgevallen en acties",
- "common.component.suite-table.header.expand-all": "Open alle testgevallen",
- "common.component.suite-table.header.expand-all-actions": "Open alle testgevallen en acties",
- "common.component.suite-table.icon.test-history": "Zie executie geschiedenis voor test",
- "common.component.suite-table.header.show-screenshots": "Toon screenshots van stappen",
- "common.component.suite-table.header.hide-screenshots": "Verberg screenshots van stappen",
- "common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
- "common.component.suite-table.execution.action-count": "{count, plural, one {# actie} other {# acties}}",
- "common.component.suite-table.action.step-count": "{count, plural, one {# stap} other {# stappen}}",
- "common.component.screenshot-view.header": "Schermafbeeldingen kijker",
- "common.component.screenshot-view.tabs.enable-alert": "Om de tabbladen \"Geschiedenis\" en \"Vergelijken met de baseline\" in te schakelen, geeft U een \"Weergave\" en \"Platform details\" op bij het uploaden van de schermafbeeldingen",
- "common.component.screenshot-view.tabs.label.image": "Schermafbeelding",
- "common.component.screenshot-view.tabs.label.history": "Geschiedenis",
- "common.component.screenshot-view.tabs.label.overlay-with-baseline": "Vergelijken met de baseline",
- "common.component.screenshot-view.tabs.label.side-by-side": "Naast elkaar met de baseline",
- "common.component.screenshot-view.tabs.label.find-image": "Zoek in afbeelding",
- "common.component.screenshot-view.tabs.baseline.label.algorithm": "Vergelijkingsalgoritme",
- "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "Pixel",
- "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "Structureel (SSIM)",
- "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "Voeg dit gewijzigde gebied toe als negeer-vak",
- "common.component.screenshot-view.tabs.find-image.message.intro": "Upload een kleinere afbeelding (bijv. een knop, logo of game-asset) om deze in de huidige schermafbeelding te vinden.",
- "common.component.screenshot-view.tabs.find-image.label.template": "Sjabloonafbeelding",
- "common.component.screenshot-view.tabs.find-image.label.min-confidence": "Minimale betrouwbaarheid",
- "common.component.screenshot-view.tabs.find-image.label.max-matches": "Maximaal aantal overeenkomsten",
- "common.component.screenshot-view.tabs.find-image.label.grayscale": "Kleurverschillen negeren (grijswaarden)",
- "common.component.screenshot-view.tabs.find-image.button.find": "Zoek in schermafbeelding",
- "common.component.screenshot-view.tabs.find-image.message.searching": "Bezig met zoeken naar de sjabloonafbeelding. Dit kan enkele seconden duren.",
- "common.component.screenshot-view.tabs.find-image.message.no-matches": "Geen overeenkomsten gevonden boven de betrouwbaarheidsdrempel.",
- "common.component.screenshot-view.tabs.find-image.label.matches": "Overeenkomst",
- "common.component.screenshot-view.tabs.find-image.label.position": "Positie",
- "common.component.screenshot-view.tabs.find-image.label.size": "Afmetingen",
- "common.component.screenshot-view.tabs.find-image.label.confidence": "Betrouwbaarheid",
- "common.component.screenshot-view.tabs.find-image.label.scale": "Schaal",
- "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "Baseline maken",
- "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "Dit is de baseline",
- "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "Genereer dynamische baseline",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "Verwijder schermafbeelding",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "Alleen beschikbaar voor dynamische baselines die niet als baseline wordt gebruikt.",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "Bezig met het ophalen van de schermafbeelding",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "Kan afbeelding niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
- "common.component.screenshot-view.tabs.history.message.loading-history": "Schermafbeelding geschiedenis aan het ophalen...",
- "common.component.screenshot-view.tabs.history.label.baseline": "Baseline schermafbeelding",
- "common.component.screenshot-view.tabs.history.label.view": "Weergave",
- "common.component.screenshot-view.tabs.history.label.date": "Datum",
- "common.component.screenshot-view.tabs.history.label.resolution": "Resolutie",
- "common.component.screenshot-view.tabs.history.label.platform": "Platform",
- "common.component.screenshot-view.tabs.history.label.device": "Apparaat",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "Huidige schermafbeelding (en baseline)",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image": "Huidige schermafbeelding",
- "common.component.screenshot-view.tabs.side-by-side.label.baseline": "Baseline",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "Baseline ophalen.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "Kan de baseline niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "Schermafbeelding ophalen.",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "Kan de schermafbeelding niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
- "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "Huidige schermafbeelding (en baseline)",
- "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "Baseline vergelijking",
- "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "Vergelijk opnieuw met de baseline.",
- "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "Bewerk negeer blokken",
- "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "Sla negeer blokken op",
- "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "Annuleer wijziging",
- "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "Er is nog geen baseline geselecteerd voor deze weergave en apparaatnaam of browsercombinatie. To select a baseline, navigate to the image you want as a baseline and click on the \"Make Baseline Image\" button",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "Vergelijking met baseline aan het ophalen",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "Kan baseline vergelijking niet ophalen.",
- "common.component.screenshot-details-table.label.view": "Weergave",
- "common.component.screenshot-details-table.label.view-link": "andere platforms",
- "common.component.screenshot-details-table.label.view-link-description": "Vind de nieuwste schermafbeeldingen voor weergave{view}, gegroepeerd per platform.",
- "common.component.screenshot-details-table.label.no-view": "Geen weergave beschikbaar",
- "common.component.screenshot-details-table.label.date": "Datum",
- "common.component.screenshot-details-table.label.baseline-date": "Baseline datum",
- "common.component.screenshot-details-table.label.mismatch": "Mismatch",
- "common.component.screenshot-details-table.label.algorithm": "Algoritme",
- "common.component.screenshot-details-table.label.ssim-score": "SSIM-score",
- "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (analyse tijd: {time} ms)",
- "common.component.screenshot-details-table.label.resolution": "Resolutie",
- "common.component.screenshot-details-table.label.platform": "Platform",
- "common.component.screenshot-details-table.label.no-platform": "Geen platform voorzien",
- "common.component.screenshot-details-table.label.platform-version": "Platform versie",
- "common.component.screenshot-details-table.label.device": "Apparaat",
- "common.component.screenshot-details-table.label.browser": "Browser",
- "common.component.screenshot-details-table.label.no-browser": "Geen browser voorzien",
- "common.component.screenshot-details-table.label.browser-version": "Browser versie",
- "common.component.screenshot-details-table.label.no-browser-version": "Geen browser versie voorzien",
- "common.component.screenshot-details-table.label.baseline": "Baseline",
- "common.component.screenshot-details-table.label.tags": "Labels",
- "common.component.screenshot-details-table.label.tags-description": "Vind de laatste screenshots met label \"{tag}\", gegroepeerd op weergave.",
- "common.yes": "Ja",
- "common.no": "Nee",
- "common.button.ok": "OK",
- "page.login.title": "Inloggen op Angles",
- "page.login.error.invalid-credentials": "Ongeldige gebruikersnaam of wachtwoord",
- "page.login.label.username": "Gebruikersnaam",
- "page.login.label.password": "Wachtwoord",
- "page.login.button.login": "Inloggen",
- "page.login.divider.or": "OF",
- "page.login.label.sign-in-with": "Inloggen met",
- "page.login.source.local": "Lokaal account",
- "page.login.button.sso": "Doorgaan met {name}",
- "page.login.error.sso-failed": "Single sign-on is mislukt. Probeer het opnieuw of neem contact op met uw beheerder.",
- "page.login.error.no-methods": "Er zijn geen inlogmethoden beschikbaar. Neem contact op met uw beheerder.",
- "page.user-settings.header": "Gebruikersinstellingen",
- "page.user-settings.api-tokens.title": "API-tokens",
- "page.user-settings.api-tokens.description": "Persoonlijke API-tokens werken net als gewone wachtwoorden, maar hiermee kunt u zich programmatisch verifiëren bij de API.",
- "page.user-settings.button.generate-new-token": "Nieuw token genereren",
- "page.user-settings.table.name": "Naam",
- "page.user-settings.table.created": "Aangemaakt",
- "page.user-settings.table.expires": "Verloopt",
- "page.user-settings.table.action": "Actie",
- "page.user-settings.table.revoke": "Intrekken",
- "page.user-settings.modal.token-generated": "Token gegenereerd",
- "page.user-settings.modal.generate-token": "Nieuw API-token genereren",
- "page.user-settings.modal.success-label": "Succes!",
- "page.user-settings.modal.success": "Uw nieuwe token is gegenereerd.",
- "page.user-settings.modal.warning": "Kopieer dit token nu. U zult het hierna niet meer kunnen zien!",
- "page.user-settings.modal.token-name": "Token naam",
- "page.user-settings.modal.token-placeholder": "bijv. CI/CD Pipeline",
- "page.user-settings.modal.expiration": "Vervaldatum",
- "page.user-settings.modal.custom-expiration": "Aangepaste vervaldatum (Dagen)",
- "page.user-settings.button.done": "Klaar",
- "page.user-settings.button.generate": "Genereren",
- "page.user-settings.button.cancel": "Annuleren",
- "page.user-settings.confirm.revoke.title": "Token intrekken",
- "page.user-settings.confirm.revoke.message": "Weet u zeker dat u dit token wilt intrekken? Scripts of toepassingen die dit token gebruiken, verliezen onmiddellijk toegang.",
- "page.user-settings.table.expires-never": "Nooit",
- "page.user-settings.modal.token-label": "Token",
- "page.user-settings.modal.button.copy": "Kopiëren",
- "page.user-settings.expiry.1-day": "1 Dag",
- "page.user-settings.expiry.1-week": "1 Week",
- "page.user-settings.expiry.1-month": "1 Maand",
- "page.user-settings.expiry.1-year": "1 Jaar",
- "page.user-settings.expiry.custom": "Aangepast",
- "page.user-settings.toast.fetch-tokens-error": "Ophalen van tokens mislukt",
- "page.user-settings.toast.token-name-required": "Tokennaam is verplicht",
- "page.user-settings.toast.expiry-required": "Geldige vervaldagen zijn vereist",
- "page.user-settings.toast.generate-token-error": "Genereren van token mislukt",
- "page.user-settings.toast.revoke-success": "Token succesvol ingetrokken",
- "page.user-settings.toast.revoke-error": "Intrekken van token mislukt",
- "page.user-settings.toast.copy-success": "Token gekopieerd naar klembord",
- "password.policy.min-length": "Minimaal 10 tekens lang zijn",
- "password.policy.max-length": "Maximaal 100 tekens lang zijn",
- "password.policy.letter": "Ten minste één letter bevatten",
- "password.policy.uppercase": "Ten minste één hoofdletter bevatten",
- "password.policy.special": "Ten minste één speciaal teken bevatten",
- "password.policy.invalid": "Wachtwoord voldoet niet aan de beveiligingsvereisten",
- "page.user-settings.password.title": "Wachtwoord",
- "page.user-settings.password.description": "Wijzig het wachtwoord waarmee u zich aanmeldt bij Angles.",
- "page.user-settings.password.button.change": "Wachtwoord wijzigen",
- "page.user-settings.password.modal.title": "Wachtwoord wijzigen",
- "page.user-settings.password.modal.current": "Huidig wachtwoord",
- "page.user-settings.password.modal.new": "Nieuw wachtwoord",
- "page.user-settings.password.modal.confirm": "Bevestig nieuw wachtwoord",
- "page.user-settings.password.modal.mismatch": "Wachtwoorden komen niet overeen",
- "page.user-settings.password.modal.submit": "Wachtwoord bijwerken",
- "page.user-settings.password.modal.success": "Uw wachtwoord is bijgewerkt. Gebruik het de volgende keer dat u zich aanmeldt.",
- "page.user-settings.password.toast.current-required": "Huidig wachtwoord is verplicht",
- "page.user-settings.password.toast.mismatch": "Nieuw wachtwoord en bevestiging komen niet overeen",
- "page.user-settings.password.toast.success": "Wachtwoord succesvol bijgewerkt",
- "page.user-settings.password.toast.error": "Wachtwoord bijwerken mislukt",
- "page.admin.settings.header": "Authenticatie instellingen",
- "page.admin.settings.loading": "Instellingen laden...",
- "page.admin.settings.local-auth.title": "Lokale Authenticatie",
- "page.admin.settings.local-auth.enable": "Lokale Authenticatie inschakelen",
- "page.admin.settings.local-auth.help": "Laat gebruikers inloggen met een gebruikersnaam en wachtwoord.",
- "page.admin.settings.button.save": "Instellingen opslaan",
- "page.admin.users.header": "Gebruikersbeheer",
- "page.admin.users.button.add-user": "Gebruiker Toevoegen",
- "page.admin.users.table.username": "Gebruikersnaam",
- "page.admin.users.table.role": "Rol",
- "page.admin.users.table.teams": "Teams",
- "page.admin.users.table.action": "Actie",
- "page.admin.users.table.edit": "Bewerk",
- "page.admin.users.table.delete": "Verwijder",
- "page.admin.users.modal.edit-user": "Gebruiker bewerken",
- "page.admin.users.modal.add-user": "Gebruiker Toevoegen",
- "page.admin.users.modal.password-help": "(Leeg laten om ongewijzigd te behouden)",
- "page.admin.users.modal.user-type": "Gebruikerstype",
- "page.admin.users.modal.team-access": "Team Toegang",
- "page.admin.users.button.save": "Opslaan",
- "page.admin.users.button.cancel": "Annuleren",
- "page.admin.users.confirm.delete.title": "Gebruiker verwijderen",
- "page.admin.users.confirm.delete.message": "Weet u zeker dat u deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
- "page.admin.users.user-type.admin": "Beheerder",
- "page.admin.users.user-type.team_lead": "Teamleider",
- "page.admin.users.user-type.user": "Gebruiker",
- "page.admin.users.modal.password-label": "Wachtwoord",
- "page.admin.users.table.teams-assigned": "{count} teams toegewezen",
- "page.admin.users.toast.fetch-error": "Ophalen van gebruikers mislukt",
- "page.admin.users.toast.update-success": "Gebruiker succesvol bijgewerkt",
- "page.admin.users.toast.create-success": "Gebruiker succesvol aangemaakt",
- "page.admin.users.toast.save-error": "Opslaan van gebruiker mislukt",
- "page.admin.users.toast.delete-success": "Gebruiker succesvol verwijderd",
- "page.admin.users.toast.delete-error": "Verwijderen van gebruiker mislukt",
- "page.admin.settings.toast.fetch-error": "Ophalen van instellingen mislukt",
- "page.admin.settings.toast.save-success": "Instellingen succesvol opgeslagen",
- "page.admin.settings.toast.save-error": "Opslaan van instellingen mislukt",
- "page.admin.settings.providers.title": "Identiteitsproviders",
- "page.admin.settings.providers.help": "Configureer single sign-on-providers. Wijzigingen zijn direct actief — een herstart is niet nodig.",
- "page.admin.settings.providers.empty": "Er zijn nog geen identiteitsproviders geconfigureerd.",
- "page.admin.settings.providers.table.name": "Naam",
- "page.admin.settings.providers.table.id": "ID",
- "page.admin.settings.providers.table.type": "Type",
- "page.admin.settings.providers.table.enabled": "Ingeschakeld",
- "page.admin.settings.providers.table.status": "Status",
- "page.admin.settings.button.add-provider": "Provider toevoegen",
- "page.admin.settings.button.save-provider": "Provider opslaan",
- "page.admin.settings.confirm.delete.title": "Provider verwijderen",
- "page.admin.settings.confirm.delete.message": "Weet u zeker dat u deze identiteitsprovider wilt verwijderen? Gebruikers die hiermee inloggen verliezen hun toegang.",
- "page.admin.settings.provider.modal.add": "Identiteitsprovider toevoegen",
- "page.admin.settings.provider.modal.edit": "Identiteitsprovider bewerken",
- "page.admin.settings.provider.name": "Weergavenaam",
- "page.admin.settings.provider.name.help": "Wordt getoond op de inlogknop, bijvoorbeeld \"Bedrijfs-SSO\".",
- "page.admin.settings.provider.id": "Provider-ID",
- "page.admin.settings.provider.id.help": "2-32 kleine letters, cijfers of koppeltekens. Maakt deel uit van de callback-URL en kan later niet worden gewijzigd.",
- "page.admin.settings.provider.id.help-locked": "De ID maakt deel uit van de callback-URL die bij de identiteitsprovider is geregistreerd en kan niet worden gewijzigd.",
- "page.admin.settings.provider.type": "Providertype",
- "page.admin.settings.provider.type.oidc": "OIDC",
- "page.admin.settings.provider.type.saml": "SAML 2.0",
- "page.admin.settings.provider.type.ldap": "LDAP / AD",
- "page.admin.settings.provider.type.help-locked": "Het providertype kan na aanmaken niet meer worden gewijzigd.",
- "page.admin.settings.provider.enabled": "Ingeschakeld",
- "page.admin.settings.provider.enabled.help": "Toon deze provider op de inlogpagina.",
- "page.admin.settings.provider.callback-url": "Redirect-URI",
- "page.admin.settings.provider.acs-url": "ACS- (callback-)URL",
- "page.admin.settings.provider.callback-url.help": "Registreer deze URL bij uw identiteitsprovider.",
- "page.admin.settings.provider.status.ready": "Gereed",
- "page.admin.settings.provider.status.error": "Fout",
- "page.admin.settings.provider.status.enabled": "Ingeschakeld",
- "page.admin.settings.provider.status.disabled": "Uitgeschakeld",
- "page.admin.settings.provider.default-role": "Standaardrol",
- "page.admin.settings.provider.default-role.deny": "Toegang weigeren",
- "page.admin.settings.provider.default-role.help": "Rol die wordt toegekend als geen enkele koppeling overeenkomt. Toegang weigeren is de veiligere standaard.",
- "page.admin.settings.provider.secret.help-set": "Er is een waarde geconfigureerd. Laat leeg om deze te behouden, of voer een nieuwe waarde in om deze te vervangen.",
- "page.admin.settings.provider.secret.help-unset": "Na het opslaan kan deze waarde niet meer worden bekeken.",
- "page.admin.settings.provider.error.id": "De provider-ID moet bestaan uit 2-32 kleine letters, cijfers of koppeltekens.",
- "page.admin.settings.provider.error.duplicate-id": "Een andere provider gebruikt deze ID al.",
- "page.admin.settings.provider.error.name": "Een weergavenaam is verplicht.",
- "page.admin.settings.provider.error.mapping-value": "Elke rolkoppeling heeft een groepswaarde nodig.",
- "page.admin.settings.oidc.title": "OIDC-configuratie",
- "page.admin.settings.oidc.issuer": "Issuer-URL",
- "page.admin.settings.oidc.issuer.help": "Endpoints en ondertekeningssleutels worden opgehaald via .well-known/openid-configuration van de issuer.",
- "page.admin.settings.oidc.client-id": "Client-ID",
- "page.admin.settings.oidc.client-secret": "Client secret",
- "page.admin.settings.oidc.scopes": "Scopes",
- "page.admin.settings.oidc.scopes.help": "Gescheiden door spaties. \"openid\" wordt altijd toegevoegd. Okta en Keycloak accepteren \"groups\"; Google weigert dit.",
- "page.admin.settings.oidc.groups-claim": "Groups-claim",
- "page.admin.settings.oidc.groups-claim.help": "Claim met het groepslidmaatschap — \"groups\" voor Okta, vaak \"roles\" voor Keycloak. Entra ID levert group-GUID’s.",
- "page.admin.settings.oidc.username-claim": "Gebruikersnaam-claim",
- "page.admin.settings.oidc.username-claim.help": "Claim die als Angles-gebruikersnaam wordt gebruikt. Valt terug op email, preferred_username en daarna sub.",
- "page.admin.settings.saml.title": "SAML 2.0-configuratie",
- "page.admin.settings.saml.metadata-url": "SP-metadata-URL",
- "page.admin.settings.saml.metadata-url.help": "Importeer deze in uw identiteitsprovider in plaats van de waarden handmatig in te voeren.",
- "page.admin.settings.saml.entry-point": "IdP SSO-URL",
- "page.admin.settings.saml.entry-point.help": "De URL van de identiteitsprovider waarnaar de browser wordt doorgestuurd.",
- "page.admin.settings.saml.idp-cert": "Ondertekeningscertificaat van de IdP",
- "page.admin.settings.saml.idp-cert.help": "Verplicht — het is de enige reden waarom een assertion vertrouwd kan worden. Wordt geaccepteerd met of zonder PEM-headerregels.",
- "page.admin.settings.saml.issuer": "SP entity-ID",
- "page.admin.settings.saml.issuer.help": "De entity-ID van deze service provider, zoals geregistreerd bij de identiteitsprovider.",
- "page.admin.settings.saml.private-key": "Privésleutel van de SP",
- "page.admin.settings.saml.private-key.help": "Alleen nodig wanneer de identiteitsprovider ondertekende authenticatieverzoeken vereist. Laat leeg om de opgeslagen sleutel te behouden.",
- "page.admin.settings.saml.signature-algorithm": "Handtekeningalgoritme",
- "page.admin.settings.saml.identifier-format": "Name ID-formaat",
- "page.admin.settings.saml.username-attribute": "Gebruikersnaamattribuut",
- "page.admin.settings.saml.username-attribute.help": "Assertion-attribuut met de gebruikersnaam. Laat leeg om de Name ID te gebruiken.",
- "page.admin.settings.saml.groups-attribute": "Groepsattribuut",
- "page.admin.settings.saml.response-signed": "Ondertekende response vereisen",
- "page.admin.settings.saml.response-signed.help": "Schakel uit voor identiteitsproviders die alleen de assertion ondertekenen. Assertion-handtekeningen zijn altijd verplicht.",
- "page.admin.settings.saml.allow-unsolicited": "IdP-geïnitieerd inloggen toestaan",
- "page.admin.settings.saml.allow-unsolicited.help": "Standaard uit: een ongevraagde assertion heeft geen verzoek om mee te correleren, waardoor de replay-beveiliging vervalt.",
- "page.admin.settings.ldap.title": "LDAP-/Active Directory-configuratie",
- "page.admin.settings.ldap.url": "Directory-URL",
- "page.admin.settings.ldap.url.help": "Gebruik ldaps://, of ldap:// met StartTLS. Een gewone ldap://-verbinding wordt geweigerd omdat wachtwoorden dan onversleuteld worden verzonden.",
- "page.admin.settings.ldap.bind-dn": "Bind-DN",
- "page.admin.settings.ldap.bind-dn.help": "Serviceaccount waarmee de gebruiker wordt opgezocht. Laat leeg voor een anonieme zoekopdracht.",
- "page.admin.settings.ldap.bind-credentials": "Bind-wachtwoord",
- "page.admin.settings.ldap.search-base": "Zoekbasis voor gebruikers",
- "page.admin.settings.ldap.search-base.help": "Bijvoorbeeld: ou=users,dc=example,dc=com",
- "page.admin.settings.ldap.search-filter": "Zoekfilter voor gebruikers",
- "page.admin.settings.ldap.search-filter.help": "{username} wordt vervangen door de ingevoerde gebruikersnaam. Active Directory gebruikt meestal (sAMAccountName={username}).",
- "page.admin.settings.ldap.username-attribute": "Gebruikersnaamattribuut",
- "page.admin.settings.ldap.username-attribute.help": "Attribuut dat als Angles-gebruikersnaam wordt gebruikt — \"uid\", of \"sAMAccountName\" bij Active Directory.",
- "page.admin.settings.ldap.group-search-base": "Zoekbasis voor groepen",
- "page.admin.settings.ldap.group-search-base.help": "Laat leeg om het groepslidmaatschap te lezen uit het memberOf-attribuut van de gebruiker, zoals Active Directory dit gewoonlijk aanbiedt.",
- "page.admin.settings.ldap.group-search-filter": "Zoekfilter voor groepen",
- "page.admin.settings.ldap.group-name-attribute": "Groepsnaamattribuut",
- "page.admin.settings.ldap.group-name-attribute.help": "Volledige DN’s worden teruggebracht tot dit attribuut, koppel rollen dus aan namen zoals \"Angles Admins\".",
- "page.admin.settings.ldap.start-tls": "StartTLS gebruiken",
- "page.admin.settings.ldap.start-tls.help": "Waardeer een gewone ldap://-verbinding op naar TLS.",
- "page.admin.settings.ldap.tls-reject-unauthorized": "TLS-certificaat verifiëren",
- "page.admin.settings.ldap.tls-reject-unauthorized.help": "Dit uitschakelen maakt de verbinding eenvoudig te onderscheppen. Alleen voor testopstellingen met zelfondertekende certificaten.",
- "page.admin.settings.role-mappings.title": "Groep-naar-rolkoppeling",
- "page.admin.settings.role-mappings.help": "Koppel groepen die de provider doorgeeft aan Angles-rollen. Hoofdlettergebruik maakt niet uit, en een gebruiker die aan meerdere koppelingen voldoet krijgt de zwaarste rol (Admin > Teamleider > Gebruiker).",
- "page.admin.settings.role-mappings.empty": "Nog geen koppelingen. Zonder koppeling hangt de toegang af van de standaardrol hieronder.",
- "page.admin.settings.role-mappings.add": "Koppeling toevoegen",
- "page.admin.settings.role-mappings.remove": "Koppeling verwijderen",
- "page.admin.settings.role-mappings.value-placeholder": "Groepsnaam of -ID",
- "page.team-settings.header": "Team Instellingen",
- "page.team-settings.label.team": "Team",
- "page.team-settings.section.details": "Team Details",
- "page.team-settings.label.name": "Teamnaam",
- "page.team-settings.button.save-name": "Naam Opslaan",
- "page.team-settings.section.components": "Onderdelen",
- "page.team-settings.components.help": "Met onderdelen kunt u uw testdekking groeperen per functioneel gebied.",
- "page.team-settings.label.existing-components": "Bestaande Onderdelen",
- "page.team-settings.label.add-components": "Onderdelen Toevoegen",
- "page.team-settings.components.add-placeholder": "Typ een onderdeelnaam en druk op enter",
- "page.team-settings.button.add-components": "Onderdelen Toevoegen",
- "page.team-settings.no-team-access": "U heeft nog geen toegang tot teams. Neem contact op met een beheerder.",
- "page.team-settings.toast.fetch-error": "Ophalen van teamgegevens mislukt",
- "page.team-settings.toast.name-update-success": "Teamnaam succesvol bijgewerkt",
- "page.team-settings.toast.name-update-error": "Bijwerken van teamnaam mislukt",
- "page.team-settings.toast.components-update-success": "Onderdelen succesvol toegevoegd",
- "page.team-settings.toast.components-update-error": "Toevoegen van onderdelen mislukt"
+ "nav.dashboard": "Dashboard",
+ "nav.execution-metrics": "Statistieken ",
+ "nav.screenshot-library": "Schermafbeeldingen",
+ "nav.language": "Taal",
+ "nav.theme": "Thema",
+ "nav.theme.light": "Licht",
+ "nav.theme.dark": "Donker",
+ "nav.theme.group.light": "Lichte thema’s",
+ "nav.theme.group.dark": "Donkere thema’s",
+ "nav.theme.slate-light": "Slate licht",
+ "nav.theme.sepia": "Sepia",
+ "nav.theme.slate-dark": "Slate donker",
+ "nav.theme.midnight": "Middernacht",
+ "nav.theme.forest-light": "Bos licht",
+ "nav.theme.contrast-light": "Hoog contrast licht",
+ "nav.theme.forest-dark": "Bos donker",
+ "nav.theme.contrast-dark": "Hoog contrast donker",
+ "nav.theme.clay-light": "Klei (kleurenblind-vriendelijk)",
+ "nav.theme.clay-dark": "Klei donker (kleurenblind-vriendelijk)",
+ "nav.about": "Over Angles",
+ "nav.team-settings": "Team Instellingen",
+ "nav.admin": "Beheer",
+ "nav.admin.users": "Gebruikers",
+ "nav.admin.settings": "Instellingen",
+ "nav.profile": "Profiel",
+ "nav.user-settings": "Instellingen",
+ "nav.logout": "Uitloggen",
+ "app.utils.duration.seconds": "{seconds} seconden",
+ "app.utils.duration.minutes": "{minutes} minuten",
+ "app.utils.duration.hours": "{hours} uren",
+ "app.utils.duration.not-started": "Nog niet begonnen",
+ "app.utils.date-range-picker.number-of-days": "Laatste {days} dagen",
+ "app.retrieving-teams": "Teams aan het ophalen...",
+ "app.retrieving-teams-error": "Er is iets misgegaan [{teamsError}]",
+ "app.result.pass": "Geslaagd",
+ "app.result.fail": "Mislukt",
+ "app.result.skipped": "Overgeslagen",
+ "app.result.error": "Fout",
+ "app.result.total": "Totaal",
+ "page.home.bread-crumb": "Home",
+ "page.dashboard.message.retrieving-builds": "Testuitvoeringen aan het ophalen voor {teamName}...",
+ "page.dashboard.filters.label.team": "Team",
+ "page.dashboard.filters.label.period": "Periode",
+ "page.dashboard.filters.label.display-limit": "Weergave limiet",
+ "page.dashboard.filters.label.environments": "Omgeving",
+ "page.dashboard.filters.label.components": "Onderdelen",
+ "page.dashboard.panel.total-test-runs": "Totaal aantal testuitvoeringen",
+ "page.dashboard.panel.total-test-executions": "Totaal aantal tests",
+ "page.dashboard.panel.total-execution-time": "Totale uitvoertijd",
+ "page.dashboard.chart.barchart.pass": "Geslaagd",
+ "page.dashboard.chart.barchart.skipped": "Overgeslagen",
+ "page.dashboard.chart.barchart.error": "Fout",
+ "page.dashboard.chart.barchart.fail": "Mislukt",
+ "page.dashboard.chart.barchart.execution-time-seconds": "Executie tijd (seconden)",
+ "page.dashboard.chart.barchart.yaxis.number-of-tests": "Aantal tests",
+ "page.dashboard.builds-table.header.build-details": "Details van testuitvoering",
+ "page.dashboard.builds-table.header.status": "Status",
+ "page.dashboard.builds-table.header.date-time": "Datum/Tijd",
+ "page.dashboard.builds-table.header.result": "Resultaat",
+ "page.dashboard.builds-table.header.flags": "Markeringen",
+ "page.dashboard.builds-table.not-available": "N.v.t.",
+ "page.dashboard.builds-table.artifacts.title": "Artefacten",
+ "page.dashboard.builds-table.artifacts.count": "{count, plural, one {# artefact} other {# artefacten}}",
+ "page.dashboard.builds-table.keep.enabled": "Bewaarmarkering ingeschakeld - deze testrun wordt niet opgeruimd",
+ "page.dashboard.builds-table.keep.disabled": "Bewaarmarkering uitgeschakeld - deze testrun kan tijdens het opruimen worden verwijderd",
+ "page.dashboard.menu.compare-test-runs": "Vergelijk testuitvoeringen",
+ "page.dashboard.menu.toggle-keep-flag": "Schakel houd vlag om",
+ "page.dashboard.menu.clear-selection": "Verwijder selectie",
+ "page.dashboard.menu.delete-build": "Build verwijderen",
+ "page.dashboard.menu.one-select-message": "Selecteer tenminste 1 testuitvoering",
+ "page.dashboard.confirm.delete-build.title": "Build verwijderen",
+ "page.dashboard.confirm.delete-build.message": "Weet u zeker dat u de {count} geselecteerde build(s) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt en verwijdert permanent alle bijbehorende uitvoeringen en screenshots.",
+ "page.dashboard.confirm.delete-build.cancel": "Annuleren",
+ "page.dashboard.toast.delete-build-success": "{count} build(s) succesvol verwijderd",
+ "page.dashboard.toast.delete-build-error": "Verwijderen van build mislukt",
+ "page.dashboard.execution-barchart.execution-time-seconds": "Uitvoertijd (seconden)",
+ "page.dashboard.execution-bar-chart.title": "Testuitvoeringen",
+ "page.dashboard.execution-pie-chart.title": "Algemene status overzicht testuitvoeringen",
+ "page.metrics.bread-crumb": "Statistieken",
+ "page.metrics.filters.labels.team": "Team",
+ "page.metrics.filters.labels.component": "Onderdeel",
+ "page.metrics.filters.labels.period": "Periode",
+ "page.metrics.filters.labels.group-by-period": "Weergave periode",
+ "page.metrics.filters.button.retrieve-metrics": "Statistieken ophalen",
+ "page.metrics.tab.execution-metrics": "Executie statistieken",
+ "page.metrics.tab.platform-metrics": "Platform statistieken",
+ "page.metrics.execution-metrics-summary.label.period": "Periode",
+ "page.metrics.execution-metrics-summary.label.number-of-tests": "Aantal test gevallen",
+ "page.metrics.execution-metrics-summary.label.number-of-runs": "Aantal testuitvoeringen",
+ "page.metrics.execution-metrics-summary.label.number-of-executions": "Aantal test executies",
+ "page.metrics.execution-metrics-summary.label.result": "Resultaat",
+ "page.metrics.execution-metrics-summary.label.duration": "Looptijd",
+ "page.metrics.execution-metrics-bar-chart.title": "Executie statistieken per resultaat",
+ "page.metrics.execution-metrics-bar-chart.yaxis-title": "Aantal executies",
+ "page.metrics.execution-metrics-phase-bar-chart.title": "Executie statistieken per fase",
+ "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "Aantal executies",
+ "page.metrics.platform-distribution-pie-chart.title": "Executies per platform",
+ "page.metrics.platform-distribution-bar-chart.title": "Executies per platform",
+ "page.metrics.platform-distribution-bar-chart.yaxis-title": "Platforms",
+ "page.metrics.platform-distribution-bar-chart.xaxis-title": "Aantal executies",
+ "page.metrics.platform-metrics-summary.label.platform": "Platform",
+ "page.metrics.platform-metrics-summary.label.version": "Versie",
+ "page.metrics.platform-metrics-summary.label.device": "Aparaat",
+ "page.metrics.platform-metrics-summary.label.browser": "Browser",
+ "page.metrics.platform-metrics-summary.label.total": "Totaal",
+ "page.metrics.platform-metrics-summary.label.result": "Resultaat",
+ "page.metrics.platform-metrics-summary.label.duration": "Looptijd",
+ "page.metrics.metrics-test-table.label.unique-testcases": "Unieke test gevallen per fase",
+ "page.metrics.metrics-test-table.label.phase": "Fase",
+ "page.metrics.metrics-test-table.label.test": "Test",
+ "page.metrics.metrics-test-table.no-tests-found": "Geen testgevallen gevonden",
+ "page.test-run.bread-crumb": "Testuitvoering",
+ "page.test-run.steps-header.start": "Start",
+ "page.test-run.steps-header.duration": "Looptijd",
+ "page.test-run.steps-header.end": "Einde",
+ "page.test-run.menu.enable-keep-flag": "Schakel de \"houd\" vlag in",
+ "page.test-run.menu.disable-keep-flag": "Schakel de \"houd\" vlag uit",
+ "page.test-run.menu.download-report": "Raport downloaden als html",
+ "page.test-run.menu.delete-build": "Build verwijderen",
+ "page.test-run.confirm.delete-build.title": "Build verwijderen",
+ "page.test-run.confirm.delete-build.message": "Weet u zeker dat u deze build wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt en verwijdert permanent alle bijbehorende uitvoeringen en screenshots.",
+ "page.test-run.confirm.delete-build.cancel": "Annuleren",
+ "page.test-run.toast.delete-build-success": "Build succesvol verwijderd",
+ "page.test-run.toast.delete-build-error": "Verwijderen van build mislukt",
+ "page.test-run.icons.environment.whisper": "Omgeving",
+ "page.test-run.icons.team.whisper": "Team",
+ "page.test-run.icons.component.whisper": "Onderdeel",
+ "page.test-run.icons.phase.whisper": "Test Fase",
+ "page.test-run.icons.artifacts.whisper": "Aantal artefacten dat aan deze testuitvoering is gekoppeld.",
+ "page.test-run.icons.keep-lock.whisper": "Indien vergrendeld, wordt een test run niet opgeschoond door de nachtelijke cronjob.",
+ "page.test-run.icons.screenshots.whisper": "Deze uitvoering heeft {numberOfScreenshots} schermafbeeldingen",
+ "page.test-run.icons.download-report.whisper": "Download het HTML-rapport voor deze test run",
+ "page.test-run.header.status.whisper": "Status: {status}",
+ "page.test-run.header.execution-count": "{count, plural, one {# uitvoering} other {# uitvoeringen}}",
+ "page.test-run.header.fact.not-set": "Niet ingesteld",
+ "page.test-run.header.artifacts-title": "Artefacten",
+ "page.test-run.suites.title": "Testsuites",
+ "page.test-run.loading": "Bezig met ophalen van buildgegevens.",
+ "page.test-run.error.load-failed": "Kan de buildgegevens niet ophalen. Vernieuw de pagina en probeer het opnieuw en/of controleer of het build-id geldig is.",
+ "page.test-run.execution-pie-chart.title": "Executie statistiek",
+ "page.test-run.feature-distribution-pie-chart.title": "Componenten statistiek",
+ "page.test-run.execution-pie-chart.pass-rate": "Slaagpercentage",
+ "page.test-run.feature-distribution-pie-chart.unspecified": "Niet opgegeven",
+ "page.test-run.feature-distribution-pie-chart.executions": "Uitvoeringen",
+ "page.test-run-compare.bread-crumb": "Vergelijk testuitvoeringen",
+ "page.test-run-compare.header.number": "#",
+ "page.test-run-compare.header.test-name": "Testnaam",
+ "page.test-run-compare.detail.start-date": "Startdatum",
+ "page.test-run-compare.detail.environment": "Omgeving",
+ "page.test-run-compare.cell.not-available": "N.v.t.",
+ "page.test-run-compare.cell.multiple-executions": "Meerdere uitvoeringen",
+ "page.test-run-compare.popover.details-title": "Details",
+ "page.test-run-compare.link.execution-history": "Bekijk uitvoeringsgeschiedenis voor {testName}",
+ "page.test-run-compare.details-popover.header.property": "Eigenschap",
+ "page.test-run-compare.details-popover.header.value": "Waarde",
+ "page.test-run-compare.details-popover.property.status": "Status",
+ "page.test-run-compare.details-popover.property.duration": "Duur",
+ "page.test-run-compare.details-popover.property.platform": "Platform",
+ "page.test-run-compare.details-popover.property.failing-step": "Mislukte stap",
+ "page.test-run-compare.loading": "Details van testuitvoeringen ophalen om de vergelijking te genereren...",
+ "page.test-run-compare.section.artifacts": "Artifactversies",
+ "page.test-run-compare.artifacts.count": "{count, plural, one {# artifact} other {# artifacten}}",
+ "page.test-run-compare.section.compare-matrix": "Testvergelijkingsmatrix",
+ "page.test-run-compare.toolbar.status-filter": "Filteren op status",
+ "page.test-run-compare.suite.tests": "{count, plural, one {# test} other {# tests}}",
+ "page.test-run-compare.suite.rollup": "{passed} van {total} geslaagd",
+ "page.test-run-compare.header.run-summary": "{passed} van {total} geslaagd, {failed} mislukt, {errors} met fouten",
+ "page.test-run-compare.cell.status-changed": "Gewijzigd ten opzichte van de vorige uitvoering",
+ "page.test-run-compare.warning.many-runs": "Je vergelijkt {count} testuitvoeringen — gebruik de filters hieronder om op de verschillen te focussen.",
+ "page.screenshot-library.bread-crumb": "Schermafbeeldingen bibliotheek",
+ "page.screenshot-library.search-options.label.view": "Naam",
+ "page.screenshot-library.search-options.label.tag": "Label",
+ "page.screenshot-library.search-options.label.or": "of",
+ "page.screenshot-library.search-options.label.period": "Periode",
+ "page.screenshot-library.search-options.label.platform": "Platform",
+ "page.screenshot-library.search-options.button.search-screenshots": "Zoek schermafbeeldingen",
+ "page.screenshot-library.search-options.dropdown.yesterday": "Gisteren",
+ "page.screenshot-library.search-options.dropdown.last-7-days": "Laatste 7 dagen",
+ "page.screenshot-library.search-options.dropdown.last-14-days": "Laatste 14 dagen",
+ "page.screenshot-library.search-options.dropdown.last-30-days": "Laatste 30 dagen",
+ "page.screenshot-library.search-options.dropdown.last-3-months": "Laatste 3 maanden",
+ "page.screenshot-library.search-options.dropdown.last-6-months": "Laatste 6 maanden",
+ "page.test-execution-history.bread-crumb": "Geschiedenis van testuitvoering",
+ "page.test-execution-history.header.suite": "Test pak",
+ "page.test-execution-history.messages.retrieving-executions": "Test executies aan het ophalen",
+ "page.test-execution-history.charts.execution-pie-chart.title": "Algemene uitvoeringsstatistieken",
+ "page.test-execution-history.filter.hint": "Selecteer een status in de grafiek hieronder om deze pagina te filteren.",
+ "page.test-execution-history.filter.showing": "Toont",
+ "page.test-execution-history.filter.active": "({count, plural, one {# uitvoering} other {# uitvoeringen}})",
+ "page.test-execution-history.filter.no-results": "Geen uitvoeringen komen overeen met de geselecteerde status.",
+ "page.test-execution-history.charts.execution-timeline-chart.title": "Executie tijden",
+ "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "Executie tijd (seconden)",
+ "page.test-execution-history.charts.execution-platform-bar-chart.title": "Uitvoeringen per platform",
+ "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "Platforms",
+ "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "Aantal uitvoeringen",
+ "page.about.bread-crumb": "Over Angles",
+ "page.about.header": "Over Angles",
+ "page.about.about-angles": "Angles is een open-source dashboard wat gebruikt kan worden om test resultaten en schermafbeeldingen op te slaan voor geautomatiseerd testen van meerdere frameworks.",
+ "page.about.about-api": "Via het duidelijk omschreven {apiLink} van Angles kan elke geautomatiseerde test framework aangepast worden om zijn resultaten in Angles op te slaan",
+ "page.about.about-github": "Voor meer information over Angles kun je terecht op onze pagine: {githubLink}",
+ "page.introduction.bread-crumb": "Inleiding",
+ "page.introduction.header": "Welkom bij Angles",
+ "page.introduction.description": "Het lijkt erop dat u geen teams of omgevingen heeft ingesteld.\n\nDit kunt u doen door {apiLink} te gebruiken.",
+ "page.introduction.api-link-text": "de Swagger-API",
+ "page.introduction.description-alternative": "Of u kunt ook {githubLink} gebruiken om aan de slag te gaan.",
+ "page.introduction.github-link-text": "de Github-pagina",
+ "page.not-found.bread-crumb": "Pagina niet gevonden",
+ "page.not-found.header": "Pagina niet gevonden",
+ "page.not-found.description": "Oeps, we hebben de pagina die je aan het zoeken was niet kunnen vinden. Klik {homeLink} om terug te gaan naar de home pagina.",
+ "page.not-found.home-link-text": "hier",
+ "common.component.suite-table.header.suite": "Test Pak",
+ "common.component.suite-table.header.test": "Test",
+ "common.component.suite-table.header.duration": "Looptijd",
+ "common.component.suite-table.header.total": "Totaal",
+ "common.component.suite-table.header.collapse-all": "Sluit alle testgevallen en acties",
+ "common.component.suite-table.header.expand-all": "Open alle testgevallen",
+ "common.component.suite-table.header.expand-all-actions": "Open alle testgevallen en acties",
+ "common.component.suite-table.icon.test-history": "Zie executie geschiedenis voor test",
+ "common.component.suite-table.header.show-screenshots": "Toon screenshots van stappen",
+ "common.component.suite-table.header.hide-screenshots": "Verberg screenshots van stappen",
+ "common.component.suite-table.header.execution-count": "{count, plural, one {# test} other {# tests}}",
+ "common.component.suite-table.execution.action-count": "{count, plural, one {# actie} other {# acties}}",
+ "common.component.suite-table.action.step-count": "{count, plural, one {# stap} other {# stappen}}",
+ "common.component.screenshot-view.header": "Schermafbeeldingen kijker",
+ "common.component.screenshot-view.tabs.enable-alert": "Om de tabbladen \"Geschiedenis\" en \"Vergelijken met de baseline\" in te schakelen, geeft U een \"Weergave\" en \"Platform details\" op bij het uploaden van de schermafbeeldingen",
+ "common.component.screenshot-view.tabs.label.image": "Schermafbeelding",
+ "common.component.screenshot-view.tabs.label.history": "Geschiedenis",
+ "common.component.screenshot-view.tabs.label.overlay-with-baseline": "Vergelijken met de baseline",
+ "common.component.screenshot-view.tabs.label.side-by-side": "Naast elkaar met de baseline",
+ "common.component.screenshot-view.tabs.label.find-image": "Zoek in afbeelding",
+ "common.component.screenshot-view.tabs.baseline.label.algorithm": "Vergelijkingsalgoritme",
+ "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "Pixel",
+ "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "Structureel (SSIM)",
+ "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "Voeg dit gewijzigde gebied toe als negeer-vak",
+ "common.component.screenshot-view.tabs.find-image.message.intro": "Upload een kleinere afbeelding (bijv. een knop, logo of game-asset) om deze in de huidige schermafbeelding te vinden.",
+ "common.component.screenshot-view.tabs.find-image.label.template": "Sjabloonafbeelding",
+ "common.component.screenshot-view.tabs.find-image.label.min-confidence": "Minimale betrouwbaarheid",
+ "common.component.screenshot-view.tabs.find-image.label.max-matches": "Maximaal aantal overeenkomsten",
+ "common.component.screenshot-view.tabs.find-image.label.grayscale": "Kleurverschillen negeren (grijswaarden)",
+ "common.component.screenshot-view.tabs.find-image.button.find": "Zoek in schermafbeelding",
+ "common.component.screenshot-view.tabs.find-image.message.searching": "Bezig met zoeken naar de sjabloonafbeelding. Dit kan enkele seconden duren.",
+ "common.component.screenshot-view.tabs.find-image.message.no-matches": "Geen overeenkomsten gevonden boven de betrouwbaarheidsdrempel.",
+ "common.component.screenshot-view.tabs.find-image.label.matches": "Overeenkomst",
+ "common.component.screenshot-view.tabs.find-image.label.position": "Positie",
+ "common.component.screenshot-view.tabs.find-image.label.size": "Afmetingen",
+ "common.component.screenshot-view.tabs.find-image.label.confidence": "Betrouwbaarheid",
+ "common.component.screenshot-view.tabs.find-image.label.scale": "Schaal",
+ "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "Baseline maken",
+ "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "Dit is de baseline",
+ "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "Genereer dynamische baseline",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "Verwijder schermafbeelding",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "Alleen beschikbaar voor dynamische baselines die niet als baseline wordt gebruikt.",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "Bezig met het ophalen van de schermafbeelding",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "Kan afbeelding niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
+ "common.component.screenshot-view.tabs.history.message.loading-history": "Schermafbeelding geschiedenis aan het ophalen...",
+ "common.component.screenshot-view.tabs.history.label.baseline": "Baseline schermafbeelding",
+ "common.component.screenshot-view.tabs.history.label.view": "Weergave",
+ "common.component.screenshot-view.tabs.history.label.date": "Datum",
+ "common.component.screenshot-view.tabs.history.label.resolution": "Resolutie",
+ "common.component.screenshot-view.tabs.history.label.platform": "Platform",
+ "common.component.screenshot-view.tabs.history.label.device": "Apparaat",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "Huidige schermafbeelding (en baseline)",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image": "Huidige schermafbeelding",
+ "common.component.screenshot-view.tabs.side-by-side.label.baseline": "Baseline",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "Baseline ophalen.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "Kan de baseline niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "Schermafbeelding ophalen.",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "Kan de schermafbeelding niet ophalen. Vernieuw de pagina en probeer het opnieuw.",
+ "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "Huidige schermafbeelding (en baseline)",
+ "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "Baseline vergelijking",
+ "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "Vergelijk opnieuw met de baseline.",
+ "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "Bewerk negeer blokken",
+ "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "Sla negeer blokken op",
+ "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "Annuleer wijziging",
+ "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "Er is nog geen baseline geselecteerd voor deze weergave en apparaatnaam of browsercombinatie. To select a baseline, navigate to the image you want as a baseline and click on the \"Make Baseline Image\" button",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "Vergelijking met baseline aan het ophalen",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "Kan baseline vergelijking niet ophalen.",
+ "common.component.screenshot-details-table.label.view": "Weergave",
+ "common.component.screenshot-details-table.label.view-link": "andere platforms",
+ "common.component.screenshot-details-table.label.view-link-description": "Vind de nieuwste schermafbeeldingen voor weergave{view}, gegroepeerd per platform.",
+ "common.component.screenshot-details-table.label.no-view": "Geen weergave beschikbaar",
+ "common.component.screenshot-details-table.label.date": "Datum",
+ "common.component.screenshot-details-table.label.baseline-date": "Baseline datum",
+ "common.component.screenshot-details-table.label.mismatch": "Mismatch",
+ "common.component.screenshot-details-table.label.algorithm": "Algoritme",
+ "common.component.screenshot-details-table.label.ssim-score": "SSIM-score",
+ "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (analyse tijd: {time} ms)",
+ "common.component.screenshot-details-table.label.resolution": "Resolutie",
+ "common.component.screenshot-details-table.label.platform": "Platform",
+ "common.component.screenshot-details-table.label.no-platform": "Geen platform voorzien",
+ "common.component.screenshot-details-table.label.platform-version": "Platform versie",
+ "common.component.screenshot-details-table.label.device": "Apparaat",
+ "common.component.screenshot-details-table.label.browser": "Browser",
+ "common.component.screenshot-details-table.label.no-browser": "Geen browser voorzien",
+ "common.component.screenshot-details-table.label.browser-version": "Browser versie",
+ "common.component.screenshot-details-table.label.no-browser-version": "Geen browser versie voorzien",
+ "common.component.screenshot-details-table.label.baseline": "Baseline",
+ "common.component.screenshot-details-table.label.tags": "Labels",
+ "common.component.screenshot-details-table.label.tags-description": "Vind de laatste screenshots met label \"{tag}\", gegroepeerd op weergave.",
+ "common.yes": "Ja",
+ "common.no": "Nee",
+ "common.button.ok": "OK",
+ "page.login.title": "Inloggen op Angles",
+ "page.login.error.invalid-credentials": "Ongeldige gebruikersnaam of wachtwoord",
+ "page.login.label.username": "Gebruikersnaam",
+ "page.login.label.password": "Wachtwoord",
+ "page.login.button.login": "Inloggen",
+ "page.login.divider.or": "OF",
+ "page.login.label.sign-in-with": "Inloggen met",
+ "page.login.source.local": "Lokaal account",
+ "page.login.button.sso": "Doorgaan met {name}",
+ "page.login.error.sso-failed": "Single sign-on is mislukt. Probeer het opnieuw of neem contact op met uw beheerder.",
+ "page.login.error.no-methods": "Er zijn geen inlogmethoden beschikbaar. Neem contact op met uw beheerder.",
+ "page.user-settings.header": "Gebruikersinstellingen",
+ "page.user-settings.api-tokens.title": "API-tokens",
+ "page.user-settings.api-tokens.description": "Persoonlijke API-tokens werken net als gewone wachtwoorden, maar hiermee kunt u zich programmatisch verifiëren bij de API.",
+ "page.user-settings.button.generate-new-token": "Nieuw token genereren",
+ "page.user-settings.table.name": "Naam",
+ "page.user-settings.table.created": "Aangemaakt",
+ "page.user-settings.table.expires": "Verloopt",
+ "page.user-settings.table.action": "Actie",
+ "page.user-settings.table.revoke": "Intrekken",
+ "page.user-settings.modal.token-generated": "Token gegenereerd",
+ "page.user-settings.modal.generate-token": "Nieuw API-token genereren",
+ "page.user-settings.modal.success-label": "Succes!",
+ "page.user-settings.modal.success": "Uw nieuwe token is gegenereerd.",
+ "page.user-settings.modal.warning": "Kopieer dit token nu. U zult het hierna niet meer kunnen zien!",
+ "page.user-settings.modal.token-name": "Token naam",
+ "page.user-settings.modal.token-placeholder": "bijv. CI/CD Pipeline",
+ "page.user-settings.modal.expiration": "Vervaldatum",
+ "page.user-settings.modal.custom-expiration": "Aangepaste vervaldatum (Dagen)",
+ "page.user-settings.button.done": "Klaar",
+ "page.user-settings.button.generate": "Genereren",
+ "page.user-settings.button.cancel": "Annuleren",
+ "page.user-settings.confirm.revoke.title": "Token intrekken",
+ "page.user-settings.confirm.revoke.message": "Weet u zeker dat u dit token wilt intrekken? Scripts of toepassingen die dit token gebruiken, verliezen onmiddellijk toegang.",
+ "page.user-settings.table.expires-never": "Nooit",
+ "page.user-settings.modal.token-label": "Token",
+ "page.user-settings.modal.button.copy": "Kopiëren",
+ "page.user-settings.expiry.1-day": "1 Dag",
+ "page.user-settings.expiry.1-week": "1 Week",
+ "page.user-settings.expiry.1-month": "1 Maand",
+ "page.user-settings.expiry.1-year": "1 Jaar",
+ "page.user-settings.expiry.custom": "Aangepast",
+ "page.user-settings.toast.fetch-tokens-error": "Ophalen van tokens mislukt",
+ "page.user-settings.toast.token-name-required": "Tokennaam is verplicht",
+ "page.user-settings.toast.expiry-required": "Geldige vervaldagen zijn vereist",
+ "page.user-settings.toast.generate-token-error": "Genereren van token mislukt",
+ "page.user-settings.toast.revoke-success": "Token succesvol ingetrokken",
+ "page.user-settings.toast.revoke-error": "Intrekken van token mislukt",
+ "page.user-settings.toast.copy-success": "Token gekopieerd naar klembord",
+ "password.policy.min-length": "Minimaal 10 tekens lang zijn",
+ "password.policy.max-length": "Maximaal 100 tekens lang zijn",
+ "password.policy.letter": "Ten minste één letter bevatten",
+ "password.policy.uppercase": "Ten minste één hoofdletter bevatten",
+ "password.policy.special": "Ten minste één speciaal teken bevatten",
+ "password.policy.invalid": "Wachtwoord voldoet niet aan de beveiligingsvereisten",
+ "page.user-settings.password.title": "Wachtwoord",
+ "page.user-settings.password.description": "Wijzig het wachtwoord waarmee u zich aanmeldt bij Angles.",
+ "page.user-settings.password.button.change": "Wachtwoord wijzigen",
+ "page.user-settings.password.modal.title": "Wachtwoord wijzigen",
+ "page.user-settings.password.modal.current": "Huidig wachtwoord",
+ "page.user-settings.password.modal.new": "Nieuw wachtwoord",
+ "page.user-settings.password.modal.confirm": "Bevestig nieuw wachtwoord",
+ "page.user-settings.password.modal.mismatch": "Wachtwoorden komen niet overeen",
+ "page.user-settings.password.modal.submit": "Wachtwoord bijwerken",
+ "page.user-settings.password.modal.success": "Uw wachtwoord is bijgewerkt. Gebruik het de volgende keer dat u zich aanmeldt.",
+ "page.user-settings.password.toast.current-required": "Huidig wachtwoord is verplicht",
+ "page.user-settings.password.toast.mismatch": "Nieuw wachtwoord en bevestiging komen niet overeen",
+ "page.user-settings.password.toast.success": "Wachtwoord succesvol bijgewerkt",
+ "page.user-settings.password.toast.error": "Wachtwoord bijwerken mislukt",
+ "page.admin.settings.header": "Authenticatie instellingen",
+ "page.admin.settings.loading": "Instellingen laden...",
+ "page.admin.settings.local-auth.title": "Lokale Authenticatie",
+ "page.admin.settings.local-auth.enable": "Lokale Authenticatie inschakelen",
+ "page.admin.settings.local-auth.help": "Laat gebruikers inloggen met een gebruikersnaam en wachtwoord.",
+ "page.admin.settings.button.save": "Instellingen opslaan",
+ "page.admin.users.header": "Gebruikersbeheer",
+ "page.admin.users.button.add-user": "Gebruiker Toevoegen",
+ "page.admin.users.table.username": "Gebruikersnaam",
+ "page.admin.users.table.role": "Rol",
+ "page.admin.users.table.teams": "Teams",
+ "page.admin.users.table.action": "Actie",
+ "page.admin.users.table.edit": "Bewerk",
+ "page.admin.users.table.delete": "Verwijder",
+ "page.admin.users.modal.edit-user": "Gebruiker bewerken",
+ "page.admin.users.modal.add-user": "Gebruiker Toevoegen",
+ "page.admin.users.modal.password-help": "(Leeg laten om ongewijzigd te behouden)",
+ "page.admin.users.modal.user-type": "Gebruikerstype",
+ "page.admin.users.modal.team-access": "Team Toegang",
+ "page.admin.users.button.save": "Opslaan",
+ "page.admin.users.button.cancel": "Annuleren",
+ "page.admin.users.confirm.delete.title": "Gebruiker verwijderen",
+ "page.admin.users.confirm.delete.message": "Weet u zeker dat u deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
+ "page.admin.users.user-type.admin": "Beheerder",
+ "page.admin.users.user-type.team_lead": "Teamleider",
+ "page.admin.users.user-type.user": "Gebruiker",
+ "page.admin.users.modal.password-label": "Wachtwoord",
+ "page.admin.users.table.teams-assigned": "{count} teams toegewezen",
+ "page.admin.users.toast.fetch-error": "Ophalen van gebruikers mislukt",
+ "page.admin.users.toast.update-success": "Gebruiker succesvol bijgewerkt",
+ "page.admin.users.toast.create-success": "Gebruiker succesvol aangemaakt",
+ "page.admin.users.toast.save-error": "Opslaan van gebruiker mislukt",
+ "page.admin.users.toast.delete-success": "Gebruiker succesvol verwijderd",
+ "page.admin.users.toast.delete-error": "Verwijderen van gebruiker mislukt",
+ "page.admin.settings.toast.fetch-error": "Ophalen van instellingen mislukt",
+ "page.admin.settings.toast.save-success": "Instellingen succesvol opgeslagen",
+ "page.admin.settings.toast.save-error": "Opslaan van instellingen mislukt",
+ "page.admin.settings.providers.title": "Identiteitsproviders",
+ "page.admin.settings.providers.help": "Configureer single sign-on-providers. Wijzigingen zijn direct actief — een herstart is niet nodig.",
+ "page.admin.settings.providers.empty": "Er zijn nog geen identiteitsproviders geconfigureerd.",
+ "page.admin.settings.providers.table.name": "Naam",
+ "page.admin.settings.providers.table.id": "ID",
+ "page.admin.settings.providers.table.type": "Type",
+ "page.admin.settings.providers.table.enabled": "Ingeschakeld",
+ "page.admin.settings.providers.table.status": "Status",
+ "page.admin.settings.button.add-provider": "Provider toevoegen",
+ "page.admin.settings.button.save-provider": "Provider opslaan",
+ "page.admin.settings.confirm.delete.title": "Provider verwijderen",
+ "page.admin.settings.confirm.delete.message": "Weet u zeker dat u deze identiteitsprovider wilt verwijderen? Gebruikers die hiermee inloggen verliezen hun toegang.",
+ "page.admin.settings.provider.modal.add": "Identiteitsprovider toevoegen",
+ "page.admin.settings.provider.modal.edit": "Identiteitsprovider bewerken",
+ "page.admin.settings.provider.name": "Weergavenaam",
+ "page.admin.settings.provider.name.help": "Wordt getoond op de inlogknop, bijvoorbeeld \"Bedrijfs-SSO\".",
+ "page.admin.settings.provider.id": "Provider-ID",
+ "page.admin.settings.provider.id.help": "2-32 kleine letters, cijfers of koppeltekens. Maakt deel uit van de callback-URL en kan later niet worden gewijzigd.",
+ "page.admin.settings.provider.id.help-locked": "De ID maakt deel uit van de callback-URL die bij de identiteitsprovider is geregistreerd en kan niet worden gewijzigd.",
+ "page.admin.settings.provider.type": "Providertype",
+ "page.admin.settings.provider.type.oidc": "OIDC",
+ "page.admin.settings.provider.type.saml": "SAML 2.0",
+ "page.admin.settings.provider.type.ldap": "LDAP / AD",
+ "page.admin.settings.provider.type.help-locked": "Het providertype kan na aanmaken niet meer worden gewijzigd.",
+ "page.admin.settings.provider.enabled": "Ingeschakeld",
+ "page.admin.settings.provider.enabled.help": "Toon deze provider op de inlogpagina.",
+ "page.admin.settings.provider.callback-url": "Redirect-URI",
+ "page.admin.settings.provider.acs-url": "ACS- (callback-)URL",
+ "page.admin.settings.provider.callback-url.help": "Registreer deze URL bij uw identiteitsprovider.",
+ "page.admin.settings.provider.status.ready": "Gereed",
+ "page.admin.settings.provider.status.error": "Fout",
+ "page.admin.settings.provider.status.enabled": "Ingeschakeld",
+ "page.admin.settings.provider.status.disabled": "Uitgeschakeld",
+ "page.admin.settings.provider.default-role": "Standaardrol",
+ "page.admin.settings.provider.default-role.deny": "Toegang weigeren",
+ "page.admin.settings.provider.default-role.help": "Rol die wordt toegekend als geen enkele koppeling overeenkomt. Toegang weigeren is de veiligere standaard.",
+ "page.admin.settings.provider.secret.help-set": "Er is een waarde geconfigureerd. Laat leeg om deze te behouden, of voer een nieuwe waarde in om deze te vervangen.",
+ "page.admin.settings.provider.secret.help-unset": "Na het opslaan kan deze waarde niet meer worden bekeken.",
+ "page.admin.settings.provider.error.id": "De provider-ID moet bestaan uit 2-32 kleine letters, cijfers of koppeltekens.",
+ "page.admin.settings.provider.error.duplicate-id": "Een andere provider gebruikt deze ID al.",
+ "page.admin.settings.provider.error.name": "Een weergavenaam is verplicht.",
+ "page.admin.settings.provider.error.mapping-value": "Elke rolkoppeling heeft een groepswaarde nodig.",
+ "page.admin.settings.oidc.title": "OIDC-configuratie",
+ "page.admin.settings.oidc.issuer": "Issuer-URL",
+ "page.admin.settings.oidc.issuer.help": "Endpoints en ondertekeningssleutels worden opgehaald via .well-known/openid-configuration van de issuer.",
+ "page.admin.settings.oidc.client-id": "Client-ID",
+ "page.admin.settings.oidc.client-secret": "Client secret",
+ "page.admin.settings.oidc.scopes": "Scopes",
+ "page.admin.settings.oidc.scopes.help": "Gescheiden door spaties. \"openid\" wordt altijd toegevoegd. Okta en Keycloak accepteren \"groups\"; Google weigert dit.",
+ "page.admin.settings.oidc.groups-claim": "Groups-claim",
+ "page.admin.settings.oidc.groups-claim.help": "Claim met het groepslidmaatschap — \"groups\" voor Okta, vaak \"roles\" voor Keycloak. Entra ID levert group-GUID’s.",
+ "page.admin.settings.oidc.username-claim": "Gebruikersnaam-claim",
+ "page.admin.settings.oidc.username-claim.help": "Claim die als Angles-gebruikersnaam wordt gebruikt. Valt terug op email, preferred_username en daarna sub.",
+ "page.admin.settings.saml.title": "SAML 2.0-configuratie",
+ "page.admin.settings.saml.metadata-url": "SP-metadata-URL",
+ "page.admin.settings.saml.metadata-url.help": "Importeer deze in uw identiteitsprovider in plaats van de waarden handmatig in te voeren.",
+ "page.admin.settings.saml.entry-point": "IdP SSO-URL",
+ "page.admin.settings.saml.entry-point.help": "De URL van de identiteitsprovider waarnaar de browser wordt doorgestuurd.",
+ "page.admin.settings.saml.idp-cert": "Ondertekeningscertificaat van de IdP",
+ "page.admin.settings.saml.idp-cert.help": "Verplicht — het is de enige reden waarom een assertion vertrouwd kan worden. Wordt geaccepteerd met of zonder PEM-headerregels.",
+ "page.admin.settings.saml.issuer": "SP entity-ID",
+ "page.admin.settings.saml.issuer.help": "De entity-ID van deze service provider, zoals geregistreerd bij de identiteitsprovider.",
+ "page.admin.settings.saml.private-key": "Privésleutel van de SP",
+ "page.admin.settings.saml.private-key.help": "Alleen nodig wanneer de identiteitsprovider ondertekende authenticatieverzoeken vereist. Laat leeg om de opgeslagen sleutel te behouden.",
+ "page.admin.settings.saml.signature-algorithm": "Handtekeningalgoritme",
+ "page.admin.settings.saml.identifier-format": "Name ID-formaat",
+ "page.admin.settings.saml.username-attribute": "Gebruikersnaamattribuut",
+ "page.admin.settings.saml.username-attribute.help": "Assertion-attribuut met de gebruikersnaam. Laat leeg om de Name ID te gebruiken.",
+ "page.admin.settings.saml.groups-attribute": "Groepsattribuut",
+ "page.admin.settings.saml.response-signed": "Ondertekende response vereisen",
+ "page.admin.settings.saml.response-signed.help": "Schakel uit voor identiteitsproviders die alleen de assertion ondertekenen. Assertion-handtekeningen zijn altijd verplicht.",
+ "page.admin.settings.saml.allow-unsolicited": "IdP-geïnitieerd inloggen toestaan",
+ "page.admin.settings.saml.allow-unsolicited.help": "Standaard uit: een ongevraagde assertion heeft geen verzoek om mee te correleren, waardoor de replay-beveiliging vervalt.",
+ "page.admin.settings.ldap.title": "LDAP-/Active Directory-configuratie",
+ "page.admin.settings.ldap.url": "Directory-URL",
+ "page.admin.settings.ldap.url.help": "Gebruik ldaps://, of ldap:// met StartTLS. Een gewone ldap://-verbinding wordt geweigerd omdat wachtwoorden dan onversleuteld worden verzonden.",
+ "page.admin.settings.ldap.bind-dn": "Bind-DN",
+ "page.admin.settings.ldap.bind-dn.help": "Serviceaccount waarmee de gebruiker wordt opgezocht. Laat leeg voor een anonieme zoekopdracht.",
+ "page.admin.settings.ldap.bind-credentials": "Bind-wachtwoord",
+ "page.admin.settings.ldap.search-base": "Zoekbasis voor gebruikers",
+ "page.admin.settings.ldap.search-base.help": "Bijvoorbeeld: ou=users,dc=example,dc=com",
+ "page.admin.settings.ldap.search-filter": "Zoekfilter voor gebruikers",
+ "page.admin.settings.ldap.search-filter.help": "{username} wordt vervangen door de ingevoerde gebruikersnaam. Active Directory gebruikt meestal (sAMAccountName={username}).",
+ "page.admin.settings.ldap.username-attribute": "Gebruikersnaamattribuut",
+ "page.admin.settings.ldap.username-attribute.help": "Attribuut dat als Angles-gebruikersnaam wordt gebruikt — \"uid\", of \"sAMAccountName\" bij Active Directory.",
+ "page.admin.settings.ldap.group-search-base": "Zoekbasis voor groepen",
+ "page.admin.settings.ldap.group-search-base.help": "Laat leeg om het groepslidmaatschap te lezen uit het memberOf-attribuut van de gebruiker, zoals Active Directory dit gewoonlijk aanbiedt.",
+ "page.admin.settings.ldap.group-search-filter": "Zoekfilter voor groepen",
+ "page.admin.settings.ldap.group-name-attribute": "Groepsnaamattribuut",
+ "page.admin.settings.ldap.group-name-attribute.help": "Volledige DN’s worden teruggebracht tot dit attribuut, koppel rollen dus aan namen zoals \"Angles Admins\".",
+ "page.admin.settings.ldap.start-tls": "StartTLS gebruiken",
+ "page.admin.settings.ldap.start-tls.help": "Waardeer een gewone ldap://-verbinding op naar TLS.",
+ "page.admin.settings.ldap.tls-reject-unauthorized": "TLS-certificaat verifiëren",
+ "page.admin.settings.ldap.tls-reject-unauthorized.help": "Dit uitschakelen maakt de verbinding eenvoudig te onderscheppen. Alleen voor testopstellingen met zelfondertekende certificaten.",
+ "page.admin.settings.role-mappings.title": "Groep-naar-rolkoppeling",
+ "page.admin.settings.role-mappings.help": "Koppel groepen die de provider doorgeeft aan Angles-rollen. Hoofdlettergebruik maakt niet uit, en een gebruiker die aan meerdere koppelingen voldoet krijgt de zwaarste rol (Admin > Teamleider > Gebruiker).",
+ "page.admin.settings.role-mappings.empty": "Nog geen koppelingen. Zonder koppeling hangt de toegang af van de standaardrol hieronder.",
+ "page.admin.settings.role-mappings.add": "Koppeling toevoegen",
+ "page.admin.settings.role-mappings.remove": "Koppeling verwijderen",
+ "page.admin.settings.role-mappings.value-placeholder": "Groepsnaam of -ID",
+ "page.team-settings.header": "Team Instellingen",
+ "page.team-settings.label.team": "Team om te configureren",
+ "page.team-settings.label.team.help": "Dit wijzigt alleen welk team u hier bewerkt. Het verandert niets aan het team dat in het bovenste menu is geselecteerd.",
+ "page.team-settings.section.details": "Team Details",
+ "page.team-settings.label.name": "Teamnaam",
+ "page.team-settings.button.save-name": "Naam Opslaan",
+ "page.team-settings.section.components": "Onderdelen",
+ "page.team-settings.components.help": "Met onderdelen kunt u uw testdekking groeperen per functioneel gebied.",
+ "page.team-settings.label.existing-components": "Bestaande Onderdelen",
+ "page.team-settings.label.add-components": "Onderdelen Toevoegen",
+ "page.team-settings.components.add-placeholder": "Typ een onderdeelnaam en druk op enter",
+ "page.team-settings.button.add-components": "Onderdelen Toevoegen",
+ "page.team-settings.no-team-access": "U heeft nog geen toegang tot teams. Neem contact op met een beheerder.",
+ "page.team-settings.toast.fetch-error": "Ophalen van teamgegevens mislukt",
+ "page.team-settings.toast.name-update-success": "Teamnaam succesvol bijgewerkt",
+ "page.team-settings.toast.name-update-error": "Bijwerken van teamnaam mislukt",
+ "page.team-settings.toast.components-update-success": "Onderdelen succesvol toegevoegd",
+ "page.team-settings.toast.components-update-error": "Toevoegen van onderdelen mislukt",
+ "nav.manual-test-cases": "Handmatige Testgevallen",
+ "app.manual.status.draft": "Concept",
+ "app.manual.status.active": "Actief",
+ "app.manual.status.deprecated": "Vervallen",
+ "app.manual.priority.low": "Laag",
+ "app.manual.priority.medium": "Gemiddeld",
+ "app.manual.priority.high": "Hoog",
+ "app.manual.priority.critical": "Kritiek",
+ "app.manual.cancel": "Annuleren",
+ "app.manual.version-badge.version": "v{version}",
+ "app.manual.version-badge.behind": "· {count} achter op laatste",
+ "app.manual.history.loading": "Geschiedenis laden...",
+ "app.manual.history.empty": "Nog geen wijzigingen vastgelegd.",
+ "app.manual.history.unknown-author": "Onbekende gebruiker",
+ "app.manual.history.caused-by": "Veroorzaakt door een wijziging in gedeelde stap \"{name}\"",
+ "app.manual.history.action.create": "Aangemaakt",
+ "app.manual.history.action.update": "Bijgewerkt",
+ "app.manual.history.action.delete": "Verwijderd",
+ "app.manual.history.action.clone": "Gekloond",
+ "app.manual.history.action.status-change": "Status gewijzigd",
+ "app.manual.history.action.shared-step-update": "Gedeelde stap bijgewerkt",
+ "app.manual.history.action.archive": "Gearchiveerd",
+ "app.manual.step-editor.expected": "Verwacht",
+ "app.manual.step-editor.data": "Gegevens",
+ "app.manual.step-editor.shared-step": "Gedeelde stap",
+ "app.manual.step-editor.shared-step-placeholder": "Kies een gedeelde stap",
+ "app.manual.step-editor.shared-step-help": "De stappen ervan vervangen deze bij het uitvoeren of opslaan van een versie.",
+ "app.manual.step-editor.from-shared-step": "Uit gedeelde stap (v{version})",
+ "app.manual.step-editor.unresolved": "Gedeelde stap bestaat niet meer",
+ "app.manual.step-editor.action-placeholder": "Wat moet de tester doen?",
+ "app.manual.step-editor.expected-placeholder": "Wat moet er gebeuren?",
+ "app.manual.step-editor.data-placeholder": "Testgegevens (optioneel)",
+ "app.manual.step-editor.add-step": "Stap toevoegen",
+ "app.manual.step-editor.add-shared-step": "Gedeelde stap opnemen",
+ "app.manual.step-editor.remove": "Stap verwijderen",
+ "app.manual.step-editor.empty": "Nog geen stappen. Voeg hieronder de eerste toe.",
+ "app.manual.custom-field.archived": "(gearchiveerd)",
+ "page.manual-test-cases": "Handmatige Testgevallen",
+ "page.manual-test-cases.create": "Nieuw testgeval",
+ "page.manual-test-cases.new-case-title": "Naamloos testgeval",
+ "page.manual-test-cases.loading": "Testgevallen laden...",
+ "page.manual-test-cases.empty": "Geen testgevallen voldoen aan deze filters.",
+ "page.manual-test-cases.no-team": "Selecteer een team in het menu boven aan de pagina om de handmatige testgevallen te zien.",
+ "page.manual-test-cases.filters.search": "Zoek in titel of omschrijving",
+ "page.manual-test-cases.filters.status": "Status",
+ "page.manual-test-cases.filters.priority": "Prioriteit",
+ "page.manual-test-cases.table.title": "Titel",
+ "page.manual-test-cases.table.status": "Status",
+ "page.manual-test-cases.table.priority": "Prioriteit",
+ "page.manual-test-cases.table.version": "Versie",
+ "page.manual-test-cases.table.updated-by": "Bijgewerkt door",
+ "page.manual-test-cases.table.steps": "Stappen",
+ "page.manual-test-cases.toast.fetch-error": "Kon de testgevallen niet laden.",
+ "page.manual-test-cases.toast.create-error": "Kon het testgeval niet aanmaken.",
+ "page.manual-test-case-detail.loading": "Testgeval laden...",
+ "page.manual-test-case-detail.save": "Opslaan",
+ "page.manual-test-case-detail.clone": "Klonen",
+ "page.manual-test-case-detail.delete": "Verwijderen",
+ "page.manual-test-case-detail.tab.details": "Details",
+ "page.manual-test-case-detail.tab.steps": "Stappen",
+ "page.manual-test-case-detail.tab.versions": "Versies",
+ "page.manual-test-case-detail.tab.history": "Geschiedenis",
+ "page.manual-test-case-detail.field.title": "Titel",
+ "page.manual-test-case-detail.field.description": "Omschrijving",
+ "page.manual-test-case-detail.field.preconditions": "Voorwaarden",
+ "page.manual-test-case-detail.field.status": "Status",
+ "page.manual-test-case-detail.field.priority": "Prioriteit",
+ "page.manual-test-case-detail.field.tags": "Labels",
+ "page.manual-test-case-detail.status-help": "Verplichte aangepaste velden gelden pas zodra een geval niet langer Concept is.",
+ "page.manual-test-case-detail.custom-fields": "Aangepaste velden",
+ "page.manual-test-case-detail.select-version": "Kies een versie",
+ "page.manual-test-case-detail.version-option": "Versie {version}",
+ "page.manual-test-case-detail.versions-help": "Kies een versie om te zien wat een tester zag bij de uitvoering.",
+ "page.manual-test-case-detail.delete-confirm-title": "Testgeval verwijderen",
+ "page.manual-test-case-detail.delete-confirm-message": "Dit verwijdert het testgeval en de volledige versiegeschiedenis. Dit kan niet ongedaan worden gemaakt.",
+ "page.manual-test-case-detail.toast.fetch-error": "Kon het testgeval niet laden.",
+ "page.manual-test-case-detail.toast.save-error": "Kon het testgeval niet opslaan.",
+ "page.manual-test-case-detail.toast.clone-error": "Kon het testgeval niet klonen.",
+ "page.manual-test-case-detail.toast.delete-error": "Kon het testgeval niet verwijderen.",
+ "page.manual-test-case-detail.toast.version-error": "Kon die versie niet laden.",
+ "page.manual-test-case-detail.toast.history-error": "Kon de geschiedenis niet laden.",
+ "app.execution-type.all": "Alle",
+ "app.execution-type.automated": "Geautomatiseerd",
+ "app.execution-type.manual": "Handmatig",
+ "page.dashboard.filters.label.execution-type": "Type",
+ "page.metrics.filters.labels.execution-type": "Type",
+ "app.manual.save": "Opslaan",
+ "app.manual.delete": "Verwijderen",
+ "app.manual.edit": "Bewerken",
+ "app.manual.actions": "Acties",
+ "app.manual.loading": "Laden...",
+ "app.manual.yes": "Ja",
+ "app.manual.no": "Nee",
+ "app.manual.no-team": "Selecteer een team in het menu boven aan de pagina om door te gaan.",
+ "app.manual.field.description": "Omschrijving",
+ "app.manual.field.preconditions": "Voorwaarden",
+ "app.manual.field.priority": "Prioriteit",
+ "app.manual.field.tags": "Tags",
+ "app.manual.field.title": "Titel",
+ "app.manual.field.expected": "Verwacht",
+ "app.manual.field.steps": "Stappen",
+ "app.manual.attachments.drop-hint": "Sleep een afbeelding hierheen of klik om er een te kiezen",
+ "app.manual.attachments.uploading": "Uploaden...",
+ "app.manual.attachments.upload-error": "Kon de bijlage niet uploaden.",
+ "app.manual.attachments.delete-error": "Kon de bijlage niet verwijderen. Mogelijk wordt deze gebruikt door een bevroren versie.",
+ "app.manual.attachments.copy-reference": "Markdown-verwijzing kopiëren",
+ "app.manual.attachments.reference-copied": "Verwijzing gekopieerd naar het klembord.",
+ "app.manual.version-diff.identical": "Deze twee versies hebben identieke inhoud.",
+ "app.manual.version-diff.field": "Veld",
+ "app.manual.version-diff.before": "Voor",
+ "app.manual.version-diff.after": "Na",
+ "app.manual.version-diff.empty": "(leeg)",
+ "app.manual.version-diff.step": "Stap {number}",
+ "app.manual.custom-field.type.text": "Tekst",
+ "app.manual.custom-field.type.textarea": "Lange tekst",
+ "app.manual.custom-field.type.number": "Getal",
+ "app.manual.custom-field.type.date": "Datum",
+ "app.manual.custom-field.type.boolean": "Ja / Nee",
+ "app.manual.custom-field.type.select": "Enkele keuze",
+ "app.manual.custom-field.type.multiselect": "Meerdere keuzes",
+ "app.manual.custom-field.type.user": "Gebruiker",
+ "app.manual.custom-field.scope.testcase": "Testgevallen",
+ "app.manual.custom-field.scope.execution": "Uitvoeringen",
+ "app.manual.custom-field.scope.both": "Beide",
+ "app.manual.run-status.planned": "Gepland",
+ "app.manual.run-status.in_progress": "Bezig",
+ "app.manual.run-status.completed": "Voltooid",
+ "app.manual.run-status.cancelled": "Geannuleerd",
+ "app.manual.case-status.pass": "Geslaagd",
+ "app.manual.case-status.fail": "Mislukt",
+ "app.manual.case-status.error": "Fout",
+ "app.manual.case-status.skipped": "Overgeslagen",
+ "app.manual.case-status.blocked": "Geblokkeerd",
+ "app.manual.case-status.not_run": "Niet uitgevoerd",
+ "app.manual.case-status.in_progress": "Bezig",
+ "app.manual.step-status.not_run": "Niet uitgevoerd",
+ "app.manual.step-status.pass": "Geslaagd",
+ "app.manual.step-status.fail": "Mislukt",
+ "app.manual.step-status.blocked": "Geblokkeerd",
+ "app.manual.step-status.skipped": "Overgeslagen",
+ "nav.manual-testing": "Handmatig testen",
+ "nav.manual-test-runs": "Testruns",
+ "nav.shared-steps": "Gedeelde stappen",
+ "nav.admin.custom-fields": "Aangepaste velden",
+ "page.admin-custom-fields.title": "Aangepaste velden",
+ "page.admin-custom-fields.help": "Extra velden die op elk handmatig testgeval van dit team worden getoond. Verplichte velden gelden pas zodra een testgeval de conceptstatus verlaat.",
+ "page.admin-custom-fields.button.add": "Veld toevoegen",
+ "page.admin-custom-fields.archived": "Gearchiveerd",
+ "page.admin-custom-fields.header.label": "Label",
+ "page.admin-custom-fields.header.key": "Sleutel",
+ "page.admin-custom-fields.header.type": "Type",
+ "page.admin-custom-fields.header.required": "Verplicht",
+ "page.admin-custom-fields.header.applies-to": "Van toepassing op",
+ "page.admin-custom-fields.modal.add-title": "Aangepast veld toevoegen",
+ "page.admin-custom-fields.modal.edit-title": "Aangepast veld bewerken",
+ "page.admin-custom-fields.form.label": "Label",
+ "page.admin-custom-fields.form.key": "Sleutel",
+ "page.admin-custom-fields.form.key-help": "Kleine letters, cijfers en underscores. Kan niet worden gewijzigd nadat het veld is aangemaakt.",
+ "page.admin-custom-fields.form.key-invalid": "Moet beginnen met een kleine letter en mag alleen kleine letters, cijfers en underscores bevatten (max. 40 tekens).",
+ "page.admin-custom-fields.form.type": "Type",
+ "page.admin-custom-fields.form.options": "Opties",
+ "page.admin-custom-fields.form.applies-to": "Van toepassing op",
+ "page.admin-custom-fields.form.order": "Weergavevolgorde",
+ "page.admin-custom-fields.form.required": "Verplicht",
+ "page.admin-custom-fields.form.required-help": "Geldt alleen zodra een testgeval de conceptstatus verlaat.",
+ "page.admin-custom-fields.delete-confirm-title": "Aangepast veld verwijderen",
+ "page.admin-custom-fields.delete-confirm-message": "Als een testgeval nog een waarde voor dit veld heeft, wordt het gearchiveerd in plaats van verwijderd zodat bestaande inhoud leesbaar blijft.",
+ "page.admin-custom-fields.toast.fetch-error": "Kon de aangepaste velden niet laden.",
+ "page.admin-custom-fields.toast.save-error": "Kon het aangepaste veld niet opslaan.",
+ "page.admin-custom-fields.toast.delete-error": "Kon het aangepaste veld niet verwijderen.",
+ "page.admin-custom-fields.toast.archived": "Het veld is in gebruik en is daarom gearchiveerd in plaats van verwijderd.",
+ "page.admin-custom-fields.toast.deleted": "Aangepast veld verwijderd.",
+ "page.shared-steps.title": "Gedeelde stappen",
+ "page.shared-steps.help": "Herbruikbare stappenreeksen die door meerdere testgevallen kunnen worden opgenomen. Bij bewerking krijgt elk testgeval dat ze bevat een nieuwe versie.",
+ "page.shared-steps.button.add": "Gedeelde stap toevoegen",
+ "page.shared-steps.search-placeholder": "Zoek gedeelde stappen",
+ "page.shared-steps.header.name": "Naam",
+ "page.shared-steps.header.description": "Omschrijving",
+ "page.shared-steps.header.steps": "Stappen",
+ "page.shared-steps.header.version": "Versie",
+ "page.shared-steps.action.usage": "Gebruik",
+ "page.shared-steps.action.history": "Geschiedenis",
+ "page.shared-steps.modal.add-title": "Gedeelde stap toevoegen",
+ "page.shared-steps.modal.edit-title": "Gedeelde stap bewerken",
+ "page.shared-steps.form.name": "Naam",
+ "page.shared-steps.form.description": "Omschrijving",
+ "page.shared-steps.cascade-warning": "{count} testgevallen bevatten deze gedeelde stap. Opslaan maakt van elk daarvan een nieuwe versie.",
+ "page.shared-steps.usage.title": "Testgevallen die deze gedeelde stap gebruiken",
+ "page.shared-steps.usage.empty": "Nog geen testgevallen bevatten deze gedeelde stap.",
+ "page.shared-steps.history.title": "Geschiedenis van gedeelde stap",
+ "page.shared-steps.delete-confirm-title": "Gedeelde stap verwijderen",
+ "page.shared-steps.delete-confirm-message": "Een gedeelde stap waarnaar nog wordt verwezen door een testgeval kan niet worden verwijderd.",
+ "page.shared-steps.toast.fetch-error": "Kon de gedeelde stappen niet laden.",
+ "page.shared-steps.toast.save-error": "Kon de gedeelde stap niet opslaan.",
+ "page.shared-steps.toast.delete-error": "Kon de gedeelde stap niet verwijderen.",
+ "page.shared-steps.toast.usage-error": "Kon de gebruikslijst niet laden.",
+ "page.shared-steps.toast.history-error": "Kon de geschiedenis niet laden.",
+ "page.shared-steps.toast.cascaded": "{count} testgevallen hebben een nieuwe versie gekregen.",
+ "page.manual-test-runs.title": "Handmatige testruns",
+ "page.manual-test-runs.button.add": "Nieuwe testrun",
+ "page.manual-test-runs.button.create": "Run aanmaken",
+ "page.manual-test-runs.filter.status": "Status",
+ "page.manual-test-runs.header.name": "Naam",
+ "page.manual-test-runs.header.status": "Status",
+ "page.manual-test-runs.header.progress": "Voortgang",
+ "page.manual-test-runs.header.environment": "Omgeving",
+ "page.manual-test-runs.modal.title": "Nieuwe testrun",
+ "page.manual-test-runs.form.name": "Naam",
+ "page.manual-test-runs.form.environment": "Omgeving",
+ "page.manual-test-runs.form.component": "Component",
+ "page.manual-test-runs.form.component-help": "Een run dekt één component. Testgevallen van een ander component kunnen niet worden toegevoegd.",
+ "page.manual-test-runs.form.test-cases": "Testgevallen",
+ "page.manual-test-runs.form.test-cases-help": "Elk testgeval wordt aan de huidige versie gekoppeld, zodat latere wijzigingen deze run niet veranderen.",
+ "page.manual-test-runs.delete-confirm-title": "Testrun verwijderen",
+ "page.manual-test-runs.delete-confirm-message": "Hiermee worden de run en de daarin vastgelegde resultaten verwijderd.",
+ "page.manual-test-runs.toast.fetch-error": "Kon de testruns niet laden.",
+ "page.manual-test-runs.toast.cases-error": "Kon de beschikbare testgevallen niet laden.",
+ "page.manual-test-runs.toast.create-error": "Kon de testrun niet aanmaken.",
+ "page.manual-test-runs.toast.delete-error": "Kon de testrun niet verwijderen.",
+ "page.manual-test-run-detail.not-found": "Die testrun kon niet worden gevonden.",
+ "page.manual-test-run-detail.button.start": "Run starten",
+ "page.manual-test-run-detail.button.complete": "Run afronden",
+ "page.manual-test-run-detail.button.record": "Resultaat vastleggen",
+ "page.manual-test-run-detail.button.rebind": "Naar nieuwste versie",
+ "page.manual-test-run-detail.case-result": "Resultaat testgeval",
+ "page.manual-test-run-detail.actual-placeholder": "Wat gebeurde er werkelijk?",
+ "page.manual-test-run-detail.notes-placeholder": "Notities (optioneel)",
+ "page.manual-test-run-detail.view-build": "Build bekijken",
+ "page.manual-test-run-detail.view-version": "Bekijk de geteste versie",
+ "page.manual-test-run-detail.toast.fetch-error": "Kon de testrun niet laden.",
+ "page.manual-test-run-detail.toast.version-error": "Kon de geteste versie niet laden.",
+ "page.manual-test-run-detail.toast.record-error": "Kon het resultaat niet vastleggen.",
+ "page.manual-test-run-detail.toast.recorded": "Resultaat vastgelegd.",
+ "page.manual-test-run-detail.toast.rebind-error": "Kon dit testgeval niet naar de nieuwste versie verplaatsen. Een testgeval met een resultaat kan niet worden verplaatst.",
+ "page.manual-test-run-detail.toast.status-error": "Kon de status van de run niet wijzigen.",
+ "page.manual-test-case-version.not-found": "Die versie kon niet worden gevonden.",
+ "page.manual-test-case-version.back-to-case": "Terug naar het testgeval",
+ "page.manual-test-case-version.frozen-notice": "Dit is een bevroren versie. Deze toont exact wat destijds is opgesteld en verandert nooit.",
+ "page.manual-test-case-version.compare-with": "Vergelijk met",
+ "page.manual-test-case-version.toast.fetch-error": "Kon die versie niet laden.",
+ "page.manual-test-case-version.toast.compare-error": "Kon de vergelijkingsversie niet laden.",
+ "page.manual-test-case-detail.toast.saved-new-version": "Opgeslagen als versie {version}.",
+ "page.manual-test-case-detail.toast.saved-no-change": "Opgeslagen. De inhoud is niet gewijzigd, dus er is geen nieuwe versie gemaakt.",
+ "page.shared-steps.toast.saved": "Gedeelde stap opgeslagen.",
+ "page.shared-steps.toast.created": "Gedeelde stap aangemaakt.",
+ "page.admin-custom-fields.toast.created": "Aangepast veld aangemaakt.",
+ "page.admin-custom-fields.toast.updated": "Aangepast veld bijgewerkt.",
+ "app.manual.folders.title": "Mappen",
+ "app.manual.folders.all": "Alle testgevallen",
+ "app.manual.folders.unfiled": "Niet in een map",
+ "app.manual.folders.add-root": "Hoofdmap toevoegen",
+ "app.manual.folders.add-sub": "Submap toevoegen",
+ "app.manual.folders.rename": "Hernoemen",
+ "app.manual.folders.delete": "Verwijderen",
+ "app.manual.folders.name": "Mapnaam",
+ "app.manual.folders.create-title": "Nieuwe map",
+ "app.manual.folders.rename-title": "Map hernoemen",
+ "app.manual.folders.creating-under": "Aanmaken in \"{name}\".",
+ "app.manual.folders.delete-confirm-title": "Map verwijderen",
+ "app.manual.folders.delete-confirm-message": "Een map kan pas worden verwijderd als deze leeg is. Verplaats of verwijder eerst de inhoud.",
+ "page.manual-test-cases.toast.folders-error": "Kon de mappen niet laden.",
+ "page.manual-test-cases.toast.folder-create-error": "Kon de map niet aanmaken.",
+ "page.manual-test-cases.toast.folder-rename-error": "Kon de map niet hernoemen.",
+ "page.manual-test-cases.toast.folder-delete-error": "Kon de map niet verwijderen.",
+ "page.manual-test-case-detail.field.folder": "Map",
+ "page.manual-test-case-detail.folder-root": "Niet in een map",
+ "app.manual.history.action.move": "Naar een map verplaatst",
+ "app.manual.step-editor.action": "Actie",
+ "app.manual.step-editor.attachments": "Bijlagen",
+ "app.manual.step-editor.attachments-need-save": "Sla eerst het testgeval op; daarna kunnen er afbeeldingen aan de stappen worden toegevoegd.",
+ "app.manual.step-editor.drag-handle": "Sleep om te herschikken",
+ "app.manual.step-editor.drag-help": "Sleep een stap om de volgorde te wijzigen.",
+ "app.manual.attachments.load-error": "Afbeelding kon niet worden geladen.",
+ "app.manual.folders.unknown": "Andere map",
+ "page.manual-test-cases.toast.move-success": "Testgeval verplaatst.",
+ "page.manual-test-cases.toast.move-error": "Kon het testgeval niet verplaatsen.",
+ "page.admin.settings.features.header": "Functiebeheer",
+ "page.admin.settings.features.help": "Schakel optionele functies in of uit voor deze instantie. Wijzigingen zijn direct actief — opnieuw opstarten is niet nodig.",
+ "page.admin.settings.features.manual-testing": "Testgevallenbeheer inschakelen",
+ "page.admin.settings.features.manual-testing.help": "Handmatige testgevallen, testruns en gedeelde stappen. Wanneer uitgeschakeld zijn deze pagina's verborgen en is de bijbehorende data niet bereikbaar, maar er wordt niets verwijderd — opnieuw inschakelen herstelt alles.",
+ "app.feature.disabled": "Deze functie is niet ingeschakeld op deze instantie. Neem contact op met een beheerder als je toegang nodig hebt.",
+ "nav.team": "Team",
+ "nav.team.none": "Niets geselecteerd"
}
diff --git a/src/translations/th.json b/src/translations/th.json
index d932566..f22734a 100644
--- a/src/translations/th.json
+++ b/src/translations/th.json
@@ -1,515 +1,811 @@
{
- "nav.dashboard": "แดชบอร์ด",
- "nav.execution-metrics": "เมตริก",
- "nav.screenshot-library": "ไลบรารีภาพหน้าจอ",
- "nav.language": "ภาษา",
- "nav.theme": "ธีม",
- "nav.theme.light": "สว่าง",
- "nav.theme.dark": "มืด",
- "nav.about": "เกี่ยวกับ Angles",
- "nav.team-settings": "การตั้งค่าทีม",
- "nav.admin": "ผู้ดูแลระบบ",
- "nav.admin.users": "ผู้ใช้",
- "nav.admin.settings": "การตั้งค่า",
- "nav.profile": "โปรไฟล์",
- "nav.user-settings": "การตั้งค่า",
- "nav.logout": "ออกจากระบบ",
- "app.utils.duration.seconds": "{seconds} วินาที",
- "app.utils.duration.minutes": "{minutes} นาที",
- "app.utils.duration.hours": "{hours} ชั่วโมง",
- "app.utils.duration.not-started": "ยังไม่เริ่ม",
- "app.utils.date-range-picker.number-of-days": "ล่าสุด {days} วัน",
- "app.retrieving-teams": "กำลังเรียกข้อมูลทีม...",
- "app.retrieving-teams-error": "มีอะไรผิดพลาดไป [{teamsError}]",
- "app.result.pass": "ผ่าน",
- "app.result.fail": "ล้มเหลว",
- "app.result.skipped": "ข้าม",
- "app.result.error": "ผิดพลาด",
- "app.result.total": "ทั้งหมด",
- "page.home.bread-crumb": "หน้าแรก",
- "page.dashboard.message.retrieving-builds": "กำลังเรียกข้อมูลบิลด์สำหรับ {teamName}...",
- "page.dashboard.filters.label.team": "ทีม",
- "page.dashboard.filters.label.period": "ช่วงเวลา",
- "page.dashboard.filters.label.display-limit": "ขีดจำกัดบิลด์",
- "page.dashboard.filters.label.environments": "สิ่งแวดล้อม",
- "page.dashboard.filters.label.components": "ส่วนประกอบ",
- "page.dashboard.panel.total-test-runs": "การทดสอบทั้งหมด",
- "page.dashboard.panel.total-test-executions": "การดำเนินการทดสอบทั้งหมด",
- "page.dashboard.panel.total-execution-time": "เวลาดำเนินการทั้งหมด",
- "page.dashboard.chart.barchart.pass": "ผ่าน",
- "page.dashboard.chart.barchart.skipped": "ข้าม",
- "page.dashboard.chart.barchart.error": "ผิดพลาด",
- "page.dashboard.chart.barchart.fail": "ล้มเหลว",
- "page.dashboard.chart.barchart.execution-time-seconds": "เวลาดำเนินการ (วินาที)",
- "page.dashboard.chart.barchart.yaxis.number-of-tests": "จำนวนการทดสอบ",
- "page.dashboard.builds-table.header.build-details": "รายละเอียดบิลด์",
- "page.dashboard.builds-table.header.status": "สถานะ",
- "page.dashboard.builds-table.header.date-time": "วันที่/เวลา",
- "page.dashboard.builds-table.header.result": "ผลลัพธ์",
- "page.dashboard.builds-table.header.flags": "เครื่องหมาย",
- "page.dashboard.builds-table.not-available": "ไม่มีข้อมูล",
- "page.dashboard.builds-table.artifacts.title": "อาร์ทิแฟกต์",
- "page.dashboard.builds-table.artifacts.count": "{count, plural, other {อาร์ทิแฟกต์ # รายการ}}",
- "page.dashboard.builds-table.keep.enabled": "เปิดใช้งานการเก็บรักษา - การรันทดสอบนี้จะไม่ถูกล้างข้อมูล",
- "page.dashboard.builds-table.keep.disabled": "ปิดใช้งานการเก็บรักษา - การรันทดสอบนี้อาจถูกลบเมื่อมีการล้างข้อมูล",
- "page.dashboard.menu.compare-test-runs": "เปรียบเทียบการทดสอบ",
- "page.dashboard.menu.toggle-keep-flag": "สลับธงเก็บ",
- "page.dashboard.menu.clear-selection": "ล้างการเลือก",
- "page.dashboard.menu.delete-build": "ลบบิลด์",
- "page.dashboard.menu.one-select-message": "โปรดเลือกการทดสอบอย่างน้อยหนึ่งอย่าง",
- "page.dashboard.confirm.delete-build.title": "ลบบิลด์",
- "page.dashboard.confirm.delete-build.message": "คุณแน่ใจหรือไม่ว่าต้องการลบบิลด์ที่เลือก {count} รายการ? การดำเนินการนี้ไม่สามารถยกเลิกได้ และจะลบการทดสอบและภาพหน้าจอที่เกี่ยวข้องทั้งหมดอย่างถาวร",
- "page.dashboard.confirm.delete-build.cancel": "ยกเลิก",
- "page.dashboard.toast.delete-build-success": "ลบบิลด์สำเร็จแล้ว {count} รายการ",
- "page.dashboard.toast.delete-build-error": "ไม่สามารถลบบิลด์ได้",
- "page.dashboard.execution-barchart.execution-time-seconds": "เวลาดำเนินการ (วินาที)",
- "page.dashboard.execution-bar-chart.title": "ทดสอบการทำงาน",
- "page.dashboard.execution-pie-chart.title": "สถานะโดยรวมของการทดสอบ",
- "page.metrics.bread-crumb": "เมตริก",
- "page.metrics.filters.labels.team": "ทีม",
- "page.metrics.filters.labels.component": "ส่วนประกอบ",
- "page.metrics.filters.labels.period": "ช่วงเวลา",
- "page.metrics.filters.labels.group-by-period": "จัดกลุ่มตามช่วงเวลา",
- "page.metrics.filters.button.retrieve-metrics": "ดึงข้อมูลเมตริก",
- "page.metrics.tab.execution-metrics": "เมตริกการดำเนิน",
- "page.metrics.tab.platform-metrics": "เมตริกแพลตฟอร์ม",
- "page.metrics.execution-metrics-summary.label.period": "ช่วงเวลา",
- "page.metrics.execution-metrics-summary.label.number-of-tests": "จำนวนการทดสอบ",
- "page.metrics.execution-metrics-summary.label.number-of-runs": "จำนวนการทดสอบรัน",
- "page.metrics.execution-metrics-summary.label.number-of-executions": "จำนวนการดำเนินการ",
- "page.metrics.execution-metrics-summary.label.result": "ผลลัพธ์",
- "page.metrics.execution-metrics-summary.label.duration": "ระยะเวลา",
- "page.metrics.execution-metrics-bar-chart.title": "ตัวชี้วัดการดำเนินการจัดกลุ่มตามผลลัพธ์",
- "page.metrics.execution-metrics-bar-chart.yaxis-title": "จำนวนการดำเนินการ",
- "page.metrics.execution-metrics-phase-bar-chart.title": "ตัวชี้วัดการดำเนินการจัดกลุ่มตามเฟส",
- "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "จำนวนการดำเนินการ",
- "page.metrics.platform-distribution-pie-chart.title": "การแจกแจงการดำเนินการโดยรวมตามแพลตฟอร์ม",
- "page.metrics.platform-distribution-bar-chart.title": "การแจกแจงการดำเนินการตามแพลตฟอร์ม",
- "page.metrics.platform-distribution-bar-chart.yaxis-title": "แพลตฟอร์ม",
- "page.metrics.platform-distribution-bar-chart.xaxis-title": "จำนวนการดำเนิน",
- "page.metrics.platform-metrics-summary.label.platform": "แพลตฟอร์ม",
- "page.metrics.platform-metrics-summary.label.version": "เวอร์ชัน",
- "page.metrics.platform-metrics-summary.label.device": "อุปกรณ์",
- "page.metrics.platform-metrics-summary.label.browser": "เบราว์เซอร์",
- "page.metrics.platform-metrics-summary.label.total": "ทั้งหมด",
- "page.metrics.platform-metrics-summary.label.result": "ผลลัพธ์",
- "page.metrics.platform-metrics-summary.label.duration": "ระยะเวลา",
- "page.metrics.metrics-test-table.label.unique-testcases": "กรณีทดสอบที่ไม่ซ้ำต่อเฟส",
- "page.metrics.metrics-test-table.label.phase": "เฟส",
- "page.metrics.metrics-test-table.label.test": "ทดสอบ",
- "page.metrics.metrics-test-table.no-tests-found": "ไม่พบการทดสอบ",
- "page.test-run.bread-crumb": "ทดสอบการทำงาน",
- "page.test-run.steps-header.start": "เริ่ม",
- "page.test-run.steps-header.duration": "ระยะเวลา",
- "page.test-run.steps-header.end": "สิ้นสุด",
- "page.test-run.menu.enable-keep-flag": "เปิดธงเก็บ",
- "page.test-run.menu.disable-keep-flag": "ปิดธงเก็บ",
- "page.test-run.menu.download-report": "ดาวน์โหลดรายงานเป็น html",
- "page.test-run.menu.delete-build": "ลบบิลด์",
- "page.test-run.confirm.delete-build.title": "ลบบิลด์",
- "page.test-run.confirm.delete-build.message": "คุณแน่ใจหรือไม่ว่าต้องการลบบิลด์นี้? การดำเนินการนี้ไม่สามารถยกเลิกได้ และจะลบการทดสอบและภาพหน้าจอที่เกี่ยวข้องทั้งหมดอย่างถาวร",
- "page.test-run.confirm.delete-build.cancel": "ยกเลิก",
- "page.test-run.toast.delete-build-success": "ลบบิลด์สำเร็จแล้ว",
- "page.test-run.toast.delete-build-error": "ไม่สามารถลบบิลด์ได้",
- "page.test-run.icons.environment.whisper": "สิ่งแวดล้อม",
- "page.test-run.icons.team.whisper": "ทีม",
- "page.test-run.icons.component.whisper": "ส่วนประกอบ",
- "page.test-run.icons.phase.whisper": "เฟสทดสอบ",
- "page.test-run.icons.artifacts.whisper": "จำนวนสิ่งประดิษฐ์ที่เกี่ยวกับการทดสอบนี้",
- "page.test-run.icons.keep-lock.whisper": "เมื่อล็อค การทดสอบการทำงานจะไม่ถูกล้างโดย cronjob ทุกคืน",
- "page.test-run.icons.screenshots.whisper": "การทดสอบนี้มีภาพหน้าจอ {numberOfScreenshots} ภาพ",
- "page.test-run.icons.download-report.whisper": "ดาวน์โหลดรายงาน HTML สำหรับการทดสอบนี้",
- "page.test-run.header.status.whisper": "สถานะ: {status}",
- "page.test-run.header.execution-count": "{count, plural, other {# การดำเนินการ}}",
- "page.test-run.header.fact.not-set": "ไม่ได้กำหนด",
- "page.test-run.header.artifacts-title": "อาร์ทิแฟกต์",
- "page.test-run.suites.title": "ชุดทดสอบ",
- "page.test-run.loading": "กำลังดึงรายละเอียดของบิลด์",
- "page.test-run.error.load-failed": "ไม่สามารถดึงรายละเอียดของบิลด์ได้ กรุณารีเฟรชหน้าและลองใหม่อีกครั้ง และ/หรือตรวจสอบว่ารหัสบิลด์ถูกต้อง",
- "page.test-run.execution-pie-chart.title": "ตัวชี้วัดการดำเนินการโดยรวม",
- "page.test-run.feature-distribution-pie-chart.title": "เมตริกการกระจายฟีเจอร์",
- "page.test-run.execution-pie-chart.pass-rate": "อัตราการผ่าน",
- "page.test-run.feature-distribution-pie-chart.unspecified": "ไม่ได้ระบุ",
- "page.test-run.feature-distribution-pie-chart.executions": "การดำเนินการ",
- "page.test-run-compare.bread-crumb": "เปรียบเทียบการทดสอบการทำงาน",
- "page.test-run-compare.header.number": "#",
- "page.test-run-compare.header.test-name": "ชื่อการทดสอบ",
- "page.test-run-compare.detail.start-date": "วันที่เริ่มต้น",
- "page.test-run-compare.detail.environment": "สิ่งแวดล้อม",
- "page.test-run-compare.cell.not-available": "ไม่มีข้อมูล",
- "page.test-run-compare.cell.multiple-executions": "หลายการดำเนินการ",
- "page.test-run-compare.popover.details-title": "รายละเอียด",
- "page.test-run-compare.link.execution-history": "ดูประวัติการดำเนินการสำหรับ {testName}",
- "page.test-run-compare.details-popover.header.property": "คุณสมบัติ",
- "page.test-run-compare.details-popover.header.value": "ค่า",
- "page.test-run-compare.details-popover.property.status": "สถานะ",
- "page.test-run-compare.details-popover.property.duration": "ระยะเวลา",
- "page.test-run-compare.details-popover.property.platform": "แพลตฟอร์ม",
- "page.test-run-compare.details-popover.property.failing-step": "ขั้นตอนที่ล้มเหลว",
- "page.test-run-compare.loading": "กำลังดึงรายละเอียดการทดสอบการทำงานเพื่อสร้างมุมมองเปรียบเทียบ...",
- "page.test-run-compare.section.artifacts": "เวอร์ชันอาร์ติแฟกต์",
- "page.test-run-compare.artifacts.count": "{count, plural, other {# อาร์ติแฟกต์}}",
- "page.test-run-compare.section.compare-matrix": "เมทริกซ์เปรียบเทียบการทดสอบ",
- "page.test-run-compare.toolbar.status-filter": "กรองตามสถานะ",
- "page.test-run-compare.suite.tests": "{count, plural, other {# การทดสอบ}}",
- "page.test-run-compare.suite.rollup": "ผ่าน {passed} จาก {total}",
- "page.test-run-compare.header.run-summary": "ผ่าน {passed} จาก {total}, ล้มเหลว {failed}, ข้อผิดพลาด {errors}",
- "page.test-run-compare.cell.status-changed": "สถานะเปลี่ยนจากการทำงานครั้งก่อน",
- "page.test-run-compare.warning.many-runs": "กำลังเปรียบเทียบการทดสอบ {count} รายการ — ใช้ตัวกรองด้านล่างเพื่อโฟกัสที่ความแตกต่าง",
- "page.screenshot-library.bread-crumb": "ลบรารีภาพหน้าจอ",
- "page.screenshot-library.search-options.label.view": "วิว",
- "page.screenshot-library.search-options.label.tag": "แท็ก",
- "page.screenshot-library.search-options.label.or": "หรือ",
- "page.screenshot-library.search-options.label.period": "ช่วงเวลา",
- "page.screenshot-library.search-options.label.platform": "แพลตฟอร์ม",
- "page.screenshot-library.search-options.button.search-screenshots": "ค้นหาภาพหน้าจอ",
- "page.screenshot-library.search-options.dropdown.yesterday": "เมื่อวาน",
- "page.screenshot-library.search-options.dropdown.last-7-days": "7 วันที่ผ่านมา",
- "page.screenshot-library.search-options.dropdown.last-14-days": "14 วันที่ผ่านมา",
- "page.screenshot-library.search-options.dropdown.last-30-days": "30 วันที่ผ่านมา",
- "page.screenshot-library.search-options.dropdown.last-3-months": "3 เดือนที่ผ่านมา",
- "page.screenshot-library.search-options.dropdown.last-6-months": "6 เดือนที่ผ่านมา",
- "page.test-execution-history.bread-crumb": "ประวัติการทดสอบ",
- "page.test-execution-history.header.suite": "สวีท",
- "page.test-execution-history.messages.retrieving-executions": "กำลังดึงการดำเนินการทดสอบ",
- "page.test-execution-history.charts.execution-pie-chart.title": "เมตริกการดำเนินการโดยรวม",
- "page.test-execution-history.filter.hint": "เลือกสถานะในแผนภูมิด้านล่างเพื่อกรองหน้านี้",
- "page.test-execution-history.filter.showing": "กำลังแสดง",
- "page.test-execution-history.filter.active": "({count, plural, other {# การดำเนินการ}})",
- "page.test-execution-history.filter.no-results": "ไม่มีการดำเนินการที่ตรงกับสถานะที่เลือก",
- "page.test-execution-history.charts.execution-timeline-chart.title": "เวลาดำเนินการ",
- "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "เวลาดำเนินการ (วินาที)",
- "page.test-execution-history.charts.execution-platform-bar-chart.title": "การดำเนินการตามแพลตฟอร์ม",
- "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "แพลตฟอร์ม",
- "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "จำนวนการดำเนินการ",
- "page.about.bread-crumb": "เกี่ยวกับ",
- "page.about.header": "เกี่ยวกับ Angles",
- "page.about.about-angles": "Angles เป็นแดชบอร์ดโอเพ่นซอร์สที่สามารถใช้เพื่อจัดเก็บผลลัพธ์และภาพหน้าจอสำหรับการทดสอบอัตโนมัติจากเฟรมเวิร์กต่างๆ",
- "page.about.about-api": "ด้วยการจัดเตรียม {apiLink} ที่กำหนดไว้อย่างชัดเจน คุณสามารถปรับใช้เฟรมเวิร์กใดๆ เพื่อจัดเก็บผลการทดสอบใน Angles ได้โดยใช้หนึ่งในไคลเอนต์ที่ให้มา (หรือโดยใช้ API โดยตรง)",
- "page.about.about-github": "สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ Angles ให้ไปที่หน้า: {githubLink}",
- "page.introduction.bread-crumb": "บทนำ",
- "page.introduction.header": "ยินดีต้อนรับสู่ Angles",
- "page.introduction.description": "ดูเหมือนว่าคุณยังไม่ได้ตั้งค่าทีมหรือสภาพแวดล้อมใดๆ\n\nซึ่งสามารถทำได้โดยใช้ {apiLink}",
- "page.introduction.api-link-text": "Swagger API",
- "page.introduction.description-alternative": "หรืออีกทางหนึ่ง คุณสามารถใช้ {githubLink} เพื่อเริ่มต้นได้",
- "page.introduction.github-link-text": "หน้า Github",
- "page.not-found.bread-crumb": "ไม่เจอ",
- "page.not-found.header": "ไม่พบหน้าเว็บ",
- "page.not-found.description": "อ๊ะ เราไม่พบที่คุณกำลังหา คลิก {homeLink} เพื่อกลับไปที่หน้าแรก",
- "page.not-found.home-link-text": "ที่นี่",
- "common.component.suite-table.header.suite": "สวีท",
- "common.component.suite-table.header.test": "ทดสอบ",
- "common.component.suite-table.header.duration": "ระยะเวลา",
- "common.component.suite-table.header.total": "ทั้งหมด",
- "common.component.suite-table.header.collapse-all": "ยุบกรณีทดสอบและการดำเนินการทั้งหมด",
- "common.component.suite-table.header.expand-all": "ขยายกรณีทดสอบทั้งหมด",
- "common.component.suite-table.header.expand-all-actions": "ขยายกรณีทดสอบและการดำเนินการทั้งหมด",
- "common.component.suite-table.icon.test-history": "ดูประวัติการดำเนินการสำหรับการทดสอบ",
- "common.component.suite-table.header.show-screenshots": "แสดงภาพหน้าจอของขั้นตอน",
- "common.component.suite-table.header.hide-screenshots": "ซ่อนภาพหน้าจอของขั้นตอน",
- "common.component.suite-table.header.execution-count": "{count, plural, other {# การทดสอบ}}",
- "common.component.suite-table.execution.action-count": "{count, plural, other {# การกระทำ}}",
- "common.component.suite-table.action.step-count": "{count, plural, other {# ขั้นตอน}}",
- "common.component.screenshot-view.header": "โปรแกรมดูภาพหน้าจอ",
- "common.component.screenshot-view.tabs.enable-alert": "หากต้องการเปิดใช้งานแท็บ \"ประวัติ\" และ \"เปรียบเทียบกับข้อมูลพื้นฐาน\" โปรดระบุมุมมองและรายละเอียดแพลตฟอร์มเมื่ออัปโหลดภาพหน้าจอ",
- "common.component.screenshot-view.tabs.label.image": "ภาพหน้าจอ",
- "common.component.screenshot-view.tabs.label.history": "ประวัติ",
- "common.component.screenshot-view.tabs.label.overlay-with-baseline": "ซ้อนทับด้วยภาพพื้นฐาน",
- "common.component.screenshot-view.tabs.label.side-by-side": "เคียงข้างกันด้วยภาพพื้นฐาน",
- "common.component.screenshot-view.tabs.label.find-image": "ค้นหาในรูปภาพ",
- "common.component.screenshot-view.tabs.baseline.label.algorithm": "อัลกอริทึมการเปรียบเทียบ",
- "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "พิกเซล",
- "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "เชิงโครงสร้าง (SSIM)",
- "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "เพิ่มบริเวณที่เปลี่ยนแปลงนี้เป็นกล่องที่ไม่ต้องตรวจสอบ",
- "common.component.screenshot-view.tabs.find-image.message.intro": "อัปโหลดรูปภาพขนาดเล็ก (เช่น ปุ่ม โลโก้ หรือแอสเซทของเกม) เพื่อค้นหาตำแหน่งในภาพหน้าจอปัจจุบัน",
- "common.component.screenshot-view.tabs.find-image.label.template": "รูปภาพต้นแบบ",
- "common.component.screenshot-view.tabs.find-image.label.min-confidence": "ความเชื่อมั่นขั้นต่ำ",
- "common.component.screenshot-view.tabs.find-image.label.max-matches": "จำนวนผลลัพธ์สูงสุด",
- "common.component.screenshot-view.tabs.find-image.label.grayscale": "ไม่สนใจความแตกต่างของสี (โทนสีเทา)",
- "common.component.screenshot-view.tabs.find-image.button.find": "ค้นหาในภาพหน้าจอ",
- "common.component.screenshot-view.tabs.find-image.message.searching": "กำลังค้นหารูปภาพต้นแบบ อาจใช้เวลาสองสามวินาที",
- "common.component.screenshot-view.tabs.find-image.message.no-matches": "ไม่พบผลลัพธ์ที่สูงกว่าเกณฑ์ความเชื่อมั่น",
- "common.component.screenshot-view.tabs.find-image.label.matches": "ผลลัพธ์",
- "common.component.screenshot-view.tabs.find-image.label.position": "ตำแหน่ง",
- "common.component.screenshot-view.tabs.find-image.label.size": "ขนาด",
- "common.component.screenshot-view.tabs.find-image.label.confidence": "ความเชื่อมั่น",
- "common.component.screenshot-view.tabs.find-image.label.scale": "อัตราส่วน",
- "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "ให้ภาพเป็นพื้นฐาน",
- "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "นี่คือภาพพื้นฐาน",
- "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "สร้างภาพพื้นฐานแบบไดนามิก",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "ลบภาพหน้าจอ",
- "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "ใช้ได้เฉพาะกับพื้นฐานแบบไดนามิกที่ไม่ได้กำหนดค่าเป็นพื้นฐานเท่านั้น",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "กำลังดึงภาพหน้าจอ",
- "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "ม่สามารถดึงภาพได้ โปรดรีเฟรชหน้าแล้วลองอีกครั้ง",
- "common.component.screenshot-view.tabs.history.message.loading-history": "กำลังโหลดประวัติ...",
- "common.component.screenshot-view.tabs.history.label.baseline": "ภาพหน้าจอพื้นฐาน",
- "common.component.screenshot-view.tabs.history.label.view": "วิว",
- "common.component.screenshot-view.tabs.history.label.date": "วันที่",
- "common.component.screenshot-view.tabs.history.label.resolution": "ความละเอียด",
- "common.component.screenshot-view.tabs.history.label.platform": "แพลตฟอร์ม",
- "common.component.screenshot-view.tabs.history.label.device": "อุปกรณ์",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "ภาพปัจจุบัน (และพื้นฐาน)",
- "common.component.screenshot-view.tabs.side-by-side.label.current-image": "ภาพปัจจุบัน",
- "common.component.screenshot-view.tabs.side-by-side.label.baseline": "พื้นฐาน",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "กำลังโหลดภาพพื้นฐาน",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "ไม่สามารถเรียกภาพพื้นฐานได้ โปรดรีเฟรชหน้านี้แล้วลองอีกครั้ง",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "กำลังเรียกภาพหน้าจอ",
- "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "ม่สามารถเรียกภาพได้ โปรดรีเฟรชหน้าแล้วลองอีกครั้ง",
- "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "ภาพปัจจุบัน (และพื้นฐาน)",
- "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "การเปรียบเทียบพื้นฐาน",
- "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "ทำซ้ำเปรียบเทียบกับพื้นฐาน.",
- "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "แก้ไขการละเว้นการบล็อก",
- "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "บันทึกการละเว้นการบล็อก",
- "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "ยกเลิกการเปลี่ยนแปลง",
- "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "ยังไม่ได้เลือกพื้นฐานสำหรับวิวนี้และชื่ออุปกรณ์หรือการรวมกันของเบราว์เซอร์ หากต้องการเลือกพื้นฐาน ให้นำทางไปรูปภาพที่คุณต้องการเป็นพื้นฐาน และคลิกที่ปุ่ม \"ให้ภาพเป็นพื้นฐาน\"",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "กำลังโหลดการเปรียบเทียบพื้นฐาน",
- "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "ไม่สามารถเรียกข้อมูลการเปรียบเทียบพื้นฐานได้",
- "common.component.screenshot-details-table.label.view": "วิว",
- "common.component.screenshot-details-table.label.view-link": "แพลตฟอร์มอื่นๆ",
- "common.component.screenshot-details-table.label.view-link-description": "ค้นหาภาพหน้าจอล่าสุดทั้งหมดสำหรับวิว {view} ซึ่งจัดกลุ่มตามแพลตฟอร์ม",
- "common.component.screenshot-details-table.label.no-view": "ไม่มีวิวให้มา",
- "common.component.screenshot-details-table.label.date": "วันที่",
- "common.component.screenshot-details-table.label.baseline-date": "วันที่พื้นฐาน",
- "common.component.screenshot-details-table.label.mismatch": "ไม่ตรงกัน",
- "common.component.screenshot-details-table.label.algorithm": "อัลกอริทึม",
- "common.component.screenshot-details-table.label.ssim-score": "คะแนน SSIM",
- "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (เวลาวิเคราะห์: {time} ms)",
- "common.component.screenshot-details-table.label.resolution": "ความละเอียด",
- "common.component.screenshot-details-table.label.platform": "แพลตฟอร์ม",
- "common.component.screenshot-details-table.label.no-platform": "ไม่มีแพลตฟอร์มให้มา",
- "common.component.screenshot-details-table.label.platform-version": "เวอร์ชันแพลตฟอร์ม",
- "common.component.screenshot-details-table.label.device": "อุปกรณ์",
- "common.component.screenshot-details-table.label.browser": "เบราว์เซอร์",
- "common.component.screenshot-details-table.label.no-browser": "ไม่มีเบราว์เซอร์ให้มา",
- "common.component.screenshot-details-table.label.browser-version": "เวอร์ชันของเบราว์เซอร์",
- "common.component.screenshot-details-table.label.no-browser-version": "ไม่มีเวอร์ชันเบราว์เซอร์ให้มา",
- "common.component.screenshot-details-table.label.baseline": "พื้นฐาน",
- "common.component.screenshot-details-table.label.tags": "แท็ก",
- "common.component.screenshot-details-table.label.tags-description": "ค้นหาภาพหน้าจอล่าสุดทั้งหมดที่มีแท็ก \"{tag}\", งจัดกลุ่มตามวิว",
- "common.yes": "ใช่",
- "common.no": "ไม่",
- "common.button.ok": "ตกลง",
- "page.login.title": "เข้าสู่ระบบ Angles",
- "page.login.error.invalid-credentials": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง",
- "page.login.label.username": "ชื่อผู้ใช้",
- "page.login.label.password": "รหัสผ่าน",
- "page.login.button.login": "เข้าสู่ระบบ",
- "page.login.divider.or": "หรือ",
- "page.login.label.sign-in-with": "เข้าสู่ระบบด้วย",
- "page.login.source.local": "บัญชีในระบบ",
- "page.login.button.sso": "ดำเนินการต่อด้วย {name}",
- "page.login.error.sso-failed": "การเข้าสู่ระบบแบบ Single Sign-On ล้มเหลว โปรดลองอีกครั้งหรือติดต่อผู้ดูแลระบบ",
- "page.login.error.no-methods": "ไม่มีวิธีการเข้าสู่ระบบที่ใช้ได้ โปรดติดต่อผู้ดูแลระบบ",
- "page.user-settings.header": "การตั้งค่าผู้ใช้",
- "page.user-settings.api-tokens.title": "API โทเค็น",
- "page.user-settings.api-tokens.description": "API โทเค็นส่วนบุคคลทำหน้าที่เหมือนรหัสผ่านธรรมดา แต่จะช่วยให้คุณตรวจสอบสิทธิ์กับ API ผ่านโปรแกรมได้",
- "page.user-settings.button.generate-new-token": "สร้างโทเค็นใหม่",
- "page.user-settings.table.name": "ชื่อ",
- "page.user-settings.table.created": "สร้างเมื่อ",
- "page.user-settings.table.expires": "หมดอายุ",
- "page.user-settings.table.action": "การดำเนินการ",
- "page.user-settings.table.revoke": "เพิกถอน",
- "page.user-settings.modal.token-generated": "สร้างโทเค็นแล้ว",
- "page.user-settings.modal.generate-token": "สร้าง API โทเค็นใหม่",
- "page.user-settings.modal.success-label": "สำเร็จ!",
- "page.user-settings.modal.success": "สร้างโทเค็นใหม่ของคุณแล้ว",
- "page.user-settings.modal.warning": "โปรดคัดลอกโทเค็นนี้ทันที คุณจะไม่สามารถเห็นมันได้อีก!",
- "page.user-settings.modal.token-name": "ชื่อโทเค็น",
- "page.user-settings.modal.token-placeholder": "เช่น CI/CD Pipeline",
- "page.user-settings.modal.expiration": "หมดอายุ",
- "page.user-settings.modal.custom-expiration": "กำหนดวันหมดอายุ (วัน)",
- "page.user-settings.button.done": "เสร็จสิ้น",
- "page.user-settings.button.generate": "สร้าง",
- "page.user-settings.button.cancel": "ยกเลิก",
- "page.user-settings.confirm.revoke.title": "เพิกถอนโทเค็น",
- "page.user-settings.confirm.revoke.message": "คุณแน่ใจหรือไม่ว่าต้องการเพิกถอนโทเค็นนี้? สคริปต์หรือแอพพลิเคชันใดๆ ที่ใช้โทเค็นนี้จะสูญเสียการเข้าถึงทันที",
- "page.user-settings.table.expires-never": "ไม่มีวันหมดอายุ",
- "page.user-settings.modal.token-label": "โทเค็น",
- "page.user-settings.modal.button.copy": "คัดลอก",
- "page.user-settings.expiry.1-day": "1 วัน",
- "page.user-settings.expiry.1-week": "1 สัปดาห์",
- "page.user-settings.expiry.1-month": "1 เดือน",
- "page.user-settings.expiry.1-year": "1 ปี",
- "page.user-settings.expiry.custom": "กำหนดเอง",
- "page.user-settings.toast.fetch-tokens-error": "ไม่สามารถดึงข้อมูลโทเค็นได้",
- "page.user-settings.toast.token-name-required": "จำเป็นต้องระบุชื่อโทเค็น",
- "page.user-settings.toast.expiry-required": "ต้องการวันหมดอายุที่ถูกต้อง",
- "page.user-settings.toast.generate-token-error": "ไม่สามารถสร้างโทเค็นได้",
- "page.user-settings.toast.revoke-success": "เพิกถอนโทเค็นสำเร็จแล้ว",
- "page.user-settings.toast.revoke-error": "ไม่สามารถเพิกถอนโทเค็นได้",
- "page.user-settings.toast.copy-success": "คัดลอกโทเค็นไปยังคลิปบอร์ดแล้ว",
- "password.policy.min-length": "มีความยาวอย่างน้อย 10 ตัวอักษร",
- "password.policy.max-length": "มีความยาวไม่เกิน 100 ตัวอักษร",
- "password.policy.letter": "มีตัวอักษรอย่างน้อยหนึ่งตัว",
- "password.policy.uppercase": "มีตัวพิมพ์ใหญ่อย่างน้อยหนึ่งตัว",
- "password.policy.special": "มีอักขระพิเศษอย่างน้อยหนึ่งตัว",
- "password.policy.invalid": "รหัสผ่านไม่ตรงตามข้อกำหนดด้านความปลอดภัย",
- "page.user-settings.password.title": "รหัสผ่าน",
- "page.user-settings.password.description": "เปลี่ยนรหัสผ่านที่คุณใช้เข้าสู่ระบบ Angles",
- "page.user-settings.password.button.change": "เปลี่ยนรหัสผ่าน",
- "page.user-settings.password.modal.title": "เปลี่ยนรหัสผ่าน",
- "page.user-settings.password.modal.current": "รหัสผ่านปัจจุบัน",
- "page.user-settings.password.modal.new": "รหัสผ่านใหม่",
- "page.user-settings.password.modal.confirm": "ยืนยันรหัสผ่านใหม่",
- "page.user-settings.password.modal.mismatch": "รหัสผ่านไม่ตรงกัน",
- "page.user-settings.password.modal.submit": "อัปเดตรหัสผ่าน",
- "page.user-settings.password.modal.success": "อัปเดตรหัสผ่านของคุณแล้ว ใช้รหัสผ่านนี้ในการเข้าสู่ระบบครั้งถัดไป",
- "page.user-settings.password.toast.current-required": "จำเป็นต้องระบุรหัสผ่านปัจจุบัน",
- "page.user-settings.password.toast.mismatch": "รหัสผ่านใหม่และการยืนยันไม่ตรงกัน",
- "page.user-settings.password.toast.success": "อัปเดตรหัสผ่านสำเร็จ",
- "page.user-settings.password.toast.error": "ไม่สามารถอัปเดตรหัสผ่านได้",
- "page.admin.settings.header": "การตั้งค่าการรับรองความถูกต้อง",
- "page.admin.settings.loading": "กำลังโหลดการตั้งค่า...",
- "page.admin.settings.local-auth.title": "การตรวจสอบสิทธิ์ภายใน",
- "page.admin.settings.local-auth.enable": "เปิดใช้งานการตรวจสอบสิทธิ์ภายใน",
- "page.admin.settings.local-auth.help": "อนุญาตให้ผู้ใช้เข้าสู่ระบบด้วยชื่อผู้ใช้และรหัสผ่าน",
- "page.admin.settings.button.save": "บันทึกการตั้งค่า",
- "page.admin.users.header": "การจัดการผู้ใช้",
- "page.admin.users.button.add-user": "เพิ่มผู้ใช้",
- "page.admin.users.table.username": "ชื่อผู้ใช้",
- "page.admin.users.table.role": "บทบาท",
- "page.admin.users.table.teams": "ทีม",
- "page.admin.users.table.action": "การดำเนินการ",
- "page.admin.users.table.edit": "แก้ไข",
- "page.admin.users.table.delete": "ลบ",
- "page.admin.users.modal.edit-user": "แก้ไขผู้ใช้",
- "page.admin.users.modal.add-user": "เพิ่มผู้ใช้",
- "page.admin.users.modal.password-help": "(เว้นว่างไว้เพื่อไม่ให้เปลี่ยนแปลง)",
- "page.admin.users.modal.user-type": "ประเภทผู้ใช้",
- "page.admin.users.modal.team-access": "การเข้าถึงทีม",
- "page.admin.users.button.save": "บันทึก",
- "page.admin.users.button.cancel": "ยกเลิก",
- "page.admin.users.confirm.delete.title": "ลบผู้ใช้",
- "page.admin.users.confirm.delete.message": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ใช้นี้? การกระทำนี้ไม่สามารถย้อนกลับได้",
- "page.admin.users.user-type.admin": "ผู้ดูแลระบบ",
- "page.admin.users.user-type.team_lead": "หัวหน้าทีม",
- "page.admin.users.user-type.user": "ผู้ใช้",
- "page.admin.users.modal.password-label": "รหัสผ่าน",
- "page.admin.users.table.teams-assigned": "มอบหมาย {count} ทีม",
- "page.admin.users.toast.fetch-error": "ไม่สามารถดึงข้อมูลผู้ใช้ได้",
- "page.admin.users.toast.update-success": "อัปเดตผู้ใช้สำเร็จแล้ว",
- "page.admin.users.toast.create-success": "สร้างผู้ใช้สำเร็จแล้ว",
- "page.admin.users.toast.save-error": "ไม่สามารถบันทึกผู้ใช้ได้",
- "page.admin.users.toast.delete-success": "ลบผู้ใช้สำเร็จแล้ว",
- "page.admin.users.toast.delete-error": "ไม่สามารถลบผู้ใช้ได้",
- "page.admin.settings.toast.fetch-error": "ไม่สามารถดึงข้อมูลการตั้งค่าได้",
- "page.admin.settings.toast.save-success": "บันทึกการตั้งค่าสำเร็จแล้ว",
- "page.admin.settings.toast.save-error": "ไม่สามารถบันทึกการตั้งค่าได้",
- "page.admin.settings.providers.title": "ผู้ให้บริการข้อมูลประจำตัว",
- "page.admin.settings.providers.help": "ตั้งค่าผู้ให้บริการ Single Sign-On การเปลี่ยนแปลงมีผลทันทีโดยไม่ต้องรีสตาร์ท",
- "page.admin.settings.providers.empty": "ยังไม่มีการตั้งค่าผู้ให้บริการข้อมูลประจำตัว",
- "page.admin.settings.providers.table.name": "ชื่อ",
- "page.admin.settings.providers.table.id": "ไอดี",
- "page.admin.settings.providers.table.type": "ประเภท",
- "page.admin.settings.providers.table.enabled": "เปิดใช้งาน",
- "page.admin.settings.providers.table.status": "สถานะ",
- "page.admin.settings.button.add-provider": "เพิ่มผู้ให้บริการ",
- "page.admin.settings.button.save-provider": "บันทึกผู้ให้บริการ",
- "page.admin.settings.confirm.delete.title": "ลบผู้ให้บริการ",
- "page.admin.settings.confirm.delete.message": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการข้อมูลประจำตัวนี้ ผู้ใช้ที่เข้าสู่ระบบผ่านผู้ให้บริการนี้จะสูญเสียสิทธิ์การเข้าถึง",
- "page.admin.settings.provider.modal.add": "เพิ่มผู้ให้บริการข้อมูลประจำตัว",
- "page.admin.settings.provider.modal.edit": "แก้ไขผู้ให้บริการข้อมูลประจำตัว",
- "page.admin.settings.provider.name": "ชื่อที่แสดง",
- "page.admin.settings.provider.name.help": "แสดงบนปุ่มเข้าสู่ระบบ เช่น \"SSO ขององค์กร\"",
- "page.admin.settings.provider.id": "ไอดีผู้ให้บริการ",
- "page.admin.settings.provider.id.help": "ตัวอักษรพิมพ์เล็ก ตัวเลข หรือขีดกลาง 2-32 ตัว เป็นส่วนหนึ่งของ callback URL และไม่สามารถแก้ไขได้ภายหลัง",
- "page.admin.settings.provider.id.help-locked": "ไอดีนี้เป็นส่วนหนึ่งของ callback URL ที่ลงทะเบียนไว้กับผู้ให้บริการข้อมูลประจำตัว จึงไม่สามารถแก้ไขได้",
- "page.admin.settings.provider.type": "ประเภทผู้ให้บริการ",
- "page.admin.settings.provider.type.oidc": "OIDC",
- "page.admin.settings.provider.type.saml": "SAML 2.0",
- "page.admin.settings.provider.type.ldap": "LDAP / AD",
- "page.admin.settings.provider.type.help-locked": "ไม่สามารถเปลี่ยนประเภทผู้ให้บริการได้หลังจากสร้างแล้ว",
- "page.admin.settings.provider.enabled": "เปิดใช้งาน",
- "page.admin.settings.provider.enabled.help": "แสดงผู้ให้บริการนี้บนหน้าเข้าสู่ระบบ",
- "page.admin.settings.provider.callback-url": "Redirect URI",
- "page.admin.settings.provider.acs-url": "ACS (Callback) URL",
- "page.admin.settings.provider.callback-url.help": "ลงทะเบียน URL นี้กับผู้ให้บริการข้อมูลประจำตัวของคุณ",
- "page.admin.settings.provider.status.ready": "พร้อมใช้งาน",
- "page.admin.settings.provider.status.error": "ข้อผิดพลาด",
- "page.admin.settings.provider.status.enabled": "เปิดใช้งาน",
- "page.admin.settings.provider.status.disabled": "ปิดใช้งาน",
- "page.admin.settings.provider.default-role": "บทบาทเริ่มต้น",
- "page.admin.settings.provider.default-role.deny": "ปฏิเสธการเข้าถึง",
- "page.admin.settings.provider.default-role.help": "บทบาทที่กำหนดให้เมื่อไม่มีการแมปใดตรงกัน การปฏิเสธการเข้าถึงเป็นค่าเริ่มต้นที่ปลอดภัยกว่า",
- "page.admin.settings.provider.secret.help-set": "มีการตั้งค่าไว้แล้ว เว้นว่างไว้เพื่อคงค่าเดิม หรือกรอกค่าใหม่เพื่อแทนที่",
- "page.admin.settings.provider.secret.help-unset": "เมื่อบันทึกแล้วจะไม่สามารถดูค่านี้ได้อีก",
- "page.admin.settings.provider.error.id": "ไอดีผู้ให้บริการต้องเป็นตัวอักษรพิมพ์เล็ก ตัวเลข หรือขีดกลาง 2-32 ตัว",
- "page.admin.settings.provider.error.duplicate-id": "มีผู้ให้บริการรายอื่นใช้ไอดีนี้อยู่แล้ว",
- "page.admin.settings.provider.error.name": "ต้องระบุชื่อที่แสดง",
- "page.admin.settings.provider.error.mapping-value": "การแมปบทบาททุกรายการต้องมีค่ากลุ่ม",
- "page.admin.settings.oidc.title": "การตั้งค่า OIDC",
- "page.admin.settings.oidc.issuer": "Issuer URL",
- "page.admin.settings.oidc.issuer.help": "ระบบจะค้นหา endpoint และคีย์สำหรับตรวจสอบลายเซ็นจาก .well-known/openid-configuration ของ issuer",
- "page.admin.settings.oidc.client-id": "Client ID",
- "page.admin.settings.oidc.client-secret": "Client Secret",
- "page.admin.settings.oidc.scopes": "Scopes",
- "page.admin.settings.oidc.scopes.help": "คั่นด้วยช่องว่าง ระบบจะเพิ่ม \"openid\" ให้เสมอ Okta และ Keycloak รองรับ \"groups\" ส่วน Google จะปฏิเสธ",
- "page.admin.settings.oidc.groups-claim": "Groups Claim",
- "page.admin.settings.oidc.groups-claim.help": "Claim ที่เก็บการเป็นสมาชิกกลุ่ม — \"groups\" สำหรับ Okta และมักเป็น \"roles\" สำหรับ Keycloak ส่วน Entra ID จะส่งเป็น GUID ของกลุ่ม",
- "page.admin.settings.oidc.username-claim": "Username Claim",
- "page.admin.settings.oidc.username-claim.help": "Claim ที่ใช้เป็นชื่อผู้ใช้ของ Angles หากไม่มีจะใช้ email, preferred_username และ sub ตามลำดับ",
- "page.admin.settings.saml.title": "การตั้งค่า SAML 2.0",
- "page.admin.settings.saml.metadata-url": "SP Metadata URL",
- "page.admin.settings.saml.metadata-url.help": "นำเข้า URL นี้ในผู้ให้บริการข้อมูลประจำตัวของคุณแทนการกรอกค่าต่าง ๆ ด้วยตนเอง",
- "page.admin.settings.saml.entry-point": "IdP SSO URL",
- "page.admin.settings.saml.entry-point.help": "URL ของผู้ให้บริการข้อมูลประจำตัวที่เบราว์เซอร์จะถูกเปลี่ยนเส้นทางไป",
- "page.admin.settings.saml.idp-cert": "ใบรับรองสำหรับลงลายเซ็นของ IdP",
- "page.admin.settings.saml.idp-cert.help": "จำเป็นต้องระบุ — เป็นสิ่งเดียวที่ทำให้เชื่อถือ assertion ได้ รองรับทั้งแบบมีและไม่มีบรรทัดหัว PEM",
- "page.admin.settings.saml.issuer": "SP Entity ID",
- "page.admin.settings.saml.issuer.help": "Entity ID ของผู้ให้บริการนี้ ตามที่ลงทะเบียนไว้กับผู้ให้บริการข้อมูลประจำตัว",
- "page.admin.settings.saml.private-key": "คีย์ส่วนตัวของ SP",
- "page.admin.settings.saml.private-key.help": "จำเป็นเฉพาะเมื่อผู้ให้บริการข้อมูลประจำตัวกำหนดให้ลงลายเซ็นคำขอยืนยันตัวตน เว้นว่างไว้เพื่อคงคีย์เดิม",
- "page.admin.settings.saml.signature-algorithm": "อัลกอริทึมลายเซ็น",
- "page.admin.settings.saml.identifier-format": "รูปแบบ Name ID",
- "page.admin.settings.saml.username-attribute": "แอตทริบิวต์ชื่อผู้ใช้",
- "page.admin.settings.saml.username-attribute.help": "แอตทริบิวต์ใน assertion ที่เก็บชื่อผู้ใช้ เว้นว่างไว้เพื่อใช้ Name ID",
- "page.admin.settings.saml.groups-attribute": "แอตทริบิวต์กลุ่ม",
- "page.admin.settings.saml.response-signed": "กำหนดให้ Response ต้องมีลายเซ็น",
- "page.admin.settings.saml.response-signed.help": "ปิดสำหรับผู้ให้บริการที่ลงลายเซ็นเฉพาะ assertion ทั้งนี้ลายเซ็นของ assertion เป็นสิ่งจำเป็นเสมอ",
- "page.admin.settings.saml.allow-unsolicited": "อนุญาตการเข้าสู่ระบบที่เริ่มจาก IdP",
- "page.admin.settings.saml.allow-unsolicited.help": "ปิดไว้เป็นค่าเริ่มต้น: assertion ที่ไม่ได้ร้องขอจะไม่มีคำขอให้เทียบเคียง จึงสูญเสียการป้องกันการโจมตีแบบ replay",
- "page.admin.settings.ldap.title": "การตั้งค่า LDAP / Active Directory",
- "page.admin.settings.ldap.url": "URL ของไดเรกทอรี",
- "page.admin.settings.ldap.url.help": "ใช้ ldaps:// หรือ ldap:// ร่วมกับ StartTLS การเชื่อมต่อ ldap:// แบบธรรมดาจะถูกปฏิเสธ เพราะรหัสผ่านจะถูกส่งเป็นข้อความธรรมดา",
- "page.admin.settings.ldap.bind-dn": "Bind DN",
- "page.admin.settings.ldap.bind-dn.help": "บัญชีบริการที่ใช้ค้นหาผู้ใช้ เว้นว่างไว้เพื่อค้นหาแบบไม่ระบุตัวตน",
- "page.admin.settings.ldap.bind-credentials": "รหัสผ่าน Bind",
- "page.admin.settings.ldap.search-base": "ฐานการค้นหาผู้ใช้",
- "page.admin.settings.ldap.search-base.help": "ตัวอย่างเช่น: ou=users,dc=example,dc=com",
- "page.admin.settings.ldap.search-filter": "ตัวกรองการค้นหาผู้ใช้",
- "page.admin.settings.ldap.search-filter.help": "{username} จะถูกแทนที่ด้วยชื่อผู้ใช้ที่กรอก โดยทั่วไป Active Directory ใช้ (sAMAccountName={username})",
- "page.admin.settings.ldap.username-attribute": "แอตทริบิวต์ชื่อผู้ใช้",
- "page.admin.settings.ldap.username-attribute.help": "แอตทริบิวต์ที่ใช้เป็นชื่อผู้ใช้ของ Angles — \"uid\" หรือ \"sAMAccountName\" บน Active Directory",
- "page.admin.settings.ldap.group-search-base": "ฐานการค้นหากลุ่ม",
- "page.admin.settings.ldap.group-search-base.help": "เว้นว่างไว้เพื่ออ่านการเป็นสมาชิกกลุ่มจากแอตทริบิวต์ memberOf ของผู้ใช้ ซึ่งเป็นวิธีที่ Active Directory ใช้โดยทั่วไป",
- "page.admin.settings.ldap.group-search-filter": "ตัวกรองการค้นหากลุ่ม",
- "page.admin.settings.ldap.group-name-attribute": "แอตทริบิวต์ชื่อกลุ่ม",
- "page.admin.settings.ldap.group-name-attribute.help": "DN แบบเต็มจะถูกย่อเหลือเพียงแอตทริบิวต์นี้ ดังนั้นให้แมปบทบาทกับชื่อ เช่น \"Angles Admins\"",
- "page.admin.settings.ldap.start-tls": "ใช้ StartTLS",
- "page.admin.settings.ldap.start-tls.help": "ยกระดับการเชื่อมต่อ ldap:// แบบธรรมดาให้เป็น TLS",
- "page.admin.settings.ldap.tls-reject-unauthorized": "ตรวจสอบใบรับรอง TLS",
- "page.admin.settings.ldap.tls-reject-unauthorized.help": "การปิดตัวเลือกนี้ทำให้การเชื่อมต่อถูกดักจับได้ง่าย ใช้เฉพาะกับระบบทดสอบที่ใช้ใบรับรองแบบ self-signed",
- "page.admin.settings.role-mappings.title": "การแมปกลุ่มกับบทบาท",
- "page.admin.settings.role-mappings.help": "แมปกลุ่มที่ผู้ให้บริการส่งมาเข้ากับบทบาทของ Angles การจับคู่ไม่คำนึงถึงตัวพิมพ์เล็กพิมพ์ใหญ่ และผู้ใช้ที่ตรงกับหลายรายการจะได้รับบทบาทที่มีสิทธิ์สูงสุด (ผู้ดูแลระบบ > หัวหน้าทีม > ผู้ใช้)",
- "page.admin.settings.role-mappings.empty": "ยังไม่มีการแมป หากไม่มีการแมป สิทธิ์การเข้าถึงจะขึ้นอยู่กับบทบาทเริ่มต้นด้านล่าง",
- "page.admin.settings.role-mappings.add": "เพิ่มการแมป",
- "page.admin.settings.role-mappings.remove": "ลบการแมป",
- "page.admin.settings.role-mappings.value-placeholder": "ชื่อกลุ่มหรือไอดี",
- "page.team-settings.header": "การตั้งค่าทีม",
- "page.team-settings.label.team": "ทีม",
- "page.team-settings.section.details": "รายละเอียดทีม",
- "page.team-settings.label.name": "ชื่อทีม",
- "page.team-settings.button.save-name": "บันทึกชื่อ",
- "page.team-settings.section.components": "ส่วนประกอบ",
- "page.team-settings.components.help": "ส่วนประกอบช่วยให้คุณจัดกลุ่มการครอบคลุมการทดสอบตามพื้นที่ของฟังก์ชันการทำงาน",
- "page.team-settings.label.existing-components": "ส่วนประกอบที่มีอยู่",
- "page.team-settings.label.add-components": "เพิ่มส่วนประกอบ",
- "page.team-settings.components.add-placeholder": "พิมพ์ชื่อส่วนประกอบแล้วกด enter",
- "page.team-settings.button.add-components": "เพิ่มส่วนประกอบ",
- "page.team-settings.no-team-access": "คุณยังไม่ได้รับสิทธิ์เข้าถึงทีมใดๆ โปรดติดต่อผู้ดูแลระบบ",
- "page.team-settings.toast.fetch-error": "ไม่สามารถดึงข้อมูลทีมได้",
- "page.team-settings.toast.name-update-success": "อัปเดตชื่อทีมสำเร็จแล้ว",
- "page.team-settings.toast.name-update-error": "ไม่สามารถอัปเดตชื่อทีมได้",
- "page.team-settings.toast.components-update-success": "เพิ่มส่วนประกอบสำเร็จแล้ว",
- "page.team-settings.toast.components-update-error": "ไม่สามารถเพิ่มส่วนประกอบได้"
+ "nav.dashboard": "แดชบอร์ด",
+ "nav.execution-metrics": "เมตริก",
+ "nav.screenshot-library": "ไลบรารีภาพหน้าจอ",
+ "nav.language": "ภาษา",
+ "nav.theme": "ธีม",
+ "nav.theme.light": "สว่าง",
+ "nav.theme.dark": "มืด",
+ "nav.theme.group.light": "ธีมสว่าง",
+ "nav.theme.group.dark": "ธีมมืด",
+ "nav.theme.slate-light": "สเลตสว่าง",
+ "nav.theme.sepia": "ซีเปีย",
+ "nav.theme.slate-dark": "สเลตมืด",
+ "nav.theme.midnight": "เที่ยงคืน",
+ "nav.theme.forest-light": "ป่าสว่าง",
+ "nav.theme.contrast-light": "คอนทราสต์สูงสว่าง",
+ "nav.theme.forest-dark": "ป่ามืด",
+ "nav.theme.contrast-dark": "คอนทราสต์สูงมืด",
+ "nav.theme.clay-light": "ดินเหนียว (เหมาะกับตาบอดสี)",
+ "nav.theme.clay-dark": "ดินเหนียวมืด (เหมาะกับตาบอดสี)",
+ "nav.about": "เกี่ยวกับ Angles",
+ "nav.team-settings": "การตั้งค่าทีม",
+ "nav.admin": "ผู้ดูแลระบบ",
+ "nav.admin.users": "ผู้ใช้",
+ "nav.admin.settings": "การตั้งค่า",
+ "nav.profile": "โปรไฟล์",
+ "nav.user-settings": "การตั้งค่า",
+ "nav.logout": "ออกจากระบบ",
+ "app.utils.duration.seconds": "{seconds} วินาที",
+ "app.utils.duration.minutes": "{minutes} นาที",
+ "app.utils.duration.hours": "{hours} ชั่วโมง",
+ "app.utils.duration.not-started": "ยังไม่เริ่ม",
+ "app.utils.date-range-picker.number-of-days": "ล่าสุด {days} วัน",
+ "app.retrieving-teams": "กำลังเรียกข้อมูลทีม...",
+ "app.retrieving-teams-error": "มีอะไรผิดพลาดไป [{teamsError}]",
+ "app.result.pass": "ผ่าน",
+ "app.result.fail": "ล้มเหลว",
+ "app.result.skipped": "ข้าม",
+ "app.result.error": "ผิดพลาด",
+ "app.result.total": "ทั้งหมด",
+ "page.home.bread-crumb": "หน้าแรก",
+ "page.dashboard.message.retrieving-builds": "กำลังเรียกข้อมูลบิลด์สำหรับ {teamName}...",
+ "page.dashboard.filters.label.team": "ทีม",
+ "page.dashboard.filters.label.period": "ช่วงเวลา",
+ "page.dashboard.filters.label.display-limit": "ขีดจำกัดบิลด์",
+ "page.dashboard.filters.label.environments": "สิ่งแวดล้อม",
+ "page.dashboard.filters.label.components": "ส่วนประกอบ",
+ "page.dashboard.panel.total-test-runs": "การทดสอบทั้งหมด",
+ "page.dashboard.panel.total-test-executions": "การดำเนินการทดสอบทั้งหมด",
+ "page.dashboard.panel.total-execution-time": "เวลาดำเนินการทั้งหมด",
+ "page.dashboard.chart.barchart.pass": "ผ่าน",
+ "page.dashboard.chart.barchart.skipped": "ข้าม",
+ "page.dashboard.chart.barchart.error": "ผิดพลาด",
+ "page.dashboard.chart.barchart.fail": "ล้มเหลว",
+ "page.dashboard.chart.barchart.execution-time-seconds": "เวลาดำเนินการ (วินาที)",
+ "page.dashboard.chart.barchart.yaxis.number-of-tests": "จำนวนการทดสอบ",
+ "page.dashboard.builds-table.header.build-details": "รายละเอียดบิลด์",
+ "page.dashboard.builds-table.header.status": "สถานะ",
+ "page.dashboard.builds-table.header.date-time": "วันที่/เวลา",
+ "page.dashboard.builds-table.header.result": "ผลลัพธ์",
+ "page.dashboard.builds-table.header.flags": "เครื่องหมาย",
+ "page.dashboard.builds-table.not-available": "ไม่มีข้อมูล",
+ "page.dashboard.builds-table.artifacts.title": "อาร์ทิแฟกต์",
+ "page.dashboard.builds-table.artifacts.count": "{count, plural, other {อาร์ทิแฟกต์ # รายการ}}",
+ "page.dashboard.builds-table.keep.enabled": "เปิดใช้งานการเก็บรักษา - การรันทดสอบนี้จะไม่ถูกล้างข้อมูล",
+ "page.dashboard.builds-table.keep.disabled": "ปิดใช้งานการเก็บรักษา - การรันทดสอบนี้อาจถูกลบเมื่อมีการล้างข้อมูล",
+ "page.dashboard.menu.compare-test-runs": "เปรียบเทียบการทดสอบ",
+ "page.dashboard.menu.toggle-keep-flag": "สลับธงเก็บ",
+ "page.dashboard.menu.clear-selection": "ล้างการเลือก",
+ "page.dashboard.menu.delete-build": "ลบบิลด์",
+ "page.dashboard.menu.one-select-message": "โปรดเลือกการทดสอบอย่างน้อยหนึ่งอย่าง",
+ "page.dashboard.confirm.delete-build.title": "ลบบิลด์",
+ "page.dashboard.confirm.delete-build.message": "คุณแน่ใจหรือไม่ว่าต้องการลบบิลด์ที่เลือก {count} รายการ? การดำเนินการนี้ไม่สามารถยกเลิกได้ และจะลบการทดสอบและภาพหน้าจอที่เกี่ยวข้องทั้งหมดอย่างถาวร",
+ "page.dashboard.confirm.delete-build.cancel": "ยกเลิก",
+ "page.dashboard.toast.delete-build-success": "ลบบิลด์สำเร็จแล้ว {count} รายการ",
+ "page.dashboard.toast.delete-build-error": "ไม่สามารถลบบิลด์ได้",
+ "page.dashboard.execution-barchart.execution-time-seconds": "เวลาดำเนินการ (วินาที)",
+ "page.dashboard.execution-bar-chart.title": "ทดสอบการทำงาน",
+ "page.dashboard.execution-pie-chart.title": "สถานะโดยรวมของการทดสอบ",
+ "page.metrics.bread-crumb": "เมตริก",
+ "page.metrics.filters.labels.team": "ทีม",
+ "page.metrics.filters.labels.component": "ส่วนประกอบ",
+ "page.metrics.filters.labels.period": "ช่วงเวลา",
+ "page.metrics.filters.labels.group-by-period": "จัดกลุ่มตามช่วงเวลา",
+ "page.metrics.filters.button.retrieve-metrics": "ดึงข้อมูลเมตริก",
+ "page.metrics.tab.execution-metrics": "เมตริกการดำเนิน",
+ "page.metrics.tab.platform-metrics": "เมตริกแพลตฟอร์ม",
+ "page.metrics.execution-metrics-summary.label.period": "ช่วงเวลา",
+ "page.metrics.execution-metrics-summary.label.number-of-tests": "จำนวนการทดสอบ",
+ "page.metrics.execution-metrics-summary.label.number-of-runs": "จำนวนการทดสอบรัน",
+ "page.metrics.execution-metrics-summary.label.number-of-executions": "จำนวนการดำเนินการ",
+ "page.metrics.execution-metrics-summary.label.result": "ผลลัพธ์",
+ "page.metrics.execution-metrics-summary.label.duration": "ระยะเวลา",
+ "page.metrics.execution-metrics-bar-chart.title": "ตัวชี้วัดการดำเนินการจัดกลุ่มตามผลลัพธ์",
+ "page.metrics.execution-metrics-bar-chart.yaxis-title": "จำนวนการดำเนินการ",
+ "page.metrics.execution-metrics-phase-bar-chart.title": "ตัวชี้วัดการดำเนินการจัดกลุ่มตามเฟส",
+ "page.metrics.execution-metrics-phase-bar-chart.yaxis-title": "จำนวนการดำเนินการ",
+ "page.metrics.platform-distribution-pie-chart.title": "การแจกแจงการดำเนินการโดยรวมตามแพลตฟอร์ม",
+ "page.metrics.platform-distribution-bar-chart.title": "การแจกแจงการดำเนินการตามแพลตฟอร์ม",
+ "page.metrics.platform-distribution-bar-chart.yaxis-title": "แพลตฟอร์ม",
+ "page.metrics.platform-distribution-bar-chart.xaxis-title": "จำนวนการดำเนิน",
+ "page.metrics.platform-metrics-summary.label.platform": "แพลตฟอร์ม",
+ "page.metrics.platform-metrics-summary.label.version": "เวอร์ชัน",
+ "page.metrics.platform-metrics-summary.label.device": "อุปกรณ์",
+ "page.metrics.platform-metrics-summary.label.browser": "เบราว์เซอร์",
+ "page.metrics.platform-metrics-summary.label.total": "ทั้งหมด",
+ "page.metrics.platform-metrics-summary.label.result": "ผลลัพธ์",
+ "page.metrics.platform-metrics-summary.label.duration": "ระยะเวลา",
+ "page.metrics.metrics-test-table.label.unique-testcases": "กรณีทดสอบที่ไม่ซ้ำต่อเฟส",
+ "page.metrics.metrics-test-table.label.phase": "เฟส",
+ "page.metrics.metrics-test-table.label.test": "ทดสอบ",
+ "page.metrics.metrics-test-table.no-tests-found": "ไม่พบการทดสอบ",
+ "page.test-run.bread-crumb": "ทดสอบการทำงาน",
+ "page.test-run.steps-header.start": "เริ่ม",
+ "page.test-run.steps-header.duration": "ระยะเวลา",
+ "page.test-run.steps-header.end": "สิ้นสุด",
+ "page.test-run.menu.enable-keep-flag": "เปิดธงเก็บ",
+ "page.test-run.menu.disable-keep-flag": "ปิดธงเก็บ",
+ "page.test-run.menu.download-report": "ดาวน์โหลดรายงานเป็น html",
+ "page.test-run.menu.delete-build": "ลบบิลด์",
+ "page.test-run.confirm.delete-build.title": "ลบบิลด์",
+ "page.test-run.confirm.delete-build.message": "คุณแน่ใจหรือไม่ว่าต้องการลบบิลด์นี้? การดำเนินการนี้ไม่สามารถยกเลิกได้ และจะลบการทดสอบและภาพหน้าจอที่เกี่ยวข้องทั้งหมดอย่างถาวร",
+ "page.test-run.confirm.delete-build.cancel": "ยกเลิก",
+ "page.test-run.toast.delete-build-success": "ลบบิลด์สำเร็จแล้ว",
+ "page.test-run.toast.delete-build-error": "ไม่สามารถลบบิลด์ได้",
+ "page.test-run.icons.environment.whisper": "สิ่งแวดล้อม",
+ "page.test-run.icons.team.whisper": "ทีม",
+ "page.test-run.icons.component.whisper": "ส่วนประกอบ",
+ "page.test-run.icons.phase.whisper": "เฟสทดสอบ",
+ "page.test-run.icons.artifacts.whisper": "จำนวนสิ่งประดิษฐ์ที่เกี่ยวกับการทดสอบนี้",
+ "page.test-run.icons.keep-lock.whisper": "เมื่อล็อค การทดสอบการทำงานจะไม่ถูกล้างโดย cronjob ทุกคืน",
+ "page.test-run.icons.screenshots.whisper": "การทดสอบนี้มีภาพหน้าจอ {numberOfScreenshots} ภาพ",
+ "page.test-run.icons.download-report.whisper": "ดาวน์โหลดรายงาน HTML สำหรับการทดสอบนี้",
+ "page.test-run.header.status.whisper": "สถานะ: {status}",
+ "page.test-run.header.execution-count": "{count, plural, other {# การดำเนินการ}}",
+ "page.test-run.header.fact.not-set": "ไม่ได้กำหนด",
+ "page.test-run.header.artifacts-title": "อาร์ทิแฟกต์",
+ "page.test-run.suites.title": "ชุดทดสอบ",
+ "page.test-run.loading": "กำลังดึงรายละเอียดของบิลด์",
+ "page.test-run.error.load-failed": "ไม่สามารถดึงรายละเอียดของบิลด์ได้ กรุณารีเฟรชหน้าและลองใหม่อีกครั้ง และ/หรือตรวจสอบว่ารหัสบิลด์ถูกต้อง",
+ "page.test-run.execution-pie-chart.title": "ตัวชี้วัดการดำเนินการโดยรวม",
+ "page.test-run.feature-distribution-pie-chart.title": "เมตริกการกระจายฟีเจอร์",
+ "page.test-run.execution-pie-chart.pass-rate": "อัตราการผ่าน",
+ "page.test-run.feature-distribution-pie-chart.unspecified": "ไม่ได้ระบุ",
+ "page.test-run.feature-distribution-pie-chart.executions": "การดำเนินการ",
+ "page.test-run-compare.bread-crumb": "เปรียบเทียบการทดสอบการทำงาน",
+ "page.test-run-compare.header.number": "#",
+ "page.test-run-compare.header.test-name": "ชื่อการทดสอบ",
+ "page.test-run-compare.detail.start-date": "วันที่เริ่มต้น",
+ "page.test-run-compare.detail.environment": "สิ่งแวดล้อม",
+ "page.test-run-compare.cell.not-available": "ไม่มีข้อมูล",
+ "page.test-run-compare.cell.multiple-executions": "หลายการดำเนินการ",
+ "page.test-run-compare.popover.details-title": "รายละเอียด",
+ "page.test-run-compare.link.execution-history": "ดูประวัติการดำเนินการสำหรับ {testName}",
+ "page.test-run-compare.details-popover.header.property": "คุณสมบัติ",
+ "page.test-run-compare.details-popover.header.value": "ค่า",
+ "page.test-run-compare.details-popover.property.status": "สถานะ",
+ "page.test-run-compare.details-popover.property.duration": "ระยะเวลา",
+ "page.test-run-compare.details-popover.property.platform": "แพลตฟอร์ม",
+ "page.test-run-compare.details-popover.property.failing-step": "ขั้นตอนที่ล้มเหลว",
+ "page.test-run-compare.loading": "กำลังดึงรายละเอียดการทดสอบการทำงานเพื่อสร้างมุมมองเปรียบเทียบ...",
+ "page.test-run-compare.section.artifacts": "เวอร์ชันอาร์ติแฟกต์",
+ "page.test-run-compare.artifacts.count": "{count, plural, other {# อาร์ติแฟกต์}}",
+ "page.test-run-compare.section.compare-matrix": "เมทริกซ์เปรียบเทียบการทดสอบ",
+ "page.test-run-compare.toolbar.status-filter": "กรองตามสถานะ",
+ "page.test-run-compare.suite.tests": "{count, plural, other {# การทดสอบ}}",
+ "page.test-run-compare.suite.rollup": "ผ่าน {passed} จาก {total}",
+ "page.test-run-compare.header.run-summary": "ผ่าน {passed} จาก {total}, ล้มเหลว {failed}, ข้อผิดพลาด {errors}",
+ "page.test-run-compare.cell.status-changed": "สถานะเปลี่ยนจากการทำงานครั้งก่อน",
+ "page.test-run-compare.warning.many-runs": "กำลังเปรียบเทียบการทดสอบ {count} รายการ — ใช้ตัวกรองด้านล่างเพื่อโฟกัสที่ความแตกต่าง",
+ "page.screenshot-library.bread-crumb": "ลบรารีภาพหน้าจอ",
+ "page.screenshot-library.search-options.label.view": "วิว",
+ "page.screenshot-library.search-options.label.tag": "แท็ก",
+ "page.screenshot-library.search-options.label.or": "หรือ",
+ "page.screenshot-library.search-options.label.period": "ช่วงเวลา",
+ "page.screenshot-library.search-options.label.platform": "แพลตฟอร์ม",
+ "page.screenshot-library.search-options.button.search-screenshots": "ค้นหาภาพหน้าจอ",
+ "page.screenshot-library.search-options.dropdown.yesterday": "เมื่อวาน",
+ "page.screenshot-library.search-options.dropdown.last-7-days": "7 วันที่ผ่านมา",
+ "page.screenshot-library.search-options.dropdown.last-14-days": "14 วันที่ผ่านมา",
+ "page.screenshot-library.search-options.dropdown.last-30-days": "30 วันที่ผ่านมา",
+ "page.screenshot-library.search-options.dropdown.last-3-months": "3 เดือนที่ผ่านมา",
+ "page.screenshot-library.search-options.dropdown.last-6-months": "6 เดือนที่ผ่านมา",
+ "page.test-execution-history.bread-crumb": "ประวัติการทดสอบ",
+ "page.test-execution-history.header.suite": "สวีท",
+ "page.test-execution-history.messages.retrieving-executions": "กำลังดึงการดำเนินการทดสอบ",
+ "page.test-execution-history.charts.execution-pie-chart.title": "เมตริกการดำเนินการโดยรวม",
+ "page.test-execution-history.filter.hint": "เลือกสถานะในแผนภูมิด้านล่างเพื่อกรองหน้านี้",
+ "page.test-execution-history.filter.showing": "กำลังแสดง",
+ "page.test-execution-history.filter.active": "({count, plural, other {# การดำเนินการ}})",
+ "page.test-execution-history.filter.no-results": "ไม่มีการดำเนินการที่ตรงกับสถานะที่เลือก",
+ "page.test-execution-history.charts.execution-timeline-chart.title": "เวลาดำเนินการ",
+ "page.test-execution-history.charts.execution-timeline-chart.yaxis-label": "เวลาดำเนินการ (วินาที)",
+ "page.test-execution-history.charts.execution-platform-bar-chart.title": "การดำเนินการตามแพลตฟอร์ม",
+ "page.test-execution-history.charts.execution-platform-bar-chart.yaxis-title": "แพลตฟอร์ม",
+ "page.test-execution-history.charts.execution-platform-bar-chart.xaxis-title": "จำนวนการดำเนินการ",
+ "page.about.bread-crumb": "เกี่ยวกับ",
+ "page.about.header": "เกี่ยวกับ Angles",
+ "page.about.about-angles": "Angles เป็นแดชบอร์ดโอเพ่นซอร์สที่สามารถใช้เพื่อจัดเก็บผลลัพธ์และภาพหน้าจอสำหรับการทดสอบอัตโนมัติจากเฟรมเวิร์กต่างๆ",
+ "page.about.about-api": "ด้วยการจัดเตรียม {apiLink} ที่กำหนดไว้อย่างชัดเจน คุณสามารถปรับใช้เฟรมเวิร์กใดๆ เพื่อจัดเก็บผลการทดสอบใน Angles ได้โดยใช้หนึ่งในไคลเอนต์ที่ให้มา (หรือโดยใช้ API โดยตรง)",
+ "page.about.about-github": "สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ Angles ให้ไปที่หน้า: {githubLink}",
+ "page.introduction.bread-crumb": "บทนำ",
+ "page.introduction.header": "ยินดีต้อนรับสู่ Angles",
+ "page.introduction.description": "ดูเหมือนว่าคุณยังไม่ได้ตั้งค่าทีมหรือสภาพแวดล้อมใดๆ\n\nซึ่งสามารถทำได้โดยใช้ {apiLink}",
+ "page.introduction.api-link-text": "Swagger API",
+ "page.introduction.description-alternative": "หรืออีกทางหนึ่ง คุณสามารถใช้ {githubLink} เพื่อเริ่มต้นได้",
+ "page.introduction.github-link-text": "หน้า Github",
+ "page.not-found.bread-crumb": "ไม่เจอ",
+ "page.not-found.header": "ไม่พบหน้าเว็บ",
+ "page.not-found.description": "อ๊ะ เราไม่พบที่คุณกำลังหา คลิก {homeLink} เพื่อกลับไปที่หน้าแรก",
+ "page.not-found.home-link-text": "ที่นี่",
+ "common.component.suite-table.header.suite": "สวีท",
+ "common.component.suite-table.header.test": "ทดสอบ",
+ "common.component.suite-table.header.duration": "ระยะเวลา",
+ "common.component.suite-table.header.total": "ทั้งหมด",
+ "common.component.suite-table.header.collapse-all": "ยุบกรณีทดสอบและการดำเนินการทั้งหมด",
+ "common.component.suite-table.header.expand-all": "ขยายกรณีทดสอบทั้งหมด",
+ "common.component.suite-table.header.expand-all-actions": "ขยายกรณีทดสอบและการดำเนินการทั้งหมด",
+ "common.component.suite-table.icon.test-history": "ดูประวัติการดำเนินการสำหรับการทดสอบ",
+ "common.component.suite-table.header.show-screenshots": "แสดงภาพหน้าจอของขั้นตอน",
+ "common.component.suite-table.header.hide-screenshots": "ซ่อนภาพหน้าจอของขั้นตอน",
+ "common.component.suite-table.header.execution-count": "{count, plural, other {# การทดสอบ}}",
+ "common.component.suite-table.execution.action-count": "{count, plural, other {# การกระทำ}}",
+ "common.component.suite-table.action.step-count": "{count, plural, other {# ขั้นตอน}}",
+ "common.component.screenshot-view.header": "โปรแกรมดูภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.enable-alert": "หากต้องการเปิดใช้งานแท็บ \"ประวัติ\" และ \"เปรียบเทียบกับข้อมูลพื้นฐาน\" โปรดระบุมุมมองและรายละเอียดแพลตฟอร์มเมื่ออัปโหลดภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.label.image": "ภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.label.history": "ประวัติ",
+ "common.component.screenshot-view.tabs.label.overlay-with-baseline": "ซ้อนทับด้วยภาพพื้นฐาน",
+ "common.component.screenshot-view.tabs.label.side-by-side": "เคียงข้างกันด้วยภาพพื้นฐาน",
+ "common.component.screenshot-view.tabs.label.find-image": "ค้นหาในรูปภาพ",
+ "common.component.screenshot-view.tabs.baseline.label.algorithm": "อัลกอริทึมการเปรียบเทียบ",
+ "common.component.screenshot-view.tabs.baseline.algorithm.pixel": "พิกเซล",
+ "common.component.screenshot-view.tabs.baseline.algorithm.ssim": "เชิงโครงสร้าง (SSIM)",
+ "common.component.screenshot-view.tabs.baseline.button.add-suggested-region": "เพิ่มบริเวณที่เปลี่ยนแปลงนี้เป็นกล่องที่ไม่ต้องตรวจสอบ",
+ "common.component.screenshot-view.tabs.find-image.message.intro": "อัปโหลดรูปภาพขนาดเล็ก (เช่น ปุ่ม โลโก้ หรือแอสเซทของเกม) เพื่อค้นหาตำแหน่งในภาพหน้าจอปัจจุบัน",
+ "common.component.screenshot-view.tabs.find-image.label.template": "รูปภาพต้นแบบ",
+ "common.component.screenshot-view.tabs.find-image.label.min-confidence": "ความเชื่อมั่นขั้นต่ำ",
+ "common.component.screenshot-view.tabs.find-image.label.max-matches": "จำนวนผลลัพธ์สูงสุด",
+ "common.component.screenshot-view.tabs.find-image.label.grayscale": "ไม่สนใจความแตกต่างของสี (โทนสีเทา)",
+ "common.component.screenshot-view.tabs.find-image.button.find": "ค้นหาในภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.find-image.message.searching": "กำลังค้นหารูปภาพต้นแบบ อาจใช้เวลาสองสามวินาที",
+ "common.component.screenshot-view.tabs.find-image.message.no-matches": "ไม่พบผลลัพธ์ที่สูงกว่าเกณฑ์ความเชื่อมั่น",
+ "common.component.screenshot-view.tabs.find-image.label.matches": "ผลลัพธ์",
+ "common.component.screenshot-view.tabs.find-image.label.position": "ตำแหน่ง",
+ "common.component.screenshot-view.tabs.find-image.label.size": "ขนาด",
+ "common.component.screenshot-view.tabs.find-image.label.confidence": "ความเชื่อมั่น",
+ "common.component.screenshot-view.tabs.find-image.label.scale": "อัตราส่วน",
+ "common.component.screenshot-view.tabs.screenshot.button.make-baseline-image": "ให้ภาพเป็นพื้นฐาน",
+ "common.component.screenshot-view.tabs.screenshot.button.is-baseline-image": "นี่คือภาพพื้นฐาน",
+ "common.component.screenshot-view.tabs.screenshot.button.generate-dynamic-baseline": "สร้างภาพพื้นฐานแบบไดนามิก",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot": "ลบภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.screenshot.button.delete-screenshot-description": "ใช้ได้เฉพาะกับพื้นฐานแบบไดนามิกที่ไม่ได้กำหนดค่าเป็นพื้นฐานเท่านั้น",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot": "กำลังดึงภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.screenshot.message.retrieving-screenshot-error": "ม่สามารถดึงภาพได้ โปรดรีเฟรชหน้าแล้วลองอีกครั้ง",
+ "common.component.screenshot-view.tabs.history.message.loading-history": "กำลังโหลดประวัติ...",
+ "common.component.screenshot-view.tabs.history.label.baseline": "ภาพหน้าจอพื้นฐาน",
+ "common.component.screenshot-view.tabs.history.label.view": "วิว",
+ "common.component.screenshot-view.tabs.history.label.date": "วันที่",
+ "common.component.screenshot-view.tabs.history.label.resolution": "ความละเอียด",
+ "common.component.screenshot-view.tabs.history.label.platform": "แพลตฟอร์ม",
+ "common.component.screenshot-view.tabs.history.label.device": "อุปกรณ์",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image-baseline": "ภาพปัจจุบัน (และพื้นฐาน)",
+ "common.component.screenshot-view.tabs.side-by-side.label.current-image": "ภาพปัจจุบัน",
+ "common.component.screenshot-view.tabs.side-by-side.label.baseline": "พื้นฐาน",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot": "กำลังโหลดภาพพื้นฐาน",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-baseline-screenshot-error": "ไม่สามารถเรียกภาพพื้นฐานได้ โปรดรีเฟรชหน้านี้แล้วลองอีกครั้ง",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot": "กำลังเรียกภาพหน้าจอ",
+ "common.component.screenshot-view.tabs.side-by-side.message.retrieving-screenshot-error": "ม่สามารถเรียกภาพได้ โปรดรีเฟรชหน้าแล้วลองอีกครั้ง",
+ "common.component.screenshot-view.tabs.baseline.label.current-image-baseline": "ภาพปัจจุบัน (และพื้นฐาน)",
+ "common.component.screenshot-view.tabs.baseline.label.baseline-compare": "การเปรียบเทียบพื้นฐาน",
+ "common.component.screenshot-view.tabs.baseline.button.redo-compare-description": "ทำซ้ำเปรียบเทียบกับพื้นฐาน.",
+ "common.component.screenshot-view.tabs.baseline.button.edit-ignore-blocks": "แก้ไขการละเว้นการบล็อก",
+ "common.component.screenshot-view.tabs.baseline.button.save-ignore-blocks": "บันทึกการละเว้นการบล็อก",
+ "common.component.screenshot-view.tabs.baseline.button.cancel-changes": "ยกเลิกการเปลี่ยนแปลง",
+ "common.component.screenshot-view.tabs.baseline.message.no-baseline-set": "ยังไม่ได้เลือกพื้นฐานสำหรับวิวนี้และชื่ออุปกรณ์หรือการรวมกันของเบราว์เซอร์ หากต้องการเลือกพื้นฐาน ให้นำทางไปรูปภาพที่คุณต้องการเป็นพื้นฐาน และคลิกที่ปุ่ม \"ให้ภาพเป็นพื้นฐาน\"",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-compare": "กำลังโหลดการเปรียบเทียบพื้นฐาน",
+ "common.component.screenshot-view.tabs.baseline.message.loading-baseline-error": "ไม่สามารถเรียกข้อมูลการเปรียบเทียบพื้นฐานได้",
+ "common.component.screenshot-details-table.label.view": "วิว",
+ "common.component.screenshot-details-table.label.view-link": "แพลตฟอร์มอื่นๆ",
+ "common.component.screenshot-details-table.label.view-link-description": "ค้นหาภาพหน้าจอล่าสุดทั้งหมดสำหรับวิว {view} ซึ่งจัดกลุ่มตามแพลตฟอร์ม",
+ "common.component.screenshot-details-table.label.no-view": "ไม่มีวิวให้มา",
+ "common.component.screenshot-details-table.label.date": "วันที่",
+ "common.component.screenshot-details-table.label.baseline-date": "วันที่พื้นฐาน",
+ "common.component.screenshot-details-table.label.mismatch": "ไม่ตรงกัน",
+ "common.component.screenshot-details-table.label.algorithm": "อัลกอริทึม",
+ "common.component.screenshot-details-table.label.ssim-score": "คะแนน SSIM",
+ "common.component.screenshot-details-table.label.mismatch-result": "{mismatchPercentage}% (เวลาวิเคราะห์: {time} ms)",
+ "common.component.screenshot-details-table.label.resolution": "ความละเอียด",
+ "common.component.screenshot-details-table.label.platform": "แพลตฟอร์ม",
+ "common.component.screenshot-details-table.label.no-platform": "ไม่มีแพลตฟอร์มให้มา",
+ "common.component.screenshot-details-table.label.platform-version": "เวอร์ชันแพลตฟอร์ม",
+ "common.component.screenshot-details-table.label.device": "อุปกรณ์",
+ "common.component.screenshot-details-table.label.browser": "เบราว์เซอร์",
+ "common.component.screenshot-details-table.label.no-browser": "ไม่มีเบราว์เซอร์ให้มา",
+ "common.component.screenshot-details-table.label.browser-version": "เวอร์ชันของเบราว์เซอร์",
+ "common.component.screenshot-details-table.label.no-browser-version": "ไม่มีเวอร์ชันเบราว์เซอร์ให้มา",
+ "common.component.screenshot-details-table.label.baseline": "พื้นฐาน",
+ "common.component.screenshot-details-table.label.tags": "แท็ก",
+ "common.component.screenshot-details-table.label.tags-description": "ค้นหาภาพหน้าจอล่าสุดทั้งหมดที่มีแท็ก \"{tag}\", งจัดกลุ่มตามวิว",
+ "common.yes": "ใช่",
+ "common.no": "ไม่",
+ "common.button.ok": "ตกลง",
+ "page.login.title": "เข้าสู่ระบบ Angles",
+ "page.login.error.invalid-credentials": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง",
+ "page.login.label.username": "ชื่อผู้ใช้",
+ "page.login.label.password": "รหัสผ่าน",
+ "page.login.button.login": "เข้าสู่ระบบ",
+ "page.login.divider.or": "หรือ",
+ "page.login.label.sign-in-with": "เข้าสู่ระบบด้วย",
+ "page.login.source.local": "บัญชีในระบบ",
+ "page.login.button.sso": "ดำเนินการต่อด้วย {name}",
+ "page.login.error.sso-failed": "การเข้าสู่ระบบแบบ Single Sign-On ล้มเหลว โปรดลองอีกครั้งหรือติดต่อผู้ดูแลระบบ",
+ "page.login.error.no-methods": "ไม่มีวิธีการเข้าสู่ระบบที่ใช้ได้ โปรดติดต่อผู้ดูแลระบบ",
+ "page.user-settings.header": "การตั้งค่าผู้ใช้",
+ "page.user-settings.api-tokens.title": "API โทเค็น",
+ "page.user-settings.api-tokens.description": "API โทเค็นส่วนบุคคลทำหน้าที่เหมือนรหัสผ่านธรรมดา แต่จะช่วยให้คุณตรวจสอบสิทธิ์กับ API ผ่านโปรแกรมได้",
+ "page.user-settings.button.generate-new-token": "สร้างโทเค็นใหม่",
+ "page.user-settings.table.name": "ชื่อ",
+ "page.user-settings.table.created": "สร้างเมื่อ",
+ "page.user-settings.table.expires": "หมดอายุ",
+ "page.user-settings.table.action": "การดำเนินการ",
+ "page.user-settings.table.revoke": "เพิกถอน",
+ "page.user-settings.modal.token-generated": "สร้างโทเค็นแล้ว",
+ "page.user-settings.modal.generate-token": "สร้าง API โทเค็นใหม่",
+ "page.user-settings.modal.success-label": "สำเร็จ!",
+ "page.user-settings.modal.success": "สร้างโทเค็นใหม่ของคุณแล้ว",
+ "page.user-settings.modal.warning": "โปรดคัดลอกโทเค็นนี้ทันที คุณจะไม่สามารถเห็นมันได้อีก!",
+ "page.user-settings.modal.token-name": "ชื่อโทเค็น",
+ "page.user-settings.modal.token-placeholder": "เช่น CI/CD Pipeline",
+ "page.user-settings.modal.expiration": "หมดอายุ",
+ "page.user-settings.modal.custom-expiration": "กำหนดวันหมดอายุ (วัน)",
+ "page.user-settings.button.done": "เสร็จสิ้น",
+ "page.user-settings.button.generate": "สร้าง",
+ "page.user-settings.button.cancel": "ยกเลิก",
+ "page.user-settings.confirm.revoke.title": "เพิกถอนโทเค็น",
+ "page.user-settings.confirm.revoke.message": "คุณแน่ใจหรือไม่ว่าต้องการเพิกถอนโทเค็นนี้? สคริปต์หรือแอพพลิเคชันใดๆ ที่ใช้โทเค็นนี้จะสูญเสียการเข้าถึงทันที",
+ "page.user-settings.table.expires-never": "ไม่มีวันหมดอายุ",
+ "page.user-settings.modal.token-label": "โทเค็น",
+ "page.user-settings.modal.button.copy": "คัดลอก",
+ "page.user-settings.expiry.1-day": "1 วัน",
+ "page.user-settings.expiry.1-week": "1 สัปดาห์",
+ "page.user-settings.expiry.1-month": "1 เดือน",
+ "page.user-settings.expiry.1-year": "1 ปี",
+ "page.user-settings.expiry.custom": "กำหนดเอง",
+ "page.user-settings.toast.fetch-tokens-error": "ไม่สามารถดึงข้อมูลโทเค็นได้",
+ "page.user-settings.toast.token-name-required": "จำเป็นต้องระบุชื่อโทเค็น",
+ "page.user-settings.toast.expiry-required": "ต้องการวันหมดอายุที่ถูกต้อง",
+ "page.user-settings.toast.generate-token-error": "ไม่สามารถสร้างโทเค็นได้",
+ "page.user-settings.toast.revoke-success": "เพิกถอนโทเค็นสำเร็จแล้ว",
+ "page.user-settings.toast.revoke-error": "ไม่สามารถเพิกถอนโทเค็นได้",
+ "page.user-settings.toast.copy-success": "คัดลอกโทเค็นไปยังคลิปบอร์ดแล้ว",
+ "password.policy.min-length": "มีความยาวอย่างน้อย 10 ตัวอักษร",
+ "password.policy.max-length": "มีความยาวไม่เกิน 100 ตัวอักษร",
+ "password.policy.letter": "มีตัวอักษรอย่างน้อยหนึ่งตัว",
+ "password.policy.uppercase": "มีตัวพิมพ์ใหญ่อย่างน้อยหนึ่งตัว",
+ "password.policy.special": "มีอักขระพิเศษอย่างน้อยหนึ่งตัว",
+ "password.policy.invalid": "รหัสผ่านไม่ตรงตามข้อกำหนดด้านความปลอดภัย",
+ "page.user-settings.password.title": "รหัสผ่าน",
+ "page.user-settings.password.description": "เปลี่ยนรหัสผ่านที่คุณใช้เข้าสู่ระบบ Angles",
+ "page.user-settings.password.button.change": "เปลี่ยนรหัสผ่าน",
+ "page.user-settings.password.modal.title": "เปลี่ยนรหัสผ่าน",
+ "page.user-settings.password.modal.current": "รหัสผ่านปัจจุบัน",
+ "page.user-settings.password.modal.new": "รหัสผ่านใหม่",
+ "page.user-settings.password.modal.confirm": "ยืนยันรหัสผ่านใหม่",
+ "page.user-settings.password.modal.mismatch": "รหัสผ่านไม่ตรงกัน",
+ "page.user-settings.password.modal.submit": "อัปเดตรหัสผ่าน",
+ "page.user-settings.password.modal.success": "อัปเดตรหัสผ่านของคุณแล้ว ใช้รหัสผ่านนี้ในการเข้าสู่ระบบครั้งถัดไป",
+ "page.user-settings.password.toast.current-required": "จำเป็นต้องระบุรหัสผ่านปัจจุบัน",
+ "page.user-settings.password.toast.mismatch": "รหัสผ่านใหม่และการยืนยันไม่ตรงกัน",
+ "page.user-settings.password.toast.success": "อัปเดตรหัสผ่านสำเร็จ",
+ "page.user-settings.password.toast.error": "ไม่สามารถอัปเดตรหัสผ่านได้",
+ "page.admin.settings.header": "การตั้งค่าการรับรองความถูกต้อง",
+ "page.admin.settings.loading": "กำลังโหลดการตั้งค่า...",
+ "page.admin.settings.local-auth.title": "การตรวจสอบสิทธิ์ภายใน",
+ "page.admin.settings.local-auth.enable": "เปิดใช้งานการตรวจสอบสิทธิ์ภายใน",
+ "page.admin.settings.local-auth.help": "อนุญาตให้ผู้ใช้เข้าสู่ระบบด้วยชื่อผู้ใช้และรหัสผ่าน",
+ "page.admin.settings.button.save": "บันทึกการตั้งค่า",
+ "page.admin.users.header": "การจัดการผู้ใช้",
+ "page.admin.users.button.add-user": "เพิ่มผู้ใช้",
+ "page.admin.users.table.username": "ชื่อผู้ใช้",
+ "page.admin.users.table.role": "บทบาท",
+ "page.admin.users.table.teams": "ทีม",
+ "page.admin.users.table.action": "การดำเนินการ",
+ "page.admin.users.table.edit": "แก้ไข",
+ "page.admin.users.table.delete": "ลบ",
+ "page.admin.users.modal.edit-user": "แก้ไขผู้ใช้",
+ "page.admin.users.modal.add-user": "เพิ่มผู้ใช้",
+ "page.admin.users.modal.password-help": "(เว้นว่างไว้เพื่อไม่ให้เปลี่ยนแปลง)",
+ "page.admin.users.modal.user-type": "ประเภทผู้ใช้",
+ "page.admin.users.modal.team-access": "การเข้าถึงทีม",
+ "page.admin.users.button.save": "บันทึก",
+ "page.admin.users.button.cancel": "ยกเลิก",
+ "page.admin.users.confirm.delete.title": "ลบผู้ใช้",
+ "page.admin.users.confirm.delete.message": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ใช้นี้? การกระทำนี้ไม่สามารถย้อนกลับได้",
+ "page.admin.users.user-type.admin": "ผู้ดูแลระบบ",
+ "page.admin.users.user-type.team_lead": "หัวหน้าทีม",
+ "page.admin.users.user-type.user": "ผู้ใช้",
+ "page.admin.users.modal.password-label": "รหัสผ่าน",
+ "page.admin.users.table.teams-assigned": "มอบหมาย {count} ทีม",
+ "page.admin.users.toast.fetch-error": "ไม่สามารถดึงข้อมูลผู้ใช้ได้",
+ "page.admin.users.toast.update-success": "อัปเดตผู้ใช้สำเร็จแล้ว",
+ "page.admin.users.toast.create-success": "สร้างผู้ใช้สำเร็จแล้ว",
+ "page.admin.users.toast.save-error": "ไม่สามารถบันทึกผู้ใช้ได้",
+ "page.admin.users.toast.delete-success": "ลบผู้ใช้สำเร็จแล้ว",
+ "page.admin.users.toast.delete-error": "ไม่สามารถลบผู้ใช้ได้",
+ "page.admin.settings.toast.fetch-error": "ไม่สามารถดึงข้อมูลการตั้งค่าได้",
+ "page.admin.settings.toast.save-success": "บันทึกการตั้งค่าสำเร็จแล้ว",
+ "page.admin.settings.toast.save-error": "ไม่สามารถบันทึกการตั้งค่าได้",
+ "page.admin.settings.providers.title": "ผู้ให้บริการข้อมูลประจำตัว",
+ "page.admin.settings.providers.help": "ตั้งค่าผู้ให้บริการ Single Sign-On การเปลี่ยนแปลงมีผลทันทีโดยไม่ต้องรีสตาร์ท",
+ "page.admin.settings.providers.empty": "ยังไม่มีการตั้งค่าผู้ให้บริการข้อมูลประจำตัว",
+ "page.admin.settings.providers.table.name": "ชื่อ",
+ "page.admin.settings.providers.table.id": "ไอดี",
+ "page.admin.settings.providers.table.type": "ประเภท",
+ "page.admin.settings.providers.table.enabled": "เปิดใช้งาน",
+ "page.admin.settings.providers.table.status": "สถานะ",
+ "page.admin.settings.button.add-provider": "เพิ่มผู้ให้บริการ",
+ "page.admin.settings.button.save-provider": "บันทึกผู้ให้บริการ",
+ "page.admin.settings.confirm.delete.title": "ลบผู้ให้บริการ",
+ "page.admin.settings.confirm.delete.message": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการข้อมูลประจำตัวนี้ ผู้ใช้ที่เข้าสู่ระบบผ่านผู้ให้บริการนี้จะสูญเสียสิทธิ์การเข้าถึง",
+ "page.admin.settings.provider.modal.add": "เพิ่มผู้ให้บริการข้อมูลประจำตัว",
+ "page.admin.settings.provider.modal.edit": "แก้ไขผู้ให้บริการข้อมูลประจำตัว",
+ "page.admin.settings.provider.name": "ชื่อที่แสดง",
+ "page.admin.settings.provider.name.help": "แสดงบนปุ่มเข้าสู่ระบบ เช่น \"SSO ขององค์กร\"",
+ "page.admin.settings.provider.id": "ไอดีผู้ให้บริการ",
+ "page.admin.settings.provider.id.help": "ตัวอักษรพิมพ์เล็ก ตัวเลข หรือขีดกลาง 2-32 ตัว เป็นส่วนหนึ่งของ callback URL และไม่สามารถแก้ไขได้ภายหลัง",
+ "page.admin.settings.provider.id.help-locked": "ไอดีนี้เป็นส่วนหนึ่งของ callback URL ที่ลงทะเบียนไว้กับผู้ให้บริการข้อมูลประจำตัว จึงไม่สามารถแก้ไขได้",
+ "page.admin.settings.provider.type": "ประเภทผู้ให้บริการ",
+ "page.admin.settings.provider.type.oidc": "OIDC",
+ "page.admin.settings.provider.type.saml": "SAML 2.0",
+ "page.admin.settings.provider.type.ldap": "LDAP / AD",
+ "page.admin.settings.provider.type.help-locked": "ไม่สามารถเปลี่ยนประเภทผู้ให้บริการได้หลังจากสร้างแล้ว",
+ "page.admin.settings.provider.enabled": "เปิดใช้งาน",
+ "page.admin.settings.provider.enabled.help": "แสดงผู้ให้บริการนี้บนหน้าเข้าสู่ระบบ",
+ "page.admin.settings.provider.callback-url": "Redirect URI",
+ "page.admin.settings.provider.acs-url": "ACS (Callback) URL",
+ "page.admin.settings.provider.callback-url.help": "ลงทะเบียน URL นี้กับผู้ให้บริการข้อมูลประจำตัวของคุณ",
+ "page.admin.settings.provider.status.ready": "พร้อมใช้งาน",
+ "page.admin.settings.provider.status.error": "ข้อผิดพลาด",
+ "page.admin.settings.provider.status.enabled": "เปิดใช้งาน",
+ "page.admin.settings.provider.status.disabled": "ปิดใช้งาน",
+ "page.admin.settings.provider.default-role": "บทบาทเริ่มต้น",
+ "page.admin.settings.provider.default-role.deny": "ปฏิเสธการเข้าถึง",
+ "page.admin.settings.provider.default-role.help": "บทบาทที่กำหนดให้เมื่อไม่มีการแมปใดตรงกัน การปฏิเสธการเข้าถึงเป็นค่าเริ่มต้นที่ปลอดภัยกว่า",
+ "page.admin.settings.provider.secret.help-set": "มีการตั้งค่าไว้แล้ว เว้นว่างไว้เพื่อคงค่าเดิม หรือกรอกค่าใหม่เพื่อแทนที่",
+ "page.admin.settings.provider.secret.help-unset": "เมื่อบันทึกแล้วจะไม่สามารถดูค่านี้ได้อีก",
+ "page.admin.settings.provider.error.id": "ไอดีผู้ให้บริการต้องเป็นตัวอักษรพิมพ์เล็ก ตัวเลข หรือขีดกลาง 2-32 ตัว",
+ "page.admin.settings.provider.error.duplicate-id": "มีผู้ให้บริการรายอื่นใช้ไอดีนี้อยู่แล้ว",
+ "page.admin.settings.provider.error.name": "ต้องระบุชื่อที่แสดง",
+ "page.admin.settings.provider.error.mapping-value": "การแมปบทบาททุกรายการต้องมีค่ากลุ่ม",
+ "page.admin.settings.oidc.title": "การตั้งค่า OIDC",
+ "page.admin.settings.oidc.issuer": "Issuer URL",
+ "page.admin.settings.oidc.issuer.help": "ระบบจะค้นหา endpoint และคีย์สำหรับตรวจสอบลายเซ็นจาก .well-known/openid-configuration ของ issuer",
+ "page.admin.settings.oidc.client-id": "Client ID",
+ "page.admin.settings.oidc.client-secret": "Client Secret",
+ "page.admin.settings.oidc.scopes": "Scopes",
+ "page.admin.settings.oidc.scopes.help": "คั่นด้วยช่องว่าง ระบบจะเพิ่ม \"openid\" ให้เสมอ Okta และ Keycloak รองรับ \"groups\" ส่วน Google จะปฏิเสธ",
+ "page.admin.settings.oidc.groups-claim": "Groups Claim",
+ "page.admin.settings.oidc.groups-claim.help": "Claim ที่เก็บการเป็นสมาชิกกลุ่ม — \"groups\" สำหรับ Okta และมักเป็น \"roles\" สำหรับ Keycloak ส่วน Entra ID จะส่งเป็น GUID ของกลุ่ม",
+ "page.admin.settings.oidc.username-claim": "Username Claim",
+ "page.admin.settings.oidc.username-claim.help": "Claim ที่ใช้เป็นชื่อผู้ใช้ของ Angles หากไม่มีจะใช้ email, preferred_username และ sub ตามลำดับ",
+ "page.admin.settings.saml.title": "การตั้งค่า SAML 2.0",
+ "page.admin.settings.saml.metadata-url": "SP Metadata URL",
+ "page.admin.settings.saml.metadata-url.help": "นำเข้า URL นี้ในผู้ให้บริการข้อมูลประจำตัวของคุณแทนการกรอกค่าต่าง ๆ ด้วยตนเอง",
+ "page.admin.settings.saml.entry-point": "IdP SSO URL",
+ "page.admin.settings.saml.entry-point.help": "URL ของผู้ให้บริการข้อมูลประจำตัวที่เบราว์เซอร์จะถูกเปลี่ยนเส้นทางไป",
+ "page.admin.settings.saml.idp-cert": "ใบรับรองสำหรับลงลายเซ็นของ IdP",
+ "page.admin.settings.saml.idp-cert.help": "จำเป็นต้องระบุ — เป็นสิ่งเดียวที่ทำให้เชื่อถือ assertion ได้ รองรับทั้งแบบมีและไม่มีบรรทัดหัว PEM",
+ "page.admin.settings.saml.issuer": "SP Entity ID",
+ "page.admin.settings.saml.issuer.help": "Entity ID ของผู้ให้บริการนี้ ตามที่ลงทะเบียนไว้กับผู้ให้บริการข้อมูลประจำตัว",
+ "page.admin.settings.saml.private-key": "คีย์ส่วนตัวของ SP",
+ "page.admin.settings.saml.private-key.help": "จำเป็นเฉพาะเมื่อผู้ให้บริการข้อมูลประจำตัวกำหนดให้ลงลายเซ็นคำขอยืนยันตัวตน เว้นว่างไว้เพื่อคงคีย์เดิม",
+ "page.admin.settings.saml.signature-algorithm": "อัลกอริทึมลายเซ็น",
+ "page.admin.settings.saml.identifier-format": "รูปแบบ Name ID",
+ "page.admin.settings.saml.username-attribute": "แอตทริบิวต์ชื่อผู้ใช้",
+ "page.admin.settings.saml.username-attribute.help": "แอตทริบิวต์ใน assertion ที่เก็บชื่อผู้ใช้ เว้นว่างไว้เพื่อใช้ Name ID",
+ "page.admin.settings.saml.groups-attribute": "แอตทริบิวต์กลุ่ม",
+ "page.admin.settings.saml.response-signed": "กำหนดให้ Response ต้องมีลายเซ็น",
+ "page.admin.settings.saml.response-signed.help": "ปิดสำหรับผู้ให้บริการที่ลงลายเซ็นเฉพาะ assertion ทั้งนี้ลายเซ็นของ assertion เป็นสิ่งจำเป็นเสมอ",
+ "page.admin.settings.saml.allow-unsolicited": "อนุญาตการเข้าสู่ระบบที่เริ่มจาก IdP",
+ "page.admin.settings.saml.allow-unsolicited.help": "ปิดไว้เป็นค่าเริ่มต้น: assertion ที่ไม่ได้ร้องขอจะไม่มีคำขอให้เทียบเคียง จึงสูญเสียการป้องกันการโจมตีแบบ replay",
+ "page.admin.settings.ldap.title": "การตั้งค่า LDAP / Active Directory",
+ "page.admin.settings.ldap.url": "URL ของไดเรกทอรี",
+ "page.admin.settings.ldap.url.help": "ใช้ ldaps:// หรือ ldap:// ร่วมกับ StartTLS การเชื่อมต่อ ldap:// แบบธรรมดาจะถูกปฏิเสธ เพราะรหัสผ่านจะถูกส่งเป็นข้อความธรรมดา",
+ "page.admin.settings.ldap.bind-dn": "Bind DN",
+ "page.admin.settings.ldap.bind-dn.help": "บัญชีบริการที่ใช้ค้นหาผู้ใช้ เว้นว่างไว้เพื่อค้นหาแบบไม่ระบุตัวตน",
+ "page.admin.settings.ldap.bind-credentials": "รหัสผ่าน Bind",
+ "page.admin.settings.ldap.search-base": "ฐานการค้นหาผู้ใช้",
+ "page.admin.settings.ldap.search-base.help": "ตัวอย่างเช่น: ou=users,dc=example,dc=com",
+ "page.admin.settings.ldap.search-filter": "ตัวกรองการค้นหาผู้ใช้",
+ "page.admin.settings.ldap.search-filter.help": "{username} จะถูกแทนที่ด้วยชื่อผู้ใช้ที่กรอก โดยทั่วไป Active Directory ใช้ (sAMAccountName={username})",
+ "page.admin.settings.ldap.username-attribute": "แอตทริบิวต์ชื่อผู้ใช้",
+ "page.admin.settings.ldap.username-attribute.help": "แอตทริบิวต์ที่ใช้เป็นชื่อผู้ใช้ของ Angles — \"uid\" หรือ \"sAMAccountName\" บน Active Directory",
+ "page.admin.settings.ldap.group-search-base": "ฐานการค้นหากลุ่ม",
+ "page.admin.settings.ldap.group-search-base.help": "เว้นว่างไว้เพื่ออ่านการเป็นสมาชิกกลุ่มจากแอตทริบิวต์ memberOf ของผู้ใช้ ซึ่งเป็นวิธีที่ Active Directory ใช้โดยทั่วไป",
+ "page.admin.settings.ldap.group-search-filter": "ตัวกรองการค้นหากลุ่ม",
+ "page.admin.settings.ldap.group-name-attribute": "แอตทริบิวต์ชื่อกลุ่ม",
+ "page.admin.settings.ldap.group-name-attribute.help": "DN แบบเต็มจะถูกย่อเหลือเพียงแอตทริบิวต์นี้ ดังนั้นให้แมปบทบาทกับชื่อ เช่น \"Angles Admins\"",
+ "page.admin.settings.ldap.start-tls": "ใช้ StartTLS",
+ "page.admin.settings.ldap.start-tls.help": "ยกระดับการเชื่อมต่อ ldap:// แบบธรรมดาให้เป็น TLS",
+ "page.admin.settings.ldap.tls-reject-unauthorized": "ตรวจสอบใบรับรอง TLS",
+ "page.admin.settings.ldap.tls-reject-unauthorized.help": "การปิดตัวเลือกนี้ทำให้การเชื่อมต่อถูกดักจับได้ง่าย ใช้เฉพาะกับระบบทดสอบที่ใช้ใบรับรองแบบ self-signed",
+ "page.admin.settings.role-mappings.title": "การแมปกลุ่มกับบทบาท",
+ "page.admin.settings.role-mappings.help": "แมปกลุ่มที่ผู้ให้บริการส่งมาเข้ากับบทบาทของ Angles การจับคู่ไม่คำนึงถึงตัวพิมพ์เล็กพิมพ์ใหญ่ และผู้ใช้ที่ตรงกับหลายรายการจะได้รับบทบาทที่มีสิทธิ์สูงสุด (ผู้ดูแลระบบ > หัวหน้าทีม > ผู้ใช้)",
+ "page.admin.settings.role-mappings.empty": "ยังไม่มีการแมป หากไม่มีการแมป สิทธิ์การเข้าถึงจะขึ้นอยู่กับบทบาทเริ่มต้นด้านล่าง",
+ "page.admin.settings.role-mappings.add": "เพิ่มการแมป",
+ "page.admin.settings.role-mappings.remove": "ลบการแมป",
+ "page.admin.settings.role-mappings.value-placeholder": "ชื่อกลุ่มหรือไอดี",
+ "page.team-settings.header": "การตั้งค่าทีม",
+ "page.team-settings.label.team": "ทีมที่ต้องการตั้งค่า",
+ "page.team-settings.label.team.help": "การเปลี่ยนแปลงนี้มีผลเฉพาะกับทีมที่คุณกำลังแก้ไขที่นี่เท่านั้น จะไม่เปลี่ยนทีมที่เลือกไว้ในเมนูด้านบน",
+ "page.team-settings.section.details": "รายละเอียดทีม",
+ "page.team-settings.label.name": "ชื่อทีม",
+ "page.team-settings.button.save-name": "บันทึกชื่อ",
+ "page.team-settings.section.components": "ส่วนประกอบ",
+ "page.team-settings.components.help": "ส่วนประกอบช่วยให้คุณจัดกลุ่มการครอบคลุมการทดสอบตามพื้นที่ของฟังก์ชันการทำงาน",
+ "page.team-settings.label.existing-components": "ส่วนประกอบที่มีอยู่",
+ "page.team-settings.label.add-components": "เพิ่มส่วนประกอบ",
+ "page.team-settings.components.add-placeholder": "พิมพ์ชื่อส่วนประกอบแล้วกด enter",
+ "page.team-settings.button.add-components": "เพิ่มส่วนประกอบ",
+ "page.team-settings.no-team-access": "คุณยังไม่ได้รับสิทธิ์เข้าถึงทีมใดๆ โปรดติดต่อผู้ดูแลระบบ",
+ "page.team-settings.toast.fetch-error": "ไม่สามารถดึงข้อมูลทีมได้",
+ "page.team-settings.toast.name-update-success": "อัปเดตชื่อทีมสำเร็จแล้ว",
+ "page.team-settings.toast.name-update-error": "ไม่สามารถอัปเดตชื่อทีมได้",
+ "page.team-settings.toast.components-update-success": "เพิ่มส่วนประกอบสำเร็จแล้ว",
+ "page.team-settings.toast.components-update-error": "ไม่สามารถเพิ่มส่วนประกอบได้",
+ "nav.manual-test-cases": "เทสต์เคสแบบแมนนวล",
+ "app.manual.status.draft": "ฉบับร่าง",
+ "app.manual.status.active": "ใช้งาน",
+ "app.manual.status.deprecated": "เลิกใช้",
+ "app.manual.priority.low": "ต่ำ",
+ "app.manual.priority.medium": "ปานกลาง",
+ "app.manual.priority.high": "สูง",
+ "app.manual.priority.critical": "วิกฤต",
+ "app.manual.cancel": "ยกเลิก",
+ "app.manual.version-badge.version": "v{version}",
+ "app.manual.version-badge.behind": "· ตามหลังล่าสุด {count}",
+ "app.manual.history.loading": "กำลังโหลดประวัติ...",
+ "app.manual.history.empty": "ยังไม่มีการบันทึกการเปลี่ยนแปลง",
+ "app.manual.history.unknown-author": "ผู้ใช้ที่ไม่รู้จัก",
+ "app.manual.history.caused-by": "เกิดจากการอัปเดตขั้นตอนที่ใช้ร่วมกัน \"{name}\"",
+ "app.manual.history.action.create": "สร้างแล้ว",
+ "app.manual.history.action.update": "อัปเดตแล้ว",
+ "app.manual.history.action.delete": "ลบแล้ว",
+ "app.manual.history.action.clone": "โคลนแล้ว",
+ "app.manual.history.action.status-change": "เปลี่ยนสถานะ",
+ "app.manual.history.action.shared-step-update": "อัปเดตขั้นตอนที่ใช้ร่วมกัน",
+ "app.manual.history.action.archive": "เก็บถาวรแล้ว",
+ "app.manual.step-editor.expected": "ผลที่คาดหวัง",
+ "app.manual.step-editor.data": "ข้อมูล",
+ "app.manual.step-editor.shared-step": "ขั้นตอนที่ใช้ร่วมกัน",
+ "app.manual.step-editor.shared-step-placeholder": "เลือกขั้นตอนที่ใช้ร่วมกัน",
+ "app.manual.step-editor.shared-step-help": "ขั้นตอนของรายการนั้นจะแทนที่ขั้นตอนนี้เมื่อรันหรือบันทึกเวอร์ชัน",
+ "app.manual.step-editor.from-shared-step": "จากขั้นตอนที่ใช้ร่วมกัน (v{version})",
+ "app.manual.step-editor.unresolved": "ไม่มีขั้นตอนที่ใช้ร่วมกันแล้ว",
+ "app.manual.step-editor.action-placeholder": "ผู้ทดสอบควรทำอะไร?",
+ "app.manual.step-editor.expected-placeholder": "ควรเกิดอะไรขึ้น?",
+ "app.manual.step-editor.data-placeholder": "ข้อมูลทดสอบ (ไม่บังคับ)",
+ "app.manual.step-editor.add-step": "เพิ่มขั้นตอน",
+ "app.manual.step-editor.add-shared-step": "เพิ่มขั้นตอนที่ใช้ร่วมกัน",
+ "app.manual.step-editor.remove": "ลบขั้นตอน",
+ "app.manual.step-editor.empty": "ยังไม่มีขั้นตอน เพิ่มขั้นตอนแรกด้านล่าง",
+ "app.manual.custom-field.archived": "(เก็บถาวร)",
+ "page.manual-test-cases": "เทสต์เคสแบบแมนนวล",
+ "page.manual-test-cases.create": "เทสต์เคสใหม่",
+ "page.manual-test-cases.new-case-title": "เทสต์เคสไม่มีชื่อ",
+ "page.manual-test-cases.loading": "กำลังโหลดเทสต์เคส...",
+ "page.manual-test-cases.empty": "ไม่มีเทสต์เคสที่ตรงกับตัวกรองนี้",
+ "page.manual-test-cases.no-team": "เลือกทีมจากเมนูด้านบนของหน้าเพื่อดูกรณีทดสอบแบบแมนนวลของทีมนั้น",
+ "page.manual-test-cases.filters.search": "ค้นหาชื่อหรือคำอธิบาย",
+ "page.manual-test-cases.filters.status": "สถานะ",
+ "page.manual-test-cases.filters.priority": "ความสำคัญ",
+ "page.manual-test-cases.table.title": "ชื่อ",
+ "page.manual-test-cases.table.status": "สถานะ",
+ "page.manual-test-cases.table.priority": "ความสำคัญ",
+ "page.manual-test-cases.table.version": "เวอร์ชัน",
+ "page.manual-test-cases.table.updated-by": "อัปเดตโดย",
+ "page.manual-test-cases.table.steps": "ขั้นตอน",
+ "page.manual-test-cases.toast.fetch-error": "ไม่สามารถโหลดเทสต์เคสได้",
+ "page.manual-test-cases.toast.create-error": "ไม่สามารถสร้างเทสต์เคสได้",
+ "page.manual-test-case-detail.loading": "กำลังโหลดเทสต์เคส...",
+ "page.manual-test-case-detail.save": "บันทึก",
+ "page.manual-test-case-detail.clone": "โคลน",
+ "page.manual-test-case-detail.delete": "ลบ",
+ "page.manual-test-case-detail.tab.details": "รายละเอียด",
+ "page.manual-test-case-detail.tab.steps": "ขั้นตอน",
+ "page.manual-test-case-detail.tab.versions": "เวอร์ชัน",
+ "page.manual-test-case-detail.tab.history": "ประวัติ",
+ "page.manual-test-case-detail.field.title": "ชื่อ",
+ "page.manual-test-case-detail.field.description": "คำอธิบาย",
+ "page.manual-test-case-detail.field.preconditions": "เงื่อนไขก่อนทดสอบ",
+ "page.manual-test-case-detail.field.status": "สถานะ",
+ "page.manual-test-case-detail.field.priority": "ความสำคัญ",
+ "page.manual-test-case-detail.field.tags": "แท็ก",
+ "page.manual-test-case-detail.status-help": "ฟิลด์ที่กำหนดเองซึ่งจำเป็นจะบังคับใช้เมื่อเคสออกจากสถานะฉบับร่างแล้วเท่านั้น",
+ "page.manual-test-case-detail.custom-fields": "ฟิลด์ที่กำหนดเอง",
+ "page.manual-test-case-detail.select-version": "เลือกเวอร์ชัน",
+ "page.manual-test-case-detail.version-option": "เวอร์ชัน {version}",
+ "page.manual-test-case-detail.versions-help": "เลือกเวอร์ชันเพื่อดูสิ่งที่ผู้ทดสอบเห็นตอนรัน",
+ "page.manual-test-case-detail.delete-confirm-title": "ลบเทสต์เคส",
+ "page.manual-test-case-detail.delete-confirm-message": "การดำเนินการนี้จะลบเทสต์เคสและประวัติเวอร์ชันทั้งหมด และไม่สามารถย้อนกลับได้",
+ "page.manual-test-case-detail.toast.fetch-error": "ไม่สามารถโหลดเทสต์เคสได้",
+ "page.manual-test-case-detail.toast.save-error": "ไม่สามารถบันทึกเทสต์เคสได้",
+ "page.manual-test-case-detail.toast.clone-error": "ไม่สามารถโคลนเทสต์เคสได้",
+ "page.manual-test-case-detail.toast.delete-error": "ไม่สามารถลบเทสต์เคสได้",
+ "page.manual-test-case-detail.toast.version-error": "ไม่สามารถโหลดเวอร์ชันนั้นได้",
+ "page.manual-test-case-detail.toast.history-error": "ไม่สามารถโหลดประวัติการเปลี่ยนแปลงได้",
+ "app.execution-type.all": "ทั้งหมด",
+ "app.execution-type.automated": "อัตโนมัติ",
+ "app.execution-type.manual": "ด้วยตนเอง",
+ "page.dashboard.filters.label.execution-type": "ประเภท",
+ "page.metrics.filters.labels.execution-type": "ประเภท",
+ "app.manual.save": "บันทึก",
+ "app.manual.delete": "ลบ",
+ "app.manual.edit": "แก้ไข",
+ "app.manual.actions": "การดำเนินการ",
+ "app.manual.loading": "กำลังโหลด...",
+ "app.manual.yes": "ใช่",
+ "app.manual.no": "ไม่",
+ "app.manual.no-team": "เลือกทีมจากเมนูด้านบนของหน้าเพื่อดำเนินการต่อ",
+ "app.manual.field.description": "คำอธิบาย",
+ "app.manual.field.preconditions": "เงื่อนไขเบื้องต้น",
+ "app.manual.field.priority": "ความสำคัญ",
+ "app.manual.field.tags": "แท็ก",
+ "app.manual.field.title": "ชื่อเรื่อง",
+ "app.manual.field.expected": "ผลที่คาดหวัง",
+ "app.manual.field.steps": "ขั้นตอน",
+ "app.manual.attachments.drop-hint": "วางรูปภาพที่นี่ หรือคลิกเพื่อเลือก",
+ "app.manual.attachments.uploading": "กำลังอัปโหลด...",
+ "app.manual.attachments.upload-error": "ไม่สามารถอัปโหลดไฟล์แนบได้",
+ "app.manual.attachments.delete-error": "ไม่สามารถลบไฟล์แนบได้ อาจถูกอ้างอิงโดยเวอร์ชันที่ถูกตรึงไว้",
+ "app.manual.attachments.copy-reference": "คัดลอกการอ้างอิง Markdown",
+ "app.manual.attachments.reference-copied": "คัดลอกการอ้างอิงไปยังคลิปบอร์ดแล้ว",
+ "app.manual.version-diff.identical": "สองเวอร์ชันนี้มีเนื้อหาเหมือนกัน",
+ "app.manual.version-diff.field": "ฟิลด์",
+ "app.manual.version-diff.before": "ก่อน",
+ "app.manual.version-diff.after": "หลัง",
+ "app.manual.version-diff.empty": "(ว่าง)",
+ "app.manual.version-diff.step": "ขั้นตอนที่ {number}",
+ "app.manual.custom-field.type.text": "ข้อความ",
+ "app.manual.custom-field.type.textarea": "ข้อความยาว",
+ "app.manual.custom-field.type.number": "ตัวเลข",
+ "app.manual.custom-field.type.date": "วันที่",
+ "app.manual.custom-field.type.boolean": "ใช่ / ไม่",
+ "app.manual.custom-field.type.select": "เลือกได้หนึ่งรายการ",
+ "app.manual.custom-field.type.multiselect": "เลือกได้หลายรายการ",
+ "app.manual.custom-field.type.user": "ผู้ใช้",
+ "app.manual.custom-field.scope.testcase": "เคสทดสอบ",
+ "app.manual.custom-field.scope.execution": "การดำเนินการทดสอบ",
+ "app.manual.custom-field.scope.both": "ทั้งสอง",
+ "app.manual.run-status.planned": "วางแผนไว้",
+ "app.manual.run-status.in_progress": "กำลังดำเนินการ",
+ "app.manual.run-status.completed": "เสร็จสิ้น",
+ "app.manual.run-status.cancelled": "ยกเลิก",
+ "app.manual.case-status.pass": "ผ่าน",
+ "app.manual.case-status.fail": "ไม่ผ่าน",
+ "app.manual.case-status.error": "ข้อผิดพลาด",
+ "app.manual.case-status.skipped": "ข้าม",
+ "app.manual.case-status.blocked": "ถูกบล็อก",
+ "app.manual.case-status.not_run": "ยังไม่ได้ทดสอบ",
+ "app.manual.case-status.in_progress": "กำลังดำเนินการ",
+ "app.manual.step-status.not_run": "ยังไม่ได้ทดสอบ",
+ "app.manual.step-status.pass": "ผ่าน",
+ "app.manual.step-status.fail": "ไม่ผ่าน",
+ "app.manual.step-status.blocked": "ถูกบล็อก",
+ "app.manual.step-status.skipped": "ข้าม",
+ "nav.manual-testing": "การทดสอบด้วยตนเอง",
+ "nav.manual-test-runs": "รอบการทดสอบ",
+ "nav.shared-steps": "ขั้นตอนที่ใช้ร่วมกัน",
+ "nav.admin.custom-fields": "ฟิลด์ที่กำหนดเอง",
+ "page.admin-custom-fields.title": "ฟิลด์ที่กำหนดเอง",
+ "page.admin-custom-fields.help": "ฟิลด์เพิ่มเติมที่แสดงในทุกเคสทดสอบด้วยตนเองของทีมนี้ ฟิลด์ที่จำเป็นจะบังคับใช้เมื่อเคสออกจากสถานะแบบร่างเท่านั้น",
+ "page.admin-custom-fields.button.add": "เพิ่มฟิลด์",
+ "page.admin-custom-fields.archived": "เก็บถาวรแล้ว",
+ "page.admin-custom-fields.header.label": "ป้ายกำกับ",
+ "page.admin-custom-fields.header.key": "คีย์",
+ "page.admin-custom-fields.header.type": "ประเภท",
+ "page.admin-custom-fields.header.required": "จำเป็น",
+ "page.admin-custom-fields.header.applies-to": "ใช้กับ",
+ "page.admin-custom-fields.modal.add-title": "เพิ่มฟิลด์ที่กำหนดเอง",
+ "page.admin-custom-fields.modal.edit-title": "แก้ไขฟิลด์ที่กำหนดเอง",
+ "page.admin-custom-fields.form.label": "ป้ายกำกับ",
+ "page.admin-custom-fields.form.key": "คีย์",
+ "page.admin-custom-fields.form.key-help": "ตัวพิมพ์เล็ก ตัวเลข และขีดล่าง ไม่สามารถเปลี่ยนได้หลังจากสร้างฟิลด์แล้ว",
+ "page.admin-custom-fields.form.key-invalid": "ต้องขึ้นต้นด้วยตัวพิมพ์เล็ก และมีเฉพาะตัวพิมพ์เล็ก ตัวเลข และขีดล่างเท่านั้น (สูงสุด 40 ตัวอักษร)",
+ "page.admin-custom-fields.form.type": "ประเภท",
+ "page.admin-custom-fields.form.options": "ตัวเลือก",
+ "page.admin-custom-fields.form.applies-to": "ใช้กับ",
+ "page.admin-custom-fields.form.order": "ลำดับการแสดง",
+ "page.admin-custom-fields.form.required": "จำเป็น",
+ "page.admin-custom-fields.form.required-help": "บังคับใช้เมื่อเคสทดสอบออกจากสถานะแบบร่างเท่านั้น",
+ "page.admin-custom-fields.delete-confirm-title": "ลบฟิลด์ที่กำหนดเอง",
+ "page.admin-custom-fields.delete-confirm-message": "หากยังมีเคสทดสอบที่เก็บค่าของฟิลด์นี้ ระบบจะเก็บถาวรแทนการลบ เพื่อให้เนื้อหาเดิมยังอ่านได้",
+ "page.admin-custom-fields.toast.fetch-error": "ไม่สามารถโหลดฟิลด์ที่กำหนดเองได้",
+ "page.admin-custom-fields.toast.save-error": "ไม่สามารถบันทึกฟิลด์ที่กำหนดเองได้",
+ "page.admin-custom-fields.toast.delete-error": "ไม่สามารถลบฟิลด์ที่กำหนดเองได้",
+ "page.admin-custom-fields.toast.archived": "ฟิลด์นี้กำลังใช้งานอยู่ จึงถูกเก็บถาวรแทนการลบ",
+ "page.admin-custom-fields.toast.deleted": "ลบฟิลด์ที่กำหนดเองแล้ว",
+ "page.shared-steps.title": "ขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.help": "ลำดับขั้นตอนที่นำกลับมาใช้ได้ ซึ่งเคสทดสอบหลายรายการสามารถรวมไว้ได้ การแก้ไขจะสร้างเวอร์ชันใหม่ของทุกเคสที่รวมขั้นตอนนี้",
+ "page.shared-steps.button.add": "เพิ่มขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.search-placeholder": "ค้นหาขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.header.name": "ชื่อ",
+ "page.shared-steps.header.description": "คำอธิบาย",
+ "page.shared-steps.header.steps": "ขั้นตอน",
+ "page.shared-steps.header.version": "เวอร์ชัน",
+ "page.shared-steps.action.usage": "การใช้งาน",
+ "page.shared-steps.action.history": "ประวัติ",
+ "page.shared-steps.modal.add-title": "เพิ่มขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.modal.edit-title": "แก้ไขขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.form.name": "ชื่อ",
+ "page.shared-steps.form.description": "คำอธิบาย",
+ "page.shared-steps.cascade-warning": "มี {count} เคสทดสอบที่รวมขั้นตอนนี้ การบันทึกจะสร้างเวอร์ชันใหม่ของแต่ละเคส",
+ "page.shared-steps.usage.title": "เคสทดสอบที่ใช้ขั้นตอนนี้",
+ "page.shared-steps.usage.empty": "ยังไม่มีเคสทดสอบที่รวมขั้นตอนนี้",
+ "page.shared-steps.history.title": "ประวัติขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.delete-confirm-title": "ลบขั้นตอนที่ใช้ร่วมกัน",
+ "page.shared-steps.delete-confirm-message": "ไม่สามารถลบขั้นตอนที่ยังถูกอ้างอิงโดยเคสทดสอบได้",
+ "page.shared-steps.toast.fetch-error": "ไม่สามารถโหลดขั้นตอนที่ใช้ร่วมกันได้",
+ "page.shared-steps.toast.save-error": "ไม่สามารถบันทึกขั้นตอนที่ใช้ร่วมกันได้",
+ "page.shared-steps.toast.delete-error": "ไม่สามารถลบขั้นตอนที่ใช้ร่วมกันได้",
+ "page.shared-steps.toast.usage-error": "ไม่สามารถโหลดรายการการใช้งานได้",
+ "page.shared-steps.toast.history-error": "ไม่สามารถโหลดประวัติได้",
+ "page.shared-steps.toast.cascaded": "สร้างเวอร์ชันใหม่ให้ {count} เคสทดสอบแล้ว",
+ "page.manual-test-runs.title": "รอบการทดสอบด้วยตนเอง",
+ "page.manual-test-runs.button.add": "สร้างรอบทดสอบใหม่",
+ "page.manual-test-runs.button.create": "สร้างรอบทดสอบ",
+ "page.manual-test-runs.filter.status": "สถานะ",
+ "page.manual-test-runs.header.name": "ชื่อ",
+ "page.manual-test-runs.header.status": "สถานะ",
+ "page.manual-test-runs.header.progress": "ความคืบหน้า",
+ "page.manual-test-runs.header.environment": "สภาพแวดล้อม",
+ "page.manual-test-runs.modal.title": "สร้างรอบทดสอบใหม่",
+ "page.manual-test-runs.form.name": "ชื่อ",
+ "page.manual-test-runs.form.environment": "สภาพแวดล้อม",
+ "page.manual-test-runs.form.component": "คอมโพเนนต์",
+ "page.manual-test-runs.form.component-help": "หนึ่งรอบทดสอบครอบคลุมหนึ่งคอมโพเนนต์ ไม่สามารถเพิ่มเคสจากคอมโพเนนต์อื่นได้",
+ "page.manual-test-runs.form.test-cases": "เคสทดสอบ",
+ "page.manual-test-runs.form.test-cases-help": "แต่ละเคสจะผูกกับเวอร์ชันปัจจุบัน การแก้ไขภายหลังจะไม่เปลี่ยนรอบทดสอบนี้",
+ "page.manual-test-runs.delete-confirm-title": "ลบรอบการทดสอบ",
+ "page.manual-test-runs.delete-confirm-message": "การดำเนินการนี้จะลบรอบทดสอบและผลลัพธ์ที่บันทึกไว้",
+ "page.manual-test-runs.toast.fetch-error": "ไม่สามารถโหลดรอบการทดสอบได้",
+ "page.manual-test-runs.toast.cases-error": "ไม่สามารถโหลดเคสทดสอบที่ใช้ได้",
+ "page.manual-test-runs.toast.create-error": "ไม่สามารถสร้างรอบการทดสอบได้",
+ "page.manual-test-runs.toast.delete-error": "ไม่สามารถลบรอบการทดสอบได้",
+ "page.manual-test-run-detail.not-found": "ไม่พบรอบการทดสอบนี้",
+ "page.manual-test-run-detail.button.start": "เริ่มรอบทดสอบ",
+ "page.manual-test-run-detail.button.complete": "จบรอบทดสอบ",
+ "page.manual-test-run-detail.button.record": "บันทึกผล",
+ "page.manual-test-run-detail.button.rebind": "ย้ายไปเวอร์ชันล่าสุด",
+ "page.manual-test-run-detail.case-result": "ผลของเคส",
+ "page.manual-test-run-detail.actual-placeholder": "เกิดอะไรขึ้นจริง?",
+ "page.manual-test-run-detail.notes-placeholder": "หมายเหตุ (ไม่บังคับ)",
+ "page.manual-test-run-detail.view-build": "ดูบิลด์",
+ "page.manual-test-run-detail.view-version": "ดูเวอร์ชันที่กำลังทดสอบ",
+ "page.manual-test-run-detail.toast.fetch-error": "ไม่สามารถโหลดรอบการทดสอบได้",
+ "page.manual-test-run-detail.toast.version-error": "ไม่สามารถโหลดเวอร์ชันที่กำลังทดสอบได้",
+ "page.manual-test-run-detail.toast.record-error": "ไม่สามารถบันทึกผลได้",
+ "page.manual-test-run-detail.toast.recorded": "บันทึกผลแล้ว",
+ "page.manual-test-run-detail.toast.rebind-error": "ไม่สามารถย้ายเคสนี้ไปเวอร์ชันล่าสุด เคสที่มีผลแล้วไม่สามารถย้ายได้",
+ "page.manual-test-run-detail.toast.status-error": "ไม่สามารถเปลี่ยนสถานะรอบทดสอบได้",
+ "page.manual-test-case-version.not-found": "ไม่พบเวอร์ชันนี้",
+ "page.manual-test-case-version.back-to-case": "กลับไปที่เคสทดสอบ",
+ "page.manual-test-case-version.frozen-notice": "นี่คือเวอร์ชันที่ถูกตรึงไว้ แสดงเนื้อหาตามที่เขียนไว้ ณ ขณะนั้น และจะไม่เปลี่ยนแปลง",
+ "page.manual-test-case-version.compare-with": "เปรียบเทียบกับ",
+ "page.manual-test-case-version.toast.fetch-error": "ไม่สามารถโหลดเวอร์ชันนี้ได้",
+ "page.manual-test-case-version.toast.compare-error": "ไม่สามารถโหลดเวอร์ชันสำหรับเปรียบเทียบได้",
+ "page.manual-test-case-detail.toast.saved-new-version": "บันทึกเป็นเวอร์ชัน {version} แล้ว",
+ "page.manual-test-case-detail.toast.saved-no-change": "บันทึกแล้ว เนื้อหาไม่เปลี่ยนแปลง จึงไม่มีการสร้างเวอร์ชันใหม่",
+ "page.shared-steps.toast.saved": "บันทึกขั้นตอนที่ใช้ร่วมกันแล้ว",
+ "page.shared-steps.toast.created": "สร้างขั้นตอนที่ใช้ร่วมกันแล้ว",
+ "page.admin-custom-fields.toast.created": "สร้างฟิลด์ที่กำหนดเองแล้ว",
+ "page.admin-custom-fields.toast.updated": "อัปเดตฟิลด์ที่กำหนดเองแล้ว",
+ "app.manual.folders.title": "โฟลเดอร์",
+ "app.manual.folders.all": "เคสทดสอบทั้งหมด",
+ "app.manual.folders.unfiled": "ไม่อยู่ในโฟลเดอร์",
+ "app.manual.folders.add-root": "เพิ่มโฟลเดอร์ระดับบนสุด",
+ "app.manual.folders.add-sub": "เพิ่มโฟลเดอร์ย่อย",
+ "app.manual.folders.rename": "เปลี่ยนชื่อ",
+ "app.manual.folders.delete": "ลบ",
+ "app.manual.folders.name": "ชื่อโฟลเดอร์",
+ "app.manual.folders.create-title": "โฟลเดอร์ใหม่",
+ "app.manual.folders.rename-title": "เปลี่ยนชื่อโฟลเดอร์",
+ "app.manual.folders.creating-under": "สร้างภายใน \"{name}\"",
+ "app.manual.folders.delete-confirm-title": "ลบโฟลเดอร์",
+ "app.manual.folders.delete-confirm-message": "สามารถลบโฟลเดอร์ได้เมื่อว่างเปล่าเท่านั้น กรุณาย้ายหรือลบสิ่งที่อยู่ภายในก่อน",
+ "page.manual-test-cases.toast.folders-error": "ไม่สามารถโหลดโฟลเดอร์ได้",
+ "page.manual-test-cases.toast.folder-create-error": "ไม่สามารถสร้างโฟลเดอร์ได้",
+ "page.manual-test-cases.toast.folder-rename-error": "ไม่สามารถเปลี่ยนชื่อโฟลเดอร์ได้",
+ "page.manual-test-cases.toast.folder-delete-error": "ไม่สามารถลบโฟลเดอร์ได้",
+ "page.manual-test-case-detail.field.folder": "โฟลเดอร์",
+ "page.manual-test-case-detail.folder-root": "ไม่อยู่ในโฟลเดอร์",
+ "app.manual.history.action.move": "ย้ายไปยังโฟลเดอร์",
+ "app.manual.step-editor.action": "การกระทำ",
+ "app.manual.step-editor.attachments": "ไฟล์แนบ",
+ "app.manual.step-editor.attachments-need-save": "กรุณาบันทึกเคสทดสอบก่อน จากนั้นจึงแนบรูปภาพกับขั้นตอนได้",
+ "app.manual.step-editor.drag-handle": "ลากเพื่อจัดลำดับใหม่",
+ "app.manual.step-editor.drag-help": "ลากขั้นตอนเพื่อเปลี่ยนลำดับ",
+ "app.manual.attachments.load-error": "ไม่สามารถโหลดรูปภาพได้",
+ "app.manual.folders.unknown": "โฟลเดอร์อื่น",
+ "page.manual-test-cases.toast.move-success": "ย้ายเทสต์เคสแล้ว",
+ "page.manual-test-cases.toast.move-error": "ไม่สามารถย้ายเทสต์เคสได้",
+ "page.admin.settings.features.header": "การจัดการฟีเจอร์",
+ "page.admin.settings.features.help": "เปิดหรือปิดฟีเจอร์เสริมสำหรับอินสแตนซ์นี้ การเปลี่ยนแปลงจะมีผลทันที — ไม่ต้องรีสตาร์ท",
+ "page.admin.settings.features.manual-testing": "เปิดการจัดการเทสต์เคส",
+ "page.admin.settings.features.manual-testing.help": "เทสต์เคสแบบแมนนวล รอบการทดสอบ และขั้นตอนที่ใช้ร่วมกัน เมื่อปิด หน้าเหล่านี้จะถูกซ่อนและไม่สามารถเข้าถึงข้อมูลได้ แต่จะไม่มีการลบข้อมูล — การเปิดอีกครั้งจะคืนค่าทั้งหมด",
+ "app.feature.disabled": "ฟีเจอร์นี้ไม่ได้เปิดใช้งานบนอินสแตนซ์นี้ หากต้องการเข้าถึง โปรดติดต่อผู้ดูแลระบบ",
+ "nav.team": "ทีม",
+ "nav.team.none": "ยังไม่ได้เลือก"
}
diff --git a/src/utility/ChartConfig.js b/src/utility/ChartConfig.js
index a37cff0..3d3db99 100644
--- a/src/utility/ChartConfig.js
+++ b/src/utility/ChartConfig.js
@@ -14,19 +14,21 @@ export const STATUS_COLORS = [
'var(--skipped-color)',
];
-// Categorical palette for non-status series (e.g. platform distribution). Vivid
-// mid-tones chosen to read on both light and dark backgrounds.
+// Categorical palette for non-status series (e.g. platform distribution).
+// Like STATUS_COLORS these are design tokens rather than literals, so each hue
+// gets a dark-mode variant (see `--chart-series-*` in tokens/_color.less) and
+// the charts follow the theme instead of holding one fixed ramp.
export const CATEGORICAL_PALETTE = [
- '#6366f1', // indigo
- '#14b8a6', // teal
- '#f59e0b', // amber
- '#a855f7', // violet
- '#f43f5e', // rose
- '#06b6d4', // cyan
- '#84cc16', // lime
- '#3b82f6', // blue
- '#ec4899', // pink
- '#f97316', // orange
+ 'var(--chart-series-1)', // indigo
+ 'var(--chart-series-2)', // teal
+ 'var(--chart-series-3)', // amber
+ 'var(--chart-series-4)', // violet
+ 'var(--chart-series-5)', // rose
+ 'var(--chart-series-6)', // cyan
+ 'var(--chart-series-7)', // lime
+ 'var(--chart-series-8)', // blue
+ 'var(--chart-series-9)', // pink
+ 'var(--chart-series-10)', // orange
];
// Deterministic colour for the Nth distinct category (by first-seen order).
diff --git a/src/utility/GeneralUtilities.js b/src/utility/GeneralUtilities.js
index a839c1e..687f0d0 100644
--- a/src/utility/GeneralUtilities.js
+++ b/src/utility/GeneralUtilities.js
@@ -1,6 +1,23 @@
import { useState } from 'react';
+import { ExecutionTypes } from 'angles-javascript-client';
+
+// The execution-type pickers need a concrete value for "no filter", but the API takes the
+// absence of the parameter to mean both. This sentinel is mapped back to undefined before
+// the request is made, so omitting the filter keeps the pre-3.0 behaviour.
+export const ALL_EXECUTION_TYPES = 'all';
+
+export const toExecutionTypeParam = (value) => (
+ value === ALL_EXECUTION_TYPES ? undefined : value
+);
+
+// Options for an execution-type SelectPicker. Takes intl rather than importing it so the
+// caller's provider is used.
+export const getExecutionTypeOptions = (intl) => ([
+ { label: intl.formatMessage({ id: 'app.execution-type.all' }), value: ALL_EXECUTION_TYPES },
+ { label: intl.formatMessage({ id: 'app.execution-type.automated' }), value: ExecutionTypes.AUTOMATED },
+ { label: intl.formatMessage({ id: 'app.execution-type.manual' }), value: ExecutionTypes.MANUAL },
+]);
-// eslint-disable-next-line import/prefer-default-export
export const useConstructor = (callBack = () => {}) => {
const [hasBeenCalled, setHasBeenCalled] = useState(false);
if (hasBeenCalled) return;
diff --git a/src/utility/Themes.js b/src/utility/Themes.js
new file mode 100644
index 0000000..0917372
--- /dev/null
+++ b/src/utility/Themes.js
@@ -0,0 +1,63 @@
+// ============================================================================
+// Theme registry
+// ----------------------------------------------------------------------------
+// The single source of truth for which themes exist. The picker renders from
+// this list and the cookie is validated against it, so adding a theme is:
+//
+// 1. Add its primitives block + registry selector in
+// `src/styles/tokens/_color.less` (see `_theme-contract.less` for the
+// contract a theme must satisfy).
+// 2. Add an entry here.
+// 3. Add its `labelId` string to every file in `src/translations/`.
+//
+// `id` is what goes in the `data-theme` attribute and the `theme` cookie, so it
+// must match the selector in `_color.less` exactly.
+//
+// `polarity` groups the picker into Light / Dark sections. It is presentation
+// only — the CSS derives its own polarity from `--theme-is-dark`, so the two
+// cannot drift into disagreeing about how anything is painted.
+// ============================================================================
+
+// Order within a polarity is the order the picker lists them, so the two
+// original Ember themes stay first.
+export const THEMES = [
+ { id: 'light', polarity: 'light', labelId: 'nav.theme.light' },
+ { id: 'slate-light', polarity: 'light', labelId: 'nav.theme.slate-light' },
+ { id: 'sepia', polarity: 'light', labelId: 'nav.theme.sepia' },
+ { id: 'forest-light', polarity: 'light', labelId: 'nav.theme.forest-light' },
+ { id: 'contrast-light', polarity: 'light', labelId: 'nav.theme.contrast-light' },
+ { id: 'clay-light', polarity: 'light', labelId: 'nav.theme.clay-light' },
+ { id: 'dark', polarity: 'dark', labelId: 'nav.theme.dark' },
+ { id: 'slate-dark', polarity: 'dark', labelId: 'nav.theme.slate-dark' },
+ { id: 'midnight', polarity: 'dark', labelId: 'nav.theme.midnight' },
+ { id: 'forest-dark', polarity: 'dark', labelId: 'nav.theme.forest-dark' },
+ { id: 'contrast-dark', polarity: 'dark', labelId: 'nav.theme.contrast-dark' },
+ { id: 'clay-dark', polarity: 'dark', labelId: 'nav.theme.clay-dark' },
+];
+
+// The theme applied when the user has made no choice. Leaving the cookie unset
+// (rather than defaulting to a value) is deliberate: with no `data-theme`
+// attribute the CSS follows the OS via `prefers-color-scheme`. Writing a
+// default would pin every new user to light and ignore their system setting.
+export const DEFAULT_THEME_ID = null;
+
+const THEME_IDS = new Set(THEMES.map((theme) => theme.id));
+
+// Guards against a stale or hand-edited cookie. An unknown id would otherwise
+// be written to `data-theme`, matching no selector — the page would render with
+// no theme's primitives at all rather than falling back cleanly.
+export const isValidThemeId = (id) => THEME_IDS.has(id);
+
+export const getThemesByPolarity = (polarity) => THEMES
+ .filter((theme) => theme.polarity === polarity);
+
+// Applies a theme to the document. Passing null (or an unknown id) removes the
+// attribute, handing control back to the OS preference.
+export const applyTheme = (id) => {
+ const root = document.documentElement;
+ if (isValidThemeId(id)) {
+ root.setAttribute('data-theme', id);
+ } else {
+ root.removeAttribute('data-theme');
+ }
+};