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
26 changes: 26 additions & 0 deletions .changeset/tidy-rails-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@qorpe/ui": patch
---

fix(app-shell): the head joins the icon column instead of sitting above it

Collapsed, the head is now ONE rail item: the mark at rest, the expand chevron under the pointer
or under keyboard focus, in the same 36x40 box as every item below. That slot's action never
changes — it expands, whichever glyph it is showing — so the swap is an affordance rather than a
mode, and a device with no hover still gets a mark it can tap.

This replaces what was there: the mark and the toggle side by side in the 50px the rail leaves
between its own padding, both `shrink-0` and together wanting about 70px, so `justify-center`
split the overflow and put the mark some 15px off the column every nav row centres on. Stacking
them fixed the centring but cost a row, which made the head the only part of the rail whose
height moved. One slot costs neither.

Going home is not on that slot. A rail already showing its nav has its own home item a row or two
down, and a second hidden action on a control whose glyph changes under the cursor is a guess.

Collapsed with no mark, the toggle keeps its own slot and tooltip — it just gets the column's
36x40 now instead of a 30px box of its own.

Expanded, the brand head faded on hover — the only hover in the rail that was not a background
change. It now takes the same rounded box, wash and `px-2.5` as the items under it, and the head
gives up its own `px-1` so the two washes start on the same line rather than four pixels apart.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ node_modules
dist
coverage
.vite

# Playwright writes actual/diff PNGs here on every run, update-visual.sh included — artefacts
# of a run, never inputs to one.
test-results/
playwright-report/
56 changes: 56 additions & 0 deletions src/components/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,62 @@ describe("the v1.1 rail (u7-b1)", () => {
expect(onHome).toHaveBeenCalledTimes(1);
});

it("collapsed, the mark and the expand control share ONE slot — the head never gains a row", async () => {
const onToggleCollapsed = vi.fn();
const onHome = vi.fn();
render(
<AppShell brand={<svg data-testid="mark" />} title="Mockifyr" nav={[item("a")]} activeId="a" collapsed
onHome={onHome} onToggleCollapsed={onToggleCollapsed}>x</AppShell>,
);
// Exactly one control, holding the mark. A second would push the icon column down a row and
// make the head the only part of the rail whose height moves between states.
const slot = screen.getByRole("button", { name: /expand navigation/i });
expect(screen.getAllByRole("button", { name: /expand navigation/i })).toHaveLength(1);
expect(slot).toContainElement(screen.getByTestId("mark"));

// Its action does not depend on the pointer being over it — which is what makes the glyph
// swap an affordance rather than a mode, and what keeps it working on a device with no hover.
await userEvent.click(slot);
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
expect(onHome).not.toHaveBeenCalled();
});

it("collapsed with no way to expand, the mark is just the mark", () => {
render(
<AppShell brand={<svg data-testid="mark" />} title="Mockifyr" nav={[item("a")]} activeId="a" collapsed>x</AppShell>,
);
expect(screen.getByTestId("mark")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /expand navigation/i })).not.toBeInTheDocument();
});

it("collapsed, the toggle takes a rail item's slot instead of its own smaller one", () => {
const slot = (el: HTMLElement) =>
el.className.split(" ").filter((c) => ["h-9", "w-10", "rounded-lg"].includes(c)).sort().join(" ");
const { rerender } = render(
<AppShell title="t" nav={[item("a")]} activeId="a" collapsed onToggleCollapsed={() => {}}>x</AppShell>,
);
// The measure is the nav item beside it, not a literal — the two must agree, whatever they are.
expect(slot(screen.getByRole("button", { name: /expand navigation/i })))
.toBe(slot(screen.getByRole("button", { name: "a" })));

// Expanded it is not in that column at all, so it keeps its own smaller affordance.
rerender(<AppShell title="t" nav={[item("a")]} activeId="a" onToggleCollapsed={() => {}}>x</AppShell>);
expect(screen.getByRole("button", { name: /collapse navigation/i }).className).toContain("p-1.5");
});

it("expanded, the brand head hovers like the items under it, not like a fading link", () => {
render(
<AppShell brand={<svg data-testid="mark" />} title="Mockifyr" nav={[item("a"), item("b")]} activeId="a" onHome={() => {}}>x</AppShell>,
);
const head = screen.getByRole("button", { name: /Mockifyr/ });
// The RESTING item, not the active one — the active row wears the accent instead of a hover.
const navItem = screen.getByRole("button", { name: "b" });
// The measure is the item beside it: whatever the rail's hover is, the head must use it.
for (const c of ["hover:bg-muted", "rounded-lg", "px-2.5"]) expect(navItem.className).toContain(c);
for (const c of ["hover:bg-muted", "rounded-lg", "px-2.5"]) expect(head.className).toContain(c);
expect(head.className).not.toContain("hover:opacity-70");
});

