// 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. */}
{columns.map((column) =>
{column.header}
)}
{hours.map((hour) =>
{hour.label}
)}
{hours.map((hour) => )}
{columns.map((column) =>
{/* Une journée sans travail possible passe SOUS ses blocs : ce qui
reste posé dessus doit se lire, pas se deviner. */}
{column.absent && }
{column.children}
)}
{Number.isFinite(nowOffset) &&
}
;
}
// 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 }),
});