/* Frise multi-projets — feuille de style unique. * * Deux principes structurent ce fichier : * * 1. Les barres, jalons et cellules d'en-tête sont positionnés en absolu : * timeline.js leur calcule un `left` et une `width` en pixels depuis * l'échelle temporelle. Les couloirs, eux, restent en flux normal. * * 2. La frise entière est une grille dans un unique conteneur défilant, dont * l'en-tête et la colonne des libellés sont figés par `position: sticky`. * Le défilement des deux axes est donc entièrement natif. */ :root { --fond: #f8fafc; --surface: #ffffff; --bordure: #e2e8f0; --bordure-forte: #cbd5e1; --texte: #0f172a; --texte-doux: #64748b; --accent: #3b82f6; --danger: #dc2626; --aujourdhui: #ef4444; --hauteur-barre: 26px; --hauteur-ligne: 38px; --hauteur-entete: 66px; /* trois bandes de 22 px : année, mois, semaine */ /* Doit rester en accord avec `LARGEUR_LIBELLES.defaut` (app.js), sur lequel retombe le double-clic de la poignée. */ --largeur-libelles: 300px; --rayon: 6px; --ombre: 0 1px 2px rgb(15 23 42 / 0.08); } @media (prefers-color-scheme: dark) { :root { --fond: #0f172a; --surface: #1e293b; --bordure: #334155; --bordure-forte: #475569; --texte: #f1f5f9; --texte-doux: #94a3b8; --ombre: 0 1px 2px rgb(0 0 0 / 0.3); } } * { box-sizing: border-box; } /* Indispensable : plus bas, `.panneau` et `.alerte` déclarent `display: flex`, ce qui écrase le `display: none` que le navigateur associe à l'attribut `hidden`. Sans cette règle, ces éléments restent des calques `position: fixed` étendus sur toute la page, invisibles mais interceptant tous les clics — la frise devient inerte à la souris. */ [hidden] { display: none !important; } body { margin: 0; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--texte); background: var(--fond); height: 100vh; display: flex; flex-direction: column; overflow: hidden; } /* --- barre d'outils (préfixe `outils` : `barre` est réservé aux barres de phase) ------------------------------------------------------ */ .outils { display: flex; align-items: center; gap: 16px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--bordure); flex-shrink: 0; } .outils h1 { font-size: 15px; font-weight: 600; margin: 0; white-space: nowrap; } .outils__groupe { display: flex; gap: 4px; } .outils__etat { margin: 0 0 0 auto; font-size: 12px; color: var(--texte-doux); min-width: 130px; text-align: right; } .outils__etat[data-etat="erreur"] { color: var(--danger); font-weight: 500; } button { font: inherit; padding: 5px 11px; border: 1px solid var(--bordure-forte); border-radius: var(--rayon); background: var(--surface); color: var(--texte); cursor: pointer; } button:hover { border-color: var(--accent); } button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; } .bouton--principal { background: var(--accent); border-color: var(--accent); color: #fff; } .bouton--danger { color: var(--danger); border-color: var(--danger); } .bouton--danger:hover { background: var(--danger); color: #fff; } /* --- barre de filtres ----------------------------------------------------- */ .filtres { display: flex; align-items: center; gap: 10px; padding: 7px 16px; background: var(--surface); border-bottom: 1px solid var(--bordure); flex-shrink: 0; } .filtres__titre { font-size: 12px; font-weight: 500; color: var(--texte-doux); white-space: nowrap; } .filtres button:not(.tag) { font-size: 12px; padding: 3px 9px; margin-left: auto; white-space: nowrap; } /* --- tags ----------------------------------------------------------------- */ /* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du nom du tag (model.js). La saturation et la clarté restent fixes, elles : ce sont elles qui garantissent le contraste, quelle que soit la teinte tirée. */ .tags { display: flex; gap: 4px; min-width: 0; overflow: hidden; } .tags--filtres, .tags--suggestions { flex-wrap: wrap; overflow: visible; } .tag { font: inherit; font-size: 10px; font-weight: 500; line-height: 1.6; padding: 0 6px; max-width: 84px; border: 1px solid transparent; border-radius: 9px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: hsl(var(--teinte-tag) 62% 92%); color: hsl(var(--teinte-tag) 55% 27%); } .tags--filtres .tag, .tags--suggestions .tag { font-size: 11px; padding: 1px 9px; max-width: none; border-radius: 11px; } .tag:hover { border-color: hsl(var(--teinte-tag) 45% 55%); } /* Un tag actif s'inverse : le fond prend la teinte pleine. Il faut donc neutraliser la règle générale `button[aria-pressed="true"]`, qui repeindrait toutes les pastilles de la même couleur d'accent. */ .tag[aria-pressed="true"] { background: hsl(var(--teinte-tag) 52% 38%); border-color: hsl(var(--teinte-tag) 52% 38%); color: #fff; } @media (prefers-color-scheme: dark) { .tag { background: hsl(var(--teinte-tag) 34% 24%); color: hsl(var(--teinte-tag) 62% 76%); } .tag[aria-pressed="true"] { background: hsl(var(--teinte-tag) 58% 62%); border-color: hsl(var(--teinte-tag) 58% 62%); color: #0f172a; } } /* --- structure de la frise ------------------------------------------------ */ /* Un seul conteneur défilant, sur les deux axes. L'en-tête et la colonne des libellés restent en place grâce à `position: sticky`, ce qui évite toute synchronisation de défilement en JavaScript. */ .frise { flex: 1; min-height: 0; overflow: auto; background: var(--fond); } .grille { display: grid; grid-template-columns: var(--largeur-libelles) max-content; grid-template-rows: var(--hauteur-entete) max-content; width: max-content; /* Sans cela, la dernière ligne colle au bas de la fenêtre et la barre de défilement horizontale recouvre le dernier couloir. */ padding-bottom: 24px; } /* Le coin cumule les deux ancrages, et doit donc passer au-dessus des deux. */ .grille__coin { position: sticky; top: 0; left: 0; z-index: 6; background: var(--surface); border-right: 1px solid var(--bordure); border-bottom: 1px solid var(--bordure); } /* Zone de saisie large de 9 px pour un trait qui n'en fait qu'un : viser une bordure d'un pixel à la souris est un exercice de patience. */ .poignee-colonne { position: absolute; top: 0; bottom: 0; right: -4px; width: 9px; cursor: col-resize; z-index: 7; } .poignee-colonne::after { content: ""; position: absolute; inset: 0 4px; background: var(--accent); opacity: 0; transition: opacity 0.12s; } .poignee-colonne:hover::after, .poignee-colonne:focus-visible::after, .poignee-colonne[data-glisse="true"]::after { opacity: 1; } .poignee-colonne:focus-visible { outline: none; } .grille__entete { position: sticky; top: 0; z-index: 5; background: var(--surface); border-bottom: 1px solid var(--bordure); } .grille__libelles { position: sticky; left: 0; z-index: 4; background: var(--surface); border-right: 1px solid var(--bordure); } .grille__couloirs { position: relative; z-index: 1; } /* --- en-tête de dates : trois bandes empilées ----------------------------- */ .bande { position: relative; height: calc(var(--hauteur-entete) / 3); border-bottom: 1px solid var(--bordure); } .bande--semaine { border-bottom: 0; } .bande__cellule { position: absolute; top: 0; height: 100%; display: flex; align-items: center; justify-content: center; border-left: 1px solid var(--bordure); font-size: 11px; color: var(--texte-doux); white-space: nowrap; overflow: hidden; pointer-events: none; } .bande--annee .bande__cellule { font-size: 12px; font-weight: 600; color: var(--texte); border-left-color: var(--bordure-forte); background: color-mix(in srgb, var(--texte) 4%, transparent); } .bande--mois .bande__cellule { font-weight: 500; color: var(--texte); border-left-color: var(--bordure-forte); } /* Les numéros de semaine sont le grain le plus fin : discrets par défaut, ils ne doivent pas concurrencer le mois et l'année. */ .bande--semaine .bande__cellule { font-size: 10px; letter-spacing: -0.2px; } /* --- traits verticaux ----------------------------------------------------- */ .trait { position: absolute; top: 0; width: 1px; background: var(--bordure); pointer-events: none; } .trait--majeur { background: var(--bordure-forte); } .trait--aujourdhui { background: var(--aujourdhui); width: 2px; z-index: 4; } .trait--aujourdhui::after { content: ""; position: absolute; top: 0; left: -3px; border: 4px solid transparent; border-top-color: var(--aujourdhui); } /* --- libellés ------------------------------------------------------------ */ .libelle-projet { --fond-libelle: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface)); position: relative; display: flex; align-items: center; gap: 6px; height: var(--hauteur-ligne); padding: 0 8px 0 6px; font-weight: 600; border-bottom: 1px solid var(--bordure); background: var(--fond-libelle); } /* Commandes secondaires, en surimpression sur le bord droit de la ligne. Le dégradé leur donne un fond opaque sans trancher : le nom qu'elles recouvrent s'estompe au lieu d'être coupé net. */ .libelle-projet__commandes { position: absolute; top: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 2px; padding: 0 6px 0 18px; opacity: 0; background: linear-gradient(to right, transparent, var(--fond-libelle) 18px); transition: opacity 0.12s; } .libelle-projet:hover .libelle-projet__commandes, .libelle-projet:focus-within .libelle-projet__commandes { opacity: 1; } /* Un projet masqué garde ses commandes visibles : le menu est le seul chemin pour le rétablir, il ne doit pas falloir le deviner sous le curseur. */ .libelle-projet--masque .libelle-projet__commandes { opacity: 1; } /* Le nom est ce qui identifie le projet : il cède en dernier, et jamais au-delà d'une largeur où il reste reconnaissable. Sous l'ancienne règle (`min-width: 4em`), les tags et l'étiquette de dérive le réduisaient à « Refon… » dans une colonne pourtant deux fois trop large pour lui. */ .libelle-projet__nom { flex: 1 1 auto; min-width: 7em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .libelle-projet .tags { flex: 0 1 auto; max-width: 40%; } /* Une pastille écrasée à « c. » occupe la place d'un tag sans en donner l'information. Elle garde donc une largeur minimale et cède en bloc : `pastillesTags` n'en affiche qu'un nombre fixe, suivies d'un compteur. */ .libelle-projet .tag { flex: 0 0 auto; min-width: 3.4em; text-align: center; } .tag--reste { flex: 0 0 auto; align-self: center; font-size: 10px; font-weight: 500; color: var(--texte-doux); white-space: nowrap; cursor: default; } .libelle-projet__derive { font-size: 11px; font-weight: 500; padding: 1px 5px; border-radius: 10px; white-space: nowrap; } .libelle-projet__derive[data-sens="retard"] { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } .libelle-projet__derive[data-sens="avance"] { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; } .plier { border: 0; background: none; padding: 2px 4px; font-size: 10px; color: var(--texte-doux); cursor: pointer; transition: transform 0.15s; } .plier[aria-expanded="true"] { transform: rotate(90deg); } /* `display: block` et non `flex` : `text-overflow: ellipsis` ne s'applique pas au texte d'un conteneur flex, et le nom serait coupé net. Le centrage vertical passe donc par `line-height`. */ .libelle-phase { display: block; line-height: var(--hauteur-ligne); height: var(--hauteur-ligne); padding: 0 8px 0 26px; color: var(--texte-doux); border-bottom: 1px solid var(--bordure); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .libelle-phase:hover { color: var(--texte); } .libelle-projet--masque { opacity: 0.45; } /* --- couloirs et barres -------------------------------------------------- */ .couloir { position: relative; height: var(--hauteur-ligne); border-bottom: 1px solid var(--bordure); } .couloir--projet { background: color-mix(in srgb, var(--couleur-projet) 5%, transparent); } .barre { position: absolute; top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2); height: var(--hauteur-barre); border-radius: 4px; background: var(--couleur-projet); color: #fff; font-size: 12px; line-height: var(--hauteur-barre); cursor: grab; box-shadow: var(--ombre); user-select: none; z-index: 2; } /* Le nom se décale pour rester au bord de la vue quand la barre s'enfonce sous la colonne des libellés — le cas d'une phase longue dont on ne voit que la fin. Trois conditions le rendent possible, et aucune n'est décorative : - `sticky` s'ancre au conteneur défilant le plus proche. Un `overflow: hidden` sur `.barre` en ferait ce conteneur, et le décalage n'aurait plus lieu : le rognage du texte trop long est donc porté ici, pas sur la barre. - le décalage est plafonné par la boîte du parent, donc le nom ne sort jamais de sa barre — d'où `inline-block`, qui laisse au texte la marge de manœuvre qu'un bloc pleine largeur n'aurait pas. - `left` vaut la largeur de la colonne des libellés : c'est là que commence la portion réellement visible de la frise. */ .barre__nom { position: sticky; left: var(--largeur-libelles); display: inline-block; max-width: 100%; padding: 0 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; pointer-events: none; } .barre:hover { filter: brightness(1.08); } .barre--glissee { cursor: grabbing; z-index: 5; filter: brightness(1.12); box-shadow: 0 4px 12px rgb(15 23 42 / 0.25); } .barre--selectionnee { outline: 2px solid var(--texte); outline-offset: 1px; } /* Le statut se lit à la texture de la barre, pas à sa couleur : la couleur appartient au projet, elle sert à distinguer les couloirs entre eux. */ .barre--todo { background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface)); color: var(--texte); border: 1px solid var(--couleur-projet); } .barre--doing { background: var(--couleur-projet); } .barre--done { background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b); } .barre--blocked { background: repeating-linear-gradient( 45deg, var(--couleur-projet), var(--couleur-projet) 6px, color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 6px, color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 12px ); } /* Poignées de redimensionnement, révélées au survol de la barre. */ .poignee { position: absolute; top: 0; width: 7px; height: 100%; cursor: ew-resize; opacity: 0; background: rgb(255 255 255 / 0.5); } .barre:hover .poignee { opacity: 1; } .poignee--debut { left: 0; border-radius: 4px 0 0 4px; } .poignee--fin { right: 0; border-radius: 0 4px 4px 0; } /* --- jalons -------------------------------------------------------------- */ .jalon { position: absolute; top: calc((var(--hauteur-ligne) - 14px) / 2); width: 14px; height: 14px; margin-left: -7px; background: var(--couleur-projet); transform: rotate(45deg); cursor: grab; z-index: 3; box-shadow: var(--ombre); } .jalon--done { background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b); } .jalon--blocked { background: var(--danger); } .jalon--selectionne { outline: 2px solid var(--texte); outline-offset: 1px; } /* Le nom du jalon vit hors du losange, pour ne pas subir la rotation. */ .jalon-nom { position: absolute; top: calc((var(--hauteur-ligne) - 16px) / 2); margin-left: 12px; font-size: 11px; color: var(--texte-doux); white-space: nowrap; pointer-events: none; z-index: 3; } /* --- barre cumulative d'un projet ---------------------------------------- */ /* Barre unique segmentée par phase, affichée que le projet soit plié ou déplié. Lecture seule : voir docs/decisions.md, section 8. */ .barre-cumulative { position: absolute; top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2); height: var(--hauteur-barre); border-radius: 4px; overflow: hidden; display: flex; box-shadow: var(--ombre); background: color-mix(in srgb, var(--couleur-projet) 15%, var(--surface)); cursor: default; z-index: 2; } .segment { position: absolute; top: 0; height: 100%; background: var(--couleur-projet); } .segment--todo { opacity: 0.3; } .segment--doing { opacity: 1; } .segment--done { opacity: 0.6; } .segment--blocked { background: var(--danger); opacity: 0.85; } /* Dépliée, la barre cumulative coiffe les phases du projet : elle s'amincit pour ne pas se faire passer pour l'une d'elles — elle ne se glisse pas. */ .barre-cumulative--fine { --hauteur-cumulative: 9px; top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2); height: var(--hauteur-cumulative); border-radius: 3px; box-shadow: none; } /* --- barre fantôme de référence ------------------------------------------ */ .fantome { position: absolute; top: calc( (var(--hauteur-ligne) - var(--hauteur-barre)) / 2 + var(--hauteur-barre) - 3px ); height: 5px; border-radius: 3px; background: repeating-linear-gradient( 90deg, var(--bordure-forte), var(--bordure-forte) 4px, transparent 4px, transparent 7px ); pointer-events: none; z-index: 1; } /* Le fantôme suit la barre qu'il double : sous une barre cumulative amincie, il flotterait sinon dans le vide. Déclaré après `.fantome`, dont il partage la spécificité — l'ordre est ce qui le fait gagner. */ .fantome--fin { top: calc((var(--hauteur-ligne) + 9px) / 2); height: 4px; } /* --- panneau de détail --------------------------------------------------- */ /* Non modal : aucun voile derrière lui, la frise reste manipulable pendant l'édition et cliquer une autre barre y bascule la sélection. */ .panneau { position: fixed; top: 0; right: 0; bottom: 0; width: 340px; max-width: 90vw; background: var(--surface); border-left: 1px solid var(--bordure); box-shadow: -4px 0 16px rgb(15 23 42 / 0.12); z-index: 20; display: flex; flex-direction: column; } /* Le panneau reçoit le focus à l'ouverture pour que le clavier le suive, mais il n'est pas un contrôle : un anneau de focus autour de tout le panneau n'apprendrait rien et attirerait l'œil loin du champ à remplir. */ .panneau:focus { outline: none; } .panneau__entete { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--bordure); } .panneau__entete h2 { margin: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .panneau__entete button { border: 0; font-size: 20px; line-height: 1; padding: 0 6px; background: none; } .panneau__corps { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; } .panneau__corps label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 500; color: var(--texte-doux); } .panneau__corps input[type="text"], .panneau__corps input[type="date"], .panneau__corps select, .panneau__corps textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--bordure-forte); border-radius: var(--rayon); background: var(--fond); color: var(--texte); width: 100%; } .panneau__corps textarea { resize: vertical; } .champ-double { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .case { flex-direction: row !important; align-items: center; gap: 8px !important; font-weight: 400 !important; color: var(--texte) !important; } .case input { width: auto; } .panneau__erreur { margin: 0; padding: 8px 10px; border-radius: var(--rayon); background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); font-size: 12px; } .panneau__actions { margin-top: 4px; } /* --- menu contextuel ------------------------------------------------------ */ .menu { position: fixed; z-index: 30; min-width: 210px; padding: 4px; background: var(--surface); border: 1px solid var(--bordure-forte); border-radius: var(--rayon); box-shadow: 0 8px 24px rgb(15 23 42 / 0.18); } .menu__entree { display: block; width: 100%; text-align: left; padding: 6px 10px; border: 0; border-radius: 4px; background: none; white-space: nowrap; } .menu__entree:hover:not(:disabled), .menu__entree:focus-visible { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: transparent; outline: none; } .menu__entree:disabled { color: var(--texte-doux); opacity: 0.5; cursor: default; } .menu__entree--danger { color: var(--danger); } .menu__entree--danger:hover:not(:disabled), .menu__entree--danger:focus-visible { background: color-mix(in srgb, var(--danger) 14%, transparent); } .menu__separateur { margin: 4px 6px; border: 0; border-top: 1px solid var(--bordure); } /* Commandes de la ligne d'un projet : ajouter une phase, ouvrir le menu. */ .commande-projet { border: 0; background: none; padding: 2px 5px; cursor: pointer; color: var(--texte-doux); font-size: 14px; line-height: 1; border-radius: 4px; } .commande-projet:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--texte); } /* --- dialogue de confirmation --------------------------------------------- */ /* Seul `` restant : les paramètres d'un projet ont rejoint un panneau (voir `.panneau`). Modal ici à dessein — interrompre est le but quand une action est irréversible. */ .dialogue { padding: 0; border: 1px solid var(--bordure-forte); border-radius: 10px; background: var(--surface); color: var(--texte); max-width: 400px; box-shadow: 0 12px 40px rgb(15 23 42 / 0.28); } .dialogue::backdrop { background: rgb(15 23 42 / 0.45); } .dialogue form { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px 16px; } .dialogue h2 { margin: 0; font-size: 15px; } .dialogue p { margin: 0; color: var(--texte-doux); line-height: 1.5; } .couleurs { margin: 0; padding: 0; border: 0; } .couleurs legend { padding: 0 0 6px; font-size: 12px; font-weight: 500; color: var(--texte-doux); } .couleurs div { display: flex; flex-wrap: wrap; gap: 7px; } .pastille { width: 24px; height: 24px; padding: 0; border: 2px solid transparent; border-radius: 50%; cursor: pointer; } .pastille[aria-pressed="true"] { border-color: var(--texte); box-shadow: inset 0 0 0 2px var(--surface); } .dialogue__actions { display: flex; justify-content: flex-end; gap: 8px; margin: 0; padding: 0; } /* --- états divers -------------------------------------------------------- */ .vide { margin: auto; text-align: center; color: var(--texte-doux); line-height: 2.4; } .alerte { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--fond); z-index: 50; padding: 24px; } .alerte p { max-width: 560px; padding: 16px 20px; border-radius: var(--rayon); border: 1px solid var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); color: var(--texte); white-space: pre-wrap; } /* Pendant un glisser, on neutralise la sélection de texte sur toute la page : sinon le navigateur surligne les libellés au passage du curseur. */ body.glisse-en-cours { user-select: none; cursor: grabbing; }