fix(ui): tint the active sidebar icon lightly instead of filling it solid (MUL-8351) (#9660)

Reverts the solid fill from 95294b1 back to the 20% body tint.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiayuan Zhang
2026-10-29 01:49:35 +08:00
committed by GitHub
co-authored by Cursor multica-agent
parent ce565c1aa4
commit d5d1382b8f
5 changed files with 16 additions and 19 deletions
+3 -3
View File
@@ -36,10 +36,10 @@ Every icon in the product is a Multica drawing exported from `@multica/ui/icons`
## Body and details
- Mark the main closed silhouette with `class="b"` (body). The active sidebar item fills it solid.
- Mark the main closed silhouette with `class="b"` (body). The active sidebar item fills it at 20% opacity.
- Any icon that can appear in navigation must have a body. A test enforces this for route icons.
- Only mark closed shapes. An open arc with a fill closes into a stray chord.
- Mark every stroke or dot that sits inside the body with `class="k"` (detail). The solid state knocks details out in the background colour; an unmarked detail disappears into the fill.
- Mark strokes that sit inside the body with `class="k"` (detail).
- **Detail budget.** At most two inner details. Every detail must carry meaning: a check, a symbol, a divider that defines the object. Drop decorative highlights, extra lines and texture. Icons render at 14px in dense rows, where a third detail turns into noise.
## Loop
@@ -48,7 +48,7 @@ Every icon in the product is a Multica drawing exported from `@multica/ui/icons`
2. Run `pnpm --filter @multica/ui icons --preview NewName Neighbour1 Neighbour2`. Open the printed HTML file in a browser and check four things:
- At 48px: does the geometry sit on the grid, with even optical weight next to its neighbours?
- At 18px and 14px: is it still legible, or do inner details merge?
- In the solid (active) tile: does the silhouette fill, with every inner detail still visible?
- In the tinted (active) tile: does only the silhouette fill?
- Against similar glyphs: can it be told apart at a glance?
3. Run `pnpm --filter @multica/ui icons` to regenerate `glyphs.ts` and `index.ts`.
4. Import the new name from `@multica/ui/icons`. Then run `pnpm --filter @multica/ui test` and the typecheck for the package that uses it.
+1 -1
View File
@@ -761,7 +761,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-body ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-accent data-active:[&_svg_.icon-body]:fill-current data-active:[&_svg_.icon-cut]:stroke-sidebar-accent data-active:[&_svg_.icon-cut-fill]:fill-sidebar-accent [&_svg]:size-4.5 [&_svg]:shrink-0 [&>span:last-child]:truncate",
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-body ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-accent data-active:[&_svg_.icon-body]:fill-current data-active:[&_svg_.icon-body]:[fill-opacity:0.2] [&_svg]:size-4.5 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
+6 -9
View File
@@ -33,9 +33,9 @@ export function renderGlyphs() {
'import type { IconNode } from "./create-icon";',
"",
"// Generated by drawings/generate.mjs from drawings/drawings.mjs; do not edit.",
"// `icon-body` marks the main silhouette, which the active sidebar item fills",
"// solid; `icon-cut` / `icon-cut-fill` mark the stroked / filled details inside",
"// it, which that state knocks out in the background colour.",
"// `icon-body` marks the main silhouette, which the active sidebar item tints",
"// with a light fill; `icon-cut` / `icon-cut-fill` mark the stroked / filled",
"// details inside it.",
"",
...Object.keys(DRAWINGS)
.sort()
@@ -79,17 +79,14 @@ export const OUTPUTS = {
"index.ts": renderIndex,
};
/** Contact sheet of `names` (default: all) at 48, 18 and 14px, plus the solid active-sidebar state. */
/** Contact sheet of `names` (default: all) at 48, 18 and 14px, plus the active-sidebar tint. */
export function renderPreview(names = Object.keys(DRAWINGS)) {
const svg = (body, px) =>
`<svg width="${px}" height="${px}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const tiles = names.map((n) => {
if (!DRAWINGS[n]) throw new Error(`no drawing named ${n}`);
const solid = DRAWINGS[n]
.replace(/class="b"/g, 'class="b" fill="currentColor"')
.replace(/class="k"([^>]*?)fill="currentColor"/g, 'class="k"$1fill="#ececef"')
.replace(/class="k"(?![^>]*fill="#)/g, 'class="k" stroke="#ececef"');
return `<figure>${svg(DRAWINGS[n], 48)}<div>${svg(DRAWINGS[n], 18)}${svg(DRAWINGS[n], 14)}<b>${svg(solid, 18)}</b></div><figcaption>${n}</figcaption></figure>`;
const tinted = DRAWINGS[n].replace(/class="b"/g, 'class="b" fill="currentColor" fill-opacity=".2"');
return `<figure>${svg(DRAWINGS[n], 48)}<div>${svg(DRAWINGS[n], 18)}${svg(DRAWINGS[n], 14)}<b>${svg(tinted, 18)}</b></div><figcaption>${n}</figcaption></figure>`;
});
return `<!doctype html><meta charset="utf-8"><style>
body{margin:0;padding:20px;font:11px system-ui;color:#27272a;display:grid;grid-template-columns:repeat(auto-fill,110px);gap:8px}
+3 -3
View File
@@ -1,9 +1,9 @@
import type { IconNode } from "./create-icon";
// Generated by drawings/generate.mjs from drawings/drawings.mjs; do not edit.
// `icon-body` marks the main silhouette, which the active sidebar item fills
// solid; `icon-cut` / `icon-cut-fill` mark the stroked / filled details inside
// it, which that state knocks out in the background colour.
// `icon-body` marks the main silhouette, which the active sidebar item tints
// with a light fill; `icon-cut` / `icon-cut-fill` mark the stroked / filled
// details inside it.
export const Activity: IconNode = [["path",{"d":"M2.75 12h3.5l2.75-7 6 14 2.75-7h3.5"}]];
export const AlarmClock: IconNode = [["circle",{"cx":"12","cy":"13","r":"7.75","className":"icon-body"}],["path",{"d":"M12 9.5V13l2.25 1.5","className":"icon-cut"}],["path",{"d":"M3 5.75 5.75 3M21 5.75 18.25 3"}]];
@@ -22,9 +22,9 @@ describe("routeIconForPath", () => {
expect(routeIconForPath(p.autopilots())).not.toBe(routeIconForPath(p.issues()));
});
// The active sidebar item fills the glyph's `icon-body` solid; without one
// the active state loses its fill.
it.each(Object.entries(ROUTE_ICON_COMPONENTS))("%s marks a body for the solid active state", (_, Icon) => {
// The active sidebar item tints the glyph's `icon-body`; without one the
// active state loses its fill.
it.each(Object.entries(ROUTE_ICON_COMPONENTS))("%s marks a body for the active tint", (_, Icon) => {
const { container } = render(<Icon />);
expect(container.querySelector("svg .icon-body")).not.toBeNull();
});