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
2 changes: 1 addition & 1 deletion apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ scripts/generate-favicon.ts # public/favicon.svg + favicon.png

Two icon libraries, each with one job. Pick by what the icon does, not by which one has a match.

- **Kubai Icons (`kubai-icons-react`) mark a destination or a primary action.** They are coloured, layered and animate on hover, so they belong where an icon names a place or a thing to do: the sidebar's sections (`src/app/shell/nav-items.ts`) and first steps (`src/app/shell/first-steps.ts`), the settings menu (`src/pages/settings/model/settings-nav-items.ts`), the phone tab bar (`src/app/shell/bottom-tab-bar.tsx`), the account menu's entries, and the theme and language switchers. The sync button keeps the Remix `RiRefreshLine`, like every other refresh control. Wrap one with `kubaiIcon(Icon, color)` from `@/shared/lib/kubai-icon` for an `IconComponent` slot. The colour is a `ChartColorVar`, one of the `CHART_COLOR_VARS` tokens in `@/shared/lib/chart-colors`, so it follows the theme and the type checker refuses a literal. A section keeps one icon and one colour everywhere it appears: Budget is a green `CurrencyDollar` in the settings menu and the first steps, and its Transactions page keeps that green on a `Receipt` in the sidebar and the tab bar.
- **Kubai Icons (`kubai-icons-react`) mark a destination or a primary action.** They are coloured, layered and animate on hover, so they belong where an icon names a place or a thing to do: the sidebar's sections (`src/app/shell/nav-items.ts`) and first steps (`src/app/shell/first-steps.ts`), the settings menu (`src/pages/settings/model/settings-nav-items.ts`), the phone tab bar (`src/app/shell/bottom-tab-bar.tsx`), the account menu's entries, and the theme and language switchers. The sync button keeps the Remix `RiRefreshLine`, like every other refresh control. Wrap one with `kubaiIcon(Icon, color)` from `@/shared/lib/kubai-icon` for an `IconComponent` slot, and render one directly as `HostAnimatedKubaiIcon`, never as the bare component: it animates when the surrounding button is hovered, focused or toggled, not only when the glyph itself is hovered. The colour is a `ChartColorVar`, one of the `CHART_COLOR_VARS` tokens in `@/shared/lib/chart-colors`, so it follows the theme and the type checker refuses a literal. A section keeps one icon and one colour everywhere it appears: Budget is a green `CurrencyDollar` in the settings menu and the first steps, and its Transactions page keeps that green on a `Receipt` in the sidebar and the tab bar.
- **Remix Icon (`@remixicon/react`) draws every glyph that is an affordance rather than a meaning:** chevrons and disclosure arrows, a submenu's `›`, the up/down selector, a button's trailing arrow, the external-link mark, the sidebar toggle, close, check, search, spinners, and every icon inside a `@freenary/ui` primitive (they come from `src/shared/lib/ff-icons.ts` through the `IconProvider`). These stay monochrome in `currentColor`. Kubai draws arrows and chevrons on a filled disc, which reads as a button next to text, so never use Kubai for one. Wrap a Remix icon with `remixIcon(Icon)` from `@/shared/lib/remix-icon` for an `IconComponent` slot.
- **Content icons stay on Remix too:** category icons (`entities/category`), row actions such as edit, delete, filter and bookmark, and status marks in notices.

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/shell/bottom-tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cn } from "@freenary/ui/lib/utils";
import { Link, useLocation } from "@tanstack/react-router";

import { m } from "@/paraglide/messages.js";
import { HostAnimatedKubaiIcon } from "@/shared/lib/kubai-icon";

import { isCurrentNav, NAV_ITEMS } from "./nav-items";

