Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .claude/agent-memory/e2e-test-engineer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
> Detailed notes live in topic files. This index links to them.
> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md`, `milestones-e2e.md`, `story-1248-mass-move.md`

## Budget Source Filter E2E (Story #1354, 2026-04-25)

- BudgetSourceChip `aria-label` = `"Filter: {name} (selected)"` / `"Filter: {name} (not selected)"` — use `new RegExp(name)` in `getByRole('button', { name })`.
- Filter toolbar `aria-label` = `"Filter by source"` (i18n key `overview.costBreakdown.sourceFilter.label`).
- Clear filter button `aria-label` = `"Clear source filter — show all sources"` — use `/Clear source filter/i`.
- Available Funds expand button `aria-label` = `"Expand available funds sources"` (hardcoded, not i18n).
- Source badge in Level 3 rows: `<span aria-label="Budget source: {name}">`. Unassigned: `aria-label="Budget source: Unassigned"`.
- Long name truncation: badge label text ends with `…`. Full name in `title` attribute.
- Mobile "badge dot-only" behavior: IMPLEMENTED. `<span class*="sourceBadgeDot">` (aria-hidden) shown at ≤767px; `<span class*="sourceBadgeLabel">` hidden (display:none). Select via `[class*="sourceBadgeDot"]` / `[class*="sourceBadgeLabel"]` (hashed CSS module names). The Badge aria-label stays in DOM inside the hidden label span for screen readers.
- CSS module class for selected source detail row: `rowSourceDetailSelected` — Playwright sees hashed class e.g. `rowSourceDetailSelected_xyz`. Use `.toMatch(/rowSourceDetailSelected/)` regex on class attribute.
- `overflow-x: auto` on `.sourceFilterStrip` — testable via `getComputedStyle(el).overflowX === 'auto'`.
- `budgetSources` array comes from `breakdown.budgetSources` (in the breakdown response), NOT from the `/api/budget-sources` endpoint — mock both if needed.
- Escape clears filter: IMPLEMENTED. `handleToolbarKeyDown` on `<div role="toolbar">` checks `e.key === 'Escape' && selectedSourceIds.size > 0`, calls `onClearSources()` + `availFundsButtonRef.current?.focus()`. No-op when no sources selected. Test via `chip.focus(); keyboard.press('Escape')` then assert `aria-pressed=false`, URL clean, and `availFundsButton.toBeFocused()`.
- Dark mode color check: create throw-away element to normalize `rgb()` format (see Print E2E Patterns note).

## Print E2E Patterns (Issue #1310, 2026-04-19)

- `page.emulateMedia({ media: 'print' })` makes CSS `@media print` rules apply without dispatching window events.
Expand Down
8 changes: 8 additions & 0 deletions .claude/agent-memory/qa-integration-tester/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
> Detailed notes live in topic files. This index links to them.
> See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md`

## Story #1354 — CostBreakdownTable Props Refactor Pattern (2026-04-25)

`CostBreakdownTable` had `budgetSources={[]}` prop replaced with `selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}}`. When a component's prop API changes, use `replace_all: true` on Edit tool to update all test usages in one pass (28 occurrences updated at once). Also add new required fixture fields (`budgetSources: []` on BudgetBreakdown, `budgetSourceId: null` on BreakdownBudgetLine) via Python `sed`-style script when the pattern is uniform across many objects.

**Fix Loop Round 1 (2026-04-25)**: Tests at lines ~1844/1859/1884 still passed `budgetSources` as a JSX prop AND were missing required props. Fix: move source data into `breakdown={{ ...buildBreakdownWithWI(), budgetSources: [buildSourceSummary(...)] }}` and add `selectedSourceIds onSourceToggle onClearSources`. Also removed obsolete `buildBudgetSource()` helper (used `BudgetSource` full type — now use `buildSourceSummary()` with `BudgetSourceSummaryBreakdown`). In `BudgetOverviewPage.test.tsx`, Scenario 30 was testing the old `budgetSources` prop flow; updated to populate `breakdown.budgetSources` instead. Added Escape key tests for new `handleToolbarKeyDown` behavior.

**Stale dist warning**: `node_modules/@cornerstone/shared/dist/` must be rebuilt (`tsc -p shared/tsconfig.json --outDir node_modules/@cornerstone/shared/dist`) when shared types change. Without rebuild, `tsc --noEmit` on client shows false positives for `budgetSourceId`, `budgetSources`, `BudgetSourceSummaryBreakdown`. Jest is unaffected (maps to source).

## BudgetBar Module-Level Mock Anti-Pattern (2026-04-20)

