reported as a plugin whose dock tile had square corners while every other one was rounded, and the expectation was right: the rounding IS dock infrastructure and has been there all along. it was simply never reaching the artwork. the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile insets its glyph to h-7, so the corners are empty and nothing looked wrong for as long as every plugin used one. `bare` fills the box edge to edge — so the first plugin to ship a logo on an OPAQUE background drew its own square corners over a rounded box, and became the one tile in the row that had not been told the shape. `overflow-hidden`, in the dock and in spotlight, which had the identical bug a few pixels away. it fixes every bare plugin rather than this one, and it is a no-op for badge tiles, which is why it belongs on the box and not on the image. the plugins list drew the same artwork with no rounding at all while the grid beside it used `rounded-xl`. rounded to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { NavLink } from 'react-router';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { resolveIcon } from 'officerdev';
|
|
import type { PluginManifest } from 'hooks/usePermissions';
|
|
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
|
|
|
export type DockItem = {
|
|
/** Already resolved for the active locale — see `useDockItems`. */
|
|
label: string;
|
|
to: string;
|
|
color: string;
|
|
// Either a lucide glyph (rendered white on the coloured tile) or an image asset (e.g. an app favicon).
|
|
icon?: LucideIcon;
|
|
image?: string;
|
|
/**
|
|
* `badge` (the default) fills a rounded square with `color` and insets the icon — which is what a
|
|
* white lucide glyph needs, because on nothing it is invisible. `bare` draws no background and lets
|
|
* artwork fill the tile, because a logo framed in an arbitrary swatch reads as a mistake.
|
|
*
|
|
* `color` is still used either way: the active glow and the indicator dot are drawn from it.
|
|
*/
|
|
tile?: 'badge' | 'bare';
|
|
};
|
|
|
|
type DockProps = {
|
|
items: DockItem[];
|
|
className?: string;
|
|
// The content region the dock lives in. Reveal/hide is measured from ITS bottom edge, which the
|
|
// in-flow music dock shrinks when present — so the dock clears the music dock with no magic offset.
|
|
boundaryRef: React.RefObject<HTMLElement | null>;
|
|
};
|
|
|
|
const ICON_SIZE = 48;
|
|
const ICON_GAP = 24;
|
|
const DOCK_PADDING = 12;
|
|
const MAX_SCALE = 1.5;
|
|
const MAX_DISTANCE = 150;
|
|
const SHOW_THRESHOLD = 24;
|
|
const HIDE_THRESHOLD = 100;
|
|
|
|
const getScale = (mouseX: number | null, iconCenterX: number) => {
|
|
if (mouseX === null) return 1;
|
|
const distance = Math.abs(mouseX - iconCenterX);
|
|
if (distance > MAX_DISTANCE) return 1;
|
|
return 1 + (MAX_SCALE - 1) * Math.cos((distance / MAX_DISTANCE) * (Math.PI / 2));
|
|
};
|
|
|
|
export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
|
const [mouseX, setMouseX] = useState<number | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const dockRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
// Distance from the bottom of the content region (which the in-flow music dock shrinks when
|
|
// present) — so reveal/hide triggers just above wherever the region ends, above the music dock.
|
|
const bottom = boundaryRef.current?.getBoundingClientRect().bottom ?? window.innerHeight;
|
|
const distFromBottom = bottom - ev.clientY;
|
|
setVisible((prev) => (prev ? distFromBottom <= HIDE_THRESHOLD : distFromBottom <= SHOW_THRESHOLD));
|
|
if (distFromBottom <= HIDE_THRESHOLD) {
|
|
const rect = dockRef.current?.getBoundingClientRect();
|
|
if (rect) setMouseX(ev.clientX - rect.left);
|
|
} else {
|
|
setMouseX(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
return () => document.removeEventListener('mousemove', handleMouseMove);
|
|
}, [boundaryRef]);
|
|
|
|
return (
|
|
<div
|
|
ref={dockRef}
|
|
className={`absolute left-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg transition-transform duration-300 ease-in-out ${className ?? 'flex'}`}
|
|
style={{
|
|
backgroundColor: 'var(--dock-bg)',
|
|
borderColor: 'var(--dock-border)',
|
|
bottom: '16px',
|
|
transform: `translateX(-50%) translateY(${visible ? '0' : 'calc(100% + 24px)'})`,
|
|
}}
|
|
>
|
|
{items.map((item, index) => {
|
|
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
|
|
const scale = getScale(mouseX, iconCenter);
|
|
|
|
return (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
// Home would otherwise light up on every route: without `end`, NavLink treats "/" as an
|
|
// ancestor of everything. Every other item wants the ancestor match, so that a detail route
|
|
// (/plans/x, /system-monitor/btop) keeps its dock tile lit.
|
|
end={item.to === '/'}
|
|
className="group relative flex flex-col items-center"
|
|
style={{
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'bottom center',
|
|
transition: 'transform 150ms ease-out',
|
|
}}
|
|
>
|
|
{({ isActive }) => (
|
|
<>
|
|
<span
|
|
className="absolute -top-9 px-2 py-1 rounded-md text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none"
|
|
style={{ backgroundColor: 'var(--dock-tooltip-bg)' }}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
<div
|
|
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
|
|
style={{
|
|
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
|
|
// does not paint it.
|
|
//
|
|
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
|
|
// its glyph, so the corners were never reached and nothing here looked wrong for as
|
|
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
|
|
// opaque background kept its own square corners and sat in the dock as the one tile
|
|
// that had not been told the shape.
|
|
background: item.tile === 'bare' ? 'transparent' : item.color,
|
|
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
|
|
}}
|
|
>
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt=""
|
|
className={
|
|
item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-7 w-7 md:h-8 md:w-8 object-contain'
|
|
}
|
|
/>
|
|
) : (
|
|
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
|
|
)}
|
|
</div>
|
|
{isActive && (
|
|
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
|
|
)}
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
import {
|
|
Activity,
|
|
Bot,
|
|
Code,
|
|
FolderOpen,
|
|
GitBranch,
|
|
Globe,
|
|
Home,
|
|
LayoutGrid,
|
|
MessageCircle,
|
|
Monitor,
|
|
Music,
|
|
Puzzle,
|
|
Radar,
|
|
Radio,
|
|
Send,
|
|
Store,
|
|
Workflow,
|
|
} from 'lucide-react';
|
|
|
|
/**
|
|
* The dock items that belong to the SHELL — present on every install, with no sidecar behind them.
|
|
*
|
|
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
|
|
* `/permissions` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
|
|
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
|
|
* not have and the shell would need editing every time a sidecar is added.
|
|
*
|
|
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
|
* the light profile because it fronts a remote instance and installs nothing locally.
|
|
*/
|
|
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
|
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
|
|
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
|
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
|
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
|
// Beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing.
|
|
// The route and the screen have existed since bots did; there was simply never a tile, so the only way
|
|
// to reach /bots was to type it.
|
|
{ labelKey: 'nav.bots', to: '/bots', icon: Bot, color: '#34d399' },
|
|
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
|
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
|
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
|
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
|
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
|
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
|
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
|
// Core because it is the door into another account's filesystem, and the permission that gates it is
|
|
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
|
// every other grantable tile here gets.
|
|
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
|
// Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a
|
|
// plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each
|
|
// with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is
|
|
// what `screens` in a manifest is for.
|
|
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
|
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
|
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
|
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
|
// things that disappears when uninstalled.
|
|
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
|
|
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
|
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
|
|
];
|
|
|
|
/**
|
|
* Turn the manifests of installed sidecars into dock tiles.
|
|
*
|
|
* `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports — they
|
|
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
|
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
|
*/
|
|
/**
|
|
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
|
|
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
|
|
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
|
|
*/
|
|
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
|
|
|
|
/**
|
|
* The composed dock, with the shell's own labels in the active locale.
|
|
*
|
|
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
|
|
* spreading the same two sources, and each would otherwise have to resolve labels itself.
|
|
*/
|
|
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
|
|
const { t } = useTranslation();
|
|
return useMemo(
|
|
() => [
|
|
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
|
|
...dockItemsFromPlugins(plugins),
|
|
],
|
|
[t, plugins],
|
|
);
|
|
}
|
|
|
|
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
|
return plugins.flatMap((plugin) => {
|
|
const tile = (t: {
|
|
name: string;
|
|
icon?: string;
|
|
image?: string;
|
|
tile?: 'badge' | 'bare';
|
|
color: string;
|
|
route: string;
|
|
}): DockItem => ({
|
|
label: t.name,
|
|
to: t.route,
|
|
color: t.color,
|
|
...(t.tile ? { tile: t.tile } : {}),
|
|
...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }),
|
|
});
|
|
|
|
return [
|
|
tile({
|
|
name: plugin.name,
|
|
icon: plugin.icon,
|
|
image: plugin.image,
|
|
tile: plugin.tile,
|
|
color: plugin.color,
|
|
route: plugin.rootRoute,
|
|
}),
|
|
...(plugin.extraTiles ?? []).map(tile),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS.
|
|
*
|
|
* This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item
|
|
* behind it, so nothing breaks — the default dock just quietly comes up one tile short on a machine
|
|
* where Music was never installed. Defaults that reference optional features are how an app ends up
|
|
* looking subtly wrong on a fresh install for no stated reason.
|
|
*/
|
|
// Calendar joined on 2026-08-19, when it stopped being an installable app and became core. The rest of
|
|
// this list is what every install has and most people want visible; a calendar is now in that category.
|
|
// Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for
|
|
// the same sidecar in everyone's default dock is a crowded default rather than a useful one.
|
|
// `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by
|
|
// default and a fresh dock that silently dropped it would look like a downgrade.
|
|
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar'];
|
|
|
|
/**
|
|
* The same defaults, plus a tile for every installed plugin.
|
|
*
|
|
* A plugin's tile is hidden by DEFAULT_DOCK_PATHS alone, because that list is core-only and `useDock`
|
|
* renders the saved list rather than everything available. For someone who has never touched their dock
|
|
* that reads as the install silently not working — they installed Music and the dock looks identical.
|
|
*
|
|
* Installing IS the choice, so the default follows it. This covers the never-customised user; someone
|
|
* with a saved dock has a row instead, and the installer appends to that (see `plugins/install.ts`).
|
|
* Both call sites use this so the dock and its settings screen cannot disagree about what "default" means.
|
|
*/
|
|
export const defaultDockPaths = (plugins: PluginManifest[]): string[] => [
|
|
...DEFAULT_DOCK_PATHS,
|
|
...dockItemsFromPlugins(plugins).map((item) => item.to),
|
|
];
|