// Design system officiel du Web Vasco : la moitié bureau. // // Décision de Sfar du 14/09/2026 : « Web bureau, Web mobile et mobile natif sont // trois cibles séparées. » `ui-system.jsx` reste donc exactement le design // system de la cible téléphone, celui que les contrôleurs de parité mobile // natif traduisent ligne à ligne. Ce fichier-ci porte la moitié bureau : le hook // de disposition, les primitives qui n'existent que là où la place existe, et // l'hôte qui recadre les surfaces héritées. Rien n'y est recopié du téléphone : // les primitives partagées restent celles de `ui-system.jsx`, consommées ici par // `window.VascoUI`. // // Les deux moitiés forment un seul design system : ce fichier republie // `window.VascoUI` en lui ajoutant ses membres. Le geler à nouveau garde la // promesse d'origine : aucun écran n'ajoute une primitive après coup. // Le contrat visuel est la seule source des valeurs, comme pour la moitié // téléphone. On ne relit pas `VASCO_UI_TOKENS` par son nom nu : le même objet // est accessible ici sans dépendre de la portée de script de Babel. const VASCO_DESKTOP_TOKENS = window.VascoDesignContract; // Moitié téléphone, capturée avant republication. const VASCO_PHONE_UI = window.VascoUI; // Disposition courante, lue depuis l'autorité unique de `layout-mode.js`. Une // primitive qui doit se placer autrement au bureau consomme ce hook ; elle ne // mesure jamais la fenêtre elle-même. function VascoUseLayout() { const authority = window.VascoLayout; const [mode, setMode] = React.useState(() => (authority ? authority.current() : 'phone')); React.useEffect(() => { if (!authority) return undefined; // Une largeur peut avoir changé entre le premier rendu et l'abonnement. setMode(authority.current()); return authority.subscribe(setMode); }, [authority]); return mode; } // Second seuil de l'autorité : au bureau, la grille de page se scinde en deux // colonnes ou reste empilée. Même mécanique que la disposition, même source. function VascoUseColumns() { const authority = window.VascoLayout; const [columns, setColumns] = React.useState( () => (authority && authority.columns ? authority.columns() : 'single'), ); React.useEffect(() => { if (!authority || !authority.subscribeColumns) return undefined; setColumns(authority.columns()); return authority.subscribeColumns(setColumns); }, [authority]); return columns; } // Les cinq entrées de la navigation Vasco. Les libellés et les tracés sont ceux // de la barre du bas de la référence figée : au bureau on change la place de la // navigation, jamais ses symboles. const VASCO_NAV_ITEMS = Object.freeze([ Object.freeze({ id: 'DICT', label: 'Dictée' }), Object.freeze({ id: 'PAT', label: 'Patients' }), Object.freeze({ id: 'AGE', label: 'Agenda' }), Object.freeze({ id: 'IA', label: 'Assistant' }), Object.freeze({ id: 'MOI', label: 'Compte' }), ]); function VascoNavIcon({ id, color, size = VASCO_DESKTOP_TOKENS.layout.sideNav.iconSize }) { const shapes = { DICT: <>, PAT: <>, AGE: <>, IA: <>, MOI: <> , }; return ; } // Navigation latérale du bureau. Elle appelle exactement le même `onTab(id)` que // la barre du bas : un seul aiguillage d'onglets pour les deux dispositions. function VascoSideNav({ active = null, onTab, accent = VASCO_DESKTOP_TOKENS.color.accent, items = VASCO_NAV_ITEMS }) { const nav = VASCO_DESKTOP_TOKENS.layout.sideNav; return ; } // En-tête d'une page de bureau : le repère de contexte, le titre, la phrase qui // résume la journée, et les actions de la page alignées sur la même ligne de // base. Les écrans ne recomposent pas cet assemblage. function VascoPageHeader({ eyebrow = null, title, subtitle = null, actions = null, style = {} }) { const page = VASCO_DESKTOP_TOKENS.layout.page; return
{eyebrow &&
{eyebrow}
}

{title}

