{visibleRemoteCursors.map((cursor) => (
diff --git a/apps/client/src/botsu/shell/BotsuFrame.tsx b/apps/client/src/botsu/shell/BotsuFrame.tsx
index 686ef2d..f069e0d 100644
--- a/apps/client/src/botsu/shell/BotsuFrame.tsx
+++ b/apps/client/src/botsu/shell/BotsuFrame.tsx
@@ -1,157 +1,64 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { Link, Outlet } from 'react-router-dom';
-import { applyTheme, defaultTheme, resolveTheme, type ThemeSettings } from '@botsu/ui';
-import { BotsuIdentity } from '../profile/BotsuIdentity';
+import React, { CSSProperties, useMemo, useRef } from 'react';
+import { color, config, vars } from 'folds';
+import { Link, Outlet, useLocation } from 'react-router-dom';
import { BotsuPresence } from '../presence/BotsuPresence';
import { PixelCanvasProvider } from '../start/PixelCanvasContext';
import '@botsu/ui/styles.css';
import './shell.css';
-const THEME_STORAGE_KEY = 'botsu.theme.v1';
+type BotsuThemeStyle = CSSProperties & Record<`--botsu-${string}`, string>;
-const loadStoredTheme = (): ThemeSettings => {
- try {
- const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
- return stored ? resolveTheme(JSON.parse(stored) as Partial
) : resolveTheme();
- } catch {
- return resolveTheme();
- }
+const BOTSU_ROUTE_LABELS: Record = {
+ botsu: 'botsu',
+ apps: 'apps',
+ documents: 'documents',
+ services: 'services',
+ home: 'home',
};
-type ThemeControlsProps = {
- theme: ThemeSettings;
- setTheme: React.Dispatch>;
+const buildBreadcrumbs = (pathname: string): { label: string; to: string }[] => {
+ const parts = pathname.split('/').filter(Boolean);
+ if (parts.length === 0) return [{ label: '/', to: '/' }];
+ return parts.map((part, index) => ({
+ label: BOTSU_ROUTE_LABELS[part] ?? part,
+ to: `/${parts.slice(0, index + 1).join('/')}/`,
+ }));
};
-function ThemeControls({ theme, setTheme }: ThemeControlsProps) {
- const update = (change: Partial) =>
- setTheme((current) => resolveTheme({ ...current, ...change }));
-
- return (
-
- Apparence
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+const botsuCinnyThemeVars = {
+ '--botsu-color-canvas': color.Background.Container,
+ '--botsu-color-surface': color.Surface.Container,
+ '--botsu-color-surface-raised': color.SurfaceVariant.Container,
+ '--botsu-color-text': color.Background.OnContainer,
+ '--botsu-color-text-muted': color.SurfaceVariant.OnContainer,
+ '--botsu-color-border': color.Surface.ContainerLine,
+ '--botsu-color-focus': vars.outline.FocusRing,
+ '--botsu-color-accent': color.Primary.Main,
+ '--botsu-color-on-accent': color.Primary.OnMain,
+ '--botsu-border-width': config.borderWidth.B300,
+ '--botsu-radius': config.radii.R300,
+ '--botsu-font-family': 'var(--font-secondary)',
+} satisfies BotsuThemeStyle;
export function BotsuFrame() {
const rootRef = useRef(null);
- const [theme, setTheme] = useState(loadStoredTheme);
-
- useEffect(() => {
- if (rootRef.current) applyTheme(rootRef.current, theme);
- try {
- window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
- } catch {
- // The in-memory theme still works when storage is blocked or full.
- }
- }, [theme]);
+ const location = useLocation();
+ const breadcrumbs = useMemo(() => buildBreadcrumbs(location.pathname), [location.pathname]);
return (
-
+
-
ESPACE BOTSU
-
-
-
-
+
-
-
-
diff --git a/apps/client/src/botsu/shell/BotsuLogo.tsx b/apps/client/src/botsu/shell/BotsuLogo.tsx
new file mode 100644
index 0000000..720632e
--- /dev/null
+++ b/apps/client/src/botsu/shell/BotsuLogo.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+const BOTSU_LOGO_PATH =
+ 'M 512.005005 31 C 573.302368 233.752808 705.327454 311.553162 908.0802 264.401428 C 766.624756 429.4328 766.624756 594.46405 908.0802 759.495361 C 705.327454 712.343506 573.302368 790.144043 512.005005 992.896851 C 450.707672 790.144043 318.682617 712.343506 115.929855 759.495361 C 257.385284 594.46405 257.385284 429.4328 115.929855 264.401428 C 318.682617 311.553162 450.707672 233.752808 512.005005 31 Z';
+
+type BotsuLogoProps = {
+ active?: boolean;
+ className?: string;
+};
+
+export function BotsuLogo({ active = false, className }: BotsuLogoProps) {
+ return (
+
+ );
+}
diff --git a/apps/client/src/botsu/shell/BotsuNav.tsx b/apps/client/src/botsu/shell/BotsuNav.tsx
new file mode 100644
index 0000000..b288a6b
--- /dev/null
+++ b/apps/client/src/botsu/shell/BotsuNav.tsx
@@ -0,0 +1,180 @@
+import React, { useState } from 'react';
+import { Box, Icon, Icons, Text } from 'folds';
+import { Link, useLocation } from 'react-router-dom';
+import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../app/components/nav';
+import { RoomNavCategoryButton } from '../../app/features/room-nav';
+import { PageNav, PageNavContent, PageNavHeader } from '../../app/components/page';
+import { getBotsuEmbedPath } from '../../app/pages/pathUtils';
+import {
+ botsuApps,
+ getVisibleApps,
+ type BotsuApp,
+ type BotsuAppId,
+ type BotsuRole,
+} from '../apps/catalog';
+import { buildLaunchPlan } from '../apps/launcher';
+import { botsuServices } from './BotsuServices';
+
+const APP_NAV_IDS = new Set(['discussions', 'documents', 'tables', 'files']);
+const HIDDEN_SERVICE_PANEL_LABELS = new Set(['accueil', 'recherche']);
+
+function getAppNavTarget(app: BotsuApp): { to: string } | { href: string } | undefined {
+ const plan = buildLaunchPlan(app);
+ if (plan.kind === 'navigate') return { to: plan.to };
+ if (plan.kind === 'embed') return { to: getBotsuEmbedPath(app.id) };
+ if (plan.kind === 'external') return { href: plan.href };
+ return undefined;
+}
+
+const getAppNavIcon = (appId: BotsuAppId | 'test') => {
+ if (appId === 'discussions') return Icons.Message;
+ if (appId === 'documents') return Icons.File;
+ if (appId === 'tables') return Icons.Category;
+ if (appId === 'files') return Icons.Attachment;
+ if (appId === 'transfers') return Icons.Download;
+ if (appId === 'services') return Icons.Server;
+ if (appId === 'administration') return Icons.ShieldUser;
+ return Icons.Space;
+};
+
+const getServiceNavIcon = (label: string) => {
+ const serviceLabel = label.toLocaleLowerCase('fr-FR');
+ if (serviceLabel === 'paste') return Icons.Pencil;
+ if (serviceLabel === 'transferts') return Icons.Download;
+ if (serviceLabel === 'ripper') return Icons.Play;
+ if (serviceLabel === 'traduction') return Icons.Globe;
+ return Icons.External;
+};
+
+function BotsuNavLabel({ children, icon }: { children: React.ReactNode; icon: string }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function BotsuNavLink({
+ active,
+ children,
+ href,
+ to,
+}: {
+ active?: boolean;
+ children: React.ReactNode;
+ href?: string;
+ to?: string;
+}) {
+ if (to) {
+ return (
+
+ {children}
+
+ );
+ }
+ if (href) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
+
+export function BotsuNav() {
+ const location = useLocation();
+ const [applicationsClosed, setApplicationsClosed] = useState(false);
+ const [servicesClosed, setServicesClosed] = useState(false);
+ const apps = getVisibleApps(new Set(['member']), botsuApps).filter((app) =>
+ APP_NAV_IDS.has(app.id)
+ );
+ const panelServices = botsuServices.filter(
+ (service) => !HIDDEN_SERVICE_PANEL_LABELS.has(service.label.toLocaleLowerCase('fr-FR'))
+ );
+
+ return (
+
+
+
+
+
+ Botsu
+
+
+
+
+
+
+
+
+ setApplicationsClosed((closed) => !closed)}
+ >
+ applications
+
+
+ {!applicationsClosed && (
+
+ {apps.map((app) => {
+ const target = getAppNavTarget(app);
+ const to = target && 'to' in target ? target.to : undefined;
+ const href = target && 'href' in target ? target.href : undefined;
+ return (
+
+
+ {app.label.toLocaleLowerCase('fr-FR')}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ setServicesClosed((closed) => !closed)}
+ >
+ services
+
+
+ {!servicesClosed && (
+
+ {panelServices.map((service) => (
+
+
+ {service.label.toLocaleLowerCase('fr-FR')}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/botsu/shell/BotsuServices.tsx b/apps/client/src/botsu/shell/BotsuServices.tsx
index ed2214c..7295e61 100644
--- a/apps/client/src/botsu/shell/BotsuServices.tsx
+++ b/apps/client/src/botsu/shell/BotsuServices.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-const services = [
+export const botsuServices = [
{ label: 'Accueil', description: 'Point d’entrée BOTSU.', url: 'https://home.botsu.net/' },
{
label: 'Recherche',
@@ -25,7 +25,7 @@ export function BotsuServices() {
Services
- {services.map((service) => (
+ {botsuServices.map((service) => (
{service.label}
diff --git a/apps/client/src/botsu/shell/BotsuTab.tsx b/apps/client/src/botsu/shell/BotsuTab.tsx
index f00e69b..79bb8fc 100644
--- a/apps/client/src/botsu/shell/BotsuTab.tsx
+++ b/apps/client/src/botsu/shell/BotsuTab.tsx
@@ -1,8 +1,8 @@
import React from 'react';
-import { Text } from 'folds';
import { useLocation, useNavigate } from 'react-router-dom';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../app/components/sidebar';
import { getBotsuPath } from '../../app/pages/pathUtils';
+import { BotsuLogo } from './BotsuLogo';
export function BotsuTab() {
const location = useLocation();
@@ -11,18 +11,16 @@ export function BotsuTab() {
return (
-
+
{(triggerRef) => (
navigate(getBotsuPath())}
>
-
- B
-
+
)}
diff --git a/apps/client/src/botsu/shell/index.ts b/apps/client/src/botsu/shell/index.ts
index 6bf7571..4e16594 100644
--- a/apps/client/src/botsu/shell/index.ts
+++ b/apps/client/src/botsu/shell/index.ts
@@ -1,5 +1,6 @@
export * from './BotsuEmbed';
export * from './BotsuFrame';
export * from './BotsuLauncher';
+export * from './BotsuNav';
export * from './BotsuServices';
export * from './BotsuTab';
diff --git a/apps/client/src/botsu/shell/layout.test.ts b/apps/client/src/botsu/shell/layout.test.ts
index 947afb6..552436e 100644
--- a/apps/client/src/botsu/shell/layout.test.ts
+++ b/apps/client/src/botsu/shell/layout.test.ts
@@ -4,7 +4,20 @@ import test from 'node:test';
const shellCssUrl = new URL('./shell.css', import.meta.url);
const shellFrameUrl = new URL('./BotsuFrame.tsx', import.meta.url);
+const shellNavUrl = new URL('./BotsuNav.tsx', import.meta.url);
+const shellTabUrl = new URL('./BotsuTab.tsx', import.meta.url);
+const shellLogoUrl = new URL('./BotsuLogo.tsx', import.meta.url);
+const presenceUrl = new URL('../presence/BotsuPresence.tsx', import.meta.url);
+const routerUrl = new URL('../../app/pages/Router.tsx', import.meta.url);
+const clientNonUiUrl = new URL('../../app/pages/client/ClientNonUIFeatures.tsx', import.meta.url);
+const settingsUrl = new URL('../../app/state/settings.ts', import.meta.url);
+const generalSettingsUrl = new URL('../../app/features/settings/general/General.tsx', import.meta.url);
+const settingsStylesUrl = new URL('../../app/features/settings/styles.css.ts', import.meta.url);
+const useThemeUrl = new URL('../../app/hooks/useTheme.ts', import.meta.url);
+const colorsUrl = new URL('../../colors.css.ts', import.meta.url);
+const indexCssUrl = new URL('../../index.css', import.meta.url);
const startPageUrl = new URL('../start/BotsuStartPage.tsx', import.meta.url);
+const pixelCanvasUrl = new URL('../start/BotsuPixelCanvas.tsx', import.meta.url);
const startCssUrl = new URL('../start/start.css', import.meta.url);
const getRuleBody = (css: string, selector: string): string => {
@@ -22,60 +35,311 @@ test('BOTSU shell leaves room for the active call footer', async () => {
assert.doesNotMatch(shellRule, /height:\s*100dvh;/);
});
-test('BOTSU shell puts the spaced presence strip before application navigation', async () => {
- const [css, frame] = await Promise.all([
+test('BOTSU shell keeps a minimal presence strip above the application surface', async () => {
+ const [css, frame, presence] = await Promise.all([
readFile(shellCssUrl, 'utf8'),
readFile(shellFrameUrl, 'utf8'),
+ readFile(presenceUrl, 'utf8'),
]);
const stripIndex = frame.indexOf('className="botsu-suite-strip"');
const presenceIndex = frame.indexOf('');
- const headerIndex = frame.indexOf('');
+ const mainIndex = frame.indexOf('');
assert.equal(frame.includes('className="botsu-shell-chrome"'), false);
assert.ok(stripIndex > -1);
assert.ok(presenceIndex > stripIndex);
- assert.ok(headerIndex > presenceIndex);
- assert.match(frame.slice(stripIndex, presenceIndex), /ESPACE BOTSU/);
+ assert.ok(mainIndex > presenceIndex);
+ assert.match(frame.slice(stripIndex, presenceIndex), /className="botsu-breadcrumb"/);
+ assert.match(frame, /import \{ color, config, vars \} from 'folds';/);
+ assert.match(frame, /const botsuCinnyThemeVars = \{/);
+ assert.match(frame, /'--botsu-color-canvas': color\.Background\.Container/);
+ assert.match(frame, /'--botsu-color-accent': color\.Primary\.Main/);
+ assert.match(frame, /'--botsu-font-family': 'var\(--font-secondary\)'/);
+ assert.match(frame, /style=\{botsuCinnyThemeVars as CSSProperties\}/);
+ assert.match(frame, /\{crumb\.label\}<\/Link>/);
+ assert.doesNotMatch(frame, /const isBotsuHome|className="botsu-draw-toggle"/);
assert.equal(frame.match(//g)?.length, 1);
- const header = frame.slice(headerIndex, frame.indexOf('', headerIndex));
- assert.doesNotMatch(
- header,
- /BotsuPresence|BotsuIdentity|ThemeControls|ESPACE COLLABORATIF||/
- );
- assert.match(header, /
]*>\s*