@micha.bigler/ui-core-micha 2.26.0 → 2.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md ADDED
@@ -0,0 +1,37 @@
1
+ # Frontend Design Principles
2
+
3
+ How we design app frontends across the estate. **Consistency here is consistency of METHOD, not one identical look** — every app honors its OWN theme tokens (principle 1); the shared surface (`ui-core-micha` components, charts, layout) carries the structural consistency. hram is the worked example throughout, not a look other apps must copy.
4
+
5
+ These principles are the spec a **prototype** encodes and the implementation matches. `frontend-engineering` (build) and `ui_reviewer` (review) point here; the prototype workflow is the `frontend-design` skill.
6
+
7
+ ## Identity
8
+
9
+ 1. **Honor the existing system, don't invent one.** Pull the app's real tokens and apply them consistently (hram: primary blue `#468AB2`, DM Sans, white, small radii, MUI `ToggleButton` / outlined-`Select` shapes from its `theme.js`). **Match first, then elevate.** Never invent a parallel language — a made-up teal / pills / breadcrumb vocabulary was the v1 mistake.
10
+ 2. **One accent, deliberately placed.** Interactive = the app's one accent colour. **Data colours are a separate layer.** Status (green / amber / red) is semantic and is NEVER repurposed as a series colour. Restraint over decoration.
11
+ 3. **Commit to the app's theme reality — with intent.** If the app is single-theme (hram is light-only), commit to that deliberately rather than bolting on dark mode as an afterthought — a decision, not an omission. The estate does NOT mandate light-only; it is per-app — but whichever it is, it is on purpose.
12
+
13
+ ## Information design (it's a tool, not a document)
14
+
15
+ 4. **Scan and operate, not read.** Summary before detail; state encoded in form (chips, knee-markers); interactive things look interactive.
16
+ 5. **The user's language, not the system's.** Humanize labels (`incidence_scale` → "Incidence scale"); keep the raw name reachable on hover for the technicians — readability without losing traceability. Words are design material.
17
+ 6. **Reduce help, don't remove it.** Explanation lives in on-demand affordances (ⓘ-popover, provisional-chip), not permanent banners — less noise, same information.
18
+ 7. **Design the edge-states, not just the happy path.** An empty / "no frontier" state gets a real design (statement + CTA), never a bare alert.
19
+
20
+ ## Charts (read by humans, executed by code)
21
+
22
+ 8. **Every chart earns:** axis labels (with units), a legend only when >1 series, a tooltip, theme-token colours, container-responsive sizing, and empty/loading states. **Single-hue for magnitude; diverging (blue/red around zero) ONLY for signed data. Never dual-axis.** The concrete data palette is per-app / the `ui-core-micha` chart kit — this states the rules, not a fixed palette.
23
+ 9. **Ground it in the real subject matter.** Faithful structures from the real panels (hram: Morris-tornado, Pareto/knee, CI-strip, allocation-scatter with shape-encoded sources) — no lorem, no generic charts. Hence: **scout the real subject before building.**
24
+
25
+ ## Craft
26
+
27
+ 10. **Prototype = spec, parity = guardrail.** Purely visual/UX — function stays. **"No behaviour / permission / data-contract change"** is the hard condition that goes into the WO.
28
+ 11. **Align to the shipped reality.** When related features land, pull the prototype onto them so it is a truthful spec, not a contradicting vision.
29
+ 12. **Avoid the generic "AI look"; take details seriously.** No cream / serif / neon default; embed the real font (CSP-safe as a data-URI, not a silent-fallback risk); `tabular-nums` for aligned numbers; a11y (role/aria on chart regions, focusable info). **Template tells to CUT** (unless the app's design system genuinely specifies them): monospace-uppercase **eyebrow / kicker labels** (e.g. `IMARA · ADVANCING RESILIENT HEALTH SYSTEMS`), decorative **pills / badges**, `FIG. 01 ·` / `F.01` **figure captions**, `↳`-prefixed **micro-links**, **oversized display** headlines, **sketchy / hand-drawn** styling, and **explanatory paragraphs that restate the UI** (help is on-demand — see #6). Restraint is the default; each of these is added only with a real reason.
30
+
31
+ For the **design craft** of a prototype (an HTML artifact) and its charts, load Claude's built-in **`artifact-design`** (design fundamentals) and **`dataviz`** (charts) skills and apply them WITHIN these principles — this doc adds the estate method (honor the app's OWN tokens, restraint), it does not replace that craft, and it must not crowd it out (load both). Do NOT default to **`swisstph-design`**: it imposes the Swiss-TPH house brand and conflicts with #1 (honor the app's own tokens).
32
+
33
+ ## Also — structural dimensions
34
+
35
+ 13. **Spacing / density = tool density, not document width.** A defined spacing rhythm, dense enough to scan and operate — not airy like a reading document. Use the theme's spacing scale; don't hand-pick pixel gaps.
36
+ 14. **Responsive = container-sized, not fixed px.** Layouts and charts size to their container; define breakpoint behaviour deliberately (what stacks, what hides, what scrolls) — the same container-responsive rule the charts already follow.
37
+ 15. **Motion = restrained and functional.** Transitions serve orientation/feedback (state change, reveal), never decoration. Short, few, purposeful; respect `prefers-reduced-motion`.
package/WORK_ORDERS.md CHANGED
@@ -43,5 +43,6 @@ prefixed ID; never reuse a bare flat number across workstreams.
43
43
  | MSG-6h | Attachment gallery (thumbnail/lightbox/right-click download), reaction bar overlap | `AttachmentList.jsx` renders each attachment as a bare download button (36px thumbnail if loaded, plus a text label that always fell back to the raw UUID `id` since dcm never returned a filename). `MessageBubble.jsx`s `ReactionBar` uses a fixed `mt: -0.5` overlap trick against the bubble that now reads as visually broken. | 2026-08-03 | done | 5d6ba6f | Envelope: [work-orders/MSG-6h.md](work-orders/MSG-6h.md). Operator report from a staging screenshot, seen for the first time end-to-end now that `django-core-micha` MSG-10 fixed attachment uploads (previously unconditionally rejected). Companion to dcm `work-orders/MSG-12.md` (published 2.40.2, exposes the real filename this WO consumes). **Implemented directly by the Orchestrator** (Codex unavailable). `AttachmentList.jsx` rewritten: each attachment is a small 64×64 tile (image thumbnail via the existing `getAttachmentThumbnail`, or a file-icon+filename for non-images); click on an image opens a lightbox (full image via `getAttachment`, cached per-attachment so a re-open doesn't re-fetch); right-click opens a context menu with Download, `stopPropagation`'d so it never also opens `MessageBubble`'s own bubble-level menu. `ReactionBar`'s fixed `mt: -0.5` overlap trick (`MessageBubble.jsx:139`) replaced with a small positive `mt: 0.5` gap — the old trick pulled the chip into whatever sat immediately above it in flow, which used to always be plain text but now can be MSG-6g's poll bars or this WO's own bordered attachment tiles. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 9 findings total, all fixed or explicitly resolved/disclosed** — see `work-orders/MSG-6h.md`'s as-built record for full detail. Headline: `reviewer` R1 and `ui_reviewer` U1 independently found the **same real regression** from two angles — the redesign made an image's only download path a mouse right-click (the lightbox had no download control), unreachable by keyboard or touch (no long-press). One fix closed both: the lightbox now carries its own Download button, reachable by click, Tab+Enter, or touch. `reviewer` R2 (P2) — the new lightbox fetch had no loading/error state; added a `loading`/`ready`/`error` status with a spinner and a visible error alert, guarded against a stale in-flight fetch being superseded by a newer open. R3 (P3) — lightbox `Dialog` had no accessible name; added. `ui_reviewer` U2 (P2, resolved not separately fixed) — no visual affordance for right-click discoverability; the R1/U1 fix already makes every attachment fully reachable via primary click, so right-click stays a shortcut, not a required hidden path — a redundant hover-icon on top of that was judged scope creep past the finding's actual concern. U3 (P2) — truncated filenames got a `title` tooltip. U4 (P3) — French `PREVIEW` translation fixed for verb-first parallelism with its siblings. U5 (P3) — lightbox buttons gained the codebase's established `boxShadow` convention for floating icons. U6 (P3, disclosed not fixed) — a residual silent-no-op edge case matching a pre-existing pattern elsewhere in the same component; R2's loading/error state already covers the actually-reachable failure path. **Scope B (reaction spacing) explicitly NOT live-verified** against the operator's original staging screenshot — no staging credentials this round; both reviewers judged the fix low-risk by static reading only. Full suite 241 → 249 (8 new tests), `tsc` clean. Published as npm 2.24.0.
44
44
  | MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | a6ef578 | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
45
45
  | NOTIF-14 | Push-preview toggle in NotificationSettings | Companion to `django-core-micha` MSG-13 (landed `39135e9`), which added a per-user `NotificationPreference.push_preview_opt_in` backend field (default on) that hides sender/text from a push body when off, already exposed on the `notifications/preferences/` endpoint — but nothing in ucm reads or writes it yet, so there is no UI path to reach it. | 2026-08-04 | done | 72e6697 | Envelope: [work-orders/NOTIF-14.md](work-orders/NOTIF-14.md). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. Adds a third `Switch` to `NotificationSettings.jsx`, mirroring the existing `email_opt_in` toggle shape exactly (not the more involved device-subscription push toggle). **Load-bearing subtlety from the envelope, confirmed correct by `reviewer`:** `push_preview_opt_in` defaults `True` on the backend (opposite of `push_opt_in`'s `False` default) — checked as `!== false`, not `Boolean(...)`, with a non-vacuous test (field absent from the mocked API response) proving it. UX call from the Expertenchat, implemented as specified: the toggle is disabled unless `push_opt_in` is already on. Two new i18n keys across all four languages (de/fr/en/sw). While writing the new tests, surfaced and fixed two latent, pre-existing gaps in `tests/NotificationSettings.test.jsx` that had simply never been exercised before (the file had only ever rendered the component twice total): no `afterEach(cleanup)` between tests (added, matches the convention already used in ~33 other test files in this repo), and the file's mocked `useTranslation` returned a fresh `t` function reference on every call, unlike real react-i18next (which memoizes it) — this re-triggered the component's `useEffect(..., [t])` on every re-render in tests and clobbered a toggle's own state update with a stale refetch; fixed by hoisting `t` to a module-level stable reference in the test file only, production code untouched. `reviewer` independently confirmed both are legitimate test-infra fixes, not a production defect. Gate: `tests/NotificationSettings.test.jsx` 2→8 passed; full suite (measured baseline via `git stash -u`) 256 passed/0 failed → after 262 passed/0 failed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Tier 2 (ui-core-micha is shared-core, forces Tier 2 regardless of size per AGENTS.md) — `sec_reviewer` not needed (the content-in-push security question was already ruled in dcm MSG-13; this WO only wires an already-shipped, already-reviewed backend preference into the settings UI). |
46
- | SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | done | 8ef8cbf (WO), bf4cdd1 (impl) | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: **Profil goes from one tap to two in every app** (paid consciously — the price of the consolidation). hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E (dcm MSG-13 preview `Switch`) had already landed separately as NOTIF-14 (`72e6697`) before this WO started, so A-D ship alongside it in the same release. Host header rewires (spesix/survey_app/jg/hram) are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar` — not done here. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (same failure as NOTIF-14) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (WO's TIER line makes both mandatory). **Decisions stated per WO's explicit requirement** (both reviewers flagged these as initially undocumented, now recorded here): scope D icons — **no entry carries a leading or trailing icon** (uniform with none, avoids an unstated icon contract for host items); Profil/Abmelden i18n — **new `UserMenu.PROFILE`/`UserMenu.LOGOUT` keys** in a fresh `src/i18n/userMenuTranslations.ts`, not a reuse of `Account.TAB_PROFILE` (different UI surface, keeps the component independently consumable). New component `src/auth/UserMenu.jsx` mirrors `NotificationBell({ resolveLink })`'s exact anchor/Menu/onClose shape — no custom keyboard/focus-trap code, relies entirely on MUI `Menu`'s native Escape/click-outside/focus-return. Gate: `tests/UserMenu.test.jsx` (new, 6/6 passed, all WO-required cases incl. non-vacuous ordering + logout-stub assertions) + affected-areas set `tests/AuthContext.test.jsx` + `tests/NotificationSettings.test.jsx` (untouched by this diff, included per WO's test-scope note since scope E is in the same release) → 18/18 passed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Both reviewers' only findings were the now-resolved documentation gaps above — no code-correctness findings from either. |
46
+ | SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | done | 8ef8cbf (WO), bf4cdd1 (impl) | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: **Profil goes from one tap to two in every app** (paid consciously — the price of the consolidation). hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E (dcm MSG-13 preview `Switch`) had already landed separately as NOTIF-14 (`72e6697`) before this WO started, so A-D ship alongside it in the same release. Host header rewires (spesix/survey_app/jg/hram) are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar` — not done here. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (same failure as NOTIF-14) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (WO's TIER line makes both mandatory). **Decisions stated per WO's explicit requirement** (both reviewers flagged these as initially undocumented, now recorded here): scope D icons — **no entry carries a leading or trailing icon** (uniform with none, avoids an unstated icon contract for host items); Profil/Abmelden i18n — **new `UserMenu.PROFILE`/`UserMenu.LOGOUT` keys** in a fresh `src/i18n/userMenuTranslations.ts`, not a reuse of `Account.TAB_PROFILE` (different UI surface, keeps the component independently consumable). New component `src/auth/UserMenu.jsx` mirrors `NotificationBell({ resolveLink })`'s exact anchor/Menu/onClose shape — no custom keyboard/focus-trap code, relies entirely on MUI `Menu`'s native Escape/click-outside/focus-return. Gate: `tests/UserMenu.test.jsx` (new, 6/6 passed, all WO-required cases incl. non-vacuous ordering + logout-stub assertions) + affected-areas set `tests/AuthContext.test.jsx` + `tests/NotificationSettings.test.jsx` (untouched by this diff, included per WO's test-scope note since scope E is in the same release) → 18/18 passed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Both reviewers' only findings were the now-resolved documentation gaps above — no code-correctness findings from either. **HOTFIX 2026-08-04 (`b5b6abd`, published 2.26.1)**: operator screenshot feedback — two-letter avatar initials looked cramped in the 32px circle (MUI's default `Avatar` font-size doesn't auto-scale for a smaller width/height override). Added `fontSize: '0.8rem'`. Independent `reviewer`: no findings. **HOTFIX 2026-08-04 (`769f55d`, published 2.26.2)**: operator screenshot from hram — identity block showed the same email twice (display name falls back to `username` when no first/last name; on allauth email-login hosts `username === email`). The email line now only renders when it differs from the shown display name. Independent `reviewer`: no findings. |
47
47
  | CHART-2 | Time-series chart preset with range picker and series toggles | Extends CHART-1, whose `ChartFrame` already ships a **toolbar slot that no consumer uses yet**. Operator request 2026-08-04 from jg's activity chart, generalised: every app charting a time series wants a duration picker and per-series toggles, and `django-core-micha` ACT-1 will make activity data available to all of them. jg's `ActivitySection.jsx:9` currently imports `@mui/x-charts` directly and hand-rolls its own Paper/loading/error/empty. | 2026-08-04 | done | a277c82, abf11b0 | Envelope: [work-orders/CHART-2.md](work-orders/CHART-2.md). **Presentational only — the host fetches**; the component takes data plus callbacks and calls no endpoint. **The half most likely to be dropped: the range picker must emit a GRANULARITY alongside the window** — new `TimeSeriesChart` component emits `onRangeChange(rangeKey, granularity)` per the fixed table (1d→hour, 1w→4hour, 1m→day, 1y→month), test 1 asserts the exact emitted payload, non-vacuity confirmed by manually removing the granularity arg and observing the test fail. **Series toggles implemented as `Checkbox`+`FormControlLabel`** (this repo's established multi-choice pattern, matching `PollCard.jsx`); range picker as `RadioGroup`/`Radio`/`FormControlLabel` (same repo's deliberate single-choice convention, chosen over `ToggleButtonGroup` for which this repo has zero precedent). **Second y-axis: explicitly NOT added** — series toggles are the chosen fix (stated in the component's own JSDoc), not silently absent. **Color/identity stability**: each series' color is keyed to its original (unfiltered) index, not its position after toggling others off — verified independently by `reviewer`, prevents a toggle from shifting a *different* series' color. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (mandatory per WO tier), **both clean, no findings**. `ui_reviewer` confirmed accessible labels (series name text, not color alone), native MUI keyboard nav untouched, all 4 languages present for new i18n keys, host-supplied series labels never passed through `t()`. Gate: 9/9 new `TimeSeriesChart` tests + 25/25 across the full chart-component test set (`ChartFrame`/`BarChart`/`LineChart`/`chartsPalette`/`TimeSeriesChart`), `tsc --noEmit` clean. `ChartFrame`/`BarChart`/`LineChart`/`palette.js` confirmed untouched. |
48
+ | MSG-6j | Poll option label collides with its vote-count/percent text at zero gap | Operator screenshots from jg-ferien production: `PollCard.jsx` renders each option's label and result text in a `Stack justifyContent="space-between"` with no `spacing`, inside a `MessageBubble` that is `width: fit-content` — since the bubble's own width is derived from this exact content, there is never leftover space for space-between to distribute, so label and vote-count/percent text end up flush against each other. Percentage math itself confirmed correct (operator's second screenshot showed the right numbers once both votes had landed) -- layout only. | 2026-08-04 | planned | | Envelope: [work-orders/MSG-6j.md](work-orders/MSG-6j.md). Two additive fixes: `spacing` on the label/result row so space-between can never collapse to zero gap; a `minWidth` on the poll's `Paper` reconciled against `MessageBubble`'s `maxWidth: min(75%, 680px)` ceiling, verified at 375px too. Tier 2 (ui-core-micha is shared-core). |
@@ -38,6 +38,6 @@ export function UserMenu({ resolveLink, items = [], profileLink = '/account', av
38
38
  handleClose();
39
39
  (_a = item.onSelect) === null || _a === void 0 ? void 0 : _a.call(item);
40
40
  };
41
- return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": t('UserMenu.TITLE'), color: "inherit", onClick: (event) => setAnchorEl(event.currentTarget), children: _jsx(Avatar, { src: avatarSrc, sx: { width: 32, height: 32 }, children: avatarSrc ? null : getInitials(user) }) }), _jsxs(Menu, { anchorEl: anchorEl, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, onClose: handleClose, open: open, transformOrigin: { horizontal: 'right', vertical: 'top' }, children: [_jsxs(Box, { sx: { minWidth: 240, px: 2, py: 1 }, children: [_jsx(Typography, { variant: "subtitle1", noWrap: true, children: getDisplayName(user) }), _jsx(Typography, { variant: "body2", color: "text.secondary", noWrap: true, children: user === null || user === void 0 ? void 0 : user.email })] }), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleProfile, children: t('UserMenu.PROFILE') }), items.length > 0 && _jsx(Divider, {}), items.map((item) => (_jsx(MenuItem, { onClick: () => handleItemSelect(item), children: item.label }, item.id))), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleLogout, children: t('UserMenu.LOGOUT') })] })] }));
41
+ return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": t('UserMenu.TITLE'), color: "inherit", onClick: (event) => setAnchorEl(event.currentTarget), children: _jsx(Avatar, { src: avatarSrc, sx: { width: 32, height: 32, fontSize: '0.8rem' }, children: avatarSrc ? null : getInitials(user) }) }), _jsxs(Menu, { anchorEl: anchorEl, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, onClose: handleClose, open: open, transformOrigin: { horizontal: 'right', vertical: 'top' }, children: [_jsxs(Box, { sx: { minWidth: 240, px: 2, py: 1 }, children: [_jsx(Typography, { variant: "subtitle1", noWrap: true, children: getDisplayName(user) }), (user === null || user === void 0 ? void 0 : user.email) && user.email !== getDisplayName(user) && (_jsx(Typography, { variant: "body2", color: "text.secondary", noWrap: true, children: user.email }))] }), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleProfile, children: t('UserMenu.PROFILE') }), items.length > 0 && _jsx(Divider, {}), items.map((item) => (_jsx(MenuItem, { onClick: () => handleItemSelect(item), children: item.label }, item.id))), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleLogout, children: t('UserMenu.LOGOUT') })] })] }));
42
42
  }
43
43
  export default UserMenu;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.26.0",
3
+ "version": "2.26.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -57,7 +57,7 @@ export function UserMenu({ resolveLink, items = [], profileLink = '/account', av
57
57
  color="inherit"
58
58
  onClick={(event) => setAnchorEl(event.currentTarget)}
59
59
  >
60
- <Avatar src={avatarSrc} sx={{ width: 32, height: 32 }}>
60
+ <Avatar src={avatarSrc} sx={{ width: 32, height: 32, fontSize: '0.8rem' }}>
61
61
  {avatarSrc ? null : getInitials(user)}
62
62
  </Avatar>
63
63
  </IconButton>
@@ -70,7 +70,13 @@ export function UserMenu({ resolveLink, items = [], profileLink = '/account', av
70
70
  >
71
71
  <Box sx={{ minWidth: 240, px: 2, py: 1 }}>
72
72
  <Typography variant="subtitle1" noWrap>{getDisplayName(user)}</Typography>
73
- <Typography variant="body2" color="text.secondary" noWrap>{user?.email}</Typography>
73
+ {/* A user with no first/last name falls back to username as the
74
+ display name above — some hosts' usernames are the email
75
+ address itself, which would otherwise repeat the same text
76
+ on both lines. */}
77
+ {user?.email && user.email !== getDisplayName(user) && (
78
+ <Typography variant="body2" color="text.secondary" noWrap>{user.email}</Typography>
79
+ )}
74
80
  </Box>
75
81
  <Divider />
76
82
  <MenuItem onClick={handleProfile}>{t('UserMenu.PROFILE')}</MenuItem>
@@ -115,6 +115,15 @@ describe('UserMenu', () => {
115
115
  });
116
116
  });
117
117
 
118
+ it('does not repeat the email when it is also the display name (no first/last name, username === email)', () => {
119
+ const user = { username: 'ada@example.com', email: 'ada@example.com' };
120
+ renderUserMenu({ user });
121
+
122
+ fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
123
+
124
+ expect(screen.getAllByText('ada@example.com')).toHaveLength(1);
125
+ });
126
+
118
127
  it('avatar renders correct initials, falling back to username when names are absent', () => {
119
128
  renderUserMenu({ user: { first_name: 'Ada', last_name: 'Lovelace', username: 'ada' } });
120
129
  expect(screen.getByText('AL')).toBeTruthy();
@@ -0,0 +1,117 @@
1
+ # WORK ORDER MSG-6j — poll option row collides with its vote count under a tight bubble width
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is
4
+ not addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not
5
+ shell out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked
6
+ **directly via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags
7
+ `--skip-git-repo-check` and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a
8
+ positional argument from this file. Fall back to direct Claude implementation only on Codex quota /
9
+ rate-limit / non-zero exit.
10
+
11
+ Feature-level WO (Expertenchat envelope). Operator screenshots from `jg-ferien` production, 2026-08-04:
12
+ a poll option's label text runs directly into its vote-count/percent text with **zero visible gap**
13
+ ("soidfsdoöifn**2** (100 %)" — the bold "2" is the vote count, not part of the option text). The
14
+ percentage math itself is confirmed correct once both votes had landed (a second screenshot showed
15
+ "2 (100 %)" / "1 (50 %)" as expected) — **this WO is width/layout only, no data or percentage logic
16
+ change.**
17
+
18
+ ## TIER
19
+ Tier 2 — `ui-core-micha` is shared-core, which forces Tier 2 regardless of change size per AGENTS.md.
20
+ Independent `reviewer` mandatory. No `sec_reviewer` needed (pure layout, no data/permission surface).
21
+
22
+ ## ROOT CAUSE — verified against the landed code (`src/messaging/PollCard.jsx`, `MessageBubble.jsx`)
23
+
24
+ `MessageBubble.jsx:100` sizes every message bubble as `width: 'fit-content', maxWidth: 'min(75%, 680px)'`
25
+ — it shrinks to exactly the width its content needs, with **no minimum floor**. Inside a poll,
26
+ `PollCard.jsx`'s `optionLabel()` renders each option's label and its `resultText` ("{count} ({percent}
27
+ %)") in a `Stack direction="row" justifyContent="space-between"` with **no `spacing`/`gap`** of its
28
+ own (`:112-116`). `justifyContent: 'space-between'` only inserts space when the row has *leftover*
29
+ width beyond what its children need — but here the bubble's own width is derived FROM this exact
30
+ content (`fit-content`), so by construction there is no leftover space, and space-between reserves
31
+ **zero minimum gap**. The label (`Typography noWrap`, which MUI resolves to
32
+ `overflow:hidden;textOverflow:ellipsis;whiteSpace:nowrap`) shows its full text without truncating
33
+ because the row isn't actually being squeezed narrower than content — it's exactly content-width, so
34
+ label and result text end up flush against each other with nothing between.
35
+
36
+ Two independent, additive fixes, both needed (confirmed by re-reading the code, not guessed):
37
+
38
+ 1. **Give the label/result row an explicit minimum gap that `space-between` cannot collapse to zero.**
39
+ Add `spacing={1}` (or equivalent `gap`) to the `Stack direction="row" justifyContent="space-between"`
40
+ at `PollCard.jsx:113` (inside `optionLabel()`'s `richLabel`). MUI's `Stack` applies its `spacing`
41
+ prop as margin between children, independent of `justifyContent` — this guarantees a floor gap
42
+ even when the row has no leftover width to distribute.
43
+ 2. **Give the poll card itself a sensible minimum width** so a short question/option set doesn't pull
44
+ the whole bubble in tighter than is comfortable to read (the operator's "insbesondere in der Breite
45
+ mehr Platz" — more room specifically in width). Add a `minWidth` to the `Paper` at
46
+ `PollCard.jsx:123` (e.g. `minWidth: 280` — pick a value that reads comfortably at this repo's base
47
+ font size; check against `MessageBubble.jsx`'s own `maxWidth` ceiling so a minWidth poll never
48
+ exceeds the bubble's own max on a narrow viewport — clamp or use `Math.min` logic if needed, verify
49
+ in the browser at a mobile width e.g. 375px, not just desktop).
50
+
51
+ ## NON-GOALS / DO NOT TOUCH
52
+
53
+ - Do not touch percentage/vote-count computation (`respondentCount`, `optionLabel`'s `percent`
54
+ calculation) — confirmed correct by the operator's second screenshot; this WO is layout-only.
55
+ - Do not touch `MessageBubble.jsx`'s own `maxWidth`/`width: fit-content` — other message kinds
56
+ (plain text, attachments, announcements) rely on that tight fit; scope the fix to the poll's own
57
+ minimum, not a global bubble-sizing change.
58
+ - Do not touch `applyOptimisticVote`, `castPollVote`, or any vote-casting logic.
59
+
60
+ ## RISKS
61
+
62
+ - A `minWidth` on the poll `Paper` that isn't reconciled against `MessageBubble`'s `maxWidth` could
63
+ overflow the bubble container on a narrow mobile viewport (375px) if chosen carelessly — verify at
64
+ that width explicitly, not just desktop, per the WO's own note in `PollCard.jsx`'s existing
65
+ accessibility comment about width assumptions.
66
+ - `spacing` on a `Stack` that already has other margin/padding assumptions elsewhere in the row could
67
+ shift alignment of the progress bar below it (`LinearProgress` sits in the same parent `Stack`,
68
+ outside the row) — verify visually that the bar still aligns with the row above it.
69
+
70
+ ## REQUIRED TESTS TO WRITE
71
+
72
+ Narrow, matching this repo's existing `PollCard` test conventions — extend
73
+ `tests/messagingPollRendering.test.jsx` (the file covering poll rendering today) rather than
74
+ inventing a new file. `tests/messagingMsg6i.test.jsx` and `messagingMsg6g.test.jsx` are the other two
75
+ poll-adjacent suites; skim them for the established assertion style before writing new tests.
76
+
77
+ 1. A poll with a short question/options renders its `Paper`/container at at least the chosen minimum
78
+ width (assert a computed style or a `sx` prop value — whatever this repo's existing width
79
+ assertions already use elsewhere, e.g. `MessageBubble`'s own tests, follow that pattern).
80
+ 2. The label/result-text row has non-zero spacing between its two children even when both are their
81
+ natural (non-ellipsized) width — a regression test for the exact reported bug: render a poll with
82
+ one option's label + result text combined width close to the container's available width, assert
83
+ there is a rendered gap (e.g. via a wrapping element's `gap`/margin style, or simply that the
84
+ two `Typography` nodes are not adjacent DOM siblings with zero computed margin — use whatever
85
+ assertion style this repo's other spacing regression tests already use, e.g. MSG-6i's own
86
+ `getComputedStyle`-based test for `Thread.jsx`'s flex fix, same technique).
87
+
88
+ **Non-vacuity:** test 2 must fail against the pre-fix code (space-between with no `spacing`) — verify
89
+ that before considering this WO done.
90
+
91
+ ## VERIFICATION
92
+
93
+ Live browser check at both a normal desktop width and a 375px mobile width (this repo's own
94
+ established convention per prior MSG-6 WOs) with a real poll: two options with realistic-length text,
95
+ confirm the label and vote-count/percent never visually touch, and the card is not uncomfortably
96
+ narrow for a two-option poll.
97
+
98
+ ## TARGET REPO
99
+
100
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`, no `develop` in this repo).
101
+
102
+ ## MINI-HANDOVER (pastable)
103
+
104
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
105
+ > `work-orders/MSG-6j.md` — read it fully, then follow the `orchestrate-codex` skill.
106
+ >
107
+ > `PollCard.jsx`'s option row uses `justifyContent: space-between` with no `spacing`, inside a message
108
+ > bubble that is `width: fit-content` — so there is never leftover space for space-between to
109
+ > distribute, and the option's label text runs directly into its vote-count/percent text with zero
110
+ > gap. Fix: add `spacing` to that row, and add a sensible `minWidth` to the poll's `Paper` (reconciled
111
+ > against `MessageBubble.jsx`'s `maxWidth: min(75%, 680px)` ceiling, verified at 375px too). No
112
+ > percentage/vote-count logic changes — that part is already correct.
113
+
114
+ ## PROGRESS CONTRACT
115
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
116
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
117
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.