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
71 changes: 71 additions & 0 deletions docs/notes/morph-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Morph icons

<a id="tier1-glyph-refs"></a>

## Tier 1 glyphs are shared references

The morph wrapper and the compatibility spike consume the same arrays the
catalog components close over (`PinGlyph`, `PinOffGlyph`, `SquareGlyph`,
`CopyGlyph`). They are re-exported, never cloned: morphicons caches plans in a
WeakMap keyed by the icon reference, so a copy would silently disable the
cache.

Fonte: `src/chrome/icons.tsx:PinGlyph`

<a id="reduced-motion-user"></a>

## Reduced motion is always honored

The wrapper passes `reducedMotion="user"` unconditionally. The library default
(`"never"`) ignores the OS setting, which `docs/motion.md` forbids:
`prefers-reduced-motion: reduce` disables all motion. With `"user"`, a morph
degrades to the same instant swap the flag-off path renders.

Fonte: `src/chrome/icons.tsx:MorphIcon`

<a id="spring-snappy"></a>

## Spring choice

`snappy` (k = 420, c = 30) settles inside the 120–180 ms band the motion
tokens use. If it hitches on WebKitGTK, the fallback is `smooth`, recorded
here before the switch.

Fonte: `src/chrome/icons.tsx:MorphIcon`

<a id="mirror-persists-flag"></a>

## The flag persists through the boot mirror

`monocode.morphIconsEnabled` is mirrored like `monocode.experimentalAnimations`.
It is not read by the inline boot script (icons paint after React loads), but
the native settings file does not carry it, so the mirror is what keeps the
choice across launches. The write path is the store, never a direct
`localStorage.setItem`.

Fonte: `src/lib/settings/bootMirror.ts:BOOT_MIRROR_KEYS`

<a id="same-slot-tier1"></a>

## Tier 1 morphs the same slot only

Pin/pin-off and maximize/restore swap two stroke glyphs in one slot with equal
size and stroke width. Nothing else in this PR changes identity: the `Copy`
glyph stays the restore glyph, however odd that reads.

Fonte: `src/chrome/icons.tsx:MorphIcon`

<a id="icon-plan-tiers"></a>

## Icon plan: what follows and what never does

Tier 2 needs a decision first: the composer stop `Square` renders filled
(`fill-current`, `strokeWidth={0}`), and filled glyphs do not morph, so either
the stop button goes stroke-only (a visual change) or the pair is dropped.
Fold/unfold are custom inline glyphs that must pass the spike before use.

Tier 3 is out: different slots (pause/play), CSS hover swaps (sidebar
chevrons), static icons (jump-to-bottom chevron), continuous spinners, and a
copy-to-check toggle that does not exist in the code.

Fonte: `src/chrome/icons.tsx:MorphIcon`
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"cuelume": "^0.2.2",
"diff": "^9.0.0",
"mermaid": "^12.0.0",
"morphicons": "^1.7.1",
"prettier": "^3.9.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
Expand Down
146 changes: 146 additions & 0 deletions src/chrome/MorphIcon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MorphIcon } from "./icons";
import {
CopyGlyph,
Pin,
PinGlyph,
PinOff,
PinOffGlyph,
SquareGlyph,
} from "./icons";

vi.mock("morphicons/react", () => ({
MorphIcon: (props: {
icon?: { readonly [0]?: { readonly [1]?: { d?: string } } } &
readonly unknown[];
reducedMotion?: unknown;
spring?: unknown;
}) => {
const first = props.icon?.[0] as
| readonly [string, { d?: string }]
| undefined;
return createElement("svg", {
"data-mock-morph": "",
"data-first-d": first?.[1]?.d ?? "",
"data-reduced-motion": String(props.reducedMotion),
"data-spring": String(props.spring),
});
},
}));

const FLAG_KEY = "monocode.morphIconsEnabled";

function seedFlag(value: string | null) {
const data = new Map<string, string>(
value == null ? [] : [[FLAG_KEY, value]],
);
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, next: string) => {
data.set(key, next);
},
removeItem: (key: string) => {
data.delete(key);
},
clear: () => {
data.clear();
},
},
configurable: true,
});
}

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
seedFlag(null);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

describe("MorphIcon", () => {
it("renders the same markup as the catalog icon with the flag off", () => {
for (const [icon, altIcon, showAlt, Catalog] of [
[PinGlyph, PinOffGlyph, false, Pin],
[PinGlyph, PinOffGlyph, true, PinOff],
] as const) {
expect(
renderToStaticMarkup(
createElement(MorphIcon, {
icon,
altIcon,
showAlt,
className: "size-3.5",
strokeWidth: 1.75,
}),
),
).toBe(
renderToStaticMarkup(
createElement(Catalog, {
className: "size-3.5",
strokeWidth: 1.75,
}),
),
);
}
});

it("morphs with reducedMotion=user and the snappy spring when on", async () => {
seedFlag("1");
await act(async () => {
root.render(
createElement(MorphIcon, {
icon: SquareGlyph,
altIcon: CopyGlyph,
showAlt: true,
className: "size-3",
strokeWidth: 1.75,
}),
);
});
const svg = container.querySelector("svg[data-mock-morph]");
expect(svg).not.toBeNull();
expect(svg?.getAttribute("data-reduced-motion")).toBe("user");
expect(svg?.getAttribute("data-spring")).toBe("snappy");
expect(svg?.getAttribute("data-first-d")).toBe(CopyGlyph[0][1].d);
});

it("swaps the endpoint when showAlt flips while on", async () => {
seedFlag("1");
const props = {
icon: SquareGlyph,
altIcon: CopyGlyph,
className: "size-3",
strokeWidth: 1.75,
};
const firstD = () =>
container.querySelector("svg[data-mock-morph]")?.getAttribute("data-first-d");

await act(async () => {
root.render(createElement(MorphIcon, { ...props, showAlt: false }));
});
expect(firstD()).toBe(SquareGlyph[0][1].d);

// Re-rendered on the same root, same instance. Without this the test passes
// on a component that read `showAlt` once and ignored it afterwards, which
// is the same shape as picking the right endpoint only at mount.
await act(async () => {
root.render(createElement(MorphIcon, { ...props, showAlt: true }));
});
expect(firstD()).toBe(CopyGlyph[0][1].d);
});
});
20 changes: 15 additions & 5 deletions src/chrome/ProjectRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
Search,
Settings,
Trash2,
PinGlyph,
PinOffGlyph,
MorphIcon,
} from "./icons";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useLocale, type Translate } from "../lib/locale";
Expand Down Expand Up @@ -1442,11 +1445,18 @@ function ProjectCard({
}}
className="absolute left-2 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-content/75 opacity-0 pointer-events-none transition-opacity hover:text-content group-hover:pointer-events-auto group-hover:opacity-100"
>
{pinned ? (
<PinOff className="size-3.5" strokeWidth={1.75} />
) : (
<Pin className="size-3.5" strokeWidth={1.75} />
)}
{/*
Same slot either way: the pin state only swaps which endpoint the
morph shows.
Nota: docs/notes/morph-icons.md#same-slot-tier1
*/}
<MorphIcon
icon={PinGlyph}
altIcon={PinOffGlyph}
showAlt={pinned}
className="size-3.5"
strokeWidth={1.75}
/>
</button>
</div>
);
Expand Down
19 changes: 13 additions & 6 deletions src/chrome/WindowControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Copy, Minus, Square, X } from "./icons";
import { CopyGlyph, Minus, MorphIcon, SquareGlyph, X } from "./icons";
import { useEffect, useState } from "react";

export function WindowControls() {
Expand Down Expand Up @@ -74,11 +74,18 @@ export function WindowControls() {
onClick={handleToggleMaximize}
className="flex w-10 items-center justify-center text-content/80 transition-colors hover:bg-content/10 hover:text-content"
>
{isMaximized ? (
<Copy className="size-3" strokeWidth={1.75} />
) : (
<Square className="size-3" strokeWidth={1.75} />
)}
{/*
The restore glyph stays `Copy`: odd, but inherited, and swapping the
glyph would change the visual identity. Only the transition is new.
Nota: docs/notes/morph-icons.md#same-slot-tier1
*/}
<MorphIcon
icon={SquareGlyph}
altIcon={CopyGlyph}
showAlt={isMaximized}
className="size-3"
strokeWidth={1.75}
/>
</button>
<button
type="button"
Expand Down
Loading
Loading