it("a count badge is NEUTRAL unless the console says it is alarming", () => {
render(
<AppShell
Expand Down
68 changes: 54 additions & 14 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,50 @@ export function AppShell({
else groups.push({ name: item.group, items: [item] });
}

// Collapsed, the head is ONE slot: the mark at rest, the expand chevron under the pointer, in
// the same 36x40 box every item below occupies. Two controls stacked there would push the icon
// column down by a row and make the head the only part of the rail whose height moves.
//
// That slot's ACTION never changes — it expands, whether it is showing the mark or the chevron —
// so the swap is an affordance and not a mode. A touch device, which has no hover at all, still
// gets a mark it can tap to open the rail, and the accessible name says "expand" throughout.
//
// Going home is not on it. A rail that is already showing its nav has a Dashboard item two rows
// down; a hidden second action on a control whose glyph changes under the cursor is a guess.
const collapsedHead = collapsed && brand && onToggleCollapsed ? (
<Tooltip label={text.expand} side="right">
<button
aria-label={text.expand}
aria-expanded={false}
onClick={onToggleCollapsed}
className="group relative flex h-9 w-10 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-muted"
>
<span className="flex items-center transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 [&>img]:w-7 [&>svg]:w-7">{brand}</span>
<ChevronsRight
size={18}
aria-hidden="true"
className="absolute text-faint opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
</Tooltip>
) : null;

// The standalone toggle survives for the cases the swap cannot cover: expanded, where it is a
// small affordance at the head's right edge, and collapsed with no mark to swap.
const toggleButton = onToggleCollapsed && !collapsedHead && (
<button
aria-label={collapsed ? text.expand : text.collapse}
aria-expanded={!collapsed}
className={`shrink-0 rounded-lg text-faint transition-colors hover:bg-muted hover:text-foreground ${collapsed ? "flex h-9 w-10 items-center justify-center" : "p-1.5"}`}
onClick={onToggleCollapsed}
>
{collapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={16} aria-hidden="true" />}
</button>
);
const railToggle = collapsed && toggleButton
? <Tooltip label={text.expand} side="right">{toggleButton}</Tooltip>
: toggleButton;

return (
<div data-testid="app-shell" className="flex h-dvh overflow-hidden bg-app">
<aside
Expand All @@ -150,39 +194,35 @@ export function AppShell({
aria-label={text.sections}
className="flex h-full flex-col overflow-hidden px-3 pb-3"
>
<div className={`flex items-center py-4 ${collapsed ? "justify-center" : "justify-between px-1"}`}>
{/* Collapsed the head holds exactly one 36x40 slot, so it neither overflows the 50px
the rail leaves nor changes height between states. Everything the head has to say
collapsed is said by that one square. */}
<div className={`flex items-center py-4 ${collapsed ? "justify-center" : "justify-between"}`}>
{/* Collapsed, the mark is all that is left of the head — a rail with no words
and no mark is an anonymous gutter, which is why it survives the collapse
while the words do not. */}
{collapsed
? brand && <span className="flex shrink-0 items-center">{brand}</span>
? collapsedHead ?? (brand && (
<span className="flex h-9 w-10 shrink-0 items-center justify-center [&>img]:w-7 [&>svg]:w-7">{brand}</span>
))
: onHome ? (
<button onClick={onHome} className="flex min-w-0 items-center gap-2.5 rounded-lg text-start transition-opacity hover:opacity-70">
<button onClick={onHome} className="flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-1 text-start transition-colors hover:bg-muted">
{brand && <span className="flex shrink-0 items-center">{brand}</span>}
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{title}</span>
{subtitle && <span className="block truncate text-xs text-faint">{subtitle}</span>}
</span>
</button>
) : (
<span className="flex min-w-0 items-center gap-2.5">
<span className="flex min-w-0 items-center gap-2.5 px-2.5 py-1">
{brand && <span className="flex shrink-0 items-center">{brand}</span>}
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{title}</span>
{subtitle && <span className="block truncate text-xs text-faint">{subtitle}</span>}
</span>
</span>
)}
{onToggleCollapsed && (
<button
aria-label={collapsed ? text.expand : text.collapse}
aria-expanded={!collapsed}
className="shrink-0 rounded-lg p-1.5 text-faint transition-colors hover:bg-muted hover:text-foreground"
onClick={onToggleCollapsed}
>
{collapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={16} aria-hidden="true" />}
</button>
)}
{railToggle}
</div>

{onSearch && (
Expand Down
4 changes: 0 additions & 4 deletions test-results/.last-run.json

This file was deleted.

Binary file modified visual/__screenshots__/shell-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual/__screenshots__/shell-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual/__screenshots__/shell-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading