Second Brain documentation

ADR 0014 – Inbox explicite (`node.inbox_at`) et palette de commandes Ctrl+K

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

ADR 0014 – Inbox explicite (node.inbox_at) et palette de commandes Ctrl+K

Statut : Accepté (2026-10-15, mode autonome)

Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles) : l’inbox est un état explicite du nœud, la colonne node.inbox_at (date d’entrée, null = hors inbox), et non une règle dérivée (sans type ni aspect), un tag réservé ou une vue YAML. On y entre par la capture (CreateNodeRequest.inbox, POST /api/nodes/files?inbox=true) ou par PUT /api/nodes/{id}/inbox. On en sort uniquement de façon explicite (DELETE /api/nodes/{id}/inbox, « Classé ») : aucune sortie automatique au jalon 1. Événements inbox.added et inbox.removed. Lecture par un filtre inbox de GET /api/nodes et un compteur générique GET /api/nodes/count. Pas de rattrapage des nœuds existants. Côté front : une page /inbox avec un champ de capture (titre seul, puis Entrée), un badge dans la navigation, et une palette Ctrl+K (Cmd+K sur macOS) dans un MatDialog, avec une liste ARIA combobox/listbox écrite à la main, sans nouvelle dépendance. La palette propose des commandes « Créer : » générées depuis config/types/, des commandes de navigation fixes, la capture et la recherche de nœuds. Les commandes locales et la capture passent avant les résultats asynchrones : la touche Entrée a toujours le même effet. Format YAML et méta-schémas inchangés.

Contexte

Le cahier (« Saisie et capture », MVP, jalon 1) demande une inbox (« nœuds capturés sans classement, à trier plus tard ») et une création rapide (« palette de commandes (Ctrl+K) : « nouvelle tâche », « nouvelle facture »… »). Le principe directeur est de « créer et classer en moins de 10 secondes, y compris depuis le téléphone ». Les actions globales (« boutons de dashboard ou de palette ») sont MVP, mais le moteur d’actions relève du jalon 3. La ligne de backlog correspondante est « Inbox et création rapide (palette Ctrl+K) ».

État du code :

  • POST /api/nodes accepte déjà un titre seul (CreateNodeRequest, title obligatoire, 500 caractères au plus). La création rattrape les liens wiki en attente du titre (ADR 0009).
  • Un type ne peut être choisi qu’à la création : PATCH /api/nodes/{id} ne touche que le titre et le corps, et aucun endpoint ne change le type. On classe un nœud existant en lui ajoutant des aspects (PUT …/aspects/{a}), un parent (PUT …/parent), des tags ou des liens.
  • Créer un type dont un aspect obligatoire a des champs requis sans valeur par défaut échoue sans données. Par exemple, facture-fournisseur exige facture.montant et facture.echeance : un POST avec le titre seul renvoie 400. tache et projet passent grâce à leurs valeurs par défaut.
  • L’ADR 0012 crée sans type ni aspect les nœuds déposés (accueil, liste, panneau des enfants) et les qualifie de « candidats naturels de l’inbox ». L’autocomplétion [[ crée aussi des nœuds sans type ni aspect, mais ce sont des cibles de liens, déjà rattachées (ADR 0009).
  • GET /api/nodes (ADR 0013) combine les filtres aspect, type, tag, parent et q. Le tri place d’abord la clé exacte, puis le début de titre insensible aux accents, puis ts_rank, puis updated_at. Sans q, il trie par updated_at desc. core/node-search.ts (searchNodes$) fournit le debounce, l’annulation et le repli en cas d’erreur.
  • Front : la coquille (app.ts) a une barre d’outils avec « Nouveau » et une navigation latérale (Accueil, Nœuds, Définitions). Le formulaire /nodes/new?type=<name> est prérempli par typeParam. L’accueil est provisoire (le dashboard arrive au jalon 2). DefinitionsStore.types$ met les types en cache. @angular/cdk et @angular/material sont installés. MatDialog et MatSnackBar servent déjà dans l’éditeur.
  • Tiptap 3.32 (StarterKit, Link, Table, Image, List) ne lie pas Mod-k. Les raccourcis liés sont Mod-b, Mod-i, Mod-e, Mod-Enter, Mod-Alt-*, Mod-Shift-*, etc. ProseMirror n’appelle preventDefault que pour les touches qu’il traite.
  • config/dashboards/accueil.yaml (exemple non chargé, jalon 2) cite déjà des actions nouvelle-tache et nouvelle-facture. Ces actions sont déclaratives et relèvent du jalon 3.

Deux choix sont difficiles à défaire : la définition de l’inbox, qui détermine la forme des données, les événements lus par les règles du jalon 3 et ce que le langage de requête des vues devra exposer au jalon 2, et le comportement de la touche Entrée dans la palette, qui devient vite un réflexe.

Décision

Définition de l’inbox : un état explicite, daté

  • L’inbox est l’ensemble des nœuds hors corbeille dont node.inbox_at n’est pas nul. inbox_at est la date d’entrée dans l’inbox. Elle permet de trier par date de capture et d’afficher « depuis… ».
  • Le moteur reste générique : il ne connaît ni type ni aspect. Être dans l’inbox ne dit rien du contenu du nœud, seulement qu’il reste à trier. Un nœud peut être typé et pourtant dans l’inbox (par exemple via un futur partage mobile qui crée un document), ou sans type ni aspect et hors inbox (une note libre délibérée, une cible [[…]]).
  • Entrée :
    • à la création, avec CreateNodeRequest.inbox = true. L’inbox se combine avec tout le reste : type, aspects, parent, tags ;
    • au dépôt de fichiers, avec POST /api/nodes/files?inbox=true ;
    • ensuite, avec PUT /api/nodes/{id}/inbox (« Remettre dans l’inbox »).
  • Sortie : uniquement par DELETE /api/nodes/{id}/inbox (« Classé »). Ajouter un aspect, déplacer le nœud ou lui poser des tags ne le sort pas de l’inbox. Le tri reste une décision de l’utilisateur, comme le traitement d’une inbox GTD. Une sortie automatique pourra être déclarée par une règle au jalon 3 (voir Reporté) ; elle ne sera pas codée en dur.
  • Corbeille : inbox_at est conservé. Un nœud en corbeille n’est ni listé ni compté. Une restauration (jalon 3) le remet donc dans l’inbox s’il y était.
  • Pas de rattrapage : l’application n’est pas encore déployée (backlog). Les nœuds déjà créés par dépôt en développement peuvent être remis dans l’inbox à la main.

Migration V8__inbox.sql

-- Inbox (ADR 0014) : date d'entrée dans l'inbox ; null = hors inbox (classé, ou jamais capturé).
alter table node add column inbox_at timestamptz;

-- Liste et compteur de l'inbox (tri par date de capture), hors corbeille.
create index node_inbox_idx on node (inbox_at desc) where inbox_at is not null and deleted_at is null;

Db gagne INBOX_AT = field(name("inbox_at"), SQLDataType.TIMESTAMPWITHTIMEZONE).

Événements

Ils sont écrits dans la transaction d’écriture, sur le modèle des fichiers (ADR 0012) :

  • création : le payload de node.created gagne "inbox": true | false. Aucun événement séparé ;
  • inbox.added {"after": {"inboxAt": "<horodatage>"}} ;
  • inbox.removed {"before": {"inboxAt": "<horodatage>"}}. La durée passée dans l’inbox se déduit de l’événement ;
  • une écriture sans effet (déjà dans l’inbox, ou déjà hors inbox) ne produit ni événement ni mise à jour. Sinon, le même UPDATE écrit inbox_at (now() ou null) et updated_at, comme pour les tags et les fichiers.

API

Méthode Chemin Effet Réponse
POST /api/nodes inbox facultatif dans le corps, false par défaut 201 NodeView
POST /api/nodes/files?inbox=true dépôt d’un fichier directement dans l’inbox (inbox vaut false par défaut) 201 NodeView
PUT /api/nodes/{id}/inbox place le nœud dans l’inbox (idempotent, sans corps) 200 NodeView ; 404 si le nœud est absent ou en corbeille
DELETE /api/nodes/{id}/inbox retire le nœud de l’inbox (idempotent) 200 NodeView ; 404 si le nœud est absent ou en corbeille
GET /api/nodes?inbox=true|false filtre combinable avec aspect, type, tag, parent, q ; s’il est absent, aucun filtrage 200 NodeSummary[]
GET /api/nodes/count?… nombre de nœuds hors corbeille qui satisfont les mêmes filtres que la recherche (aspect, q, type, tag, parent, inbox) 200 NodeCount
  • DELETE reste idempotent (200 même si le nœud n’est pas dans l’inbox), contrairement à removeAspect ou DELETE …/file qui renvoient 404. L’inbox est un état binaire et non une ressource qui porte des données : un double clic sur « Classé », ou un « Annuler » qui arrive en retard, ne doit pas produire d’erreur.
  • Tri : avec inbox=true et sans q, le résultat est trié par inbox_at desc, created_at desc (dernière capture en tête). Dans tous les autres cas, le tri de l’ADR 0013 s’applique tel quel.
  • GET /api/nodes/count est générique et n’est pas propre à l’inbox. Il sert au badge de navigation et resservira à tout compteur jusqu’aux widgets du jalon 2. Spring MVC préfère le segment littéral /count à /{id} ; un test le vérifie.

DTO (NodeDtos) :

public record CreateNodeRequest(
        @NotBlank @Size(max = 500) String title,
        String type,
        @Size(max = MAX_BODY_LENGTH, message = BODY_TOO_LONG) String body,
        UUID parentId,
        Map<String, Map<String, Object>> aspects,
        @Size(max = 500) List<String> tags,
        /** place le nœud dans l'inbox (ADR 0014) ; absent = faux */ Boolean inbox) {

    /** Constructeurs de commodité conservés (46 appels existants) : 6 et 5 arguments, inbox = null. */
    public CreateNodeRequest(String title, String type, String body, UUID parentId,
                             Map<String, Map<String, Object>> aspects, List<String> tags) { … }

    boolean inInbox() { return Boolean.TRUE.equals(inbox); }
}

/** @param count nombre de nœuds hors corbeille qui satisfont les filtres */
public record NodeCount(long count) { }
  • NodeView et NodeSummary gagnent un dernier composant inboxAt (OffsetDateTime), absent du JSON s’il est nul (@JsonInclude(NON_NULL), @Schema(requiredMode = NOT_REQUIRED), comme file).

Contrôleurs :

// NodeController
@GetMapping public List<NodeSummary> search(…, @RequestParam(required = false) Boolean inbox);
@GetMapping("/count") public NodeCount countNodes(List<String> aspect, String q, List<String> type,
                                                  List<String> tag, UUID parent, Boolean inbox);
@PutMapping("/{id}/inbox")    public NodeView addToInbox(@PathVariable UUID id);
@DeleteMapping("/{id}/inbox") public NodeView removeFromInbox(@PathVariable UUID id);

// NodeFileController
public ResponseEntity<NodeView> createNodeWithFile(@RequestPart("file") MultipartFile file,
        @RequestParam(required = false) UUID parentId, @RequestParam(defaultValue = "false") boolean inbox);

Service et dépôt (paquet node) :

/** Filtres de la recherche simple du jalon 1, déjà nettoyés et normalisés (tags). */
record NodeQuery(List<String> aspects, List<String> types, List<String> tags, String text, UUID parent, Boolean inbox) { }

// NodeService
public List<NodeSummary> search(List<String> aspects, String text, int limit, List<String> types,
                                List<String> tags, UUID parent, Boolean inbox);
public long count(List<String> aspects, String text, List<String> types, List<String> tags, UUID parent, Boolean inbox);
/** Idempotent : lock, find (404), compare, puis updateInbox et événement si l'état change. */
@Transactional public NodeView setInbox(UUID id, boolean inbox);

// NodeRepository
List<NodeRow> search(NodeQuery query, int limit);
long count(NodeQuery query);                          // mêmes conditions, sans tri
/** Écrit inbox_at (now() ou null) et updated_at ; renvoie la nouvelle valeur (returning). */
OffsetDateTime updateInbox(UUID id, boolean inbox);
  • Les conditions de search sont extraites dans une méthode privée partagée avec count. La condition plein texte reste celle de l’ADR 0013. NodeRow gagne inboxAt, lu par find et search.
  • NodeService.create passe request.inInbox() à NodeRepository.insertNode(…, boolean inbox), qui écrit inbox_at = now() (heure de la transaction, identique à created_at).
  • NodeFileService transmet inbox à CreateNodeRequest.

Capture rapide (front)

  • core/inbox/inbox-store.ts, InboxStore (providedIn: 'root') :

    • count: Signal<number> et refresh() (countNodes avec inbox=true) ;
    • capture(title): Observable<NodeView> (create({ title: title.trim(), inbox: true })) ;
    • classify(id) (DELETE) et restore(id) (PUT).

    Chaque écriture appelle refresh(). La coquille appelle aussi refresh() au démarrage, à chaque NavigationEnd et quand l’onglet redevient visible (visibilitychange). Il n’y a pas de sondage périodique.

  • shared/capture/quick-capture.ts, app-quick-capture : un <form> avec un seul champ « Capturer » (maxlength="500", enterkeyhint="send", autocomplete="off") et un bouton « Capturer ». Entrée envoie. En cas de succès, le champ est vidé et garde le focus, ce qui permet d’enchaîner les captures. Une région role="status" annonce « « … » ajouté à l’inbox ». En cas d’erreur, le texte est conservé et un mat-error affiche describeApiError. Une saisie vide ou faite d’espaces n’envoie rien. Entrées : autofocus ; sortie : captured: NodeView.

  • Page /inbox (features/inbox/inbox-page.ts, titre « Inbox · Second Brain », chargée à la demande) :

    • app-quick-capture (avec autofocus), le bouton « Importer des fichiers » et une zone de dépôt (appFileDrop), avec inbox=true ;
    • une liste (mat-list, adaptée au mobile, sans AG Grid) de GET /api/nodes?inbox=true&limit=200. Chaque élément affiche :
      • le titre, en lien vers /nodes/:id ;
      • l’icône du type, ou une icône de fichier ;
      • la date de capture, relative (Intl.RelativeTimeFormat('fr')) ;
      • les aspects et les tags ;
      • un bouton « Classé » (aria-label = « Marquer « titre » comme classé »).
    • « Classé » retire l’élément de la liste et affiche un MatSnackBar « Classé » avec l’action « Annuler », qui appelle restore.
    • Si le compteur dépasse le nombre d’éléments affichés, on indique « 200 premiers sur N ». Liste vide : « Rien à trier. »
    • Aucune mise en corbeille depuis la liste : elle ne se restaure pas avant le jalon 3.
  • Navigation : entrée « Inbox » (icône inbox) entre Accueil et Nœuds, avec un matBadge égal au compteur, masqué à 0. matBadgeDescription vaut « N éléments à trier ». Les entrées de navigation sont extraites de app.ts dans une constante partagée NAV_ITEMS (core/navigation.ts), que la palette réutilise.

  • Accueil (provisoire) : app-quick-capture en tête et une carte « Inbox : N à trier » qui renvoie vers /inbox.

  • Éditeur (node-editor-page) :

    • si inboxAt est présent, un bandeau « Dans l’inbox depuis le … » propose un bouton « Classé » ;
    • sinon, un bouton d’en-tête move_to_inbox « Remettre dans l’inbox ».

    Comme les panneaux de liens et de fichier, ces boutons écrivent immédiatement et ne mettent à jour que le signal inboxAt : le formulaire n’est pas rechargé et la saisie en cours est préservée.

  • Dépôts : FileUploader.createNodes(files, parentId?, inbox = false). L’accueil, la liste des nœuds et la page inbox déposent avec inbox=true. Le panneau des enfants n’utilise pas l’inbox : un parent suffit à classer.

Palette de commandes

Ouverture

  • Raccourci : core/palette/palette-hotkey.ts.

    /** Cmd+K sur macOS, Ctrl+K ailleurs ; ni Alt ni Maj ; ignoré si déjà traité ou pendant une composition IME. */
    export function isPaletteShortcut(e: KeyboardEvent, mac: boolean): boolean;
    /** Écoute keydown sur document (phase de bouillonnement) ; renvoie la fonction de désinstallation. */
    export function installPaletteHotkey(open: () => void, mac = isMacPlatform()): () => void;
    • On ne retient qu’une seule touche par plateforme : sous macOS, Ctrl+K est le « kill line » des champs de texte et ne doit pas être volé. La plateforme est lue dans navigator.userAgentData?.platform ?? navigator.platform.
    • Le gestionnaire appelle preventDefault(), ce qui empêche Chrome et Firefox de placer le focus dans leur barre de recherche.
    • Il est installé dans le constructeur de App, comme installFileDropGuard.
  • Tiptap : Mod-k n’est lié à rien dans l’éditeur (voir Contexte), donc l’événement remonte jusqu’à document. Règle : Ctrl/Cmd+K est réservé à la palette, aucune extension de l’éditeur ne doit le lier. Un test de markdown-editor.spec.ts vérifie que handleKeyDown ne traite pas Mod-k. Le gestionnaire ignore les événements defaultPrevented. Ainsi, si un composant revendique un jour la touche, il l’emporte au lieu de provoquer une double action, et le test signale l’écart.

  • Bouton : icône bolt dans la barre d’outils, avant « Nouveau », visible à toutes les tailles d’écran. aria-label vaut « Commandes et capture (Ctrl+K) » ; sous macOS, le texte affiche « ⌘K ». C’est l’accès mobile.

  • Lanceur : core/palette/palette-launcher.ts, PaletteLauncher (providedIn: 'root').

    • open() charge à la demande features/palette/open-palette.ts, qui importe MatDialog et le composant ; le bundle initial n’embarque ni l’un ni l’autre. preload() est appelé au repos (requestIdleCallback, repli setTimeout) après le démarrage, pour que la première ouverture soit immédiate.
    • Une seule instance à la fois : un second Ctrl+K redonne le focus au champ.
    • Ctrl+K est sans effet si un autre dialogue est ouvert (MatDialog.openDialogs).
  • Dialogue : MatDialog, qui apporte le CDK Overlay, le piège de focus, la restauration du focus, Échap, le fond et aria-modal.

    • ariaLabel vaut « Palette de commandes » et autoFocus vise le champ.
    • Sur poste de travail : largeur min(640px, 100vw - 32px), en haut à 12vh.
    • Sur Breakpoints.XSmall : plein écran (100vw × 100dvh).

Contenu (fonctions pures, core/palette/palette-items.ts) :

export interface PaletteCommand {
  id: string; group: 'create' | 'goto'; label: string; icon: string;
  keywords: readonly string[]; route: readonly string[]; queryParams?: Readonly<Record<string, string>>;
}
export type PaletteItem =
  | { kind: 'command'; id: string; command: PaletteCommand }
  | { kind: 'capture'; id: 'capture'; title: string; disabled: boolean }   // disabled : plus de 500 caractères
  | { kind: 'node'; id: string; node: NodeSummary };
export interface PaletteGroup { id: string; label: string; items: readonly PaletteItem[] }

/** « Créer : <label> » par type de config/types/ (tri fr), icône du type, route /nodes/new?type=<name>. */
export function typeCommands(types: readonly TypeDefinition[]): PaletteCommand[];
/** Commandes fixes : « Nouveau nœud » (/nodes/new) et « Aller à : <entrée> » pour chaque NAV_ITEMS. */
export function fixedCommands(): PaletteCommand[];
/** Chaque mot de la saisie (minuscules, sans accents) préfixe un mot du libellé ou des mots-clés. */
export function matchesCommand(query: string, command: PaletteCommand): boolean;
export function paletteGroups(query: string, commands: readonly PaletteCommand[], nodes: readonly NodeSummary[]): PaletteGroup[];
/** Option active après ↑/↓ (avec bouclage) ; conservée par id quand la liste change, sinon première option active. */
export function moveActive(items: readonly PaletteItem[], activeId: string | null, delta: 1 | -1): string | null;
  • Commandes de création générées depuis config/types/ : une commande « Créer : Tâche », « Créer : Facture fournisseur », etc. par type, avec les mots-clés créer, nouveau, nouvelle et le nom technique.

    • Le libellé est neutre (« Créer :
    • La commande ouvre le formulaire /nodes/new?type=<name>, sans rien créer directement. Elle fonctionne ainsi pour tous les types, y compris ceux dont les champs requis n’ont pas de valeur par défaut (facture.montant), sans que le front ait à le savoir.
  • Formulaire de création (/nodes/new seulement) :

    • le champ Titre reçoit le focus au chargement ;
    • Entrée dans ce champ enregistre la création (même effet que le bouton, y compris les erreurs de validation des aspects).

    Avec un type sans champ requis à saisir, « créer une tâche » tient donc en quatre gestes : Ctrl+K, « tâ », Entrée, titre puis Entrée.

  • Ordre des groupes :

    Saisie Groupes, dans l’ordre
    vide « Créer » (Nouveau nœud, puis les types), « Aller à », « Modifiés récemment » (8 nœuds, searchNodes$ sans q)
    non vide commandes qui correspondent (« Créer », « Aller à »), puis « Capturer » (Capturer « saisie » dans l'inbox), puis « Nœuds » (8 résultats de searchNodes$, debounce 150 ms)
  • Entrée exécute l’option active. Par défaut, c’est la première. Les commandes sont filtrées de façon synchrone et la capture arrive juste après elles : l’option active par défaut ne dépend donc jamais de l’arrivée des résultats réseau. « Payer facture Proximus » puis Entrée capture toujours, même si un nœud « Proximus » arrive 150 ms plus tard. « tâche » puis Entrée ouvre « Créer : Tâche ». « inbox » puis Entrée ouvre l’inbox. Une saisie dont un seul mot ne correspond à aucune commande est capturée. Les résultats de nœuds d’une saisie précédente sont retirés dès la frappe.

  • Exécution :

    • commande : fermeture du dialogue, puis navigation. Le garde des modifications non enregistrées s’applique ;
    • nœud : fermeture du dialogue, puis navigation vers /nodes/:id. Une option de nœud affiche l’icône et le libellé du type, et la mention « inbox » si inboxAt est présent ;
    • capture : InboxStore.capture dialogue ouvert, avec un indicateur sur l’option. En cas de succès, le dialogue se ferme et un MatSnackBar affiche « Ajouté à l’inbox » avec l’action « Ouvrir ». En cas d’erreur, le message s’affiche dans le dialogue (role="alert") et la saisie est conservée. Comme la capture ne quitte pas la page, elle peut se faire depuis un éditeur sans rien perdre.

Accessibilité (motif combobox de la WAI-ARIA APG, liste avec sélection automatique) :

  • Le champ porte :

    • role="combobox", aria-autocomplete="list" et aria-expanded="true" ;
    • aria-controls="palette-listbox" ;
    • aria-activedescendant="palette-opt-<id>".

    Le focus reste toujours dans le champ.

  • Liste :

    • le conteneur porte role="listbox" ;
    • chaque groupe porte role="group", avec aria-labelledby sur son intitulé ;
    • chaque option porte role="option", un id, aria-selected et, le cas échéant, aria-disabled ;
    • les options ne prennent pas le focus (tabindex="-1").
  • Clavier : ↑ et ↓ bouclent ; Entrée exécute ; Échap ferme ; Tab reste dans le dialogue. Origine et Fin restent au champ de saisie.

  • Souris et toucher :

    • un clic ou un appui exécute ;
    • mousemove, et non mouseenter, rend une option active, pour que le défilement sous un pointeur immobile ne change pas la sélection ;
    • l’option active est gardée visible (scrollIntoView({ block: 'nearest' })).
  • Une région role="status" annonce le nombre de résultats (« 5 résultats »). Un pied de dialogue rappelle « Entrée : exécuter · ↑↓ : parcourir · Échap : fermer ».

  • On n’utilise ni mat-autocomplete (panneau flottant séparé, fermeture au blur, Échap en deux temps) ni @angular/cdk/listbox (le focus y va aux options, alors que la combobox le garde dans le champ). La gestion de l’option active tient dans moveActive, une fonction pure testée.

Ce qui reste hors périmètre

  • Vues et dashboards du jalon 2 : l’inbox est un écran fixe et non une vue YAML. Le langage de requête du jalon 2 devra exposer inbox comme champ système filtrable (query: { inbox: true }), pour qu’un dashboard puisse afficher un compteur « À trier ».
  • Actions globales : la palette n’exécute que des commandes d’interface (navigation, formulaire de création, capture). Les actions YAML (nouvelle-tache, nouvelle-facture) viendront avec le moteur d’actions du jalon 3, comme seconde source de commandes de la palette.

Tests

Backend, intégration (Testcontainers, NodeInboxIT, sur le modèle de NodeTagIT) :

  • POST avec inbox: true : inboxAt présent dans NodeView et NodeSummary, node.created avec "inbox": true. Sans inbox : absent du JSON, "inbox": false.
  • PUT …/inbox sur un nœud hors inbox : un événement inbox.added, updated_at modifié. Un second PUT : ni événement ni modification de updated_at. Même chose pour DELETE et inbox.removed (dont le before.inboxAt vaut l’ancienne date). 404 sur un nœud inconnu ou en corbeille.
  • GET ?inbox=true : seulement l’inbox, corbeille exclue, tri par inbox_at desc (trois captures successives). inbox=false : le complément. Combinaison avec q et type.
  • GET /api/nodes/count : égal à la taille de la liste pour plusieurs combinaisons de filtres (inbox, q, tag, parent). Par HTTP : /api/nodes/count répond 200 et n’est pas pris pour un {id}.
  • POST /api/nodes/files?inbox=true : nœud dans l’inbox, puis node.created et file.attached. Sans le paramètre : hors inbox.
  • Mise en corbeille d’un nœud de l’inbox : inbox_at est conservé en base, mais le nœud n’est plus listé ni compté.
  • OpenApiContractIT : présence de /api/nodes/count et de /api/nodes/{id}/inbox.

Front (Vitest) :

  • palette-items.spec.ts :
    • correspondance insensible aux accents et à la casse, par préfixe de mot, tous les mots requis ;
    • typeCommands (libellé, icône par défaut, route, tri) ;
    • ordre des groupes, saisie vide ou non ;
    • capture absente si la saisie est vide, désactivée au-delà de 500 caractères ;
    • moveActive : bouclage, conservation par id, repli.
  • palette-hotkey.spec.ts : Ctrl+K (Windows) et Cmd+K (macOS) ouvrent et appellent preventDefault. Sont ignorés : Ctrl+K sous macOS, Cmd+K hors macOS, Ctrl+Maj+K, Alt, defaultPrevented, isComposing, ainsi que l’appel après désinstallation.
  • command-palette.spec.ts :
    • attributs ARIA ; aria-activedescendant suit ↓ et ↑ ;
    • Entrée sur la capture appelle create avec inbox: true puis ferme le dialogue ;
    • une erreur garde le dialogue ouvert et la saisie ;
    • des résultats de nœuds qui arrivent après la frappe ne changent pas l’option active ;
    • Entrée sur une commande ou sur un nœud navigue.
  • inbox-page.spec.ts : liste, capture par Entrée (champ vidé, liste et compteur rafraîchis), « Classé » puis « Annuler » (DELETE puis PUT), état vide, mention « 200 premiers sur N ».
  • quick-capture.spec.ts : saisie vide non envoyée, erreur affichée, texte conservé.
  • inbox-store.spec.ts : refresh après chaque écriture.
  • markdown-editor.spec.ts : Mod-k n’est pas traité par l’éditeur.
  • node-editor-page.spec.ts : bandeau et bouton « Classé », bouton « Remettre dans l’inbox », focus du titre et Entrée qui enregistre en création.
  • file-drop-points.spec.ts : inbox=true depuis l’accueil, la liste et l’inbox, absent depuis le panneau des enfants.

Découpage

  1. Backend (feat(inbox): inbox explicite, filtre et compteur des nœuds) :

    • V8__inbox.sql, Db.INBOX_AT ;
    • DTO (CreateNodeRequest.inbox, inboxAt, NodeCount), NodeQuery, search et count ;
    • setInbox et ses événements, endpoints, paramètre inbox du dépôt de fichiers ;
    • descriptions OpenAPI et tests backend.

    Testable seule par Swagger UI.

  2. Front, inbox (feat(inbox): page inbox et capture rapide) :

    • client régénéré (skill api-contract) ;
    • InboxStore, app-quick-capture, page /inbox, NAV_ITEMS et badge ;
    • bandeau et bouton de l’éditeur, carte et capture de l’accueil, dépôts avec inbox=true ;
    • tests.
  3. Front, palette (feat(palette): palette de commandes Ctrl+K) :

    • raccourci, lanceur (chargement à la demande et préchargement), palette-items, dialogue, bouton de la barre d’outils ;
    • focus du titre et Entrée sur /nodes/new ;
    • test de non-liaison de Mod-k ;
    • skill frontend-conventions (Ctrl/Cmd+K réservé à la palette) ;
    • backlog : case cochée et lignes reportées ci-dessous.

Alternatives écartées

  • Inbox dérivée : sans type ni aspect (éventuellement aussi sans parent ni tag saisi) : aucune migration, et sortie automatique dès qu’on classe. Écartée pour quatre raisons :
    • les cibles créées par [[…]] et toute note libre délibérée y tomberaient, sans moyen de les en sortir autrement qu’en leur ajoutant artificiellement une métadonnée ;
    • il n’y a pas de date de capture pour trier ;
    • la requête doit combiner des not exists sur node_aspect et node_tag ;
    • la sémantique « non classé » deviendrait une règle cachée du moteur, à redéfinir à chaque nouvelle forme de classement.
  • Tag réservé inbox : aucune migration, et filtre ?tag=inbox déjà disponible. Mais une chaîne magique se retrouve dans le code. L’état système se mêle au vocabulaire libre de l’utilisateur (autocomplétion, comptes). Un #inbox écrit dans un corps ferait entrer le nœud dans l’inbox (union des tags, ADR 0011). Et sortir de l’inbox passe par PUT …/tags, qui remplace toute la liste, au risque d’écraser une saisie concurrente.
  • Inbox configurable en YAML (vue inbox dans config/views/) : demande le langage de requête et le moteur de vues du jalon 2. La colonne retenue sera filtrable par ce langage, sans rien défaire.
  • Booléen inbox boolean not null default false : même coût, mais sans date de capture pour trier ni afficher « depuis ».
  • Table inbox_item : une jointure de plus pour un attribut un-à-un du nœud.
  • Sortie automatique côté serveur (à l’ajout d’un aspect, au déplacement, à l’ajout de tags) : règle implicite répartie dans plusieurs écritures, qui ferait sortir un nœud qu’on enrichit sans l’avoir trié. Une sortie automatique côté front (l’éditeur appelle DELETE après un ajout d’aspect) cacherait la même règle dans un seul client. Le jalon 3 pourra la déclarer comme règle.
  • Rattrapage par la migration (nœuds avec fichier, sans type ni aspect, à la racine) : écriture de données sans node_event, sauf à insérer aussi des événements en SQL, pour une base qui n’est pas encore déployée.
  • Endpoint dédié GET /api/inbox (éléments et compteur) : doublerait les filtres de la recherche (q dans l’inbox, par exemple). Le filtre suit le précédent de parent (ADR 0007).
  • Compteur dans un en-tête X-Total-Count, ou longueur de la liste côté client : l’en-tête n’apparaît pas dans le client généré, et la longueur est plafonnée à 500.
  • PUT /api/nodes/{id}/inbox avec { "inbox": bool }, ou inbox dans PATCH /api/nodes/{id} : le PATCH exige title et écrase body (ADR 0006, 0007). La paire PUT/DELETE sans corps est plus lisible et idempotente.
  • Création directe d’un nœud typé depuis la palette (POST avec le type et le titre) : échoue pour les types dont les champs requis n’ont pas de valeur par défaut (facture-fournisseur), à moins que le front ne lise les schémas pour savoir quand ouvrir un formulaire. Le formulaire convient à tous les types.
  • Résultats de nœuds en tête de la palette (Entrée ouvre le premier résultat) : l’effet d’Entrée dépendrait de la latence du réseau et de la largeur de la recherche plein texte. Une capture rapide ouvrirait un nœud au hasard.
  • Modes par préfixe (> pour les commandes, comme VS Code) : plus puissants, mais à apprendre. La règle « une commande ne correspond que si tous les mots correspondent » suffit à départager.
  • Raccourci de capture distinct (Ctrl+Maj+K, ou une lettre seule) : une lettre seule entre en conflit avec la frappe, et un second accord est un raccourci de plus à retenir. La palette fait déjà la capture.
  • Ctrl+K et Cmd+K acceptés sur toutes les plateformes : volerait le « kill line » de macOS dans les champs de texte.
  • Écoute en phase de capture, qui l’emporte sur tout composant : empêcherait un composant de revendiquer légitimement la touche, et masquerait un conflit au lieu de le révéler.
  • Bibliothèque de palette (cmdk et ses portages Angular, ninja-keys) : dépendance nouvelle et peu maintenue côté Angular, pour une centaine de lignes de logique pure.
  • mat-autocomplete dans le dialogue : ARIA fourni, mais un panneau flottant séparé, une fermeture au blur, Échap en deux temps et une hauteur de 256 px. Mauvais rendu plein écran sur mobile.
  • @angular/cdk/listbox : le focus va aux options, ce qui ne correspond pas au motif combobox où il reste dans le champ.
  • CDK Overlay brut : le piège de focus, la restauration, Échap, aria-modal et le fond seraient à recoder ; MatDialog les fournit déjà.
  • Libellé de création par type (clé YAML createLabel: Nouvelle tâche) : modifie le méta-schéma pour une question d’accord grammatical. Le libellé neutre suffit, et la clé peut s’ajouter plus tard.
  • Recherche floue (pg_trgm) pour la palette : la recherche actuelle (début de mot du titre sans accents, plein texte, clé exacte) suffit pour naviguer. La recherche floue relève du saut rapide (Ctrl+O, cahier) ; voir l’ADR 0013.

Conséquences

  • La capture tient en un geste depuis n’importe où (Ctrl+K, texte, Entrée), et sur mobile depuis l’inbox ou l’accueil (champ, puis « Envoyer » du clavier). Créer un nœud typé passe par le formulaire, ouvert par la palette en deux touches.
  • Un nœud enrichi d’aspects reste dans l’inbox tant qu’on ne l’a pas marqué « Classé ». Le bandeau de l’éditeur le rappelle. Si cette friction se confirme à l’usage, une règle du jalon 3 pourra le sortir à l’ajout d’un aspect.
  • Trier un élément modifie son updated_at : dans la liste des nœuds, les éléments triés remontent en tête.
  • De nouveaux types d’événements (inbox.added, inbox.removed) devront être libellés par l’écran d’historique (jalon 3) et pourront déclencher des règles. L’export (tranche dédiée) ajoute inboxAt au frontmatter. Les sauvegardes ne changent pas.
  • Le langage de requête du jalon 2 doit exposer inbox (champ système), au même titre que type et parent.
  • Ctrl/Cmd+K est réservé : aucune extension Tiptap ne doit le lier (de nombreux éditeurs l’utilisent pour insérer un lien), ce que le test vérifie. Quand le focus est dans la barre d’adresse du navigateur, Ctrl+K reste au navigateur.
  • Sur iOS, le focus programmatique du champ de la palette après l’ouverture animée du dialogue peut ne pas faire apparaître le clavier ; il faut alors toucher le champ. Le champ de capture de la page inbox, lui, prend le focus au toucher et n’a pas ce problème. À vérifier sur appareil.
  • GET /api/nodes/count fait un comptage complet avec les mêmes conditions que la recherche, en parcours séquentiel si q est fourni (ADR 0013). Le compteur de l’inbox utilise l’index partiel node_inbox_idx.
  • Le bundle initial ne gagne que le raccourci, le lanceur, InboxStore et MatBadge. Le dialogue et la page inbox sont des modules chargés à la demande.

Reporté

  • Changement de type d’un nœud existant (réalisé par l’ADR 0015) (« Classer comme… », PUT /api/nodes/{id}/type, ajout des aspects obligatoires et événement dédié) : utile pour trier l’inbox, mais c’est une décision à part entière (sort des aspects obligatoires de l’ancien type, validation des champs requis). Ligne de backlog à ajouter au jalon 1.
  • Sortie automatique de l’inbox par une règle déclarée (jalon 3), et actions globales YAML dans la palette (jalon 3).
  • Saut rapide Ctrl+O et recherche floue pg_trgm ; récents et favoris (la palette montre seulement les nœuds modifiés récemment).
  • Saisie en langage court (« Payer facture Proximus @demain #perso », V2), et création typée avec titre en une saisie (/nodes/new?title=).
  • Raccourcis d’application et cible de partage de la PWA vers la capture (jalon 2, V2).
  • Libellé de création par type (createLabel), traitement en lot dans l’inbox (sélection multiple), tri du plus ancien au plus récent, commande « Importer des fichiers » dans la palette.
  • Inbox comme vue ou widget de dashboard YAML (jalon 2).

Questions ouvertes tranchées (mode autonome)

  • Définition de l’inbox : état explicite daté (node.inbox_at), ni règle dérivée, ni tag, ni YAML.
  • Entrée : capture (palette, champ de capture), dépôt à la racine (accueil, liste, inbox) et « Remettre dans l’inbox ». La création par formulaire, les cibles [[…]] et les dépôts dans un parent n’entrent pas dans l’inbox.
  • Sortie : explicite seulement (« Classé », avec annulation par le snackbar).
  • Rattrapage des nœuds existants : aucun.
  • API : filtre inbox de GET /api/nodes, compteur générique GET /api/nodes/count, PUT/DELETE …/inbox idempotents.
  • Capture : titre seul, puis Entrée, dans l’inbox. Au-delà de 500 caractères, l’option est désactivée (pas de débordement dans le corps).
  • Palette : MatDialog et une combobox/listbox écrite à la main, sans dépendance nouvelle. Commandes « Créer : » depuis config/types/, qui ouvrent le formulaire. Navigation fixe, capture, nœuds. Les éléments locaux passent avant les résultats réseau.
  • Raccourci : Cmd+K sur macOS, Ctrl+K ailleurs, en bouillonnement sur document, réservé dans l’éditeur.
  • Mobile : bouton dans la barre d’outils, et champ de capture sur l’inbox et l’accueil.
  • Format YAML et méta-schémas : inchangés.