From f1537587bd2b99df5db183457c0d58daa8014cbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:02:16 +0000 Subject: [PATCH 1/8] Initial plan From ce18ca034143cbcc076c5aa67818f1fcf2a78d5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:10:06 +0000 Subject: [PATCH 2/8] feat: CI/CD pipeline evolution - sharding, CodeQL, budgets, and a11y Agent-Logs-Url: https://github.com/fderuiter/Clinical-Randomization-Generator/sessions/c681cf11-2fe2-4641-ac1b-86c6312c6774 Co-authored-by: fderuiter <127706008+fderuiter@users.noreply.github.com> --- .github/workflows/ci.yml | 105 +++++++++++++++++- .github/workflows/codeql.yml | 41 +++++++ angular.json | 4 +- package-lock.json | 24 ++++ package.json | 1 + playwright.config.ts | 2 +- .../core/randomization-algorithm.ts | 2 +- .../randomization-engine.facade.ts | 4 +- src/environments/version.ts | 2 +- tests_e2e/a11y.spec.ts | 44 ++++++++ tests_e2e/a11y.ts | 38 +++++++ 11 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 tests_e2e/a11y.spec.ts create mode 100644 tests_e2e/a11y.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4735c7f..6164c1d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,22 @@ on: - main pull_request: +permissions: + contents: read + jobs: build_and_test: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' - name: Install Dependencies run: npm ci @@ -28,8 +32,99 @@ jobs: - name: Run Unit Tests run: npm test -- --watch=false + e2e: + runs-on: ubuntu-latest + needs: build_and_test + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + - name: Install Playwright Browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps chromium + + - name: Run E2E Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + run: npm run start & npx wait-on http://localhost:4200 && npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + CI: true + + - name: Upload blob report + uses: actions/upload-artifact@v4 + if: always() + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report/ + retention-days: 1 + + merge_reports: + runs-on: ubuntu-latest + needs: e2e + if: always() + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Download all blob reports + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge Playwright reports + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload merged HTML report + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + security_scan: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Run E2E Tests - run: npm run start & npx wait-on http://localhost:4200 && npx playwright test + - name: Check for insecure Math.random() in randomization domain + run: | + echo "Scanning for Math.random() usage in randomization-engine..." + if grep -rn "Math\.random()" src/app/domain/randomization-engine/; then + echo "ERROR: Math.random() detected in randomization-engine domain." + echo "Use seedrandom or the Web Crypto API (crypto.getRandomValues) instead." + exit 1 + fi + echo "No insecure Math.random() found in randomization domain." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..8f55e44c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL Security Analysis + +on: + push: + branches: + - main + pull_request: + +permissions: + security-events: write + contents: read + actions: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/angular.json b/angular.json index 9aa752cb..ab5b8e0c 100644 --- a/angular.json +++ b/angular.json @@ -49,8 +49,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1.5MB", - "maximumError": "2.5MB" + "maximumWarning": "1.85MB", + "maximumError": "1.93MB" }, { "type": "anyComponentStyle", diff --git a/package-lock.json b/package-lock.json index 88068159..a376c5d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@angular/cli": "21.2.7", "@angular/compiler-cli": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7", + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.58.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", @@ -965,6 +966,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", + "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -6726,6 +6740,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", diff --git a/package.json b/package.json index 52777a37..97d83bb2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@angular/cli": "21.2.7", "@angular/compiler-cli": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7", + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.58.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index 5ce0d4b7..92272690 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: 'html', + reporter: process.env.CI ? [['blob'], ['line']] : 'html', use: { trace: 'on-first-retry', }, diff --git a/src/app/domain/randomization-engine/core/randomization-algorithm.ts b/src/app/domain/randomization-engine/core/randomization-algorithm.ts index 757f49a4..3b91c217 100644 --- a/src/app/domain/randomization-engine/core/randomization-algorithm.ts +++ b/src/app/domain/randomization-engine/core/randomization-algorithm.ts @@ -345,7 +345,7 @@ function generateMarginalOnly( export function generateRandomizationSchema(config: RandomizationConfig): RandomizationResult { const resolvedConfig = config.seed ? config - : { ...config, seed: Math.random().toString(36).substring(2, 15) }; + : { ...config, seed: Array.from(crypto.getRandomValues(new Uint32Array(4))).map(n => n.toString(16).padStart(8, '0')).join('') }; const rng = seedrandom(resolvedConfig.seed); diff --git a/src/app/domain/randomization-engine/randomization-engine.facade.ts b/src/app/domain/randomization-engine/randomization-engine.facade.ts index d7ee4e8b..11ea031c 100644 --- a/src/app/domain/randomization-engine/randomization-engine.facade.ts +++ b/src/app/domain/randomization-engine/randomization-engine.facade.ts @@ -134,7 +134,7 @@ export class RandomizationEngineFacade { return; } - const id = Math.random().toString(36).substring(2); + const id = crypto.randomUUID(); this.pendingMonteCarloCallbacks.set(id, { onProgress: (p: MonteCarloProgressPayload) => { @@ -224,7 +224,7 @@ export class RandomizationEngineFacade { } private dispatchToWorker(config: RandomizationConfig): void { - const id = Math.random().toString(36).substring(2); + const id = crypto.randomUUID(); this.pendingCallbacks.set(id, { resolve: async result => { diff --git a/src/environments/version.ts b/src/environments/version.ts index 66d5eb53..fbe0ce49 100644 --- a/src/environments/version.ts +++ b/src/environments/version.ts @@ -1,2 +1,2 @@ // This file is auto-generated by generate-version.js -export const APP_VERSION = 'v1.19.0'; +export const APP_VERSION = 'v1.25.0'; diff --git a/tests_e2e/a11y.spec.ts b/tests_e2e/a11y.spec.ts new file mode 100644 index 00000000..afc0b4ca --- /dev/null +++ b/tests_e2e/a11y.spec.ts @@ -0,0 +1,44 @@ +import { test } from '@playwright/test'; +import { checkA11y } from './a11y'; + +test.describe('Accessibility (WCAG 2.1 AA)', () => { + test.beforeEach(async ({ page }) => { + page.on('pageerror', err => console.log(`Page Error: ${err.message}`)); + }); + + test('Landing page should have no critical/serious accessibility violations', async ({ page }) => { + await page.goto('http://localhost:4200'); + await page.waitForLoadState('networkidle'); + await checkA11y(page); + }); + + test('About page should have no critical/serious accessibility violations', async ({ page }) => { + await page.goto('http://localhost:4200/about'); + await page.waitForLoadState('networkidle'); + await checkA11y(page); + }); + + test('Generator page (configuration wizard) should have no critical/serious accessibility violations', async ({ page }) => { + await page.goto('http://localhost:4200/generator'); + await page.waitForLoadState('networkidle'); + await checkA11y(page); + }); + + test('Results grid should have no critical/serious accessibility violations after schema generation', async ({ page }) => { + await page.goto('http://localhost:4200/generator'); + await page.waitForLoadState('networkidle'); + + // Load a preset and generate a schema to render the results grid + const complexPresetBtn = page.getByRole('button', { name: /Complex \(Multi-strata\)/i }); + await complexPresetBtn.click(); + + const generateSchemaBtn = page.getByRole('button', { name: /Generate Schema/i }); + await generateSchemaBtn.click(); + + // Wait for results grid to be visible + const resultsSection = page.locator('#results-section'); + await resultsSection.waitFor({ state: 'visible', timeout: 15000 }); + + await checkA11y(page); + }); +}); diff --git a/tests_e2e/a11y.ts b/tests_e2e/a11y.ts new file mode 100644 index 00000000..41166b3d --- /dev/null +++ b/tests_e2e/a11y.ts @@ -0,0 +1,38 @@ +import AxeBuilder from '@axe-core/playwright'; +import { Page } from '@playwright/test'; + +/** + * Runs an axe-core accessibility audit against the current page state. + * Enforces WCAG 2.1 AA standards and fails if any "critical" or "serious" + * violations are found. + * + * @param page - The Playwright Page object to audit. + * @returns The axe accessibility scan results. + * @throws If critical or serious WCAG 2.1 AA violations are detected. + */ +export async function checkA11y(page: Page) { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + + const violations = results.violations.filter( + v => v.impact === 'critical' || v.impact === 'serious' + ); + + if (violations.length > 0) { + const details = violations + .map(v => { + const nodes = v.nodes + .map(n => ` Element: ${n.target.join(', ')}\n HTML: ${n.html}`) + .join('\n'); + return `[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n WCAG: ${v.tags.filter(t => t.startsWith('wcag')).join(', ')}\n${nodes}`; + }) + .join('\n\n'); + + throw new Error( + `Accessibility violations found (${violations.length}):\n\n${details}` + ); + } + + return results; +} From 81ce2fcc2ef49935dfeed2646d84c6d0120205c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:14:28 +0000 Subject: [PATCH 3/8] fix: remove codeql.yml to resolve conflict with repo's default CodeQL setup Agent-Logs-Url: https://github.com/fderuiter/Clinical-Randomization-Generator/sessions/ac715a75-46d5-4cba-8bbd-16ce61f122e8 Co-authored-by: fderuiter <127706008+fderuiter@users.noreply.github.com> --- .github/workflows/codeql.yml | 41 ------------------------------------ 1 file changed, 41 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 8f55e44c..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CodeQL Security Analysis - -on: - push: - branches: - - main - pull_request: - -permissions: - security-events: write - contents: read - actions: read - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: - - javascript-typescript - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - queries: security-and-quality - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{ matrix.language }}" From 19faffbd8a0e09d5695208a74f3323ee076768f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:21:46 +0000 Subject: [PATCH 4/8] fix: apply review feedback - deterministic a11y waits, curl server readiness, shared generateCryptoSeed Agent-Logs-Url: https://github.com/fderuiter/Clinical-Randomization-Generator/sessions/1f3c1502-f453-400b-85c7-dc828abff038 Co-authored-by: fderuiter <127706008+fderuiter@users.noreply.github.com> --- .github/workflows/ci.yml | 14 +++++++++++++- .../core/randomization-algorithm.ts | 12 +++++++++++- .../worker/randomization-engine.worker.ts | 8 +------- tests_e2e/a11y.spec.ts | 15 +++++++++------ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6164c1d6..5a5798a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,19 @@ jobs: run: npx playwright install --with-deps chromium - name: Run E2E Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - run: npm run start & npx wait-on http://localhost:4200 && npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: | + npm run start & + for i in {1..60}; do + if curl --silent --fail http://localhost:4200 > /dev/null; then + break + fi + if [ "$i" -eq 60 ]; then + echo "Timed out waiting for http://localhost:4200" + exit 1 + fi + sleep 2 + done + npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: CI: true diff --git a/src/app/domain/randomization-engine/core/randomization-algorithm.ts b/src/app/domain/randomization-engine/core/randomization-algorithm.ts index 3b91c217..af9d6b83 100644 --- a/src/app/domain/randomization-engine/core/randomization-algorithm.ts +++ b/src/app/domain/randomization-engine/core/randomization-algorithm.ts @@ -9,6 +9,16 @@ import { import { generateSubjectId } from './subject-id-engine'; import { generateMinimization } from './minimization-algorithm'; +// --------------------------------------------------------------------------- +// Crypto seed helper (shared with the Web Worker) +// --------------------------------------------------------------------------- + +export function generateCryptoSeed(): string { + const array = new Uint32Array(4); + crypto.getRandomValues(array); + return Array.from(array, n => n.toString(16).padStart(8, '0')).join(''); +} + // --------------------------------------------------------------------------- // Shared block-generation helpers // --------------------------------------------------------------------------- @@ -345,7 +355,7 @@ function generateMarginalOnly( export function generateRandomizationSchema(config: RandomizationConfig): RandomizationResult { const resolvedConfig = config.seed ? config - : { ...config, seed: Array.from(crypto.getRandomValues(new Uint32Array(4))).map(n => n.toString(16).padStart(8, '0')).join('') }; + : { ...config, seed: generateCryptoSeed() }; const rng = seedrandom(resolvedConfig.seed); diff --git a/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts b/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts index ceb62618..02947e66 100644 --- a/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts +++ b/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts @@ -1,6 +1,6 @@ /// -import { generateRandomizationSchema } from '../core/randomization-algorithm'; +import { generateRandomizationSchema, generateCryptoSeed } from '../core/randomization-algorithm'; import type { GenerationCommand, MonteCarloProgressPayload, @@ -38,12 +38,6 @@ addEventListener('message', (event: MessageEvent) => { } }); -function generateCryptoSeed(): string { - const array = new Uint32Array(4); - crypto.getRandomValues(array); - return Array.from(array, n => n.toString(16).padStart(8, '0')).join(''); -} - function runMonteCarlo(id: string, config: RandomizationConfig): void { const TOTAL_ITERATIONS = 10_000; const PROGRESS_INTERVAL = 500; diff --git a/tests_e2e/a11y.spec.ts b/tests_e2e/a11y.spec.ts index afc0b4ca..802dbb2a 100644 --- a/tests_e2e/a11y.spec.ts +++ b/tests_e2e/a11y.spec.ts @@ -1,4 +1,4 @@ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { checkA11y } from './a11y'; test.describe('Accessibility (WCAG 2.1 AA)', () => { @@ -8,28 +8,31 @@ test.describe('Accessibility (WCAG 2.1 AA)', () => { test('Landing page should have no critical/serious accessibility violations', async ({ page }) => { await page.goto('http://localhost:4200'); - await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /Equipose/i })).toBeVisible(); await checkA11y(page); }); test('About page should have no critical/serious accessibility violations', async ({ page }) => { await page.goto('http://localhost:4200/about'); - await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /About Equipose/i })).toBeVisible(); await checkA11y(page); }); test('Generator page (configuration wizard) should have no critical/serious accessibility violations', async ({ page }) => { await page.goto('http://localhost:4200/generator'); - await page.waitForLoadState('networkidle'); + const complexPresetBtn = page.getByRole('button', { name: /Complex \(Multi-strata\)/i }); + await complexPresetBtn.waitFor({ state: 'visible' }); await checkA11y(page); }); test('Results grid should have no critical/serious accessibility violations after schema generation', async ({ page }) => { await page.goto('http://localhost:4200/generator'); - await page.waitForLoadState('networkidle'); - // Load a preset and generate a schema to render the results grid + // Wait for the generator UI to be ready before interacting with it const complexPresetBtn = page.getByRole('button', { name: /Complex \(Multi-strata\)/i }); + await complexPresetBtn.waitFor({ state: 'visible' }); + + // Load a preset and generate a schema to render the results grid await complexPresetBtn.click(); const generateSchemaBtn = page.getByRole('button', { name: /Generate Schema/i }); From b84f9fc0cf0ba0b3b44909431e1620fa0b84d547 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:48:16 +0000 Subject: [PATCH 5/8] Changes before error encountered Agent-Logs-Url: https://github.com/fderuiter/Clinical-Randomization-Generator/sessions/5f8c3c41-e32c-45a0-a11e-2408ef7751b3 Co-authored-by: fderuiter <127706008+fderuiter@users.noreply.github.com> --- .../study-builder/components/config-form.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/domain/study-builder/components/config-form.component.html b/src/app/domain/study-builder/components/config-form.component.html index 12b00139..0bdb7f4a 100644 --- a/src/app/domain/study-builder/components/config-form.component.html +++ b/src/app/domain/study-builder/components/config-form.component.html @@ -168,8 +168,8 @@