{subtitle &&
{subtitle}
}
{actions &&
{actions}
}
; } // Grille de page à douze colonnes. Ses enfants sont des colonnes qui déclarent // leur `span` ; sous le seuil de scission, la grille n'a plus qu'une colonne et // les panneaux s'empilent plutôt que d'écraser la colonne latérale en bande. function VascoPageGrid({ children, style = {} }) { const grid = VASCO_DESKTOP_TOKENS.layout.grid; const split = VascoUseColumns() === 'split'; return
{children}
; } // Colonne de la grille : elle empile ses panneaux avec la gouttière de la grille // et occupe la largeur entière dès que la grille repasse en colonne unique. function VascoPageColumn({ span, children, style = {} }) { const grid = VASCO_DESKTOP_TOKENS.layout.grid; const split = VascoUseColumns() === 'split'; const width = Math.min(Number(span) || grid.columns, grid.columns); return
{children}
; } // Panneau d'une page de bureau. Il porte son titre, son décompte, son action, et // surtout ses états : occupé, vide, indisponible. Un panneau dont la donnée // manque n'affiche jamais une moitié de contenu, il dit pourquoi. function VascoPanel({ title, count = null, action = null, children = null, footer = null, busy = false, empty = null, unavailable = null, onRetry = null, retryLabel = 'Réessayer', busyLabel = 'Chargement…', style = {} }) { const panel = VASCO_DESKTOP_TOKENS.layout.panel; const { InlineAction } = VASCO_PHONE_UI; const body = unavailable ?
{unavailable} {onRetry &&
{retryLabel}
}
: busy ?
{busyLabel}
: empty ?
{empty}
:
{children}
; return

{title} {count !== null && {count}}

