/*
 * Le projet et sa discussion, sur une seule page.
 *
 * La forme est celle d'une conversation : ce qu'on a écrit se détache à droite, ce que
 * l'assistant répond occupe la colonne sans cadre — un texte qu'on lit, pas une réplique dans
 * une bulle. Une pastille annonce ses réponses et tient la marge sur laquelle elles s'alignent.
 * La barre de saisie reste sous la main, le fil défile derrière elle.
 *
 * Rien n'est écrit en dur : chaque couleur, chaque espacement et chaque rayon vient d'un alias
 * du design system, ce qui fait suivre les cinq palettes sans une règle de plus.
 */

/*
 * Le projet à gauche, la discussion à droite, à parts égales.
 *
 * Empilés par défaut : sur un téléphone, deux colonnes rendraient les deux illisibles. La
 * discussion passe alors dessous, ce qui est le bon ordre — on regarde de quoi il s'agit avant
 * d'en parler.
 */
/*
 * Le paquet borne le contenu à `--app-content-width`, ce qui convient à une page qui se lit en
 * une colonne. Ici deux panneaux se partagent l'écran, et tout plafond de largeur se paie en
 * bordure : la place qu'il refuse s'accumule d'un seul côté, où elle se voit comme une marge
 * démesurée. Les deux panneaux se partagent donc l'écran quel qu'il soit, et chacun garde la
 * moitié — ce qui est déjà une mesure de lecture.
 *
 * Ciblé par ce que la page contient, plutôt que par une classe posée sur le corps : le gabarit
 * du paquet n'offre pas d'emplacement pour en ajouter une, et le recopier entier pour ça
 * coûterait bien plus que cette règle.
 */
.app-main:has(.project-layout) {
    max-width: none;
}

/*
 * Le bouton à trois points de la barre de titre. Renommer et effacer sont des gestes rares : la
 * commande qui les porte reste en retrait, à la taille d'un dessin plutôt qu'à celle d'un mot,
 * et ne s'allume qu'au survol comme les autres commandes discrètes de la page.
 */
.app-btn.project-actions {
    /* Le carré des autres commandes sans texte de l'application. Les marges du bouton du paquet
       le rendraient plus haut que la barre qui le porte, qu'il repousserait d'autant. */
    width: 2.25rem;
    height: 2.25rem;
    padding: 0;
    font-size: var(--app-text-lg);
    color: var(--app-ink-muted);
}

.app-btn.project-actions:hover {
    color: var(--app-ink);
}

.project-layout {
    display: grid;
    gap: var(--app-space-lg);
    grid-template-columns: minmax(0, 1fr);
}

/*
 * Sur un écran assez large pour les deux colonnes, les deux panneaux descendent jusqu'au bas de
 * la fenêtre : la charpente donne déjà au contenu la hauteur qui reste sous la barre du haut, et
 * la disposition n'a qu'à la prendre entière. Rien n'est laissé vide en dessous — c'est la place
 * que la discussion prend en hauteur.
 *
 * `min-height: 0` parce qu'un enfant flexible refuse de rétrécir sous la hauteur de son contenu :
 * sans lui, un long fil rendrait la disposition plus haute que la fenêtre.
 */
@media (min-width: 64rem) {
    .app-main:has(.project-layout) {
        display: flex;
        flex-direction: column;
    }

    .project-layout {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        flex: 1;
        min-height: 0;
    }

    /*
     * La colonne ne défile pas d'un bloc : c'est le fil, à l'intérieur de son cadre, qui
     * défile. Sans cela, un projet chargé d'événements repousse le cadre des pièces jointes
     * sous la ligne de flottaison — il faut alors faire défiler toute la colonne pour se
     * rappeler qu'il existe, ce que personne ne fait.
     *
     * Ce qu'on regarde reste donc en place : le projet en tête, ses fichiers en pied, et
     * l'histoire au milieu, qui prend ce qui reste et se parcourt là où elle est.
     *
     * `min-height: 0` partout dans la chaîne : sans lui, un enfant flexible refuse de
     * rétrécir sous la hauteur de son contenu, et le débordement remonte jusqu'à la page.
     */
    .project-panel {
        display: flex;
        flex-direction: column;
        min-height: 0;
    }

    .project-panel > [data-controller~="panel"] {
        display: flex;
        flex: 1 1 auto;
        min-height: 0;
    }

    .project-panel .app-stack {
        flex: 1 1 auto;
        min-height: 0;
    }

    /* Seul le fil absorbe la place qui reste. Un plancher, pour qu'il ne se réduise pas à
       rien quand les deux autres cadres sont chargés. */
    .project-feed {
        flex: 1 1 auto;
        min-height: 9rem;
    }

    .feed {
        min-height: 0;
        overflow-y: auto;
        /* La barre de défilement se fait discrète : elle traverse une colonne de lecture. */
        scrollbar-width: thin;
        scrollbar-color: var(--app-line-strong) transparent;
    }
}

/* Le tiroir garde une hauteur de lecture : trente fichiers ne doivent pas repousser le fil
   hors de l'écran, pas plus que l'inverse. */
.project-files .attachment-list {
    max-height: 15rem;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--app-line-strong) transparent;
}

/*
 * La boîte de dialogue des réglages. Le navigateur pose le fond et la centre lui-même ; il ne
 * reste qu'à lui donner l'allure d'une carte et une largeur de lecture.
 */
.settings-dialog {
    max-width: 32rem;
    border: none;
    border-radius: var(--app-radius);
    padding: var(--app-space-lg);
    background: var(--app-surface);
    color: var(--app-ink);
    box-shadow: var(--app-shadow-pop);
}

/* Le voile est celui du paquet, et non un noir écrit ici : le tiroir du menu latéral pose le
   même, et une palette qui l'éclaircit les emporte tous les deux. */
.settings-dialog::backdrop {
    background: var(--app-backdrop);
}

