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 }) => ( +