Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,20 @@ When introducing shortcuts, known issues, or deferred security work, file an iss

### CSS & Styling

- **All UI must work in both light and dark mode automatically.** Never use hard-coded colours (e.g., `text-gray-700`, `bg-white`, `border-slate-200`). Always use DaisyUI semantic colour classes (`text-base-content`, `bg-base-100`, `bg-base-200`, `border-base-300`, `text-primary`, `bg-error`, etc.) and Tailwind opacity modifiers on those classes (`text-base-content/60`, `bg-primary/10`) so that colours adapt to the active theme.
- **All UI must work in both light and dark mode automatically.** Never use hard-coded colours (e.g., `text-gray-700`, `bg-white`, `border-slate-200`). Always use DaisyUI semantic colour classes (`text-base-content`, `bg-base-100`, `bg-base-200`, `border-base-300`, `text-primary`, `bg-error`, etc.) and Tailwind opacity modifiers on those classes (`text-base-content/80`, `bg-primary/10`) so that colours adapt to the active theme.
- **Secondary text uses `text-muted`** (defined in `src/app.css`), never `text-base-content` at 60% opacity or less, and never `text-muted` with an opacity: those fail the 4.5:1 contrast ratio in the light theme. `aria-hidden` is no exemption, sighted users still read hidden text. Lower opacities are only allowed on `aria-hidden` icon components that duplicate adjacent text; icons that show a state (e.g. an expand chevron) need 3:1 and also use `text-muted` (enforced by `npm run test:arch`).
- **All pages and components must be mobile-friendly.** Use responsive Tailwind breakpoints (`sm:`, `md:`, `lg:`) and test layouts at mobile viewport widths. Navigation must be accessible on small screens.
- Use Tailwind v4 `@source` directive for component CSS scanning

### Accessibility (BITV 2.0)
### Accessibility (WCAG 2.2 AA)

The application must comply with **BITV 2.0** (German accessibility regulation, based on WCAG 2.1 AA). Key requirements:
The application must comply with **WCAG 2.2 AA**. This covers **BITV 2.0** (German accessibility regulation, based on WCAG 2.1 AA). The axe tests in `e2e/accessibility.spec.ts` check the WCAG 2.0, 2.1 and 2.2 A/AA rules. Key requirements:

- **Semantic HTML**: Use correct elements (`<nav>`, `<main>`, `<header>`, `<button>`, `<a>`, headings in order). Never use `<div>` or `<span>` for interactive elements.
- **Keyboard navigation**: All interactive elements must be reachable and operable via keyboard. Visible focus indicators are required.
- **ARIA attributes**: Use `aria-label`, `aria-current`, `aria-disabled`, `aria-expanded`, `role` where semantic HTML is insufficient. Do not use ARIA to fix what semantic HTML can handle.
- **Colour contrast**: Text must meet WCAG AA contrast ratios (4.5:1 normal text, 3:1 large text). Never convey information through colour alone.
- **Target size**: Interactive targets must be at least 24×24 CSS px, or have enough spacing to undersized neighbours (WCAG 2.5.8).
- **Form labels**: Every form control must have a visible, associated `<label>` (see label-element association rule below).
- **Alt text**: Every `<img>` must have a meaningful `alt` attribute (or `alt=""` for decorative images).
- **Language**: The `<html>` element must have a `lang` attribute. Use `lang` attributes on content in other languages.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ Read more about the platform in the [documentation](https://docs.stackable.tech/

More modules will be added over time.

## Accessibility

Stackable Cockpit targets [WCAG 2.2](https://www.w3.org/TR/WCAG22/) level AA.
This is a superset of [BITV 2.0](https://www.gesetze-im-internet.de/bitv_2_0/), which is based on [WCAG 2.1](https://www.w3.org/TR/WCAG21/) level AA.
As the project is still experimental, it may not fully meet these requirements yet.
We treat every accessibility violation as a bug, so please [open an issue](https://github.com/stackabletech/cockpit/issues) if you find one.

## Quick Start

The required Node.js version is pinned in [`.node-version`](./.node-version).
Expand Down
109 changes: 109 additions & 0 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';
import { test, expect } from './support/fixtures.js';
import { hasGarageCredentials, requireGarageCredentials } from './support/garage.js';
import { setTabSql, setTabState, waitForHydration, waitForQueryComplete } from './support/helpers';
import { connectAndOpenPrefix, connectToStorage } from './storage/helpers.js';

const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'];
const THEMES = ['light', 'dark'] as const;

async function expectNoViolations(page: Page) {
const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
const summary = violations.flatMap((v) =>
v.nodes.map((n) => `${v.id} [${v.impact}] ${n.target.join(' ')}: ${n.failureSummary}`)
);
expect(summary).toEqual([]);
}

for (const theme of THEMES) {
test.describe(`Accessibility (axe, ${theme} theme)`, () => {
test.use({ locale: 'en-US' });

test.beforeEach(async ({ page }) => {
await page.addInitScript((t) => localStorage.setItem('theme', t), theme);
});

test.describe('logged out', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('login page', async ({ page }) => {
await page.goto('/auth/login');
await waitForHydration(page);
await expectNoViolations(page);
});
});

test('dashboard', async ({ page }) => {
await page.goto('/');
await waitForHydration(page);
await expectNoViolations(page);
});

test('Trino editor with query results', async ({ page }) => {
await setTabSql(page, 'SELECT id, name FROM users');
await page.goto('/trino');
await waitForHydration(page);
// Waits for Monaco to load and sets the cursor that "Run at cursor" needs
await page.locator('.monaco-editor').first().click();
await page.getByRole('button', { name: 'Run', exact: true }).click();
await waitForQueryComplete(page);
await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible();
await expectNoViolations(page);
});

test('Trino run mode menu open', async ({ page }) => {
await page.goto('/trino');
await waitForHydration(page);
await page.getByRole('button', { name: 'Select run mode' }).click();
await expect(page.getByRole('menu', { name: 'Select run mode' })).toBeVisible();
await expectNoViolations(page);
});

test('Trino editor with two tabs', async ({ page }) => {
await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }]);
await page.goto('/trino');
await waitForHydration(page);
await expect(page.getByRole('tab')).toHaveCount(2);
await expectNoViolations(page);
});

test('storage connect form', async ({ page }) => {
await page.goto('/storage');
await waitForHydration(page);
await expectNoViolations(page);
});

test('storage connection settings', async ({ page }) => {
await page.goto('/settings/connections');
await waitForHydration(page);
await expectNoViolations(page);
});

test.describe('with storage', () => {
test.beforeEach(() => {
test.skip(
!hasGarageCredentials(),
'Skipped: no s3-config.json found (requires a running Garage instance)'
);
});

test('storage overview when connected', async ({ page }) => {
await connectToStorage(page, requireGarageCredentials());
await expectNoViolations(page);
});

test('bucket object list', async ({ page }) => {
await connectAndOpenPrefix(page, requireGarageCredentials());
await expectNoViolations(page);
});

test('bucket object list with two tabs', async ({ page }) => {
await connectAndOpenPrefix(page, requireGarageCredentials());
await page.getByRole('button', { name: 'New Tab' }).click();
await expect(page.getByRole('tab')).toHaveCount(2);
await expectNoViolations(page);
});
});
});
}
67 changes: 65 additions & 2 deletions e2e/storage/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test.describe('Storage — Explorer tab bar', () => {
// The button cannot be nested in the tab button without producing invalid HTML.
const firstTab = tablist.getByRole('tab').nth(0);
await firstTab.hover();
await tablist.getByRole('button', { name: 'Close tab' }).first().click();
await tablist.getByRole('button', { name: 'Close tab', includeHidden: true }).first().click();

await expect(tablist).toBeVisible();
await expect(tablist.getByRole('tab')).toHaveCount(1);
Expand All @@ -93,7 +93,70 @@ test.describe('Storage — Explorer tab bar', () => {
const credentials = requireGarageCredentials();
await connectAndOpenPrefix(page, credentials);

await expect(page.getByRole('button', { name: 'Close tab' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Close tab', includeHidden: true })).toHaveCount(
0
);
});

test('Delete on a focused tab closes it', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectAndOpenPrefix(page, credentials);

await page.getByRole('button', { name: 'New Tab' }).click();

const tablist = page.getByRole('tablist', { name: 'Explorer tabs' });
const tabs = tablist.getByRole('tab');
await expect(tabs).toHaveCount(2);
await expect(tabs.nth(1)).toHaveAttribute('aria-keyshortcuts', 'Delete F2');

await tabs.nth(1).focus();
await page.keyboard.press('Delete');

await expect(tabs).toHaveCount(1);
await expect(tabs.nth(0)).toBeFocused();
await expect(tabs.nth(0)).toHaveAttribute('aria-keyshortcuts', 'F2');
});

test('Delete on the first of three tabs keeps focus on the newly active tab', async ({
page
}) => {
const credentials = requireGarageCredentials();
await connectAndOpenPrefix(page, credentials);

await page.getByRole('button', { name: 'New Tab' }).click();
await page.getByRole('button', { name: 'New Tab' }).click();

const tablist = page.getByRole('tablist', { name: 'Explorer tabs' });
const tabs = tablist.getByRole('tab');
await expect(tabs).toHaveCount(3);

await tabs.nth(2).focus();
await page.keyboard.press('Home');
await expect(tabs.nth(0)).toBeFocused();
await page.keyboard.press('Delete');

await expect(tabs).toHaveCount(2);
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(0)).toBeFocused();
});

test('clicking a tab keeps keyboard focus on it', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectAndOpenPrefix(page, credentials);

await page.getByRole('button', { name: 'New Tab' }).click();

const tablist = page.getByRole('tablist', { name: 'Explorer tabs' });
const tabs = tablist.getByRole('tab');
await expect(tabs).toHaveCount(2);

await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(0)).toBeFocused();

await page.keyboard.press('ArrowRight');
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(1)).toBeFocused();
});