.chat {
    /*
     * Une teinte voisine de l'accent, obtenue en tournant sa nuance plutôt qu'en inventant une
     * couleur. Le liseré de la barre de saisie et le bouton d'envoi y prennent leur dégradé,
     * et suivent les cinq palettes comme le reste.
     *
     * Une palette qui veut choisir cette seconde teinte plutôt que de la laisser calculer pose
     * `--app-accent-far` — et `--app-accent-far-hover` avec elle : c'est alors la sienne qui
     * sert, sans qu'une règle d'ici ne bouge. Sans cela, la rotation reste la réponse.
     */
    --chat-accent-far: var(--app-accent-far, oklch(from var(--app-accent) l c calc(h + 40)));
    --chat-accent-far-hover: var(--app-accent-far-hover, oklch(from var(--app-accent-hover) l c calc(h + 40)));

    /* La distance sur laquelle le fil s'efface en haut et en bas de son cadre. Elle sert deux
       fois : au fondu, et à la marge intérieure qui empêche la dernière ligne d'y entrer. */
    --chat-fade: var(--app-space-lg);

    display: flex;
    flex-direction: column;
    gap: var(--app-space-sm);
    /*
     * En colonne unique, la discussion prend la hauteur de la fenêtre sous la barre du haut,
     * gouttières comprises : le fil défile pour lui-même, la saisie ne bouge pas.
     *
     * En deux colonnes, la grille la fait descendre jusqu'au bas de la page, et cette hauteur
     * cesse de servir.
     */
    height: calc(100dvh - var(--app-topbar-height) - var(--app-space-lg) * 2);
    min-height: 22rem;
}

@media (min-width: 64rem) {
    .chat {
        height: auto;
    }
}

.chat-thread {
    flex: 1 1 auto;
    /* Sans cette ligne, un enfant flexible refuse de rétrécir sous sa hauteur naturelle, et
       c'est la page qui défile au lieu du fil. */
    min-height: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: var(--app-space-lg);
    /* Les marges hautes et basses valent le fondu : la première et la dernière ligne s'arrêtent
       là où il commence, et restent donc pleines quand le fil est au bout de sa course. */
    padding: var(--chat-fade) var(--app-space-xs) var(--chat-fade) 0;
    /* La barre de défilement se fait discrète : elle traverse une colonne de lecture. */
    scrollbar-width: thin;
    scrollbar-color: var(--app-line-strong) transparent;
    /* Ce qui sort du cadre s'efface au lieu d'être tranché : le fil passe alors derrière le
       titre et sous la barre de saisie, au lieu de s'arrêter à un bord invisible. */
    mask-image: linear-gradient(
        to bottom,
        transparent 0,
        #000 var(--chat-fade),
        #000 calc(100% - var(--chat-fade)),
        transparent 100%
    );
}

/*
 * Une conversation courte se pose au bas du fil, comme partout ailleurs — un échange qui
 * commence en haut d'une grande page vide se lit comme un document, pas comme une discussion.
 * Cette marge fait le travail de `justify-content: flex-end`, qui rendrait le haut du fil
 * inatteignable dès que la conversation dépasse la hauteur visible.
 */
.chat-thread > :first-child {
    margin-top: auto;
}

/*
 * Deux colonnes : la pastille, puis ce qui est dit. La seconde absorbe la largeur restante, ce
 * qui pose toutes les réponses sur la même marge quelle que soit leur longueur.
 */
.chat-turn {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: var(--app-space-md);
    align-items: start;
}

/* Ce qu'on a écrit soi-même n'a pas de pastille : une seule colonne, poussée contre le bord
   droit, où elle rejoint la fin des lignes de l'assistant. */
.chat-turn--mine {
    grid-template-columns: minmax(0, 1fr);
    justify-items: end;
}

.chat-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: var(--app-radius-pill);
    background: var(--app-accent-soft);
    color: var(--app-accent);
    font-size: var(--app-text);
}

.chat-bubble {
    line-height: 1.6;
    /* Une adresse ou un jeton sans espace déborderait de la colonne plutôt que d'y revenir. */
    overflow-wrap: anywhere;
}

/*
 * Ce qu'on a écrit soi-même prend la forme d'une réplique : un fond, un coin rentrant du côté
 * d'où elle vient. Ce que l'assistant répond n'en prend pas — un paragraphe encadré se lit mal
 * dès qu'il dépasse trois lignes, et les réponses en font souvent trente.
 */
.chat-turn--mine .chat-bubble {
    max-width: 85%;
    padding: var(--app-space-sm) var(--app-space-md);
    border-radius: var(--app-radius);
    border-bottom-right-radius: var(--app-radius-ui);
    background: var(--app-accent-soft);
    color: var(--app-accent-ink);
}

.chat-bubble > :first-child {
    margin-top: 0;
}

.chat-bubble > :last-child {
    margin-bottom: 0;
}

.chat-bubble[data-failed] {
    color: var(--app-ink-muted);
    font-style: italic;
}

/*
 * Une réponse arrive en Markdown : paragraphes, titres, listes, code, citations, tableaux. Le
 * socle pose ces balises sans marge, ce qui convient à une interface mais colle un texte suivi.
 * Ce qui suit lui rend un rythme de lecture, à l'intérieur de la bulle seulement.
 */
.chat-bubble > * + * {
    margin-top: var(--app-space-md);
}

.chat-bubble :is(h1, h2, h3, h4) {
    font-size: var(--app-text-lg);
}

/* Un titre appelle ce qu'il annonce : l'écart au-dessus reste, celui du dessous se resserre. */
.chat-bubble > :is(h1, h2, h3, h4) + * {
    margin-top: var(--app-space-xs);
}

.chat-bubble :is(ul, ol) {
    margin: 0;
    /* De quoi loger la puce, et rien de plus : le retrait du navigateur décalerait la liste
       loin de la marge où le reste de la réponse s'aligne. */
    padding-left: 1.25em;
}

