feat(botsu): park documents behind files

This commit is contained in:
2026-08-04 18:20:38 +02:00
parent 511b37d5a8
commit 18237d5832
11 changed files with 51 additions and 210 deletions
+12 -9
View File
@@ -78,6 +78,7 @@ import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { BotsuEmbed, BotsuFrame, BotsuLauncher, BotsuNav, BotsuServices } from '../../botsu/shell';
import { BotsuDocumentEditor, BotsuDocuments } from '../../botsu/documents';
import { DocumentSyncProvider } from '../../botsu/documents/DocumentSyncContext';
import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
@@ -136,15 +137,17 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<ClientBindAtoms>
<ClientNonUIFeatures>
<CallEmbedProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
<DocumentSyncProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
</DocumentSyncProvider>
<CallStatusRenderer />
</CallEmbedProvider>
<SearchModalRenderer />
+1 -1
View File
@@ -37,7 +37,7 @@ test('catalogue identifiers and native paths are unique', () => {
test('catalogue records live and planned applications explicitly', () => {
assert.equal(getApp('discussions')?.availability, 'available');
assert.equal(getApp('documents')?.availability, 'available');
assert.equal(getApp('documents')?.availability, 'planned');
assert.equal(getApp('tables')?.launch.mode, 'iframe');
assert.equal(getApp('transfers')?.launch.mode, 'external');
assert.equal(getApp('missing'), undefined);
+2 -2
View File
@@ -34,9 +34,9 @@ export const botsuApps: readonly BotsuApp[] = [
{
id: 'documents',
label: 'Documents',
description: 'Brouillons BOTSU natifs, collaboration Yjs en préparation.',
description: 'Pause: socle fichiers prioritaire avant les documents collaboratifs.',
roles: ['member', 'admin'],
availability: 'available',
availability: 'planned',
launch: { mode: 'native', path: '/botsu/documents/' },
},
{
+14 -17
View File
@@ -2,7 +2,6 @@ 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 { DocumentSyncProvider } from '../documents/DocumentSyncContext';
import { PixelCanvasProvider } from '../start/PixelCanvasContext';
import '@botsu/ui/styles.css';
import './shell.css';
@@ -49,22 +48,20 @@ export function BotsuFrame() {
return (
<div ref={rootRef} className="botsu-theme botsu-shell" style={botsuCinnyThemeVars as CSSProperties}>
<PixelCanvasProvider>
<DocumentSyncProvider>
<div className="botsu-suite-strip">
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={crumb.to}>
{index > 0 && <span aria-hidden="true">/</span>}
<Link to={crumb.to}>{crumb.label}</Link>
</React.Fragment>
))}
</nav>
<BotsuPresence />
</div>
<main className="botsu-shell-main">
<Outlet />
</main>
</DocumentSyncProvider>
<div className="botsu-suite-strip">
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={crumb.to}>
{index > 0 && <span aria-hidden="true">/</span>}
<Link to={crumb.to}>{crumb.label}</Link>
</React.Fragment>
))}
</nav>
<BotsuPresence />
</div>
<main className="botsu-shell-main">
<Outlet />
</main>
</PixelCanvasProvider>
</div>
);
+3 -3
View File
@@ -56,8 +56,7 @@ test('BOTSU shell keeps a minimal presence strip above the application surface',
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, /<DocumentSyncProvider>/);
assert.ok(frame.indexOf('<DocumentSyncProvider>') < frame.indexOf('<BotsuPresence />'));
assert.doesNotMatch(frame, /DocumentSyncProvider/);
assert.match(frame, /<Link to=\{crumb\.to\}>\{crumb\.label\}<\/Link>/);
assert.doesNotMatch(frame, /const isBotsuHome|className="botsu-draw-toggle"/);
assert.equal(frame.match(/<BotsuPresence \/>/g)?.length, 1);
@@ -256,6 +255,7 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /<BotsuNavLabel icon=\{getAppNavIcon\(app\.id\)\}>/);
assert.match(nav, /<BotsuNavLabel icon=\{getServiceNavIcon\(service\.label\)\}>/);
assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/);
assert.match(nav, /APP_NAV_IDS = new Set\(\['discussions', 'documents', 'tables', 'files'\]\)/);
assert.match(nav, /appId === 'documents'\) return Icons\.File/);
assert.match(nav, /appId === 'tables'\) return Icons\.Category/);
assert.match(nav, /appId === 'files'\) return Icons\.Attachment/);
@@ -313,7 +313,7 @@ test('BOTSU start page exposes a single-screen searchable draw surface', async (
assert.doesNotMatch(startPage, /presence-api|espace de dessin communautaire/);
assert.match(startPage, /<BotsuPresence \/>/);
assert.match(startPage, /<PixelCanvasProvider>/);
assert.match(startPage, /récents/);
assert.doesNotMatch(startPage, /botsu-start-recents|récents/);
assert.doesNotMatch(startPage, /botsu-start-section--apps|botsu-start-section--services/);
assert.doesNotMatch(startCss, /botsu-start-links|botsu-start-section/);
assert.match(startPage, /variant="background"/);
+2 -46
View File
@@ -6,13 +6,8 @@ import { botsuApps, getVisibleApps, type BotsuRole } from '../apps/catalog';
import { buildLaunchPlan } from '../apps/launcher';
import { BotsuPresence } from '../presence/BotsuPresence';
import { PixelCanvasProvider } from './PixelCanvasContext';
import {
getOptionalBotsuDocumentPath,
listBotsuDocumentDrafts,
type BotsuDocumentDraft,
} from '../documents/model';
import { getBotsuEmbedPath } from '../../app/pages/pathUtils';
import { formatBotsuClock, getRecentBotsuDocuments, searchBotsuStartItems } from './model';
import { formatBotsuClock, searchBotsuStartItems } from './model';
import { BotsuPixelCanvas } from './BotsuPixelCanvas';
import {
createCookieController,
@@ -92,25 +87,7 @@ const botsuCinnyThemeVars = {
'--botsu-font-family': 'var(--font-secondary)',
} satisfies BotsuThemeStyle;
const readLocalDrafts = (): BotsuDocumentDraft[] => {
try {
return listBotsuDocumentDrafts(window.localStorage);
} catch {
return [];
}
};
function StartSearchAction({ item }: { item: ReturnType<typeof searchBotsuStartItems>[number] }) {
if (item.kind === 'document') {
const path = getOptionalBotsuDocumentPath(item.document.id);
return path ? (
<Link className="botsu-start-result" to={path}>
<strong>{item.label}</strong>
<span>{item.description}</span>
</Link>
) : null;
}
const plan = buildLaunchPlan(item.app);
const content = (
<>
@@ -146,7 +123,6 @@ export function BotsuStartPage() {
const mx = useMatrixClient();
const [now, setNow] = useState(() => new Date());
const [query, setQuery] = useState('');
const [drafts] = useState(readLocalDrafts);
const [cookies, setCookies] = useState<CookieClientState>(createInitialCookieState);
const [cookieHudVisible, setCookieHudVisible] = useState(false);
const [averageClicksPerSecond, setAverageClicksPerSecond] = useState(0);
@@ -158,8 +134,7 @@ export function BotsuStartPage() {
const startPageRef = useRef<HTMLDivElement>(null);
const reducedMotionRef = useRef(false);
const apps = useMemo(() => getVisibleApps(new Set<BotsuRole>(['member']), botsuApps), []);
const recentDocuments = useMemo(() => getRecentBotsuDocuments(drafts, 3), [drafts]);
const results = useMemo(() => searchBotsuStartItems(query, apps, drafts), [apps, drafts, query]);
const results = useMemo(() => searchBotsuStartItems(query, apps, []), [apps, query]);
const cookieVelocity = averageClicksPerSecond * cookies.cookiesPerClick + cookies.cookiesPerSecond;
useEffect(() => {
@@ -319,25 +294,6 @@ export function BotsuStartPage() {
</div>
)}
</form>
{recentDocuments.length > 0 && (
<section className="botsu-start-recents" aria-labelledby="botsu-start-recents-title">
<h3 id="botsu-start-recents-title">récents</h3>
<ul>
{recentDocuments.map((draft) => {
const path = getOptionalBotsuDocumentPath(draft.id);
return path ? (
<li key={draft.id}>
<Link to={path}>
<strong>{draft.title}</strong>
<span>document</span>
</Link>
</li>
) : null;
})}
</ul>
</section>
)}
</section>
{cookieHudVisible && (
<footer className="botsu-start-cookie-footer" aria-live="polite" role="status">
+3 -3
View File
@@ -46,14 +46,14 @@ test('selects recent local documents without mutating the source', () => {
assert.throws(() => getRecentBotsuDocuments(source, 0), TypeError);
});
test('searches available applications and local documents with bounded normalized input', () => {
test('searches available applications without exposing paused local documents', () => {
assert.deepEqual(
searchBotsuStartItems(' DOCUMENTS ', apps, drafts).map((item) => [item.kind, item.label]),
[['app', 'Documents']]
[]
);
assert.deepEqual(
searchBotsuStartItems('reunion', apps, drafts).map((item) => [item.kind, item.label]),
[['document', 'Réunion produit']]
[]
);
assert.deepEqual(searchBotsuStartItems('tableaux', apps, drafts), []);
assert.deepEqual(searchBotsuStartItems('', apps, drafts), []);
+9 -28
View File
@@ -46,20 +46,13 @@ export const formatBotsuClock = (date: Date): string => {
};
export type BotsuStartSearchItem =
| {
kind: 'app';
id: string;
label: string;
description: string;
app: BotsuApp;
}
| {
kind: 'document';
id: string;
label: string;
description: string;
document: BotsuDocumentDraft;
};
{
kind: 'app';
id: string;
label: string;
description: string;
app: BotsuApp;
};
const normalizeSearchText = (value: string): string =>
value
@@ -88,6 +81,7 @@ export const searchBotsuStartItems = (
if (!needle) return [];
const appItems: BotsuStartSearchItem[] = apps.flatMap((app) => {
if (app.id === 'documents') return [];
if (app.availability !== 'available') return [];
const haystack = normalizeSearchText(`${app.label} ${app.description}`);
if (!haystack.includes(needle)) return [];
@@ -102,18 +96,5 @@ export const searchBotsuStartItems = (
];
});
const documentItems: BotsuStartSearchItem[] = drafts.flatMap((draft) => {
if (!normalizeSearchText(draft.title).includes(needle)) return [];
return [
{
kind: 'document',
id: draft.id,
label: draft.title,
description: 'Document local récent',
document: draft,
},
];
});
return [...appItems, ...documentItems].slice(0, MAXIMUM_SEARCH_RESULTS);
return appItems.slice(0, MAXIMUM_SEARCH_RESULTS);
};
+1 -65
View File
@@ -247,8 +247,7 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
.botsu-start-search-block input:focus-visible,
.botsu-start-result:focus-visible,
.botsu-start-web-result:focus-visible,
.botsu-start-recents a:focus-visible {
.botsu-start-web-result:focus-visible {
outline: 2px solid var(--botsu-color-focus);
outline-offset: 3px;
}
@@ -310,65 +309,6 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
white-space: nowrap;
}
.botsu-start-recents {
grid-row: 4;
align-self: start;
width: min(36rem, 100%);
display: grid;
gap: 0.65rem;
margin-top: clamp(0.2rem, 2vh, 1rem);
}
.botsu-start-recents h3 {
margin: 0;
color: color-mix(in srgb, var(--botsu-color-text) 42%, transparent);
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.08em;
text-align: center;
text-transform: lowercase;
}
.botsu-start-recents ul {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.botsu-start-recents a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
padding: 0.1rem 0;
color: color-mix(in srgb, var(--botsu-color-text) 62%, transparent);
font-size: 0.78rem;
text-decoration: none;
}
.botsu-start-recents strong,
.botsu-start-recents span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.botsu-start-recents strong {
font-weight: 400;
}
.botsu-start-recents span {
color: color-mix(in srgb, var(--botsu-color-text) 36%, transparent);
font-size: 0.7rem;
}
.botsu-start-recents a:hover {
color: var(--botsu-color-text);
text-decoration: underline;
text-underline-offset: 0.28em;
}
.botsu-pixel-canvas-frame {
display: grid;
gap: 0.65rem;
@@ -484,10 +424,6 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
gap: 0.75rem;
padding-block: 1.4rem;
}
.botsu-start-recents {
display: none;
}
}
@media (prefers-reduced-transparency: reduce) {
@@ -18,11 +18,9 @@ import { getOptionalBotsuDocumentPath } from '../documents/model';
import { readMatrixWorkspaceState } from './state';
import { canEditMatrixWorkspace } from './permissions';
import {
addLocalDocumentWorkspaceDraftTab,
addWorkspaceDraftTab,
buildMatrixWorkspaceEvent,
createWorkspaceSubmissionLock,
getSuggestedWorkspaceDocumentTitle,
hasLiveWorkspaceEventConflict,
readWorkspaceEditorSnapshot,
removeWorkspaceDraftTab,
@@ -102,27 +100,6 @@ export function BotsuWorkspaceBar({ room }: { room: Room }) {
);
};
const addDocumentTab = () => {
try {
setDraftTabs(
(current) =>
addLocalDocumentWorkspaceDraftTab(
current,
window.localStorage,
() => crypto.randomUUID(),
() => crypto.randomUUID(),
Date.now,
getSuggestedWorkspaceDocumentTitle(room)
).tabs
);
setError(undefined);
} catch {
setError({
kind: 'validation',
message: 'Impossible de créer le document local. Vérifie le stockage du navigateur.',
});
}
};
const submit: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
@@ -293,16 +270,7 @@ export function BotsuWorkspaceBar({ room }: { room: Room }) {
>
+
</button>
<button
aria-label="Créer et attacher un document"
className={css.WorkspaceIconButton}
title="Créer et attacher un document"
type="button"
onClick={addDocumentTab}
disabled={draftTabs.length >= MAXIMUM_WORKSPACE_TABS}
>
d+
</button>
<button
aria-label="Fermer la configuration Botsu"
className={css.WorkspaceIconButton}
@@ -195,9 +195,9 @@ test('conversation workspace bar stays discreet with integrated controls', async
assert.match(bar, />\s*\+\s*<\/button>/);
assert.match(bar, />\s*×\s*<\/button>/);
assert.match(bar, />\s*✓\s*<\/button>/);
assert.match(bar, /aria-label="Créer et attacher un document"/);
assert.match(bar, /getSuggestedWorkspaceDocumentTitle\(room\)/);
assert.match(bar, /addLocalDocumentWorkspaceDraftTab/);
assert.doesNotMatch(bar, /aria-label="Créer et attacher un document"/);
assert.doesNotMatch(bar, /getSuggestedWorkspaceDocumentTitle\(room\)/);
assert.doesNotMatch(bar, /addLocalDocumentWorkspaceDraftTab/);
assert.match(bar, /<Link className=\{css\.WorkspaceTabLink\} to=\{documentPath\}>/);
assert.match(css, /export const WorkspaceIconButton = style/);
assert.match(css, /backgroundColor: 'transparent'/);