- Implemented command palette with keyboard shortcut (⌘K) for opening. - Added commands for opening, closing, setting query, and selecting items in the command palette. - Created tests for command palette commands and input handling. - Enhanced layer actions with functions for adding, grouping, and deleting layers. - Updated UI components to integrate command palette and shortcuts.
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Command,
|
|
CornersOut,
|
|
Cursor,
|
|
DownloadSimple,
|
|
DropHalf,
|
|
Eraser,
|
|
Eye,
|
|
EyeSlash,
|
|
FolderOpen,
|
|
FolderPlus,
|
|
Hand,
|
|
Lock,
|
|
LockOpen,
|
|
MagicWand,
|
|
MagnifyingGlass,
|
|
Minus,
|
|
PaintBrush,
|
|
Plus,
|
|
Sparkle,
|
|
Stack,
|
|
Trash,
|
|
} from "@phosphor-icons/react";
|
|
import { commandIds } from "@commands/ids";
|
|
import type { Artboard } from "@core/artboard";
|
|
import type { ImageDocument } from "@core/document";
|
|
import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state";
|
|
import type { AppStore } from "@editor/store";
|
|
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
|
|
import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
|
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
|
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions";
|
|
import { labelForTool } from "./toolLabels";
|
|
|
|
export type CommandPaletteProps = {
|
|
state: CommandPaletteState;
|
|
document: ImageDocument;
|
|
selection: SelectionState;
|
|
viewport: ViewportState;
|
|
tools: ToolState;
|
|
generation: GenerationState;
|
|
layersOpen: boolean;
|
|
activeArtboard?: Artboard;
|
|
dispatch: AppStore["dispatch"];
|
|
openFilePicker: () => void;
|
|
openGenerate: () => void;
|
|
openLayers: () => void;
|
|
closeLayers: () => void;
|
|
};
|
|
|
|
type PaletteItem = {
|
|
id: string;
|
|
title: string;
|
|
section: string;
|
|
subtitle?: string;
|
|
keywords?: string[];
|
|
disabled?: boolean;
|
|
icon: ReactNode;
|
|
run: () => void;
|
|
};
|
|
|
|
export function CommandPalette({
|
|
state,
|
|
document,
|
|
selection,
|
|
viewport,
|
|
tools,
|
|
generation,
|
|
layersOpen,
|
|
activeArtboard,
|
|
dispatch,
|
|
openFilePicker,
|
|
openGenerate,
|
|
openLayers,
|
|
closeLayers,
|
|
}: CommandPaletteProps) {
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
|
const activeArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
|
const selectedLayerId = selection.layerIds[0];
|
|
const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined;
|
|
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
|
const canUngroup = selectedLayer?.layer.type === "group";
|
|
const items = useMemo(
|
|
() =>
|
|
createPaletteItems({
|
|
document,
|
|
documentIndex,
|
|
activeArtboard,
|
|
activeArtboardId,
|
|
selectedLayer,
|
|
canGroup,
|
|
canUngroup,
|
|
selection,
|
|
viewport,
|
|
tools,
|
|
generation,
|
|
layersOpen,
|
|
dispatch,
|
|
openFilePicker,
|
|
openGenerate,
|
|
openLayers,
|
|
closeLayers,
|
|
}),
|
|
[
|
|
activeArtboard,
|
|
activeArtboardId,
|
|
canGroup,
|
|
canUngroup,
|
|
closeLayers,
|
|
dispatch,
|
|
document,
|
|
documentIndex,
|
|
generation,
|
|
layersOpen,
|
|
openFilePicker,
|
|
openGenerate,
|
|
openLayers,
|
|
selectedLayer,
|
|
selection,
|
|
tools,
|
|
viewport,
|
|
],
|
|
);
|
|
const filteredItems = useMemo(() => filterItems(items, state.query), [items, state.query]);
|
|
const activeIndex = filteredItems.length > 0 ? Math.min(state.selectedIndex, filteredItems.length - 1) : 0;
|
|
|
|
useEffect(() => {
|
|
if (!state.open) return;
|
|
const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [state.open]);
|
|
|
|
const close = useCallback(() => {
|
|
dispatch(commandIds.commandPaletteClose, undefined);
|
|
}, [dispatch]);
|
|
|
|
const runItem = useCallback(
|
|
(item: PaletteItem | undefined) => {
|
|
if (!item || item.disabled) return;
|
|
dispatch(commandIds.commandPaletteClose, undefined);
|
|
item.run();
|
|
},
|
|
[dispatch],
|
|
);
|
|
|
|
const handleInputKeyDown = useCallback(
|
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
event.stopPropagation();
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
const nextIndex = filteredItems.length > 0 ? (activeIndex + 1) % filteredItems.length : 0;
|
|
dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
const nextIndex = filteredItems.length > 0 ? (activeIndex - 1 + filteredItems.length) % filteredItems.length : 0;
|
|
dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
runItem(filteredItems[activeIndex]);
|
|
}
|
|
},
|
|
[activeIndex, close, dispatch, filteredItems, runItem],
|
|
);
|
|
|
|
if (!state.open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/20 px-3 pt-[10vh] backdrop-blur-sm"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Command palette"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) close();
|
|
}}
|
|
>
|
|
<div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-slate-950/[0.92] text-white shadow-2xl ring-1 ring-white/[0.12]">
|
|
<div className="flex h-16 items-center gap-3 border-b border-white/10 px-4">
|
|
<MagnifyingGlass size={22} className="shrink-0 text-white/45" />
|
|
<input
|
|
ref={inputRef}
|
|
value={state.query}
|
|
onChange={(event) => dispatch(commandIds.commandPaletteSetQuery, { query: event.currentTarget.value })}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder="Search commands"
|
|
aria-label="Search commands"
|
|
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
|
/>
|
|
<span className="hidden items-center gap-1 rounded-full bg-white/[0.07] px-2 py-1 text-xs font-semibold text-white/45 sm:inline-flex">
|
|
<Command size={14} weight="bold" /> K
|
|
</span>
|
|
</div>
|
|
<div className="subtle-scrollbar max-h-[min(32rem,70vh)] overflow-auto p-2">
|
|
{filteredItems.length > 0 ? (
|
|
filteredItems.map((item, index) => {
|
|
const active = index === activeIndex;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={paletteItemClass(active, Boolean(item.disabled))}
|
|
disabled={item.disabled}
|
|
onMouseEnter={() => dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: index })}
|
|
onClick={() => runItem(item)}
|
|
>
|
|
<span className={paletteIconClass(active)}>{item.icon}</span>
|
|
<span className="min-w-0 flex-1 text-left">
|
|
<span className="block truncate text-sm font-semibold">{item.title}</span>
|
|
<span className={paletteSubtitleClass(active)}>{item.subtitle ?? item.section}</span>
|
|
</span>
|
|
<span className={paletteSectionClass(active)}>
|
|
{item.section}
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="grid h-28 place-items-center text-sm text-white/45">No commands</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function createPaletteItems(options: {
|
|
document: ImageDocument;
|
|
documentIndex: DocumentReadIndex;
|
|
activeArtboard?: Artboard;
|
|
activeArtboardId?: string;
|
|
selectedLayer?: IndexedLayerInfo;
|
|
canGroup: boolean;
|
|
canUngroup: boolean;
|
|
selection: SelectionState;
|
|
viewport: ViewportState;
|
|
tools: ToolState;
|
|
generation: GenerationState;
|
|
layersOpen: boolean;
|
|
dispatch: AppStore["dispatch"];
|
|
openFilePicker: () => void;
|
|
openGenerate: () => void;
|
|
openLayers: () => void;
|
|
closeLayers: () => void;
|
|
}): PaletteItem[] {
|
|
const {
|
|
document,
|
|
documentIndex,
|
|
activeArtboard,
|
|
activeArtboardId,
|
|
selectedLayer,
|
|
canGroup,
|
|
canUngroup,
|
|
selection,
|
|
viewport,
|
|
tools,
|
|
generation,
|
|
layersOpen,
|
|
dispatch,
|
|
openFilePicker,
|
|
openGenerate,
|
|
openLayers,
|
|
closeLayers,
|
|
} = options;
|
|
const hasCandidates = generation.candidates.length > 0;
|
|
const items: PaletteItem[] = [];
|
|
|
|
items.push(
|
|
...availableToolIds.map((tool) => ({
|
|
id: `tool-${tool}`,
|
|
section: "Tools",
|
|
title: `Switch to ${labelForTool(tool)}`,
|
|
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
|
|
keywords: [tool],
|
|
icon: toolIcon(tool),
|
|
run: () => {
|
|
if (tool === "generate") openGenerate();
|
|
else dispatch(commandIds.toolSetActive, { tool });
|
|
},
|
|
})),
|
|
);
|
|
|
|
items.push(
|
|
{
|
|
id: "import-image",
|
|
section: "File",
|
|
title: "Import image",
|
|
subtitle: "Add an image as a layer",
|
|
keywords: ["open", "file", "layer"],
|
|
icon: <FolderOpen size={20} />,
|
|
run: openFilePicker,
|
|
},
|
|
{
|
|
id: "export-artboard",
|
|
section: "File",
|
|
title: "Export artboard as PNG",
|
|
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
|
|
keywords: ["download", "png"],
|
|
disabled: !activeArtboard,
|
|
icon: <DownloadSimple size={20} />,
|
|
run: () => {
|
|
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
|
|
},
|
|
},
|
|
);
|
|
|
|
items.push(
|
|
{
|
|
id: layersOpen ? "close-layers" : "open-layers",
|
|
section: "Layers",
|
|
title: layersOpen ? "Close layers panel" : "Open layers panel",
|
|
keywords: ["panel", "stack"],
|
|
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
|
|
run: layersOpen ? closeLayers : openLayers,
|
|
},
|
|
{
|
|
id: "add-artboard",
|
|
section: "Layers",
|
|
title: "Add artboard",
|
|
icon: <Plus size={20} />,
|
|
run: () => addArtboard(document, dispatch),
|
|
},
|
|
{
|
|
id: "add-empty-layer",
|
|
section: "Layers",
|
|
title: "Add empty layer",
|
|
subtitle: activeArtboardId ? undefined : "No artboard available",
|
|
keywords: ["new", "raster"],
|
|
disabled: !activeArtboardId,
|
|
icon: <Plus size={20} />,
|
|
run: () => {
|
|
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
|
|
},
|
|
},
|
|
{
|
|
id: "add-group",
|
|
section: "Layers",
|
|
title: "Add group",
|
|
subtitle: activeArtboardId ? undefined : "No artboard available",
|
|
disabled: !activeArtboardId,
|
|
icon: <FolderPlus size={20} />,
|
|
run: () => {
|
|
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
|
|
},
|
|
},
|
|
{
|
|
id: "group-selection",
|
|
section: "Layers",
|
|
title: "Group selected layers",
|
|
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
|
|
disabled: !canGroup,
|
|
icon: <Stack size={20} />,
|
|
run: () => {
|
|
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
|
|
},
|
|
},
|
|
{
|
|
id: "ungroup-selection",
|
|
section: "Layers",
|
|
title: "Ungroup selected group",
|
|
subtitle: selectedLayer?.layer.name,
|
|
disabled: !canUngroup || !selectedLayer,
|
|
icon: <Stack size={20} weight="fill" />,
|
|
run: () => {
|
|
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
|
|
},
|
|
},
|
|
{
|
|
id: "move-layer-up",
|
|
section: "Layers",
|
|
title: "Move selected layer up",
|
|
subtitle: selectedLayer?.layer.name,
|
|
disabled: !selectedLayer,
|
|
icon: <ArrowUp size={20} />,
|
|
run: () => {
|
|
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
|
|
},
|
|
},
|
|
{
|
|
id: "move-layer-down",
|
|
section: "Layers",
|
|
title: "Move selected layer down",
|
|
subtitle: selectedLayer?.layer.name,
|
|
disabled: !selectedLayer,
|
|
icon: <ArrowDown size={20} />,
|
|
run: () => {
|
|
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
|
|
},
|
|
},
|
|
{
|
|
id: "toggle-layer-visible",
|
|
section: "Layers",
|
|
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
|
|
subtitle: selectedLayer?.layer.name,
|
|
disabled: !selectedLayer,
|
|
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
|
|
run: () => {
|
|
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
|
|
},
|
|
},
|
|
{
|
|
id: "toggle-layer-lock",
|
|
section: "Layers",
|
|
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
|
|
subtitle: selectedLayer?.layer.name,
|
|
disabled: !selectedLayer,
|
|
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
|
|
run: () => {
|
|
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
|
|
},
|
|
},
|
|
{
|
|
id: "delete-selection",
|
|
section: "Layers",
|
|
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
|
|
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
|
|
disabled: !selectedLayer && !selection.artboardId,
|
|
icon: <Trash size={20} />,
|
|
run: () => deleteSelection(selection, selectedLayer, dispatch),
|
|
},
|
|
);
|
|
|
|
items.push(
|
|
{
|
|
id: "open-generate",
|
|
section: "Generate",
|
|
title: "Open generate panel",
|
|
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
|
|
keywords: ["ai"],
|
|
icon: <Sparkle size={20} />,
|
|
run: openGenerate,
|
|
},
|
|
...generateModeItems.map((modeItem) => ({
|
|
id: `generate-mode-${modeItem.mode}`,
|
|
section: "Generate",
|
|
title: modeItem.title,
|
|
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
|
|
keywords: ["mode", modeItem.mode],
|
|
icon: <Sparkle size={20} />,
|
|
run: () => {
|
|
openGenerate();
|
|
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
|
|
},
|
|
})),
|
|
{
|
|
id: "generate-random-seed",
|
|
section: "Generate",
|
|
title: "Use random seed",
|
|
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
|
|
keywords: ["seed"],
|
|
icon: <Sparkle size={20} />,
|
|
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
|
|
},
|
|
{
|
|
id: "clear-generation-candidates",
|
|
section: "Generate",
|
|
title: "Clear candidates",
|
|
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
|
|
disabled: !hasCandidates,
|
|
icon: <Trash size={20} />,
|
|
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
|
},
|
|
...generationCompareItems.map((compareItem) => ({
|
|
id: `generation-compare-${compareItem.mode}`,
|
|
section: "Generate",
|
|
title: compareItem.title,
|
|
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
|
|
disabled: !hasCandidates,
|
|
icon: <Sparkle size={20} />,
|
|
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
|
|
})),
|
|
);
|
|
|
|
items.push(
|
|
{
|
|
id: "zoom-in",
|
|
section: "Zoom",
|
|
title: "Zoom in",
|
|
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
|
icon: <Plus size={20} />,
|
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
|
|
},
|
|
{
|
|
id: "zoom-out",
|
|
section: "Zoom",
|
|
title: "Zoom out",
|
|
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
|
icon: <Minus size={20} />,
|
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
|
|
},
|
|
{
|
|
id: "zoom-100",
|
|
section: "Zoom",
|
|
title: "Zoom to 100%",
|
|
icon: <CornersOut size={20} />,
|
|
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
|
|
},
|
|
{
|
|
id: "fit-artboard",
|
|
section: "Zoom",
|
|
title: "Fit artboard",
|
|
subtitle: activeArtboard?.name,
|
|
disabled: !activeArtboard,
|
|
icon: <CornersOut size={20} />,
|
|
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
|
|
},
|
|
);
|
|
|
|
items.push(
|
|
{
|
|
id: "debug-reset-viewport",
|
|
section: "Debug",
|
|
title: "Reset viewport",
|
|
icon: <CornersOut size={20} />,
|
|
run: () => dispatch(commandIds.viewportReset, undefined),
|
|
},
|
|
{
|
|
id: "debug-clear-selection",
|
|
section: "Debug",
|
|
title: "Clear selection",
|
|
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
|
|
disabled: selection.layerIds.length === 0 && !selection.artboardId,
|
|
icon: <Cursor size={20} />,
|
|
run: () => dispatch(commandIds.selectionClear, undefined),
|
|
},
|
|
{
|
|
id: "debug-clear-candidates",
|
|
section: "Debug",
|
|
title: "Clear generation state",
|
|
disabled: !hasCandidates,
|
|
icon: <Trash size={20} />,
|
|
run: () => dispatch(commandIds.generationClearCandidates, undefined),
|
|
},
|
|
);
|
|
|
|
return items;
|
|
}
|
|
|
|
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
|
|
{ mode: "text-to-image", title: "Text-to-image mode" },
|
|
{ mode: "image-to-image", title: "Image-to-image mode" },
|
|
{ mode: "inpaint", title: "Inpaint mode" },
|
|
{ mode: "outpaint", title: "Outpaint mode" },
|
|
];
|
|
|
|
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
|
|
{ mode: "result", title: "Show result" },
|
|
{ mode: "before", title: "Show before" },
|
|
{ mode: "split", title: "Split compare" },
|
|
];
|
|
|
|
function filterItems(items: PaletteItem[], query: string) {
|
|
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (terms.length === 0) return items;
|
|
|
|
return items.filter((item) => {
|
|
const haystack = [item.title, item.subtitle, item.section, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase();
|
|
return terms.every((term) => haystack.includes(term));
|
|
});
|
|
}
|
|
|
|
function toolIcon(tool: ToolId) {
|
|
switch (tool) {
|
|
case "select":
|
|
return <Cursor size={20} />;
|
|
case "generate":
|
|
return <Sparkle size={20} />;
|
|
case "brush":
|
|
return <PaintBrush size={20} />;
|
|
case "eraser":
|
|
return <Eraser size={20} />;
|
|
case "chromaKey":
|
|
return <DropHalf size={20} />;
|
|
case "magicWand":
|
|
return <MagicWand size={20} />;
|
|
case "pan":
|
|
return <Hand size={20} />;
|
|
}
|
|
}
|
|
|
|
function paletteItemClass(active: boolean, disabled: boolean) {
|
|
const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none";
|
|
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;
|
|
return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`;
|
|
}
|
|
|
|
function paletteIconClass(active: boolean) {
|
|
return active
|
|
? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60"
|
|
: "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60";
|
|
}
|
|
|
|
function paletteSubtitleClass(active: boolean) {
|
|
return active ? "block truncate text-xs text-black/55" : "block truncate text-xs text-white/40";
|
|
}
|
|
|
|
function paletteSectionClass(active: boolean) {
|
|
return active
|
|
? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45"
|
|
: "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35";
|
|
}
|