diff --git a/docs/superpowers/specs/2026-06-12-sidebar-icons-design.md b/docs/superpowers/specs/2026-06-12-sidebar-icons-design.md new file mode 100644 index 0000000..aa7e787 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-sidebar-icons-design.md @@ -0,0 +1,52 @@ +# Sidebar icon refresh — design + +**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens) + +## Problem + +All 31 sidebar items have icons, but 13 share a glyph with another item +(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury +uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat +the purpose of icons as unique visual anchors. + +## Decisions (user-approved) + +1. **Full refresh** of all items (option B over targeted fix). +2. **Tile language (option B of 3 rendered directions):** every item's glyph + sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a + **soft color wash — one muted hue per menu section**: + Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` · + Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`. +3. **Odin stays unique:** the only **solid** (gradient) tile and the only + animated icon. No animation anywhere else. Red stays reserved for + Odin/primary. +4. **Glyph set** approved on the full-set screen: receipt for Faktury, + business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování, + ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for + Inventura, shelf rack for Lokace, arrows into/out of a box for + Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for + Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti, + timesheet for Moje historie (docházka), shapes for Kategorie, chart frame + for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique + in the set): clock (Záznam docházky), calendar (Plán prací), people + (Uživatelé), gear (Nastavení), box (Položky). + +## Implementation + +- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark + mains, full main/light/dark/contrastText so cssVariables emits `*Channel` + tokens) + TS module augmentation. +- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs + (stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for + Odin (its icon ships its own tile). +- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor: +rgba(.mainChannel / 0.12)`, glyph color `.main`, svg 16 px) + around every non-`rawIcon` item. No hardcoded hex; washes are + channel-alpha so both schemes work. +- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique + static markup (no two items may ever share a glyph again). + +## Out of scope + +Animation for non-Odin icons (explicitly rejected), page-level icons, +mobile drawer changes (it reuses SidebarNav). diff --git a/package-lock.json b/package-lock.json index 973a93a..a4470d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "2.4.31", + "version": "2.4.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "2.4.31", + "version": "2.4.32", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.102.0", diff --git a/package.json b/package.json index 4a14e93..ea2f5d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "2.4.31", + "version": "2.4.32", "description": "", "main": "dist/server.js", "scripts": { diff --git a/src/__tests__/navdata-icons.test.ts b/src/__tests__/navdata-icons.test.ts new file mode 100644 index 0000000..6b96f09 --- /dev/null +++ b/src/__tests__/navdata-icons.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { isValidElement, type ReactElement } from "react"; +import { menuSections, type MenuItem } from "../admin/ui/navData"; + +/** + * Pins the sidebar icon-refresh contract (2026-06-12 design): every menu + * item has a glyph, and no two items share one — duplicated glyphs were the + * whole problem the refresh fixed (3× "people", 3× "sliders", …). Odin's + * rawIcon (OdinMark, MUI-styled + animated) is exempt from rendering here + * but still must exist and stay the ONLY rawIcon. + */ + +const allItems: Array = menuSections.flatMap( + (s) => s.items.map((item) => ({ section: s.label, ...item })), +); + +describe("sidebar nav icons", () => { + it("every item has an icon and a section hue", () => { + for (const section of menuSections) { + expect(section.hue, `section ${section.label}`).toBeTruthy(); + } + for (const item of allItems) { + expect(isValidElement(item.icon), `${item.section}/${item.label}`).toBe( + true, + ); + } + }); + + it("Odin is the only rawIcon (its own tile + animation stay unique)", () => { + const raw = allItems.filter((i) => i.rawIcon); + expect(raw.map((i) => i.label)).toEqual(["Odin"]); + }); + + it("no two items share a glyph", () => { + const rendered = allItems + .filter((i) => !i.rawIcon) + .map((item) => ({ + key: `${item.section}/${item.label}`, + markup: renderToStaticMarkup(item.icon as ReactElement), + })); + + const seen = new Map(); + for (const { key, markup } of rendered) { + const dupe = seen.get(markup); + expect(dupe, `${key} shares its glyph with ${dupe}`).toBeUndefined(); + seen.set(markup, key); + } + expect(seen.size).toBe(rendered.length); + }); +}); diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index 00f9911..72e03b1 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -389,7 +389,17 @@ export default function OrderDetail() { mb: 3, }} > - + {/* flexWrap: the title must drop below the Zpět button on phones + instead of overflowing the viewport (same as Offer/Invoice + detail headers). */} + - + - Objednávka {order.order_number} + Objednávka + + {order.order_number} + - + {/* flexWrap: the title must drop below the Zpět button on phones + instead of being squeezed beside it (mirrors Offer/Invoice + detail headers). */} + - + - Projekt {project.project_number} + Projekt + + {project.project_number} + .mainChannel is how +// the nav tiles build their alpha washes. +type NavPaletteColor = PaletteColor & PaletteColorChannel; + +declare module "@mui/material/styles" { + interface Palette { + teal: NavPaletteColor; + violet: NavPaletteColor; + slate: NavPaletteColor; + } + interface PaletteOptions { + teal?: PaletteOptions["primary"]; + violet?: PaletteOptions["primary"]; + slate?: PaletteOptions["primary"]; + } +} + export type IconBadgeColor = | "default" | "primary" diff --git a/src/admin/ui/SidebarNav.tsx b/src/admin/ui/SidebarNav.tsx index 0f75263..1b869fd 100644 --- a/src/admin/ui/SidebarNav.tsx +++ b/src/admin/ui/SidebarNav.tsx @@ -88,9 +88,8 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) { sx={{ borderRadius: 2, mb: 0.25, - py: 0.6, + py: 0.45, color: "text.secondary", - "& svg": { width: 18, height: 18 }, "&.Mui-selected, &.Mui-selected:hover": { bgcolor: "background.paper", color: "primary.main", @@ -99,8 +98,29 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) { }, }} > - - {item.icon} + + {item.rawIcon ? ( + item.icon + ) : ( + ({ + width: 26, + height: 26, + // Echoes OdinMark's rounded-square tile; only Odin + // is solid + animated, these are quiet washes. + borderRadius: "32%", + flexShrink: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + bgcolor: `rgba(${t.vars!.palette[section.hue].mainChannel} / 0.12)`, + color: t.vars!.palette[section.hue].main, + "& svg": { width: 16, height: 16 }, + })} + > + {item.icon} + + )} + {children} + + ); +} + export const menuSections: MenuSection[] = [ { label: "Přehled", + hue: "primary", items: [ { path: "/", label: "Přehled", end: true, - icon: ( - - - - - - + // tachometr — dashboard + icon: glyph( + <> + + + , ), }, { @@ -44,78 +71,66 @@ export const menuSections: MenuSection[] = [ label: "Odin", permission: "ai.use", end: true, - icon: , + icon: , + rawIcon: true, }, ], }, { label: "Docházka", + hue: "info", items: [ { path: "/attendance", label: "Záznam", permission: "attendance.record", end: true, - icon: ( - + // píchačky — hodiny + icon: glyph( + <> - + , ), }, { path: "/attendance/history", label: "Moje historie", permission: "attendance.history", - icon: ( - - - - + // výkaz s hodinami — timesheet + icon: glyph( + <> + + + + + + , ), }, { path: "/attendance/requests", label: "Žádosti", permission: "attendance.record", - icon: ( - - - - - - + // odeslaná žádost — vlaštovka + icon: glyph( + <> + + + , ), }, { path: "/attendance/approval", label: "Schvalování", permission: "attendance.approve", - icon: ( - - - - + // razítko + icon: glyph( + <> + + + + , ), }, { @@ -124,147 +139,108 @@ export const menuSections: MenuSection[] = [ permission: "attendance.manage", matchPrefix: "/attendance/admin", matchAlso: ["/attendance/create", "/attendance/location"], - icon: ( - - - - - - - - - - - + // osoba + fajfka + icon: glyph( + <> + + + + , ), }, { path: "/attendance/balances", label: "Správa bilancí", permission: "attendance.balances", - icon: ( - - - - - + // kniha plus/mínus — ruční korekce bilancí + icon: glyph( + <> + + + + + , ), }, { path: "/plan-work", label: "Plán prací", permission: ["attendance.manage", "attendance.record"], - icon: ( - - + // kalendář s plánovacím blokem + icon: glyph( + <> + - - + + + , ), }, ], }, { label: "Kniha jízd", + hue: "teal", items: [ { path: "/trips", label: "Záznam", permission: "trips.record", end: true, - icon: ( - - - - - - + // trasa A→B + icon: glyph( + <> + + + + , ), }, { path: "/trips/history", label: "Moje historie", permission: "trips.history", - icon: ( - - - - + // navštívená místa — pin + icon: glyph( + <> + + + , ), }, { path: "/trips/admin", label: "Správa", permission: "trips.manage", - icon: ( - - - - - - - - - - - + // schránka s fajfkou — kniha jízd + icon: glyph( + <> + + + + , ), }, { path: "/vehicles", label: "Vozidla", permission: "vehicles.manage", - icon: ( - - - - - - + // auto + icon: glyph( + <> + + + + + , ), }, ], }, { label: "Administrativa", + hue: "warning", items: [ { path: "/offers", @@ -272,16 +248,12 @@ export const menuSections: MenuSection[] = [ permission: "offers.view", matchPrefix: "/offers", matchExclude: ["/offers/customers", "/offers/templates"], - icon: ( - - - - + // cenovka + icon: glyph( + <> + + + , ), }, { @@ -289,17 +261,13 @@ export const menuSections: MenuSection[] = [ label: "Objednávky", permission: "orders.view", matchPrefix: "/orders", - icon: ( - - - - - + // košík + icon: glyph( + <> + + + + , ), }, { @@ -307,16 +275,13 @@ export const menuSections: MenuSection[] = [ label: "Faktury", permission: "invoices.view", matchPrefix: "/invoices", - icon: ( - - - - + // účtenka + icon: glyph( + <> + + + + , ), }, { @@ -324,60 +289,44 @@ export const menuSections: MenuSection[] = [ label: "Projekty", permission: "projects.view", matchPrefix: "/projects", - icon: ( - - - - - + // složka (NAS projektové složky) + icon: glyph( + , ), }, { path: "/offers/customers", label: "Zákazníci", permission: "customers.view", - icon: ( - - - - - - + // vizitka + icon: glyph( + <> + + + + + + , ), }, ], }, { label: "Sklad", + hue: "violet", items: [ { path: "/warehouse", label: "Přehled", permission: "warehouse.view", end: true, - icon: ( - - - - - + // skladová hala + icon: glyph( + <> + + + + , ), }, { @@ -385,17 +334,13 @@ export const menuSections: MenuSection[] = [ label: "Položky", permission: "warehouse.view", matchPrefix: "/warehouse/items", - icon: ( - + // krabice + icon: glyph( + <> - + , ), }, { @@ -403,17 +348,13 @@ export const menuSections: MenuSection[] = [ label: "Příjmy", permission: "warehouse.operate", matchPrefix: "/warehouse/receipts", - icon: ( - - - - - + // šipka do krabice + icon: glyph( + <> + + + + , ), }, { @@ -421,17 +362,13 @@ export const menuSections: MenuSection[] = [ label: "Výdeje", permission: "warehouse.operate", matchPrefix: "/warehouse/issues", - icon: ( - - - - - + // šipka z krabice ven + icon: glyph( + <> + + + + , ), }, { @@ -439,18 +376,12 @@ export const menuSections: MenuSection[] = [ label: "Rezervace", permission: "warehouse.operate", matchPrefix: "/warehouse/reservations", - icon: ( - - - - - - + // krabice se záložkou + icon: glyph( + <> + + + , ), }, { @@ -458,16 +389,15 @@ export const menuSections: MenuSection[] = [ label: "Inventura", permission: "warehouse.inventory", matchPrefix: "/warehouse/inventory", - icon: ( - - - - + // čárový kód — fyzický sken zásob + icon: glyph( + <> + + + + + + , ), }, { @@ -475,17 +405,12 @@ export const menuSections: MenuSection[] = [ label: "Reporty", permission: "warehouse.view", end: true, - icon: ( - - - - - + // graf v rámu + icon: glyph( + <> + + + , ), }, { @@ -493,23 +418,14 @@ export const menuSections: MenuSection[] = [ label: "Kategorie", permission: "warehouse.manage", matchPrefix: "/warehouse/categories", - icon: ( - - - - - - - - - - - + // tvary — třídění do kategorií + icon: glyph( + <> + + + + + , ), }, { @@ -517,18 +433,16 @@ export const menuSections: MenuSection[] = [ label: "Lokace", permission: "warehouse.manage", matchPrefix: "/warehouse/locations", - icon: ( - - - - - - + // regál s krabicemi + icon: glyph( + <> + + + + + + + , ), }, { @@ -536,41 +450,34 @@ export const menuSections: MenuSection[] = [ label: "Dodavatelé", permission: "warehouse.manage", matchPrefix: "/warehouse/suppliers", - icon: ( - - - - - - + // kamion + icon: glyph( + <> + + + + + , ), }, ], }, { label: "Systém", + hue: "slate", items: [ { path: "/users", label: "Uživatelé", permission: "users.view", - icon: ( - + // lidé — uživatelské účty + icon: glyph( + <> - + , ), }, { @@ -583,35 +490,27 @@ export const menuSections: MenuSection[] = [ "settings.system", "settings.templates", ], - icon: ( - + // ozubené kolo + icon: glyph( + <> - + , ), }, { path: "/audit-log", label: "Audit log", permission: "settings.audit", - icon: ( - - - - - - - + // lupa nad záznamy + icon: glyph( + <> + + + + + + , ), }, ], diff --git a/tsconfig.test.json b/tsconfig.test.json index 894715f..2d1ebf8 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,6 +4,7 @@ "module": "commonjs", "lib": ["ES2022"], "types": ["node", "vitest/globals"], + "jsx": "react-jsx", "moduleResolution": "node", "noEmit": true, "strict": true,