Expand All @@ -20,7 +21,6 @@ export const BottomTabBar = () => {
<ul className="mx-auto grid max-w-md grid-cols-4">
{NAV_ITEMS.map((item) => {
const isCurrent = isCurrentNav(pathname, item.to);
const Icon = item.icon;

return (
<li key={item.to}>
Expand All @@ -32,9 +32,10 @@ export const BottomTabBar = () => {
)}
to={item.to}
>
<Icon
<HostAnimatedKubaiIcon
aria-hidden="true"
color={isCurrent ? item.color : "currentColor"}
icon={item.icon}
size={22}
/>
<span className="max-w-full truncate">{item.label()}</span>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/app/shell/sidebar-first-step-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext } from "react";

import type { ChartColorVar } from "@/shared/lib/chart-colors";
import { HostAnimatedKubaiIcon } from "@/shared/lib/kubai-icon";

interface FirstStepIconState {
color: ChartColorVar;
Expand All @@ -33,15 +34,20 @@ export const SidebarFirstStepIcon = ({
return null;
}

const { color, done, icon: StepIcon } = state;
const { color, done, icon: stepIcon } = state;
const icon = done ? (
<RiCheckboxCircleFill
className={cn(className, "text-primary")}
size={size}
strokeWidth={strokeWidth}
/>
) : (
<StepIcon className={className} color={color} size={size} />
<HostAnimatedKubaiIcon
className={className}
color={color}
icon={stepIcon}
size={size}
/>
);

if (prefersReducedMotion) {
Expand Down
152 changes: 150 additions & 2 deletions apps/web/src/shared/lib/kubai-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,153 @@ import type {
IconComponent,
IconComponentProps,
} from "@freenary/ui/lib/icon-context";
import type { KubaiIconComponent } from "kubai-icons-react";
import type { KubaiIconComponent, KubaiIconProps } from "kubai-icons-react";
import { useEffect, useState } from "react";

import type { ChartColorVar } from "./chart-colors";

type TogglePulse = "replay" | "play" | null;

const TRIGGER_HOST_SELECTOR = [
"button",
"a[href]",
"[role='button']",
"[role='link']",
"[role='menuitem']",
"[role='menuitemcheckbox']",
"[role='menuitemradio']",
"[role='option']",
"[role='switch']",
"[role='tab']",
].join(", ");

const TOGGLE_ATTRIBUTES = [
"aria-checked",
"aria-current",
"aria-expanded",
"aria-pressed",
"aria-selected",
];

const KUBAI_MOTION_MS = 460;
const PLAY_HOLD_MS = KUBAI_MOTION_MS + 240;
const REPLAY_RETREAT_MS = 200;

const pulseDurationMs = (pulse: Exclude<TogglePulse, null>) =>
pulse === "play" ? PLAY_HOLD_MS : REPLAY_RETREAT_MS;

const isMousePointer = (event: Event) =>
!(event instanceof PointerEvent) ||
event.pointerType === "" ||
event.pointerType === "mouse";

const useTriggerHostMotion = (icon: SVGSVGElement | null) => {
const [isHovered, setIsHovered] = useState(false);
const [isFocusVisible, setIsFocusVisible] = useState(false);
const [pulse, setPulse] = useState<TogglePulse>(null);
useEffect(() => {
const host = icon?.parentElement?.closest(TRIGGER_HOST_SELECTOR) ?? icon;

if (!host) {
return;
}

let pulseTimer: ReturnType<typeof setTimeout> | undefined;
let pointerInside = false;
let focusVisible = false;

const setPointerInside = (inside: boolean) => {
pointerInside = inside;
setIsHovered(inside);
};

const setFocusVisible = (visible: boolean) => {
focusVisible = visible;
setIsFocusVisible(visible);
};

const isToggle = (mutation: MutationRecord) =>
mutation.attributeName !== null &&
mutation.oldValue !== host.getAttribute(mutation.attributeName) &&
!(
mutation.attributeName === "aria-expanded" &&
(pointerInside || focusVisible)
);

const startPulse = (mutations: MutationRecord[]) => {
if (!mutations.some(isToggle)) {
return;
}

const next = pointerInside || focusVisible ? "replay" : "play";
clearTimeout(pulseTimer);
setPulse(next);
pulseTimer = setTimeout(() => setPulse(null), pulseDurationMs(next));
};

const handlePointerEnter = (event: Event) => {
if (isMousePointer(event)) {
setPointerInside(true);
}
};

const handlePointerLeave = (event: Event) => {
if (isMousePointer(event)) {
setPointerInside(false);
}
};

const handleFocusIn = () => setFocusVisible(host.matches(":focus-visible"));

const handleFocusOut = () => setFocusVisible(false);

host.addEventListener("pointerenter", handlePointerEnter);
host.addEventListener("pointerleave", handlePointerLeave);
host.addEventListener("focusin", handleFocusIn);
host.addEventListener("focusout", handleFocusOut);

const toggleObserver = new MutationObserver(startPulse);
toggleObserver.observe(host, {
attributeFilter: TOGGLE_ATTRIBUTES,
attributeOldValue: true,
attributes: true,
});

return () => {
clearTimeout(pulseTimer);
toggleObserver.disconnect();
host.removeEventListener("pointerenter", handlePointerEnter);
host.removeEventListener("pointerleave", handlePointerLeave);
host.removeEventListener("focusin", handleFocusIn);
host.removeEventListener("focusout", handleFocusOut);
};
}, [icon]);

if (pulse === "play") {
return true;
}

if (pulse === "replay") {
return false;
}

return isHovered || isFocusVisible;
};

type HostAnimatedKubaiIconProps = Omit<KubaiIconProps, "hover" | "trigger"> & {
icon: KubaiIconComponent;
};

export const HostAnimatedKubaiIcon = ({
icon: Icon,
...props
}: HostAnimatedKubaiIconProps) => {
const [iconElement, setIconElement] = useState<SVGSVGElement | null>(null);
const hover = useTriggerHostMotion(iconElement);

return <Icon {...props} hover={hover} ref={setIconElement} trigger="none" />;
};

const cache = new Map<KubaiIconComponent, Map<ChartColorVar, IconComponent>>();

export const kubaiIcon = (
Expand All @@ -17,7 +160,12 @@ export const kubaiIcon = (

if (!adapted) {
adapted = ({ className, size }: IconComponentProps) => (
<Icon className={className} color={color} size={size} />
<HostAnimatedKubaiIcon
className={className}
color={color}
icon={Icon}
size={size}
/>
);

byColor.set(color, adapted);
Expand Down
1 change: 1 addition & 0 deletions docs/engineering/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module
- `apps/web/src/pages/settings/ui/security-passkey-row.tsx › SecurityPasskeyRow` — a passkey registered by another client can arrive with a blank name, so the stored name and the displayed fallback stay separate values: prefilling the rename dialog with the translated "unnamed" label would save that label as the real name.
- `apps/web/src/shared/lib/ff-icons.ts › ffIcons`, `lib/remix-icon.tsx › remixIcon`, `budget/category-menu-icon.tsx › categoryMenuIcon` — the Fluid Functionalism primitives draw their own glyphs (chevrons, checks, the search magnifier, the sidebar toggle) through named icon slots that default to Lucide, so this app overrides the shared slots with Remix Icon and lets the names it leaves out keep their default. No shared slot takes a Kubai icon. Each adapter caches per icon, because a slot given a fresh closure on every render remounts its icon.
- `apps/web/src/shared/lib/kubai-icon.tsx › kubaiIcon` — adapts a coloured Kubai icon to an `IconComponent` slot. It caches per icon and per colour for the same remount reason, and it drops the slot's `strokeWidth`, because a Kubai icon is a layered fill and a stroke width set on its root `<svg>` would cascade into every outline. The colour is a `ChartColorVar`, so a Kubai icon always takes a theme token.
- `apps/web/src/shared/lib/kubai-icon.tsx › HostAnimatedKubaiIcon` — every Kubai icon inside a control plays its motion from the control, not from the glyph: it walks up to the nearest button, link or menu row and drives the icon's controlled `hover` from that host's mouse pointer and visible focus, with the library's own trigger off so the two never disagree. A real change to the host's `aria-pressed`, `aria-checked`, `aria-selected`, `aria-expanded` or `aria-current` plays the motion once, in both directions, so the row being left plays as well as the row being picked; a write of the same value plays nothing. When the host is already hovered, the icon falls back for 200 ms and plays again, because a pose already reached shows nothing new. An `aria-expanded` change on a hovered or focused host plays nothing, because a submenu row opens on hover and would otherwise stutter on every pass. The library exposes no duration, so `KUBAI_MOTION_MS` copies the 460 ms from `kubai-icons/dist/motion.js`.
- `apps/web/src/pages/budget/ui/filter-token-search.tsx › FilterTokenSearch`, `model/use-filter-token-search.ts` — the Transactions search is a hand-built combobox rather than `@freenary/ui`'s `Combobox`, because one input carries three things in turn: free text, then a field, an operator and values, and the primitive owns its input value. The input keeps focus throughout and moves `aria-activedescendant` over the rows, and the popup cancels `mousedown` so a pick never blurs it. That is also why its `listbox` and `option` roles carry a `prefer-tag-over-role` exemption: `<select>` and `<datalist>` render neither icons nor checkmarks. Nothing is highlighted by default while no filter is being built, or while `is any of` waits for Enter, so Enter keeps its plain meaning there. Picking a field drops the last typed word, which is what found it. Leaving the box applies an `is any of` that has values and drops any other half-built filter.
- `apps/web/src/shared/ui/theme-menu-items.tsx › ThemeMenuItems`, `shared/locale-menu-items.tsx › LocaleMenuItems` — a Fluid Functionalism menu row registers by an index local to the popup it sits in, so these lists number their rows from zero and the sidebar user menu puts each one behind its own `DropdownSub` rather than offsetting them into a shared count. The theme helper is a hook (`useThemeCheckedIndex`) because it reads next-themes; the locale one is a plain `localeCheckedIndex()`.
- `apps/web/src/pages/budget/ui/recurrence-filter-menu.tsx › CONFIDENCE_START` — frequency and confidence sit behind one trigger, because both answer which repeats are in view, and the filter row already carries a company filter and a category filter beside them; the two sections share the popup's running index, so `CONFIDENCE_START` offsets the confidence rows past the frequency rows.
Expand Down
Loading