.chat-bubble li + li {
    margin-top: var(--app-space-2xs);
}

.chat-bubble pre {
    margin: 0;
    padding: var(--app-space-md);
    border-radius: var(--app-radius-ui);
    background: var(--app-raised);
    overflow-x: auto;
}

/* Dans un bloc, le fond et le rayon du code en ligne feraient double emploi. */
.chat-bubble pre code {
    padding: 0;
    background: none;
}

.chat-bubble blockquote {
    margin: 0;
    padding-left: var(--app-space-md);
    border-left: 2px solid var(--app-line-strong);
    color: var(--app-ink-soft);
}

/* Un tableau plus large que la colonne défile pour lui-même, au lieu d'élargir la discussion. */
.chat-bubble table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
    border-collapse: collapse;
    font-size: var(--app-text-sm);
}

.chat-bubble :is(th, td) {
    padding: var(--app-space-xs) var(--app-space-sm);
    border-bottom: 1px solid var(--app-line);
    text-align: left;
    vertical-align: top;
}

/* L'attente, entre l'envoi et le premier mot : trois points qui respirent. */
.chat-bubble.is-waiting::after {
    content: '•••';
    letter-spacing: 0.2em;
    color: var(--app-ink-muted);
    animation: chat-breathing 1.4s ease-in-out infinite;
}

@keyframes chat-breathing {
    0%, 100% { opacity: 0.25; }
    50% { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
    .chat-bubble.is-waiting::after {
        animation: none;
        opacity: 0.6;
    }
}

.chat-empty {
    margin: auto;
    max-width: 26rem;
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--app-space-xs);
}

/* La même pastille que devant les réponses, à la taille d'une page d'accueil : ce qui attend
   là est celui qui répondra ensuite. */
.chat-empty-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 3.25rem;
    height: 3.25rem;
    margin-bottom: var(--app-space-xs);
    border-radius: var(--app-radius-pill);
    background: var(--app-accent-soft);
    color: var(--app-accent);
    font-size: var(--app-text-2xl);
}

.chat-empty-title {
    font-size: var(--app-text-lg);
    margin-bottom: var(--app-space-2xs);
}

.chat-empty form {
    margin-top: var(--app-space-sm);
}

/*
 * La saisie et ses commandes ne font qu'un à l'œil : un seul contour, qui s'allume quand le
 * curseur entre dans le champ. Au clavier, elles restent des éléments distincts, chacun
 * atteignable.
 *
 * Le liseré est un dégradé, ce qu'une bordure ne sait pas porter : le cadre reçoit donc deux
 * fonds superposés — le sien, arrêté à la marge intérieure, et le dégradé, arrêté à la bordure
 * qu'il vient remplir.
 */
.chat-composer {
    display: flex;
    flex-direction: column;
    gap: var(--app-space-sm);
    padding: var(--app-space-md);
    border: 1px solid transparent;
    border-radius: var(--app-radius);
    /* Le fond de la page, et non celui de la carte : sans ce contraste, la barre de saisie se
       fondrait dans le panneau qui la porte. */
    background:
        linear-gradient(var(--app-bg), var(--app-bg)) padding-box,
        linear-gradient(135deg,
            color-mix(in srgb, var(--app-accent) 40%, transparent),
            color-mix(in srgb, var(--chat-accent-far) 40%, transparent)) border-box;
    /* Une ombre douce la décolle du panneau : posée à plat, elle se lisait comme une bande de
       plus dans le fil au lieu du plan où l'on écrit. */
    box-shadow: var(--app-shadow);
    transition: box-shadow 150ms var(--app-ease);
}

.chat-composer:hover {
    background:
        linear-gradient(var(--app-bg), var(--app-bg)) padding-box,
        linear-gradient(135deg,
            color-mix(in srgb, var(--app-accent) 60%, transparent),
            color-mix(in srgb, var(--chat-accent-far) 60%, transparent)) border-box;
}

/* Le champ n'a pas de halo à lui : c'est le cadre entier qui s'allume, liseré plein et ombre
   creusée, faute de quoi rien ne dirait où le curseur se trouve. */
.chat-composer:focus-within {
    background:
        linear-gradient(var(--app-bg), var(--app-bg)) padding-box,
        linear-gradient(135deg, var(--app-accent), var(--chat-accent-far)) border-box;
    box-shadow: var(--app-shadow-focus), var(--app-shadow-pop);
}

.chat-input {
    border: none;
    background: none;
    box-shadow: none;
    padding: var(--app-space-2xs) 0;
    resize: none;
    /*
     * Le champ grandit avec ce qu'on y écrit, jusqu'à cette hauteur ; au-delà il défile. C'est
     * le navigateur qui mesure, sans une ligne de script : un vieux navigateur garde la
     * hauteur de départ, ce qui reste utilisable.
     */
    field-sizing: content;
    max-height: 12rem;
    color: inherit;
    font: inherit;
    line-height: 1.5;
}

.chat-input:focus,
.chat-input:focus-visible {
    outline: none;
    box-shadow: none;
}

/* Sous le champ : ce qu'on ajoute au message d'un côté, l'envoi de l'autre. */
.chat-tools {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--app-space-sm);
}

.chat-tools-start {
    display: flex;
    align-items: center;
    gap: var(--app-space-2xs);
}

/* Une commande discrète : sans fond, elle ne s'allume qu'au survol. */
.chat-tool {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border: none;
    border-radius: var(--app-radius-ui);
    background: none;
    color: var(--app-ink-muted);
    font-size: var(--app-text-lg);
    cursor: pointer;
    transition: background 150ms var(--app-ease), color 150ms var(--app-ease);
}

.chat-tool:hover {
    background: var(--app-hover);
    color: var(--app-ink);
}

