// 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);