diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 8e4c1be8df..a76f927f2e 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -1555,6 +1555,7 @@ short-landscape variant (FN-5751 lesson: never a desktop-only or portrait-only f opacity: 1; width: 28px; height: 28px; + line-height: 1; border-radius: var(--radius-md); } @@ -1822,11 +1823,21 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge min-height: var(--card-chip-height-mobile); } + /* + FNXC:TaskCardLayout 2026-07-13-00:00: + FN-7933 follows FN-7928 by keeping the Send-back/Actions trigger, ⋯ menu button, and size badge on one optical vertical center inside the header actions at the mobile breakpoint. Preserve FN-7889's cluster↔id nudge, FN-7862's flex-start header anchor, FN-7837's badge-wrap/size-chip grouping, FN-7928's desktop centering, and FN-4351's no-min-height mobile rule for the send-back/menu buttons. + */ .card-header-actions { + align-items: center; gap: calc(var(--space-xs) / 2); flex-shrink: 0; } + .card-menu-btn, + .card-size-badge { + line-height: 1; + } + /* FN-4351/FN-3965: keep secondary actions visible on touch, but compact per WCAG 2.5.8 because the card tap surface is the primary target for opening task detail. */ .card-archive-btn, .card-unarchive-btn, @@ -1836,6 +1847,7 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge .card-send-back-btn { opacity: 1; + line-height: 1; } .card-promote-action.card-send-back-btn { @@ -1900,6 +1912,10 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge padding: calc(var(--space-xs) / 4) calc((var(--space-xs) * 3) / 2); } + .card-size-badge { + padding-block: calc((var(--space-xs) / 4) + var(--btn-border-width)); + } + .card-stalled-review-reason { font-size: 0.625rem; -webkit-line-clamp: 3; diff --git a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx index 4f0293c5a8..7e3c23cc24 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx @@ -82,6 +82,54 @@ function expectSharedHeaderBaseline(container: HTMLElement) { expect(actionsStyles.flex).toBe("0 0 auto"); } +function getCssBlocks(css: string, atRuleFragment: string): string[] { + const re = /@media[^{}]*\{/g; + const blocks: string[] = []; + + for (const match of css.matchAll(re)) { + if (!match[0].includes(atRuleFragment)) continue; + const start = match.index! + match[0].length; + let depth = 1; + let i = start; + while (i < css.length && depth > 0) { + const ch = css[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + blocks.push(css.slice(start, i - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks; +} + +function getCssRuleBodies(section: string, selectorFragment: string): string[] { + const bodies: string[] = []; + const pattern = /([^{}]+)\{([\s\S]*?)\}/g; + + for (const match of section.matchAll(pattern)) { + if (match[1].includes(selectorFragment)) { + bodies.push(match[2]); + } + } + + expect(bodies.length, `Expected CSS rule for ${selectorFragment}`).toBeGreaterThan(0); + return bodies; +} + +function expectCssRuleToContain(section: string, selectorFragment: string, declaration: string): void { + const bodies = getCssRuleBodies(section, selectorFragment); + expect(bodies.some((body) => body.includes(declaration)), `${selectorFragment} should include ${declaration}`).toBe(true); +} + +function expectCssRuleNotToContain(section: string, selectorFragment: string, declaration: string): void { + const bodies = getCssRuleBodies(section, selectorFragment); + for (const body of bodies) { + expect(body, `${selectorFragment} should not include ${declaration}`).not.toContain(declaration); + } +} + function expectHeaderActionsControlCenterline(container: HTMLElement, expected: { sendBack?: boolean; menu?: boolean; @@ -404,21 +452,65 @@ describe("TaskCard badge wrapping (FN-5162)", () => { expectSharedHeaderBaseline(sizeAbsentContainer); expectHeaderActionsControlCenterline(sizeAbsentContainer, { sendBack: true, menu: true }); + + const { container: awaitingInputContainer } = render( + makeTask()} + />, + ); + + expect(awaitingInputContainer.querySelector(".card-answer-questions-btn")).toBeTruthy(); + expectSharedHeaderBaseline(awaitingInputContainer); + expectHeaderActionsControlCenterline(awaitingInputContainer, { sendBack: true, menu: true, size: true }); }); it("keeps the centered-id nudge and mobile header rhythm tokenized with the badge-wrap contract", () => { + const cardHeaderRule = loadedCss.match(/\.card-header\s*\{(?[^}]*)\}/)?.groups?.body ?? ""; const cardIdRule = loadedCss.match(/\.card-id\s*\{(?[^}]*)\}/)?.groups?.body ?? ""; const actionsRule = loadedCss.match(/\.card-header-actions\s*\{(?[^}]*)\}/)?.groups?.body ?? ""; + expect(cardHeaderRule).toContain("align-items: flex-start;"); expect(cardIdRule).toContain("min-height: var(--card-chip-height);"); expect(cardIdRule).toContain("line-height: 1;"); expect(cardIdRule).toContain("transform: translateY(calc(var(--space-xs) / 4));"); expect(cardIdRule).not.toMatch(/translateY\(\d/); + expect(actionsRule).toContain("align-items: center;"); expect(actionsRule).toContain("transform: translateY(calc(var(--space-xs) / 4));"); expect(actionsRule).not.toMatch(/translateY\(\d/); expect(loadedCss).toContain(".card-id,\n .card-header-badges,\n .card-header-actions"); expect(loadedCss).toContain("min-height: var(--card-chip-height-mobile);"); }); + it("locks the mobile Send back, menu, and size controls to one header-actions centerline", () => { + const mobileSection = getCssBlocks(loadedCss, "max-width: 768px").join("\n"); + const menuTouchSection = getCssBlocks(loadedCss, "max-height: 480px").join("\n"); + + expectCssRuleToContain(mobileSection, ".card-header-actions", "min-height: var(--card-chip-height-mobile);"); + expectCssRuleToContain(mobileSection, ".card-header-actions", "align-items: center;"); + expectCssRuleToContain(mobileSection, ".card-header-actions", "gap: calc(var(--space-xs) / 2);"); + expectCssRuleToContain(mobileSection, ".card-send-back-btn", "line-height: 1;"); + expectCssRuleToContain(mobileSection, ".card-menu-btn", "line-height: 1;"); + expectCssRuleToContain(mobileSection, ".card-size-badge", "line-height: 1;"); + expectCssRuleToContain(mobileSection, ".card-size-badge", "font-size: 0.5625rem;"); + expectCssRuleToContain(mobileSection, ".card-size-badge", "padding: calc(var(--space-xs) / 4) calc((var(--space-xs) * 3) / 2);"); + expectCssRuleToContain(mobileSection, ".card-size-badge", "padding-block: calc((var(--space-xs) / 4) + var(--btn-border-width));"); + expectCssRuleNotToContain(mobileSection, ".card-send-back-btn", "min-height:"); + expectCssRuleNotToContain(mobileSection, ".card-menu-btn", "min-height:"); + expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "width: 28px;"); + expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "height: 28px;"); + expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "line-height: 1;"); + expectCssRuleToContain(menuTouchSection, ".card-menu-btn svg", "width: 16px;"); + expectCssRuleToContain(menuTouchSection, ".card-menu-btn svg", "height: 16px;"); + }); + it.each([ ".card-status-badge", ".card-priority-badge",