.chat-send {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 2.25rem;
    height: 2.25rem;
    border: none;
    border-radius: var(--app-radius-pill);
    background: linear-gradient(135deg, var(--app-accent), var(--chat-accent-far));
    /*
     * « sur accent », et non « encre d'accent » : la seconde est une teinte sombre, faite pour
     * écrire EN accent sur un fond clair. Employée ici, elle donnait du sombre sur du sombre.
     */
    color: var(--app-on-accent);
    font-size: var(--app-text-lg);
    line-height: 1;
    cursor: pointer;
    transition: background 150ms var(--app-ease), transform 150ms var(--app-ease);
}

.chat-send:hover:not(:disabled) {
    background: linear-gradient(135deg, var(--app-accent-hover), var(--chat-accent-far-hover));
    transform: scale(1.06);
}

.chat-send:disabled {
    opacity: 0.5;
    cursor: default;
}

@media (prefers-reduced-motion: reduce) {
    .chat-send:hover:not(:disabled) {
        transform: none;
    }
}

.chat-note {
    margin: 0;
    text-align: center;
    font-size: var(--app-text-xs);
    color: var(--app-ink-muted);
}

/*
 * Le fil du projet : ce qui s'y accumule, dans l'ordre où c'est arrivé.
 *
 * Une entrée porte une pastille qui dit sa nature, ce qu'elle contient, et ce qu'on peut en
 * faire. Trois colonnes fixes de part et d'autre du contenu : ce qui s'ajoutera plus tard —
 * notes, tâches, échéances — entrera dans la même forme sans qu'une règle soit à écrire.
 */
.feed {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--app-space-sm);
}

.feed-entry {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: var(--app-space-sm);
    align-items: start;
    padding-top: var(--app-space-sm);
    border-top: 1px solid var(--app-line);
}

.feed-entry:first-child {
    padding-top: 0;
    border-top: none;
}

.feed-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: var(--app-radius-pill);
    background: var(--app-accent-soft);
    color: var(--app-accent);
    font-size: var(--app-text);
}

.feed-body {
    min-width: 0;
}

.feed-fact {
    margin: 0;
    line-height: 1.5;
    /* Une adresse ou un jeton sans espace déborderait de la colonne plutôt que d'y revenir. */
    overflow-wrap: anywhere;
}

.feed-meta {
    margin: var(--app-space-2xs) 0 0;
    font-size: var(--app-text-xs);
    color: var(--app-ink-muted);
}

/* Ne s'allume qu'au survol : oublier ne doit pas s'offrir avant qu'on le cherche. */
.feed-forget {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: none;
    border-radius: var(--app-radius-ui);
    background: none;
    color: var(--app-ink-muted);
    cursor: pointer;
    transition: background 150ms var(--app-ease), color 150ms var(--app-ease);
}

.feed-forget:hover {
    background: var(--app-hover);
    color: var(--app-danger, var(--app-ink));
}

/*
 * Ce que l'assistant propose de retenir, posé dans le fil de la discussion.
 *
 * Ni une réplique ni une bulle : un encart qui attend une réponse. Il occupe la largeur, se
 * distingue par un fond d'accent, et disparaît dès qu'on a tranché — remplacé par la phrase
 * que la décision a écrite.
 */
.memory-offer {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: var(--app-space-md);
    align-items: start;
    padding: var(--app-space-md);
    border: 1px solid var(--app-accent-soft);
    border-radius: var(--app-radius);
    background: var(--app-accent-soft);
    color: var(--app-accent-ink);
}

.memory-offer[data-failed] {
    border-color: var(--app-line-strong);
}

.memory-offer-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: var(--app-radius-pill);
    background: var(--app-surface);
    color: var(--app-accent);
    font-size: var(--app-text);
}

.memory-offer-body {
    min-width: 0;
}

.memory-offer-fact {
    margin: 0 0 var(--app-space-sm);
    line-height: 1.5;
    overflow-wrap: anywhere;
}

.memory-offer-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--app-space-xs);
}

/*
 * Une décision consignée dans le fil : retenir un fait, le refuser.
 *
 * Centrée, discrète, sans bulle ni pastille — elle n'appartient ni à la personne ni à
 * l'assistant du point de vue de l'œil, alors qu'elle appartient bien à la personne du point de
 * vue du modèle. C'est exactement ce qu'il faut montrer : la phrase a été écrite par un clic,
 * pas au clavier.
 */
.chat-decision {
    margin: 0 auto;
    max-width: 90%;
    padding: var(--app-space-2xs) var(--app-space-md);
    border: 1px solid var(--app-line);
    border-radius: var(--app-radius-pill);
    background: var(--app-bg);
    color: var(--app-ink-muted);
    font-size: var(--app-text-sm);
    text-align: center;
    overflow-wrap: anywhere;
}

/* ═══ Les pièces jointes ═══════════════════════════════════════════════════════════════ */

/*
 * Une pastille, et la même partout : dans le tiroir du projet, sous un message du fil, et dans
 * la barre de saisie avant l'envoi. Un fichier ne change pas de nature selon l'endroit où on le
 * regarde, et le reconnaître d'un coup d'œil vaut mieux que trois dessins à apprendre.
 *
 * Le gabarit `_attachment.html.twig` et le contrôleur `conversation` produisent exactement ce
 * balisage : c'est ce qui fait qu'un fichier déposé à l'instant ressemble à un fichier
 * rechargé.
 */
.attachment-list {
    display: flex;
    flex-wrap: wrap;
    gap: var(--app-space-xs);
}

.attachment {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--app-space-xs);
    max-width: 100%;
    padding: var(--app-space-2xs);
    border: 1px solid var(--app-line);
    border-radius: var(--app-radius);
    background: var(--app-bg);
}

/* Une entrée qu'on peut emmener vers la discussion le dit par le curseur : rien d'autre ne
   l'annonce, et un glissé qu'on ne soupçonne pas n'est jamais tenté. */