**Critical**: Mocking `BudgetBar` at module level (`jest.unstable_mockModule('../../components/BudgetBar/BudgetBar.js', ...)`) breaks ALL existing tests that rely on BudgetBar rendering content (labels, role="img", segment text). BudgetBar renders segment labels (e.g. "Paid (unclaimed)", "Claimed") that existing tests assert on. The fix: test segment keys via observable behavior (aria-label, summaryLabel text) rather than mock capture. For segment structure verification, use `container.querySelectorAll('[class*="summaryRow"]')` to check rows and their label text order.
Expand Down
14 changes: 14 additions & 0 deletions .claude/agent-memory/translator/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,17 @@ Note: `claimed` here uses "Beantragt" (applied/requested for subsidy) rather tha
- `summaryClaimedLabel` → "Eingereicht" (bar chart summary; consistent with `barChart.claimed` = "Eingereicht")
- `srOnly` screen reader text: "Eingereicht {{claimed}}, Bezahlt {{paid}}, Projiziert {{projectedMin}} bis {{projectedMax}}, von Gesamt {{total}}"
- Obsolete keys removed in this update: `allocated`, `total`, `available`, `planned`

## Source Filter & Source Badge Patterns — Issue #1354 (2026-04-25)

- `overview.costBreakdown.sourceFilter.*`, `sourceImpact.*`, `sourceBadge.*` added to `de/budget.json`
- "Filter by source" → "Nach Quelle filtern" (matches `filterByStatus` = "Nach Status filtern" / `filterByVendor` = "Nach Auftragnehmer filtern" pattern)
- "All sources" → "Alle Quellen" (matches `allStatuses` = "Alle Status" / `allVendors` = "Alle Auftragnehmer" pattern)
- "Clear filters" (button label) → "Filter Löschen" (exact match with `invoices.clearFilters`)
- "clearAriaLabel" (descriptive aria label) → sentence-style with en-dash: "Quellenfilter löschen – alle Quellen anzeigen"
- "Unassigned" (source filter / source badge context) → "Nicht zugewiesen" (glossary `Unassigned` term, not "Kein X" pattern which is used for area/category absence)
- Live-region announcement sentence structure: "Es werden {{count}} von {{total}} Budgetpositionen angezeigt" / "Es werden alle Budgetpositionen angezeigt"
- "Budget source: {{name}}" (aria label) → "Budgetquelle: {{name}}" — always use full glossary term "Budgetquelle" in aria labels, short "Quelle" only in UI labels
- `sourceImpact.allocated` → "Zugeordnet" (consistent with `invoices.tableHeaders.allocated` = "Zugeordnet")
- `sourceImpact.remaining` → "Verbleibend" (consistent with established `barChart.remaining` / `summary.remainingLabel`)
- Chip aria labels: "Filter: {{name}} (ausgewählt)" / "Filter: {{name}} (nicht ausgewählt)"
4 changes: 4 additions & 0 deletions .claude/agent-memory/ux-designer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,7 @@ Zero new tokens, zero new CSS, zero new components. Text replacement only. Key r
- German currency trailing `€` with space may widen budget table cells — acceptable, no layout fix needed
- SearchPicker default prop strings (`placeholder`, `emptyHint`, etc.) replaced with `t()` — no CSS change
- Language selector UI is Story #917 (ProfilePage) — not this story

## Story #1354 — Source Badges & Filter (Budget Overview)

See [story-1354-source-badges-filter.md](story-1354-source-badges-filter.md). Key: 10-slot `--color-source-N-{bg,text,dot}` token family; `BudgetSourceChip` new shared component; `role="toolbar"` for chip strip; scoped `--chip-*` CSS custom properties pattern for per-slot colors.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: Story #1354 — Source Attribution Badges and Per-Source Filter
description: Visual spec for budget source color palette, source badge in BudgetLineRow, and filter chip strip in Available Funds section
type: project
---

## Key Decisions

**Source color palette**: 10-slot deterministic palette (`colorIndex = sourceId % 10`). Slot 0 = Unassigned (gray). Slots 1–9 match calendar-item hue families plus cyan. No schema change required.

**New token family**: `--color-source-N-bg`, `--color-source-N-text`, `--color-source-N-dot` for N=0–9, with Layer 3 dark-mode overrides. Added to tokens.css Layer 2 / Layer 3.

**Token naming**: `-dot` suffix provides a higher-saturation swatch color for the circle dot indicator (distinct from `-bg` which is low-saturation for badge background).

**Color-blind affordance**: source name label always present alongside dot; name truncated at 20 chars (badge) / 24 chars (chip) with full name in `title` + `aria-label`.

**Badge extension**: Add `source0`–`source9` and `sourceUnassigned` CSS classes to `Badge.module.css`. Add optional `title` prop to `Badge.tsx`. No new component needed for the badge.

**BudgetSourceChip**: New shared component at `client/src/components/BudgetSourceChip/`. Renders as `<button>`. Uses scoped CSS custom properties (`--chip-bg`, `--chip-text`, `--chip-dot`) set as inline `style` on the element to allow static CSS classes to consume per-slot token values cleanly (avoids 10x `.chipSlotN.chipSelected` rules).

**ARIA pattern for chip strip**: `role="toolbar"` (NOT `role="radiogroup"` — multi-select semantics). Each chip: `role="button"` (native), `aria-pressed="true|false"`. Tab moves through all chips. Escape clears filter and focuses "Available Funds" expand button.

**Filter state**: URL query param `?sources=2,5,8` (comma-separated IDs). `BudgetOverviewPage` owns URL state, passes `selectedSourceIds: Set<number>` + `onSourceToggle` + `onClearFilters` props to `CostBreakdownTable`.