Stratificati -
-
Site
+
Site
{{row.site}}
@for (stratum of data.metadata.strata; track stratum.id) { -
{{stratum.name || stratum.id}}
+
{{stratum.name || stratum.id}}
{{row.stratum[stratum.id]}}
} -
Block
-
{{row.blockNumber}} (Size: {{row.blockSize}})
-
Treatment Arm
+
Block
+
{{row.blockNumber}} (Size: {{row.blockSize}})
+
Treatment Arm
@if (isUnblinded()) { {{row.treatmentArm}} } @else { - *** BLINDED *** + *** BLINDED *** }
@@ -307,18 +307,18 @@

Generated Schema @for (chunk of splitSubjectId(row.subjectId); track $index) { @if ($index > 0) { - - + - } {{chunk}} } {{row.site}} @for (stratum of data.metadata.strata; track stratum.id) { - {{row.stratum[stratum.id]}} + {{row.stratum[stratum.id]}} } {{row.blockNumber}} - (Size: {{row.blockSize}}) + (Size: {{row.blockSize}}) @if (isUnblinded()) { @@ -326,7 +326,7 @@

Generated Schema {{row.treatmentArm}} } @else { - + *** BLINDED *** } @@ -336,7 +336,7 @@

Generated Schema type="button" [cdkMenuTriggerFor]="rowMenu" (click)="openRowMenu(row)" - class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-400 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" + class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" [attr.aria-label]="'Actions for subject ' + row.subjectId" >