.attachment[draggable="true"] {
    cursor: grab;
}

.attachment-link {
    display: inline-flex;
    align-items: center;
    gap: var(--app-space-2xs);
    min-width: 0;
    color: inherit;
    text-decoration: none;
}

.attachment-link:hover .attachment-name {
    text-decoration: underline;
}

/* La vignette montre l'image, elle ne la signale pas : un nom de capture ne dit rien de ce
   qu'elle contient, et reconnaître son écran vaut mieux que lire « capture-17h23 ». Le nom
   passe en survol, et la place qu'il prenait revient au dessin. Faute de miniatures, elle
   charge l'image entière — à revoir le jour où un projet en portera cinquante. */
.attachment-thumb {
    flex: 0 0 auto;
    width: 5rem;
    height: 5rem;
    border-radius: var(--app-radius-ui);
    object-fit: cover;
}

/* Ce qui n'est pas une image occupe la même place : une rangée où chaque pastille a sa hauteur
   se lit deux fois moins bien. */
.attachment-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 3.5rem;
    height: 3.5rem;
    border-radius: var(--app-radius-ui);
    background: var(--app-accent-soft);
    color: var(--app-accent);
    font-size: var(--app-text-lg);
}

/* Un nom de fichier est souvent long et sans espace : il se coupe plutôt que de pousser la
   pastille hors de la colonne. */
.attachment-name {
    overflow: hidden;
    max-width: 14rem;
    font-size: var(--app-text-sm);
    text-overflow: ellipsis;
    white-space: nowrap;
}

/*
 * Un fichier dont il ne reste que la ligne en base.
 *
 * Ni lien ni glissé — il n'y a rien à ouvrir et rien à envoyer. La tuile prend la place qu'aurait
 * la vignette, le trait discontinu dit qu'il manque quelque chose, et la phrase attend au survol :
 * écrite, elle occuperait plus de place que ce qu'elle remplace.
 */
.attachment--missing {
    border-style: dashed;
    cursor: help;
}

.attachment--missing .attachment-icon {
    width: 5rem;
    height: 5rem;
    background: var(--app-bg);
    border: 1px dashed var(--app-line-strong);
    color: var(--app-danger, var(--app-ink-muted));
    font-size: var(--app-text-lg);
}

/* Ne s'allume qu'au survol : retirer ne doit pas s'offrir avant qu'on le cherche. */
.attachment-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 1.5rem;
    height: 1.5rem;
    padding: 0;
    border: none;
    border-radius: var(--app-radius-pill);
    background: none;
    color: var(--app-ink-muted);
    font-size: var(--app-text-sm);
    cursor: pointer;
    transition: background 150ms var(--app-ease), color 150ms var(--app-ease);
}

.attachment-remove:hover {
    background: var(--app-hover);
    color: var(--app-danger, var(--app-ink));
}

/*
 * Sur une tuile — une image, ou ce qu'il reste d'un fichier perdu —, retirer se pose dans le
 * coin : posé à côté, il élargirait de moitié une pastille qui n'a plus de texte. Un fond plein
 * le garde lisible quelle que soit l'image en dessous.
 */
.attachment:has(.attachment-thumb) form,
.attachment--missing form {
    position: absolute;
    top: var(--app-space-2xs);
    right: var(--app-space-2xs);
}

.attachment:has(.attachment-thumb) .attachment-remove,
.attachment--missing .attachment-remove {
    background: var(--app-surface);
    box-shadow: var(--app-shadow-pop);
}

/*
 * Sous un message du fil. La grille du tour a deux colonnes — la pastille de l'assistant, puis
 * ce qui est dit — et cette liste appartient à la seconde ; ce qu'on a écrit soi-même n'en a
 * qu'une.
 */
.chat-turn .chat-attachments {
    grid-column: 2;
    margin-top: var(--app-space-2xs);
}

.chat-turn--mine .chat-attachments {
    grid-column: 1;
    justify-content: flex-end;
}

/* Ce qui partira avec le prochain message. Rien à montrer tant qu'on n'a rien joint : la barre
   de saisie ne doit pas garder une ligne vide en réserve. */
.chat-pending:empty {
    display: none;
}

/*
 * Le fil accepte un dépôt : des fichiers du système, ou une pastille venue du tiroir. Le cadre
 * entier s'allume, faute de quoi rien ne dirait où lâcher.
 */
.chat[data-over] {
    outline: 2px dashed var(--app-accent);
    outline-offset: -4px;
}

/*
 * Le tiroir du projet accepte un dépôt sur toute sa surface, et le dit comme le fil : le cadre
 * entier s'allume. Une bande en pointillés au fond ne recevait que ce qu'on visait juste.
 */
.project-files[data-over] {
    outline: 2px dashed var(--app-accent);
    outline-offset: -4px;
}

/*
 * Choisir un fichier sans le glisser : une ligne au fond du cadre, de la hauteur de son texte.
 * Les pointillés restent — ils disent aussi ce qu'on peut lâcher — mais la surface qu'ils
 * entouraient revient aux fichiers.
 */
.attachment-add {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--app-space-2xs);
    width: 100%;
    padding: var(--app-space-2xs) var(--app-space-xs);
    border: 1px dashed var(--app-line-strong);
    border-radius: var(--app-radius-ui);
    background: none;
    color: var(--app-ink-muted);
    font: inherit;
    font-size: var(--app-text-sm);
    cursor: pointer;
    transition: border-color 150ms var(--app-ease), background 150ms var(--app-ease);
}

.attachment-add:hover {
    border-color: var(--app-accent);
    background: var(--app-accent-soft);
    color: var(--app-ink);
}

/* Le temps d'un envoi : la commande cesse de répondre à l'œil comme elle cesse d'accepter. */
.project-files[data-busy] .attachment-add {
    opacity: .6;
    cursor: progress;
}