test('middle-click on a tab closes it', async ({ page }) => {
Expand Down
83 changes: 78 additions & 5 deletions e2e/trino/trino-tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,44 @@ test.describe('Trino editor tabs', () => {
await expect(tabs).toHaveCount(1);
});

test('tab panel is labelled by the active tab', async ({ page }) => {
await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }]);
await page.goto('/trino');
await waitForHydration(page);

const tabs = page.getByRole('tab');
await tabs.nth(1).click();
const activeTabId = await tabs.nth(1).getAttribute('id');

await expect(page.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', activeTabId!);
});

test('overflowing tabs fade at the clipped edge and update on resize', async ({ page }) => {
await page.setViewportSize({ width: 2560, height: 844 });
await setTabState(
page,
Array.from({ length: 8 }, (_, i) => ({ sql: `SELECT ${i + 1}` }))
);
await page.goto('/trino');
await waitForHydration(page);

const strip = page.getByRole('tablist').locator('..');
const fade = (side: 'left' | 'right') =>
strip.evaluate((el, s) => (el as HTMLElement).style.getPropertyValue(`--fade-${s}`), side);

// Everything fits on a wide screen
await expect.poll(() => fade('right')).toBe('0px');
expect(await fade('left')).toBe('0px');

// Narrowing does not scroll, so only the resize can reveal the overflow
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => fade('right')).toBe('var(--fade-size)');
expect(await fade('left')).toBe('0px');

await page.getByRole('tab').last().focus();
await expect.poll(() => fade('left')).toBe('var(--fade-size)');
});

test('create new tab via + button', async ({ page }) => {
await page.getByRole('button', { name: 'New tab' }).click();

Expand Down Expand Up @@ -53,12 +91,47 @@ test.describe('Trino editor tabs', () => {
const closeBtn = tabs
.nth(0)
.locator('..')
.getByRole('button', { name: /Close tab/ });
.getByRole('button', { name: /Close tab/, includeHidden: true });
await closeBtn.click();

await expect(tabs).toHaveCount(1);
});

test('Delete on a focused tab closes it', async ({ page }) => {
await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }]);
await page.goto('/trino');
await waitForHydration(page);

const tabs = page.getByRole('tab');
await expect(tabs).toHaveCount(2);
await expect(tabs.nth(0)).toHaveAttribute('aria-keyshortcuts', 'Delete F2');

await tabs.nth(0).focus();
await page.keyboard.press('Delete');

await expect(tabs).toHaveCount(1);
await expect(tabs.nth(0)).toBeFocused();
await expect(tabs.nth(0)).toHaveAttribute('aria-keyshortcuts', 'F2');
});