**Available Funds columns**: existing name+total columns gain `allocatedCost` and `remaining` columns. Selected source detail rows get left-border accent using `--chip-dot` scoped property on `<tr>`.

**Empty filter state**: use existing `EmptyState` component in a `<td colSpan={4}>` row. No icon variant (filtered, not "add first item").

**Mobile badge**: dot only (label hidden via CSS). Touch-and-hold shows `title` attribute. Dot uses `-dot` token (higher contrast than `-bg`).

**Mobile chip strip**: `flex-wrap: nowrap; overflow-x: auto`. Chips 44px min touch height on mobile.

**Animations**: row fade-in via `@keyframes fadeIn` + `var(--transition-normal)`. `prefers-reduced-motion: reduce` disables all animations. Chip transitions via `var(--transition-normal)`.

**Why**: Spec posted as comment on issue #1354. Architecture decision (deterministic vs. persisted color) flagged to product-architect.
57 changes: 57 additions & 0 deletions client/src/components/Badge/Badge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,63 @@
color: var(--color-user-inactive-text);
}

/* Budget source badge variants (source0–source9, sourceUnassigned) */
.source0 {
background-color: var(--color-source-0-bg);
color: var(--color-source-0-text);
}

.source1 {
background-color: var(--color-source-1-bg);
color: var(--color-source-1-text);
}

.source2 {
background-color: var(--color-source-2-bg);
color: var(--color-source-2-text);
}

.source3 {
background-color: var(--color-source-3-bg);
color: var(--color-source-3-text);
}

.source4 {
background-color: var(--color-source-4-bg);
color: var(--color-source-4-text);
}

.source5 {
background-color: var(--color-source-5-bg);
color: var(--color-source-5-text);
}

.source6 {
background-color: var(--color-source-6-bg);
color: var(--color-source-6-text);
}

.source7 {
background-color: var(--color-source-7-bg);
color: var(--color-source-7-text);
}

.source8 {
background-color: var(--color-source-8-bg);
color: var(--color-source-8-text);
}

.source9 {
background-color: var(--color-source-9-bg);
color: var(--color-source-9-text);
}

.sourceUnassigned {
background-color: var(--color-source-0-bg);
color: var(--color-source-0-text);
font-style: italic;
}

/* Responsive */
@media (max-width: 767px) {
.badge {
Expand Down
16 changes: 16 additions & 0 deletions client/src/components/Badge/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,20 @@ describe('Badge', () => {
const span = container.querySelector('span');
expect(span?.textContent).toBe('raw_value');
});

// ─── title prop ─────────────────────────────────────────────────────────────

it('forwards title prop to the rendered span', () => {
const { container } = render(
<Badge variants={SIMPLE_VARIANTS} value="foo" title="Full Source Name" />,
);
const span = container.querySelector('span');
expect(span).toHaveAttribute('title', 'Full Source Name');
});

it('does not render title attribute when title prop is not passed', () => {
const { container } = render(<Badge variants={SIMPLE_VARIANTS} value="foo" />);
const span = container.querySelector('span');
expect(span).not.toHaveAttribute('title');
});
});
5 changes: 3 additions & 2 deletions client/src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ interface BadgeProps {
variants: BadgeVariantMap;
value: string;
ariaLabel?: string;
title?: string;
testId?: string;
className?: string;
}

export function Badge({ variants, value, ariaLabel, testId, className }: BadgeProps) {
export function Badge({ variants, value, ariaLabel, title, testId, className }: BadgeProps) {
const variant = variants[value];
const combinedClass = [styles.badge, variant?.className, className].filter(Boolean).join(' ');

return (
<span className={combinedClass} aria-label={ariaLabel} data-testid={testId}>
<span className={combinedClass} aria-label={ariaLabel} title={title} data-testid={testId}>
{variant?.label ?? value}
</span>
);
Expand Down
70 changes: 70 additions & 0 deletions client/src/components/BudgetSourceChip/BudgetSourceChip.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1-5) var(--spacing-3);
min-height: var(--spacing-8);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
transition: background-color var(--transition-normal), border-color var(--transition-normal),
box-shadow var(--transition-normal);
flex-shrink: 0;
white-space: nowrap;
cursor: pointer;
}

.chip:hover:not(:disabled) {
background-color: var(--color-bg-hover);
}

.chipSelected {
background-color: var(--chip-bg);
color: var(--chip-text);
border-color: transparent;
box-shadow: 0 0 0 2px var(--chip-dot);
}

.chipSelected:hover:not(:disabled) {
opacity: 0.9;
}

.chip:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}

.chipSelected:focus-visible {
box-shadow: var(--shadow-focus), 0 0 0 2px var(--chip-dot);
}

.chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.dot {
width: var(--spacing-2);
height: var(--spacing-2);
border-radius: var(--radius-circle);
background-color: var(--chip-dot);
flex-shrink: 0;
}

.label {
/* inherits from .chip */
}

@media (max-width: 767px) {
.chip {
min-height: 44px;
padding: var(--spacing-2) var(--spacing-3);
}
}

@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}
Loading