.attachment-add-icon {
    display: inline-flex;
    color: var(--app-accent);
    font-size: var(--app-text);
}

/*
 * Un refus, dit dans le fil : un fichier trop lourd, d'un type qu'on ne range pas.
 *
 * La forme d'une décision — centrée, discrète, sans bulle : ce n'est ni une parole ni une
 * réponse, c'est l'application qui parle. La teinte d'alerte fait la différence, et suffit :
 * la phrase dit déjà ce qui s'est passé.
 */
.chat-notice {
    margin: 0 auto;
    max-width: 90%;
    padding: var(--app-space-2xs) var(--app-space-md);
    border: 1px solid var(--app-danger, var(--app-line-strong));
    border-radius: var(--app-radius-pill);
    background: var(--app-bg);
    color: var(--app-danger, var(--app-ink));
    font-size: var(--app-text-sm);
    text-align: center;
    overflow-wrap: anywhere;
}

/* ═══ Ce que le projet dit de lui-même ═════════════════════════════════════════════════ */

/*
 * L'échéance : une ligne, un dessin, une date. Elle se lit en premier quand on rouvre un
 * projet, d'où sa place au-dessus de la description.
 */
.project-deadline {
    display: flex;
    align-items: center;
    gap: var(--app-space-2xs);
    margin: 0;
    font-size: var(--app-text-sm);
    font-weight: var(--app-weight-semibold);
    color: var(--app-ink);
}

.project-deadline-icon {
    display: inline-flex;
    flex: 0 0 auto;
    color: var(--app-accent);
}

/* Une date passée se signale : la laisser se lire comme les autres reviendrait à cacher un
   retard, ce qu'un projet ne pardonne pas. */
.project-deadline[data-late] {
    color: var(--app-danger, var(--app-ink));
}

.project-deadline[data-late] .project-deadline-icon {
    color: inherit;
}

.project-description {
    margin: 0;
    line-height: 1.6;
    /* Une adresse ou un jeton sans espace déborderait de la colonne plutôt que d'y revenir. */
    overflow-wrap: anywhere;
}

/*
 * Ce qui vient d'apparaître, signalé un instant.
 *
 * L'assistant modifie le projet pendant qu'il répond : sans ce signal, une échéance posée ou
 * un souvenir retenu s'ajoutent en silence, et on ne voit pas ce qui a bougé — c'est
 * précisément ce que le journal est censé rendre visible.
 *
 * Un fond qui s'éteint, et rien qui bouge : la ligne ne doit pas se déplacer sous l'œil de
 * qui est en train de la lire. Le contrôleur retire la marque après la même durée, faute de
 * quoi elle réapparaîtrait au prochain rechargement du fragment.
 */
[data-fresh] {
    border-radius: var(--app-radius-ui);
    animation: entry-fresh 10s var(--app-ease) forwards;
}

@keyframes entry-fresh {
    0%, 65% { background: var(--app-accent-soft); }
    100% { background: transparent; }
}

/* Pour qui a demandé moins de mouvement : le fond reste posé, et disparaît d'un coup quand le
   contrôleur retire la marque. Le signal est rendu, sans transition. */
@media (prefers-reduced-motion: reduce) {
    [data-fresh] {
        animation: none;
        background: var(--app-accent-soft);
    }
}

/*
 * L'interrupteur du web, posé dans la barre de saisie.
 *
 * Toujours visible, allumé ou éteint : un réglage qui coûte à chaque usage ne doit pas se
 * deviner. Éteint, il reste discret comme les autres commandes de la barre ; allumé, il prend
 * la couleur d'accent et un fond, ce qui se voit du coin de l'œil sans rien lire.
 */
.chat-ability[data-on="true"] {
    background: var(--app-accent-soft);
    color: var(--app-accent);
}

.chat-ability[data-on="true"]:hover {
    background: var(--app-accent-soft);
    color: var(--app-accent-hover, var(--app-accent));
}

/* ═══ Ce que fait un outil ═════════════════════════════════════════════════════════════ */

/*
 * Une ligne par outil, et sa description derrière un point d'interrogation.
 *
 * Les descriptions font deux à trois lignes chacune : posées à côté des noms, elles allongent
 * la liste jusqu'à ce qu'on n'y trouve plus rien. Mises en retrait, elles restent à un survol
 * de qui se demande ce que fait celui-là.
 *
 * Aucun JavaScript : l'infobulle s'ouvre au survol et à la mise au point du bouton. Elle est
 * donc atteignable au clavier — on tabule jusqu'au bouton — et au doigt, où une pression vaut
 * mise au point.
 */
.ability-help {
    position: relative;
    display: inline-flex;
    flex: 0 0 auto;
}

.ability-info {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    padding: 0;
    border: none;
    border-radius: var(--app-radius-pill);
    background: none;
    color: var(--app-ink-muted);
    cursor: help;
    transition: color 150ms var(--app-ease), background 150ms var(--app-ease);
}

.ability-info:hover,
.ability-info:focus-visible {
    background: var(--app-hover);
    color: var(--app-accent);
}

/*
 * La bulle, posée sous le bouton et alignée sur son bord droit : celui-ci termine la ligne, et
 * une bulle qui partirait vers la droite sortirait de la boîte de dialogue.
 *
 * Vers le bas et jamais vers le haut, même pour la première ligne : au-dessus, elle
 * recouvrirait le titre du cadre, ce qui se lit comme un défaut d'affichage. En dessous, elle
 * cache une ligne de la liste — qu'on retrouve en relâchant.
 *
 * `visibility` en plus de `opacity` : sans elle, une bulle transparente reste sous le doigt et
 * sous le curseur, et intercepte ce qui passe derrière.
 */
