Let users pick and order their workbench module tiles.

Per-user layout lives in the new app_user.prefs JSON column, read via
/v1/auth/me and written via PUT /v1/auth/me/prefs/workbench. The
workbench gets an in-place edit mode (gear -> drag to reorder, x to
remove, tray to add back, reset/done). Reset stores empty prefs so
registry changes still apply; ids of modules the user can't currently
see are kept on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-13 08:31:51 +08:00
parent 313706595d
commit 2307f19779
16 changed files with 906 additions and 23 deletions

View file

@ -28,6 +28,8 @@ export type ModuleDefinition = {
adminOnly?: boolean;
/** Hide from workbench launcher; still used for module window registration. */
workbenchHidden?: boolean;
/** When true and not listed in the user's order, start in the "available" tray. */
defaultHidden?: boolean;
};
export const MODULES: readonly ModuleDefinition[] = [
@ -159,6 +161,13 @@ export function modulesInSection(section: WorkbenchSection): ModuleDefinition[]
return MODULES.filter((m) => m.section === section);
}
/** Operations-area tiles users can show/hide/reorder (monitoring folded into ops). */
export function customizableModules(): ModuleDefinition[] {
return MODULES.filter(
(m) => (m.section === "operations" || m.section === "monitoring") && !m.workbenchHidden,
);
}
export function isWorkbenchPath(pathname: string): boolean {
return pathname === "/" || pathname === "/workbench";
}

View file

