+
- Ctrl K
+ {shortcutLabel("K")}
);
}
diff --git a/src/palette/CommandPalette.test.tsx b/src/palette/CommandPalette.test.tsx
new file mode 100644
index 0000000..b30081d
--- /dev/null
+++ b/src/palette/CommandPalette.test.tsx
@@ -0,0 +1,123 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { act } from "react";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { useProjectStore } from "../projects/projectStore";
+import { useProjectModalStore } from "../projects/ui/modalStore";
+import { useIdeStore } from "../state/ideStore";
+import { CommandPalette } from "./CommandPalette";
+import { usePaletteStore } from "./paletteStore";
+
+describe("CommandPalette", () => {
+ beforeEach(() => {
+ // Reset all stores the palette reads so tests are order-independent.
+ useProjectStore.setState({ projects: [], activeProjectId: null });
+ useProjectModalStore.setState({
+ createProject: null,
+ renameProject: null,
+ deleteProject: null,
+ });
+ useIdeStore.setState({ view: "home", openFiles: [], activeFile: null });
+ usePaletteStore.setState({ open: false, query: "" });
+ });
+
+ afterEach(() => {
+ // vitest doesn't auto-cleanup testing-library renders (no `globals: true`
+ // in `vitest.config.ts`), so we do it manually to stop previous mounts
+ // from leaking their DOM into the next test.
+ cleanup();
+ });
+
+ test("renders nothing when the palette is closed", () => {
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ test("renders nothing when the view is not home even if the store is open", () => {
+ useIdeStore.setState({ view: "ide" });
+ usePaletteStore.setState({ open: true });
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ test("renders the dialog with the input focused when opened on home", () => {
+ usePaletteStore.setState({ open: true });
+ render(
);
+ const input = screen.getByRole("textbox", { name: /rechercher/i });
+ expect(input).toBeTruthy();
+ expect(document.activeElement).toBe(input);
+ });
+
+ test("typing filters the visible items", () => {
+ usePaletteStore.setState({ open: true });
+ const { container } = render(
);
+ const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement;
+ // "Nouveau projet" is a command title; typing "nouv" should keep it.
+ // The row renders with `
` around the matched substring, so the
+ // title is split across text nodes — use the aggregated textContent.
+ fireEvent.change(input, { target: { value: "nouv" } });
+ expect(container.textContent).toContain("Nouveau projet");
+ // Typing a query that matches nothing should hit the empty-state copy.
+ fireEvent.change(input, { target: { value: "xyzzy" } });
+ expect(container.textContent).toContain("Aucun résultat");
+ });
+
+ test("Enter invokes the selected command and closes the palette", () => {
+ const runSpy = vi.fn();
+ useProjectModalStore.setState({
+ openCreate: runSpy,
+ openRename: () => {},
+ openDelete: () => {},
+ closeAll: () => {},
+ } as unknown as ReturnType);
+ usePaletteStore.setState({ open: true, query: "nouv" });
+ render();
+ const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement;
+ act(() => {
+ fireEvent.keyDown(input, { key: "Enter" });
+ });
+ expect(runSpy).toHaveBeenCalledTimes(1);
+ expect(usePaletteStore.getState().open).toBe(false);
+ });
+
+ test("Escape closes the palette via useModalA11y", () => {
+ usePaletteStore.setState({ open: true });
+ render();
+ act(() => {
+ fireEvent.keyDown(document, { key: "Escape" });
+ });
+ expect(usePaletteStore.getState().open).toBe(false);
+ });
+
+ test("arrow down moves the selected row across groups", () => {
+ // Seed a project so the "Reprendre" group has at least one row, giving
+ // us two groups (Reprendre + Commandes + Leçons) to navigate across.
+ useProjectStore.setState({
+ projects: [
+ {
+ id: "p1",
+ name: "alpha",
+ path: "/tmp/alpha",
+ kind: "blank",
+ tags: [],
+ createdAt: 0,
+ lastOpenedAt: 1,
+ fileCount: 0,
+ lineCount: 0,
+ },
+ ],
+ activeProjectId: null,
+ });
+ usePaletteStore.setState({ open: true });
+ render();
+ const firstRow = document.querySelector(".palette-item--sel");
+ expect(firstRow?.textContent).toContain("alpha");
+ const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement;
+ act(() => {
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ });
+ const nextRow = document.querySelector(".palette-item--sel");
+ expect(nextRow?.textContent).not.toContain("alpha");
+ });
+});
diff --git a/src/palette/CommandPalette.tsx b/src/palette/CommandPalette.tsx
new file mode 100644
index 0000000..8229d1a
--- /dev/null
+++ b/src/palette/CommandPalette.tsx
@@ -0,0 +1,195 @@
+import { useEffect, useRef, useState, type ReactElement } from "react";
+
+import { useModalA11y } from "../hooks/useModalA11y";
+import { useProjectStore } from "../projects/projectStore";
+import { useIdeStore } from "../state/ideStore";
+import { vfs } from "../vfs/VirtualFS";
+import { filterPaletteItems } from "./filterPaletteItems";
+import { highlightMatch } from "./highlightMatch";
+import { usePaletteStore } from "./paletteStore";
+import { buildAllGroups } from "./sources";
+import type { PaletteGroup, PaletteItem } from "./types";
+
+// Top-level entry: decides whether the dialog should mount at all. We keep
+// the hook-laden body in a child so the keydown listener / focus trap from
+// `useModalA11y` only run while the palette is actually on screen.
+export function CommandPalette(): ReactElement | null {
+ const open = usePaletteStore((s) => s.open);
+ const view = useIdeStore((s) => s.view);
+ if (!open || view !== "home") return null;
+ return ;
+}
+
+function PaletteDialog(): ReactElement {
+ const query = usePaletteStore((s) => s.query);
+ const setQuery = usePaletteStore((s) => s.setQuery);
+ const closePalette = usePaletteStore((s) => s.closePalette);
+
+ // Subscribing to the relevant slices forces a re-render when the live data
+ // changes while the palette is open (e.g. a project finishes loading). The
+ // returned values are unused — `buildAllGroups()` reads fresh state below.
+ useProjectStore((s) => s.projects);
+ useProjectStore((s) => s.activeProjectId);
+
+ const [, bumpVfsVersion] = useState(0);
+ useEffect(() => {
+ return vfs.on("change", (event) => {
+ // Only structural changes alter the file list; plain writes fire on
+ // every editor keystroke and don't change anything we care about here.
+ if (event.kind !== "write") bumpVfsVersion((v) => v + 1);
+ });
+ }, []);
+
+ const groups: PaletteGroup[] = buildAllGroups();
+ const filteredGroups: PaletteGroup[] = groups
+ .map((group) => ({ ...group, items: filterPaletteItems(group.items, query) }))
+ .filter((group) => group.items.length > 0);
+ const flatItems: PaletteItem[] = filteredGroups.flatMap((g) => g.items);
+
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ // Reset selection when the query changes — React 19's supported pattern for
+ // "derive state from a prop change" without an effect. Comparing a mirror
+ // state against the incoming query is lint-safe because setState happens
+ // during render rather than inside a useEffect body.
+ const [lastQuery, setLastQuery] = useState(query);
+ if (lastQuery !== query) {
+ setLastQuery(query);
+ setSelectedIndex(0);
+ }
+ // Clamp at render time so a shrunk filtered list can't reference an
+ // out-of-range row. Keeps `selectedIndex` as the user's intent while
+ // `activeIndex` is what we render and run against.
+ const activeIndex = flatItems.length === 0 ? 0 : Math.min(selectedIndex, flatItems.length - 1);
+
+ const dialogRef = useRef(null);
+ const inputRef = useRef(null);
+ useModalA11y(dialogRef, { onClose: closePalette, initialFocus: inputRef });
+
+ const runSelected = () => {
+ const item = flatItems[activeIndex];
+ if (!item) return;
+ // Close first so the view flip happens against a clean palette state; any
+ // downstream action (setView, openFile, openCreate) runs after.
+ closePalette();
+ void item.onSelect();
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setSelectedIndex((i) => Math.min(i + 1, Math.max(flatItems.length - 1, 0)));
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setSelectedIndex((i) => Math.max(i - 1, 0));
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ runSelected();
+ }
+ };
+
+ const hasQuery = query.trim().length > 0;
+
+ return (
+ <>
+
+
+
+
+
setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Rechercher un projet, une leçon ou ouvrir un fichier…"
+ autoComplete="off"
+ aria-label="Rechercher"
+ />
+ {hasQuery && (
+
+ )}
+
esc
+
+
+
+ {flatItems.length === 0 ? (
+
Aucun résultat pour « {query} ».
+ ) : (
+
+ {filteredGroups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.items.map((item) => {
+ const flatIndex = flatItems.indexOf(item);
+ const selected = flatIndex === activeIndex;
+ return (
+
setSelectedIndex(flatIndex)}
+ onClick={() => {
+ setSelectedIndex(flatIndex);
+ runSelected();
+ }}
+ >
+
+ {item.icon.glyph}
+
+
+ {highlightMatch(item.title, query)}
+ {item.subtitle && {item.subtitle}}
+
+
+ {item.pill && {item.pill}}
+ ↵ ouvrir
+
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+
+
+ ↑
+ ↓ naviguer
+
+
+ ↵ ouvrir
+
+
+ esc fermer
+
+
+
+
+ >
+ );
+}
diff --git a/src/palette/commands.test.ts b/src/palette/commands.test.ts
new file mode 100644
index 0000000..4067895
--- /dev/null
+++ b/src/palette/commands.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vitest";
+
+import { COMMANDS } from "./commands";
+
+describe("COMMANDS registry", () => {
+ test("is non-empty", () => {
+ expect(COMMANDS.length).toBeGreaterThan(0);
+ });
+
+ test("has unique ids", () => {
+ const ids = COMMANDS.map((c) => c.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ test("every command has a callable run", () => {
+ for (const cmd of COMMANDS) {
+ expect(typeof cmd.run).toBe("function");
+ }
+ });
+
+ test("every command carries a non-empty title", () => {
+ for (const cmd of COMMANDS) {
+ expect(cmd.title.trim().length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/src/palette/commands.ts b/src/palette/commands.ts
new file mode 100644
index 0000000..f880f87
--- /dev/null
+++ b/src/palette/commands.ts
@@ -0,0 +1,24 @@
+import { useProjectModalStore } from "../projects/ui/modalStore";
+
+// Global actions surfaced in the palette's "Commandes" section. v1 ships the
+// Home-relevant shortcuts only; `reloadPreview`, `goHome` and friends will
+// land when the palette works from the IDE too.
+export interface PaletteCommand {
+ id: string;
+ title: string;
+ subtitle?: string;
+ pill?: string;
+ run: () => void | Promise;
+}
+
+export const COMMANDS: readonly PaletteCommand[] = [
+ {
+ id: "open-create-project",
+ title: "Nouveau projet",
+ subtitle: "Partir d'un modèle",
+ pill: "commande",
+ run: () => {
+ useProjectModalStore.getState().openCreate({ templateId: "html-css" });
+ },
+ },
+];
diff --git a/src/palette/filterPaletteItems.test.ts b/src/palette/filterPaletteItems.test.ts
new file mode 100644
index 0000000..2879e5a
--- /dev/null
+++ b/src/palette/filterPaletteItems.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from "vitest";
+
+import { filterPaletteItems } from "./filterPaletteItems";
+import type { PaletteItem } from "./types";
+
+function makeItem(partial: Partial & Pick): PaletteItem {
+ return {
+ group: "files",
+ icon: { glyph: "·", tone: "blank" },
+ onSelect: () => {},
+ ...partial,
+ };
+}
+
+describe("filterPaletteItems", () => {
+ test("returns the full input when the query is empty or whitespace", () => {
+ const items = [makeItem({ id: "a", title: "Alpha" }), makeItem({ id: "b", title: "Bravo" })];
+ expect(filterPaletteItems(items, "")).toEqual(items);
+ expect(filterPaletteItems(items, " ")).toEqual(items);
+ });
+
+ test("drops items with no match", () => {
+ const items = [makeItem({ id: "a", title: "Alpha" }), makeItem({ id: "b", title: "Bravo" })];
+ expect(filterPaletteItems(items, "zz")).toEqual([]);
+ });
+
+ test("is case insensitive", () => {
+ const items = [makeItem({ id: "a", title: "INDEX.html" })];
+ expect(filterPaletteItems(items, "ind")).toHaveLength(1);
+ expect(filterPaletteItems(items, "INDEX")).toHaveLength(1);
+ });
+
+ test("title prefix ranks above title substring", () => {
+ const items = [
+ makeItem({ id: "mid", title: "ready-steady-go" }),
+ makeItem({ id: "pre", title: "steady-state" }),
+ ];
+ const result = filterPaletteItems(items, "steady");
+ expect(result.map((i) => i.id)).toEqual(["pre", "mid"]);
+ });
+
+ test("title substring ranks above subtitle substring", () => {
+ const items = [
+ makeItem({ id: "sub", title: "projet", subtitle: "contient index" }),
+ makeItem({ id: "title", title: "index.html" }),
+ ];
+ const result = filterPaletteItems(items, "index");
+ expect(result.map((i) => i.id)).toEqual(["title", "sub"]);
+ });
+
+ test("preserves input order for ties (stable sort)", () => {
+ const items = [
+ makeItem({ id: "first", title: "alpha" }),
+ makeItem({ id: "second", title: "alpha-two" }),
+ makeItem({ id: "third", title: "alpha-three" }),
+ ];
+ const result = filterPaletteItems(items, "alpha");
+ expect(result.map((i) => i.id)).toEqual(["first", "second", "third"]);
+ });
+
+ test("respects an explicit searchTokens override", () => {
+ const items = [
+ makeItem({
+ id: "with-tokens",
+ title: "Nothing visible",
+ searchTokens: "hidden-needle",
+ }),
+ ];
+ expect(filterPaletteItems(items, "needle")).toHaveLength(1);
+ });
+});
diff --git a/src/palette/filterPaletteItems.ts b/src/palette/filterPaletteItems.ts
new file mode 100644
index 0000000..3a85061
--- /dev/null
+++ b/src/palette/filterPaletteItems.ts
@@ -0,0 +1,39 @@
+import type { PaletteItem } from "./types";
+
+// Rank buckets — lower is better (we use Array.prototype.sort stable order).
+const RANK_TITLE_PREFIX = 0;
+const RANK_TITLE_SUBSTR = 1;
+const RANK_SUB_SUBSTR = 2;
+const RANK_MISS = 3;
+
+function haystack(item: PaletteItem): string {
+ return (item.searchTokens ?? `${item.title} ${item.subtitle ?? ""}`).toLowerCase();
+}
+
+function rankOf(item: PaletteItem, needle: string): number {
+ const title = item.title.toLowerCase();
+ if (title.startsWith(needle)) return RANK_TITLE_PREFIX;
+ if (title.includes(needle)) return RANK_TITLE_SUBSTR;
+ const sub = (item.subtitle ?? "").toLowerCase();
+ if (sub.includes(needle)) return RANK_SUB_SUBSTR;
+ if (haystack(item).includes(needle)) return RANK_SUB_SUBSTR;
+ return RANK_MISS;
+}
+
+// Case-insensitive substring filter with a light three-tier ranking so that
+// title matches surface above subtitle matches. Pure function — safe to call
+// from a render without memoisation for reasonable list sizes.
+export function filterPaletteItems(items: PaletteItem[], query: string): PaletteItem[] {
+ const needle = query.trim().toLowerCase();
+ if (needle === "") return items;
+
+ const ranked: Array<{ item: PaletteItem; rank: number; index: number }> = [];
+ items.forEach((item, index) => {
+ const rank = rankOf(item, needle);
+ if (rank === RANK_MISS) return;
+ ranked.push({ item, rank, index });
+ });
+
+ ranked.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank));
+ return ranked.map((r) => r.item);
+}
diff --git a/src/palette/formatShortcut.test.ts b/src/palette/formatShortcut.test.ts
new file mode 100644
index 0000000..ee77b51
--- /dev/null
+++ b/src/palette/formatShortcut.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+
+import { isMacPlatform, modifierLabel, shortcutLabel } from "./formatShortcut";
+
+// `navigator.platform` is a runtime string (`"MacIntel"`, `"Win32"`,
+// `"Linux x86_64"`). We stub it per test and restore the original in
+// `afterEach` so the suite is order-independent.
+const globalNavigator = globalThis.navigator as Navigator | undefined;
+const originalPlatform = globalNavigator?.platform;
+
+function stubPlatform(value: string): void {
+ if (!globalNavigator) {
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: { platform: value },
+ });
+ return;
+ }
+ Object.defineProperty(globalNavigator, "platform", {
+ configurable: true,
+ value,
+ });
+}
+
+describe("formatShortcut helpers", () => {
+ beforeEach(() => {
+ stubPlatform("Win32");
+ });
+
+ afterEach(() => {
+ stubPlatform(originalPlatform ?? "");
+ });
+
+ test("isMacPlatform returns true on MacIntel", () => {
+ stubPlatform("MacIntel");
+ expect(isMacPlatform()).toBe(true);
+ });
+
+ test("isMacPlatform returns true on macOS userAgent variants", () => {
+ stubPlatform("Mac68K");
+ expect(isMacPlatform()).toBe(true);
+ });
+
+ test("isMacPlatform returns false on Win32", () => {
+ stubPlatform("Win32");
+ expect(isMacPlatform()).toBe(false);
+ });
+
+ test("isMacPlatform returns false on Linux", () => {
+ stubPlatform("Linux x86_64");
+ expect(isMacPlatform()).toBe(false);
+ });
+
+ test("modifierLabel returns ⌘ on Mac and Ctrl elsewhere", () => {
+ stubPlatform("MacIntel");
+ expect(modifierLabel()).toBe("⌘");
+ stubPlatform("Win32");
+ expect(modifierLabel()).toBe("Ctrl");
+ });
+
+ test("shortcutLabel uppercases the key and uses the right modifier", () => {
+ stubPlatform("MacIntel");
+ expect(shortcutLabel("k")).toBe("⌘K");
+ stubPlatform("Win32");
+ expect(shortcutLabel("k")).toBe("Ctrl K");
+ });
+});
diff --git a/src/palette/formatShortcut.ts b/src/palette/formatShortcut.ts
new file mode 100644
index 0000000..2202732
--- /dev/null
+++ b/src/palette/formatShortcut.ts
@@ -0,0 +1,22 @@
+// On macOS the user expects ⌘K; everywhere else the convention is Ctrl K.
+// We listen for `metaKey OR ctrlKey` in the global handler regardless — this
+// helper just picks the *display* string for the badge.
+export function isMacPlatform(): boolean {
+ if (typeof navigator === "undefined") return false;
+ // `navigator.userAgentData.platform` is the modern API but still
+ // patchy across browsers; fall back to the legacy `platform` string.
+ // Tauri reports "MacIntel" on macOS, "Win32" on Windows, "Linux x86_64".
+ const platform =
+ (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
+ navigator.platform ??
+ "";
+ return /mac/i.test(platform);
+}
+
+export function modifierLabel(): string {
+ return isMacPlatform() ? "⌘" : "Ctrl";
+}
+
+export function shortcutLabel(key: string): string {
+ return isMacPlatform() ? `⌘${key.toUpperCase()}` : `Ctrl ${key.toUpperCase()}`;
+}
diff --git a/src/palette/highlightMatch.test.tsx b/src/palette/highlightMatch.test.tsx
new file mode 100644
index 0000000..48311f4
--- /dev/null
+++ b/src/palette/highlightMatch.test.tsx
@@ -0,0 +1,36 @@
+// @vitest-environment jsdom
+import { render } from "@testing-library/react";
+import { describe, expect, test } from "vitest";
+
+import { highlightMatch } from "./highlightMatch";
+
+describe("highlightMatch", () => {
+ test("returns the raw text when the query is empty", () => {
+ expect(highlightMatch("index.html", "")).toEqual(["index.html"]);
+ expect(highlightMatch("index.html", " ")).toEqual(["index.html"]);
+ });
+
+ test("returns the raw text when the query is not found", () => {
+ expect(highlightMatch("index.html", "zzz")).toEqual(["index.html"]);
+ });
+
+ test("wraps the matched substring in a mark tag preserving original case", () => {
+ const { container } = render({highlightMatch("INDEX.html", "ind")});
+ const mark = container.querySelector("mark");
+ expect(mark).toBeTruthy();
+ expect(mark?.textContent).toBe("IND");
+ });
+
+ test("keeps prefix and suffix around the mark", () => {
+ const { container } = render({highlightMatch("styles.css", "les")});
+ expect(container.textContent).toBe("styles.css");
+ expect(container.querySelector("mark")?.textContent).toBe("les");
+ });
+
+ test("matches only the first occurrence", () => {
+ const { container } = render({highlightMatch("aaa-bbb-aaa", "aaa")});
+ const marks = container.querySelectorAll("mark");
+ expect(marks).toHaveLength(1);
+ expect(marks[0]?.textContent).toBe("aaa");
+ });
+});
diff --git a/src/palette/highlightMatch.tsx b/src/palette/highlightMatch.tsx
new file mode 100644
index 0000000..19e92b9
--- /dev/null
+++ b/src/palette/highlightMatch.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+
+// Return the `text` split into prefix / match / suffix around the
+// first case-insensitive occurrence of `query`. Original casing is preserved
+// (we slice the input, we don't lowercase it). When there's no match or the
+// query is empty the result is a single-element array containing the raw text
+// so the caller can render it uniformly.
+export function highlightMatch(text: string, query: string): ReactNode[] {
+ const needle = query.trim().toLowerCase();
+ if (needle === "") return [text];
+ const haystack = text.toLowerCase();
+ const index = haystack.indexOf(needle);
+ if (index === -1) return [text];
+ const prefix = text.slice(0, index);
+ const match = text.slice(index, index + needle.length);
+ const suffix = text.slice(index + needle.length);
+ return [prefix, {match}, suffix];
+}
diff --git a/src/palette/iconForFile.test.ts b/src/palette/iconForFile.test.ts
new file mode 100644
index 0000000..c7d53e7
--- /dev/null
+++ b/src/palette/iconForFile.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from "vitest";
+
+import { iconForFile } from "./iconForFile";
+
+describe("iconForFile", () => {
+ test("maps html extensions to the html tone", () => {
+ expect(iconForFile("/index.html")).toEqual({ glyph: "◇", tone: "html" });
+ expect(iconForFile("/about.htm")).toEqual({ glyph: "◇", tone: "html" });
+ });
+
+ test("maps css extensions to the css tone", () => {
+ expect(iconForFile("/style.css")).toEqual({ glyph: "#", tone: "css" });
+ });
+
+ test("maps js / mjs extensions to the js tone", () => {
+ expect(iconForFile("/main.js")).toEqual({ glyph: "JS", tone: "js" });
+ expect(iconForFile("/module.mjs")).toEqual({ glyph: "JS", tone: "js" });
+ });
+
+ test("is case insensitive on the extension", () => {
+ expect(iconForFile("/UPPER.HTML")).toEqual({ glyph: "◇", tone: "html" });
+ });
+
+ test("falls back to the blank tone for unknown extensions", () => {
+ expect(iconForFile("/notes.md")).toEqual({ glyph: "·", tone: "blank" });
+ expect(iconForFile("/data.json")).toEqual({ glyph: "·", tone: "blank" });
+ expect(iconForFile("/no-extension")).toEqual({ glyph: "·", tone: "blank" });
+ });
+});
diff --git a/src/palette/iconForFile.ts b/src/palette/iconForFile.ts
new file mode 100644
index 0000000..c6b7407
--- /dev/null
+++ b/src/palette/iconForFile.ts
@@ -0,0 +1,20 @@
+import type { PaletteIconTone } from "./types";
+
+// Pick a glyph + tone for a VFS path based on its extension. Kept minimal on
+// purpose — the palette only highlights the handful of languages WeCode
+// actively supports; anything else falls back to a neutral dot.
+export function iconForFile(path: string): { glyph: string; tone: PaletteIconTone } {
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
+ switch (ext) {
+ case "html":
+ case "htm":
+ return { glyph: "◇", tone: "html" };
+ case "css":
+ return { glyph: "#", tone: "css" };
+ case "js":
+ case "mjs":
+ return { glyph: "JS", tone: "js" };
+ default:
+ return { glyph: "·", tone: "blank" };
+ }
+}
diff --git a/src/palette/paletteStore.ts b/src/palette/paletteStore.ts
new file mode 100644
index 0000000..cf657f2
--- /dev/null
+++ b/src/palette/paletteStore.ts
@@ -0,0 +1,22 @@
+import { create } from "zustand";
+
+interface PaletteState {
+ open: boolean;
+ query: string;
+ openPalette: () => void;
+ closePalette: () => void;
+ toggle: () => void;
+ setQuery: (q: string) => void;
+}
+
+// Singleton open/close + query state. Kept tiny on purpose — the palette is
+// otherwise stateless: every render derives its visible items from the
+// current sources (projects store, VFS, commands registry).
+export const usePaletteStore = create((set, get) => ({
+ open: false,
+ query: "",
+ openPalette: () => set({ open: true }),
+ closePalette: () => set({ open: false, query: "" }),
+ toggle: () => set({ open: !get().open, query: get().open ? "" : get().query }),
+ setQuery: (query) => set({ query }),
+}));
diff --git a/src/palette/sources.ts b/src/palette/sources.ts
new file mode 100644
index 0000000..fbd8c51
--- /dev/null
+++ b/src/palette/sources.ts
@@ -0,0 +1,122 @@
+import { toast } from "../ide/shell/toastStore";
+import { openProject } from "../projects/actions";
+import { useProjectStore } from "../projects/projectStore";
+import { useIdeStore } from "../state/ideStore";
+import { vfs } from "../vfs/VirtualFS";
+import { COMMANDS } from "./commands";
+import { iconForFile } from "./iconForFile";
+import {
+ PALETTE_GROUP_LABELS,
+ PALETTE_GROUP_ORDER,
+ type PaletteGroup,
+ type PaletteItem,
+} from "./types";
+
+const MAX_JUMP_ITEMS = 5;
+
+function basename(path: string): string {
+ const trimmed = path.startsWith("/") ? path.slice(1) : path;
+ const slash = trimmed.lastIndexOf("/");
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1);
+}
+
+// Top N projects by `lastOpenedAt`, excluding the active one — the Home "Jump
+// back in" section that lets the user hop to a different project in one go.
+export function buildJumpItems(): PaletteItem[] {
+ const { projects, activeProjectId } = useProjectStore.getState();
+ return [...projects]
+ .filter((p) => p.id !== activeProjectId)
+ .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)
+ .slice(0, MAX_JUMP_ITEMS)
+ .map((project) => ({
+ id: `jump:${project.id}`,
+ group: "jump",
+ title: project.name,
+ subtitle: project.path,
+ pill: "projet",
+ icon: {
+ glyph: project.kind === "blank" ? "·" : project.kind.toUpperCase().slice(0, 3),
+ tone: project.kind,
+ },
+ onSelect: () => {
+ void openProject(project.id);
+ },
+ }));
+}
+
+// Files from the VFS — only populated when an active project is in memory.
+// Selecting a row flips the view to IDE (if we were on Home) and opens the
+// file via the existing `openFile` store action.
+export function buildFileItems(): PaletteItem[] {
+ const { activeProjectId } = useProjectStore.getState();
+ if (!activeProjectId) return [];
+
+ const paths = vfs.listFiles();
+ return paths.map((path) => {
+ const icon = iconForFile(path);
+ return {
+ id: `file:${path}`,
+ group: "files",
+ title: basename(path),
+ subtitle: path,
+ pill: "fichier",
+ icon,
+ onSelect: () => {
+ const { view, setView, openFile } = useIdeStore.getState();
+ if (view !== "ide") setView("ide");
+ openFile(path);
+ },
+ };
+ });
+}
+
+// Wraps the typed command registry into palette-shaped rows.
+export function buildCommandItems(): PaletteItem[] {
+ return COMMANDS.map((cmd) => ({
+ id: `cmd:${cmd.id}`,
+ group: "commands",
+ title: cmd.title,
+ ...(cmd.subtitle ? { subtitle: cmd.subtitle } : {}),
+ ...(cmd.pill ? { pill: cmd.pill } : {}),
+ icon: { glyph: "⌘", tone: "cmd" },
+ onSelect: () => {
+ void cmd.run();
+ },
+ }));
+}
+
+// Single placeholder row so the Leçons section shows up even before the
+// curriculum lands. Selecting it toasts "Bientôt disponible".
+export function buildLessonItems(): PaletteItem[] {
+ return [
+ {
+ id: "lesson:placeholder",
+ group: "lessons",
+ title: "Parcours de leçons",
+ subtitle: "Bientôt disponible",
+ pill: "leçon",
+ icon: { glyph: "L", tone: "lesson" },
+ onSelect: () => {
+ toast.info("Bientôt disponible");
+ },
+ },
+ ];
+}
+
+// Orchestrator: assemble every group in the canonical order and drop those
+// that came back empty (typically "Fichiers" when no project is active).
+export function buildAllGroups(): PaletteGroup[] {
+ const bucket: Record = {
+ jump: buildJumpItems(),
+ files: buildFileItems(),
+ commands: buildCommandItems(),
+ lessons: buildLessonItems(),
+ };
+ const groups: PaletteGroup[] = [];
+ for (const key of PALETTE_GROUP_ORDER) {
+ const items = bucket[key] ?? [];
+ if (items.length === 0) continue;
+ groups.push({ key, label: PALETTE_GROUP_LABELS[key], items });
+ }
+ return groups;
+}
diff --git a/src/palette/types.ts b/src/palette/types.ts
new file mode 100644
index 0000000..7fd6b79
--- /dev/null
+++ b/src/palette/types.ts
@@ -0,0 +1,49 @@
+// One row in the palette dropdown — a project to open, a file to load, a
+// command to run, etc. Sources produce these and the overlay renders them.
+export interface PaletteItem {
+ /** Stable per-render identifier used for selection state and React keys. */
+ id: string;
+ /** Group bucket (e.g. "Jump back in", "Files", "Commands"). */
+ group: PaletteGroupKey;
+ /** Main title — what the user reads first. */
+ title: string;
+ /** Optional secondary text shown muted to the right of the title. */
+ subtitle?: string;
+ /** Short label for the type pill on the right (e.g. "file", "project"). */
+ pill?: string;
+ /** Two-letter glyph and tone for the leading icon. */
+ icon: { glyph: string; tone: PaletteIconTone };
+ /** Lower-case haystack used by the filter — defaults to title + subtitle. */
+ searchTokens?: string;
+ /** Triggered when the user picks the row (Enter or click). */
+ onSelect: () => void | Promise;
+}
+
+export type PaletteGroupKey = "jump" | "files" | "commands" | "lessons";
+
+export interface PaletteGroup {
+ key: PaletteGroupKey;
+ label: string;
+ items: PaletteItem[];
+}
+
+// Order groups appear in the palette body — matches the Claude Design handoff.
+export const PALETTE_GROUP_ORDER: readonly PaletteGroupKey[] = [
+ "jump",
+ "files",
+ "commands",
+ "lessons",
+];
+
+// Centralised user-facing labels (French). Keep in one place so the overlay
+// and the tests can agree.
+export const PALETTE_GROUP_LABELS: Record = {
+ jump: "Reprendre",
+ files: "Fichiers",
+ commands: "Commandes",
+ lessons: "Leçons",
+};
+
+// Soft palette shared with project cards / template cards so glyphs read at a
+// glance. New entries can extend this union.
+export type PaletteIconTone = "html" | "css" | "js" | "game" | "blank" | "cmd" | "lesson";
diff --git a/src/palette/useCommandPaletteShortcut.ts b/src/palette/useCommandPaletteShortcut.ts
new file mode 100644
index 0000000..8871df1
--- /dev/null
+++ b/src/palette/useCommandPaletteShortcut.ts
@@ -0,0 +1,24 @@
+import { useEffect } from "react";
+
+import { useIdeStore } from "../state/ideStore";
+import { usePaletteStore } from "./paletteStore";
+
+// App-wide Cmd/Ctrl+K listener. Lives outside `useGlobalShortcuts` (which is
+// IDE-scoped) because the palette is Home-only — the guard on
+// `view === "home"` is read live on every event, not captured at mount.
+export function useCommandPaletteShortcut(): void {
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (event.isComposing) return;
+ if (!(event.metaKey || event.ctrlKey)) return;
+ if (event.key !== "k" && event.key !== "K") return;
+ if (useIdeStore.getState().view !== "home") return;
+ event.preventDefault();
+ usePaletteStore.getState().toggle();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ };
+ }, []);
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index 63ee513..464ee21 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -125,7 +125,11 @@ button {
outline-offset: 2px;
border-radius: var(--radius-sm);
}
-.home-search input:focus-visible,
+.home-search input:focus-visible {
+ /* HomeSearch input is a read-only trigger, not a real text field — the
+ amber ring looks like a browser selection artifact when clicking it. */
+ outline: none;
+}
.modal__input:focus-visible,
.modal__select:focus-visible {
outline: 2px solid var(--accent);
@@ -2120,6 +2124,338 @@ button.brand {
line-height: 1.4;
}
+/* ============================================================
+ Command palette (⌘K / Ctrl+K) — Home overlay
+ ============================================================ */
+
+.palette-backdrop {
+ position: fixed;
+ inset: 0;
+ background: oklch(0.08 0.004 260 / 0.55);
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+ z-index: calc(var(--z-modal) - 1);
+ animation: palette-backdrop-in 0.28s ease;
+}
+
+@keyframes palette-backdrop-in {
+ from {
+ opacity: 0;
+ }
+}
+
+/* FLIP animation: HomeSearch measures its own rect on click and writes
+ --palette-dy / --palette-dx / --palette-sx as CSS custom properties on
+ . The keyframe starts at those offsets (the search bar's position
+ and width) and settles at the palette's resting center. All transforms
+ → GPU-composited, zero layout thrash. */
+.palette-root {
+ position: fixed;
+ top: 88px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(720px, calc(100vw - 48px));
+ z-index: var(--z-modal);
+ display: flex;
+ flex-direction: column;
+ font-family: var(--font-ui);
+ animation: palette-enter 0.28s cubic-bezier(0.16, 1, 0.3, 1);
+ transform-origin: top center;
+}
+
+@keyframes palette-enter {
+ from {
+ opacity: 0;
+ transform: translateX(calc(-50% + var(--palette-dx, 0px))) translateY(var(--palette-dy, 0px))
+ scaleX(var(--palette-sx, 1));
+ }
+ 15% {
+ opacity: 1;
+ }
+}
+
+.palette-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ background: var(--bg-2);
+ border: 1px solid var(--accent-dim);
+ border-radius: 10px 10px 0 0;
+ color: var(--fg-0);
+ box-shadow:
+ 0 0 0 3px var(--accent-soft),
+ var(--shadow-modal);
+}
+
+.palette-ico {
+ width: 16px;
+ height: 16px;
+ color: var(--accent);
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ flex: none;
+}
+
+.palette-input {
+ flex: 1;
+ background: none;
+ border: 0;
+ outline: 0;
+ color: var(--fg-0);
+ font-family: inherit;
+ font-size: 14px;
+}
+
+.palette-input::placeholder {
+ color: var(--fg-2);
+}
+
+.palette-clear {
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ display: grid;
+ place-items: center;
+ color: var(--fg-3);
+ background: transparent;
+ cursor: pointer;
+}
+
+.palette-clear:hover {
+ background: var(--bg-3);
+ color: var(--fg-0);
+}
+
+.palette-clear svg {
+ width: 12px;
+ height: 12px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+}
+
+.palette-esc {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--fg-3);
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--bg-1);
+ border: 1px solid var(--line-soft);
+}
+
+.palette-results {
+ background: var(--bg-1);
+ border: 1px solid var(--line-soft);
+ border-top: 0;
+ border-radius: 0 0 10px 10px;
+ box-shadow: var(--shadow-modal);
+ display: flex;
+ flex-direction: column;
+ max-height: 440px;
+}
+
+.palette-body {
+ overflow-y: auto;
+ flex: 1;
+ padding: 8px 0;
+}
+
+.palette-empty {
+ padding: 18px 16px;
+ color: var(--fg-3);
+ font-size: 13px;
+}
+
+.palette-group + .palette-group {
+ margin-top: 6px;
+}
+
+.palette-group-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 16px 4px;
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--fg-3);
+}
+
+.palette-group-line {
+ flex: 1;
+ height: 1px;
+ background: var(--line-soft);
+}
+
+.palette-item {
+ display: grid;
+ grid-template-columns: 26px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 16px;
+ color: var(--fg-1);
+ cursor: pointer;
+ position: relative;
+ font-size: 13px;
+}
+
+.palette-item:hover {
+ background: var(--bg-2);
+}
+
+.palette-item--sel {
+ background: var(--accent-soft);
+ color: var(--fg-0);
+}
+
+.palette-item--sel::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 6px;
+ bottom: 6px;
+ width: 2px;
+ background: var(--accent);
+ border-radius: 2px;
+}
+
+.palette-ic {
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--fg-0);
+ flex: none;
+}
+
+.palette-ic--html {
+ background: oklch(0.38 0.1 25 / 0.35);
+ color: oklch(0.82 0.12 25);
+}
+
+.palette-ic--css {
+ background: oklch(0.38 0.09 230 / 0.35);
+ color: oklch(0.82 0.11 230);
+}
+
+.palette-ic--js {
+ background: oklch(0.36 0.1 90 / 0.35);
+ color: oklch(0.82 0.12 90);
+}
+
+.palette-ic--game {
+ background: oklch(0.36 0.12 320 / 0.35);
+ color: oklch(0.82 0.13 320);
+}
+
+.palette-ic--blank {
+ background: var(--bg-3);
+ color: var(--fg-2);
+}
+
+.palette-ic--cmd {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.palette-ic--lesson {
+ background: oklch(0.36 0.09 155 / 0.35);
+ color: oklch(0.82 0.11 155);
+}
+
+.palette-lbl {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.palette-title {
+ color: inherit;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.palette-title mark {
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-radius: 2px;
+ padding: 0 1px;
+ font-weight: 600;
+}
+
+.palette-sub {
+ color: var(--fg-3);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.palette-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: none;
+}
+
+.palette-pill {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--fg-2);
+ padding: 2px 6px;
+ background: var(--bg-2);
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+}
+
+.palette-kbd-ret {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--accent);
+ display: none;
+}
+
+.palette-item--sel .palette-kbd-ret {
+ display: inline;
+}
+
+.palette-foot {
+ display: flex;
+ gap: 16px;
+ padding: 8px 16px;
+ border-top: 1px solid var(--line-soft);
+ color: var(--fg-3);
+ font-size: 11px;
+ background: var(--bg-0);
+ border-radius: 0 0 10px 10px;
+}
+
+.palette-fk kbd {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--fg-1);
+ background: var(--bg-2);
+ border: 1px solid var(--line-soft);
+ border-radius: 3px;
+ padding: 1px 5px;
+ margin-right: 2px;
+}
+
/* ============================================================
Reduced motion — honor the user's OS-level preference
============================================================ */