.ability-tip {
    position: absolute;
    right: 0;
    top: calc(100% + var(--app-space-2xs));
    z-index: 2;
    width: max-content;
    max-width: 18rem;
    padding: var(--app-space-xs) var(--app-space-sm);
    border-radius: var(--app-radius-ui);
    /* L'encre en fond et la surface en texte : les deux s'échangent d'un thème à l'autre, et
       la bulle reste lisible sans qu'une couleur soit écrite ici. */
    background: var(--app-ink);
    color: var(--app-surface);
    font-size: var(--app-text-xs);
    font-weight: var(--app-weight-normal);
    line-height: 1.5;
    text-align: left;
    box-shadow: var(--app-shadow-pop);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-.25rem);
    transition: opacity 120ms var(--app-ease), transform 120ms var(--app-ease), visibility 120ms;
}

/* La pointe, qui rattache la bulle à son bouton. Sans elle, la bulle flotte sans qu'on sache
   ce qu'elle explique. */
.ability-tip::after {
    content: "";
    position: absolute;
    bottom: 100%;
    right: .5rem;
    border: .3rem solid transparent;
    border-bottom-color: var(--app-ink);
}

.ability-info:hover + .ability-tip,
.ability-info:focus + .ability-tip {
    opacity: 1;
    visibility: visible;
    transform: none;
}

@media (prefers-reduced-motion: reduce) {
    .ability-tip {
        transition: none;
        transform: none;
    }
}

/* ═══ Les champs qui se corrigent là où ils se lisent ══════════════════════════════════ */

/*
 * La forme est celle du titre d'affaire de Pipedrive, dont ce motif vient : **rien ne bouge
 * quand le champ s'ouvre**. La saisie occupe exactement la boîte de la lecture — même fonte,
 * même retrait, même fond, même rayon — et il ne s'ajoute qu'un liseré d'accent. Ce qui rend
 * le geste agréable est précisément qu'il ne déplace rien sous l'œil.
 *
 * L'intitulé tient une colonne étroite à gauche, aligné à droite, hors du surlignage : c'est
 * la valeur qu'on va corriger, pas le mot qui la nomme.
 *
 * Le repli est l'état par défaut, et non quelque chose qu'un script vient demander : sinon la
 * page montrerait toutes les saisies et tous les boutons le temps que les contrôleurs
 * démarrent, et battrait à chaque chargement.
 *
 * Le champ réclame donc le JavaScript pour s'ouvrir. Le serveur continue de rendre le
 * formulaire entier — c'est lui qui possède le balisage, et c'est ce qui permet d'éprouver le
 * renommage par une simple requête, sans navigateur.
 */
.field [data-edit] {
    display: none;
}

.field[data-open] [data-read] {
    display: none;
}

.field[data-open] [data-edit] {
    display: block;
}

/* Sur une ligne simple, Entrée et le départ du champ enregistrent : les boutons n'existent que
   pour qui n'a pas de script. */
.field-acts,
.field-acts--spare {
    display: none;
}

.field[data-open] .field-acts:not(.field-acts--spare) {
    display: flex;
}

.field {
    display: grid;
    grid-template-columns: 7rem minmax(0, 1fr);
    gap: var(--app-space-sm);
    align-items: start;
}

.field form {
    margin: 0;
    min-width: 0;
}

/* L'intitulé : petit, en retrait, aligné à gauche sur une colonne fixe — la mesure du CRM, où
   des intitulés alignés se parcourent d'un coup d'œil. Il prend la couleur d'accent quand le
   champ est ouvert, ce qui dit lequel on corrige. */
.field-label {
    padding-top: .35rem;
    font-size: var(--app-text-xs);
    font-weight: var(--app-weight-medium);
    line-height: 1.4;
    color: var(--app-ink-muted);
}

.field[data-open] .field-label {
    color: var(--app-accent);
}

/*
 * La boîte tient les deux états et le crayon. Elle est le repère commun : c'est parce que la
 * lecture et la saisie partagent ce cadre — et les mêmes mesures — que l'ouverture ne déplace
 * rien.
 */
.field-box {
    position: relative;
    display: block;
}

/*
 * Les mesures communes aux deux états. Toute valeur changée ici doit l'être pour les deux, sans
 * quoi le texte saute d'un ou deux pixels à l'ouverture — c'est peu, et cela se voit.
 *
 * Le retrait de droite réserve la place du crayon, posé par-dessus. Le liseré transparent en
 * lecture retient la place de celui, coloré, qui apparaît à la saisie.
 *
 * Le retrait vertical n'est pas au plus juste : deux pixels suffisaient à l'œil, mais les
 * jambages descendants — le g, le p, le q — s'appuyaient contre le bord selon la police du
 * système. Ce qui se lit doit avoir de l'air sous la dernière ligne, et la commande gagne au
 * passage une hauteur qu'on vise sans hésiter.
 */
.field-read,
.field-edit [data-input] {
    display: block;
    width: 100%;
    padding: var(--app-space-xs) 1.75rem var(--app-space-xs) var(--app-space-2xs);
    border: 1px solid transparent;
    border-radius: var(--app-radius-ui);
    font: inherit;
    line-height: 1.5;
    text-align: left;
    color: var(--app-ink);
}

.field-read {
    background: none;
    cursor: pointer;
}