@ -21,11 +21,15 @@ export type AuthUser = {
updated_at?: string | null;
};
export type UserPrefs = Record<string, unknown>;
type AuthState = {
ready: boolean;
token: string | null;
user: AuthUser | null;
scopes: string[];
prefs: UserPrefs;
setPrefs: (prefs: UserPrefs) => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshMe: () => Promise<void>;
@ -35,24 +39,33 @@ type AuthState = {
const AuthContext = createContext<AuthState | null>(null);
function asPrefs(value: unknown): UserPrefs {
return value && typeof value === "object" && !Array.isArray(value) ? (value as UserPrefs) : {};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
// token is opaque for UI; cookie session means we only care about user presence.
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const [prefs, setPrefs] = useState<UserPrefs>({});
const refreshMe = useCallback(async () => {
clearAuthToken(); // drop any legacy localStorage tokens
try {
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
const data = await apiGet<{ user: AuthUser; scopes?: string[]; prefs?: UserPrefs }>(
"/v1/auth/me",
);
setToken("cookie");
setUser(data.user);
setScopes(data.scopes || data.user.scopes || []);
setPrefs(asPrefs(data.prefs));
} catch {
setToken(null);
setUser(null);
setScopes([]);
setPrefs({});
}
}, []);
@ -64,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [refreshMe]);
const login = useCallback(async (username: string, password: string) => {
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
const data = await apiPost<{ user: AuthUser; prefs?: UserPrefs }>("/v1/auth/login", {
username,
password,
});
@ -72,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken("cookie");
setUser(data.user);
setScopes(data.user.scopes || []);
setPrefs(asPrefs(data.prefs));
}, []);
const logout = useCallback(async () => {
@ -84,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null);
setUser(null);
setScopes([]);
setPrefs({});
}, []);
const value = useMemo<AuthState>(
@ -92,13 +107,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token,
user,
scopes,
prefs,
setPrefs,
login,
logout,
refreshMe,
isAdmin: user?.role === "admin",
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
}),
[ready, token, user, scopes, login, logout, refreshMe],
[ready, token, user, scopes, prefs, login, logout, refreshMe],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@heroui/react";
import { useI18n } from "../../i18n";
import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules";
import { WorkbenchModules } from "./components/WorkbenchModules";
import { ConsoleIcon } from "./components/ConsoleIcon";
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
import { openOrFocusModule } from "../../utils/moduleWindows";
@ -331,15 +331,11 @@ export function WorkbenchPage() {
) as Record<WorkbenchSection, ModuleDefinition[]>;
}, [isAdmin, hasScope]);
const displaySections = useMemo((): DisplaySection[] => {
const ops = [...visibleBySection.monitoring, ...visibleBySection.operations];
const system = visibleBySection.system;
const sections: DisplaySection[] = [
{ key: "operations", mods: ops },
{ key: "system", mods: system },
];
return sections.filter((s) => s.mods.length > 0);
}, [visibleBySection]);
const opsCandidates = useMemo(
() => [...visibleBySection.monitoring, ...visibleBySection.operations],
[visibleBySection],
);
const systemMods = visibleBySection.system;
const railTasks = useMemo(() => {
const items = opsQuery.data?.items ?? [];
@ -501,7 +497,7 @@ export function WorkbenchPage() {
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
</section>
<div className="wb-body">
<WorkbenchModules sections={displaySections} />
<WorkbenchModules opsCandidates={opsCandidates} systemMods={systemMods} />
<aside className="wb-side" aria-label={t("workbench.sidePanel")}>
<section className="wb-rail wb-panel wb-rail--tasks">
<div className="wb-rail__head">

View file

@ -1,4 +1,4 @@
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" };
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" | "settings" };
export function ConsoleIcon({ kind }: Props) {
return (
@ -9,6 +9,12 @@ export function ConsoleIcon({ kind }: Props) {
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
{kind === "settings" && (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.05 5.05l1.56 1.56M17.39 17.39l1.56 1.56M5.05 18.95l1.56-1.56M17.39 6.61l1.56-1.56" />
</>
)}
</svg>
);
}

View file

@ -1,20 +1,258 @@
import { useCallback, useEffect, useMemo, useState, type DragEvent } from "react";
import { Button } from "@heroui/react";
import type { ModuleDefinition } from "../../../config/modules";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import { openOrFocusModule } from "../../../utils/moduleWindows";
import { useWorkbenchLayout } from "../useWorkbenchLayout";
import { ConsoleIcon } from "./ConsoleIcon";
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] };
type Props = {
/** Permission-filtered customizable modules (monitoring + operations). */
opsCandidates: ModuleDefinition[];
systemMods: ModuleDefinition[];
};
function draftFromLayout(shown: ModuleDefinition[], available: ModuleDefinition[]) {
return {
order: shown.map((m) => m.moduleId),
hidden: available.map((m) => m.moduleId),
};
}
/** Primary modules as a grid of tiles, system admin as a quiet link row. */
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
export function WorkbenchModules({ opsCandidates, systemMods }: Props) {
const { t } = useI18n();
const { showError } = useToast();
const { shown, available, save, reset, layoutPrefs } = useWorkbenchLayout(opsCandidates);
const [editing, setEditing] = useState(false);
const [draftOrder, setDraftOrder] = useState<string[]>([]);
const [draftHidden, setDraftHidden] = useState<string[]>([]);
const [dragId, setDragId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const byId = useMemo(() => new Map(opsCandidates.map((m) => [m.moduleId, m])), [opsCandidates]);
const enterEdit = useCallback(() => {
const d = draftFromLayout(shown, available);
setDraftOrder(d.order);
setDraftHidden(d.hidden);
setEditing(true);
}, [shown, available]);
const exitEdit = useCallback(() => {
setEditing(false);
setDragId(null);
setDraftOrder([]);
setDraftHidden([]);
}, []);
useEffect(() => {
if (!editing) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
exitEdit();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [editing, exitEdit]);
const draftShown = useMemo(
() => draftOrder.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
[draftOrder, byId],
);
const draftAvailable = useMemo(
() => draftHidden.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
[draftHidden, byId],
);
const displayOps = editing ? draftShown : shown;
const canCustomize = opsCandidates.length > 0;
const removeTile = (moduleId: string) => {
setDraftOrder((prev) => prev.filter((id) => id !== moduleId));
setDraftHidden((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
};
const addTile = (moduleId: string) => {
setDraftHidden((prev) => prev.filter((id) => id !== moduleId));
setDraftOrder((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
};
const onDragStart = (moduleId: string) => (e: DragEvent) => {
setDragId(moduleId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", moduleId);
};
const onDragOver = (overId: string) => (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dragId || dragId === overId) return;
setDraftOrder((prev) => {
const from = prev.indexOf(dragId);
const to = prev.indexOf(overId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...prev];
next.splice(from, 1);
next.splice(to, 0, dragId);
return next;
});
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragId(null);
};
const defaults = useMemo(
() => ({
order: opsCandidates.filter((m) => !m.defaultHidden).map((m) => m.moduleId),
hidden: opsCandidates.filter((m) => m.defaultHidden).map((m) => m.moduleId),
}),
[opsCandidates],
);
const onDone = async () => {
setSaving(true);
try {
const sameAsDefault =
draftOrder.join(",") === defaults.order.join(",") &&
[...draftHidden].sort().join(",") === [...defaults.hidden].sort().join(",");
if (sameAsDefault) {
// Store nothing so later registry changes (new modules, reordering) still apply.
await reset();
} else {
// Keep entries for modules this user can't see right now (e.g. scope temporarily
// revoked) so their choice survives until the permission comes back.
const keepOrder = (layoutPrefs?.order ?? []).filter((id) => !byId.has(id));
const keepHidden = (layoutPrefs?.hidden ?? []).filter((id) => !byId.has(id));
await save([...draftOrder, ...keepOrder], [...draftHidden, ...keepHidden]);
}
exitEdit();
} catch {
showError(t("workbench.customizeSaveFailed"));
} finally {
setSaving(false);
}
};
const onReset = () => {
setDraftOrder(defaults.order);
setDraftHidden(defaults.hidden);
};
return (
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
{sections.map((section) => (
<div key={section.key} className={`wb-section wb-section--${section.key}`} role="group" aria-label={t(`workbench.${section.key}`)}>
{section.mods.map((mod) => (
<section
className={`wb-modules wb-panel${editing ? " wb-modules--editing" : ""}`}
aria-label={t("workbench.modules")}
>
{canCustomize ? (
<div className="wb-modules__toolbar">
{!editing ? (
<Button
type="button"
variant="ghost"
size="sm"
className="wb-modules__gear"
aria-label={t("workbench.customize")}
onPress={enterEdit}
>
<ConsoleIcon kind="settings" />
</Button>
) : null}
</div>
) : null}
{canCustomize || displayOps.length > 0 ? (
<div className="wb-section wb-section--operations" role="group" aria-label={t("workbench.operations")}>
{displayOps.length === 0 && !editing ? (
<div className="wb-modules__empty">
<Button type="button" variant="ghost" className="wb-modules__empty-btn" onPress={enterEdit}>
{t("workbench.customizeEmpty")}
</Button>
</div>
) : (
displayOps.map((mod) => (
<div
key={mod.moduleId}
className={`wb-tile-wrap${editing ? " wb-tile-wrap--edit" : ""}${dragId === mod.moduleId ? " is-dragging" : ""}`}
draggable={editing}
onDragStart={editing ? onDragStart(mod.moduleId) : undefined}
onDragOver={editing ? onDragOver(mod.moduleId) : undefined}
onDrop={editing ? onDrop : undefined}
onDragEnd={() => setDragId(null)}
>
<Button
variant="ghost"
className={`wb-tile wb-tile--${mod.iconTone}`}
aria-label={
editing
? t(mod.labelKey)
: `${t(mod.labelKey)} · ${t("workbench.openModule")}`
}
onPress={() => {
if (editing) return;
openOrFocusModule({ moduleId: mod.moduleId, path: mod.path });
}}
>
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-tile__label">{t(mod.labelKey)}</span>
</Button>
{editing ? (
<button
type="button"
className="wb-tile__remove"
aria-label={`${t(mod.labelKey)} ×`}
onClick={() => removeTile(mod.moduleId)}
>
<ConsoleIcon kind="close" />
</button>
) : null}
</div>
))
)}
</div>
) : null}
{editing && draftAvailable.length > 0 ? (
<div className="wb-modules__tray" role="group" aria-label={t("workbench.customizeAvailable")}>
<div className="wb-modules__tray-label">{t("workbench.customizeAvailable")}</div>
<div className="wb-modules__tray-grid">
{draftAvailable.map((mod) => (
<Button
key={mod.moduleId}
variant="ghost"
className={`wb-tile wb-tile--${mod.iconTone} wb-tile--available`}
aria-label={t(mod.labelKey)}
onPress={() => addTile(mod.moduleId)}
>
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-tile__label">{t(mod.labelKey)}</span>
</Button>
))}
</div>
</div>
) : null}
{editing ? (
<div className="wb-modules__actions">
<Button type="button" variant="ghost" size="sm" onPress={onReset} isDisabled={saving}>
{t("workbench.customizeReset")}
</Button>
<Button type="button" variant="primary" size="sm" onPress={() => void onDone()} isDisabled={saving}>
{t("workbench.customizeDone")}
</Button>
</div>
) : null}
{systemMods.length > 0 ? (
<div className="wb-section wb-section--system" role="group" aria-label={t("workbench.system")}>
{systemMods.map((mod) => (
<Button
key={mod.moduleId}
variant="ghost"
@ -27,7 +265,7 @@ export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
</Button>
))}
</div>
))}
) : null}
</section>
);
}

View file

@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import type { ModuleDefinition } from "../../config/modules";
import { apiPut } from "../../services/api";
import { useAuth, type UserPrefs } from "../auth/AuthContext";
export type WorkbenchLayoutPrefs = {
order: string[];
hidden: string[];
};
const LS_PREFIX = "netx.wb.layout.";
function readLocalLayout(userId: string): WorkbenchLayoutPrefs | null {
try {
const raw = localStorage.getItem(`${LS_PREFIX}${userId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const order = (parsed as { order?: unknown }).order;
const hidden = (parsed as { hidden?: unknown }).hidden;
if (!Array.isArray(order) || !Array.isArray(hidden)) return null;
return {
order: order.filter((x): x is string => typeof x === "string"),
hidden: hidden.filter((x): x is string => typeof x === "string"),
};
} catch {
return null;
}
}
function writeLocalLayout(userId: string, layout: WorkbenchLayoutPrefs): void {
try {
localStorage.setItem(`${LS_PREFIX}${userId}`, JSON.stringify(layout));
} catch {
// ignore quota / private mode
}
}
function asWorkbenchPrefs(value: unknown): WorkbenchLayoutPrefs | null {
if (!value || typeof value !== "object") return null;
const order = (value as { order?: unknown }).order;
const hidden = (value as { hidden?: unknown }).hidden;
if (!Array.isArray(order) && !Array.isArray(hidden)) return null;
return {
order: Array.isArray(order) ? order.filter((x): x is string => typeof x === "string") : [],
hidden: Array.isArray(hidden) ? hidden.filter((x): x is string => typeof x === "string") : [],
};
}
/** Pure layout resolver — permission-filtered `all` in registry order. */
export function resolveLayout(
all: ModuleDefinition[],
prefs: WorkbenchLayoutPrefs | null | undefined,
): { shown: ModuleDefinition[]; available: ModuleDefinition[] } {
const byId = new Map(all.map((m) => [m.moduleId, m]));
const prefOrder = prefs?.order ?? [];
const hiddenSet = new Set((prefs?.hidden ?? []).filter((id) => byId.has(id)));
const orderIds = prefOrder.filter((id) => byId.has(id));
const inOrder = new Set(orderIds);
const ranked: ModuleDefinition[] = [];
const seen = new Set<string>();
for (const id of orderIds) {
const mod = byId.get(id);
if (!mod || seen.has(id)) continue;
ranked.push(mod);
seen.add(id);
}
for (const mod of all) {
if (seen.has(mod.moduleId)) continue;
ranked.push(mod);
seen.add(mod.moduleId);
}
const shown: ModuleDefinition[] = [];
const available: ModuleDefinition[] = [];
for (const mod of ranked) {
const hide =
hiddenSet.has(mod.moduleId) || (Boolean(mod.defaultHidden) && !inOrder.has(mod.moduleId));
if (hide) available.push(mod);
else shown.push(mod);
}
return { shown, available };
}
export function useWorkbenchLayout(all: ModuleDefinition[]) {
const { user, prefs, setPrefs, ready } = useAuth();
const userId = user?.id ?? "";
const layoutPrefs = useMemo(() => {
// After auth is ready, server prefs win (missing workbench => default).
// Before ready, fall back to localStorage to avoid a layout flash.
if (ready) return asWorkbenchPrefs(prefs?.workbench);
return userId ? readLocalLayout(userId) : null;
}, [ready, prefs, userId]);
const { shown, available } = useMemo(
() => resolveLayout(all, layoutPrefs),
[all, layoutPrefs],
);
const persist = useCallback(
async (next: WorkbenchLayoutPrefs) => {
const res = await apiPut<{ prefs: UserPrefs }>("/v1/auth/me/prefs/workbench", next);
const merged =
res.prefs && typeof res.prefs === "object" ? res.prefs : { ...prefs, workbench: next };
setPrefs(merged);
if (userId) writeLocalLayout(userId, next);
},
[prefs, setPrefs, userId],
);
const save = useCallback(
async (order: string[], hidden: string[]) => {
await persist({ order, hidden });
},
[persist],
);
const reset = useCallback(async () => {
await persist({ order: [], hidden: [] });
}, [persist]);
return { shown, available, save, reset, layoutPrefs };
}

View file

@ -149,6 +149,12 @@ const workbench = {
apiKeys: "API Keys",
sessions: "Login sessions",
},
customize: "Customize modules",
customizeDone: "Done",
customizeReset: "Reset",
customizeAvailable: "Available",
customizeEmpty: "Add modules",
customizeSaveFailed: "Save failed",
};
export default workbench;

View file

@ -149,6 +149,12 @@ const workbench = {
apiKeys: "API Key",
sessions: "登录会话",
},
customize: "自定义功能区",
customizeDone: "完成",
customizeReset: "恢复默认",
customizeAvailable: "可添加",
customizeEmpty: "添加功能",
customizeSaveFailed: "保存失败",
};
export default workbench;

View file

@ -29,6 +29,53 @@
/* Modules: one row of primary tiles, then a quiet row of admin links. */
.wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; }
.wb-modules__toolbar { display: flex; justify-content: flex-end; margin: -4px 0 -6px; min-height: 28px; }
.app--shell .app-main .wb-modules__gear {
width: 28px; height: 28px; min-height: 0; padding: 0; margin: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 0; border-radius: var(--nx-radius-sm); background: transparent;
color: var(--nx-text-faint); box-shadow: none;
}
.app--shell .app-main .wb-modules__gear:hover { background: rgb(255 255 255 / .06); color: var(--nx-text); }
.wb-modules__gear svg { width: 16px; height: 16px; }
.wb-modules__empty {
grid-column: 1 / -1; min-height: var(--wb-tile-h);
display: flex; align-items: center; justify-content: center;
}
.app--shell .app-main .wb-modules__empty-btn {
height: auto; min-height: 40px; padding: 8px 16px; border: 1px dashed var(--nx-line);
border-radius: 12px; background: transparent; color: var(--nx-text-muted); box-shadow: none;
}
.app--shell .app-main .wb-modules__empty-btn:hover { background: rgb(255 255 255 / .04); color: var(--nx-text); border-color: var(--nx-line-strong); }
.wb-tile-wrap { position: relative; min-width: 0; }
.wb-modules--editing .wb-tile-wrap--edit .wb-tile { border-color: var(--nx-line); cursor: grab; }
.wb-modules--editing .wb-tile-wrap--edit.is-dragging .wb-tile { opacity: .55; cursor: grabbing; }
.wb-modules--editing .wb-tile-wrap--edit .wb-tile:hover { transform: none; }
.app--shell .app-main .wb-tile__remove {
position: absolute; top: 4px; right: 4px; z-index: 2;
width: 22px; height: 22px; padding: 0; margin: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--nx-line); border-radius: 999px;
background: rgb(15 23 42 / .72); color: var(--nx-text-muted); cursor: pointer;
}
.app--shell .app-main .wb-tile__remove:hover { color: var(--nx-text); border-color: var(--nx-line-strong); }
.wb-tile__remove svg { width: 12px; height: 12px; }
.wb-modules__tray { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.wb-modules__tray-label { font-size: 12px; font-weight: 500; color: var(--nx-text-muted); }
.wb-modules__tray-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr));
grid-auto-rows: calc(var(--wb-tile-h) * .85); gap: 10px;
}
.app--shell .app-main .wb-tile--available {
opacity: .55; border-style: dashed; border-color: var(--nx-line); min-height: 0;
background: rgb(255 255 255 / .02);
}
.app--shell .app-main .wb-tile--available:hover {
opacity: .85; transform: none;
border-color: var(--nx-line-strong);
background: rgb(255 255 255 / .05);
}
.wb-modules__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
/* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */
.wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; }
.app--shell .app-main .wb-tile {