Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d669ece
feat(manual): add manual test case authoring screens
snevesbarros Aug 23, 2026
ee2c2d2
feat(manual): add the automated/manual toggle to the dashboard and me…
Aug 23, 2026
3ba12ed
feat(manual): add the remaining manual test management screens
Aug 23, 2026
45da6bc
fix(manual): import ALL_EXECUTION_TYPES into the dashboard and metric…
Aug 23, 2026
15b1e69
fix(manual): do not send an empty action for a shared step inclusion
Aug 23, 2026
bc0206d
feat(manual): show a clear result when a test case is saved
Aug 23, 2026
b6d62d2
feat(manual): organise test cases in a folder tree
Aug 23, 2026
9f70c1d
chore: consume the published angles-javascript-client 3.1.1
Aug 23, 2026
5896d2d
feat(manual): rework the step editor layout, attachments and reordering
Aug 23, 2026
8adccaa
fix(manual): save step attachments as ids and load thumbnails with cr…
Aug 23, 2026
49684a5
feat(manual): group test cases into folder suites with drag-and-drop …
snevesbarros Aug 23, 2026
a700b96
chore(deps): upgrade angles-javascript-client to 3.1.2
snevesbarros Aug 23, 2026
0b15e0e
fix(ui): make panel actions icon buttons and correct dark-mode chrome
snevesbarros Aug 31, 2026
d3cf8e0
feat(settings): gate manual testing behind an admin feature toggle
snevesbarros Sep 6, 2026
4ae4602
fix(theme): bridge rsuite's variable API to the design tokens
snevesbarros Sep 6, 2026
5ec7619
refactor(theme): formalise the primitive contract for themes
snevesbarros Sep 6, 2026
1764aab
refactor(theme): key themes on data-theme and fix the fallback guard
snevesbarros Sep 6, 2026
6c6738d
feat(theme): drive the theme picker from a registry
snevesbarros Sep 6, 2026
50ea51b
feat(theme): add Slate Light, Sepia, Slate Dark and Midnight
snevesbarros Sep 6, 2026
6f39c7d
feat(theme): add Forest and High Contrast theme pairs
snevesbarros Sep 6, 2026
7ca4256
fix(i18n): complete the Hindi catalogue and enforce translation parity
snevesbarros Sep 6, 2026
0f18774
feat(theme): widen the light range and add colour-blind-safe themes
snevesbarros Sep 6, 2026
eeac9b5
feat(brand): draw the logo as themed inline SVG
snevesbarros Sep 6, 2026
cb3d813
feat(brand): set the wordmark in Oswald with the original outline
snevesbarros Sep 6, 2026
67b12f0
feat(brand): set the wordmark in Saira Condensed and centre it on the…
snevesbarros Sep 6, 2026
532484c
style(brand): match the wordmark height to the cube
snevesbarros Sep 6, 2026
58cf7ce
style(brand): thicken the wordmark outline to 2 units
snevesbarros Sep 6, 2026
e796e23
style(brand): increase the wordmark outline to 2.6 units
snevesbarros Sep 6, 2026
84be27c
fix(brand): stop the viewBox clipping the wordmark outline; +10% size
snevesbarros Sep 6, 2026
7adb6e9
fix(nav): stop the cube shifting when the sidebar collapses
snevesbarros Sep 6, 2026
327f3d9
feat(nav): add a global team picker to the top menu
snevesbarros Sep 6, 2026
ef8d699
refactor(teams): drop the duplicated in-page team pickers
snevesbarros Sep 6, 2026
bafa8cf
fix(nav): hide the team picker when signed out and label the menu
snevesbarros Sep 6, 2026
31eff09
style(nav): separate the team label from its value with a colon
snevesbarros Sep 6, 2026
b696dde
feat(theme): preview each theme's colours in the picker
snevesbarros Sep 6, 2026
29bd741
fix(team-settings): stop the page picker from switching the global team
snevesbarros Sep 6, 2026
143d25d
style(manual): make a folder's cases read as its contents
snevesbarros Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
160 changes: 160 additions & 0 deletions scripts/check-translations.js
Original file line number Diff line number Diff line change
@@ -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.');
8 changes: 8 additions & 0 deletions src/app/admin/custom-fields/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import React from 'react';
import AdminCustomFieldsPage from '../../../components/pages/admin-custom-fields/AdminCustomFieldsPage';

export default function Page() {
return <AdminCustomFieldsPage />;
}
15 changes: 14 additions & 1 deletion src/app/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -17,7 +30,7 @@ export const dynamic = 'force-dynamic';

export default function RootLayout({ children }) {
return (
<html lang="en">
<html lang="en" className={brandFont.variable}>
<head>
<RuntimeConfigScript />
</head>
Expand Down
10 changes: 10 additions & 0 deletions src/app/manual-test-cases/[caseId]/page.js
Original file line number Diff line number Diff line change
@@ -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 <ManualTestCaseDetailPage caseId={params.caseId} />;
}
10 changes: 10 additions & 0 deletions src/app/manual-test-cases/[caseId]/version/[version]/page.js
Original file line number Diff line number Diff line change
@@ -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 <ManualTestCaseVersionPage caseId={params.caseId} version={params.version} />;
}
10 changes: 10 additions & 0 deletions src/app/manual-test-cases/layout.js
Original file line number Diff line number Diff line change
@@ -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 <ManualTestingGuard>{children}</ManualTestingGuard>;
}
8 changes: 8 additions & 0 deletions src/app/manual-test-cases/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import React from 'react';
import ManualTestCasesPage from '../../components/pages/manual-test-cases/ManualTestCasesPage';

export default function Page() {
return <ManualTestCasesPage />;
}
Loading