.field-read > [data-value] {
    display: block;
    /* Le texte tel qu'il a été écrit, sauts de ligne compris : c'est de la prose. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* Un champ vide se lit comme le geste qui le remplit, pas comme un manque. */
.field-read[data-empty] > [data-value] {
    color: var(--app-accent);
    font-weight: var(--app-weight-medium);
}

/* Une échéance dépassée se signale : la laisser se lire comme les autres cacherait un retard. */
.field-read[data-late] > [data-value] {
    color: var(--app-danger, var(--app-ink));
    font-weight: var(--app-weight-semibold);
}

.field-read:hover,
.field-read:focus-visible {
    background: var(--app-hover);
}

/* En saisie : le même fond qu'au survol, et le liseré d'accent qui dit que c'est ouvert. Rien
   d'autre — ni ombre, ni halo, qui déborderaient de la boîte et la feraient paraître bouger. */
.field-edit [data-input] {
    background: var(--app-hover);
    border-color: var(--app-accent);
    box-shadow: none;
    outline: none;
}

.field-edit textarea {
    resize: vertical;
    min-height: 5rem;
}

/*
 * Le crayon reste visible pendant qu'on écrit : il n'annonce pas seulement qu'on peut corriger,
 * il marque aussi que le champ n'est pas un formulaire de plus dans la page.
 */
.field-pencil {
    position: absolute;
    top: calc(var(--app-space-xs) + 1px);
    right: var(--app-space-2xs);
    display: inline-flex;
    color: var(--app-ink-muted);
    font-size: var(--app-text-sm);
    opacity: 0;
    transition: opacity 150ms var(--app-ease);
    /* Le clic traverse : le crayon couvre le bord droit de la zone de lecture, et l'atteindre
       ne doit pas empêcher de l'ouvrir. */
    pointer-events: none;
}

.field-box:hover .field-pencil,
.field-read:focus-visible ~ .field-pencil,
.field[data-open] .field-pencil {
    opacity: 1;
}

/*
 * La date fait exception, et c'est le navigateur qui l'impose : son champ apporte son propre
 * calendrier, dans le coin même où vit le crayon. Deux dessins l'un sur l'autre ne diraient
 * plus rien — le crayon s'efface donc le temps de la saisie, et le retrait qui lui était
 * réservé revient au calendrier.
 */
.field[data-native][data-open] .field-pencil {
    opacity: 0;
}

.field[data-native] .field-edit [data-input] {
    padding-right: var(--app-space-2xs);
}

/* Les deux commandes sous la saisie, poussées à droite : elles referment le geste, elles ne
   l'annoncent pas. Leur `display` est décidé plus haut, avec le repli — le redire ici le
   défairait. */
.field-acts {
    justify-content: flex-end;
    align-items: center;
    gap: var(--app-space-xs);
    margin-top: var(--app-space-xs);
}

.field-refusal {
    margin: var(--app-space-2xs) 0 0;
    font-size: var(--app-text-sm);
    color: var(--app-danger, var(--app-ink));
}

/*
 * Le nom du projet, en tête de page, et ce qu'on peut en faire au bout de la ligne. La barre du
 * haut nomme la section ; ce titre nomme ce qu'on regarde, et il a ici la place d'être grand.
 */
.project-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--app-space-md);
    margin-bottom: var(--app-space-lg);
}

/*
 * Pas d'intitulé — la ligne dit déjà de quoi il s'agit — et la boîte épouse le texte au lieu de
 * prendre la ligne entière.
 *
 * Tout y tient sur une ligne. Le champ ne s'étire pas à la largeur qu'on lui accorde : il prend
 * celle de son texte, et c'est ce qui empêche le titre de se replier en deux lignes parce qu'un
 * crayon lui a pris trois millimètres. Trop long, il se coupe par des points de suspension — la
 * barre n'est pas l'endroit où lire un nom de trente mots.
 */
.project-title,
.project-title .field,
.project-title .field-box {
    display: inline-flex;
    min-width: 0;
    max-width: 100%;
}

/* La mesure d'un titre de fiche : assez grand pour qu'on sache où l'on est, sans le gras d'un
   titre de document — la page en porte déjà assez. */
.project-title {
    font-size: var(--app-text-2xl);
    font-weight: var(--app-weight-medium);
    line-height: 1.2;
}

.project-title .field {
    grid-template-columns: minmax(0, 1fr);
}

.project-title .field-label {
    display: none;
}

.project-title .field-read,
.project-title .field-edit [data-input] {
    width: auto;
    max-width: 100%;
    /* Ni marge négative ni retrait à gauche : une marge négative se retranche de la mesure que
       le titre transmet à la barre, et le nom se coupait de quelques millimètres pour cette
       seule raison. */
    padding: 0 1.5rem 0 var(--app-space-2xs);
    margin: 0;
    font: inherit;
}

.project-title .field-read > [data-value] {
    /* Sa largeur naturelle, et non celle qu'on lui accorde : un enfant en `flex: 1` ne compte
       pas sa propre mesure dans celle de son parent, et le titre se coupait sans raison. */
    flex: 0 1 auto;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.project-title .field-pencil {
    top: 50%;
    right: var(--app-space-2xs);
    transform: translateY(-50%);
}

/*
 * À l'ouverture, le champ prend un peu plus que son texte : épousé au caractère près, il se
 * lirait comme une valeur figée plutôt que comme une place où écrire. Ce qui déborde de la
 * barre reste borné par la largeur disponible.
 */
.project-title .field-edit [data-input] {
    width: 100%;
    min-width: min(24rem, 100%);
}


/*
 * Un cadre qui n'a pas pu se remettre à jour.
 *
 * Ce qu'il montre reste — un cadre vide vaudrait moins que du contenu périmé — mais il cesse
 * de se faire passer pour l'état courant. Sans ce signal, on ne distingue pas « le souvenir
 * n'a pas été retenu » de « il l'a été, mais l'affichage n'a pas suivi », et on refait le
 * geste.
 *
 * Discret volontairement : la panne est celle de l'affichage, pas celle du projet.
 */
[data-controller~="panel"][data-stale] {
    opacity: 0.55;
}

[data-controller~="panel"][data-stale]::before {
    content: attr(data-stale-message);
    display: block;
    padding: var(--app-space-2xs) var(--app-space-xs);
    border-radius: var(--app-radius-sm);
    background: var(--app-surface-sunken, transparent);
    color: var(--app-text-muted);
    font-size: var(--app-font-size-sm);
    text-align: center;
}