test('Delete on the first of three tabs keeps focus on the newly active tab', async ({
page
}) => {
await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }, { sql: 'SELECT 3' }]);
await page.goto('/trino');
await waitForHydration(page);

const tabs = page.getByRole('tab');
await expect(tabs).toHaveCount(3);

await tabs.nth(0).click();
await page.keyboard.press('Delete');

await expect(tabs).toHaveCount(2);
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(0)).toBeFocused();
});

test('close non-active tab removes it and preserves active tab', async ({ page }) => {
await setTabState(page, [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }]);
await page.goto('/trino');
Expand All @@ -75,7 +148,7 @@ test.describe('Trino editor tabs', () => {
const closeBtn = tabs
.nth(0)
.locator('..')
.getByRole('button', { name: /Close tab/ });
.getByRole('button', { name: /Close tab/, includeHidden: true });
await closeBtn.click();

// Only one tab remains with default label — it should still be active
Expand All @@ -91,7 +164,7 @@ test.describe('Trino editor tabs', () => {
const closeBtn = tabs
.nth(0)
.locator('..')
.getByRole('button', { name: /Close tab/ });
.getByRole('button', { name: /Close tab/, includeHidden: true });
await expect(closeBtn).toHaveCount(0);
});

Expand All @@ -112,8 +185,8 @@ test.describe('Trino editor tabs', () => {
const tab = page.locator('[role="tab"]').first();
await tab.dblclick();

// Input should appear.
const input = tab.getByRole('textbox', { name: 'Rename tab' });
// Input should appear in place of the tab.
const input = page.getByRole('textbox', { name: 'Rename tab' });
await expect(input).toBeVisible();

await input.fill('My custom name');
Expand Down
17 changes: 17 additions & 0 deletions e2e/trino/trino.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ test.describe('Trino query editor', () => {
await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible();
});

test('run mode menu switches to Run all with the keyboard', async ({ page }) => {
const toggle = page.getByRole('button', { name: 'Select run mode' });
await toggle.focus();
await page.keyboard.press('ArrowDown');

const menu = page.getByRole('menu', { name: 'Select run mode' });
await expect(menu).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');

await expect(menu).toBeHidden();
await expect(toggle).toBeFocused();
await page.getByRole('button', { name: 'Run all', exact: true }).click();
await waitForQueryComplete(page);
await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible();
});

test('query error is shown in an alert', async ({ page }) => {
await setTabSql(page, 'SHOULD_ERROR');
await page.goto('/trino');
Expand Down
1 change: 1 addition & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"trino_tab_close": "Tab {name} schließen",
"trino_tab_default_name": "Unbenannt",
"trino_tab_rename": "Tab umbenennen",
"trino_tab_list": "Abfrage-Tabs",
"trino_tabs_persist_error": "Tab-Inhalt zu groß zum Speichern. Einige Änderungen gehen beim Neuladen möglicherweise verloren.",
"trino_editor_char_limit_reached": "Abfrage überschreitet {limit} Zeichen und wird nicht zwischen Sitzungen gespeichert",
"trino_run_at_cursor": "Ausführen",
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"trino_tab_close": "Close tab {name}",
"trino_tab_default_name": "Untitled",
"trino_tab_rename": "Rename tab",
"trino_tab_list": "Query tabs",
"trino_tabs_persist_error": "Tab content too large to save. Some changes may be lost on reload.",
"trino_editor_char_limit_reached": "Query exceeds {limit} characters and will not be saved between sessions",
"trino_run_at_cursor": "Run",
Expand Down
Loading
Loading