Generated Schema @for (chunk of splitSubjectId($any(item).data.subjectId); track $index) { @if ($index > 0) { - - + - } {{chunk}} } {{$any(item).data.site}} @for (stratum of data.metadata.strata; track stratum.id) { - {{$any(item).data.stratum[stratum.id]}} + {{$any(item).data.stratum[stratum.id]}} } {{$any(item).data.blockNumber}} - (Size: {{$any(item).data.blockSize}}) + (Size: {{$any(item).data.blockSize}}) @if (isUnblinded()) { @@ -426,7 +426,7 @@

Generated Schema {{$any(item).data.treatmentArm}} } @else { - + *** BLINDED *** } @@ -436,7 +436,7 @@

Generated Schema type="button" [cdkMenuTriggerFor]="rowMenu" (click)="openRowMenu($any(item).data)" - class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-400 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" + class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" [attr.aria-label]="'Actions for subject ' + $any(item).data.subjectId" >

Generated Schema
-

+

Filter: {{activeFilterColumn()}}

-

+

Click a chart segment or bar to cross-filter the results grid below.

diff --git a/src/app/domain/study-builder/components/block-preview.component.ts b/src/app/domain/study-builder/components/block-preview.component.ts index 2398860f..1e600914 100644 --- a/src/app/domain/study-builder/components/block-preview.component.ts +++ b/src/app/domain/study-builder/components/block-preview.component.ts @@ -140,7 +140,7 @@ export function buildPreviews(arms: ArmInput[], blockSizes: number[]): BlockPrev {{ arm.name || arm.id }} } - + Unallocatable @@ -156,7 +156,7 @@ export function buildPreviews(arms: ArmInput[], blockSizes: number[]): BlockPrev
} -

