Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8ae0fae
fix(ui): reveal custom feed tab pencil on hover
infin1t3 Aug 16, 2026
7a3bf96
fix(ui): make custom feed tab link fill the tab
infin1t3 Aug 16, 2026
c9d0e4e
fix(ui): stretch custom feed link across the whole tab
infin1t3 Aug 16, 2026
12addc0
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
secondl1ght Aug 17, 2026
846b46c
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
infin1t3 Aug 18, 2026
c97ee8a
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
infin1t3 Aug 20, 2026
484f34a
feat(ui): redesign feed tabs with reach label, icons, and mobile tab bar
infin1t3 Aug 20, 2026
4214d8d
fix(ui): apply code-review findings to feed tabs and icon stack
infin1t3 Aug 20, 2026
b5bfc81
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
infin1t3 Aug 20, 2026
4ee9f03
test(vrt): regenerate baselines 2026-08-20T09:21:51Z
github-actions[bot] Aug 20, 2026
c65b6c1
refactor(ui): rework dynamic icon store and icon picker internals
infin1t3 Aug 20, 2026
1b87186
fix(test): stabilize VRT runs against browser-mode startup races
infin1t3 Aug 20, 2026
3b02499
test(vrt): regenerate baselines 2026-08-20T12:55:34Z
github-actions[bot] Aug 20, 2026
caa220a
refactor(ui): use Container/Typography atoms and trim single-use type…
infin1t3 Aug 20, 2026
f335d6b
Merge remote-tracking branch 'origin/dev' into bug-ui/1898-custom-fee…
infin1t3 Aug 20, 2026
a9c1079
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
aldertnl Aug 24, 2026
dfd6786
fix(ui): ui fixes
aldertnl Aug 24, 2026
031fee1
test(vrt): regenerate baselines 2026-08-24T14:38:51Z
github-actions[bot] Aug 24, 2026
90686eb
Merge remote-tracking branch 'origin/dev' into bug-ui/1898-custom-fee…
infin1t3 Aug 25, 2026
a51996d
test(vrt): drop ineffective optimizeDeps entry, correct the paralleli…
infin1t3 Aug 25, 2026
50757ac
test(vrt): run the suite in parallel again
infin1t3 Aug 25, 2026
2b35834
refactor(ui): move lucide helpers into libs/lucide and fix review fin…
infin1t3 Aug 25, 2026
a24579a
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
infin1t3 Aug 25, 2026
adb3dae
refactor(core): move the tagged-as reach constant out of the filter c…
infin1t3 Aug 25, 2026
e8fa276
fix(ui): harden the icon path against peer-controlled names and load …
infin1t3 Aug 25, 2026
a78ae14
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
Taewa Aug 25, 2026
1412eaf
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
Taewa Aug 25, 2026
23bbeb4
perf(ui): load picker icons as one chunk and drop the dead mobile dra…
infin1t3 Aug 26, 2026
b7534d7
Merge branch 'dev' into bug-ui/1898-custom-feeds-hover-interaction
infin1t3 Aug 26, 2026
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: 2 additions & 0 deletions docs/component-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ A single-step widening cast like `value as unknown` or `[] as unknown[]` (inside

Stock Lucide icons imported from `lucide-react` and custom SVG icons from `@/icons` (`src/libs/icons/icons.tsx`) should **always** use real implementations in tests—do not `vi.mock('lucide-react')` or `vi.mock('@/icons')` to stub icons. This ensures snapshots capture actual SVG output and visual regression tests detect icon changes.

`DynamicLucideIcon` is also real, but resolves its icon chunk **asynchronously**: a first render shows an empty size-preserving svg (`<svg class="lucide">` with no children), and the resolved paths appear after the dynamic import settles. Before asserting on paths or matching a snapshot, either await resolution (`waitFor` on `svg.childElementCount > 0` — avoid `querySelector('svg *')`, jsdom's selector engine misses svg descendants) or warm the icon with `requestLucideIcon(name)` from `@/libs/lucide/lucideIcons` and wait until `getLucideIconState(name)?.status === 'loaded'`. Note the icon cache is **module-level and persists across tests within a file** — a loading-state assertion needs an icon name no earlier test in the file has loaded.

Application import conventions (where to import icons, URL helpers, and what not to do) are documented in **`docs/components.md`** — _Icons (Lucide and custom)_.

### Radix UI Components: Always Real
Expand Down
22 changes: 21 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export { Home as default } from '@/templates/Feed/Home/Home';

## Icons (Lucide and custom)

Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`).
Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`); **data-driven Lucide icons** (an icon _name_ stored on a record, e.g. a custom feed's icon) render through the `DynamicLucideIcon` atom.

### Stock Lucide icons

Expand All @@ -154,6 +154,26 @@ import { ChevronDown, Plus, Trash2 } from 'lucide-react';

Use named imports from `lucide-react` only.

### Data-driven Lucide icons (`DynamicLucideIcon`)

When the icon is chosen at runtime from data (a kebab-case Lucide name like `"folder-heart"` stored on a feed), a static named import is impossible. Render it with the **`DynamicLucideIcon`** atom (`@/atoms/DynamicLucideIcon/DynamicLucideIcon`):

```tsx
<DynamicLucideIcon name={feed.icon} className="size-5" />
```

Icon state lives in a module-level store in **`@/libs/lucide/lucideIcons`** (`subscribeToLucideIcons`, `getLucideIconState`, `requestLucideIcon`, `preloadLucideIcons`), read through `useSyncExternalStore`. The full Lucide catalog (`lucide-react/dynamic.js`, ~116KB raw) and the picker's alias/tag metadata are lazily-loaded chunks — nothing icon-related ships in the initial bundle; sync callers can only normalize a name's shape (`toLucideIconName`, which lowercases so an icon another client stored as `Activity` still resolves), and the store answers unknown names once the catalog is resident. Once an icon has resolved anywhere in the session it renders synchronously everywhere, a loaded icon carries the same `lucide-<name>` class as a static import, and a failed chunk heals every mounted instance when any retry succeeds. While a valid name is still loading, the atom renders an empty size-preserving svg — never a wrong icon; the `fallback` prop (default `Activity`) covers missing, unknown, and failed names. Call `preloadLucideIcons(names)` when the icon names become known (e.g. when feed data lands) so mounts hit the cache. Never use this path for static UI icons — those stay named imports.

The icon picker takes a different route on purpose: it loads **all** canonical icon nodes in a single lazy chunk (`lucideIcons.nodes.ts`) instead of one chunk per cell, because it renders ~1700 of them and the per-icon path would cost ~1700 requests. Feed tabs keep the per-icon path — they render a handful of icons and must not pull the whole set.

Three files under `@/libs/lucide` are generated from the installed `lucide-react` and must be regenerated after a lucide upgrade:

| File | Contents |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `lucideIcons.nodes.ts` | every canonical icon's node, for the picker's single-chunk load |
| `lucideIcons.aliases.ts` | deprecated alias → canonical name, so the grid hides duplicate glyphs and search still finds old names |
| `lucideIcons.tags.ts` | search synonyms pruned from `lucide-static`'s `tags.json`, hyphenated to match the picker's normalized query |

### Custom / brand icons

```tsx
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"motion": "12.39.0",
"next": "16.2.6",
"next-themes": "0.4.6",
"pubky-app-specs": "0.6.2",
"pubky-app-specs": "0.7.0",
"qrcode.react": "4.2.0",
"radix-ui": "1.4.3",
"react": "19.2.6",
Expand Down
9 changes: 6 additions & 3 deletions src/app/(feeds)/_shell/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,25 @@ describe('tryResolveFeedsShellConfig', () => {
expect(config).not.toBeNull();
expect(config?.feedVariant).toBe(TIMELINE_FEED_VARIANT.HOME);
expect(config?.showRightMobileButton).toBeUndefined();
expect(config?.rightDrawerContentMobile).toBeDefined();
expect(config?.rightDrawerContent).toBeDefined();
expect(config?.hasGradientBackground).toBe(false);
expect(config?.classNameMobileHeader).toBe('pb-0');
});

it('returns the customFeed config for /feed/<id>', () => {
const config = tryResolveFeedsShellConfig('/feed/abc123');
expect(config).not.toBeNull();
expect(config?.feedVariant).toBe(TIMELINE_FEED_VARIANT.CUSTOM);
expect(config?.rightDrawerContentMobile).toBeDefined();
expect(config?.rightDrawerContent).toBeDefined();
expect(config?.hasGradientBackground).toBe(false);
expect(config?.classNameMobileHeader).toBe('pb-0');
});

it('returns the search config with showRightMobileButton=false and no mobile right drawer', () => {
const config = tryResolveFeedsShellConfig(APP_ROUTES.SEARCH);
expect(config).not.toBeNull();
expect(config?.feedVariant).toBe(TIMELINE_FEED_VARIANT.SEARCH);
expect(config?.showRightMobileButton).toBe(false);
expect(config?.rightDrawerContentMobile).toBeUndefined();
});

it('returns null for the intercepted-post pathname', () => {
Expand Down
11 changes: 8 additions & 3 deletions src/app/(feeds)/_shell/configs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { APP_ROUTES } from '@/app/routes';
import { TIMELINE_FEED_VARIANT } from '@/config/feed';
import type { ContentLayoutProps } from '@/organisms/ContentLayout/ContentLayout.types';
import { CustomFeedFilters } from '@/organisms/CustomFeedFilters/CustomFeedFilters';
import { FeedNavigation } from '@/organisms/FeedNavigation/FeedNavigation';
import { HomeFeedRightDrawer, HomeFeedRightSidebar } from '@/organisms/FeedRightSidebar/FeedRightSidebar';
import { HomeFeedDrawer, HomeFeedDrawerMobile, HomeFeedSidebar } from '@/organisms/HomeFeedSidebar/HomeFeedSidebar';

Expand Down Expand Up @@ -37,21 +36,27 @@ export function matchFeedsRouteKey(pathname: string): FeedsRouteKey | null {
const configs: Record<FeedsRouteKey, FeedsShellConfig> = {
home: {
feedVariant: TIMELINE_FEED_VARIANT.HOME,
// Compact opaque mobile header (Hot pattern) so the feed tab bar rendered
// by the Home template can stick right below it. Feed selection moved from
// the mobile right drawer into that tab bar, so the right drawer falls
// back to `rightDrawerContent` on mobile too.
hasGradientBackground: false,
classNameMobileHeader: 'pb-0',
leftSidebarContent: <HomeFeedSidebar allowVisualLayout feedVariant={TIMELINE_FEED_VARIANT.HOME} />,
rightSidebarContent: <HomeFeedRightSidebar />,
leftDrawerContent: <HomeFeedDrawer allowVisualLayout feedVariant={TIMELINE_FEED_VARIANT.HOME} />,
rightDrawerContent: <HomeFeedRightDrawer />,
leftDrawerContentMobile: <HomeFeedDrawerMobile allowVisualLayout feedVariant={TIMELINE_FEED_VARIANT.HOME} />,
rightDrawerContentMobile: <FeedNavigation className="lg:hidden" />,
},
customFeed: {
feedVariant: TIMELINE_FEED_VARIANT.CUSTOM,
hasGradientBackground: false,
classNameMobileHeader: 'pb-0',
leftSidebarContent: <CustomFeedFilters variant="sidebar" />,
rightSidebarContent: <HomeFeedRightSidebar />,
leftDrawerContent: <CustomFeedFilters variant="drawer" />,
rightDrawerContent: <HomeFeedRightDrawer />,
leftDrawerContentMobile: <CustomFeedFilters variant="drawer" />,
rightDrawerContentMobile: <FeedNavigation className="lg:hidden" />,
},
search: {
feedVariant: TIMELINE_FEED_VARIANT.SEARCH,
Expand Down
8 changes: 0 additions & 8 deletions src/app/(feeds)/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,21 @@ vi.mock('@/organisms/ContentLayout/ContentLayout', () => ({
feedVariant,
leftSidebarContent,
rightSidebarContent,
rightDrawerContentMobile,
showRightMobileButton,
}: {
children: React.ReactNode;
feedVariant?: string;
leftSidebarContent?: React.ReactNode;
rightSidebarContent?: React.ReactNode;
rightDrawerContentMobile?: React.ReactNode;
showRightMobileButton?: boolean;
}) => (
<div
data-testid="content-layout"
data-feed-variant={feedVariant ?? ''}
data-show-right-mobile-button={showRightMobileButton === false ? 'false' : 'true'}
data-has-right-drawer-mobile={rightDrawerContentMobile ? 'true' : 'false'}
>
<div data-testid="left-sidebar">{leftSidebarContent}</div>
<div data-testid="right-sidebar">{rightSidebarContent}</div>
<div data-testid="right-drawer-mobile">{rightDrawerContentMobile}</div>
<div data-testid="children">{children}</div>
</div>
),
Expand All @@ -66,7 +62,6 @@ const SHELL_CONFIG = {
feedVariant: 'home' as const,
leftSidebarContent: <div data-testid="config-left">left</div>,
rightSidebarContent: <div data-testid="config-right">right</div>,
rightDrawerContentMobile: <div data-testid="config-right-mobile">right-mobile</div>,
};

describe('FeedsLayout', () => {
Expand All @@ -89,7 +84,6 @@ describe('FeedsLayout', () => {
expect(layout).toHaveAttribute('data-feed-variant', 'home');
expect(screen.getByTestId('config-left')).toBeInTheDocument();
expect(screen.getByTestId('config-right')).toBeInTheDocument();
expect(screen.getByTestId('config-right-mobile')).toBeInTheDocument();
});

it('renders children inside ContentLayout when post slot is not active', () => {
Expand Down Expand Up @@ -144,7 +138,6 @@ describe('FeedsLayout', () => {
feedVariant: 'custom' as const,
leftSidebarContent: <div data-testid="feed-left">feed-left</div>,
rightSidebarContent: <div data-testid="feed-right">feed-right</div>,
rightDrawerContentMobile: <div data-testid="feed-right-mobile">feed-right-mobile</div>,
};

// First render: user is on /feed/abc123 and resolver returns the feed config.
Expand Down Expand Up @@ -194,7 +187,6 @@ describe('FeedsLayout', () => {
feedVariant: 'home' as const,
leftSidebarContent: <div data-testid="feed-left">feed-left</div>,
rightSidebarContent: <div data-testid="feed-right">feed-right</div>,
rightDrawerContentMobile: <div data-testid="feed-right-mobile">feed-right-mobile</div>,
};

// First render on a real feeds route to populate the cache.
Expand Down
82 changes: 82 additions & 0 deletions src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { render, screen, waitFor } from '@testing-library/react';
import { Library } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { getLucideIconState, requestLucideIcon } from '@/libs/lucide/lucideIcons';
import { DynamicLucideIcon } from './DynamicLucideIcon';

// The icon cache is module-level and persists across tests in this file, so
// every loading-state assertion uses an icon name no other test has loaded.
describe('DynamicLucideIcon', () => {
it('renders a valid dynamic icon once its chunk resolves', async () => {
render(<DynamicLucideIcon name="mountain" data-testid="dynamic-icon" />);

await waitFor(() => expect(screen.getByTestId('dynamic-icon').querySelector('path')).not.toBeNull());
});

it('never shows the fallback while a valid icon is loading', async () => {
render(<DynamicLucideIcon name="anchor" data-testid="loading-icon" className="size-5" />);

const svg = screen.getByTestId('loading-icon');
expect(svg).toHaveClass('lucide');
expect(svg).toHaveClass('size-5');
expect(svg).not.toHaveClass('lucide-activity');
expect(svg.childElementCount).toBe(0);

await waitFor(() => expect(svg.querySelector('path')).not.toBeNull());
});

it('renders a cached icon synchronously on first paint', async () => {
requestLucideIcon('library');
await vi.waitFor(() => {
if (getLucideIconState('library')?.status !== 'loaded') throw new Error('icon not cached yet');
});

render(<DynamicLucideIcon name="library" data-testid="cached-icon" />);

expect(screen.getByTestId('cached-icon').querySelector('path')).not.toBeNull();
});

it('renders the default fallback for a missing icon', () => {
render(<DynamicLucideIcon data-testid="fallback-icon" />);

expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-activity');
});

it('renders a consumer-provided fallback for a malformed icon name', () => {
render(
<DynamicLucideIcon name="Not A Real Icon" fallback={Library} data-testid="fallback-icon" className="size-6" />,
);

expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-library');
expect(screen.getByTestId('fallback-icon')).toHaveClass('size-6');
});

it('falls back once a plausible-but-unknown name resolves to no catalog entry', async () => {
render(
<DynamicLucideIcon
name="not-a-real-lucide-icon"
fallback={Library}
data-testid="fallback-icon"
className="size-6"
/>,
);

// Shape-valid names load through the lazy catalog, so the fallback lands
// only after the lookup resolves null.
await waitFor(() => expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-library'));
});

it('can omit the fallback while a consumer handles its own loading state', () => {
const { container } = render(<DynamicLucideIcon name="Not A Real Icon" fallback={null} />);

expect(container.firstChild).toBeNull();
});
});

describe('DynamicLucideIcon - Snapshots', () => {
it('matches snapshot for a consumer-provided fallback', () => {
const { container } = render(<DynamicLucideIcon name={null} fallback={Library} className="size-6" />);

expect(container.firstChild).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`DynamicLucideIcon - Snapshots > matches snapshot for a consumer-provided fallback 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-library size-6"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16 6 4 14"
/>
<path
d="M12 6v14"
/>
<path
d="M8 8v12"
/>
<path
d="M4 4v16"
/>
</svg>
`;
57 changes: 57 additions & 0 deletions src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import { useEffect, useSyncExternalStore } from 'react';
import { Activity as ActivityFallback, Icon, type IconNode, type LucideIcon, type LucideProps } from 'lucide-react';
import {
getLucideIconState,
requestLucideIcon,
subscribeToLucideIcons,
toLucideIconName,
} from '@/libs/lucide/lucideIcons';
import { cn } from '@/libs/utils/utils';

const EMPTY_ICON_NODE: IconNode = [];

interface DynamicLucideIconProps extends Omit<LucideProps, 'name'> {
name?: string | null;
/** Rendered for a missing/unknown/failed name — never while a valid icon is loading. */
fallback?: LucideIcon | null;
}

/**
* Renders a Lucide icon by its dynamic (kebab-case) name without bundling the
* full icon set. Icon state lives in a module-level store read through
* useSyncExternalStore, so: a resolved icon renders synchronously everywhere
* for the rest of the session, hydration is correct by construction (the
* server snapshot is always "nothing known"), and a failed chunk heals every
* mounted instance the moment any retry succeeds. While a valid icon is
* genuinely loading it renders an empty, size-preserving svg — never a wrong
* icon.
*/
export function DynamicLucideIcon({ name, fallback, className, ...iconProps }: DynamicLucideIconProps) {
const FallbackIcon = fallback === undefined ? ActivityFallback : fallback;
// Normalized shape only — the catalog is lazy-loaded, so a name that looks
// like a Lucide name but is absent resolves to `unknown` and falls back.
const validName = toLucideIconName(name);
const state = useSyncExternalStore(
subscribeToLucideIcons,
() => (validName ? getLucideIconState(validName) : undefined),
() => undefined,
);

useEffect(() => {
if (validName) requestLucideIcon(validName);
}, [validName]);

if (!validName || state?.status === 'unknown' || state?.status === 'error') {
return FallbackIcon ? <FallbackIcon className={className} {...iconProps} /> : null;
}

if (state?.status === 'loaded') {
// Carry the `lucide-<name>` class statically-imported icons get, so CSS
// rules and test selectors match dynamic icons too.
return <Icon iconNode={state.node} className={cn(`lucide-${validName}`, className)} {...iconProps} />;
}

return <Icon iconNode={EMPTY_ICON_NODE} className={className} {...iconProps} />;
}
Loading
Loading