// Coque de bureau de la maquette Web.
//
// Décision de Sfar du 14/09/2026 : Web bureau, Web mobile et mobile natif sont
// trois cibles séparées, et le bureau ne diffère du Web mobile que par
// l'affichage. `app-mobile.jsx` est donc rendu à la cible téléphone, sans une
// seule lecture de disposition ; la coque de bureau vit ici et la compose depuis
// l'extérieur.
//
// Ce fichier ne tient aucun état d'application : ni route, ni session, ni
// dossier, ni conversation. Il n'appelle pas le client d'API et ne rejoue aucune
// règle métier. Il fait trois choses, toutes d'affichage :
//
// 1. il enveloppe l'application au montage dans la coquille de bureau, avec sa
// navigation latérale et sa colonne de contenu ;
// 2. il retire la barre du bas, dont la place est prise par cette colonne ;
// 3. il dit que sélectionner un patient n'est pas un changement d'écran au
// bureau, donc qu'aucun écran ne doit glisser.
//
// Il est chargé avant `app-mobile.jsx` : c'est ce fichier-là qui monte
// l'application, et l'enveloppe doit donc être posée avant.
// Correspondance route vers onglet : exactement celle de la barre du bas. Un
// dossier patient et l'assistant de création restent sous l'onglet Patients.
const VASCO_DESKTOP_ROUTE_TABS = Object.freeze({
dashboard: 'DICT', patients: 'PAT', patient: 'PAT', wizard: 'PAT',
agenda: 'AGE', aichat: 'IA', account: 'MOI',
});
// Mesure de la colonne par page, et qui défile.
// `split` : une page en deux volets paie sa marge latérale deux fois moins cher,
// chaque pixel rendu au contenu élargit le volet de détail. `page` : une vraie
// page de bureau en grille. `content` : un écran encore composé comme un
// téléphone.
// `inner` : la colonne tient dans la hauteur offerte et c'est le contenu qui
// défile, ce que réclame une grille dont les en-têtes doivent rester en place.
const VASCO_DESKTOP_ROUTE_MEASURES = Object.freeze({
dashboard: { width: 'page', scroll: 'page' },
patients: { width: 'split', scroll: 'page' },
patient: { width: 'split', scroll: 'page' },
wizard: { width: 'split', scroll: 'page' },
agenda: { width: 'page', scroll: 'inner' },
aichat: { width: 'split', scroll: 'inner' },
account: { width: 'split', scroll: 'inner' },
});
// La barre du bas n'existe pas au bureau : la navigation vit dans la colonne
// latérale de la coquille, et chaque écran continue de rendre la sienne. On la
// neutralise donc à la source, par la même réassignation de symbole global que
// les autres couches locales ; la référence figée n'est pas modifiée.
const VASCO_DESKTOP_LEGACY_TAB_BAR = TabBar;
TabBar = function VascoTabBarForLayout(props) {
if (window.VascoUI.useLayout() === 'desktop') return null;
return ;
};
window.TabBar = TabBar;
// Sélectionner un patient n'est pas un changement d'écran au bureau : la liste
// et le dossier sont la même page. La clé de transition est donc la même pour
// `patients` et `patient`, et aucun écran ne glisse entre les deux. La coque
// téléphone continue, elle, de demander son glissement : on ne lui a rien
// enlevé, c'est la présentation du mouvement qui répond autrement.
// La coque téléphone enveloppe son répertoire dans une boîte que le dossier n'a
// pas : elle ne porte qu'un repère de style. Au bureau, les deux routes rendent
// la même page, mais cette boîte leur donnerait deux formes différentes au même
// endroit de l'arbre, et React remonterait le volet de gauche à chaque ouverture
// de dossier – sa recherche, son tri et son défilement avec. On la retire, sans
// toucher à ce qu'elle contient.
function vascoDesktopUnwrapPatients(node, depth = 0) {
if (depth > 4 || !React.isValidElement(node)) return node;
if (node.props['data-vasco-screen'] === 'patients') {
// L'enfant est remonté tel quel, clé comprise. Ni `React.Children.toArray`
// ni un `cloneElement` avec une clé : les deux lui en donneraient une que
// l'élément de l'autre route n'a pas, React verrait deux éléments
// différents au même endroit, et il remonterait la page.
const children = node.props.children;
const only = Array.isArray(children) ? children[0] : children;
return React.isValidElement(only) ? only : node;
}
if (node.props.children === undefined) return node;
return React.cloneElement(node, undefined, React.Children.map(node.props.children,
(child) => vascoDesktopUnwrapPatients(child, depth + 1)));
}
const VASCO_DESKTOP_LEGACY_TRANSITION = VascoNavigationTransition;
VascoNavigationTransition = function VascoNavigationTransitionForLayout(props) {
const desktop = window.VascoUI.useLayout() === 'desktop';
const previousKey = React.useRef(null);
const screenKey = desktop && props.screenKey === 'patient' ? 'patients' : props.screenKey;
const direction = desktop && screenKey === previousKey.current ? 'none' : props.direction;
React.useEffect(() => { previousKey.current = screenKey; });
const children = desktop && screenKey === 'patients'
? vascoDesktopUnwrapPatients(props.children) : props.children;
return {children};
};
window.VascoNavigationTransition = VascoNavigationTransition;
// Recherche bornée dans un arbre React déjà rendu. Le bureau lit des propriétés
// publiques d'éléments, jamais un état interne, et s'arrête vite : la coque
// téléphone est un fichier gelé dont la forme ne bouge pas sous nos pieds, et
// une recherche profonde traverserait des écrans entiers pour rien.
function vascoDesktopFind(node, predicate, depth = 0) {
if (depth > 6 || node === null || node === undefined || typeof node === 'boolean') return null;
if (Array.isArray(node)) {
for (const child of node) {
const found = vascoDesktopFind(child, predicate, depth);
if (found) return found;
}
return null;
}
if (!React.isValidElement(node)) return null;
if (predicate(node)) return node;
return vascoDesktopFind(node.props.children, predicate, depth + 1);
}
// La route et l'aiguillage d'onglets, lus là où la coque téléphone les publie
// déjà. `VascoNavigationTransition` porte la route dans `screenKey`. L'écran
// rendu reçoit le même `handleTab` que la barre du bas, sous le nom
// `onTabChange` pour les écrans de la V6, `onChangeTab` pour le tableau de bord,
// `onChange` pour la barre d'onglets posée à côté du dossier patient. C'est
// exactement ce que la barre du bas recevrait ; la colonne latérale ne crée
// donc aucun second chemin de navigation.
function vascoDesktopShellContext(tree) {
const transition = vascoDesktopFind(tree,
(element) => typeof element.props.screenKey === 'string');
if (!transition) return null;
const screen = vascoDesktopFind(transition.props.children,
(element) => typeof element.props.onTabChange === 'function'
|| typeof element.props.onChangeTab === 'function');
const tabBar = vascoDesktopFind(transition.props.children,
(element) => typeof element.props.onChange === 'function'
&& typeof element.props.active === 'string');
const onTab = (screen && (screen.props.onTabChange || screen.props.onChangeTab))
|| (tabBar && tabBar.props.onChange) || null;
const accent = (screen && screen.props.accent) || (tabBar && tabBar.props.accent) || undefined;
return { route: transition.props.screenKey, onTab, accent };
}
// Coquille : navigation à gauche, colonne de contenu centrée à droite. Sous le
// seuil, `AppShell` disparaît complètement, pas un élément, pas un style, et la
// scène téléphone validée reste exactement celle d'avant.
//
// Pas de navigation tant que la connexion ou l'accueil tiennent l'écran : la
// coque téléphone ne monte alors aucune route, et une colonne d'onglets derrière
// un écran de connexion promettrait une application ouverte.
function vascoDesktopShellFor(App) {
const shell = function VascoAppForLayout(props) {
const desktop = window.VascoUI.useLayout() === 'desktop';
// Appel inconditionnel : les états de la coque téléphone appartiennent alors
// à ce composant et y restent stables d'un rendu à l'autre. C'est le
// mécanisme déjà employé par `document-import.jsx` et `legacy-surfaces.jsx`.
const tree = App(props);
if (!desktop) return tree;
const context = vascoDesktopShellContext(tree);
// Sans route lisible, la coque téléphone n'a monté ni écran ni navigation :
// connexion, accueil ou erreur. On rend son arbre tel quel plutôt que
// d'inventer une coquille autour d'un écran qui occupe déjà la fenêtre.
if (!context || !context.onTab) return tree;
const measure = VASCO_DESKTOP_ROUTE_MEASURES[context.route]
|| { width: 'content', scroll: 'page' };
return }>
{tree}
;
};
Object.defineProperty(shell, 'name', { value: 'VascoAppForLayout', configurable: true });
return shell;
}
// L'enveloppe est posée au montage plutôt qu'autour du composant : `app-mobile`
// monte son application lui-même, et c'est un fichier de la cible téléphone
// qu'on ne modifie pas. Le UI Kit, lui, est un catalogue qui démontre déjà les
// deux dispositions : il n'est pas enveloppé.
(function vascoWrapDesktopMount(reactDom) {
const createRoot = reactDom.createRoot;
const shells = new Map();
reactDom.createRoot = function vascoCreateRootWithDesktopShell(container, options) {
const root = createRoot.call(reactDom, container, options);
const render = root.render.bind(root);
root.render = (element) => {
if (VASCO_UI_KIT_MODE || !React.isValidElement(element)
|| typeof element.type !== 'function') {
return render(element);
}
// Une seule enveloppe par type monté : un nouveau composant à chaque rendu
// remonterait toute l'application.
if (!shells.has(element.type)) shells.set(element.type, vascoDesktopShellFor(element.type));
return render(React.createElement(shells.get(element.type), element.props));
};
return root;
};
})(ReactDOM);