{action}
{body}
{footer &&
{footer}
}
; } // Masque, dans un arbre déjà rendu, la commande qui déclenche un geste devenu // inutile. Une page de bureau qui montre déjà la liste n'a pas besoin du retour // vers cette liste : la commande disparaît, le geste reste disponible ailleurs. // Même mécanique que `VascoReplaceCloseAction`, qui repère la commande par son // gestionnaire plutôt que par sa place dans le balisage. function VascoHideAction(node, handler, depth = 0) { if (!handler || depth > 6) return node; return React.Children.map(node, (child) => { if (!React.isValidElement(child)) return child; if (child.props.onClick === handler) { return React.cloneElement(child, { 'data-vasco-hidden-action': 'true', style: { ...child.props.style, display: 'none' }, }); } if (child.props.children === undefined) return child; return React.cloneElement(child, undefined, VascoHideAction(child.props.children, handler, depth + 1)); }); } // Grille horaire de bureau : un axe des heures à gauche, des colonnes de jours à // droite. La primitive ne connaît ni rendez-vous ni agenda : elle reçoit une // géométrie (minutes de début et de fin, hauteur d'une heure, hauteur totale), // des libellés d'heures déjà calculés, et le contenu déjà positionné de chaque // colonne. Les calculs restent là où ils vivent déjà. // L’encre du « rien à faire ici » : la même que celle des heures révolues du // téléphone, à la même densité. Une journée de congé se lit en gris, jamais en // rouge – le rouge est celui du danger. const VASCO_DESKTOP_ABSENT_HATCH = 'repeating-linear-gradient(135deg, rgba(10,10,10,0.05) 0 5px, rgba(10,10,10,0) 5px 11px)'; const VASCO_DESKTOP_ABSENT_TINT = 'rgba(10,10,10,0.06)'; function VascoTimeGrid({ geometry, hours = [], columns = [], nowOffset = null, ariaLabel = 'Grille horaire', axisWidth, scrollRef = null, style = {} }) { const agenda = VASCO_DESKTOP_TOKENS.layout.agenda; const gutter = Number.isFinite(axisWidth) ? axisWidth : VASCO_DESKTOP_TOKENS.control.large; return
{/* Les en-têtes de jours restent visibles quand la journée défile : une colonne sans son jour ne se lit plus. */}
{hours.map((hour) =>
; } // Bloc d'un rendez-vous dans une grille horaire. Sa place et sa colonne lui sont // données : la primitive ne calcule ni horaire ni chevauchement, elle les rend. function VascoCalendarEvent({ top, height, column = 0, columns = 1, tone = null, title, subtitle = null, meta = null, ariaLabel, onSelect = null, selected = false }) { const width = 100 / Math.max(1, columns); const accent = tone || VASCO_DESKTOP_TOKENS.color.accent; const body = {meta && {meta}} {title} {subtitle && {subtitle}} ; const placement = { position: 'absolute', top, height: Math.max(height, VASCO_DESKTOP_TOKENS.space.xl), left: `calc(${column * width}% + ${VASCO_DESKTOP_TOKENS.space.xs}px)`, width: `calc(${width}% - ${VASCO_DESKTOP_TOKENS.space.sm}px)`, padding: `${VASCO_DESKTOP_TOKENS.space.xs}px ${VASCO_DESKTOP_TOKENS.space.sm}px`, borderRadius: VASCO_DESKTOP_TOKENS.radius.sm, borderLeft: `3px solid ${accent}`, background: selected ? VASCO_DESKTOP_TOKENS.color.accentTint : VASCO_DESKTOP_TOKENS.color.surfaceAlt, boxShadow: VASCO_DESKTOP_TOKENS.shadow.small, overflow: 'hidden', display: 'block', textAlign: 'left', fontFamily: VASCO_DESKTOP_TOKENS.type.body, }; if (!onSelect) { return
{body}
; } return ; } // Page de bureau en deux volets : une liste à gauche, le détail de l'élément // choisi à droite. Chaque volet défile pour son compte, garde son étiquette et // ne dépend pas de l'autre : changer de détail ne bouge pas la liste. function VascoSplitView({ list, detail, listLabel = 'Liste', detailLabel = 'Détail', style = {} }) { const split = VASCO_DESKTOP_TOKENS.layout.split; const narrow = VascoUseColumns() !== 'split'; return
{list}
{/* `minWidth: 0` plutôt que la mesure minimale : un volet qui refuserait de se réduire déborderait la page sur une fenêtre étroite. La mesure reste la cible de composition, pas une contrainte qui casse la mise en page. */}
{detail}
; } // Hôte de présentation des surfaces héritées de la V5. // // Les feuilles de la référence figée n'ont pas de composant commun : chacune // recopie la même forme à trois éléments, un cadre plein écran, un voile et un // panneau ancré en bas. On ne les corrige donc pas une par une : cet hôte reçoit // l'arbre déjà rendu d'une feuille et recadre ses trois éléments au bureau. Le // voile reste celui de la feuille, il n'y en a jamais deux ; la croix et le // contenu restent ceux de la feuille, aucune commande n'est ajoutée ni orpheline. // Sous le seuil, l'arbre est rendu tel quel, sans un attribut de plus. function vascoIsLegacyVeil(node) { const style = React.isValidElement(node) ? node.props.style : null; return Boolean(style && style.inset === 0 && style.position === 'absolute' && typeof style.background === 'string' && style.background.startsWith('rgba(')); } // Deux ancrages coexistent dans la référence : le panneau collé aux trois bords // (`bottom/left/right` à zéro) et le panneau en retrait de quelques pixels. Les // deux sont des feuilles basses ; seule la valeur des bords change. function vascoIsLegacyBottomPanel(node) { const style = React.isValidElement(node) ? node.props.style : null; return Boolean(style && style.position === 'absolute' && style.bottom !== undefined && style.left !== undefined && style.right !== undefined && style.top === undefined); } // La poignée de glissement des feuilles V5 est une barre de 36 sur 4. Elle // annonce un geste qui n'existe pas sur un dialogue centré : on la masque au // bureau, sans la retirer de la référence ni toucher au reste du contenu. function vascoHideLegacyHandle(node, depth = 0) { if (Array.isArray(node)) return node.map((child) => vascoHideLegacyHandle(child, depth)); if (!React.isValidElement(node) || depth > 2) return node; const style = node.props.style; if (style && style.width === 36 && style.height === 4) { return React.cloneElement(node, { style: { ...style, display: 'none' } }); } if (node.props.children === undefined) return node; return React.cloneElement(node, undefined, ...React.Children.toArray(node.props.children) .map((child) => vascoHideLegacyHandle(child, depth + 1))); } function VascoLegacySurfaceFrame({ tree, width = 'dialog', variant = 'sheet', label = null }) { const desktop = VascoUseLayout() === 'desktop'; const [visible, setVisible] = React.useState(false); React.useEffect(() => { if (!desktop) return undefined; // Deux images : la position d'entrée est peinte avant que la transition // commence, comme pour la feuille de formulaire officielle. let revealFrame = null; const mountFrame = window.requestAnimationFrame(() => { revealFrame = window.requestAnimationFrame(() => setVisible(true)); }); return () => { window.cancelAnimationFrame(mountFrame); if (revealFrame !== null) window.cancelAnimationFrame(revealFrame); }; }, [desktop]); // Certaines surfaces ont un fragment pour racine : leur voile et leur panneau // sont alors des frères sans conteneur. On leur en donne un plutôt que de // poser un style sur un fragment, qui n'en porte pas. const fragmentRoot = React.isValidElement(tree) && tree.type === React.Fragment; const children = React.isValidElement(tree) ? React.Children.toArray(tree.props.children) : []; const veil = children.find(vascoIsLegacyVeil) || null; // La feuille ferme déjà par son voile : on réutilise ce geste pour Échap au // lieu d'exiger une propriété que la référence figée ne donne pas. const requestClose = veil && typeof veil.props.onClick === 'function' ? veil.props.onClick : null; React.useEffect(() => { if (!desktop || !requestClose) return undefined; const closeOnEscape = (event) => { if (event.key === 'Escape') requestClose(event); }; document.addEventListener('keydown', closeOnEscape); return () => document.removeEventListener('keydown', closeOnEscape); }, [desktop, requestClose]); if (!desktop || !React.isValidElement(tree)) return tree; const maxWidth = width === 'wide' ? VASCO_DESKTOP_TOKENS.layout.dialog.wideMaxWidth : VASCO_DESKTOP_TOKENS.layout.dialog.maxWidth; const duration = visible ? VASCO_DESKTOP_TOKENS.motion.formEnter : VASCO_DESKTOP_TOKENS.motion.formExit; if (variant === 'takeover') { // Un parcours qui prend tout l'écran au téléphone doit prendre toute la // fenêtre au bureau, navigation comprise : sinon il s'enferme dans la // colonne de contenu sans jamais l'avoir demandé. return React.cloneElement(tree, { style: { ...tree.props.style, position: 'fixed', inset: 0 }, }); } const reframed = children.map((child, index) => { if (!React.isValidElement(child)) return child; if (vascoIsLegacyVeil(child)) { return React.cloneElement(child, { key: child.key || `veil-${index}`, style: { ...child.props.style, position: 'fixed', inset: 0 }, }); } if (!vascoIsLegacyBottomPanel(child)) return child; const body = React.Children.toArray(child.props.children) .map((grandChild) => vascoHideLegacyHandle(grandChild)); return React.cloneElement(child, { key: child.key || `panel-${index}`, className: [child.props.className, 'vui-legacy-surface-panel'].filter(Boolean).join(' '), role: 'dialog', 'aria-modal': 'true', 'aria-label': label || child.props['aria-label'] || undefined, style: { ...child.props.style, position: 'relative', bottom: 'auto', left: 'auto', right: 'auto', width: '100%', maxWidth, margin: '0 auto', borderRadius: VASCO_DESKTOP_TOKENS.radius.sheet, // Le glissement depuis le bas de la référence n'a plus de sens pour un // dialogue centré : on le neutralise et on entre avec le même « lift » // que FormSheet. animation: 'none', paddingBottom: VASCO_DESKTOP_TOKENS.space.xl, boxShadow: VASCO_DESKTOP_TOKENS.shadow.large, opacity: visible ? 1 : 0, transform: visible ? 'translateY(0) scale(1)' : 'translateY(8px) scale(.975)', transition: `opacity ${duration}ms ${VASCO_DESKTOP_TOKENS.motion.form}, transform ${duration}ms ${VASCO_DESKTOP_TOKENS.motion.form}`, }, }, ...body); }); const frameStyle = { ...(fragmentRoot ? null : tree.props.style), position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: VASCO_DESKTOP_TOKENS.space.xxxl, zIndex: (!fragmentRoot && tree.props.style && tree.props.style.zIndex) || VASCO_DESKTOP_TOKENS.layer.overlay, }; const frameChildren = [...reframed, ]; if (fragmentRoot) { return
{frameChildren}
; } return React.cloneElement(tree, { 'data-vasco-legacy-surface': width, style: frameStyle, }, ...frameChildren); } // Coquille de bureau : navigation à gauche, colonne de contenu centrée à droite. // Sous le seuil elle disparaît complètement – pas un élément, pas un style – pour // que la scène téléphone validée reste exactement celle d'avant. // `width` choisit la mesure de la colonne : `content` pour un écran encore // composé comme un téléphone, `page` pour une vraie page de bureau en grille. // `contentScroll` dit qui défile. `page` : la colonne défile, c'est le cas de // tous les écrans encore composés en hauteur libre. `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. function VascoAppShell({ nav = null, width = 'content', contentScroll = 'page', children }) { if (VascoUseLayout() !== 'desktop') return <>{children}; // 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, qui est le plus contraint. const measure = width === 'split' ? { ...VASCO_DESKTOP_TOKENS.layout.page, paddingX: VASCO_DESKTOP_TOKENS.layout.split.paddingX } : width === 'page' ? VASCO_DESKTOP_TOKENS.layout.page : VASCO_DESKTOP_TOKENS.layout.content; return
{nav}
{children}
; } // L'encoche iOS n'existe qu'au bureau : l'en-tête d'écran garde exactement son // assemblage, seule sa réserve haute change. On recompose donc son arbre au lieu // d'en écrire un second – les deux valeurs viennent du contrat. function VascoDesktopScreenHeader(props) { const desktop = VascoUseLayout() === 'desktop'; const tree = VASCO_PHONE_UI.ScreenHeader(props); if (!desktop || !React.isValidElement(tree)) return tree; const header = VASCO_DESKTOP_TOKENS.layout.header; return React.cloneElement(tree, { 'data-vasco-screen-header': 'desktop', style: { ...tree.props.style, padding: `${header.desktopTopInset}px 18px 12px` }, }); } // Les mesures que la feuille de style bureau doit connaître, publiées depuis le // contrat visuel plutôt que recopiées dans le CSS. `desktop-layout.css` recadre // des primitives dont le style est posé en ligne : il lui faut ces valeurs, et // il n'y a qu'une source possible. (function vascoPublishDesktopCssVariables() { const root = document.documentElement; // Un contexte de test peut n'avoir qu'un document synthétique : on n'y publie // rien plutôt que d'exiger une vraie feuille de style en ligne. if (!root || !root.style || typeof root.style.setProperty !== 'function') return; const variables = { '--vasco-dialog-max-width': `${VASCO_DESKTOP_TOKENS.layout.dialog.maxWidth}px`, '--vasco-radius-sheet': `${VASCO_DESKTOP_TOKENS.radius.sheet}px`, '--vasco-shadow-large': VASCO_DESKTOP_TOKENS.shadow.large, '--vasco-space-xxxl': `${VASCO_DESKTOP_TOKENS.space.xxxl}px`, '--vasco-split-padding-x': `${VASCO_DESKTOP_TOKENS.layout.split.paddingX}px`, '--vasco-form-enter': `${VASCO_DESKTOP_TOKENS.motion.formEnter}ms`, '--vasco-form-exit': `${VASCO_DESKTOP_TOKENS.motion.formExit}ms`, '--vasco-form-ease': VASCO_DESKTOP_TOKENS.motion.form, }; for (const [name, value] of Object.entries(variables)) root.style.setProperty(name, value); })(); // Republication du design system complet. `window.VascoUI` reste gelé : les deux // moitiés se rejoignent ici, et aucun écran n'y ajoute quoi que ce soit ensuite. window.VascoUI = Object.freeze({ ...VASCO_PHONE_UI, ScreenHeader: VascoDesktopScreenHeader, AppShell: VascoAppShell, SideNav: VascoSideNav, PageHeader: VascoPageHeader, PageGrid: VascoPageGrid, PageColumn: VascoPageColumn, Panel: VascoPanel, SplitView: VascoSplitView, TimeGrid: VascoTimeGrid, CalendarEvent: VascoCalendarEvent, hideAction: VascoHideAction, LegacySurfaceFrame: VascoLegacySurfaceFrame, navItems: VASCO_NAV_ITEMS, useLayout: VascoUseLayout, useColumns: VascoUseColumns, icons: Object.freeze({ ...VASCO_PHONE_UI.icons, Nav: VascoNavIcon }), });