Enter block sizes above to see a preview.

+

Enter block sizes above to see a preview.

} @else { @for (preview of previews(); track preview.blockSize) { diff --git a/src/app/domain/study-builder/components/config-form.component.html b/src/app/domain/study-builder/components/config-form.component.html index 0bdb7f4a..f628317b 100644 --- a/src/app/domain/study-builder/components/config-form.component.html +++ b/src/app/domain/study-builder/components/config-form.component.html @@ -70,7 +70,7 @@

Treatment Ar type="button" (click)="removeArm($index)" [disabled]="arms.length <= 2" - class="absolute top-3 right-3 text-gray-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-rose-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" + class="absolute top-3 right-3 text-gray-600 dark:text-slate-500 hover:text-red-500 dark:hover:text-rose-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Remove arm" > @@ -80,7 +80,7 @@

Treatment Ar
- + Treatment Ar
- +
- of {{ totalRatio }} total + of {{ totalRatio }} total
@@ -121,7 +121,7 @@

Treatment Ar

Stratification Factors

-

Drag cards to reorder - the Cartesian product depends on factor order.

+

Drag cards to reorder - the Cartesian product depends on factor order.

@@ -338,7 +338,7 @@

Sizes are applied in order and cycle when exhausted. PRNG is bypassed for size selection.

+

Sizes are applied in order and cycle when exhausted. PRNG is bypassed for size selection.

} @@ -356,7 +356,7 @@

No overrides defined. All sites and strata use the global block sizes above.

+

No overrides defined. All sites and strata use the global block sizes above.

}
@@ -368,7 +368,7 @@

@@ -380,7 +380,7 @@

- + Block Sizes (comma-separated) + Selection Mode +
} @if (isFactorPercentageInvalid(factor.id)) { @@ -620,7 +620,7 @@

-

+

Set hard limits per factor level. The engine randomly allocates subjects across intersections until each level's cap is reached.

@for (factor of strataWithLevels; track factor.id) { @@ -639,7 +639,7 @@

- subjects + subjects

}

@@ -695,7 +695,7 @@

Random Seed (Optional)