Files
gestion_projets/css/style.css
Bertrand Benjamin 39ea249cdb Filtrer la frise par tag
Les tags s'affichent en pastilles colorées dans la colonne des libellés, et une
barre de filtres apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un
tag — dans cette barre ou directement sur la pastille d'un projet — restreint la
frise ; recliquer le retire.

Le filtre est conjonctif : un projet doit porter tous les tags cochés. Cocher un
tag de plus resserre donc toujours la sélection, sans jamais faire se repeupler
la frise. Un message dédié prend la place de la frise quand plus rien ne passe le
filtre, sans quoi elle laisserait croire que les projets ont disparu.

Le filtre est un état de vue, jamais enregistré, au même titre que la fenêtre
temporelle. Il ne fait pas le même travail que le masquage par l'œil, qui reste
enregistré : le filtre retire le projet de la vue entière, l'œil garde son
libellé grisé et n'ôte que les barres.

Deux ajustements de confort : créer un projet sous filtre actif préremplit ses
tags avec ceux du filtre, sinon le nouveau venu naîtrait invisible ; et un tag
que plus aucun projet ne porte est retiré du filtre, sinon il masquerait tout.

Le dialogue d'un projet gagne un champ de saisie et propose en un clic les tags
déjà employés ailleurs, seul garde-fou contre la dérive orthographique.

Dans la colonne des libellés, les pastilles rognent le nom du projet mais lui
laissent quatre caractères, et s'effacent à leur tour plutôt que de repousser
l'œil et le menu hors de la colonne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 05:52:28 +02:00

983 lines
20 KiB
CSS

/* 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 */
--largeur-libelles: 250px;
--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);
}
.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 {
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: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface));
}
.libelle-projet__nom {
flex: 1;
/* Le nom cède la place aux pastilles, mais jamais entièrement : sous quatre
caractères il ne resterait qu'un « … » qui n'identifie plus rien. */
min-width: 4em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
/* Les pastilles rognent le nom, pas l'inverse — mais elles s'effacent à leur
tour plutôt que de repousser l'œil et le menu hors de la colonne. */
.libelle-projet .tags {
flex: 0 1 auto;
max-width: 45%;
}
.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);
}
.icone-oeil {
border: 0;
background: none;
padding: 2px 4px;
cursor: pointer;
opacity: 0.45;
font-size: 12px;
}
.icone-oeil:hover {
opacity: 1;
}
/* `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);
padding: 0 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: grab;
box-shadow: var(--ombre);
user-select: none;
z-index: 2;
}
.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;
}
/* --- projet plié --------------------------------------------------------- */
/* Barre unique segmentée par phase. Lecture seule : voir docs/decisions.md,
section 8. */
.barre-pliee {
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;
}
/* --- 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;
}
/* --- panneau de détail --------------------------------------------------- */
.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;
}
.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;
}
.voile {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 0.15);
z-index: 15;
}
/* --- 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);
}
.menu-projet {
border: 0;
background: none;
padding: 2px 5px;
cursor: pointer;
color: var(--texte-doux);
font-size: 14px;
line-height: 1;
border-radius: 4px;
}
.menu-projet:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--texte);
}
/* --- dialogues ------------------------------------------------------------ */
.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;
}
.dialogue label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
}
.dialogue input[type="text"] {
font: inherit;
padding: 7px 9px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--fond);
color: var(--texte);
}
.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__erreur {
color: var(--danger) !important;
font-size: 12px;
}
.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;
}