feat: initial commit
Showing 54 changed files with +5,076 and −0
added
.gitignore
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# .gitignore — Prisme | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | + | |
| 4 | +# Generated by XcodeGen — run `xcodegen generate` | |
| 5 | +Prisme.xcodeproj/ | |
| 6 | + | |
| 7 | +# Xcode | |
| 8 | +DerivedData/ | |
| 9 | +*.xcresult | |
| 10 | +xcuserdata/ | |
| 11 | +*.xcuserstate | |
| 12 | + | |
| 13 | +# macOS | |
| 14 | +.DS_Store | |
added
CLAUDE.md
+293 −0
@@ -0,0 +1,293 @@ | ||
| 1 | +# CLAUDE.md — Prisme | |
| 2 | + | |
| 3 | +Navigateur iOS intelligent. SwiftUI + WebKit + Foundation Models. | |
| 4 | +Ce fichier est la source de vérité du projet. Le lire avant toute modification. | |
| 5 | + | |
| 6 | +--- | |
| 7 | + | |
| 8 | +## 1. Thèse produit | |
| 9 | + | |
| 10 | +**Un navigateur n'est pas un afficheur de pages. C'est un lecteur qui comprend ce qu'il affiche.** | |
| 11 | + | |
| 12 | +Le web moderne est hostile : bannières, murs de consentement, 2000 mots de remplissage SEO pour une réponse de 40 mots, patterns manipulateurs, pistage. Les navigateurs actuels rendent fidèlement cette hostilité. Prisme s'interpose : chaque page est comprise localement avant d'être affichée, puis re-présentée selon l'intention de l'utilisateur. | |
| 13 | + | |
| 14 | +Trois règles non négociables : | |
| 15 | + | |
| 16 | +1. **Rien ne quitte l'appareil par défaut.** Le contenu des pages est traité on-device. L'escalade vers Private Cloud Compute est explicite, visible, et jamais automatique sur du contenu marqué sensible. | |
| 17 | +2. **Utile pour un seul utilisateur, dès la première session.** Aucune fonctionnalité ne dépend d'un effet de réseau, d'un compte, ou d'un serveur qu'on opère. | |
| 18 | +3. **Le modèle ne remplace jamais la page.** Il l'augmente, la résume, la range. L'utilisateur peut toujours accéder au HTML brut en un geste. Une hallucination ne doit jamais être indiscernable du contenu réel — voir §7. | |
| 19 | + | |
| 20 | +### Ce que Prisme n'est pas | |
| 21 | + | |
| 22 | +- Pas un chatbot avec une webview collée à côté (Dia, Comet, Atlas occupent déjà ce terrain). | |
| 23 | +- Pas un agent qui navigue à ta place pendant que tu regardes. L'agent existe (§6) mais il est en arrière-plan, pas au centre. | |
| 24 | +- Pas un navigateur qui demande de changer ses habitudes avant de donner de la valeur. Arc est mort de ça. | |
| 25 | + | |
| 26 | +--- | |
| 27 | + | |
| 28 | +## 2. Stack & contraintes | |
| 29 | + | |
| 30 | +| Élément | Choix | Note | | |
| 31 | +|---|---|---| | |
| 32 | +| UI | SwiftUI, iOS 27+ | `@Observable`, pas d'`ObservableObject` | | |
| 33 | +| Moteur web | `WKWebView` | pas d'alternative viable sur iOS | | |
| 34 | +| IA locale | `FoundationModels` | `SystemLanguageModel.default` | | |
| 35 | +| IA lourde | `PrivateCloudComputeLanguageModel` | 32K contexte, quota par utilisateur | | |
| 36 | +| Persistance | SwiftData | + fichiers pour les snapshots texte | | |
| 37 | +| Recherche | Index vectoriel local | voir §5 | | |
| 38 | +| Tâches fond | `BGTaskScheduler` | favoris vivants, veilles | | |
| 39 | +| Intégrations | App Intents | export vers Notes/Rappels/Calendrier | | |
| 40 | + | |
| 41 | +### Contraintes matérielles à respecter partout | |
| 42 | + | |
| 43 | +- **Foundation Models exige A17 Pro ou plus récent.** Sur iPhone 14 et antérieur, `SystemLanguageModel.default.availability` renvoie indisponible. **Toute fonctionnalité IA doit dégrader proprement** : Prisme reste un excellent navigateur sans IA. Jamais d'écran d'erreur, jamais de bouton mort. | |
| 44 | +- **Contexte : 4096 tokens (iOS 26) / 8192 (iOS 27, appareils récents).** C'est la contrainte structurante du projet. Une page web fait couramment 30 000 tokens. Voir §4 : on ne passe jamais une page brute au modèle. | |
| 45 | +- **L'inférence est sérialisée sur le Neural Engine.** Des sessions parallèles sont permises par l'API mais s'exécutent en série. Budgéter en conséquence : jamais plus de 2 requêtes en vol, file d'attente avec priorité. | |
| 46 | +- **Batterie.** L'inférence continue tue un téléphone. Toute tâche non déclenchée par l'utilisateur passe par le budget d'énergie (§8). | |
| 47 | + | |
| 48 | +### Vérifier avant de coder | |
| 49 | + | |
| 50 | +`contextSize` et `tokenCount(for:)` existent depuis iOS 26.4. **Toujours** faire un preflight de tokens avant d'appeler le modèle — l'échec de dépassement est abrupt et tue la session. | |
| 51 | + | |
| 52 | +```swift | |
| 53 | +let model = SystemLanguageModel.default | |
| 54 | +let budget = try await model.contextSize | |
| 55 | +let cost = try await model.tokenCount(for: prompt) | |
| 56 | +guard cost < budget - reserveForResponse else { /* condenser */ } | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Réserver systématiquement ~30 % du contexte pour la réponse. Une erreur classique : un prompt à 4092 tokens échoue quand même, parce que le modèle n'a plus la place de répondre. | |
| 60 | + | |
| 61 | +--- | |
| 62 | + | |
| 63 | +## 3. Architecture | |
| 64 | + | |
| 65 | +``` | |
| 66 | +Prisme/ | |
| 67 | +├─ App/ point d'entrée, scènes, raccourcis | |
| 68 | +├─ Browser/ | |
| 69 | +│ ├─ Engine/ WKWebView, pool, delegates, règles de contenu | |
| 70 | +│ ├─ Tabs/ modèle d'onglets, groupes, sessions | |
| 71 | +│ └─ Chrome/ barre d'adresse, gestes, navigation | |
| 72 | +├─ Intelligence/ | |
| 73 | +│ ├─ Router/ choix du modèle (on-device / PCC / aucun) | |
| 74 | +│ ├─ Distiller/ HTML → structure compacte ⚠️ cœur du projet | |
| 75 | +│ ├─ Schemas/ tous les types @Generable | |
| 76 | +│ ├─ Tools/ Tool protocol : accès onglets, historique, page | |
| 77 | +│ └─ Sessions/ gestion du cycle de vie, reprise après dépassement | |
| 78 | +├─ Memory/ | |
| 79 | +│ ├─ Index/ index sémantique local | |
| 80 | +│ ├─ Snapshots/ versions texte des pages visitées | |
| 81 | +│ └─ Recall/ recherche floue, rappels proactifs | |
| 82 | +├─ Library/ favoris, extraits, collections | |
| 83 | +├─ Privacy/ conteneurs d'identité, blocage, détection de patterns | |
| 84 | +└─ Design/ tokens, typographie, thèmes sémantiques | |
| 85 | +``` | |
| 86 | + | |
| 87 | +### Le routeur (`Intelligence/Router`) | |
| 88 | + | |
| 89 | +Chaque tâche déclare son niveau. Le routeur choisit. Aucun appel direct au modèle ailleurs dans le code. | |
| 90 | + | |
| 91 | +```swift | |
| 92 | +enum Tier { | |
| 93 | + case none // heuristique pure, zéro IA | |
| 94 | + case local // on-device, gratuit, illimité, hors ligne | |
| 95 | + case cloud // PCC : 32K, raisonnement — quota utilisateur, consentement | |
| 96 | +} | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Règles de routage : | |
| 100 | + | |
| 101 | +- **Tout ce qui est fréquent va en `local`.** Classification, extraction, résumé de section, titre d'onglet, tag de favori. C'est gratuit et illimité — c'est là qu'est l'avantage concurrentiel : un concurrent sur API cloud ne peut pas se permettre ce volume. | |
| 102 | +- **`cloud` uniquement sur action explicite** : comparaison multi-onglets, question complexe, synthèse d'un long document. Toujours avec une indication visuelle que ça sort de l'appareil. | |
| 103 | +- **`none` dès qu'une heuristique suffit.** Ne pas appeler un LLM pour détecter un mur de cookies : un sélecteur CSS le fait mieux, en 0 ms, avec 0 % d'hallucination. **Un LLM n'est pas une réponse à tout — c'est le dernier recours, pas le premier.** | |
| 104 | +- Contenu marqué sensible (santé, finance, tout ce qui est dans un conteneur privé) : `cloud` interdit, sans exception. | |
| 105 | + | |
| 106 | +--- | |
| 107 | + | |
| 108 | +## 4. Le Distiller — pièce centrale | |
| 109 | + | |
| 110 | +**Problème : une page fait 30 000 tokens, le modèle en accepte 8 000.** Tout le projet tient sur la qualité de cette réduction. | |
| 111 | + | |
| 112 | +Pipeline, dans l'ordre. Chaque étape est déterministe sauf la dernière. | |
| 113 | + | |
| 114 | +1. **Extraction DOM** (JS injecté, `WKUserScript` au `documentEnd`) — retire nav, footer, pub, scripts, commentaires. Algorithme type Readability, en dur, pas d'IA. | |
| 115 | +2. **Structuration** — produit un arbre de blocs typés : titre, section, paragraphe, code, tableau, image, formulaire. Conserve les offsets DOM pour pouvoir remonter à l'élément d'origine (indispensable pour §5 et le zoom sémantique). | |
| 116 | +3. **Budgétisation** — mesure les tokens par bloc, alloue le budget par importance (profondeur de titre, position, densité de liens). | |
| 117 | +4. **Condensation hiérarchique** — si dépassement : résumer les sections les moins prioritaires en `local`, garder intactes les prioritaires. Jamais de troncature brutale au milieu d'une phrase. | |
| 118 | +5. **Sortie structurée** — `@Generable`, jamais de texte libre à parser. | |
| 119 | + | |
| 120 | +```swift | |
| 121 | +@Generable | |
| 122 | +struct PageDigest { | |
| 123 | + @Guide(description: "Type de page") | |
| 124 | + let kind: PageKind // article, doc, forum, boutique, appli, portail, formulaire | |
| 125 | + | |
| 126 | + @Guide(description: "Réponse à la question implicite de la page, max 2 phrases") | |
| 127 | + let gist: String | |
| 128 | + | |
| 129 | + @Guide(description: "Sections dans l'ordre du document", .count(3...12)) | |
| 130 | + let outline: [Section] | |
| 131 | + | |
| 132 | + @Guide(description: "Affirmations chiffrées ou datées, avec leur offset DOM") | |
| 133 | + let claims: [Claim] | |
| 134 | + | |
| 135 | + let hostility: HostilityReport // voir §8 | |
| 136 | +} | |
| 137 | +``` | |
| 138 | + | |
| 139 | +### Règles de fer du Distiller | |
| 140 | + | |
| 141 | +- **Ne jamais envoyer de HTML brut au modèle.** Ça brûle le contexte en balises et dégrade la qualité. | |
| 142 | +- **Toujours conserver l'offset DOM** de chaque élément produit. Sans ça, impossible de lier un résumé à sa source — et donc impossible de vérifier une hallucination. C'est la contrainte la plus facile à oublier et la plus coûteuse à rattraper. | |
| 143 | +- **Cache agressif.** Un digest est indexé par hash du contenu extrait. Une même page ne doit jamais être distillée deux fois. Les mêmes pages reviennent constamment. | |
| 144 | +- **Le digest est un artefact durable**, pas un intermédiaire jetable : il alimente l'historique (§5), les favoris (§5) et le diff temporel. | |
| 145 | + | |
| 146 | +--- | |
| 147 | + | |
| 148 | +## 5. Fonctionnalités par domaine | |
| 149 | + | |
| 150 | +Priorité : `P0` = MVP, `P1` = v1, `P2` = après. | |
| 151 | + | |
| 152 | +### Affichage | |
| 153 | + | |
| 154 | +- **`P0` Zoom sémantique.** Le pincement ne change pas la taille du texte : il change le **niveau de détail**. Écarté au max = page complète. Pincé d'un cran = paragraphes condensés. Deux crans = plan de la page. Trois = une phrase. Le geste le plus familier de l'iPhone, remappé sur la compréhension. *C'est la fonctionnalité signature. Si une seule chose doit être parfaite, c'est celle-là.* Toutes les transitions sont interpolées, pas des sauts d'écran — l'utilisateur doit voir le texte se contracter. | |
| 155 | +- **`P0` Rendu adaptatif par type.** Le `kind` du digest sélectionne un gabarit SwiftUI natif. Un article devient une vraie page de lecture ; une doc technique garde son code et sa nav ; un forum devient un fil hiérarchisé. Pas un « mode lecture » unique appliqué de force. | |
| 156 | +- **`P1` Barre de défilement sémantique.** La scrollbar devient une carte de la page : sections nommées, position des tableaux/code/images. On saute à une idée, pas à un pourcentage. | |
| 157 | +- **`P1` Thème sémantique.** Le mode sombre n'inverse pas les couleurs : le modèle attribue un rôle à chaque bloc (corps, citation, code, avertissement) et applique le thème natif de Prisme. Fin des sites illisibles la nuit. | |
| 158 | +- **`P1` Tiroir du bruit.** Tout ce qui a été retiré est empilé dans un tiroir consultable. Transparence totale : l'utilisateur voit ce que la page voulait lui faire faire. Aussi une soupape de sécurité quand l'extraction rate. | |
| 159 | +- **`P2` Diff temporel.** Snapshot texte à chaque visite. Au retour : ce qui a changé est surligné. Puissant sur les pages de prix, les politiques, les docs. | |
| 160 | +- **`P2` Lecture d'images.** Vision on-device (iOS 27) : un graphique en image, une capture d'écran, un menu en photo deviennent du texte interrogeable. | |
| 161 | + | |
| 162 | +### Onglets | |
| 163 | + | |
| 164 | +- **`P0` Regroupement par intention.** Les onglets se rangent par ce que tu es en train de faire (« tu magasines un vélo », « tu débogues du Swift »), pas par domaine. Le regroupement est **proposé, jamais imposé** — un onglet qui bouge tout seul est une trahison. | |
| 165 | +- **`P0` Reprise narrative.** À la réouverture : pas 47 vignettes, un paragraphe. « Tu comparais trois assurances. Tu avais retenu X. Il te restait à vérifier les franchises. » | |
| 166 | +- **`P1` Onglets périssables.** Le modèle estime la durée de vie utile de chaque onglet. Une recette meurt à la fermeture ; une doc de travail survit. Purge proposée, jamais silencieuse. | |
| 167 | +- **`P2` Préchargement spéculatif.** Les 2-3 liens les plus probables sont préchargés et pré-distillés. Le clic devient instantané. **Plafonné par le budget d'énergie (§8)** et désactivé sur données cellulaires. | |
| 168 | + | |
| 169 | +### Favoris — à repenser complètement | |
| 170 | + | |
| 171 | +Le favori est une relique de 1995 : un pointeur vers une URL, qui pourrit. On le remplace par quatre objets. | |
| 172 | + | |
| 173 | +- **`P0` L'extrait.** On sélectionne un paragraphe, c'est *ça* qui est sauvé — avec sa source, sa date, et son contexte de section. La plupart du temps on ne veut pas la page, on veut le passage. | |
| 174 | +- **`P0` Le favori structuré.** Une recette sauvée devient ingrédients + étapes en données natives (guided generation), pas une page. Une fiche produit devient prix + specs + vendeur. Le contenu est libéré de sa mise en page. | |
| 175 | +- **`P1` Le favori vivant.** Le favori surveille sa page en tâche de fond et notifie au changement : prix, disponibilité, mise à jour d'une doc, modification d'une politique. **La fonctionnalité la plus vendeuse du lot** — un favori qui travaille pour toi. | |
| 176 | +- **`P1` Le favori-question.** On enregistre « combien coûte le renouvellement du passeport » plutôt qu'une URL. Si l'URL meurt, le favori se re-résout tout seul. Immunisé contre le lien mort. | |
| 177 | +- **`P2` Collections émergentes.** Quand un thème apparaît dans les sauvegardes, Prisme propose une collection. Suggestion, jamais action automatique. | |
| 178 | +- **`P2` Purge honnête.** « 340 favoris jamais rouverts depuis 2 ans. » Proposition d'archivage groupé. | |
| 179 | + | |
| 180 | +### Mémoire | |
| 181 | + | |
| 182 | +- **`P0` Historique sémantique.** Chaque page visitée est distillée et indexée localement. Recherche en langage naturel : « le site avec la recette de ramen vu au printemps ». C'est ici que le modèle local gratuit écrase toute solution cloud : le volume serait impayable en API, et l'intimité de l'historique rend l'envoi hors appareil inacceptable. | |
| 183 | +- **`P1` Ligne du temps de sujet.** Toutes les visites autour d'un thème, regroupées chronologiquement. On retrace une recherche étalée sur des semaines. | |
| 184 | +- **`P1` Rappel proactif.** Retour sur une fiche produit : « vu en mars, c'était 899 $ ». Silencieux, une ligne, jamais un popup. | |
| 185 | + | |
| 186 | +### Vie privée | |
| 187 | + | |
| 188 | +- **`P0` Conteneurs d'identité.** Travail / perso / magasinage / recherche sensible. Cookies, sessions et empreinte entièrement cloisonnés via `WKWebsiteDataStore(forIdentifier:)`. Changement d'univers en un geste. Un site ne peut pas relier tes vies. | |
| 189 | +- **`P0` Blocage de contenu.** `WKContentRuleList` compilé, mis à jour, mesurable. Non négociable pour la performance et la vie privée. | |
| 190 | +- **`P1` Détecteur de patterns manipulateurs.** Faux compte à rebours, désabonnement caché, prix barré mensonger, consentement pré-coché : nommés à l'écran. Éducatif et défensif. | |
| 191 | +- **`P1` Traducteur de conditions.** Le mur de cookies et les CGU résumés en trois lignes **avant** d'accepter, avec ce qui est réellement cédé. | |
| 192 | + | |
| 193 | +### Entrée | |
| 194 | + | |
| 195 | +- **`P0` Barre à intention.** Un champ unique qui distingue URL, recherche, question, et commande. Il ne devine pas en silence : il propose l'interprétation, l'utilisateur confirme d'un geste. | |
| 196 | +- **`P1` Export structuré.** Une page → Rappels, Calendrier, Notes, avec les bons champs remplis (App Intents). Une page d'événement devient une entrée d'agenda correcte, pas un lien. | |
| 197 | +- **`P2` Écoute.** Le digest lu à voix haute. Utile en déplacement, et c'est la seule façon d'« emporter » un long article. | |
| 198 | + | |
| 199 | +--- | |
| 200 | + | |
| 201 | +## 6. Agent (`P2` — pas avant que le reste soit excellent) | |
| 202 | + | |
| 203 | +Portée volontairement étroite. Un agent qui échoue une fois sur cinq est pire qu'aucun agent. | |
| 204 | + | |
| 205 | +Autorisé : comparer des pages déjà ouvertes ; surveiller des favoris vivants ; extraire et remplir un formulaire avec des données confirmées par l'utilisateur. | |
| 206 | + | |
| 207 | +**Interdit :** toute action irréversible sans confirmation explicite — achat, envoi, suppression, publication. Aucune exception, aucun mode « expert » qui la contourne. | |
| 208 | + | |
| 209 | +Implémentation par `Tool` : | |
| 210 | + | |
| 211 | +```swift | |
| 212 | +struct OpenTabsTool: Tool { | |
| 213 | + let name = "lire_onglets_ouverts" | |
| 214 | + let description = "Retourne le digest des onglets actuellement ouverts" | |
| 215 | + | |
| 216 | + @Generable struct Arguments { | |
| 217 | + @Guide(description: "Filtre optionnel sur le titre ou le domaine") | |
| 218 | + let filter: String? | |
| 219 | + } | |
| 220 | + | |
| 221 | + func call(arguments: Arguments) async throws -> String { | |
| 222 | + // Retourner les DIGESTS, jamais le HTML. | |
| 223 | + // Plafonner : 3 onglets max, ~400 tokens chacun. | |
| 224 | + } | |
| 225 | +} | |
| 226 | +``` | |
| 227 | + | |
| 228 | +Règle : **un outil retourne toujours du contenu déjà budgété.** Un outil qui renvoie une page entière fait exploser le contexte et tue la session. | |
| 229 | + | |
| 230 | +--- | |
| 231 | + | |
| 232 | +## 7. Honnêteté du modèle | |
| 233 | + | |
| 234 | +Non négociable. Un navigateur qui invente est un navigateur inutilisable. | |
| 235 | + | |
| 236 | +- **Distinction visuelle permanente** entre le contenu de la page et le contenu généré. Un traitement typographique et chromatique dédié, cohérent partout. Jamais de texte généré qui ressemble au texte source. | |
| 237 | +- **Tout résumé est cliquable vers sa source** dans le DOM. C'est à ça que servent les offsets du §4. Un résumé sans ancre ne s'affiche pas. | |
| 238 | +- **Sur incertitude, on dit qu'on ne sait pas.** Pas de comblement. Le modèle 3B ne connaît pas le monde — il traite le texte qu'on lui donne. Toute question dépassant la page doit être routée ou refusée, jamais devinée. | |
| 239 | +- **Un geste, toujours disponible, ramène la page brute.** Si l'utilisateur ne fait pas confiance à ce qu'il voit, il doit pouvoir vérifier en une seconde. | |
| 240 | +- Les garde-fous de Foundation Models peuvent refuser un contenu légitime (faux positifs, améliorés en iOS 26.4 mais présents). Gérer le refus comme un état normal : afficher la page brute, sans message d'erreur alarmant. | |
| 241 | + | |
| 242 | +--- | |
| 243 | + | |
| 244 | +## 8. Performance & énergie | |
| 245 | + | |
| 246 | +Un navigateur se juge d'abord sur la vitesse d'affichage. **L'IA ne doit jamais retarder le rendu.** | |
| 247 | + | |
| 248 | +- La page s'affiche immédiatement. La distillation démarre après `didFinish`, en priorité basse, et l'enrichissement arrive progressivement. | |
| 249 | +- Pool de `WKWebView` réutilisées. Ne jamais en instancier une par onglet. | |
| 250 | +- Budget d'énergie global : compteur d'inférences par heure, seuil bas en mode économie, préchargement spéculatif coupé en premier, tâches de fond suspendues sous 20 % de batterie. | |
| 251 | +- File d'inférence à priorité : geste utilisateur > page active > arrière-plan. Annulation immédiate si l'utilisateur quitte l'onglet. | |
| 252 | +- Cible : **aucune régression perceptible du temps d'affichage** contre Safari. Mesurée à chaque build. | |
| 253 | + | |
| 254 | +--- | |
| 255 | + | |
| 256 | +## 9. Conventions de code | |
| 257 | + | |
| 258 | +- SwiftUI uniquement. `@Observable`. Pas de Combine sauf pour les ponts WebKit. | |
| 259 | +- Swift 6, concurrence stricte. Les états d'onglet sont `@MainActor`. L'inférence et la distillation sont hors du main actor. | |
| 260 | +- `WKWebView` enveloppée dans `UIViewRepresentable`, une seule fois, dans `Browser/Engine`. Aucun accès direct ailleurs. | |
| 261 | +- **Jamais de `String` libre en sortie de modèle.** Toujours `@Generable`. Une réponse à parser au regex est un bug. | |
| 262 | +- Le JS injecté vit dans des fichiers `.js` versionnés, jamais dans des chaînes Swift. | |
| 263 | +- Chaque fonctionnalité IA a un chemin de repli non-IA testé. La suite de tests tourne avec le modèle désactivé. | |
| 264 | +- Nommage utilisateur en français ; code, commentaires et commits en anglais. | |
| 265 | +- **Chaque fichier de code** (Swift, JS, YAML…) commence par un en-tête d'auteur : nom du fichier, projet, puis `Author: Simon-Pierre Boucher <contact@spboucher.ai>`. (Exception : JSON, qui n'accepte pas de commentaires.) | |
| 266 | + | |
| 267 | +--- | |
| 268 | + | |
| 269 | +## 10. Ordre de construction | |
| 270 | + | |
| 271 | +Ne pas dévier. Chaque étape doit être solide avant la suivante. | |
| 272 | + | |
| 273 | +1. **Navigateur nu, excellent.** Onglets, gestes, blocage de contenu, conteneurs d'identité. Zéro IA. S'il n'est pas déjà agréable ici, l'IA ne le sauvera pas. | |
| 274 | +2. **Distiller + cache.** Rien de visible pour l'utilisateur, mais tout le reste en dépend. | |
| 275 | +3. **Zoom sémantique + rendu adaptatif.** La démo. Le moment « je ne peux plus revenir en arrière ». | |
| 276 | +4. **Historique sémantique.** La valeur qui s'accumule et qui enferme l'utilisateur — au bon sens du terme. | |
| 277 | +5. **Favoris repensés**, dont le favori vivant. | |
| 278 | +6. **Vie privée avancée** : patterns manipulateurs, traducteur de conditions. | |
| 279 | +7. **Agent**, seulement là. | |
| 280 | + | |
| 281 | +--- | |
| 282 | + | |
| 283 | +## 11. Risques connus | |
| 284 | + | |
| 285 | +| Risque | Réalité | | |
| 286 | +|---|---| | |
| 287 | +| 99 % des gens ne changent jamais de navigateur | Le zoom sémantique doit être démontrable en 10 secondes, sans configuration | | |
| 288 | +| Le contexte de 4-8K | Contrainte permanente. Le Distiller est la réponse ; il ne sera jamais « terminé » | | |
| 289 | +| Batterie | Un navigateur qui vide la batterie est désinstallé la semaine suivante | | |
| 290 | +| Éditeurs de sites hostiles au retrait de pub | Prisme réorganise à l'affichage, ne republie rien, ne contourne pas les paywalls durs. Ne pas franchir cette ligne | | |
| 291 | +| Appareils sans Foundation Models | Base installée réelle limitée. Le mode sans IA doit être un bon produit, pas une version dégradée | | |
| 292 | +| Revue App Store | Ne pas se présenter comme un moteur alternatif ; c'est une interface au-dessus de WebKit | | |
| 293 | +| Arc est mort | Sa leçon : la nouveauté d'interface sans bénéfice immédiat ne retient personne. Chaque fonctionnalité doit répondre à « qu'est-ce que ça me donne aujourd'hui » | | |
added
Design/icon/prisme-icon.svg
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!-- | |
| 3 | + prisme-icon.svg — Prisme | |
| 4 | + Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 5 | + | |
| 6 | + App icon source. A glass prism refracting a beam of light into a spectrum. | |
| 7 | + Render: rsvg-convert -w 1024 -h 1024 prisme-icon.svg -o AppIcon-1024.png | |
| 8 | +--> | |
| 9 | +<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> | |
| 10 | + <defs> | |
| 11 | + <!-- Deep night background --> | |
| 12 | + <radialGradient id="bg" cx="42%" cy="34%" r="90%"> | |
| 13 | + <stop offset="0%" stop-color="#1B2145"/> | |
| 14 | + <stop offset="45%" stop-color="#111531"/> | |
| 15 | + <stop offset="100%" stop-color="#070A18"/> | |
| 16 | + </radialGradient> | |
| 17 | + | |
| 18 | + <!-- Halo behind the prism --> | |
| 19 | + <radialGradient id="halo" cx="50%" cy="50%" r="50%"> | |
| 20 | + <stop offset="0%" stop-color="#5B6CFF" stop-opacity="0.55"/> | |
| 21 | + <stop offset="55%" stop-color="#3A46B8" stop-opacity="0.22"/> | |
| 22 | + <stop offset="100%" stop-color="#3A46B8" stop-opacity="0"/> | |
| 23 | + </radialGradient> | |
| 24 | + | |
| 25 | + <!-- Incoming white beam --> | |
| 26 | + <linearGradient id="beam" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 27 | + <stop offset="0%" stop-color="#FFFFFF" stop-opacity="0"/> | |
| 28 | + <stop offset="18%" stop-color="#FFFFFF" stop-opacity="0.75"/> | |
| 29 | + <stop offset="100%" stop-color="#FFFFFF" stop-opacity="0.98"/> | |
| 30 | + </linearGradient> | |
| 31 | + | |
| 32 | + <!-- Glass faces --> | |
| 33 | + <linearGradient id="glass" x1="0%" y1="0%" x2="80%" y2="100%"> | |
| 34 | + <stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.52"/> | |
| 35 | + <stop offset="40%" stop-color="#C7D4FF" stop-opacity="0.26"/> | |
| 36 | + <stop offset="100%" stop-color="#8FA2E8" stop-opacity="0.14"/> | |
| 37 | + </linearGradient> | |
| 38 | + <linearGradient id="glassEdge" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 39 | + <stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.95"/> | |
| 40 | + <stop offset="100%" stop-color="#AFC2FF" stop-opacity="0.35"/> | |
| 41 | + </linearGradient> | |
| 42 | + | |
| 43 | + <!-- Spectrum band gradients: bright at the prism, luminous outward --> | |
| 44 | + <linearGradient id="ray-red" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#FF3B5C"/><stop offset="100%" stop-color="#FF3B5C" stop-opacity="0.85"/></linearGradient> | |
| 45 | + <linearGradient id="ray-orange" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#FF9F1C"/><stop offset="100%" stop-color="#FF9F1C" stop-opacity="0.85"/></linearGradient> | |
| 46 | + <linearGradient id="ray-yellow" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#FFE066"/><stop offset="100%" stop-color="#FFE066" stop-opacity="0.85"/></linearGradient> | |
| 47 | + <linearGradient id="ray-green" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#3DDC84"/><stop offset="100%" stop-color="#3DDC84" stop-opacity="0.85"/></linearGradient> | |
| 48 | + <linearGradient id="ray-blue" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#38B6FF"/><stop offset="100%" stop-color="#38B6FF" stop-opacity="0.85"/></linearGradient> | |
| 49 | + <linearGradient id="ray-violet" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/><stop offset="22%" stop-color="#8B5CF6"/><stop offset="100%" stop-color="#8B5CF6" stop-opacity="0.85"/></linearGradient> | |
| 50 | + | |
| 51 | + <filter id="blurL" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="34"/></filter> | |
| 52 | + <filter id="blurM" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="14"/></filter> | |
| 53 | + <filter id="blurS" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="5"/></filter> | |
| 54 | + </defs> | |
| 55 | + | |
| 56 | + <!-- Background --> | |
| 57 | + <rect width="1024" height="1024" fill="url(#bg)"/> | |
| 58 | + | |
| 59 | + <!-- Faint stars --> | |
| 60 | + <g fill="#FFFFFF"> | |
| 61 | + <circle cx="142" cy="612" r="3.4" opacity="0.35"/> | |
| 62 | + <circle cx="222" cy="806" r="2.4" opacity="0.28"/> | |
| 63 | + <circle cx="864" cy="152" r="3.0" opacity="0.32"/> | |
| 64 | + <circle cx="748" cy="96" r="2.2" opacity="0.25"/> | |
| 65 | + <circle cx="98" cy="332" r="2.6" opacity="0.30"/> | |
| 66 | + <circle cx="920" cy="884" r="2.8" opacity="0.26"/> | |
| 67 | + <circle cx="512" cy="120" r="2.4" opacity="0.30"/> | |
| 68 | + <circle cx="330" cy="180" r="2.0" opacity="0.22"/> | |
| 69 | + </g> | |
| 70 | + | |
| 71 | + <!-- Halo behind the prism --> | |
| 72 | + <circle cx="512" cy="500" r="430" fill="url(#halo)"/> | |
| 73 | + | |
| 74 | + <!-- ===== Refracted spectrum (glow layer, then sharp layer) ===== --> | |
| 75 | + <g filter="url(#blurL)" opacity="0.75"> | |
| 76 | + <polygon points="614,470 1211,248 1238,348" fill="#FF3B5C"/> | |
| 77 | + <polygon points="614,470 1238,348 1250,451" fill="#FF9F1C"/> | |
| 78 | + <polygon points="614,470 1250,451 1244,555" fill="#FFE066"/> | |
| 79 | + <polygon points="614,470 1244,555 1223,657" fill="#3DDC84"/> | |
| 80 | + <polygon points="614,470 1223,657 1185,753" fill="#38B6FF"/> | |
| 81 | + <polygon points="614,470 1185,753 1132,843" fill="#8B5CF6"/> | |
| 82 | + </g> | |
| 83 | + <g opacity="0.96"> | |
| 84 | + <polygon points="614,470 1211,248 1238,348" fill="url(#ray-red)"/> | |
| 85 | + <polygon points="614,470 1238,348 1250,451" fill="url(#ray-orange)"/> | |
| 86 | + <polygon points="614,470 1250,451 1244,555" fill="url(#ray-yellow)"/> | |
| 87 | + <polygon points="614,470 1244,555 1223,657" fill="url(#ray-green)"/> | |
| 88 | + <polygon points="614,470 1223,657 1185,753" fill="url(#ray-blue)"/> | |
| 89 | + <polygon points="614,470 1185,753 1132,843" fill="url(#ray-violet)"/> | |
| 90 | + </g> | |
| 91 | + | |
| 92 | + <!-- ===== Incoming white beam (stops on the left face) ===== --> | |
| 93 | + <polygon points="70,128 150,84 402,486 368,514" fill="#FFFFFF" opacity="0.5" filter="url(#blurM)"/> | |
| 94 | + <polygon points="82,132 142,98 398,488 372,510" fill="url(#beam)"/> | |
| 95 | + | |
| 96 | + <!-- ===== Glass prism ===== --> | |
| 97 | + <!-- Refraction inside the glass: entry point to exit point --> | |
| 98 | + <polygon points="384,490 614,458 614,482 390,512" fill="#FFFFFF" opacity="0.6" filter="url(#blurS)"/> | |
| 99 | + <polygon points="386,494 614,462 614,478 390,508" fill="#FFFFFF" opacity="0.55"/> | |
| 100 | + | |
| 101 | + <polygon points="512,268 292,688 732,688" fill="url(#glass)" | |
| 102 | + stroke="url(#glassEdge)" stroke-width="10" stroke-linejoin="round"/> | |
| 103 | + | |
| 104 | + <!-- Edge highlights --> | |
| 105 | + <line x1="512" y1="268" x2="292" y2="688" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="5" stroke-linecap="round"/> | |
| 106 | + <line x1="512" y1="268" x2="732" y2="688" stroke="#D9E2FF" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/> | |
| 107 | + | |
| 108 | + <!-- Apex sparkle --> | |
| 109 | + <circle cx="512" cy="268" r="15" fill="#FFFFFF" opacity="0.9" filter="url(#blurS)"/> | |
| 110 | + <circle cx="512" cy="268" r="6" fill="#FFFFFF"/> | |
| 111 | + | |
| 112 | + <!-- Vignette --> | |
| 113 | + <rect width="1024" height="1024" fill="url(#bg)" opacity="0" /> | |
| 114 | + <radialGradient id="vignette" cx="50%" cy="48%" r="75%"> | |
| 115 | + <stop offset="70%" stop-color="#000000" stop-opacity="0"/> | |
| 116 | + <stop offset="100%" stop-color="#000000" stop-opacity="0.35"/> | |
| 117 | + </radialGradient> | |
| 118 | + <rect width="1024" height="1024" fill="url(#vignette)"/> | |
| 119 | +</svg> | |
added
LICENSE
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Copyright (c) 2026 Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. | |
added
Prisme/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
+0 −0
Binary file not shown.
added
Prisme/App/Assets.xcassets/AppIcon.appiconset/Contents.json
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "images": [ | |
| 3 | + { | |
| 4 | + "filename": "AppIcon-1024.png", | |
| 5 | + "idiom": "universal", | |
| 6 | + "platform": "ios", | |
| 7 | + "size": "1024x1024" | |
| 8 | + } | |
| 9 | + ], | |
| 10 | + "info": { | |
| 11 | + "author": "xcode", | |
| 12 | + "version": 1 | |
| 13 | + } | |
| 14 | +} | |
added
Prisme/App/Assets.xcassets/Contents.json
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "info": { | |
| 3 | + "author": "xcode", | |
| 4 | + "version": 1 | |
| 5 | + } | |
| 6 | +} | |
added
Prisme/App/BrowserModel.swift
+212 −0
@@ -0,0 +1,212 @@ | ||
| 1 | +// | |
| 2 | +// BrowserModel.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | + | |
| 11 | +/// Root model wiring together tabs, identity containers, the web view pool | |
| 12 | +/// and content blocking. Owned by the app entry point, passed down to views. | |
| 13 | +@MainActor | |
| 14 | +@Observable | |
| 15 | +final class BrowserModel { | |
| 16 | + let containers: IdentityContainerStore | |
| 17 | + let contentRules: ContentRuleManager | |
| 18 | + let pool: WebViewPool | |
| 19 | + let tabs: TabStore | |
| 20 | + let intelligence = IntelligenceCenter() | |
| 21 | + let history = HistoryStore() | |
| 22 | + let library = LibraryStore() | |
| 23 | + | |
| 24 | + /// Transient confirmation shown after a save ("Extrait sauvé"). | |
| 25 | + private(set) var notice: String? | |
| 26 | + @ObservationIgnored private var noticeTask: Task<Void, Never>? | |
| 27 | + | |
| 28 | + /// Container used for newly opened tabs. | |
| 29 | + var activeContainerID: IdentityContainer.ID | |
| 30 | + | |
| 31 | + var activeContainer: IdentityContainer { | |
| 32 | + containers.container(for: activeContainerID) ?? containers.all[0] | |
| 33 | + } | |
| 34 | + | |
| 35 | + init() { | |
| 36 | + let containers = IdentityContainerStore() | |
| 37 | + let contentRules = ContentRuleManager() | |
| 38 | + self.containers = containers | |
| 39 | + self.contentRules = contentRules | |
| 40 | + self.pool = WebViewPool(containers: containers, rules: contentRules) | |
| 41 | + self.tabs = TabStore() | |
| 42 | + self.activeContainerID = containers.all[0].id | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// One-time async startup work. Content rules compile off the critical | |
| 46 | + /// path: pages render immediately, blocking kicks in as soon as ready. | |
| 47 | + func start() async { | |
| 48 | + await contentRules.compileIfNeeded() | |
| 49 | + if let list = contentRules.ruleList { | |
| 50 | + pool.apply(list) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Resolve a confirmed address-bar intent: load in the active tab if it | |
| 55 | + /// belongs to the active container, otherwise open a new tab there. | |
| 56 | + func submit(_ intent: EntryIntent) { | |
| 57 | + if let tab = tabs.activeTab, tab.containerID == activeContainerID { | |
| 58 | + tab.page.load(intent.destination) | |
| 59 | + } else { | |
| 60 | + openTab(intent.destination, in: activeContainerID) | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + func newTab() { | |
| 65 | + openTab(nil, in: activeContainerID) | |
| 66 | + } | |
| 67 | + | |
| 68 | + /// Reopen a remembered page — in the container it was visited in, so | |
| 69 | + /// identities stay partitioned. | |
| 70 | + func open(_ visit: PageVisit) { | |
| 71 | + guard let url = URL(string: visit.urlString) else { return } | |
| 72 | + let containerID = containers.container(for: visit.containerID)?.id ?? activeContainerID | |
| 73 | + activeContainerID = containerID | |
| 74 | + openTab(url, in: containerID) | |
| 75 | + } | |
| 76 | + | |
| 77 | + @discardableResult | |
| 78 | + private func openTab(_ url: URL?, in containerID: IdentityContainer.ID) -> Tab { | |
| 79 | + let tab = tabs.open(url, in: containerID) | |
| 80 | + wireHistory(for: tab) | |
| 81 | + return tab | |
| 82 | + } | |
| 83 | + | |
| 84 | + // MARK: - Library (excerpts & structured favourites) | |
| 85 | + | |
| 86 | + private func saveExcerpt(from tab: Tab) { | |
| 87 | + let page = tab.page | |
| 88 | + let containerID = tab.containerID | |
| 89 | + Task { [weak self] in | |
| 90 | + guard let self, | |
| 91 | + let selection = try? await page.captureSelection() else { return } | |
| 92 | + self.library.saveExcerpt( | |
| 93 | + selection, | |
| 94 | + url: page.url, | |
| 95 | + pageTitle: page.title, | |
| 96 | + containerID: containerID | |
| 97 | + ) | |
| 98 | + self.show(notice: "Extrait sauvé dans la bibliothèque") | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Saves the current page as native data: the digest's structure when | |
| 103 | + /// the model produced one, a deterministic distillation otherwise. | |
| 104 | + func saveFavorite(for tab: Tab) { | |
| 105 | + guard let url = tab.page.url else { return } | |
| 106 | + let page = tab.page | |
| 107 | + let containerID = tab.containerID | |
| 108 | + | |
| 109 | + var kindRaw: String? | |
| 110 | + var gist = "" | |
| 111 | + var outline: [String] = [] | |
| 112 | + var isGenerated = false | |
| 113 | + if case .ready(let digest, _) = intelligence.digestState(for: tab) { | |
| 114 | + kindRaw = digest.kind.rawValue | |
| 115 | + gist = digest.gist | |
| 116 | + outline = digest.outline.map(\.title) | |
| 117 | + isGenerated = true | |
| 118 | + } | |
| 119 | + | |
| 120 | + Task { [weak self] in | |
| 121 | + guard let self else { return } | |
| 122 | + let content = try? await page.extractContent() | |
| 123 | + var title = content?.title ?? "" | |
| 124 | + if title.isEmpty { title = page.title } | |
| 125 | + if title.isEmpty { title = url.host() ?? url.absoluteString } | |
| 126 | + if gist.isEmpty { gist = content?.leadSentence ?? "" } | |
| 127 | + if outline.isEmpty { | |
| 128 | + outline = content?.blocks.filter { $0.kind == .heading }.map(\.text) ?? [] | |
| 129 | + } | |
| 130 | + self.library.saveFavorite( | |
| 131 | + url: url, | |
| 132 | + title: title, | |
| 133 | + gist: gist, | |
| 134 | + kindRaw: kindRaw, | |
| 135 | + outlineTitles: outline, | |
| 136 | + isGenerated: isGenerated, | |
| 137 | + containerID: containerID | |
| 138 | + ) | |
| 139 | + self.show(notice: "Page sauvée en données natives") | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + private func show(notice text: String) { | |
| 144 | + notice = text | |
| 145 | + noticeTask?.cancel() | |
| 146 | + noticeTask = Task { [weak self] in | |
| 147 | + try? await Task.sleep(for: .seconds(2.2)) | |
| 148 | + guard !Task.isCancelled else { return } | |
| 149 | + self?.notice = nil | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + // MARK: - Semantic history recording | |
| 154 | + | |
| 155 | + /// Every finished load in a non-sensitive container is distilled and | |
| 156 | + /// indexed locally. Sensitive containers are never recorded at all. | |
| 157 | + private func wireHistory(for tab: Tab) { | |
| 158 | + tab.page.onDidFinishLoad = { [weak self, weak tab] url in | |
| 159 | + guard let self, let tab else { return } | |
| 160 | + self.recordVisit(of: tab, url: url) | |
| 161 | + } | |
| 162 | + tab.page.onExcerptRequested = { [weak self, weak tab] in | |
| 163 | + guard let self, let tab else { return } | |
| 164 | + self.saveExcerpt(from: tab) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + private func recordVisit(of tab: Tab, url: URL) { | |
| 169 | + let page = tab.page | |
| 170 | + let containerID = tab.containerID | |
| 171 | + let sensitive = containers.container(for: containerID)?.isSensitive ?? true | |
| 172 | + Task { [weak self, weak tab] in | |
| 173 | + // Let the page settle; skip if the user already moved on. | |
| 174 | + try? await Task.sleep(for: .milliseconds(700)) | |
| 175 | + guard let self, let tab, page.url == url, !page.isLoading else { return } | |
| 176 | + guard let content = try? await page.extractContent() else { return } | |
| 177 | + | |
| 178 | + // Understanding strip: deterministic, instant, purely local. | |
| 179 | + let approxWords = content.blocks.reduce(0) { $0 + $1.text.count } / 6 | |
| 180 | + tab.insight = PageInsight( | |
| 181 | + url: url, | |
| 182 | + approxWords: approxWords, | |
| 183 | + sectionCount: content.blocks.count(where: { $0.kind == .heading }) | |
| 184 | + ) | |
| 185 | + tab.insightDismissed = false | |
| 186 | + | |
| 187 | + // Background enrichment, local tier only, page priority (§8). | |
| 188 | + self.intelligence.requestDigest(for: tab, priority: .activePage) | |
| 189 | + | |
| 190 | + // Sensitive containers are never written to history. | |
| 191 | + if !sensitive { | |
| 192 | + self.history.record(url: url, content: content, containerID: containerID) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + func activate(_ tab: Tab) { | |
| 198 | + tabs.activate(tab) | |
| 199 | + activeContainerID = tab.containerID | |
| 200 | + } | |
| 201 | + | |
| 202 | + /// Switching universe: activate the most recent tab of that container, | |
| 203 | + /// or show the start page if it has none. | |
| 204 | + func selectContainer(_ id: IdentityContainer.ID) { | |
| 205 | + activeContainerID = id | |
| 206 | + if let tab = tabs.mostRecentTab(in: id) { | |
| 207 | + tabs.activate(tab) | |
| 208 | + } else { | |
| 209 | + tabs.activeTabID = nil | |
| 210 | + } | |
| 211 | + } | |
| 212 | +} | |
added
Prisme/App/PrismeApp.swift
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +// | |
| 2 | +// PrismeApp.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +@main | |
| 11 | +struct PrismeApp: App { | |
| 12 | + @State private var model = BrowserModel() | |
| 13 | + | |
| 14 | + var body: some Scene { | |
| 15 | + WindowGroup { | |
| 16 | + BrowserView(model: model) | |
| 17 | + .task { await model.start() } | |
| 18 | + } | |
| 19 | + } | |
| 20 | +} | |
added
Prisme/Browser/Chrome/AddressBar.swift
+314 −0
@@ -0,0 +1,314 @@ | ||
| 1 | +// | |
| 2 | +// AddressBar.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// The single entry field ("barre à intention", P0). Two states: | |
| 11 | +/// - compact: security indicator + host, reload/stop — one tap to edit; | |
| 12 | +/// - editing: text field with interpretation proposals above. The bar never | |
| 13 | +/// guesses in silence: the user confirms a proposal by tapping it or by | |
| 14 | +/// submitting, which picks the first (most likely) one. | |
| 15 | +/// Searches can go through DuckDuckGo or Google — both are proposed on every | |
| 16 | +/// query, and the default engine is switchable right from the proposal card. | |
| 17 | +/// Page load progress is drawn inside the capsule itself. | |
| 18 | +struct AddressBar: View { | |
| 19 | + let page: WebPageProxy? | |
| 20 | + let accent: Color | |
| 21 | + var history: HistoryStore? | |
| 22 | + let onIntent: (EntryIntent) -> Void | |
| 23 | + var onRecall: ((PageVisit) -> Void)? | |
| 24 | + var onOpenReader: (() -> Void)? | |
| 25 | + | |
| 26 | + @State private var text = "" | |
| 27 | + @State private var isEditing = false | |
| 28 | + @FocusState private var fieldFocused: Bool | |
| 29 | + @AppStorage("prisme.defaultSearchEngine") | |
| 30 | + private var defaultEngineRaw = SearchEngine.duckDuckGo.rawValue | |
| 31 | + | |
| 32 | + private var defaultEngine: SearchEngine { | |
| 33 | + SearchEngine(rawValue: defaultEngineRaw) ?? .duckDuckGo | |
| 34 | + } | |
| 35 | + | |
| 36 | + private var proposals: [EntryIntent] { | |
| 37 | + isEditing ? IntentResolver.propose(for: text, preferring: defaultEngine) : [] | |
| 38 | + } | |
| 39 | + | |
| 40 | + var body: some View { | |
| 41 | + VStack(spacing: Spacing.s) { | |
| 42 | + if isEditing { | |
| 43 | + proposalCard | |
| 44 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 45 | + } | |
| 46 | + bar | |
| 47 | + } | |
| 48 | + .animation(.spring(duration: 0.28), value: isEditing) | |
| 49 | + .animation(.spring(duration: 0.28), value: proposals) | |
| 50 | + } | |
| 51 | + | |
| 52 | + // MARK: - Bar | |
| 53 | + | |
| 54 | + private var bar: some View { | |
| 55 | + ZStack { | |
| 56 | + if isEditing { | |
| 57 | + editingField | |
| 58 | + } else { | |
| 59 | + compactDisplay | |
| 60 | + } | |
| 61 | + } | |
| 62 | + .padding(.horizontal, Spacing.l) | |
| 63 | + .frame(height: 52) | |
| 64 | + .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: Radius.xl)) | |
| 65 | + .overlay(alignment: .bottom) { progressLine } | |
| 66 | + } | |
| 67 | + | |
| 68 | + private var compactDisplay: some View { | |
| 69 | + Button { | |
| 70 | + text = page?.url?.absoluteString ?? "" | |
| 71 | + isEditing = true | |
| 72 | + } label: { | |
| 73 | + HStack(spacing: Spacing.s) { | |
| 74 | + if let url = page?.url { | |
| 75 | + Image(systemName: url.scheme == "https" ? "lock.fill" : "globe") | |
| 76 | + .font(.footnote) | |
| 77 | + .foregroundStyle(url.scheme == "https" ? accent : .secondary) | |
| 78 | + Text(url.host() ?? url.absoluteString) | |
| 79 | + .lineLimit(1) | |
| 80 | + .foregroundStyle(.primary) | |
| 81 | + } else { | |
| 82 | + Image(systemName: "magnifyingglass") | |
| 83 | + .font(.footnote) | |
| 84 | + .foregroundStyle(.secondary) | |
| 85 | + Text("Rechercher ou saisir une adresse") | |
| 86 | + .lineLimit(1) | |
| 87 | + .foregroundStyle(.secondary) | |
| 88 | + } | |
| 89 | + Spacer() | |
| 90 | + } | |
| 91 | + .padding(.leading, page?.url != nil && onOpenReader != nil ? 36 : 0) | |
| 92 | + .contentShape(Rectangle()) | |
| 93 | + } | |
| 94 | + .buttonStyle(.plain) | |
| 95 | + .accessibilityIdentifier("addressBar.compact") | |
| 96 | + .overlay(alignment: .leading) { | |
| 97 | + // Reader entry, always visible once a page is loaded — the | |
| 98 | + // same affordance Safari trained everyone on. | |
| 99 | + if page?.url != nil, let onOpenReader { | |
| 100 | + Button(action: onOpenReader) { | |
| 101 | + Image(systemName: "doc.plaintext") | |
| 102 | + .font(.footnote.weight(.semibold)) | |
| 103 | + .foregroundStyle(accent) | |
| 104 | + .frame(width: 32, height: 32) | |
| 105 | + .contentShape(Rectangle()) | |
| 106 | + } | |
| 107 | + .buttonStyle(.plain) | |
| 108 | + .accessibilityIdentifier("addressBar.reader") | |
| 109 | + } | |
| 110 | + } | |
| 111 | + .overlay(alignment: .trailing) { | |
| 112 | + if page?.url != nil { | |
| 113 | + Button { | |
| 114 | + if page?.isLoading == true { | |
| 115 | + page?.stopLoading() | |
| 116 | + } else { | |
| 117 | + page?.reload() | |
| 118 | + } | |
| 119 | + } label: { | |
| 120 | + Image(systemName: page?.isLoading == true ? "xmark" : "arrow.clockwise") | |
| 121 | + .font(.footnote.weight(.semibold)) | |
| 122 | + .foregroundStyle(.secondary) | |
| 123 | + .frame(width: 32, height: 32) | |
| 124 | + .contentShape(Rectangle()) | |
| 125 | + } | |
| 126 | + .buttonStyle(.plain) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + private var editingField: some View { | |
| 132 | + HStack(spacing: Spacing.s) { | |
| 133 | + Image(systemName: "magnifyingglass") | |
| 134 | + .font(.footnote) | |
| 135 | + .foregroundStyle(accent) | |
| 136 | + | |
| 137 | + TextField("Rechercher ou saisir une adresse", text: $text) | |
| 138 | + .focused($fieldFocused) | |
| 139 | + .textInputAutocapitalization(.never) | |
| 140 | + .autocorrectionDisabled() | |
| 141 | + .keyboardType(.webSearch) | |
| 142 | + .submitLabel(.go) | |
| 143 | + .onSubmit(confirmFirstProposal) | |
| 144 | + .accessibilityIdentifier("addressBar.field") | |
| 145 | + .task { fieldFocused = true } | |
| 146 | + .onChange(of: fieldFocused) { _, focused in | |
| 147 | + if !focused { isEditing = false } | |
| 148 | + } | |
| 149 | + | |
| 150 | + if !text.isEmpty { | |
| 151 | + Button { | |
| 152 | + text = "" | |
| 153 | + } label: { | |
| 154 | + Image(systemName: "xmark.circle.fill") | |
| 155 | + .foregroundStyle(.secondary) | |
| 156 | + } | |
| 157 | + .buttonStyle(.plain) | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + private var progressLine: some View { | |
| 163 | + GeometryReader { geometry in | |
| 164 | + if let page, page.isLoading { | |
| 165 | + Capsule() | |
| 166 | + .fill(accent) | |
| 167 | + .frame(width: max(12, geometry.size.width * page.estimatedProgress), height: 3) | |
| 168 | + .animation(.easeOut(duration: 0.25), value: page.estimatedProgress) | |
| 169 | + } | |
| 170 | + } | |
| 171 | + .frame(height: 3) | |
| 172 | + .padding(.horizontal, Spacing.m) | |
| 173 | + .padding(.bottom, 3) | |
| 174 | + } | |
| 175 | + | |
| 176 | + // MARK: - Proposals | |
| 177 | + | |
| 178 | + /// Pages remembered by the semantic history that match the input — | |
| 179 | + /// recall is part of the intent bar, not a separate mode. | |
| 180 | + private var recallMatches: [PageVisit] { | |
| 181 | + guard isEditing, text.trimmingCharacters(in: .whitespaces).count >= 2 else { return [] } | |
| 182 | + return Array((history?.search(text, limit: 3) ?? []).prefix(2)) | |
| 183 | + } | |
| 184 | + | |
| 185 | + @ViewBuilder | |
| 186 | + private var proposalCard: some View { | |
| 187 | + VStack(spacing: 0) { | |
| 188 | + ForEach(recallMatches, id: \.urlString) { visit in | |
| 189 | + recallRow(visit) | |
| 190 | + Divider().padding(.leading, 58) | |
| 191 | + } | |
| 192 | + ForEach(Array(proposals.enumerated()), id: \.element) { index, intent in | |
| 193 | + proposalRow(intent, isPrimary: index == 0) | |
| 194 | + Divider().padding(.leading, 58) | |
| 195 | + } | |
| 196 | + enginePicker | |
| 197 | + } | |
| 198 | + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Radius.l)) | |
| 199 | + } | |
| 200 | + | |
| 201 | + private func recallRow(_ visit: PageVisit) -> some View { | |
| 202 | + Button { | |
| 203 | + fieldFocused = false | |
| 204 | + isEditing = false | |
| 205 | + onRecall?(visit) | |
| 206 | + } label: { | |
| 207 | + HStack(spacing: Spacing.m) { | |
| 208 | + Image(systemName: "clock.arrow.circlepath") | |
| 209 | + .font(.subheadline.weight(.semibold)) | |
| 210 | + .foregroundStyle(.secondary) | |
| 211 | + .frame(width: 34, height: 34) | |
| 212 | + .background(.quaternary.opacity(0.5), in: Circle()) | |
| 213 | + | |
| 214 | + VStack(alignment: .leading, spacing: 1) { | |
| 215 | + Text(visit.title) | |
| 216 | + .lineLimit(1) | |
| 217 | + .foregroundStyle(.primary) | |
| 218 | + Text("Déjà visité · \(visit.host)") | |
| 219 | + .font(.caption) | |
| 220 | + .foregroundStyle(.secondary) | |
| 221 | + } | |
| 222 | + Spacer() | |
| 223 | + } | |
| 224 | + .padding(.horizontal, Spacing.m) | |
| 225 | + .padding(.vertical, Spacing.s + 2) | |
| 226 | + .contentShape(Rectangle()) | |
| 227 | + } | |
| 228 | + .buttonStyle(.plain) | |
| 229 | + .accessibilityIdentifier("proposal.recall") | |
| 230 | + } | |
| 231 | + | |
| 232 | + private func proposalRow(_ intent: EntryIntent, isPrimary: Bool) -> some View { | |
| 233 | + Button { | |
| 234 | + confirm(intent) | |
| 235 | + } label: { | |
| 236 | + HStack(spacing: Spacing.m) { | |
| 237 | + Image(systemName: intent.symbol) | |
| 238 | + .font(.subheadline.weight(.semibold)) | |
| 239 | + .foregroundStyle(tint(for: intent)) | |
| 240 | + .frame(width: 34, height: 34) | |
| 241 | + .background(tint(for: intent).opacity(0.14), in: Circle()) | |
| 242 | + | |
| 243 | + VStack(alignment: .leading, spacing: 1) { | |
| 244 | + Text(intent.label) | |
| 245 | + .lineLimit(1) | |
| 246 | + .foregroundStyle(.primary) | |
| 247 | + .fontWeight(isPrimary ? .medium : .regular) | |
| 248 | + Text(intent.detail) | |
| 249 | + .font(.caption) | |
| 250 | + .foregroundStyle(.secondary) | |
| 251 | + } | |
| 252 | + | |
| 253 | + Spacer() | |
| 254 | + | |
| 255 | + if isPrimary { | |
| 256 | + Image(systemName: "return") | |
| 257 | + .font(.caption) | |
| 258 | + .foregroundStyle(.tertiary) | |
| 259 | + } | |
| 260 | + } | |
| 261 | + .padding(.horizontal, Spacing.m) | |
| 262 | + .padding(.vertical, Spacing.s + 2) | |
| 263 | + .contentShape(Rectangle()) | |
| 264 | + } | |
| 265 | + .buttonStyle(.plain) | |
| 266 | + .accessibilityIdentifier(accessibilityID(for: intent)) | |
| 267 | + } | |
| 268 | + | |
| 269 | + private func accessibilityID(for intent: EntryIntent) -> String { | |
| 270 | + switch intent { | |
| 271 | + case .navigate: "proposal.navigate" | |
| 272 | + case .search(_, let engine): "proposal.search.\(engine.rawValue)" | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + /// Explicit default-engine control — visible, never a hidden setting. | |
| 277 | + private var enginePicker: some View { | |
| 278 | + HStack { | |
| 279 | + Text("Moteur par défaut") | |
| 280 | + .font(.caption) | |
| 281 | + .foregroundStyle(.secondary) | |
| 282 | + Spacer() | |
| 283 | + Picker("Moteur par défaut", selection: $defaultEngineRaw) { | |
| 284 | + ForEach(SearchEngine.allCases) { engine in | |
| 285 | + Text(engine.name).tag(engine.rawValue) | |
| 286 | + } | |
| 287 | + } | |
| 288 | + .pickerStyle(.segmented) | |
| 289 | + .fixedSize() | |
| 290 | + } | |
| 291 | + .padding(.horizontal, Spacing.m) | |
| 292 | + .padding(.vertical, Spacing.s) | |
| 293 | + } | |
| 294 | + | |
| 295 | + private func tint(for intent: EntryIntent) -> Color { | |
| 296 | + switch intent { | |
| 297 | + case .navigate: | |
| 298 | + accent | |
| 299 | + case .search(_, let engine): | |
| 300 | + engine == .duckDuckGo ? Color.orange : Color(red: 0.26, green: 0.52, blue: 0.96) | |
| 301 | + } | |
| 302 | + } | |
| 303 | + | |
| 304 | + private func confirmFirstProposal() { | |
| 305 | + guard let first = proposals.first else { return } | |
| 306 | + confirm(first) | |
| 307 | + } | |
| 308 | + | |
| 309 | + private func confirm(_ intent: EntryIntent) { | |
| 310 | + fieldFocused = false | |
| 311 | + isEditing = false | |
| 312 | + onIntent(intent) | |
| 313 | + } | |
| 314 | +} | |
added
Prisme/Browser/Chrome/BrowserView.swift
+288 −0
@@ -0,0 +1,288 @@ | ||
| 1 | +// | |
| 2 | +// BrowserView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// Main screen: the web content fills the screen, a floating glass chrome | |
| 11 | +/// sits at the bottom. The page always renders immediately — nothing in the | |
| 12 | +/// chrome may ever block it (CLAUDE.md §8). | |
| 13 | +struct BrowserView: View { | |
| 14 | + let model: BrowserModel | |
| 15 | + | |
| 16 | + @State private var showTabSwitcher = false | |
| 17 | + @State private var showReader = false | |
| 18 | + @State private var showLibrary = false | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + ZStack(alignment: .bottom) { | |
| 22 | + content | |
| 23 | + .ignoresSafeArea(edges: .bottom) | |
| 24 | + chrome | |
| 25 | + } | |
| 26 | + .sheet(isPresented: $showTabSwitcher) { | |
| 27 | + TabSwitcherView(model: model) | |
| 28 | + } | |
| 29 | + .fullScreenCover(isPresented: $showReader) { | |
| 30 | + if let tab = model.tabs.activeTab { | |
| 31 | + ReaderView( | |
| 32 | + tab: tab, | |
| 33 | + intelligence: model.intelligence, | |
| 34 | + accent: model.activeContainer.color.color, | |
| 35 | + library: model.library, | |
| 36 | + onSaveFavorite: { model.saveFavorite(for: tab) } | |
| 37 | + ) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + .sheet(isPresented: $showLibrary) { | |
| 41 | + LibraryView( | |
| 42 | + library: model.library, | |
| 43 | + containers: model.containers, | |
| 44 | + onOpen: { url, containerID in | |
| 45 | + model.selectContainer(containerID) | |
| 46 | + model.submit(.navigate(url)) | |
| 47 | + } | |
| 48 | + ) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + @ViewBuilder | |
| 53 | + private var content: some View { | |
| 54 | + if let tab = model.tabs.activeTab, !tab.isBlank { | |
| 55 | + BrowserWebView(tab: tab, pool: model.pool) | |
| 56 | + .id(tab.id) | |
| 57 | + .transition(.opacity) | |
| 58 | + } else { | |
| 59 | + StartPageView( | |
| 60 | + active: model.activeContainer, | |
| 61 | + containers: model.containers.all, | |
| 62 | + onSelect: { model.selectContainer($0) } | |
| 63 | + ) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + // MARK: - Chrome | |
| 68 | + | |
| 69 | + private var chrome: some View { | |
| 70 | + VStack(spacing: Spacing.m) { | |
| 71 | + if let tab = model.tabs.activeTab, | |
| 72 | + let insight = tab.insight, | |
| 73 | + !tab.insightDismissed, | |
| 74 | + insight.url == tab.page.url { | |
| 75 | + InsightStrip( | |
| 76 | + insight: insight, | |
| 77 | + digest: readyDigest(for: tab), | |
| 78 | + accent: model.activeContainer.color.color, | |
| 79 | + onRead: { showReader = true }, | |
| 80 | + onDismiss: { | |
| 81 | + withAnimation(.spring(duration: 0.3)) { | |
| 82 | + tab.insightDismissed = true | |
| 83 | + } | |
| 84 | + } | |
| 85 | + ) | |
| 86 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 87 | + } | |
| 88 | + | |
| 89 | + if let notice = model.notice { | |
| 90 | + Text(notice) | |
| 91 | + .font(.footnote.weight(.medium)) | |
| 92 | + .padding(.horizontal, Spacing.l) | |
| 93 | + .padding(.vertical, Spacing.s) | |
| 94 | + .glassEffect(.regular, in: Capsule()) | |
| 95 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 96 | + } | |
| 97 | + | |
| 98 | + AddressBar( | |
| 99 | + page: model.tabs.activeTab?.page, | |
| 100 | + accent: model.activeContainer.color.color, | |
| 101 | + history: model.history, | |
| 102 | + onIntent: { model.submit($0) }, | |
| 103 | + onRecall: { model.open($0) }, | |
| 104 | + onOpenReader: { showReader = true } | |
| 105 | + ) | |
| 106 | + .id(model.tabs.activeTabID) | |
| 107 | + | |
| 108 | + toolbar | |
| 109 | + } | |
| 110 | + .padding(.horizontal, Spacing.l) | |
| 111 | + .padding(.bottom, Spacing.s) | |
| 112 | + .animation(.spring(duration: 0.3), value: model.notice) | |
| 113 | + .animation(.spring(duration: 0.35), value: model.tabs.activeTab?.insight) | |
| 114 | + } | |
| 115 | + | |
| 116 | + private func readyDigest(for tab: Tab) -> PageDigest? { | |
| 117 | + if case .ready(let digest, _) = model.intelligence.digestState(for: tab) { | |
| 118 | + return digest | |
| 119 | + } | |
| 120 | + return nil | |
| 121 | + } | |
| 122 | + | |
| 123 | + private var toolbar: some View { | |
| 124 | + HStack(spacing: 0) { | |
| 125 | + toolbarButton("chevron.left", disabled: model.tabs.activeTab?.page.canGoBack != true) { | |
| 126 | + model.tabs.activeTab?.page.goBack() | |
| 127 | + } | |
| 128 | + toolbarButton("chevron.right", disabled: model.tabs.activeTab?.page.canGoForward != true) { | |
| 129 | + model.tabs.activeTab?.page.goForward() | |
| 130 | + } | |
| 131 | + | |
| 132 | + Spacer(minLength: 0) | |
| 133 | + containerMenu | |
| 134 | + Spacer(minLength: 0) | |
| 135 | + | |
| 136 | + toolbarButton("books.vertical") { | |
| 137 | + showLibrary = true | |
| 138 | + } | |
| 139 | + | |
| 140 | + toolbarButton("plus") { | |
| 141 | + withAnimation(.spring(duration: 0.3)) { model.newTab() } | |
| 142 | + } | |
| 143 | + tabCountButton | |
| 144 | + } | |
| 145 | + .padding(.horizontal, Spacing.s) | |
| 146 | + .frame(height: 54) | |
| 147 | + .glassEffect(.regular, in: Capsule()) | |
| 148 | + } | |
| 149 | + | |
| 150 | + private func toolbarButton( | |
| 151 | + _ symbol: String, | |
| 152 | + disabled: Bool = false, | |
| 153 | + action: @escaping () -> Void | |
| 154 | + ) -> some View { | |
| 155 | + Button(action: action) { | |
| 156 | + Image(systemName: symbol) | |
| 157 | + .font(.title3.weight(.medium)) | |
| 158 | + .frame(width: 52, height: 44) | |
| 159 | + .contentShape(Rectangle()) | |
| 160 | + } | |
| 161 | + .buttonStyle(.plain) | |
| 162 | + .foregroundStyle(disabled ? Color.secondary.opacity(0.4) : Color.primary) | |
| 163 | + .disabled(disabled) | |
| 164 | + } | |
| 165 | + | |
| 166 | + private var tabCountButton: some View { | |
| 167 | + Button { | |
| 168 | + showTabSwitcher = true | |
| 169 | + } label: { | |
| 170 | + let count = model.tabs.tabs(in: model.activeContainerID).count | |
| 171 | + ZStack { | |
| 172 | + RoundedRectangle(cornerRadius: 7) | |
| 173 | + .strokeBorder(Color.primary, lineWidth: 2) | |
| 174 | + .frame(width: 24, height: 24) | |
| 175 | + Text(count > 0 ? "\(count)" : "") | |
| 176 | + .font(.caption.bold()) | |
| 177 | + .monospacedDigit() | |
| 178 | + } | |
| 179 | + .frame(width: 52, height: 44) | |
| 180 | + .contentShape(Rectangle()) | |
| 181 | + } | |
| 182 | + .buttonStyle(.plain) | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// One gesture to change universe (identity container, P0). | |
| 186 | + private var containerMenu: some View { | |
| 187 | + Menu { | |
| 188 | + ForEach(model.containers.all) { container in | |
| 189 | + Button { | |
| 190 | + withAnimation(.spring(duration: 0.3)) { | |
| 191 | + model.selectContainer(container.id) | |
| 192 | + } | |
| 193 | + } label: { | |
| 194 | + Label(container.name, systemImage: container.symbol) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } label: { | |
| 198 | + Image(systemName: model.activeContainer.symbol) | |
| 199 | + .font(.subheadline.weight(.semibold)) | |
| 200 | + .foregroundStyle(.white) | |
| 201 | + .frame(width: 40, height: 40) | |
| 202 | + .background(model.activeContainer.color.gradient, in: Circle()) | |
| 203 | + } | |
| 204 | + .buttonStyle(.plain) | |
| 205 | + .accessibilityLabel(model.activeContainer.name) | |
| 206 | + } | |
| 207 | +} | |
| 208 | + | |
| 209 | +// MARK: - Start page | |
| 210 | + | |
| 211 | +/// Shown when the active tab has nothing to render yet: the brand mark and | |
| 212 | +/// a one-tap switch between identity universes. | |
| 213 | +struct StartPageView: View { | |
| 214 | + let active: IdentityContainer | |
| 215 | + let containers: [IdentityContainer] | |
| 216 | + let onSelect: (IdentityContainer.ID) -> Void | |
| 217 | + | |
| 218 | + var body: some View { | |
| 219 | + ZStack { | |
| 220 | + LinearGradient( | |
| 221 | + colors: [ | |
| 222 | + active.color.color.opacity(0.28), | |
| 223 | + active.color.color.opacity(0.06), | |
| 224 | + Color(.systemBackground), | |
| 225 | + ], | |
| 226 | + startPoint: .top, | |
| 227 | + endPoint: .bottom | |
| 228 | + ) | |
| 229 | + .ignoresSafeArea() | |
| 230 | + | |
| 231 | + VStack(spacing: Spacing.l) { | |
| 232 | + Spacer() | |
| 233 | + | |
| 234 | + PrismMark() | |
| 235 | + .frame(width: 170, height: 170) | |
| 236 | + | |
| 237 | + Text("Prisme") | |
| 238 | + .font(.system(size: 46, weight: .bold, design: .rounded)) | |
| 239 | + | |
| 240 | + Text("Un univers « \(active.name) » — cookies, sessions et empreinte y sont isolés du reste de vos vies.") | |
| 241 | + .font(.callout) | |
| 242 | + .foregroundStyle(.secondary) | |
| 243 | + .multilineTextAlignment(.center) | |
| 244 | + .padding(.horizontal, Spacing.xl * 2) | |
| 245 | + | |
| 246 | + containerChips | |
| 247 | + .padding(.top, Spacing.m) | |
| 248 | + | |
| 249 | + Spacer() | |
| 250 | + Spacer() | |
| 251 | + } | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + private var containerChips: some View { | |
| 256 | + HStack(spacing: Spacing.s) { | |
| 257 | + ForEach(containers) { container in | |
| 258 | + let isActive = container.id == active.id | |
| 259 | + Button { | |
| 260 | + withAnimation(.spring(duration: 0.3)) { onSelect(container.id) } | |
| 261 | + } label: { | |
| 262 | + HStack(spacing: Spacing.xs) { | |
| 263 | + Image(systemName: container.symbol) | |
| 264 | + .font(.caption.weight(.semibold)) | |
| 265 | + if isActive { | |
| 266 | + Text(container.name) | |
| 267 | + .font(.caption.weight(.semibold)) | |
| 268 | + .lineLimit(1) | |
| 269 | + .fixedSize() | |
| 270 | + } | |
| 271 | + } | |
| 272 | + .foregroundStyle(isActive ? .white : container.color.color) | |
| 273 | + .padding(.horizontal, isActive ? Spacing.l : Spacing.m) | |
| 274 | + .padding(.vertical, Spacing.m) | |
| 275 | + .background { | |
| 276 | + if isActive { | |
| 277 | + Capsule().fill(container.color.gradient) | |
| 278 | + } else { | |
| 279 | + Capsule().fill(container.color.color.opacity(0.14)) | |
| 280 | + } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + .buttonStyle(.plain) | |
| 284 | + } | |
| 285 | + } | |
| 286 | + .animation(.spring(duration: 0.3), value: active.id) | |
| 287 | + } | |
| 288 | +} | |
added
Prisme/Browser/Chrome/InsightStrip.swift
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// | |
| 2 | +// InsightStrip.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// The understanding strip: after a page settles, Prisme shows what it | |
| 11 | +/// read — instantly and deterministically (reading time, structure), then | |
| 12 | +/// enriched with the page kind and essence when the local model delivers. | |
| 13 | +/// This is the thesis made visible ("chaque page est comprise avant d'être | |
| 14 | +/// affichée") without the user hunting for a button. One tap opens the | |
| 15 | +/// reader; the × dismisses it for this page. Never a popup, never modal. | |
| 16 | +struct InsightStrip: View { | |
| 17 | + let insight: PageInsight | |
| 18 | + let digest: PageDigest? | |
| 19 | + let accent: Color | |
| 20 | + let onRead: () -> Void | |
| 21 | + let onDismiss: () -> Void | |
| 22 | + | |
| 23 | + /// Single tint for generated content, everywhere in the app (§7). | |
| 24 | + private static let generated = Color(red: 0.55, green: 0.36, blue: 0.96) | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + HStack(spacing: Spacing.m) { | |
| 28 | + Button(action: onRead) { | |
| 29 | + HStack(spacing: Spacing.m) { | |
| 30 | + Image(systemName: "doc.plaintext") | |
| 31 | + .font(.subheadline.weight(.semibold)) | |
| 32 | + .foregroundStyle(accent) | |
| 33 | + | |
| 34 | + VStack(alignment: .leading, spacing: 2) { | |
| 35 | + Text(headline) | |
| 36 | + .font(.footnote.weight(.semibold)) | |
| 37 | + .foregroundStyle(.primary) | |
| 38 | + if let gist = digest?.gist, !gist.isEmpty { | |
| 39 | + HStack(alignment: .top, spacing: Spacing.xs) { | |
| 40 | + Image(systemName: "sparkles") | |
| 41 | + .font(.caption2) | |
| 42 | + .padding(.top, 2) | |
| 43 | + Text(gist) | |
| 44 | + .font(.caption) | |
| 45 | + .italic() | |
| 46 | + .lineLimit(2) | |
| 47 | + } | |
| 48 | + .foregroundStyle(Self.generated) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + Spacer(minLength: Spacing.s) | |
| 53 | + | |
| 54 | + Text("Lire") | |
| 55 | + .font(.footnote.weight(.semibold)) | |
| 56 | + .foregroundStyle(.white) | |
| 57 | + .padding(.horizontal, Spacing.m) | |
| 58 | + .padding(.vertical, Spacing.xs + 2) | |
| 59 | + .background(accent, in: Capsule()) | |
| 60 | + } | |
| 61 | + .contentShape(Rectangle()) | |
| 62 | + } | |
| 63 | + .buttonStyle(.plain) | |
| 64 | + .accessibilityIdentifier("insight.read") | |
| 65 | + | |
| 66 | + Button(action: onDismiss) { | |
| 67 | + Image(systemName: "xmark") | |
| 68 | + .font(.caption.bold()) | |
| 69 | + .foregroundStyle(.secondary) | |
| 70 | + .frame(width: 28, height: 28) | |
| 71 | + .contentShape(Circle()) | |
| 72 | + } | |
| 73 | + .buttonStyle(.plain) | |
| 74 | + .accessibilityIdentifier("insight.dismiss") | |
| 75 | + } | |
| 76 | + .padding(.horizontal, Spacing.l) | |
| 77 | + .padding(.vertical, Spacing.m) | |
| 78 | + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Radius.l)) | |
| 79 | + } | |
| 80 | + | |
| 81 | + private var headline: String { | |
| 82 | + var parts: [String] = [] | |
| 83 | + if let kind = digest?.kind { | |
| 84 | + parts.append(kind.label) | |
| 85 | + } | |
| 86 | + parts.append("~\(insight.readingMinutes) min de lecture") | |
| 87 | + if digest == nil, insight.sectionCount > 1 { | |
| 88 | + parts.append("\(insight.sectionCount) sections") | |
| 89 | + } | |
| 90 | + return parts.joined(separator: " · ") | |
| 91 | + } | |
| 92 | +} | |
added
Prisme/Browser/Chrome/IntentResolver.swift
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +// | |
| 2 | +// IntentResolver.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | + | |
| 10 | +/// A search engine the user can route a query through. DuckDuckGo is the | |
| 11 | +/// default (privacy thesis, CLAUDE.md §1); Google is offered as an explicit | |
| 12 | +/// alternative on every query — the user always sees where a search goes. | |
| 13 | +enum SearchEngine: String, CaseIterable, Codable, Sendable, Identifiable { | |
| 14 | + case duckDuckGo | |
| 15 | + case google | |
| 16 | + | |
| 17 | + var id: String { rawValue } | |
| 18 | + | |
| 19 | + var name: String { | |
| 20 | + switch self { | |
| 21 | + case .duckDuckGo: "DuckDuckGo" | |
| 22 | + case .google: "Google" | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + var symbol: String { | |
| 27 | + switch self { | |
| 28 | + case .duckDuckGo: "shield.lefthalf.filled" | |
| 29 | + case .google: "g.circle.fill" | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + var other: SearchEngine { | |
| 34 | + self == .duckDuckGo ? .google : .duckDuckGo | |
| 35 | + } | |
| 36 | + | |
| 37 | + func searchURL(for query: String) -> URL { | |
| 38 | + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query | |
| 39 | + switch self { | |
| 40 | + case .duckDuckGo: | |
| 41 | + return URL(string: "https://duckduckgo.com/?q=\(encoded)")! | |
| 42 | + case .google: | |
| 43 | + return URL(string: "https://www.google.com/search?q=\(encoded)")! | |
| 44 | + } | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// What the user meant when typing in the address bar. | |
| 49 | +/// Phase 1 distinguishes URL vs search with pure heuristics (tier `none`, | |
| 50 | +/// CLAUDE.md §3) — question and command tiers arrive with the intelligence | |
| 51 | +/// layer. The bar never guesses silently: proposals are shown and the user | |
| 52 | +/// confirms one. | |
| 53 | +enum EntryIntent: Hashable { | |
| 54 | + case navigate(URL) | |
| 55 | + case search(String, SearchEngine) | |
| 56 | + | |
| 57 | + /// User-facing label (French, per product conventions). | |
| 58 | + var label: String { | |
| 59 | + switch self { | |
| 60 | + case .navigate(let url): | |
| 61 | + "Aller sur \(url.host() ?? url.absoluteString)" | |
| 62 | + case .search(let query, _): | |
| 63 | + "Rechercher « \(query) »" | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + /// Secondary line: where the action goes. | |
| 68 | + var detail: String { | |
| 69 | + switch self { | |
| 70 | + case .navigate: "Adresse directe" | |
| 71 | + case .search(_, let engine): engine.name | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + var symbol: String { | |
| 76 | + switch self { | |
| 77 | + case .navigate: "globe" | |
| 78 | + case .search(_, let engine): engine.symbol | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// URL to actually load. | |
| 83 | + var destination: URL { | |
| 84 | + switch self { | |
| 85 | + case .navigate(let url): | |
| 86 | + url | |
| 87 | + case .search(let query, let engine): | |
| 88 | + engine.searchURL(for: query) | |
| 89 | + } | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Pure-heuristic resolver: zero AI, zero network, deterministic. | |
| 94 | +enum IntentResolver { | |
| 95 | + /// Ordered proposals for the given input, most likely first. The | |
| 96 | + /// preferred engine leads; the other engine is always one tap away. | |
| 97 | + static func propose(for input: String, preferring engine: SearchEngine) -> [EntryIntent] { | |
| 98 | + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 99 | + guard !trimmed.isEmpty else { return [] } | |
| 100 | + | |
| 101 | + if let url = explicitURL(from: trimmed) ?? implicitURL(from: trimmed) { | |
| 102 | + return [.navigate(url), .search(trimmed, engine), .search(trimmed, engine.other)] | |
| 103 | + } | |
| 104 | + return [.search(trimmed, engine), .search(trimmed, engine.other)] | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// Input carrying its own scheme, e.g. "https://example.com". | |
| 108 | + private static func explicitURL(from input: String) -> URL? { | |
| 109 | + guard input.lowercased().hasPrefix("http://") || input.lowercased().hasPrefix("https://"), | |
| 110 | + let url = URL(string: input), url.host() != nil | |
| 111 | + else { return nil } | |
| 112 | + return url | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Bare host-like input, e.g. "example.com/page" or "localhost:3000". | |
| 116 | + private static func implicitURL(from input: String) -> URL? { | |
| 117 | + guard !input.contains(" ") else { return nil } | |
| 118 | + let hostPart = input.split(separator: "/").first.map(String.init) ?? input | |
| 119 | + let looksLikeHost = hostPart.contains(".") || hostPart.hasPrefix("localhost") | |
| 120 | + guard looksLikeHost, let url = URL(string: "https://\(input)"), url.host() != nil else { | |
| 121 | + return nil | |
| 122 | + } | |
| 123 | + return url | |
| 124 | + } | |
| 125 | +} | |
added
Prisme/Browser/Chrome/TabSwitcherView.swift
+197 −0
@@ -0,0 +1,197 @@ | ||
| 1 | +// | |
| 2 | +// TabSwitcherView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// Tab overview, sectioned by identity container. Intention-based grouping | |
| 11 | +/// (P0) will be *proposed* on top of this later — never imposed. | |
| 12 | +struct TabSwitcherView: View { | |
| 13 | + let model: BrowserModel | |
| 14 | + | |
| 15 | + @Environment(\.dismiss) private var dismiss | |
| 16 | + @State private var showHistory = false | |
| 17 | + | |
| 18 | + private let columns = [GridItem(.adaptive(minimum: 160), spacing: Spacing.m)] | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + NavigationStack { | |
| 22 | + Group { | |
| 23 | + if model.tabs.tabs.isEmpty { | |
| 24 | + ContentUnavailableView( | |
| 25 | + "Aucun onglet", | |
| 26 | + systemImage: "square.on.square", | |
| 27 | + description: Text("Touchez + pour ouvrir un onglet dans l'univers actif.") | |
| 28 | + ) | |
| 29 | + } else { | |
| 30 | + grid | |
| 31 | + } | |
| 32 | + } | |
| 33 | + .navigationTitle("Onglets") | |
| 34 | + .navigationBarTitleDisplayMode(.inline) | |
| 35 | + .toolbar { | |
| 36 | + ToolbarItem(placement: .cancellationAction) { | |
| 37 | + Button("OK") { dismiss() } | |
| 38 | + .fontWeight(.semibold) | |
| 39 | + } | |
| 40 | + ToolbarItem(placement: .primaryAction) { | |
| 41 | + Button { | |
| 42 | + model.newTab() | |
| 43 | + dismiss() | |
| 44 | + } label: { | |
| 45 | + Image(systemName: "plus") | |
| 46 | + } | |
| 47 | + } | |
| 48 | + ToolbarItem(placement: .secondaryAction) { | |
| 49 | + Button { | |
| 50 | + showHistory = true | |
| 51 | + } label: { | |
| 52 | + Label("Historique", systemImage: "clock.arrow.circlepath") | |
| 53 | + } | |
| 54 | + .accessibilityIdentifier("tabs.history") | |
| 55 | + } | |
| 56 | + } | |
| 57 | + .sheet(isPresented: $showHistory) { | |
| 58 | + HistoryView( | |
| 59 | + history: model.history, | |
| 60 | + containers: model.containers, | |
| 61 | + onOpen: { visit in | |
| 62 | + model.open(visit) | |
| 63 | + dismiss() | |
| 64 | + } | |
| 65 | + ) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + private var grid: some View { | |
| 71 | + ScrollView { | |
| 72 | + LazyVGrid(columns: columns, alignment: .leading, spacing: Spacing.m) { | |
| 73 | + ForEach(model.containers.all) { container in | |
| 74 | + let tabs = model.tabs.tabs(in: container.id) | |
| 75 | + if !tabs.isEmpty { | |
| 76 | + Section { | |
| 77 | + ForEach(tabs) { tab in | |
| 78 | + TabCard( | |
| 79 | + tab: tab, | |
| 80 | + container: container, | |
| 81 | + isActive: tab.id == model.tabs.activeTabID, | |
| 82 | + onSelect: { | |
| 83 | + model.activate(tab) | |
| 84 | + dismiss() | |
| 85 | + }, | |
| 86 | + onClose: { | |
| 87 | + withAnimation(.spring(duration: 0.3)) { | |
| 88 | + model.tabs.close(tab) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + ) | |
| 92 | + .transition(.scale(scale: 0.85).combined(with: .opacity)) | |
| 93 | + } | |
| 94 | + } header: { | |
| 95 | + HStack(spacing: Spacing.s) { | |
| 96 | + Image(systemName: container.symbol) | |
| 97 | + .font(.subheadline.weight(.semibold)) | |
| 98 | + Text(container.name) | |
| 99 | + .font(.headline) | |
| 100 | + Text("\(tabs.count)") | |
| 101 | + .font(.subheadline.weight(.semibold)) | |
| 102 | + .foregroundStyle(.secondary) | |
| 103 | + } | |
| 104 | + .foregroundStyle(container.color.color) | |
| 105 | + .padding(.top, Spacing.l) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + .padding(Spacing.l) | |
| 111 | + } | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// MARK: - Card | |
| 116 | + | |
| 117 | +private struct TabCard: View { | |
| 118 | + let tab: Tab | |
| 119 | + let container: IdentityContainer | |
| 120 | + let isActive: Bool | |
| 121 | + let onSelect: () -> Void | |
| 122 | + let onClose: () -> Void | |
| 123 | + | |
| 124 | + var body: some View { | |
| 125 | + Button(action: onSelect) { | |
| 126 | + VStack(alignment: .leading, spacing: 0) { | |
| 127 | + // Container-colored crown. | |
| 128 | + Rectangle() | |
| 129 | + .fill(container.color.gradient) | |
| 130 | + .frame(height: 5) | |
| 131 | + | |
| 132 | + VStack(alignment: .leading, spacing: Spacing.s) { | |
| 133 | + HStack(alignment: .top) { | |
| 134 | + siteBadge | |
| 135 | + Spacer(minLength: Spacing.s) | |
| 136 | + Button(action: onClose) { | |
| 137 | + Image(systemName: "xmark") | |
| 138 | + .font(.caption.bold()) | |
| 139 | + .foregroundStyle(.secondary) | |
| 140 | + .frame(width: 26, height: 26) | |
| 141 | + .background(.quaternary, in: Circle()) | |
| 142 | + .contentShape(Circle()) | |
| 143 | + } | |
| 144 | + .buttonStyle(.plain) | |
| 145 | + } | |
| 146 | + | |
| 147 | + Text(tab.displayTitle) | |
| 148 | + .font(.subheadline.weight(.semibold)) | |
| 149 | + .lineLimit(2) | |
| 150 | + .multilineTextAlignment(.leading) | |
| 151 | + .foregroundStyle(.primary) | |
| 152 | + | |
| 153 | + if let host = tab.page.url?.host() { | |
| 154 | + Text(host) | |
| 155 | + .font(.caption) | |
| 156 | + .foregroundStyle(.secondary) | |
| 157 | + .lineLimit(1) | |
| 158 | + } | |
| 159 | + } | |
| 160 | + .padding(Spacing.m) | |
| 161 | + } | |
| 162 | + .frame(maxWidth: .infinity, minHeight: 118, alignment: .topLeading) | |
| 163 | + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Radius.l)) | |
| 164 | + .overlay { | |
| 165 | + RoundedRectangle(cornerRadius: Radius.l) | |
| 166 | + .strokeBorder( | |
| 167 | + isActive ? AnyShapeStyle(container.color.color) : AnyShapeStyle(.quaternary), | |
| 168 | + lineWidth: isActive ? 2.5 : 1 | |
| 169 | + ) | |
| 170 | + } | |
| 171 | + .clipShape(RoundedRectangle(cornerRadius: Radius.l)) | |
| 172 | + .shadow(color: .black.opacity(isActive ? 0.14 : 0.06), radius: 10, y: 4) | |
| 173 | + } | |
| 174 | + .buttonStyle(.plain) | |
| 175 | + } | |
| 176 | + | |
| 177 | + /// Favicon placeholder: first letter of the host in the container tint. | |
| 178 | + private var siteBadge: some View { | |
| 179 | + let letter = tab.page.url?.host()? | |
| 180 | + .replacingOccurrences(of: "www.", with: "") | |
| 181 | + .prefix(1).uppercased() | |
| 182 | + return ZStack { | |
| 183 | + Circle() | |
| 184 | + .fill(container.color.color.opacity(0.16)) | |
| 185 | + .frame(width: 30, height: 30) | |
| 186 | + if let letter, !letter.isEmpty { | |
| 187 | + Text(letter) | |
| 188 | + .font(.footnote.bold()) | |
| 189 | + .foregroundStyle(container.color.color) | |
| 190 | + } else { | |
| 191 | + Image(systemName: "globe") | |
| 192 | + .font(.footnote.weight(.semibold)) | |
| 193 | + .foregroundStyle(container.color.color) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | +} | |
added
Prisme/Browser/Engine/BrowserWebView.swift
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +// | |
| 2 | +// BrowserWebView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | +import WebKit | |
| 10 | + | |
| 11 | +/// The single `UIViewRepresentable` wrapping `WKWebView` (CLAUDE.md §9). | |
| 12 | +/// One instance renders the active tab; use `.id(tab.id)` so switching tabs | |
| 13 | +/// tears down and rebuilds the attachment, returning the view to the pool. | |
| 14 | +struct BrowserWebView: UIViewRepresentable { | |
| 15 | + let tab: Tab | |
| 16 | + let pool: WebViewPool | |
| 17 | + | |
| 18 | + func makeCoordinator() -> Coordinator { | |
| 19 | + Coordinator(tab: tab, pool: pool) | |
| 20 | + } | |
| 21 | + | |
| 22 | + func makeUIView(context: Context) -> WKWebView { | |
| 23 | + let webView = pool.checkout(for: tab.containerID) | |
| 24 | + context.coordinator.attach(to: webView) | |
| 25 | + | |
| 26 | + if let state = tab.interactionState { | |
| 27 | + // Restores history, scroll position and form state. | |
| 28 | + webView.interactionState = state | |
| 29 | + } else if let url = tab.initialURL { | |
| 30 | + webView.load(URLRequest(url: url)) | |
| 31 | + } | |
| 32 | + return webView | |
| 33 | + } | |
| 34 | + | |
| 35 | + func updateUIView(_ webView: WKWebView, context: Context) {} | |
| 36 | + | |
| 37 | + static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) { | |
| 38 | + coordinator.detach(from: webView) | |
| 39 | + } | |
| 40 | + | |
| 41 | + // MARK: - Coordinator | |
| 42 | + | |
| 43 | + @MainActor | |
| 44 | + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { | |
| 45 | + private let tab: Tab | |
| 46 | + private let pool: WebViewPool | |
| 47 | + private var observations: [NSKeyValueObservation] = [] | |
| 48 | + | |
| 49 | + init(tab: Tab, pool: WebViewPool) { | |
| 50 | + self.tab = tab | |
| 51 | + self.pool = pool | |
| 52 | + } | |
| 53 | + | |
| 54 | + func attach(to webView: WKWebView) { | |
| 55 | + webView.navigationDelegate = self | |
| 56 | + webView.uiDelegate = self | |
| 57 | + tab.page.webView = webView | |
| 58 | + tab.page.sync(from: webView) | |
| 59 | + installRefreshControl(on: webView) | |
| 60 | + observe(webView) | |
| 61 | + if let prismeWebView = webView as? PrismeWebView { | |
| 62 | + let page = tab.page | |
| 63 | + prismeWebView.onSaveExcerpt = { page.onExcerptRequested?() } | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + func detach(from webView: WKWebView) { | |
| 68 | + observations.removeAll() | |
| 69 | + (webView as? PrismeWebView)?.onSaveExcerpt = nil | |
| 70 | + tab.interactionState = webView.interactionState | |
| 71 | + tab.page.webView = nil | |
| 72 | + tab.page.resetTransientState() | |
| 73 | + webView.navigationDelegate = nil | |
| 74 | + webView.uiDelegate = nil | |
| 75 | + webView.scrollView.refreshControl = nil | |
| 76 | + pool.checkin(webView, containerID: tab.containerID) | |
| 77 | + } | |
| 78 | + | |
| 79 | + // MARK: State observation | |
| 80 | + | |
| 81 | + private func observe(_ webView: WKWebView) { | |
| 82 | + // WKWebView KVO fires on the main thread; assumeIsolated is safe. | |
| 83 | + let page = tab.page | |
| 84 | + observations = [ | |
| 85 | + webView.observe(\.title) { wv, _ in | |
| 86 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 87 | + }, | |
| 88 | + webView.observe(\.url) { wv, _ in | |
| 89 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 90 | + }, | |
| 91 | + webView.observe(\.estimatedProgress) { wv, _ in | |
| 92 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 93 | + }, | |
| 94 | + webView.observe(\.isLoading) { wv, _ in | |
| 95 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 96 | + }, | |
| 97 | + webView.observe(\.canGoBack) { wv, _ in | |
| 98 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 99 | + }, | |
| 100 | + webView.observe(\.canGoForward) { wv, _ in | |
| 101 | + MainActor.assumeIsolated { page.sync(from: wv) } | |
| 102 | + }, | |
| 103 | + ] | |
| 104 | + } | |
| 105 | + | |
| 106 | + // MARK: Pull to refresh | |
| 107 | + | |
| 108 | + private func installRefreshControl(on webView: WKWebView) { | |
| 109 | + let control = UIRefreshControl() | |
| 110 | + control.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) | |
| 111 | + webView.scrollView.refreshControl = control | |
| 112 | + } | |
| 113 | + | |
| 114 | + @objc private func handleRefresh() { | |
| 115 | + tab.page.reload() | |
| 116 | + } | |
| 117 | + | |
| 118 | + // MARK: WKNavigationDelegate | |
| 119 | + | |
| 120 | + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { | |
| 121 | + webView.scrollView.refreshControl?.endRefreshing() | |
| 122 | + tab.page.sync(from: webView) | |
| 123 | + if let url = webView.url { | |
| 124 | + tab.page.onDidFinishLoad?(url) | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { | |
| 129 | + webView.scrollView.refreshControl?.endRefreshing() | |
| 130 | + } | |
| 131 | + | |
| 132 | + func webView( | |
| 133 | + _ webView: WKWebView, | |
| 134 | + didFailProvisionalNavigation navigation: WKNavigation!, | |
| 135 | + withError error: Error | |
| 136 | + ) { | |
| 137 | + webView.scrollView.refreshControl?.endRefreshing() | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: WKUIDelegate | |
| 141 | + | |
| 142 | + func webView( | |
| 143 | + _ webView: WKWebView, | |
| 144 | + createWebViewWith configuration: WKWebViewConfiguration, | |
| 145 | + for navigationAction: WKNavigationAction, | |
| 146 | + windowFeatures: WKWindowFeatures | |
| 147 | + ) -> WKWebView? { | |
| 148 | + // target=_blank: load in place for now; real popup-to-tab | |
| 149 | + // routing comes with tab groups. | |
| 150 | + if navigationAction.targetFrame == nil { | |
| 151 | + webView.load(navigationAction.request) | |
| 152 | + } | |
| 153 | + return nil | |
| 154 | + } | |
| 155 | + } | |
| 156 | +} | |
added
Prisme/Browser/Engine/ContentRuleManager.swift
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +// | |
| 2 | +// ContentRuleManager.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | +import WebKit | |
| 11 | + | |
| 12 | +/// Compiles the bundled blocker rules into a `WKContentRuleList`. | |
| 13 | +/// Compilation is async and off the rendering critical path: the first page | |
| 14 | +/// can start loading before rules are ready; the pool applies them to every | |
| 15 | +/// live web view as soon as compilation finishes. | |
| 16 | +@MainActor | |
| 17 | +@Observable | |
| 18 | +final class ContentRuleManager { | |
| 19 | + private static let identifier = "prisme-blocker-v1" | |
| 20 | + | |
| 21 | + private(set) var ruleList: WKContentRuleList? | |
| 22 | + | |
| 23 | + func compileIfNeeded() async { | |
| 24 | + guard ruleList == nil else { return } | |
| 25 | + guard let store = WKContentRuleListStore.default() else { return } | |
| 26 | + | |
| 27 | + // Reuse a previously compiled list when the bundle hasn't changed. | |
| 28 | + if let cached = try? await store.contentRuleList(forIdentifier: Self.identifier) { | |
| 29 | + ruleList = cached | |
| 30 | + return | |
| 31 | + } | |
| 32 | + | |
| 33 | + guard | |
| 34 | + let url = Bundle.main.url(forResource: "blockerRules", withExtension: "json"), | |
| 35 | + let json = try? String(contentsOf: url, encoding: .utf8) | |
| 36 | + else { | |
| 37 | + assertionFailure("blockerRules.json missing from bundle") | |
| 38 | + return | |
| 39 | + } | |
| 40 | + | |
| 41 | + do { | |
| 42 | + ruleList = try await store.compileContentRuleList( | |
| 43 | + forIdentifier: Self.identifier, | |
| 44 | + encodedContentRuleList: json | |
| 45 | + ) | |
| 46 | + } catch { | |
| 47 | + // Blocking is an enhancement, never a gate: browse without it. | |
| 48 | + ruleList = nil | |
| 49 | + } | |
| 50 | + } | |
| 51 | +} | |
added
Prisme/Browser/Engine/PrismeWebView.swift
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +// | |
| 2 | +// PrismeWebView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import UIKit | |
| 9 | +import WebKit | |
| 10 | + | |
| 11 | +/// The engine's web view: adds "Sauver l'extrait" to the system text | |
| 12 | +/// selection menu. Selecting a passage and keeping *it* — not the page — | |
| 13 | +/// is the primary save gesture (CLAUDE.md §5, "L'extrait", P0). | |
| 14 | +final class PrismeWebView: WKWebView { | |
| 15 | + /// Set by the coordinator while a tab is attached. | |
| 16 | + var onSaveExcerpt: (() -> Void)? | |
| 17 | + | |
| 18 | + override func buildMenu(with builder: UIMenuBuilder) { | |
| 19 | + super.buildMenu(with: builder) | |
| 20 | + guard onSaveExcerpt != nil else { return } | |
| 21 | + let save = UIAction( | |
| 22 | + title: "Sauver l'extrait", | |
| 23 | + image: UIImage(systemName: "quote.opening") | |
| 24 | + ) { [weak self] _ in | |
| 25 | + self?.onSaveExcerpt?() | |
| 26 | + } | |
| 27 | + builder.insertSibling( | |
| 28 | + UIMenu(options: .displayInline, children: [save]), | |
| 29 | + afterMenu: .standardEdit | |
| 30 | + ) | |
| 31 | + } | |
| 32 | +} | |
added
Prisme/Browser/Engine/Resources/blockerRules.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +[ | |
| 2 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?doubleclick\\.net/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 3 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?googletagmanager\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 4 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?google-analytics\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 5 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?googlesyndication\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 6 | + { "trigger": { "url-filter": "^https?://connect\\.facebook\\.net/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 7 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?scorecardresearch\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 8 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?adnxs\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 9 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?criteo\\.(com|net)/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 10 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?taboola\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 11 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?outbrain\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 12 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?hotjar\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 13 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?mixpanel\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 14 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?segment\\.(io|com)/", "load-type": ["third-party"] }, "action": { "type": "block" } }, | |
| 15 | + { "trigger": { "url-filter": "^https?://([^/]+\\.)?amplitude\\.com/", "load-type": ["third-party"] }, "action": { "type": "block" } } | |
| 16 | +] | |
added
Prisme/Browser/Engine/WebPageProxy.swift
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// | |
| 2 | +// WebPageProxy.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | +import WebKit | |
| 11 | + | |
| 12 | +/// The only surface through which the rest of the app talks to a web page. | |
| 13 | +/// `WKWebView` itself never escapes `Browser/Engine` (CLAUDE.md §9): chrome | |
| 14 | +/// and tabs read observable state here and issue commands through it. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class WebPageProxy { | |
| 18 | + /// The pooled web view currently rendering this page, if any. | |
| 19 | + /// Set by the engine while the owning tab is on screen. | |
| 20 | + @ObservationIgnored weak var webView: WKWebView? | |
| 21 | + | |
| 22 | + /// Fired by the engine when a main-frame load finishes. The app layer | |
| 23 | + /// hooks history recording here; the engine stays ignorant of it. | |
| 24 | + @ObservationIgnored var onDidFinishLoad: ((URL) -> Void)? | |
| 25 | + | |
| 26 | + /// Fired when the user picks "Sauver l'extrait" in the selection menu. | |
| 27 | + @ObservationIgnored var onExcerptRequested: (() -> Void)? | |
| 28 | + | |
| 29 | + private(set) var url: URL? | |
| 30 | + private(set) var title: String = "" | |
| 31 | + private(set) var estimatedProgress: Double = 0 | |
| 32 | + private(set) var isLoading = false | |
| 33 | + private(set) var canGoBack = false | |
| 34 | + private(set) var canGoForward = false | |
| 35 | + | |
| 36 | + // MARK: Commands | |
| 37 | + | |
| 38 | + func load(_ url: URL) { | |
| 39 | + webView?.load(URLRequest(url: url)) | |
| 40 | + } | |
| 41 | + | |
| 42 | + func goBack() { webView?.goBack() } | |
| 43 | + func goForward() { webView?.goForward() } | |
| 44 | + func reload() { webView?.reload() } | |
| 45 | + func stopLoading() { webView?.stopLoading() } | |
| 46 | + | |
| 47 | + // MARK: Content extraction (Distiller steps 1–2, in-page) | |
| 48 | + | |
| 49 | + enum PageError: LocalizedError { | |
| 50 | + case notAttached | |
| 51 | + case extractionFailed | |
| 52 | + | |
| 53 | + var errorDescription: String? { | |
| 54 | + switch self { | |
| 55 | + case .notAttached: "L'onglet n'est pas affiché." | |
| 56 | + case .extractionFailed: "Le contenu de la page n'a pas pu être lu." | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Runs the injected extractor and returns the typed block list. | |
| 62 | + /// Never returns raw HTML (CLAUDE.md §4). | |
| 63 | + func extractContent() async throws -> ExtractedContent { | |
| 64 | + guard let webView else { throw PageError.notAttached } | |
| 65 | + let result = try await webView.evaluateJavaScript("window.__prismeExtract(400)") | |
| 66 | + guard let json = result as? String, let data = json.data(using: .utf8) else { | |
| 67 | + throw PageError.extractionFailed | |
| 68 | + } | |
| 69 | + return try JSONDecoder().decode(ExtractedContent.self, from: data) | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// The user's current text selection, with source anchor and section | |
| 73 | + /// context. Nil when nothing is selected. | |
| 74 | + func captureSelection() async throws -> SelectionExcerpt? { | |
| 75 | + guard let webView else { throw PageError.notAttached } | |
| 76 | + let result = try await webView.evaluateJavaScript("window.__prismeSelection()") | |
| 77 | + guard let json = result as? String, let data = json.data(using: .utf8) else { | |
| 78 | + return nil | |
| 79 | + } | |
| 80 | + return try? JSONDecoder().decode(SelectionExcerpt.self, from: data) | |
| 81 | + } | |
| 82 | + | |
| 83 | + /// Scrolls the page to the DOM element a digest item was derived from — | |
| 84 | + /// the anchor that keeps generated content honest (§7). | |
| 85 | + func scrollToBlock(domPath: String) { | |
| 86 | + let escaped = domPath.replacingOccurrences(of: "'", with: "") | |
| 87 | + webView?.evaluateJavaScript("window.__prismeScrollTo('\(escaped)')") | |
| 88 | + } | |
| 89 | + | |
| 90 | + // MARK: Engine-side state sync | |
| 91 | + | |
| 92 | + func sync(from webView: WKWebView) { | |
| 93 | + url = webView.url | |
| 94 | + title = webView.title ?? "" | |
| 95 | + estimatedProgress = webView.estimatedProgress | |
| 96 | + isLoading = webView.isLoading | |
| 97 | + canGoBack = webView.canGoBack | |
| 98 | + canGoForward = webView.canGoForward | |
| 99 | + } | |
| 100 | + | |
| 101 | + func resetTransientState() { | |
| 102 | + estimatedProgress = 0 | |
| 103 | + isLoading = false | |
| 104 | + } | |
| 105 | +} | |
added
Prisme/Browser/Engine/WebViewPool.swift
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +// | |
| 2 | +// WebViewPool.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import WebKit | |
| 10 | + | |
| 11 | +/// Reusable `WKWebView` instances, partitioned by identity container. | |
| 12 | +/// Never instantiate one web view per tab (CLAUDE.md §8): tabs check a view | |
| 13 | +/// out while visible and check it back in when they leave the screen. | |
| 14 | +@MainActor | |
| 15 | +final class WebViewPool { | |
| 16 | + private static let maxIdlePerContainer = 3 | |
| 17 | + | |
| 18 | + private let containers: IdentityContainerStore | |
| 19 | + private let rules: ContentRuleManager | |
| 20 | + | |
| 21 | + private var idle: [IdentityContainer.ID: [WKWebView]] = [:] | |
| 22 | + /// Weak set of every view we ever vended, so freshly compiled content | |
| 23 | + /// rules can be applied to views already on screen. | |
| 24 | + private let live = NSHashTable<WKWebView>.weakObjects() | |
| 25 | + | |
| 26 | + init(containers: IdentityContainerStore, rules: ContentRuleManager) { | |
| 27 | + self.containers = containers | |
| 28 | + self.rules = rules | |
| 29 | + } | |
| 30 | + | |
| 31 | + func checkout(for containerID: IdentityContainer.ID) -> WKWebView { | |
| 32 | + if let reused = idle[containerID]?.popLast() { | |
| 33 | + return reused | |
| 34 | + } | |
| 35 | + let webView = makeWebView(for: containerID) | |
| 36 | + live.add(webView) | |
| 37 | + return webView | |
| 38 | + } | |
| 39 | + | |
| 40 | + func checkin(_ webView: WKWebView, containerID: IdentityContainer.ID) { | |
| 41 | + webView.stopLoading() | |
| 42 | + var stack = idle[containerID] ?? [] | |
| 43 | + guard stack.count < Self.maxIdlePerContainer else { return } | |
| 44 | + stack.append(webView) | |
| 45 | + idle[containerID] = stack | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Apply a compiled rule list to all current and future web views. | |
| 49 | + func apply(_ ruleList: WKContentRuleList) { | |
| 50 | + for webView in live.allObjects { | |
| 51 | + webView.configuration.userContentController.add(ruleList) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Extractor source is read once; it only defines functions, so the | |
| 56 | + /// injection cost at documentEnd is negligible (CLAUDE.md §8). | |
| 57 | + private static let extractorSource: String? = { | |
| 58 | + guard let url = Bundle.main.url(forResource: "extractor", withExtension: "js") else { return nil } | |
| 59 | + return try? String(contentsOf: url, encoding: .utf8) | |
| 60 | + }() | |
| 61 | + | |
| 62 | + private func makeWebView(for containerID: IdentityContainer.ID) -> WKWebView { | |
| 63 | + let configuration = WKWebViewConfiguration() | |
| 64 | + configuration.websiteDataStore = containers.dataStore(for: containerID) | |
| 65 | + configuration.allowsInlineMediaPlayback = true | |
| 66 | + if let ruleList = rules.ruleList { | |
| 67 | + configuration.userContentController.add(ruleList) | |
| 68 | + } | |
| 69 | + if let source = Self.extractorSource { | |
| 70 | + configuration.userContentController.addUserScript( | |
| 71 | + WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) | |
| 72 | + ) | |
| 73 | + } | |
| 74 | + | |
| 75 | + let webView = PrismeWebView(frame: .zero, configuration: configuration) | |
| 76 | + webView.allowsBackForwardNavigationGestures = true | |
| 77 | + webView.isFindInteractionEnabled = true | |
| 78 | + webView.scrollView.contentInsetAdjustmentBehavior = .always | |
| 79 | + return webView | |
| 80 | + } | |
| 81 | +} | |
added
Prisme/Browser/Reader/ReaderModel.swift
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +// | |
| 2 | +// ReaderModel.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | + | |
| 11 | +/// Detail level of the semantic zoom (CLAUDE.md §5, the signature feature). | |
| 12 | +/// Pinching moves along this scale; spreading past `.full` returns to the | |
| 13 | +/// raw page — one gesture always brings the real page back (§7). | |
| 14 | +enum ReaderLevel: Int, CaseIterable, Comparable { | |
| 15 | + case full = 0 | |
| 16 | + case condensed | |
| 17 | + case outline | |
| 18 | + case gist | |
| 19 | + | |
| 20 | + var label: String { | |
| 21 | + switch self { | |
| 22 | + case .full: "Texte" | |
| 23 | + case .condensed: "Sections" | |
| 24 | + case .outline: "Plan" | |
| 25 | + case .gist: "Essentiel" | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// A run of blocks under one heading. | |
| 33 | +struct ReaderSection: Identifiable { | |
| 34 | + let id: Int | |
| 35 | + let title: String | |
| 36 | + /// Heading depth (1–6); 0 for the leading section before any heading. | |
| 37 | + let level: Int | |
| 38 | + let blockRange: Range<Int> | |
| 39 | + /// Deterministic summary (first sentence of the first paragraph) — | |
| 40 | + /// tier `none`, used whenever the model has nothing better. | |
| 41 | + let fallbackSummary: String | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// State behind the reader. Extraction is deterministic and instant (no AI); | |
| 45 | +/// the digest enriches summaries and the gist when the local model delivers. | |
| 46 | +@MainActor | |
| 47 | +@Observable | |
| 48 | +final class ReaderModel { | |
| 49 | + let tab: Tab | |
| 50 | + let intelligence: IntelligenceCenter | |
| 51 | + | |
| 52 | + private(set) var title: String = "" | |
| 53 | + private(set) var blocks: [ContentBlock] = [] | |
| 54 | + private(set) var sections: [ReaderSection] = [] | |
| 55 | + private(set) var extractionFailed = false | |
| 56 | + | |
| 57 | + var level: ReaderLevel = .full | |
| 58 | + | |
| 59 | + init(tab: Tab, intelligence: IntelligenceCenter) { | |
| 60 | + self.tab = tab | |
| 61 | + self.intelligence = intelligence | |
| 62 | + } | |
| 63 | + | |
| 64 | + var digest: PageDigest? { | |
| 65 | + if case .ready(let digest, _) = intelligence.digestState(for: tab) { | |
| 66 | + return digest | |
| 67 | + } | |
| 68 | + return nil | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Blocks the digest was computed on (budgeted subset). | |
| 72 | + private var digestBlocks: [ContentBlock]? { | |
| 73 | + if case .ready(_, let kept) = intelligence.digestState(for: tab) { | |
| 74 | + return kept | |
| 75 | + } | |
| 76 | + return nil | |
| 77 | + } | |
| 78 | + | |
| 79 | + func load() async { | |
| 80 | + do { | |
| 81 | + let content = try await tab.page.extractContent() | |
| 82 | + title = content.title | |
| 83 | + blocks = content.blocks | |
| 84 | + sections = Self.groupSections(content.blocks, pageTitle: content.title) | |
| 85 | + extractionFailed = content.blocks.isEmpty | |
| 86 | + // Enrichment is optional and arrives when it arrives. | |
| 87 | + intelligence.requestDigest(for: tab) | |
| 88 | + } catch { | |
| 89 | + extractionFailed = true | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + /// Summary for a section: the model's if it maps to this section | |
| 94 | + /// (matched through DOM paths, never guessed), else the deterministic | |
| 95 | + /// fallback. `generated` drives the distinct visual treatment (§7). | |
| 96 | + func summary(for section: ReaderSection) -> (text: String, generated: Bool) { | |
| 97 | + if let digest, let kept = digestBlocks { | |
| 98 | + for digestSection in digest.outline { | |
| 99 | + guard kept.indices.contains(digestSection.sourceBlock) else { continue } | |
| 100 | + let path = kept[digestSection.sourceBlock].domPath | |
| 101 | + if let index = blocks.firstIndex(where: { $0.domPath == path }), | |
| 102 | + section.blockRange.contains(index) { | |
| 103 | + return (digestSection.summary, true) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + return (section.fallbackSummary, false) | |
| 108 | + } | |
| 109 | + | |
| 110 | + var gist: (text: String, generated: Bool) { | |
| 111 | + if let digest { | |
| 112 | + return (digest.gist, true) | |
| 113 | + } | |
| 114 | + if let first = sections.first, !first.fallbackSummary.isEmpty { | |
| 115 | + return (first.fallbackSummary, false) | |
| 116 | + } | |
| 117 | + return (title, false) | |
| 118 | + } | |
| 119 | + | |
| 120 | + // MARK: - Grouping | |
| 121 | + | |
| 122 | + private static func groupSections(_ blocks: [ContentBlock], pageTitle: String) -> [ReaderSection] { | |
| 123 | + var sections: [ReaderSection] = [] | |
| 124 | + var start = 0 | |
| 125 | + var currentTitle = pageTitle | |
| 126 | + var currentLevel = 0 | |
| 127 | + | |
| 128 | + func close(at end: Int) { | |
| 129 | + guard end > start else { return } | |
| 130 | + let range = start..<end | |
| 131 | + sections.append(ReaderSection( | |
| 132 | + id: start, | |
| 133 | + title: currentTitle, | |
| 134 | + level: currentLevel, | |
| 135 | + blockRange: range, | |
| 136 | + fallbackSummary: firstSentence(in: blocks[range]) | |
| 137 | + )) | |
| 138 | + } | |
| 139 | + | |
| 140 | + for (index, block) in blocks.enumerated() where block.kind == .heading { | |
| 141 | + close(at: index) | |
| 142 | + currentTitle = block.text | |
| 143 | + currentLevel = block.level | |
| 144 | + start = index | |
| 145 | + } | |
| 146 | + close(at: blocks.count) | |
| 147 | + return sections | |
| 148 | + } | |
| 149 | + | |
| 150 | + private static func firstSentence(in slice: ArraySlice<ContentBlock>) -> String { | |
| 151 | + guard let paragraph = slice.first(where: { $0.kind == .paragraph })?.text else { | |
| 152 | + return "" | |
| 153 | + } | |
| 154 | + if let end = paragraph.firstIndex(where: { ".!?".contains($0) }) { | |
| 155 | + return String(paragraph[...end]) | |
| 156 | + } | |
| 157 | + return paragraph | |
| 158 | + } | |
| 159 | +} | |
added
Prisme/Browser/Reader/ReaderView.swift
+373 −0
@@ -0,0 +1,373 @@ | ||
| 1 | +// | |
| 2 | +// ReaderView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// Semantic zoom (CLAUDE.md §5). The pinch does not change text size — it | |
| 11 | +/// changes the level of detail: full text, condensed sections, outline, | |
| 12 | +/// one sentence. Spreading past the full text dismisses the reader and | |
| 13 | +/// returns the raw page. The rendering adapts to the page kind (article → | |
| 14 | +/// serif reading page; documentation → code kept prominent). | |
| 15 | +struct ReaderView: View { | |
| 16 | + @State private var model: ReaderModel | |
| 17 | + let accent: Color | |
| 18 | + let library: LibraryStore | |
| 19 | + let onSaveFavorite: () -> Void | |
| 20 | + | |
| 21 | + @Environment(\.dismiss) private var dismiss | |
| 22 | + @GestureState private var pinch: CGFloat = 1 | |
| 23 | + @State private var scrollTarget: Int? | |
| 24 | + | |
| 25 | + /// Single tint for generated content, everywhere in the app (§7). | |
| 26 | + static let generated = Color(red: 0.55, green: 0.36, blue: 0.96) | |
| 27 | + | |
| 28 | + init( | |
| 29 | + tab: Tab, | |
| 30 | + intelligence: IntelligenceCenter, | |
| 31 | + accent: Color, | |
| 32 | + library: LibraryStore, | |
| 33 | + onSaveFavorite: @escaping () -> Void | |
| 34 | + ) { | |
| 35 | + _model = State(initialValue: ReaderModel(tab: tab, intelligence: intelligence)) | |
| 36 | + self.accent = accent | |
| 37 | + self.library = library | |
| 38 | + self.onSaveFavorite = onSaveFavorite | |
| 39 | + } | |
| 40 | + | |
| 41 | + var body: some View { | |
| 42 | + VStack(spacing: 0) { | |
| 43 | + header | |
| 44 | + Divider() | |
| 45 | + content | |
| 46 | + .scaleEffect(pinchScale) | |
| 47 | + .opacity(pinchOpacity) | |
| 48 | + .animation(.spring(duration: 0.35), value: model.level) | |
| 49 | + } | |
| 50 | + .background(Color(.systemBackground)) | |
| 51 | + .gesture(zoomGesture) | |
| 52 | + .task { await model.load() } | |
| 53 | + } | |
| 54 | + | |
| 55 | + // MARK: - Header | |
| 56 | + | |
| 57 | + private var header: some View { | |
| 58 | + VStack(spacing: Spacing.s) { | |
| 59 | + HStack { | |
| 60 | + Button { | |
| 61 | + dismiss() | |
| 62 | + } label: { | |
| 63 | + Image(systemName: "xmark") | |
| 64 | + .font(.subheadline.weight(.semibold)) | |
| 65 | + .frame(width: 34, height: 34) | |
| 66 | + .background(.quaternary.opacity(0.5), in: Circle()) | |
| 67 | + } | |
| 68 | + .buttonStyle(.plain) | |
| 69 | + .accessibilityIdentifier("reader.close") | |
| 70 | + | |
| 71 | + Spacer() | |
| 72 | + | |
| 73 | + Text(model.title.isEmpty ? "Lecture" : model.title) | |
| 74 | + .font(.footnote.weight(.semibold)) | |
| 75 | + .lineLimit(1) | |
| 76 | + | |
| 77 | + Spacer() | |
| 78 | + | |
| 79 | + // Save the page as native data ("Le favori structuré", P0). | |
| 80 | + let saved = library.hasFavorite(for: model.tab.page.url) | |
| 81 | + Button { | |
| 82 | + if !saved { onSaveFavorite() } | |
| 83 | + } label: { | |
| 84 | + Image(systemName: saved ? "bookmark.fill" : "bookmark") | |
| 85 | + .font(.subheadline.weight(.semibold)) | |
| 86 | + .foregroundStyle(saved ? accent : .primary) | |
| 87 | + .frame(width: 34, height: 34) | |
| 88 | + .background(.quaternary.opacity(0.5), in: Circle()) | |
| 89 | + } | |
| 90 | + .buttonStyle(.plain) | |
| 91 | + .accessibilityIdentifier("reader.save") | |
| 92 | + } | |
| 93 | + | |
| 94 | + levelChips | |
| 95 | + } | |
| 96 | + .padding(.horizontal, Spacing.l) | |
| 97 | + .padding(.vertical, Spacing.s) | |
| 98 | + } | |
| 99 | + | |
| 100 | + private var levelChips: some View { | |
| 101 | + HStack(spacing: Spacing.s) { | |
| 102 | + ForEach(ReaderLevel.allCases, id: \.rawValue) { level in | |
| 103 | + let selected = model.level == level | |
| 104 | + Button { | |
| 105 | + withAnimation(.spring(duration: 0.35)) { model.level = level } | |
| 106 | + } label: { | |
| 107 | + Text(level.label) | |
| 108 | + .font(.caption.weight(selected ? .semibold : .regular)) | |
| 109 | + .foregroundStyle(selected ? .white : .primary) | |
| 110 | + .padding(.horizontal, Spacing.m) | |
| 111 | + .padding(.vertical, Spacing.s) | |
| 112 | + .background( | |
| 113 | + selected ? AnyShapeStyle(accent) : AnyShapeStyle(.quaternary.opacity(0.5)), | |
| 114 | + in: Capsule() | |
| 115 | + ) | |
| 116 | + } | |
| 117 | + .buttonStyle(.plain) | |
| 118 | + .accessibilityIdentifier("reader.level.\(level.label)") | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + // MARK: - Pinch = level of detail | |
| 124 | + | |
| 125 | + private var zoomGesture: some Gesture { | |
| 126 | + MagnifyGesture() | |
| 127 | + .updating($pinch) { value, state, _ in | |
| 128 | + state = value.magnification | |
| 129 | + } | |
| 130 | + .onEnded { value in | |
| 131 | + if value.magnification < 0.8 { | |
| 132 | + // Pinch in: condense. | |
| 133 | + if let next = ReaderLevel(rawValue: model.level.rawValue + 1) { | |
| 134 | + model.level = next | |
| 135 | + } | |
| 136 | + } else if value.magnification > 1.25 { | |
| 137 | + // Spread: more detail; past full text, the raw page. | |
| 138 | + if let previous = ReaderLevel(rawValue: model.level.rawValue - 1) { | |
| 139 | + model.level = previous | |
| 140 | + } else { | |
| 141 | + dismiss() | |
| 142 | + } | |
| 143 | + } | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + /// The text visibly contracts or expands while pinching (§5: the user | |
| 148 | + /// must see it happen, no screen jumps). | |
| 149 | + private var pinchScale: CGFloat { | |
| 150 | + min(max(pinch, 0.9), 1.1) | |
| 151 | + } | |
| 152 | + | |
| 153 | + private var pinchOpacity: Double { | |
| 154 | + let deviation = abs(pinch - 1) | |
| 155 | + return max(0.6, 1 - deviation * 0.8) | |
| 156 | + } | |
| 157 | + | |
| 158 | + // MARK: - Content | |
| 159 | + | |
| 160 | + @ViewBuilder | |
| 161 | + private var content: some View { | |
| 162 | + if model.extractionFailed { | |
| 163 | + ContentUnavailableView { | |
| 164 | + Label("Rien à lire ici", systemImage: "book") | |
| 165 | + } description: { | |
| 166 | + Text("Cette page ne contient pas assez de texte. La page d'origine reste affichée derrière.") | |
| 167 | + } | |
| 168 | + } else { | |
| 169 | + switch model.level { | |
| 170 | + case .full: fullText | |
| 171 | + case .condensed: condensedSections | |
| 172 | + case .outline: outline | |
| 173 | + case .gist: gistView | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + /// Article pages read in serif; everything else keeps the system face. | |
| 179 | + private var serif: Bool { model.digest?.kind == .article } | |
| 180 | + | |
| 181 | + private var fullText: some View { | |
| 182 | + ScrollViewReader { proxy in | |
| 183 | + ScrollView { | |
| 184 | + LazyVStack(alignment: .leading, spacing: Spacing.l) { | |
| 185 | + ForEach(Array(model.blocks.enumerated()), id: \.offset) { index, block in | |
| 186 | + BlockView(block: block, serif: serif) | |
| 187 | + .id(index) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + .padding(Spacing.xl) | |
| 191 | + .frame(maxWidth: 700, alignment: .leading) | |
| 192 | + .frame(maxWidth: .infinity) | |
| 193 | + } | |
| 194 | + .onAppear { | |
| 195 | + if let target = scrollTarget { | |
| 196 | + proxy.scrollTo(target, anchor: .top) | |
| 197 | + scrollTarget = nil | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + private var condensedSections: some View { | |
| 204 | + ScrollView { | |
| 205 | + LazyVStack(alignment: .leading, spacing: Spacing.m) { | |
| 206 | + ForEach(model.sections) { section in | |
| 207 | + let summary = model.summary(for: section) | |
| 208 | + Button { | |
| 209 | + jump(to: section) | |
| 210 | + } label: { | |
| 211 | + VStack(alignment: .leading, spacing: Spacing.s) { | |
| 212 | + Text(section.title) | |
| 213 | + .font(.headline) | |
| 214 | + .foregroundStyle(.primary) | |
| 215 | + .multilineTextAlignment(.leading) | |
| 216 | + if !summary.text.isEmpty { | |
| 217 | + summaryText(summary) | |
| 218 | + } | |
| 219 | + } | |
| 220 | + .padding(Spacing.l) | |
| 221 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 222 | + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.l)) | |
| 223 | + } | |
| 224 | + .buttonStyle(.plain) | |
| 225 | + } | |
| 226 | + } | |
| 227 | + .padding(Spacing.l) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + private var outline: some View { | |
| 232 | + ScrollView { | |
| 233 | + VStack(alignment: .leading, spacing: 0) { | |
| 234 | + ForEach(model.sections) { section in | |
| 235 | + Button { | |
| 236 | + jump(to: section) | |
| 237 | + } label: { | |
| 238 | + HStack(spacing: Spacing.m) { | |
| 239 | + Rectangle() | |
| 240 | + .fill(accent.opacity(0.6)) | |
| 241 | + .frame(width: 3, height: 18) | |
| 242 | + Text(section.title) | |
| 243 | + .font(section.level <= 1 ? .body.weight(.semibold) : .subheadline) | |
| 244 | + .foregroundStyle(.primary) | |
| 245 | + .multilineTextAlignment(.leading) | |
| 246 | + Spacer() | |
| 247 | + } | |
| 248 | + .padding(.leading, CGFloat(max(0, section.level - 1)) * Spacing.l) | |
| 249 | + .padding(.vertical, Spacing.m) | |
| 250 | + .contentShape(Rectangle()) | |
| 251 | + } | |
| 252 | + .buttonStyle(.plain) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + .padding(Spacing.xl) | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + private var gistView: some View { | |
| 260 | + VStack(spacing: Spacing.l) { | |
| 261 | + Spacer() | |
| 262 | + if let kind = model.digest?.kind { | |
| 263 | + Text(kind.label) | |
| 264 | + .font(.caption.weight(.semibold)) | |
| 265 | + .foregroundStyle(Self.generated) | |
| 266 | + .padding(.horizontal, Spacing.m) | |
| 267 | + .padding(.vertical, Spacing.xs) | |
| 268 | + .background(Self.generated.opacity(0.12), in: Capsule()) | |
| 269 | + } | |
| 270 | + Text(model.title) | |
| 271 | + .font(.footnote.weight(.semibold)) | |
| 272 | + .foregroundStyle(.secondary) | |
| 273 | + .multilineTextAlignment(.center) | |
| 274 | + summaryText(model.gist) | |
| 275 | + .font(.title2.weight(.medium)) | |
| 276 | + .multilineTextAlignment(.center) | |
| 277 | + Spacer() | |
| 278 | + Spacer() | |
| 279 | + } | |
| 280 | + .padding(Spacing.xl) | |
| 281 | + .frame(maxWidth: .infinity) | |
| 282 | + .accessibilityIdentifier("reader.gist") | |
| 283 | + } | |
| 284 | + | |
| 285 | + /// Generated text always wears the distinct treatment; deterministic | |
| 286 | + /// fallbacks look like ordinary interface text (§7). | |
| 287 | + @ViewBuilder | |
| 288 | + private func summaryText(_ summary: (text: String, generated: Bool)) -> some View { | |
| 289 | + if summary.generated { | |
| 290 | + HStack(alignment: .top, spacing: Spacing.s) { | |
| 291 | + Image(systemName: "sparkles") | |
| 292 | + .font(.caption) | |
| 293 | + .foregroundStyle(Self.generated) | |
| 294 | + .padding(.top, 3) | |
| 295 | + Text(summary.text) | |
| 296 | + .italic() | |
| 297 | + .foregroundStyle(.primary) | |
| 298 | + } | |
| 299 | + } else { | |
| 300 | + Text(summary.text) | |
| 301 | + .foregroundStyle(.secondary) | |
| 302 | + } | |
| 303 | + } | |
| 304 | + | |
| 305 | + private func jump(to section: ReaderSection) { | |
| 306 | + scrollTarget = section.blockRange.lowerBound | |
| 307 | + withAnimation(.spring(duration: 0.35)) { model.level = .full } | |
| 308 | + } | |
| 309 | +} | |
| 310 | + | |
| 311 | +// MARK: - Block rendering (adaptive by kind) | |
| 312 | + | |
| 313 | +private struct BlockView: View { | |
| 314 | + let block: ContentBlock | |
| 315 | + let serif: Bool | |
| 316 | + | |
| 317 | + var body: some View { | |
| 318 | + switch block.kind { | |
| 319 | + case .heading: | |
| 320 | + Text(block.text) | |
| 321 | + .font(headingFont) | |
| 322 | + .padding(.top, block.level <= 2 ? Spacing.m : Spacing.xs) | |
| 323 | + case .paragraph: | |
| 324 | + Text(block.text) | |
| 325 | + .font(serif ? .system(.body, design: .serif) : .body) | |
| 326 | + .lineSpacing(5) | |
| 327 | + case .listItem: | |
| 328 | + HStack(alignment: .top, spacing: Spacing.s) { | |
| 329 | + Text("•").foregroundStyle(.secondary) | |
| 330 | + Text(block.text) | |
| 331 | + .font(serif ? .system(.body, design: .serif) : .body) | |
| 332 | + .lineSpacing(4) | |
| 333 | + } | |
| 334 | + case .quote: | |
| 335 | + HStack(alignment: .top, spacing: Spacing.m) { | |
| 336 | + RoundedRectangle(cornerRadius: 2) | |
| 337 | + .fill(.quaternary) | |
| 338 | + .frame(width: 3) | |
| 339 | + Text(block.text) | |
| 340 | + .font(.system(.body, design: serif ? .serif : .default)) | |
| 341 | + .italic() | |
| 342 | + .foregroundStyle(.secondary) | |
| 343 | + } | |
| 344 | + case .code: | |
| 345 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 346 | + Text(block.text) | |
| 347 | + .font(.system(.callout, design: .monospaced)) | |
| 348 | + .padding(Spacing.m) | |
| 349 | + } | |
| 350 | + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: Radius.m)) | |
| 351 | + case .table: | |
| 352 | + Text(block.text) | |
| 353 | + .font(.system(.caption, design: .monospaced)) | |
| 354 | + .padding(Spacing.m) | |
| 355 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 356 | + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: Radius.m)) | |
| 357 | + case .caption: | |
| 358 | + Text(block.text) | |
| 359 | + .font(.caption) | |
| 360 | + .foregroundStyle(.secondary) | |
| 361 | + } | |
| 362 | + } | |
| 363 | + | |
| 364 | + private var headingFont: Font { | |
| 365 | + let design: Font.Design = serif ? .serif : .default | |
| 366 | + switch block.level { | |
| 367 | + case 1: return .system(.title, design: design).weight(.bold) | |
| 368 | + case 2: return .system(.title2, design: design).weight(.bold) | |
| 369 | + case 3: return .system(.title3, design: design).weight(.semibold) | |
| 370 | + default: return .system(.headline, design: design) | |
| 371 | + } | |
| 372 | + } | |
| 373 | +} | |
added
Prisme/Browser/Tabs/Tab.swift
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +// | |
| 2 | +// Tab.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | + | |
| 11 | +/// What Prisme understood of the current page, deterministically and | |
| 12 | +/// instantly — the visible proof that the browser read the page. The | |
| 13 | +/// digest enriches it when the local model delivers. | |
| 14 | +struct PageInsight: Equatable, Sendable { | |
| 15 | + let url: URL | |
| 16 | + let approxWords: Int | |
| 17 | + let sectionCount: Int | |
| 18 | + | |
| 19 | + var readingMinutes: Int { max(1, approxWords / 200) } | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// A browsing tab. Holds no `WKWebView` of its own — web views live in the | |
| 23 | +/// pool (CLAUDE.md §8) and are attached only while the tab is on screen. | |
| 24 | +/// Page state survives detachment through `interactionState`, which | |
| 25 | +/// preserves the back/forward history and scroll position. | |
| 26 | +@MainActor | |
| 27 | +@Observable | |
| 28 | +final class Tab: Identifiable { | |
| 29 | + let id = UUID() | |
| 30 | + let containerID: IdentityContainer.ID | |
| 31 | + | |
| 32 | + /// URL to load when the tab first gets a web view. Nil = start page. | |
| 33 | + var initialURL: URL? | |
| 34 | + | |
| 35 | + /// Live navigation state and commands, bridged from the engine. | |
| 36 | + let page = WebPageProxy() | |
| 37 | + | |
| 38 | + /// Opaque WebKit session state, captured when the tab goes off screen. | |
| 39 | + @ObservationIgnored var interactionState: Any? | |
| 40 | + | |
| 41 | + /// Set after each settled load; drives the understanding strip. | |
| 42 | + var insight: PageInsight? | |
| 43 | + /// The user dismissed the strip for the current page. | |
| 44 | + var insightDismissed = false | |
| 45 | + | |
| 46 | + let createdAt = Date() | |
| 47 | + var lastActivatedAt = Date() | |
| 48 | + | |
| 49 | + init(initialURL: URL?, containerID: IdentityContainer.ID) { | |
| 50 | + self.initialURL = initialURL | |
| 51 | + self.containerID = containerID | |
| 52 | + } | |
| 53 | + | |
| 54 | + var displayTitle: String { | |
| 55 | + if !page.title.isEmpty { return page.title } | |
| 56 | + if let host = (page.url ?? initialURL)?.host() { return host } | |
| 57 | + return "Nouvel onglet" | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// True while the tab has nothing to render — show the start page. | |
| 61 | + var isBlank: Bool { | |
| 62 | + page.url == nil && initialURL == nil && interactionState == nil | |
| 63 | + } | |
| 64 | +} | |
added
Prisme/Browser/Tabs/TabStore.swift
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// | |
| 2 | +// TabStore.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | + | |
| 11 | +/// Ordered collection of open tabs plus the active selection. | |
| 12 | +/// Grouping by intention (P0) will layer on top of this later — the store | |
| 13 | +/// itself stays a dumb, predictable list. | |
| 14 | +@MainActor | |
| 15 | +@Observable | |
| 16 | +final class TabStore { | |
| 17 | + private(set) var tabs: [Tab] = [] | |
| 18 | + var activeTabID: Tab.ID? | |
| 19 | + | |
| 20 | + var activeTab: Tab? { | |
| 21 | + guard let id = activeTabID else { return nil } | |
| 22 | + return tabs.first { $0.id == id } | |
| 23 | + } | |
| 24 | + | |
| 25 | + func tabs(in containerID: IdentityContainer.ID) -> [Tab] { | |
| 26 | + tabs.filter { $0.containerID == containerID } | |
| 27 | + } | |
| 28 | + | |
| 29 | + func mostRecentTab(in containerID: IdentityContainer.ID) -> Tab? { | |
| 30 | + tabs(in: containerID).max { $0.lastActivatedAt < $1.lastActivatedAt } | |
| 31 | + } | |
| 32 | + | |
| 33 | + @discardableResult | |
| 34 | + func open(_ url: URL?, in containerID: IdentityContainer.ID, activate: Bool = true) -> Tab { | |
| 35 | + let tab = Tab(initialURL: url, containerID: containerID) | |
| 36 | + tabs.append(tab) | |
| 37 | + if activate { | |
| 38 | + self.activate(tab) | |
| 39 | + } | |
| 40 | + return tab | |
| 41 | + } | |
| 42 | + | |
| 43 | + func activate(_ tab: Tab) { | |
| 44 | + tab.lastActivatedAt = Date() | |
| 45 | + activeTabID = tab.id | |
| 46 | + } | |
| 47 | + | |
| 48 | + func close(_ tab: Tab) { | |
| 49 | + guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } | |
| 50 | + tabs.remove(at: index) | |
| 51 | + guard activeTabID == tab.id else { return } | |
| 52 | + // Prefer a neighbour from the same container, else none. | |
| 53 | + let siblings = tabs(in: tab.containerID) | |
| 54 | + activeTabID = siblings.last?.id | |
| 55 | + } | |
| 56 | +} | |
added
Prisme/Design/DesignTokens.swift
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +// | |
| 2 | +// DesignTokens.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// Semantic color assigned to an identity container. Stored as a token | |
| 11 | +/// (not raw color values) so themes can reinterpret it later. | |
| 12 | +enum ContainerColor: String, Codable, CaseIterable, Sendable { | |
| 13 | + case bleu | |
| 14 | + case vert | |
| 15 | + case orange | |
| 16 | + case violet | |
| 17 | + | |
| 18 | + var color: Color { | |
| 19 | + switch self { | |
| 20 | + case .bleu: Color(red: 0.20, green: 0.45, blue: 1.00) | |
| 21 | + case .vert: Color(red: 0.18, green: 0.72, blue: 0.42) | |
| 22 | + case .orange: Color(red: 1.00, green: 0.56, blue: 0.15) | |
| 23 | + case .violet: Color(red: 0.55, green: 0.36, blue: 0.96) | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Lighter companion shade, for gradients. | |
| 28 | + var secondary: Color { | |
| 29 | + switch self { | |
| 30 | + case .bleu: Color(red: 0.35, green: 0.78, blue: 1.00) | |
| 31 | + case .vert: Color(red: 0.55, green: 0.90, blue: 0.55) | |
| 32 | + case .orange: Color(red: 1.00, green: 0.80, blue: 0.30) | |
| 33 | + case .violet: Color(red: 0.80, green: 0.50, blue: 1.00) | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + var gradient: LinearGradient { | |
| 38 | + LinearGradient( | |
| 39 | + colors: [color, secondary], | |
| 40 | + startPoint: .topLeading, | |
| 41 | + endPoint: .bottomTrailing | |
| 42 | + ) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +/// Spacing scale. Use these instead of magic numbers in views. | |
| 47 | +enum Spacing { | |
| 48 | + static let xs: CGFloat = 4 | |
| 49 | + static let s: CGFloat = 8 | |
| 50 | + static let m: CGFloat = 12 | |
| 51 | + static let l: CGFloat = 16 | |
| 52 | + static let xl: CGFloat = 24 | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Corner radius scale. | |
| 56 | +enum Radius { | |
| 57 | + static let s: CGFloat = 8 | |
| 58 | + static let m: CGFloat = 12 | |
| 59 | + static let l: CGFloat = 20 | |
| 60 | + static let xl: CGFloat = 26 | |
| 61 | +} | |
added
Prisme/Design/PrismMark.swift
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +// | |
| 2 | +// PrismMark.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// The Prisme logo, drawn in code so it adapts to light/dark and any size. | |
| 11 | +/// Same geometry as the app icon (Design/icon/prisme-icon.svg): a white beam | |
| 12 | +/// enters a glass prism and exits as a spectrum fan. | |
| 13 | +struct PrismMark: View { | |
| 14 | + var body: some View { | |
| 15 | + Canvas { context, size in | |
| 16 | + let s = min(size.width, size.height) / 1024 | |
| 17 | + | |
| 18 | + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { | |
| 19 | + CGPoint(x: x * s, y: y * s) | |
| 20 | + } | |
| 21 | + func polygon(_ pts: [(CGFloat, CGFloat)]) -> Path { | |
| 22 | + var path = Path() | |
| 23 | + path.move(to: point(pts[0].0, pts[0].1)) | |
| 24 | + for p in pts.dropFirst() { path.addLine(to: point(p.0, p.1)) } | |
| 25 | + path.closeSubpath() | |
| 26 | + return path | |
| 27 | + } | |
| 28 | + | |
| 29 | + // Spectrum fan (clipped by the canvas, like the icon). | |
| 30 | + let origin: (CGFloat, CGFloat) = (614, 470) | |
| 31 | + let boundaries: [(CGFloat, CGFloat)] = [ | |
| 32 | + (1211, 248), (1238, 348), (1250, 451), | |
| 33 | + (1244, 555), (1223, 657), (1185, 753), (1132, 843), | |
| 34 | + ] | |
| 35 | + let colors: [Color] = [ | |
| 36 | + Color(red: 1.00, green: 0.23, blue: 0.36), | |
| 37 | + Color(red: 1.00, green: 0.62, blue: 0.11), | |
| 38 | + Color(red: 1.00, green: 0.88, blue: 0.40), | |
| 39 | + Color(red: 0.24, green: 0.86, blue: 0.52), | |
| 40 | + Color(red: 0.22, green: 0.71, blue: 1.00), | |
| 41 | + Color(red: 0.55, green: 0.36, blue: 0.96), | |
| 42 | + ] | |
| 43 | + for (index, color) in colors.enumerated() { | |
| 44 | + let band = polygon([origin, boundaries[index], boundaries[index + 1]]) | |
| 45 | + context.fill(band, with: .color(color.opacity(0.95))) | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Incoming beam, stopping on the left face. Light stays white in | |
| 49 | + // both color schemes; a soft shadow keeps it visible on light | |
| 50 | + // backgrounds. | |
| 51 | + let beam = polygon([(82, 132), (142, 98), (398, 488), (372, 510)]) | |
| 52 | + context.drawLayer { layer in | |
| 53 | + layer.addFilter(.shadow(color: .black.opacity(0.22), radius: 7 * s, y: 2 * s)) | |
| 54 | + layer.fill(beam, with: .color(.white)) | |
| 55 | + } | |
| 56 | + context.stroke(beam, with: .color(.black.opacity(0.10)), lineWidth: 2 * s) | |
| 57 | + | |
| 58 | + // Internal refraction. | |
| 59 | + let inner = polygon([(386, 494), (614, 462), (614, 478), (390, 508)]) | |
| 60 | + context.fill(inner, with: .color(.white.opacity(0.9))) | |
| 61 | + context.stroke(inner, with: .color(.black.opacity(0.08)), lineWidth: 1.5 * s) | |
| 62 | + | |
| 63 | + // Glass triangle. | |
| 64 | + let prism = polygon([(512, 268), (292, 688), (732, 688)]) | |
| 65 | + context.fill(prism, with: .color(.primary.opacity(0.07))) | |
| 66 | + context.stroke( | |
| 67 | + prism, | |
| 68 | + with: .color(.primary.opacity(0.75)), | |
| 69 | + style: StrokeStyle(lineWidth: 10 * s, lineCap: .round, lineJoin: .round) | |
| 70 | + ) | |
| 71 | + } | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +#Preview { | |
| 76 | + PrismMark() | |
| 77 | + .frame(width: 160, height: 160) | |
| 78 | + .padding() | |
| 79 | +} | |
added
Prisme/Intelligence/Distiller/ContentBlock.swift
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// | |
| 2 | +// ContentBlock.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | + | |
| 10 | +/// A typed block produced by the deterministic DOM extraction (Distiller | |
| 11 | +/// step 2). `domPath` is the child-index path from `document.body` to the | |
| 12 | +/// source element — the anchor that lets every generated summary point back | |
| 13 | +/// to the real page (CLAUDE.md §4, the easiest constraint to forget). | |
| 14 | +struct ContentBlock: Codable, Hashable, Sendable { | |
| 15 | + enum Kind: String, Codable, Sendable { | |
| 16 | + case heading | |
| 17 | + case paragraph | |
| 18 | + case listItem | |
| 19 | + case quote | |
| 20 | + case code | |
| 21 | + case table | |
| 22 | + case caption | |
| 23 | + } | |
| 24 | + | |
| 25 | + let kind: Kind | |
| 26 | + /// Heading depth (1–6) for headings, 0 otherwise. | |
| 27 | + let level: Int | |
| 28 | + let text: String | |
| 29 | + let domPath: String | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// Everything the extractor returns for one page. | |
| 33 | +struct ExtractedContent: Codable, Sendable { | |
| 34 | + let title: String | |
| 35 | + let lang: String? | |
| 36 | + let blocks: [ContentBlock] | |
| 37 | + | |
| 38 | + /// First sentence of the first paragraph — the deterministic essence | |
| 39 | + /// used wherever the model has nothing better to offer. | |
| 40 | + var leadSentence: String? { | |
| 41 | + guard let paragraph = blocks.first(where: { $0.kind == .paragraph })?.text else { | |
| 42 | + return nil | |
| 43 | + } | |
| 44 | + if let end = paragraph.firstIndex(where: { ".!?".contains($0) }) { | |
| 45 | + return String(paragraph[...end]) | |
| 46 | + } | |
| 47 | + return String(paragraph.prefix(200)) | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// A text selection captured in the page, with its anchor and context. | |
| 52 | +struct SelectionExcerpt: Codable, Sendable { | |
| 53 | + let text: String | |
| 54 | + let domPath: String? | |
| 55 | + let section: String? | |
| 56 | +} | |
added
Prisme/Intelligence/Distiller/DigestCache.swift
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +// | |
| 2 | +// DigestCache.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import CryptoKit | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Content-addressed digest cache (CLAUDE.md §4: a page is never distilled | |
| 12 | +/// twice). Keys are hashes of the extracted text, so a reloaded page with | |
| 13 | +/// identical content is a hit even if the URL differs. Digests are durable | |
| 14 | +/// artefacts: kept in memory and on disk, ready to feed history and diffs. | |
| 15 | +actor DigestCache { | |
| 16 | + private struct Entry: Codable { | |
| 17 | + let digest: PageDigest | |
| 18 | + let blocks: [ContentBlock] | |
| 19 | + } | |
| 20 | + | |
| 21 | + private var memory: [String: Entry] = [:] | |
| 22 | + private let directory: URL | |
| 23 | + | |
| 24 | + init() { | |
| 25 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 26 | + directory = base.appendingPathComponent("Digests", isDirectory: true) | |
| 27 | + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| 28 | + } | |
| 29 | + | |
| 30 | + static func key(for content: ExtractedContent) -> String { | |
| 31 | + var hasher = SHA256() | |
| 32 | + hasher.update(data: Data(content.title.utf8)) | |
| 33 | + for block in content.blocks { | |
| 34 | + hasher.update(data: Data(block.text.utf8)) | |
| 35 | + } | |
| 36 | + return hasher.finalize().map { String(format: "%02x", $0) }.joined() | |
| 37 | + } | |
| 38 | + | |
| 39 | + func lookup(_ key: String) -> (PageDigest, [ContentBlock])? { | |
| 40 | + if let entry = memory[key] { | |
| 41 | + return (entry.digest, entry.blocks) | |
| 42 | + } | |
| 43 | + let file = directory.appendingPathComponent("\(key).json") | |
| 44 | + guard let data = try? Data(contentsOf: file), | |
| 45 | + let entry = try? JSONDecoder().decode(Entry.self, from: data) | |
| 46 | + else { return nil } | |
| 47 | + memory[key] = entry | |
| 48 | + return (entry.digest, entry.blocks) | |
| 49 | + } | |
| 50 | + | |
| 51 | + func store(_ digest: PageDigest, blocks: [ContentBlock], key: String) { | |
| 52 | + let entry = Entry(digest: digest, blocks: blocks) | |
| 53 | + memory[key] = entry | |
| 54 | + if memory.count > 64 { | |
| 55 | + // Cheap pressure valve; disk keeps the long tail. | |
| 56 | + memory.removeValue(forKey: memory.keys.first!) | |
| 57 | + } | |
| 58 | + let file = directory.appendingPathComponent("\(key).json") | |
| 59 | + if let data = try? JSONEncoder().encode(entry) { | |
| 60 | + try? data.write(to: file, options: .atomic) | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
added
Prisme/Intelligence/Distiller/Distiller.swift
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +// | |
| 2 | +// Distiller.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import FoundationModels | |
| 10 | + | |
| 11 | +/// HTML → compact structure (CLAUDE.md §4, the heart of the project). | |
| 12 | +/// Steps 1–2 (extraction, typed blocks) happen in the page via extractor.js. | |
| 13 | +/// This actor performs step 3 (token budgeting), a deterministic form of | |
| 14 | +/// step 4 (condensation), and step 5 (guided generation). Hierarchical | |
| 15 | +/// LLM condensation of low-priority sections comes later — the budgeter | |
| 16 | +/// currently trims deterministically, never mid-sentence. | |
| 17 | +actor Distiller { | |
| 18 | + enum DistillerError: LocalizedError { | |
| 19 | + case emptyPage | |
| 20 | + case overBudget | |
| 21 | + | |
| 22 | + var errorDescription: String? { | |
| 23 | + switch self { | |
| 24 | + case .emptyPage: "La page ne contient pas assez de texte lisible." | |
| 25 | + case .overBudget: "La page dépasse la capacité du modèle local." | |
| 26 | + } | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + private let cache = DigestCache() | |
| 31 | + | |
| 32 | + /// Fraction of the context window reserved for the model's response — | |
| 33 | + /// a prompt that fills the window leaves no room to answer (§2). | |
| 34 | + private static let responseReserve = 0.30 | |
| 35 | + | |
| 36 | + func digest( | |
| 37 | + _ content: ExtractedContent, | |
| 38 | + queue: InferenceQueue, | |
| 39 | + priority: InferencePriority | |
| 40 | + ) async throws -> (PageDigest, [ContentBlock]) { | |
| 41 | + guard !content.blocks.isEmpty else { throw DistillerError.emptyPage } | |
| 42 | + | |
| 43 | + let key = DigestCache.key(for: content) | |
| 44 | + if let hit = await cache.lookup(key) { | |
| 45 | + return hit | |
| 46 | + } | |
| 47 | + | |
| 48 | + let model = SystemLanguageModel.default | |
| 49 | + let instructions = """ | |
| 50 | + You are the reading engine of a browser. You receive a numbered list \ | |
| 51 | + of text blocks extracted from one web page. Produce the requested \ | |
| 52 | + structured digest using ONLY the provided text — never outside \ | |
| 53 | + knowledge, never invented facts. If the page does not support a \ | |
| 54 | + field, keep it minimal. Write summaries in the language of the page. \ | |
| 55 | + Every sourceBlock must be the index of a block from the list. | |
| 56 | + """ | |
| 57 | + | |
| 58 | + let (prompt, keptBlocks) = try await budgetedPrompt( | |
| 59 | + content: content, | |
| 60 | + model: model, | |
| 61 | + instructionsCost: try await model.tokenCount(for: instructions) | |
| 62 | + ) | |
| 63 | + | |
| 64 | + let digest = try await queue.run(priority) { | |
| 65 | + let session = LanguageModelSession(model: model, instructions: instructions) | |
| 66 | + let response = try await session.respond(to: prompt, generating: PageDigest.self) | |
| 67 | + return response.content | |
| 68 | + } | |
| 69 | + | |
| 70 | + await cache.store(digest, blocks: keptBlocks, key: key) | |
| 71 | + return (digest, keptBlocks) | |
| 72 | + } | |
| 73 | + | |
| 74 | + /// Step 3: measure and allocate. Headings are always kept (they are the | |
| 75 | + /// skeleton); body blocks are added by document order until the budget | |
| 76 | + /// is spent, long blocks trimmed at a sentence boundary. | |
| 77 | + private func budgetedPrompt( | |
| 78 | + content: ExtractedContent, | |
| 79 | + model: SystemLanguageModel, | |
| 80 | + instructionsCost: Int | |
| 81 | + ) async throws -> (String, [ContentBlock]) { | |
| 82 | + let contextSize = try await model.contextSize | |
| 83 | + let budget = Int(Double(contextSize) * (1 - Self.responseReserve)) - instructionsCost - 200 | |
| 84 | + guard budget > 300 else { throw DistillerError.overBudget } | |
| 85 | + | |
| 86 | + var lines: [String] = ["Page title: \(content.title)", "Blocks:"] | |
| 87 | + var kept: [ContentBlock] = [] | |
| 88 | + var spent = try await model.tokenCount(for: lines.joined(separator: "\n")) | |
| 89 | + | |
| 90 | + for block in content.blocks { | |
| 91 | + var text = block.text | |
| 92 | + if text.count > 600 { | |
| 93 | + text = trimToSentence(text, limit: 600) | |
| 94 | + } | |
| 95 | + let line = "[\(kept.count)] (\(block.kind.rawValue)) \(text)" | |
| 96 | + let cost = try await model.tokenCount(for: line) | |
| 97 | + if spent + cost > budget { | |
| 98 | + if block.kind == .heading { | |
| 99 | + // Headings squeeze in with a shorter form when possible. | |
| 100 | + let short = "[\(kept.count)] (heading) \(String(text.prefix(80)))" | |
| 101 | + let shortCost = try await model.tokenCount(for: short) | |
| 102 | + guard spent + shortCost <= budget else { break } | |
| 103 | + lines.append(short) | |
| 104 | + kept.append(block) | |
| 105 | + spent += shortCost | |
| 106 | + continue | |
| 107 | + } | |
| 108 | + continue | |
| 109 | + } | |
| 110 | + lines.append(line) | |
| 111 | + kept.append(block) | |
| 112 | + spent += cost | |
| 113 | + } | |
| 114 | + | |
| 115 | + guard !kept.isEmpty else { throw DistillerError.overBudget } | |
| 116 | + return (lines.joined(separator: "\n"), kept) | |
| 117 | + } | |
| 118 | + | |
| 119 | + private func trimToSentence(_ text: String, limit: Int) -> String { | |
| 120 | + guard text.count > limit else { return text } | |
| 121 | + let head = String(text.prefix(limit)) | |
| 122 | + if let cut = head.lastIndex(where: { ".!?".contains($0) }) { | |
| 123 | + return String(head[...cut]) | |
| 124 | + } | |
| 125 | + if let space = head.lastIndex(of: " ") { | |
| 126 | + return String(head[..<space]) + "…" | |
| 127 | + } | |
| 128 | + return head + "…" | |
| 129 | + } | |
| 130 | +} | |
added
Prisme/Intelligence/Distiller/Resources/extractor.js
+144 −0
@@ -0,0 +1,144 @@ | ||
| 1 | +// | |
| 2 | +// extractor.js — Prisme | |
| 3 | +// | |
| 4 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 5 | +// | |
| 6 | +// Deterministic DOM extraction (Distiller step 1, CLAUDE.md §4). | |
| 7 | +// Injected at documentEnd; defines functions only — nothing runs until | |
| 8 | +// the app asks. Never sends raw HTML anywhere: produces a compact block | |
| 9 | +// list with DOM paths so every derived element can be traced back. | |
| 10 | + | |
| 11 | +(function () { | |
| 12 | + "use strict"; | |
| 13 | + | |
| 14 | + var SKIP_TAGS = { | |
| 15 | + SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, IFRAME: 1, SVG: 1, CANVAS: 1, | |
| 16 | + NAV: 1, FOOTER: 1, ASIDE: 1, FORM: 1, BUTTON: 1, SELECT: 1, TEMPLATE: 1 | |
| 17 | + }; | |
| 18 | + var NOISE_RE = /(^|[-_ ])(ad|ads|advert|promo|banner|cookie|consent|gdpr|newsletter|popup|paywall|subscribe|share|social|sidebar|related|comment)s?([-_ ]|$)/i; | |
| 19 | + | |
| 20 | + function isNoise(el) { | |
| 21 | + var probe = (el.className && String(el.className)) + " " + (el.id || ""); | |
| 22 | + return NOISE_RE.test(probe); | |
| 23 | + } | |
| 24 | + | |
| 25 | + function isHidden(el) { | |
| 26 | + return el.getClientRects && el.getClientRects().length === 0; | |
| 27 | + } | |
| 28 | + | |
| 29 | + function linkDensity(el) { | |
| 30 | + var text = el.textContent || ""; | |
| 31 | + if (text.length === 0) return 1; | |
| 32 | + var linked = 0; | |
| 33 | + var anchors = el.getElementsByTagName("a"); | |
| 34 | + for (var i = 0; i < anchors.length; i++) linked += (anchors[i].textContent || "").length; | |
| 35 | + return linked / text.length; | |
| 36 | + } | |
| 37 | + | |
| 38 | + function domPath(el) { | |
| 39 | + var path = []; | |
| 40 | + var node = el; | |
| 41 | + while (node && node !== document.body) { | |
| 42 | + var parent = node.parentElement; | |
| 43 | + if (!parent) break; | |
| 44 | + path.unshift(Array.prototype.indexOf.call(parent.children, node)); | |
| 45 | + node = parent; | |
| 46 | + } | |
| 47 | + return path.join("/"); | |
| 48 | + } | |
| 49 | + | |
| 50 | + function clean(text) { | |
| 51 | + return text.replace(/\s+/g, " ").trim(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + function blockFor(el) { | |
| 55 | + var tag = el.tagName; | |
| 56 | + if (/^H[1-6]$/.test(tag)) return { kind: "heading", level: parseInt(tag[1], 10) }; | |
| 57 | + if (tag === "P") return { kind: "paragraph", level: 0 }; | |
| 58 | + if (tag === "LI") return { kind: "listItem", level: 0 }; | |
| 59 | + if (tag === "BLOCKQUOTE") return { kind: "quote", level: 0 }; | |
| 60 | + if (tag === "PRE") return { kind: "code", level: 0 }; | |
| 61 | + if (tag === "TABLE") return { kind: "table", level: 0 }; | |
| 62 | + if (tag === "FIGCAPTION") return { kind: "caption", level: 0 }; | |
| 63 | + return null; | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Depth-first walk. A captured block's subtree is not descended into, so | |
| 67 | + // nested text is never emitted twice. | |
| 68 | + function walk(el, blocks, maxBlocks) { | |
| 69 | + if (blocks.length >= maxBlocks) return; | |
| 70 | + if (SKIP_TAGS[el.tagName] || isNoise(el) || isHidden(el)) return; | |
| 71 | + | |
| 72 | + var spec = blockFor(el); | |
| 73 | + if (spec) { | |
| 74 | + if (spec.kind === "heading" || linkDensity(el) <= 0.5) { | |
| 75 | + var text = clean(spec.kind === "table" ? (el.innerText || "") : (el.textContent || "")); | |
| 76 | + if (text.length >= (spec.kind === "heading" ? 2 : 25)) { | |
| 77 | + if (text.length > 2000) text = text.slice(0, 2000) + "…"; | |
| 78 | + blocks.push({ kind: spec.kind, level: spec.level, text: text, domPath: domPath(el) }); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + | |
| 84 | + for (var i = 0; i < el.children.length; i++) { | |
| 85 | + walk(el.children[i], blocks, maxBlocks); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + window.__prismeExtract = function (maxBlocks) { | |
| 90 | + var blocks = []; | |
| 91 | + walk(document.body, blocks, maxBlocks || 400); | |
| 92 | + return JSON.stringify({ | |
| 93 | + title: clean(document.title || ""), | |
| 94 | + lang: document.documentElement.lang || null, | |
| 95 | + blocks: blocks | |
| 96 | + }); | |
| 97 | + }; | |
| 98 | + | |
| 99 | + // Current text selection with its source anchor and section context — | |
| 100 | + // everything an excerpt needs (CLAUDE.md §5, "L'extrait", P0). | |
| 101 | + window.__prismeSelection = function () { | |
| 102 | + var sel = document.getSelection(); | |
| 103 | + if (!sel || sel.isCollapsed) return null; | |
| 104 | + var text = sel.toString().replace(/\s+/g, " ").trim(); | |
| 105 | + if (!text) return null; | |
| 106 | + if (text.length > 2000) text = text.slice(0, 2000) + "…"; | |
| 107 | + | |
| 108 | + var node = sel.anchorNode; | |
| 109 | + var el = node ? (node.nodeType === 1 ? node : node.parentElement) : null; | |
| 110 | + var path = el ? domPath(el) : null; | |
| 111 | + | |
| 112 | + // Nearest heading above the selection gives the section context. | |
| 113 | + var heading = null; | |
| 114 | + var probe = el; | |
| 115 | + while (probe && probe !== document.body && !heading) { | |
| 116 | + var sibling = probe; | |
| 117 | + while ((sibling = sibling.previousElementSibling)) { | |
| 118 | + if (/^H[1-6]$/.test(sibling.tagName)) { heading = sibling.textContent; break; } | |
| 119 | + } | |
| 120 | + probe = probe.parentElement; | |
| 121 | + } | |
| 122 | + | |
| 123 | + return JSON.stringify({ | |
| 124 | + text: text, | |
| 125 | + domPath: path, | |
| 126 | + section: heading ? clean(heading) : null | |
| 127 | + }); | |
| 128 | + }; | |
| 129 | + | |
| 130 | + window.__prismeScrollTo = function (path) { | |
| 131 | + var node = document.body; | |
| 132 | + if (path) { | |
| 133 | + var parts = path.split("/"); | |
| 134 | + for (var i = 0; i < parts.length && node; i++) { | |
| 135 | + node = node.children[parseInt(parts[i], 10)]; | |
| 136 | + } | |
| 137 | + } | |
| 138 | + if (node && node.scrollIntoView) { | |
| 139 | + node.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 140 | + return true; | |
| 141 | + } | |
| 142 | + return false; | |
| 143 | + }; | |
| 144 | +})(); | |
added
Prisme/Intelligence/IntelligenceCenter.swift
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +// | |
| 2 | +// IntelligenceCenter.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import FoundationModels | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// Entry point of the intelligence layer for the rest of the app. | |
| 13 | +/// Owns the router and the distiller; publishes digest state per tab. | |
| 14 | +/// Everything degrades: on devices without Foundation Models the states | |
| 15 | +/// are `.unavailable` with calm copy and the browser stays fully usable. | |
| 16 | +@MainActor | |
| 17 | +@Observable | |
| 18 | +final class IntelligenceCenter { | |
| 19 | + let router = ModelRouter() | |
| 20 | + private let distiller = Distiller() | |
| 21 | + | |
| 22 | + private(set) var digestStates: [Tab.ID: DigestState] = [:] | |
| 23 | + /// URL each digest was computed for — a navigation makes it stale. | |
| 24 | + private var digestURLs: [Tab.ID: URL] = [:] | |
| 25 | + | |
| 26 | + func digestState(for tab: Tab) -> DigestState { | |
| 27 | + digestStates[tab.id] ?? .idle | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// Digest of a page (tier `local`). User gestures preempt the | |
| 31 | + /// automatic post-load enrichment. Cached content is returned without | |
| 32 | + /// touching the model. | |
| 33 | + func requestDigest(for tab: Tab, priority: InferencePriority = .userGesture) { | |
| 34 | + switch digestState(for: tab) { | |
| 35 | + case .working: | |
| 36 | + return | |
| 37 | + case .ready where digestURLs[tab.id] == tab.page.url: | |
| 38 | + return | |
| 39 | + case .idle, .failed, .unavailable, .ready: | |
| 40 | + break | |
| 41 | + } | |
| 42 | + digestURLs[tab.id] = tab.page.url | |
| 43 | + | |
| 44 | + router.refreshAvailability() | |
| 45 | + guard case .available = router.local else { | |
| 46 | + if case .unavailable(let reason) = router.local { | |
| 47 | + digestStates[tab.id] = .unavailable(reason) | |
| 48 | + } | |
| 49 | + return | |
| 50 | + } | |
| 51 | + | |
| 52 | + digestStates[tab.id] = .working | |
| 53 | + let queue = router.queue | |
| 54 | + let distiller = distiller | |
| 55 | + let page = tab.page | |
| 56 | + let tabID = tab.id | |
| 57 | + | |
| 58 | + Task { [weak self] in | |
| 59 | + do { | |
| 60 | + let content = try await page.extractContent() | |
| 61 | + let (digest, blocks) = try await distiller.digest( | |
| 62 | + content, | |
| 63 | + queue: queue, | |
| 64 | + priority: priority | |
| 65 | + ) | |
| 66 | + self?.digestStates[tabID] = .ready(digest, blocks: blocks) | |
| 67 | + } catch let error as LanguageModelSession.GenerationError { | |
| 68 | + // Guardrail refusals are a normal state, not an alarm (§7). | |
| 69 | + self?.digestStates[tabID] = .failed(Self.describe(error)) | |
| 70 | + } catch let error as Distiller.DistillerError { | |
| 71 | + self?.digestStates[tabID] = .failed( | |
| 72 | + error.errorDescription ?? Self.genericFailure | |
| 73 | + ) | |
| 74 | + } catch { | |
| 75 | + // Raw system errors never reach the user (§7); the page | |
| 76 | + // stays fully readable either way. | |
| 77 | + self?.digestStates[tabID] = .failed(Self.genericFailure) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + func invalidateDigest(for tab: Tab) { | |
| 83 | + digestStates[tab.id] = nil | |
| 84 | + } | |
| 85 | + | |
| 86 | + private static let genericFailure = | |
| 87 | + "Le modèle local n'a pas pu traiter cette page pour le moment. La page reste entièrement lisible." | |
| 88 | + | |
| 89 | + private static func describe(_ error: LanguageModelSession.GenerationError) -> String { | |
| 90 | + switch error { | |
| 91 | + case .guardrailViolation: | |
| 92 | + "Le modèle a préféré ne pas résumer cette page. La page reste entièrement lisible." | |
| 93 | + case .exceededContextWindowSize: | |
| 94 | + "La page est trop longue pour le modèle local." | |
| 95 | + default: | |
| 96 | + "Le résumé n'a pas abouti. La page reste entièrement lisible." | |
| 97 | + } | |
| 98 | + } | |
| 99 | +} | |
added
Prisme/Intelligence/Router/ModelRouter.swift
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +// | |
| 2 | +// ModelRouter.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import FoundationModels | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// Effort level a task declares. The router decides what actually runs — | |
| 13 | +/// no other code calls a model directly (CLAUDE.md §3). | |
| 14 | +enum Tier { | |
| 15 | + /// Pure heuristics, zero AI. The first choice whenever it suffices. | |
| 16 | + case none | |
| 17 | + /// On-device Foundation Models: free, unlimited, offline. | |
| 18 | + case local | |
| 19 | + /// Private Cloud Compute: explicit user action only, never on | |
| 20 | + /// sensitive-container content. Not wired yet (P2). | |
| 21 | + case cloud | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Availability of the on-device model, with a user-presentable reason. | |
| 25 | +/// AI is an enhancement: Prisme must remain a great browser without it — | |
| 26 | +/// never an error screen, never a dead button (CLAUDE.md §2). | |
| 27 | +enum LocalModelAvailability: Equatable { | |
| 28 | + case available | |
| 29 | + case unavailable(reason: String) | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// Single owner of model availability and session creation. | |
| 33 | +@MainActor | |
| 34 | +@Observable | |
| 35 | +final class ModelRouter { | |
| 36 | + private(set) var local: LocalModelAvailability = .unavailable(reason: "") | |
| 37 | + | |
| 38 | + /// Serializes on-device inference (Neural Engine runs requests in | |
| 39 | + /// series; more than 2 in flight only builds a hidden queue). | |
| 40 | + let queue = InferenceQueue(maxConcurrent: 2) | |
| 41 | + | |
| 42 | + init() { | |
| 43 | + refreshAvailability() | |
| 44 | + } | |
| 45 | + | |
| 46 | + var isLocalAvailable: Bool { local == .available } | |
| 47 | + | |
| 48 | + func refreshAvailability() { | |
| 49 | + switch SystemLanguageModel.default.availability { | |
| 50 | + case .available: | |
| 51 | + local = .available | |
| 52 | + case .unavailable(let reason): | |
| 53 | + local = .unavailable(reason: Self.describe(reason)) | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + private static func describe(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { | |
| 58 | + switch reason { | |
| 59 | + case .deviceNotEligible: | |
| 60 | + "Cet appareil ne prend pas en charge l'intelligence locale. Prisme reste un navigateur complet." | |
| 61 | + case .appleIntelligenceNotEnabled: | |
| 62 | + "Apple Intelligence est désactivé. Activez-le dans Réglages pour les résumés locaux." | |
| 63 | + case .modelNotReady: | |
| 64 | + "Le modèle local se prépare (téléchargement en cours). Réessayez dans quelques minutes." | |
| 65 | + @unknown default: | |
| 66 | + "L'intelligence locale n'est pas disponible pour le moment." | |
| 67 | + } | |
| 68 | + } | |
| 69 | +} | |
added
Prisme/Intelligence/Schemas/PageDigest.swift
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +// | |
| 2 | +// PageDigest.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import FoundationModels | |
| 10 | + | |
| 11 | +/// Structured output of the Distiller — never free-form text to parse | |
| 12 | +/// (CLAUDE.md §9). Every generated element carries the index of the source | |
| 13 | +/// content block it came from, so the UI can always anchor a summary back | |
| 14 | +/// to the real DOM element (§7: a summary without an anchor is not shown). | |
| 15 | +@Generable | |
| 16 | +enum PageKind: String, Codable, Sendable { | |
| 17 | + case article | |
| 18 | + case documentation | |
| 19 | + case forum | |
| 20 | + case boutique | |
| 21 | + case application | |
| 22 | + case portail | |
| 23 | + case formulaire | |
| 24 | + case autre | |
| 25 | +} | |
| 26 | + | |
| 27 | +extension PageKind { | |
| 28 | + /// User-facing label (French, per product conventions). | |
| 29 | + var label: String { | |
| 30 | + switch self { | |
| 31 | + case .article: "Article" | |
| 32 | + case .documentation: "Documentation" | |
| 33 | + case .forum: "Forum" | |
| 34 | + case .boutique: "Boutique" | |
| 35 | + case .application: "Application" | |
| 36 | + case .portail: "Portail" | |
| 37 | + case .formulaire: "Formulaire" | |
| 38 | + case .autre: "Page" | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +@Generable | |
| 44 | +struct DigestSection: Codable, Hashable, Sendable { | |
| 45 | + @Guide(description: "Section heading as it appears in the document") | |
| 46 | + let title: String | |
| 47 | + | |
| 48 | + @Guide(description: "One-sentence summary of the section, based only on the provided text") | |
| 49 | + let summary: String | |
| 50 | + | |
| 51 | + @Guide(description: "Index of the source block where this section starts, from the numbered list") | |
| 52 | + let sourceBlock: Int | |
| 53 | +} | |
| 54 | + | |
| 55 | +@Generable | |
| 56 | +struct DigestClaim: Codable, Hashable, Sendable { | |
| 57 | + @Guide(description: "A dated or numeric factual statement quoted or closely paraphrased from the page") | |
| 58 | + let text: String | |
| 59 | + | |
| 60 | + @Guide(description: "Index of the source block containing this statement") | |
| 61 | + let sourceBlock: Int | |
| 62 | +} | |
| 63 | + | |
| 64 | +@Generable | |
| 65 | +struct PageDigest: Codable, Hashable, Sendable { | |
| 66 | + @Guide(description: "Type of page") | |
| 67 | + let kind: PageKind | |
| 68 | + | |
| 69 | + @Guide(description: "Answer to the page's implicit question, maximum two sentences") | |
| 70 | + let gist: String | |
| 71 | + | |
| 72 | + @Guide(description: "Main sections in document order", .count(1...8)) | |
| 73 | + let outline: [DigestSection] | |
| 74 | + | |
| 75 | + @Guide(description: "Up to five dated or numeric statements from the page", .count(0...5)) | |
| 76 | + let claims: [DigestClaim] | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// Lifecycle of a digest for a given tab. Model refusals and failures are | |
| 80 | +/// normal states with calm copy — the raw page is always available (§7). | |
| 81 | +enum DigestState { | |
| 82 | + case idle | |
| 83 | + case working | |
| 84 | + case ready(PageDigest, blocks: [ContentBlock]) | |
| 85 | + case unavailable(String) | |
| 86 | + case failed(String) | |
| 87 | +} | |
added
Prisme/Intelligence/Sessions/InferenceQueue.swift
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +// | |
| 2 | +// InferenceQueue.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | + | |
| 10 | +/// Priority of an inference request. User gestures preempt page enrichment, | |
| 11 | +/// which preempts background work (CLAUDE.md §8). | |
| 12 | +enum InferencePriority: Int, Comparable, Sendable { | |
| 13 | + case background = 0 | |
| 14 | + case activePage = 1 | |
| 15 | + case userGesture = 2 | |
| 16 | + | |
| 17 | + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } | |
| 18 | +} | |
| 19 | + | |
| 20 | +/// Caps in-flight model requests and wakes waiters by priority. The Neural | |
| 21 | +/// Engine serializes inference anyway — an unbounded fan-out only hides the | |
| 22 | +/// real queue and wastes energy. | |
| 23 | +actor InferenceQueue { | |
| 24 | + private let maxConcurrent: Int | |
| 25 | + private var inFlight = 0 | |
| 26 | + private var waiters: [(priority: InferencePriority, order: Int, continuation: CheckedContinuation<Void, Never>)] = [] | |
| 27 | + private var counter = 0 | |
| 28 | + | |
| 29 | + init(maxConcurrent: Int) { | |
| 30 | + self.maxConcurrent = maxConcurrent | |
| 31 | + } | |
| 32 | + | |
| 33 | + func run<T: Sendable>( | |
| 34 | + _ priority: InferencePriority, | |
| 35 | + operation: @Sendable () async throws -> T | |
| 36 | + ) async rethrows -> T { | |
| 37 | + await acquire(priority) | |
| 38 | + defer { release() } | |
| 39 | + return try await operation() | |
| 40 | + } | |
| 41 | + | |
| 42 | + private func acquire(_ priority: InferencePriority) async { | |
| 43 | + if inFlight < maxConcurrent { | |
| 44 | + inFlight += 1 | |
| 45 | + return | |
| 46 | + } | |
| 47 | + counter += 1 | |
| 48 | + let order = counter | |
| 49 | + await withCheckedContinuation { continuation in | |
| 50 | + waiters.append((priority, order, continuation)) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + private func release() { | |
| 55 | + if let index = waiters.indices.max(by: { lhs, rhs in | |
| 56 | + (waiters[lhs].priority, -waiters[lhs].order) < (waiters[rhs].priority, -waiters[rhs].order) | |
| 57 | + }) { | |
| 58 | + let next = waiters.remove(at: index) | |
| 59 | + next.continuation.resume() | |
| 60 | + } else { | |
| 61 | + inFlight -= 1 | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} | |
added
Prisme/Library/LibraryModels.swift
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +// | |
| 2 | +// LibraryModels.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import SwiftData | |
| 10 | + | |
| 11 | +/// A saved passage — most of the time you don't want the page, you want | |
| 12 | +/// the paragraph (CLAUDE.md §5, "L'extrait", P0). Stored with its source, | |
| 13 | +/// date and section context; the DOM path anchors it back in the page. | |
| 14 | +@Model | |
| 15 | +final class Excerpt { | |
| 16 | + var text: String | |
| 17 | + var sourceURLString: String | |
| 18 | + var sourceTitle: String | |
| 19 | + var sourceHost: String | |
| 20 | + var sectionTitle: String? | |
| 21 | + var domPath: String? | |
| 22 | + var containerID: UUID | |
| 23 | + var savedAt: Date | |
| 24 | + | |
| 25 | + init( | |
| 26 | + text: String, | |
| 27 | + sourceURLString: String, | |
| 28 | + sourceTitle: String, | |
| 29 | + sourceHost: String, | |
| 30 | + sectionTitle: String?, | |
| 31 | + domPath: String?, | |
| 32 | + containerID: UUID | |
| 33 | + ) { | |
| 34 | + self.text = text | |
| 35 | + self.sourceURLString = sourceURLString | |
| 36 | + self.sourceTitle = sourceTitle | |
| 37 | + self.sourceHost = sourceHost | |
| 38 | + self.sectionTitle = sectionTitle | |
| 39 | + self.domPath = domPath | |
| 40 | + self.containerID = containerID | |
| 41 | + self.savedAt = Date() | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// A page saved as native data, freed from its layout ("Le favori | |
| 46 | +/// structuré", P0). Enriched by the digest when the model delivered one; | |
| 47 | +/// deterministic (title, lead sentence, headings) otherwise. Not a rotting | |
| 48 | +/// URL pointer: the structure is the favourite. | |
| 49 | +@Model | |
| 50 | +final class StructuredFavorite { | |
| 51 | + @Attribute(.unique) var urlString: String | |
| 52 | + var host: String | |
| 53 | + var title: String | |
| 54 | + var gist: String | |
| 55 | + /// PageKind raw value when a digest classified the page. | |
| 56 | + var kindRaw: String? | |
| 57 | + var outlineTitles: [String] | |
| 58 | + /// True when gist/outline came from the local model — drives the | |
| 59 | + /// distinct "generated" treatment (§7). | |
| 60 | + var isGenerated: Bool | |
| 61 | + var containerID: UUID | |
| 62 | + var savedAt: Date | |
| 63 | + | |
| 64 | + init( | |
| 65 | + urlString: String, | |
| 66 | + host: String, | |
| 67 | + title: String, | |
| 68 | + gist: String, | |
| 69 | + kindRaw: String?, | |
| 70 | + outlineTitles: [String], | |
| 71 | + isGenerated: Bool, | |
| 72 | + containerID: UUID | |
| 73 | + ) { | |
| 74 | + self.urlString = urlString | |
| 75 | + self.host = host | |
| 76 | + self.title = title | |
| 77 | + self.gist = gist | |
| 78 | + self.kindRaw = kindRaw | |
| 79 | + self.outlineTitles = outlineTitles | |
| 80 | + self.isGenerated = isGenerated | |
| 81 | + self.containerID = containerID | |
| 82 | + self.savedAt = Date() | |
| 83 | + } | |
| 84 | +} | |
added
Prisme/Library/LibraryStore.swift
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +// | |
| 2 | +// LibraryStore.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | +import SwiftData | |
| 11 | + | |
| 12 | +/// Owns excerpts and structured favourites. Separate store file from the | |
| 13 | +/// history — each SwiftData container needs its own URL or they fight | |
| 14 | +/// over the default store. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class LibraryStore { | |
| 18 | + private let container: ModelContainer | |
| 19 | + /// Bumped on every mutation so observing views refresh their fetches. | |
| 20 | + private var revision = 0 | |
| 21 | + | |
| 22 | + private var context: ModelContext { container.mainContext } | |
| 23 | + | |
| 24 | + init() { | |
| 25 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 26 | + let schema = Schema([Excerpt.self, StructuredFavorite.self]) | |
| 27 | + do { | |
| 28 | + let config = ModelConfiguration(url: base.appendingPathComponent("library.store")) | |
| 29 | + container = try ModelContainer(for: schema, configurations: config) | |
| 30 | + } catch { | |
| 31 | + let memoryOnly = ModelConfiguration(isStoredInMemoryOnly: true) | |
| 32 | + container = try! ModelContainer(for: schema, configurations: memoryOnly) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + // MARK: - Excerpts | |
| 37 | + | |
| 38 | + var excerpts: [Excerpt] { | |
| 39 | + _ = revision | |
| 40 | + let descriptor = FetchDescriptor<Excerpt>( | |
| 41 | + sortBy: [SortDescriptor(\.savedAt, order: .reverse)] | |
| 42 | + ) | |
| 43 | + return (try? context.fetch(descriptor)) ?? [] | |
| 44 | + } | |
| 45 | + | |
| 46 | + func saveExcerpt( | |
| 47 | + _ selection: SelectionExcerpt, | |
| 48 | + url: URL?, | |
| 49 | + pageTitle: String, | |
| 50 | + containerID: UUID | |
| 51 | + ) { | |
| 52 | + context.insert(Excerpt( | |
| 53 | + text: selection.text, | |
| 54 | + sourceURLString: url?.absoluteString ?? "", | |
| 55 | + sourceTitle: pageTitle, | |
| 56 | + sourceHost: url?.host() ?? "", | |
| 57 | + sectionTitle: selection.section, | |
| 58 | + domPath: selection.domPath, | |
| 59 | + containerID: containerID | |
| 60 | + )) | |
| 61 | + persist() | |
| 62 | + } | |
| 63 | + | |
| 64 | + func delete(_ excerpt: Excerpt) { | |
| 65 | + context.delete(excerpt) | |
| 66 | + persist() | |
| 67 | + } | |
| 68 | + | |
| 69 | + // MARK: - Structured favourites | |
| 70 | + | |
| 71 | + var favorites: [StructuredFavorite] { | |
| 72 | + _ = revision | |
| 73 | + let descriptor = FetchDescriptor<StructuredFavorite>( | |
| 74 | + sortBy: [SortDescriptor(\.savedAt, order: .reverse)] | |
| 75 | + ) | |
| 76 | + return (try? context.fetch(descriptor)) ?? [] | |
| 77 | + } | |
| 78 | + | |
| 79 | + func hasFavorite(for url: URL?) -> Bool { | |
| 80 | + guard let urlString = url?.absoluteString else { return false } | |
| 81 | + _ = revision | |
| 82 | + var descriptor = FetchDescriptor<StructuredFavorite>( | |
| 83 | + predicate: #Predicate { $0.urlString == urlString } | |
| 84 | + ) | |
| 85 | + descriptor.fetchLimit = 1 | |
| 86 | + return ((try? context.fetch(descriptor))?.first) != nil | |
| 87 | + } | |
| 88 | + | |
| 89 | + func saveFavorite( | |
| 90 | + url: URL, | |
| 91 | + title: String, | |
| 92 | + gist: String, | |
| 93 | + kindRaw: String?, | |
| 94 | + outlineTitles: [String], | |
| 95 | + isGenerated: Bool, | |
| 96 | + containerID: UUID | |
| 97 | + ) { | |
| 98 | + let urlString = url.absoluteString | |
| 99 | + var descriptor = FetchDescriptor<StructuredFavorite>( | |
| 100 | + predicate: #Predicate { $0.urlString == urlString } | |
| 101 | + ) | |
| 102 | + descriptor.fetchLimit = 1 | |
| 103 | + if let existing = (try? context.fetch(descriptor))?.first { | |
| 104 | + existing.title = title | |
| 105 | + existing.gist = gist | |
| 106 | + existing.kindRaw = kindRaw | |
| 107 | + existing.outlineTitles = outlineTitles | |
| 108 | + existing.isGenerated = isGenerated | |
| 109 | + existing.savedAt = Date() | |
| 110 | + } else { | |
| 111 | + context.insert(StructuredFavorite( | |
| 112 | + urlString: urlString, | |
| 113 | + host: url.host() ?? "", | |
| 114 | + title: title, | |
| 115 | + gist: gist, | |
| 116 | + kindRaw: kindRaw, | |
| 117 | + outlineTitles: outlineTitles, | |
| 118 | + isGenerated: isGenerated, | |
| 119 | + containerID: containerID | |
| 120 | + )) | |
| 121 | + } | |
| 122 | + persist() | |
| 123 | + } | |
| 124 | + | |
| 125 | + func delete(_ favorite: StructuredFavorite) { | |
| 126 | + context.delete(favorite) | |
| 127 | + persist() | |
| 128 | + } | |
| 129 | + | |
| 130 | + private func persist() { | |
| 131 | + try? context.save() | |
| 132 | + revision += 1 | |
| 133 | + } | |
| 134 | +} | |
added
Prisme/Library/LibraryView.swift
+200 −0
@@ -0,0 +1,200 @@ | ||
| 1 | +// | |
| 2 | +// LibraryView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// The library: saved excerpts and structured favourites. Content saved | |
| 11 | +/// here is native data with a source anchor — not a list of rotting URLs. | |
| 12 | +struct LibraryView: View { | |
| 13 | + let library: LibraryStore | |
| 14 | + let containers: IdentityContainerStore | |
| 15 | + let onOpen: (URL, IdentityContainer.ID) -> Void | |
| 16 | + | |
| 17 | + @Environment(\.dismiss) private var dismiss | |
| 18 | + @State private var section: Section = .excerpts | |
| 19 | + | |
| 20 | + private static let generated = Color(red: 0.55, green: 0.36, blue: 0.96) | |
| 21 | + | |
| 22 | + enum Section: String, CaseIterable { | |
| 23 | + case excerpts = "Extraits" | |
| 24 | + case pages = "Pages" | |
| 25 | + } | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + NavigationStack { | |
| 29 | + VStack(spacing: 0) { | |
| 30 | + Picker("Section", selection: $section) { | |
| 31 | + ForEach(Section.allCases, id: \.self) { section in | |
| 32 | + Text(section.rawValue).tag(section) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + .pickerStyle(.segmented) | |
| 36 | + .padding(.horizontal, Spacing.l) | |
| 37 | + .padding(.bottom, Spacing.s) | |
| 38 | + | |
| 39 | + switch section { | |
| 40 | + case .excerpts: excerptList | |
| 41 | + case .pages: favoriteList | |
| 42 | + } | |
| 43 | + } | |
| 44 | + .navigationTitle("Bibliothèque") | |
| 45 | + .navigationBarTitleDisplayMode(.inline) | |
| 46 | + .toolbar { | |
| 47 | + ToolbarItem(placement: .cancellationAction) { | |
| 48 | + Button("Fermer") { dismiss() } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + // MARK: - Excerpts | |
| 55 | + | |
| 56 | + @ViewBuilder | |
| 57 | + private var excerptList: some View { | |
| 58 | + let excerpts = library.excerpts | |
| 59 | + if excerpts.isEmpty { | |
| 60 | + ContentUnavailableView { | |
| 61 | + Label("Aucun extrait", systemImage: "quote.opening") | |
| 62 | + } description: { | |
| 63 | + Text("Sélectionnez un passage dans une page, puis « Sauver l'extrait ». C'est le passage qui est gardé, avec sa source.") | |
| 64 | + } | |
| 65 | + } else { | |
| 66 | + List { | |
| 67 | + ForEach(excerpts, id: \.persistentModelID) { excerpt in | |
| 68 | + excerptRow(excerpt) | |
| 69 | + .swipeActions { | |
| 70 | + Button(role: .destructive) { | |
| 71 | + library.delete(excerpt) | |
| 72 | + } label: { | |
| 73 | + Label("Supprimer", systemImage: "trash") | |
| 74 | + } | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + .listStyle(.plain) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + private func excerptRow(_ excerpt: Excerpt) -> some View { | |
| 83 | + Button { | |
| 84 | + open(excerpt.sourceURLString, containerID: excerpt.containerID) | |
| 85 | + } label: { | |
| 86 | + VStack(alignment: .leading, spacing: Spacing.s) { | |
| 87 | + HStack(alignment: .top, spacing: Spacing.m) { | |
| 88 | + RoundedRectangle(cornerRadius: 2) | |
| 89 | + .fill(color(for: excerpt.containerID)) | |
| 90 | + .frame(width: 3) | |
| 91 | + Text(excerpt.text) | |
| 92 | + .font(.callout) | |
| 93 | + .foregroundStyle(.primary) | |
| 94 | + .lineLimit(5) | |
| 95 | + } | |
| 96 | + HStack(spacing: Spacing.xs) { | |
| 97 | + if let section = excerpt.sectionTitle, !section.isEmpty { | |
| 98 | + Text(section) | |
| 99 | + Text("·") | |
| 100 | + } | |
| 101 | + Text(excerpt.sourceHost) | |
| 102 | + Text("·") | |
| 103 | + Text(excerpt.savedAt, style: .date) | |
| 104 | + } | |
| 105 | + .font(.caption) | |
| 106 | + .foregroundStyle(.secondary) | |
| 107 | + .lineLimit(1) | |
| 108 | + } | |
| 109 | + .padding(.vertical, Spacing.xs) | |
| 110 | + } | |
| 111 | + .buttonStyle(.plain) | |
| 112 | + .accessibilityIdentifier("library.excerpt") | |
| 113 | + } | |
| 114 | + | |
| 115 | + // MARK: - Structured favourites | |
| 116 | + | |
| 117 | + @ViewBuilder | |
| 118 | + private var favoriteList: some View { | |
| 119 | + let favorites = library.favorites | |
| 120 | + if favorites.isEmpty { | |
| 121 | + ContentUnavailableView { | |
| 122 | + Label("Aucune page sauvée", systemImage: "bookmark") | |
| 123 | + } description: { | |
| 124 | + Text("Dans le lecteur, touchez le signet : la page est gardée en données natives — l'essentiel, le plan, la source.") | |
| 125 | + } | |
| 126 | + } else { | |
| 127 | + List { | |
| 128 | + ForEach(favorites, id: \.persistentModelID) { favorite in | |
| 129 | + favoriteRow(favorite) | |
| 130 | + .swipeActions { | |
| 131 | + Button(role: .destructive) { | |
| 132 | + library.delete(favorite) | |
| 133 | + } label: { | |
| 134 | + Label("Supprimer", systemImage: "trash") | |
| 135 | + } | |
| 136 | + } | |
| 137 | + } | |
| 138 | + } | |
| 139 | + .listStyle(.plain) | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + private func favoriteRow(_ favorite: StructuredFavorite) -> some View { | |
| 144 | + Button { | |
| 145 | + open(favorite.urlString, containerID: favorite.containerID) | |
| 146 | + } label: { | |
| 147 | + VStack(alignment: .leading, spacing: Spacing.xs) { | |
| 148 | + HStack(spacing: Spacing.s) { | |
| 149 | + Circle() | |
| 150 | + .fill(color(for: favorite.containerID)) | |
| 151 | + .frame(width: 8, height: 8) | |
| 152 | + Text(favorite.title) | |
| 153 | + .foregroundStyle(.primary) | |
| 154 | + .lineLimit(2) | |
| 155 | + } | |
| 156 | + if !favorite.gist.isEmpty { | |
| 157 | + HStack(alignment: .top, spacing: Spacing.xs) { | |
| 158 | + if favorite.isGenerated { | |
| 159 | + Image(systemName: "sparkles") | |
| 160 | + .font(.caption2) | |
| 161 | + .foregroundStyle(Self.generated) | |
| 162 | + .padding(.top, 2) | |
| 163 | + } | |
| 164 | + Text(favorite.gist) | |
| 165 | + .font(.caption) | |
| 166 | + .foregroundStyle(favorite.isGenerated ? AnyShapeStyle(Self.generated) : AnyShapeStyle(.secondary)) | |
| 167 | + .italic(favorite.isGenerated) | |
| 168 | + .lineLimit(2) | |
| 169 | + } | |
| 170 | + } | |
| 171 | + HStack(spacing: Spacing.xs) { | |
| 172 | + Text(favorite.host) | |
| 173 | + Text("·") | |
| 174 | + let count = favorite.outlineTitles.count | |
| 175 | + Text(count > 1 ? "\(count) sections" : "\(count) section") | |
| 176 | + Text("·") | |
| 177 | + Text(favorite.savedAt, style: .date) | |
| 178 | + } | |
| 179 | + .font(.caption2) | |
| 180 | + .foregroundStyle(.tertiary) | |
| 181 | + .lineLimit(1) | |
| 182 | + } | |
| 183 | + .padding(.vertical, Spacing.xs) | |
| 184 | + } | |
| 185 | + .buttonStyle(.plain) | |
| 186 | + .accessibilityIdentifier("library.favorite") | |
| 187 | + } | |
| 188 | + | |
| 189 | + // MARK: - Helpers | |
| 190 | + | |
| 191 | + private func open(_ urlString: String, containerID: UUID) { | |
| 192 | + guard let url = URL(string: urlString) else { return } | |
| 193 | + onOpen(url, containerID) | |
| 194 | + dismiss() | |
| 195 | + } | |
| 196 | + | |
| 197 | + private func color(for containerID: UUID) -> Color { | |
| 198 | + containers.container(for: containerID)?.color.color ?? .secondary | |
| 199 | + } | |
| 200 | +} | |
added
Prisme/Memory/Index/SentenceEmbedder.swift
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// | |
| 2 | +// SentenceEmbedder.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import NaturalLanguage | |
| 10 | + | |
| 11 | +/// On-device sentence embeddings (NaturalLanguage framework — no Foundation | |
| 12 | +/// Models involved, works on every device, offline). Vectors from different | |
| 13 | +/// language models live in different spaces: the store tags each vector | |
| 14 | +/// with its language and only compares like with like. | |
| 15 | +@MainActor | |
| 16 | +final class SentenceEmbedder { | |
| 17 | + private var cache: [NLLanguage: NLEmbedding] = [:] | |
| 18 | + | |
| 19 | + func languageCode(for text: String) -> String? { | |
| 20 | + let recognizer = NLLanguageRecognizer() | |
| 21 | + recognizer.processString(String(text.prefix(400))) | |
| 22 | + return recognizer.dominantLanguage?.rawValue | |
| 23 | + } | |
| 24 | + | |
| 25 | + func embed(_ text: String, languageCode: String?) -> [Float]? { | |
| 26 | + guard let languageCode else { return nil } | |
| 27 | + let language = NLLanguage(rawValue: languageCode) | |
| 28 | + let embedding: NLEmbedding? | |
| 29 | + if let cached = cache[language] { | |
| 30 | + embedding = cached | |
| 31 | + } else { | |
| 32 | + embedding = NLEmbedding.sentenceEmbedding(for: language) | |
| 33 | + if let embedding { cache[language] = embedding } | |
| 34 | + } | |
| 35 | + guard let vector = embedding?.vector(for: String(text.prefix(600))) else { | |
| 36 | + return nil | |
| 37 | + } | |
| 38 | + return vector.map(Float.init) | |
| 39 | + } | |
| 40 | + | |
| 41 | + static func cosine(_ a: [Float], _ b: [Float]) -> Float { | |
| 42 | + guard a.count == b.count, !a.isEmpty else { return 0 } | |
| 43 | + var dot: Float = 0, normA: Float = 0, normB: Float = 0 | |
| 44 | + for i in a.indices { | |
| 45 | + dot += a[i] * b[i] | |
| 46 | + normA += a[i] * a[i] | |
| 47 | + normB += b[i] * b[i] | |
| 48 | + } | |
| 49 | + let denominator = (normA.squareRoot() * normB.squareRoot()) | |
| 50 | + return denominator > 0 ? dot / denominator : 0 | |
| 51 | + } | |
| 52 | +} | |
added
Prisme/Memory/Recall/HistoryStore.swift
+147 −0
@@ -0,0 +1,147 @@ | ||
| 1 | +// | |
| 2 | +// HistoryStore.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import CryptoKit | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | +import SwiftData | |
| 12 | + | |
| 13 | +/// The semantic history: records distilled visits and answers natural | |
| 14 | +/// language queries locally. Ranking blends vector similarity, keyword | |
| 15 | +/// hits and recency — no network, no model quota, works offline. | |
| 16 | +@MainActor | |
| 17 | +@Observable | |
| 18 | +final class HistoryStore { | |
| 19 | + private let container: ModelContainer | |
| 20 | + private let embedder = SentenceEmbedder() | |
| 21 | + private let snapshotsDirectory: URL | |
| 22 | + | |
| 23 | + private var context: ModelContext { container.mainContext } | |
| 24 | + | |
| 25 | + init() { | |
| 26 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 27 | + do { | |
| 28 | + // Explicit store URL: every SwiftData container in the app gets | |
| 29 | + // its own file, or they would clash on the default store. | |
| 30 | + let config = ModelConfiguration(url: base.appendingPathComponent("history.store")) | |
| 31 | + container = try ModelContainer(for: PageVisit.self, configurations: config) | |
| 32 | + } catch { | |
| 33 | + // A corrupt store must never take the browser down; degrade to | |
| 34 | + // an in-memory history for this session. | |
| 35 | + let memoryOnly = ModelConfiguration(isStoredInMemoryOnly: true) | |
| 36 | + container = try! ModelContainer(for: PageVisit.self, configurations: memoryOnly) | |
| 37 | + } | |
| 38 | + snapshotsDirectory = base.appendingPathComponent("Snapshots", isDirectory: true) | |
| 39 | + try? FileManager.default.createDirectory(at: snapshotsDirectory, withIntermediateDirectories: true) | |
| 40 | + } | |
| 41 | + | |
| 42 | + // MARK: - Recording | |
| 43 | + | |
| 44 | + /// Records a finished page load. Callers must have already excluded | |
| 45 | + /// sensitive containers — this store never sees them. | |
| 46 | + func record(url: URL, content: ExtractedContent, containerID: UUID) { | |
| 47 | + guard url.scheme == "https" || url.scheme == "http" else { return } | |
| 48 | + let title = content.title.isEmpty ? (url.host() ?? url.absoluteString) : content.title | |
| 49 | + let gist = content.leadSentence ?? "" | |
| 50 | + let hash = DigestCache.key(for: content) | |
| 51 | + let searchable = "\(title). \(gist)" | |
| 52 | + let language = embedder.languageCode(for: searchable) | |
| 53 | + let vector = embedder.embed(searchable, languageCode: language) | |
| 54 | + | |
| 55 | + if let existing = visit(for: url.absoluteString) { | |
| 56 | + existing.title = title | |
| 57 | + existing.gist = gist | |
| 58 | + existing.visitedAt = Date() | |
| 59 | + existing.visitCount += 1 | |
| 60 | + existing.contentHash = hash | |
| 61 | + existing.languageCode = language | |
| 62 | + existing.embedding = vector | |
| 63 | + } else { | |
| 64 | + context.insert(PageVisit( | |
| 65 | + urlString: url.absoluteString, | |
| 66 | + host: url.host() ?? "", | |
| 67 | + title: title, | |
| 68 | + gist: gist, | |
| 69 | + containerID: containerID, | |
| 70 | + visitedAt: Date(), | |
| 71 | + contentHash: hash, | |
| 72 | + languageCode: language, | |
| 73 | + embedding: vector | |
| 74 | + )) | |
| 75 | + } | |
| 76 | + try? context.save() | |
| 77 | + writeSnapshot(content, urlString: url.absoluteString) | |
| 78 | + } | |
| 79 | + | |
| 80 | + // MARK: - Recall | |
| 81 | + | |
| 82 | + var recent: [PageVisit] { | |
| 83 | + var descriptor = FetchDescriptor<PageVisit>( | |
| 84 | + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] | |
| 85 | + ) | |
| 86 | + descriptor.fetchLimit = 200 | |
| 87 | + return (try? context.fetch(descriptor)) ?? [] | |
| 88 | + } | |
| 89 | + | |
| 90 | + /// Natural language search. Vector similarity within the query's | |
| 91 | + /// language, keyword matching across everything, recent pages float up. | |
| 92 | + func search(_ query: String, limit: Int = 20) -> [PageVisit] { | |
| 93 | + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 94 | + guard trimmed.count >= 2 else { return [] } | |
| 95 | + | |
| 96 | + let lowered = trimmed.lowercased() | |
| 97 | + let queryLanguage = embedder.languageCode(for: trimmed) | |
| 98 | + let queryVector = embedder.embed(trimmed, languageCode: queryLanguage) | |
| 99 | + | |
| 100 | + let scored: [(PageVisit, Float)] = recent.compactMap { visit in | |
| 101 | + var score: Float = 0 | |
| 102 | + | |
| 103 | + if let queryVector, | |
| 104 | + let vector = visit.embedding, | |
| 105 | + visit.languageCode == queryLanguage { | |
| 106 | + score += 0.6 * max(0, SentenceEmbedder.cosine(queryVector, vector)) | |
| 107 | + } | |
| 108 | + | |
| 109 | + let haystack = "\(visit.title) \(visit.host) \(visit.gist)".lowercased() | |
| 110 | + let words = lowered.split(separator: " ") | |
| 111 | + let hits = words.filter { haystack.contains($0) }.count | |
| 112 | + if !words.isEmpty { | |
| 113 | + score += 0.3 * Float(hits) / Float(words.count) | |
| 114 | + } | |
| 115 | + | |
| 116 | + // Half-life of ~30 days keeps yesterday above last spring. | |
| 117 | + let age = Date().timeIntervalSince(visit.visitedAt) | |
| 118 | + score += 0.1 * Float(exp(-age / (30 * 24 * 3600))) | |
| 119 | + | |
| 120 | + return score > 0.08 ? (visit, score) : nil | |
| 121 | + } | |
| 122 | + | |
| 123 | + return scored | |
| 124 | + .sorted { $0.1 > $1.1 } | |
| 125 | + .prefix(limit) | |
| 126 | + .map(\.0) | |
| 127 | + } | |
| 128 | + | |
| 129 | + // MARK: - Snapshots (durable text, feeds the future temporal diff) | |
| 130 | + | |
| 131 | + private func writeSnapshot(_ content: ExtractedContent, urlString: String) { | |
| 132 | + let name = SHA256.hash(data: Data(urlString.utf8)) | |
| 133 | + .map { String(format: "%02x", $0) }.joined() | |
| 134 | + let file = snapshotsDirectory.appendingPathComponent("\(name).txt") | |
| 135 | + let text = ([content.title] + content.blocks.map(\.text)).joined(separator: "\n\n") | |
| 136 | + try? text.write(to: file, atomically: true, encoding: .utf8) | |
| 137 | + } | |
| 138 | + | |
| 139 | + private func visit(for urlString: String) -> PageVisit? { | |
| 140 | + var descriptor = FetchDescriptor<PageVisit>( | |
| 141 | + predicate: #Predicate { $0.urlString == urlString } | |
| 142 | + ) | |
| 143 | + descriptor.fetchLimit = 1 | |
| 144 | + return (try? context.fetch(descriptor))?.first | |
| 145 | + } | |
| 146 | + | |
| 147 | +} | |
added
Prisme/Memory/Recall/HistoryView.swift
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +// | |
| 2 | +// HistoryView.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import SwiftUI | |
| 9 | + | |
| 10 | +/// Semantic history browser: recent visits by day, natural language search | |
| 11 | +/// on top ("le site avec la recette de ramen vu au printemps"). | |
| 12 | +struct HistoryView: View { | |
| 13 | + let history: HistoryStore | |
| 14 | + let containers: IdentityContainerStore | |
| 15 | + let onOpen: (PageVisit) -> Void | |
| 16 | + | |
| 17 | + @Environment(\.dismiss) private var dismiss | |
| 18 | + @State private var query = "" | |
| 19 | + | |
| 20 | + private var results: [PageVisit] { | |
| 21 | + query.trimmingCharacters(in: .whitespaces).count >= 2 | |
| 22 | + ? history.search(query) | |
| 23 | + : history.recent | |
| 24 | + } | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + NavigationStack { | |
| 28 | + Group { | |
| 29 | + if results.isEmpty { | |
| 30 | + ContentUnavailableView { | |
| 31 | + Label( | |
| 32 | + query.isEmpty ? "Encore rien à retrouver" : "Rien trouvé", | |
| 33 | + systemImage: "clock.arrow.circlepath" | |
| 34 | + ) | |
| 35 | + } description: { | |
| 36 | + Text(query.isEmpty | |
| 37 | + ? "Les pages visitées seront retrouvables ici, en langage naturel. Tout reste sur l'appareil." | |
| 38 | + : "Aucune page visitée ne correspond à « \(query) ».") | |
| 39 | + } | |
| 40 | + } else { | |
| 41 | + visitList | |
| 42 | + } | |
| 43 | + } | |
| 44 | + .navigationTitle("Historique") | |
| 45 | + .navigationBarTitleDisplayMode(.inline) | |
| 46 | + .searchable( | |
| 47 | + text: $query, | |
| 48 | + placement: .navigationBarDrawer(displayMode: .always), | |
| 49 | + prompt: "Rechercher une page visitée…" | |
| 50 | + ) | |
| 51 | + .toolbar { | |
| 52 | + ToolbarItem(placement: .cancellationAction) { | |
| 53 | + Button("Fermer") { dismiss() } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + private var visitList: some View { | |
| 60 | + List(results, id: \.urlString) { visit in | |
| 61 | + Button { | |
| 62 | + onOpen(visit) | |
| 63 | + dismiss() | |
| 64 | + } label: { | |
| 65 | + HStack(alignment: .top, spacing: Spacing.m) { | |
| 66 | + Circle() | |
| 67 | + .fill(color(for: visit)) | |
| 68 | + .frame(width: 8, height: 8) | |
| 69 | + .padding(.top, 6) | |
| 70 | + VStack(alignment: .leading, spacing: 2) { | |
| 71 | + Text(visit.title) | |
| 72 | + .foregroundStyle(.primary) | |
| 73 | + .lineLimit(2) | |
| 74 | + HStack(spacing: Spacing.xs) { | |
| 75 | + Text(visit.host) | |
| 76 | + Text("·") | |
| 77 | + Text(visit.visitedAt, style: .relative) | |
| 78 | + } | |
| 79 | + .font(.caption) | |
| 80 | + .foregroundStyle(.secondary) | |
| 81 | + if !visit.gist.isEmpty { | |
| 82 | + Text(visit.gist) | |
| 83 | + .font(.caption) | |
| 84 | + .foregroundStyle(.tertiary) | |
| 85 | + .lineLimit(2) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | + .accessibilityIdentifier("history.row") | |
| 91 | + } | |
| 92 | + .listStyle(.plain) | |
| 93 | + } | |
| 94 | + | |
| 95 | + private func color(for visit: PageVisit) -> Color { | |
| 96 | + containers.container(for: visit.containerID)?.color.color ?? .secondary | |
| 97 | + } | |
| 98 | +} | |
added
Prisme/Memory/Snapshots/PageVisit.swift
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// | |
| 2 | +// PageVisit.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import SwiftData | |
| 10 | + | |
| 11 | +/// One remembered page. The semantic history (CLAUDE.md §5, P0) is where | |
| 12 | +/// the free local model beats any cloud solution: the volume would be | |
| 13 | +/// unpayable through an API, and sending browsing history off-device is | |
| 14 | +/// unacceptable anyway. Visits from sensitive containers are never | |
| 15 | +/// recorded — not stored-and-filtered, simply never written. | |
| 16 | +@Model | |
| 17 | +final class PageVisit { | |
| 18 | + @Attribute(.unique) var urlString: String | |
| 19 | + var host: String | |
| 20 | + var title: String | |
| 21 | + /// Deterministic one-sentence essence (first sentence of the first | |
| 22 | + /// paragraph) — searchable text, tier `none`. | |
| 23 | + var gist: String | |
| 24 | + var containerID: UUID | |
| 25 | + var visitedAt: Date | |
| 26 | + var visitCount: Int | |
| 27 | + /// Hash of the extracted content at last visit — feeds the future | |
| 28 | + /// temporal diff (P2). | |
| 29 | + var contentHash: String | |
| 30 | + /// BCP-47 code of the page's dominant language, so query vectors are | |
| 31 | + /// only compared with vectors from the same embedding space. | |
| 32 | + var languageCode: String? | |
| 33 | + /// On-device sentence embedding of title + gist. | |
| 34 | + var embedding: [Float]? | |
| 35 | + | |
| 36 | + init( | |
| 37 | + urlString: String, | |
| 38 | + host: String, | |
| 39 | + title: String, | |
| 40 | + gist: String, | |
| 41 | + containerID: UUID, | |
| 42 | + visitedAt: Date, | |
| 43 | + contentHash: String, | |
| 44 | + languageCode: String?, | |
| 45 | + embedding: [Float]? | |
| 46 | + ) { | |
| 47 | + self.urlString = urlString | |
| 48 | + self.host = host | |
| 49 | + self.title = title | |
| 50 | + self.gist = gist | |
| 51 | + self.containerID = containerID | |
| 52 | + self.visitedAt = visitedAt | |
| 53 | + self.visitCount = 1 | |
| 54 | + self.contentHash = contentHash | |
| 55 | + self.languageCode = languageCode | |
| 56 | + self.embedding = embedding | |
| 57 | + } | |
| 58 | +} | |
added
Prisme/Privacy/IdentityContainer.swift
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +// | |
| 2 | +// IdentityContainer.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | + | |
| 10 | +/// An isolated browsing identity. Cookies, sessions and local storage are | |
| 11 | +/// fully partitioned per container via `WKWebsiteDataStore(forIdentifier:)`. | |
| 12 | +/// A site opened in "Travail" can never link you to "Perso". | |
| 13 | +struct IdentityContainer: Identifiable, Codable, Hashable, Sendable { | |
| 14 | + let id: UUID | |
| 15 | + /// User-facing name (French, per product conventions). | |
| 16 | + var name: String | |
| 17 | + /// SF Symbol name shown in the chrome and tab switcher. | |
| 18 | + var symbol: String | |
| 19 | + var color: ContainerColor | |
| 20 | + /// Sensitive containers are hard-excluded from any cloud escalation | |
| 21 | + /// once the intelligence layer lands (CLAUDE.md §3). | |
| 22 | + var isSensitive: Bool | |
| 23 | +} | |
| 24 | + | |
| 25 | +extension IdentityContainer { | |
| 26 | + /// Default set created on first launch. IDs are generated once and then | |
| 27 | + /// persisted — they must stay stable because they key the data stores. | |
| 28 | + static func makeDefaults() -> [IdentityContainer] { | |
| 29 | + [ | |
| 30 | + IdentityContainer(id: UUID(), name: "Perso", symbol: "person.fill", color: .vert, isSensitive: false), | |
| 31 | + IdentityContainer(id: UUID(), name: "Travail", symbol: "briefcase.fill", color: .bleu, isSensitive: false), | |
| 32 | + IdentityContainer(id: UUID(), name: "Magasinage", symbol: "cart.fill", color: .orange, isSensitive: false), | |
| 33 | + IdentityContainer(id: UUID(), name: "Recherche sensible", symbol: "lock.shield.fill", color: .violet, isSensitive: true), | |
| 34 | + ] | |
| 35 | + } | |
| 36 | +} | |
added
Prisme/Privacy/IdentityContainerStore.swift
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// | |
| 2 | +// IdentityContainerStore.swift | |
| 3 | +// Prisme | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import Foundation | |
| 9 | +import Observation | |
| 10 | +import WebKit | |
| 11 | + | |
| 12 | +/// Owns the list of identity containers and hands out their partitioned | |
| 13 | +/// `WKWebsiteDataStore`s. Container IDs are persisted in UserDefaults so the | |
| 14 | +/// same data stores are recovered across launches. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class IdentityContainerStore { | |
| 18 | + private static let defaultsKey = "prisme.identityContainers" | |
| 19 | + | |
| 20 | + private(set) var all: [IdentityContainer] | |
| 21 | + | |
| 22 | + init() { | |
| 23 | + if let data = UserDefaults.standard.data(forKey: Self.defaultsKey), | |
| 24 | + let decoded = try? JSONDecoder().decode([IdentityContainer].self, from: data), | |
| 25 | + !decoded.isEmpty { | |
| 26 | + all = decoded | |
| 27 | + } else { | |
| 28 | + all = IdentityContainer.makeDefaults() | |
| 29 | + persist() | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + func container(for id: IdentityContainer.ID) -> IdentityContainer? { | |
| 34 | + all.first { $0.id == id } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// The partitioned data store for a container. Same UUID → same | |
| 38 | + /// persistent store, fully isolated from every other container. | |
| 39 | + func dataStore(for id: IdentityContainer.ID) -> WKWebsiteDataStore { | |
| 40 | + WKWebsiteDataStore(forIdentifier: id) | |
| 41 | + } | |
| 42 | + | |
| 43 | + private func persist() { | |
| 44 | + if let data = try? JSONEncoder().encode(all) { | |
| 45 | + UserDefaults.standard.set(data, forKey: Self.defaultsKey) | |
| 46 | + } | |
| 47 | + } | |
| 48 | +} | |
added
PrismeUITests/SmokeTests.swift
+186 −0
@@ -0,0 +1,186 @@ | ||
| 1 | +// | |
| 2 | +// SmokeTests.swift | |
| 3 | +// PrismeUITests | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 6 | +// | |
| 7 | + | |
| 8 | +import XCTest | |
| 9 | + | |
| 10 | +/// End-to-end smoke tests for the bare browser: address bar intents, | |
| 11 | +/// search-engine proposals, and actual page loads. Proposals are confirmed | |
| 12 | +/// by tapping — exactly the designed interaction — because a hardware | |
| 13 | +/// Return key press can be routed to focused buttons instead of the field. | |
| 14 | +final class SmokeTests: XCTestCase { | |
| 15 | + | |
| 16 | + override func setUp() { | |
| 17 | + continueAfterFailure = false | |
| 18 | + } | |
| 19 | + | |
| 20 | + @MainActor | |
| 21 | + private func startEditing(_ app: XCUIApplication) -> XCUIElement { | |
| 22 | + let compact = app.buttons["addressBar.compact"] | |
| 23 | + XCTAssertTrue(compact.waitForExistence(timeout: 5)) | |
| 24 | + compact.tap() | |
| 25 | + | |
| 26 | + let field = app.textFields["addressBar.field"] | |
| 27 | + if !field.waitForExistence(timeout: 3) { | |
| 28 | + // A tap landing mid-launch-animation can be swallowed; retry once. | |
| 29 | + compact.tap() | |
| 30 | + } | |
| 31 | + XCTAssertTrue(field.waitForExistence(timeout: 5)) | |
| 32 | + field.tap() | |
| 33 | + return field | |
| 34 | + } | |
| 35 | + | |
| 36 | + @MainActor | |
| 37 | + func testNavigateToURLLoadsPage() throws { | |
| 38 | + let app = XCUIApplication() | |
| 39 | + app.launch() | |
| 40 | + | |
| 41 | + let field = startEditing(app) | |
| 42 | + field.typeText("example.com") | |
| 43 | + | |
| 44 | + let navigate = app.buttons["proposal.navigate"] | |
| 45 | + XCTAssertTrue(navigate.waitForExistence(timeout: 5)) | |
| 46 | + navigate.tap() | |
| 47 | + | |
| 48 | + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 10)) | |
| 49 | + let pageText = app.webViews.staticTexts["Example Domain"] | |
| 50 | + XCTAssertTrue(pageText.waitForExistence(timeout: 20)) | |
| 51 | + XCTAssertTrue(app.buttons["addressBar.compact"].label.contains("example.com")) | |
| 52 | + } | |
| 53 | + | |
| 54 | + @MainActor | |
| 55 | + func testSearchProposalsOfferBothEngines() throws { | |
| 56 | + let app = XCUIApplication() | |
| 57 | + app.launch() | |
| 58 | + | |
| 59 | + let field = startEditing(app) | |
| 60 | + field.typeText("chat noir") | |
| 61 | + | |
| 62 | + // Both engines must be proposed on every query. | |
| 63 | + XCTAssertTrue(app.buttons["proposal.search.duckDuckGo"].waitForExistence(timeout: 5)) | |
| 64 | + XCTAssertTrue(app.buttons["proposal.search.google"].exists) | |
| 65 | + } | |
| 66 | + | |
| 67 | + @MainActor | |
| 68 | + func testReaderSemanticZoomLevels() throws { | |
| 69 | + let app = XCUIApplication() | |
| 70 | + app.launch() | |
| 71 | + | |
| 72 | + let field = startEditing(app) | |
| 73 | + field.typeText("example.com") | |
| 74 | + app.buttons["proposal.navigate"].tap() | |
| 75 | + XCTAssertTrue(app.webViews.staticTexts["Example Domain"].waitForExistence(timeout: 20)) | |
| 76 | + | |
| 77 | + // The understanding strip appears once the page is distilled; | |
| 78 | + // it is the main entry into the reader. | |
| 79 | + let strip = app.buttons["insight.read"] | |
| 80 | + XCTAssertTrue(strip.waitForExistence(timeout: 10)) | |
| 81 | + attachScreenshot(named: "insight-strip") | |
| 82 | + strip.tap() | |
| 83 | + XCTAssertTrue(app.buttons["reader.level.Texte"].waitForExistence(timeout: 5)) | |
| 84 | + | |
| 85 | + // Full text renders the page's blocks natively. | |
| 86 | + XCTAssertTrue(app.staticTexts["Example Domain"].waitForExistence(timeout: 5)) | |
| 87 | + attachScreenshot(named: "reader-texte") | |
| 88 | + | |
| 89 | + // Outline lists the page's headings. | |
| 90 | + app.buttons["reader.level.Plan"].tap() | |
| 91 | + XCTAssertTrue(app.staticTexts["Example Domain"].waitForExistence(timeout: 5)) | |
| 92 | + | |
| 93 | + // Gist level always resolves — model or deterministic fallback. | |
| 94 | + // (The container identifier cascades to the gist's text elements.) | |
| 95 | + app.buttons["reader.level.Essentiel"].tap() | |
| 96 | + XCTAssertTrue(app.staticTexts["reader.gist"].firstMatch.waitForExistence(timeout: 5)) | |
| 97 | + attachScreenshot(named: "reader-essentiel") | |
| 98 | + | |
| 99 | + // Closing returns to the raw page (§7). | |
| 100 | + app.buttons["reader.close"].tap() | |
| 101 | + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 5)) | |
| 102 | + } | |
| 103 | + | |
| 104 | + @MainActor | |
| 105 | + func testSemanticHistoryRecallInAddressBar() throws { | |
| 106 | + let app = XCUIApplication() | |
| 107 | + app.launch() | |
| 108 | + | |
| 109 | + // Visit a page so the history has something to remember. | |
| 110 | + var field = startEditing(app) | |
| 111 | + field.typeText("example.com") | |
| 112 | + app.buttons["proposal.navigate"].tap() | |
| 113 | + XCTAssertTrue(app.webViews.staticTexts["Example Domain"].waitForExistence(timeout: 20)) | |
| 114 | + | |
| 115 | + // Recording happens shortly after the load settles. | |
| 116 | + Thread.sleep(forTimeInterval: 2.5) | |
| 117 | + | |
| 118 | + // From a fresh tab, typing recalls the visited page. | |
| 119 | + app.buttons["plus"].tap() | |
| 120 | + field = startEditing(app) | |
| 121 | + field.typeText("example domain") | |
| 122 | + | |
| 123 | + let recall = app.buttons["proposal.recall"].firstMatch | |
| 124 | + XCTAssertTrue(recall.waitForExistence(timeout: 5)) | |
| 125 | + attachScreenshot(named: "history-recall") | |
| 126 | + recall.tap() | |
| 127 | + | |
| 128 | + XCTAssertTrue(app.webViews.staticTexts["Example Domain"].waitForExistence(timeout: 20)) | |
| 129 | + } | |
| 130 | + | |
| 131 | + @MainActor | |
| 132 | + func testStructuredFavoriteFromReaderAppearsInLibrary() throws { | |
| 133 | + let app = XCUIApplication() | |
| 134 | + app.launch() | |
| 135 | + | |
| 136 | + let field = startEditing(app) | |
| 137 | + field.typeText("example.com") | |
| 138 | + app.buttons["proposal.navigate"].tap() | |
| 139 | + XCTAssertTrue(app.webViews.staticTexts["Example Domain"].waitForExistence(timeout: 20)) | |
| 140 | + | |
| 141 | + // Save from the reader: works with or without the local model. | |
| 142 | + // (Second reader entry: the affordance in the address bar.) | |
| 143 | + XCTAssertTrue(app.buttons["addressBar.reader"].waitForExistence(timeout: 5)) | |
| 144 | + app.buttons["addressBar.reader"].tap() | |
| 145 | + let save = app.buttons["reader.save"] | |
| 146 | + XCTAssertTrue(save.waitForExistence(timeout: 5)) | |
| 147 | + save.tap() | |
| 148 | + app.buttons["reader.close"].tap() | |
| 149 | + | |
| 150 | + // The library lists the page as native data. | |
| 151 | + app.buttons["books.vertical"].tap() | |
| 152 | + XCTAssertTrue(app.buttons["Pages"].waitForExistence(timeout: 5)) | |
| 153 | + app.buttons["Pages"].tap() | |
| 154 | + let favorite = app.buttons["library.favorite"].firstMatch | |
| 155 | + XCTAssertTrue(favorite.waitForExistence(timeout: 5)) | |
| 156 | + XCTAssertTrue(app.staticTexts["Example Domain"].exists) | |
| 157 | + attachScreenshot(named: "library-favorite") | |
| 158 | + } | |
| 159 | + | |
| 160 | + @MainActor | |
| 161 | + private func attachScreenshot(named name: String) { | |
| 162 | + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) | |
| 163 | + attachment.name = name | |
| 164 | + attachment.lifetime = .keepAlways | |
| 165 | + add(attachment) | |
| 166 | + } | |
| 167 | + | |
| 168 | + @MainActor | |
| 169 | + func testGoogleSearchLoadsGoogle() throws { | |
| 170 | + let app = XCUIApplication() | |
| 171 | + app.launch() | |
| 172 | + | |
| 173 | + let field = startEditing(app) | |
| 174 | + field.typeText("chat noir") | |
| 175 | + | |
| 176 | + let google = app.buttons["proposal.search.google"] | |
| 177 | + XCTAssertTrue(google.waitForExistence(timeout: 5)) | |
| 178 | + google.tap() | |
| 179 | + | |
| 180 | + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 10)) | |
| 181 | + let bar = app.buttons["addressBar.compact"] | |
| 182 | + let onGoogle = NSPredicate(format: "label CONTAINS 'google'") | |
| 183 | + expectation(for: onGoogle, evaluatedWith: bar) | |
| 184 | + waitForExpectations(timeout: 15) | |
| 185 | + } | |
| 186 | +} | |
added
README.md
+325 −0
@@ -0,0 +1,325 @@ | ||
| 1 | +<!-- | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + Prisme — Navigateur iOS intelligent | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : README.md | |
| 8 | + Purpose : Documentation principale du projet | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +--> | |
| 12 | + | |
| 13 | +<div align="center"> | |
| 14 | + | |
| 15 | +<img src="docs/screenshots/icon.png" width="160" alt="Icône Prisme" /> | |
| 16 | + | |
| 17 | +# Prisme | |
| 18 | + | |
| 19 | +**Un navigateur n'est pas un afficheur de pages. C'est un lecteur qui comprend ce qu'il affiche.** | |
| 20 | + | |
| 21 | +[](#-stack-technique) | |
| 22 | +[](#-stack-technique) | |
| 23 | +[](#-stack-technique) | |
| 24 | +[](#-intelligence-locale) | |
| 25 | +[](#-tests) | |
| 26 | +[](#-m%C3%A9triques) | |
| 27 | +[-orange)](#-stack-technique) | |
| 28 | +[](LICENSE) | |
| 29 | + | |
| 30 | +*SwiftUI · WebKit · Foundation Models · SwiftData · NaturalLanguage — zéro serveur, zéro compte, zéro télémétrie.* | |
| 31 | + | |
| 32 | +</div> | |
| 33 | + | |
| 34 | +--- | |
| 35 | + | |
| 36 | +## Table des matières | |
| 37 | + | |
| 38 | +- [Vision](#-vision) | |
| 39 | +- [Captures d'écran](#-captures-décran) | |
| 40 | +- [Fonctionnalités](#-fonctionnalités) | |
| 41 | +- [Intelligence locale](#-intelligence-locale) | |
| 42 | +- [Architecture](#-architecture) | |
| 43 | +- [Métriques](#-métriques) | |
| 44 | +- [Compiler et lancer](#-compiler-et-lancer) | |
| 45 | +- [Tests](#-tests) | |
| 46 | +- [Vie privée](#-vie-privée) | |
| 47 | +- [Feuille de route](#-feuille-de-route) | |
| 48 | +- [Auteur](#-auteur) | |
| 49 | + | |
| 50 | +--- | |
| 51 | + | |
| 52 | +## 🔭 Vision | |
| 53 | + | |
| 54 | +Le web moderne est hostile : bannières, murs de consentement, 2 000 mots de remplissage SEO | |
| 55 | +pour une réponse de 40 mots, patterns manipulateurs, pistage. Les navigateurs actuels rendent | |
| 56 | +fidèlement cette hostilité. **Prisme s'interpose** : chaque page est comprise localement avant | |
| 57 | +d'être affichée, puis re-présentée selon l'intention de l'utilisateur. | |
| 58 | + | |
| 59 | +Trois règles non négociables : | |
| 60 | + | |
| 61 | +| # | Règle | Concrètement | | |
| 62 | +|---|-------|--------------| | |
| 63 | +| 1 | **Rien ne quitte l'appareil par défaut** | Distillation, embeddings, historique, digest : tout est calculé on-device. Aucun serveur opéré, aucun compte. | | |
| 64 | +| 2 | **Utile dès la première session, pour un seul utilisateur** | Aucun effet de réseau requis. Le bandeau de compréhension démontre la valeur en 10 secondes. | | |
| 65 | +| 3 | **Le modèle ne remplace jamais la page** | Contenu généré toujours distinct visuellement (violet ✨), toujours ancré vers sa source DOM, page brute toujours à un geste. | | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## 📱 Captures d'écran | |
| 70 | + | |
| 71 | +| Page d'accueil | Barre à intention | Bandeau de compréhension | | |
| 72 | +|:---:|:---:|:---:| | |
| 73 | +| <img src="docs/screenshots/start-page.png" width="240" alt="Page d'accueil avec logo prisme et univers" /> | <img src="docs/screenshots/intent-bar.png" width="240" alt="Propositions : adresse directe, Google, DuckDuckGo" /> | <img src="docs/screenshots/insight-strip.png" width="240" alt="Bandeau : temps de lecture et bouton Lire" /> | | |
| 74 | +| *Univers isolés, logo dessiné en code* | *URL, moteurs au choix, moteur par défaut* | *« ~1 min de lecture » + entrée lecteur* | | |
| 75 | + | |
| 76 | +| Lecteur — Texte | Lecteur — Essentiel | Rappel d'historique | Bibliothèque | | |
| 77 | +|:---:|:---:|:---:|:---:| | |
| 78 | +| <img src="docs/screenshots/reader-texte.png" width="180" alt="Rendu natif des blocs de la page" /> | <img src="docs/screenshots/reader-essentiel.png" width="180" alt="Niveau essentiel : une phrase" /> | <img src="docs/screenshots/history-recall.png" width="180" alt="Page retrouvée en tapant dans la barre" /> | <img src="docs/screenshots/library.png" width="180" alt="Favori structuré en données natives" /> | | |
| 79 | +| *Rendu natif typé* | *Zoom sémantique max* | *« Déjà visité »* | *Données natives* | | |
| 80 | + | |
| 81 | +--- | |
| 82 | + | |
| 83 | +## ✨ Fonctionnalités | |
| 84 | + | |
| 85 | +Légende : ✅ implémenté · 🚧 prévu (P1) · 🔮 plus tard (P2) | |
| 86 | + | |
| 87 | +### Affichage | |
| 88 | + | |
| 89 | +| État | Fonctionnalité | Description | | |
| 90 | +|:---:|---|---| | |
| 91 | +| ✅ | **Zoom sémantique** | Le pincement ne change pas la taille du texte : il change le **niveau de détail**. 4 niveaux — Texte · Sections · Plan · Essentiel — et écarter au-delà du texte ramène la page brute. *La fonctionnalité signature.* | | |
| 92 | +| ✅ | **Rendu adaptatif par type** | Le `kind` du digest sélectionne la typographie : un article passe en serif de lecture, une documentation garde son code monospace défilant. | | |
| 93 | +| ✅ | **Bandeau de compréhension** | Après chaque chargement : « Article · ~4 min de lecture » + l'essentiel généré. Un tap → lecteur. Jamais un popup. | | |
| 94 | +| 🚧 | Barre de défilement sémantique | La scrollbar devient une carte de la page. | | |
| 95 | +| 🚧 | Thème sémantique | Le mode sombre par rôle de bloc, pas par inversion. | | |
| 96 | +| 🚧 | Tiroir du bruit | Tout ce qui a été retiré, consultable. | | |
| 97 | +| 🔮 | Diff temporel | Ce qui a changé depuis la dernière visite, surligné. *(Les snapshots texte sont déjà archivés à chaque visite.)* | | |
| 98 | + | |
| 99 | +### Onglets & entrée | |
| 100 | + | |
| 101 | +| État | Fonctionnalité | Description | | |
| 102 | +|:---:|---|---| | |
| 103 | +| ✅ | **Barre à intention** | Un champ unique qui propose — jamais ne devine en silence : adresse directe, recherche DuckDuckGo, recherche Google, pages déjà visitées. Moteur par défaut commutable dans la carte même. | | |
| 104 | +| ✅ | **Pool de WKWebView** | Jamais une webview par onglet : pool réutilisé, état de session (`interactionState`) préservé au changement d'onglet — historique arrière/avant et défilement intacts. | | |
| 105 | +| ✅ | Sélecteur d'onglets par univers | Cartes sectionnées par conteneur, bandeau de couleur, badge de site. | | |
| 106 | +| 🚧 | Regroupement par intention | Proposé, jamais imposé. | | |
| 107 | +| 🚧 | Reprise narrative | À la réouverture : un paragraphe, pas 47 vignettes. | | |
| 108 | +| 🚧 | Onglets périssables | Durée de vie estimée, purge proposée. | | |
| 109 | + | |
| 110 | +### Favoris repensés | |
| 111 | + | |
| 112 | +| État | Fonctionnalité | Description | | |
| 113 | +|:---:|---|---| | |
| 114 | +| ✅ | **L'extrait** | Sélectionner un passage → « Sauver l'extrait » dans le menu d'édition natif. Gardé : le texte, la source, la date, le **contexte de section**, l'ancre DOM. | | |
| 115 | +| ✅ | **Le favori structuré** | La page sauvée en données natives : titre, essentiel, plan, type — pas un pointeur qui pourrit. Enrichi par le modèle quand disponible (marqué ✨). | | |
| 116 | +| 🚧 | Le favori vivant | Surveille sa page en tâche de fond (`BGTaskScheduler`), notifie au changement. | | |
| 117 | +| 🚧 | Le favori-question | « Combien coûte le passeport » plutôt qu'une URL — immunisé contre le lien mort. | | |
| 118 | +| 🔮 | Collections émergentes · Purge honnête | Suggestions, jamais d'action automatique. | | |
| 119 | + | |
| 120 | +### Mémoire | |
| 121 | + | |
| 122 | +| État | Fonctionnalité | Description | | |
| 123 | +|:---:|---|---| | |
| 124 | +| ✅ | **Historique sémantique** | Chaque page visitée est distillée et indexée localement (embeddings de phrases NaturalLanguage, par langue). Recherche en langage naturel, rappel directement dans la barre à intention (« Déjà visité »). | | |
| 125 | +| ✅ | **Snapshots texte** | Le texte extrait de chaque visite est archivé — la matière première du futur diff temporel. | | |
| 126 | +| 🚧 | Ligne du temps de sujet | Toutes les visites autour d'un thème. | | |
| 127 | +| 🚧 | Rappel proactif | « Vu en mars, c'était 899 $ » — une ligne, jamais un popup. | | |
| 128 | + | |
| 129 | +### Vie privée | |
| 130 | + | |
| 131 | +| État | Fonctionnalité | Description | | |
| 132 | +|:---:|---|---| | |
| 133 | +| ✅ | **Conteneurs d'identité** | Perso · Travail · Magasinage · Recherche sensible — cookies, sessions et empreinte cloisonnés par `WKWebsiteDataStore(forIdentifier:)`. Changement d'univers en un geste. | | |
| 134 | +| ✅ | **Blocage de contenu** | `WKContentRuleList` compilée (14 règles anti-traqueurs), appliquée à chaud dès compilation, hors du chemin critique de rendu. | | |
| 135 | +| ✅ | **Exclusion du sensible** | Les visites de l'univers « Recherche sensible » ne sont **jamais écrites** dans l'historique — pas stockées-puis-filtrées : jamais vues. | | |
| 136 | +| 🚧 | Détecteur de patterns manipulateurs | Faux compte à rebours, consentement pré-coché : nommés à l'écran. | | |
| 137 | +| 🚧 | Traducteur de conditions | Les CGU en trois lignes **avant** d'accepter. | | |
| 138 | + | |
| 139 | +--- | |
| 140 | + | |
| 141 | +## 🧠 Intelligence locale | |
| 142 | + | |
| 143 | +**Un LLM n'est pas une réponse à tout — c'est le dernier recours, pas le premier.** | |
| 144 | +Chaque tâche déclare son niveau ; le routeur décide. Aucun appel direct au modèle ailleurs. | |
| 145 | + | |
| 146 | +| Tier | Quand | Exemples dans Prisme | | |
| 147 | +|---|---|---| | |
| 148 | +| `none` | Une heuristique suffit | Détection URL/recherche, extraction DOM, temps de lecture, plan par titres, résumés de repli (première phrase), embeddings de recherche | | |
| 149 | +| `local` | Fréquent, gratuit, illimité, hors ligne | Digest de page (type, essentiel, plan, affirmations chiffrées) via `SystemLanguageModel` | | |
| 150 | +| `cloud` | Action explicite uniquement — *pas encore câblé* | Comparaisons multi-onglets, synthèses longues (Private Cloud Compute) | | |
| 151 | + | |
| 152 | +### Le Distiller — pièce centrale | |
| 153 | + | |
| 154 | +Une page fait 30 000 tokens ; le modèle en accepte 4–8 000. Pipeline en 5 étapes, | |
| 155 | +déterministe sauf la dernière : | |
| 156 | + | |
| 157 | +``` | |
| 158 | +HTML ──▶ ① Extraction DOM (extractor.js, injecté à documentEnd, zéro IA) | |
| 159 | + ──▶ ② Blocs typés (titre, paragraphe, code, tableau…) + chemins DOM | |
| 160 | + ──▶ ③ Budgétisation (contextSize / tokenCount, 30 % réservés à la réponse) | |
| 161 | + ──▶ ④ Condensation déterministe (jamais de coupe en pleine phrase) | |
| 162 | + ──▶ ⑤ Génération guidée (@Generable PageDigest — jamais de texte libre à parser) | |
| 163 | +``` | |
| 164 | + | |
| 165 | +- **Cache par hash de contenu** : une même page n'est jamais distillée deux fois. | |
| 166 | +- **File d'inférence** : max 2 requêtes en vol, priorité geste > page active > arrière-plan. | |
| 167 | +- **Honnêteté structurelle** : chaque élément généré porte l'index de son bloc source ; | |
| 168 | + un résumé sans ancre ne s'affiche pas ; les refus du modèle sont des états calmes, | |
| 169 | + jamais des erreurs système brutes. | |
| 170 | +- **Dégradation totale** : sur un appareil sans Foundation Models (< A17 Pro), *tout* | |
| 171 | + fonctionne — lecteur, zoom, historique, favoris — via les chemins déterministes. | |
| 172 | + La suite de tests tourne avec le modèle indisponible. | |
| 173 | + | |
| 174 | +--- | |
| 175 | + | |
| 176 | +## 🏛 Architecture | |
| 177 | + | |
| 178 | +``` | |
| 179 | +Prisme/ | |
| 180 | +├─ App/ point d'entrée, modèle racine | |
| 181 | +├─ Browser/ | |
| 182 | +│ ├─ Engine/ WKWebView (pool, proxy, délégués, règles) — seul accès WebKit | |
| 183 | +│ ├─ Tabs/ onglets, insight de page | |
| 184 | +│ ├─ Chrome/ barre à intention, bandeau, toolbar, sélecteur | |
| 185 | +│ └─ Reader/ zoom sémantique (4 niveaux, pincement) | |
| 186 | +├─ Intelligence/ | |
| 187 | +│ ├─ Router/ choix du tier + disponibilité du modèle | |
| 188 | +│ ├─ Distiller/ extractor.js, blocs, budgets, cache ⚠️ cœur du projet | |
| 189 | +│ ├─ Schemas/ types @Generable (PageDigest…) | |
| 190 | +│ └─ Sessions/ file d'inférence à priorité | |
| 191 | +├─ Memory/ | |
| 192 | +│ ├─ Index/ embeddings de phrases (NaturalLanguage) | |
| 193 | +│ ├─ Snapshots/ visites SwiftData + textes archivés | |
| 194 | +│ └─ Recall/ recherche naturelle, vue historique | |
| 195 | +├─ Library/ extraits + favoris structurés (SwiftData) | |
| 196 | +├─ Privacy/ conteneurs d'identité | |
| 197 | +└─ Design/ tokens, logo PrismMark (Canvas) | |
| 198 | +``` | |
| 199 | + | |
| 200 | +Règles d'or du code : | |
| 201 | + | |
| 202 | +- `WKWebView` enveloppée **une seule fois** (`Browser/Engine`) ; le reste de l'app parle à `WebPageProxy`. | |
| 203 | +- **Jamais de `String` libre en sortie de modèle** — toujours `@Generable`. | |
| 204 | +- Le JS injecté vit dans des fichiers `.js` versionnés, jamais dans des chaînes Swift. | |
| 205 | +- Chaque store SwiftData a **son fichier** (`history.store`, `library.store`). | |
| 206 | +- Chaque fichier commence par l'en-tête d'auteur. | |
| 207 | + | |
| 208 | +--- | |
| 209 | + | |
| 210 | +## 📊 Métriques | |
| 211 | + | |
| 212 | +| Métrique | Valeur | | |
| 213 | +|---|---| | |
| 214 | +| Fichiers Swift | **35** | | |
| 215 | +| Lignes Swift | **~4 100** | | |
| 216 | +| JavaScript injecté | **144 lignes** (extraction + sélection + scroll-to-source) | | |
| 217 | +| Dépendances tierces | **0** — uniquement les frameworks Apple | | |
| 218 | +| Tests UI de bout en bout | **6/6 ✓** | | |
| 219 | +| Cible de déploiement | iOS 26.4+ (requis par `tokenCount(for:)`) | | |
| 220 | +| Concurrence | Swift 6, `SWIFT_STRICT_CONCURRENCY = complete` | | |
| 221 | +| Règles de blocage | 14 familles de traqueurs (troisième partie) | | |
| 222 | + | |
| 223 | +Répartition par module (fichiers Swift) : | |
| 224 | + | |
| 225 | +| Module | Fichiers | Rôle | | |
| 226 | +|---|:---:|---| | |
| 227 | +| `Browser/` | 14 | moteur, onglets, chrome, lecteur | | |
| 228 | +| `Intelligence/` | 7 | routeur, distiller, schémas, sessions | | |
| 229 | +| `Memory/` | 4 | index, snapshots, rappel | | |
| 230 | +| `Library/` | 3 | extraits, favoris structurés | | |
| 231 | +| `App/` · `Design/` · `Privacy/` | 6 | racine, tokens, conteneurs | | |
| 232 | +| `PrismeUITests/` | 1 | 6 tests E2E | | |
| 233 | + | |
| 234 | +--- | |
| 235 | + | |
| 236 | +## 🛠 Compiler et lancer | |
| 237 | + | |
| 238 | +Prérequis : **Xcode 26.6+** (SDK iOS 26.4+), [XcodeGen](https://github.com/yonaskolb/XcodeGen). | |
| 239 | + | |
| 240 | +```bash | |
| 241 | +git clone https://git.spboucher.ai/prisme.git | |
| 242 | +cd prisme | |
| 243 | +xcodegen generate # génère Prisme.xcodeproj depuis project.yml | |
| 244 | +open Prisme.xcodeproj # ⌘R sur un simulateur iPhone | |
| 245 | +``` | |
| 246 | + | |
| 247 | +En ligne de commande : | |
| 248 | + | |
| 249 | +```bash | |
| 250 | +xcodebuild -project Prisme.xcodeproj -scheme Prisme \ | |
| 251 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ | |
| 252 | + build CODE_SIGNING_ALLOWED=NO | |
| 253 | +``` | |
| 254 | + | |
| 255 | +L'icône se régénère depuis le SVG source : | |
| 256 | + | |
| 257 | +```bash | |
| 258 | +rsvg-convert -w 1024 -h 1024 Design/icon/prisme-icon.svg \ | |
| 259 | + -o Prisme/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png | |
| 260 | +``` | |
| 261 | + | |
| 262 | +> **Note simulateur** : `SystemLanguageModel` s'y déclare disponible mais échoue à | |
| 263 | +> l'inférence (assets absents). Les chemins déterministes prennent le relais — c'est | |
| 264 | +> le comportement attendu. Pour les digests réels : iPhone A17 Pro+ avec Apple | |
| 265 | +> Intelligence activée. | |
| 266 | + | |
| 267 | +--- | |
| 268 | + | |
| 269 | +## ✅ Tests | |
| 270 | + | |
| 271 | +Six tests XCUITest de bout en bout — réseau réel, gestes réels, zéro mock : | |
| 272 | + | |
| 273 | +| Test | Ce qu'il prouve | | |
| 274 | +|---|---| | |
| 275 | +| `testNavigateToURLLoadsPage` | Saisie → proposition « Aller sur » → la page charge, cadenas + domaine | | |
| 276 | +| `testSearchProposalsOfferBothEngines` | Chaque requête propose DuckDuckGo **et** Google | | |
| 277 | +| `testGoogleSearchLoadsGoogle` | La recherche Google aboutit sur Google | | |
| 278 | +| `testReaderSemanticZoomLevels` | Bandeau → lecteur → 4 niveaux → retour page brute | | |
| 279 | +| `testSemanticHistoryRecallInAddressBar` | Visite → distillation → rappel « Déjà visité » → réouverture | | |
| 280 | +| `testStructuredFavoriteFromReaderAppearsInLibrary` | Signet lecteur → favori en données natives dans la bibliothèque | | |
| 281 | + | |
| 282 | +```bash | |
| 283 | +xcodebuild -project Prisme.xcodeproj -scheme Prisme \ | |
| 284 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ | |
| 285 | + test CODE_SIGNING_ALLOWED=NO | |
| 286 | +``` | |
| 287 | + | |
| 288 | +--- | |
| 289 | + | |
| 290 | +## 🔒 Vie privée | |
| 291 | + | |
| 292 | +- **Aucun serveur.** Prisme n'opère aucun backend ; rien à héberger, rien qui fuite. | |
| 293 | +- **Aucun compte, aucune télémétrie, aucun identifiant.** | |
| 294 | +- **IA 100 % on-device** ; l'escalade cloud (Private Cloud Compute) sera explicite, | |
| 295 | + visible, et interdite sans exception sur le contenu des conteneurs sensibles. | |
| 296 | +- **Recherche par défaut : DuckDuckGo** ; Google proposé à chaque requête, jamais imposé. | |
| 297 | +- **L'univers « Recherche sensible » n'existe pas pour la mémoire** : ni historique, | |
| 298 | + ni index, ni snapshot. | |
| 299 | + | |
| 300 | +--- | |
| 301 | + | |
| 302 | +## 🗺 Feuille de route | |
| 303 | + | |
| 304 | +Ordre de construction strict — chaque étape solide avant la suivante : | |
| 305 | + | |
| 306 | +- [x] **1. Navigateur nu, excellent** — onglets, pool, gestes, blocage, conteneurs | |
| 307 | +- [x] **2. Distiller + cache** — le cœur invisible dont tout dépend | |
| 308 | +- [x] **3. Zoom sémantique + rendu adaptatif** — la démo | |
| 309 | +- [x] **4. Historique sémantique** — la valeur qui s'accumule | |
| 310 | +- [x] **5. Favoris repensés (P0)** — l'extrait, le favori structuré | |
| 311 | +- [ ] 5bis. **Favori vivant** (`BGTaskScheduler`) + favori-question | |
| 312 | +- [ ] **6. Vie privée avancée** — patterns manipulateurs, traducteur de conditions | |
| 313 | +- [ ] **7. Agent** — portée étroite, jamais d'action irréversible sans confirmation | |
| 314 | + | |
| 315 | +--- | |
| 316 | + | |
| 317 | +## 👤 Auteur | |
| 318 | + | |
| 319 | +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 320 | + | |
| 321 | +Licence [MIT](LICENSE) © 2026 Simon-Pierre Boucher | |
| 322 | + | |
| 323 | +<div align="center"> | |
| 324 | +<sub>Construit en SwiftUI, distillé sur l'appareil, rien ne quitte votre iPhone.</sub> | |
| 325 | +</div> | |
added
docs/screenshots/history-recall.png
+0 −0
Binary file not shown.
added
docs/screenshots/icon.png
+0 −0
Binary file not shown.
added
docs/screenshots/insight-strip.png
+0 −0
Binary file not shown.
added
docs/screenshots/intent-bar.png
+0 −0
Binary file not shown.
added
docs/screenshots/library.png
+0 −0
Binary file not shown.
added
docs/screenshots/reader-essentiel.png
+0 −0
Binary file not shown.
added
docs/screenshots/reader-texte.png
+0 −0
Binary file not shown.
added
docs/screenshots/start-page.png
+0 −0
Binary file not shown.
added
project.yml
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +# project.yml — Prisme | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# | |
| 4 | +# XcodeGen spec. Regenerate the project with: xcodegen generate | |
| 5 | + | |
| 6 | +name: Prisme | |
| 7 | +options: | |
| 8 | + bundleIdPrefix: ai.spboucher | |
| 9 | + createIntermediateGroups: true | |
| 10 | + deploymentTarget: | |
| 11 | + iOS: "26.4" | |
| 12 | +settings: | |
| 13 | + base: | |
| 14 | + SWIFT_VERSION: "6.0" | |
| 15 | + SWIFT_STRICT_CONCURRENCY: complete | |
| 16 | + GENERATE_INFOPLIST_FILE: YES | |
| 17 | + INFOPLIST_KEY_CFBundleDisplayName: Prisme | |
| 18 | + INFOPLIST_KEY_UILaunchScreen_Generation: YES | |
| 19 | + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight | |
| 20 | + CURRENT_PROJECT_VERSION: 1 | |
| 21 | + MARKETING_VERSION: "0.1.0" | |
| 22 | + CODE_SIGN_STYLE: Automatic | |
| 23 | +targets: | |
| 24 | + Prisme: | |
| 25 | + type: application | |
| 26 | + platform: iOS | |
| 27 | + sources: | |
| 28 | + - Prisme | |
| 29 | + settings: | |
| 30 | + base: | |
| 31 | + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.prisme | |
| 32 | + TARGETED_DEVICE_FAMILY: "1,2" | |
| 33 | + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon | |
| 34 | + scheme: | |
| 35 | + testTargets: | |
| 36 | + - PrismeUITests | |
| 37 | + PrismeUITests: | |
| 38 | + type: bundle.ui-testing | |
| 39 | + platform: iOS | |
| 40 | + sources: | |
| 41 | + - PrismeUITests | |
| 42 | + dependencies: | |
| 43 | + - target: Prisme | |
| 44 | + settings: | |
| 45 | + base: | |
| 46 | + # The iOS 26.5 sim runtime lacks lib_TestingInterop.dylib; embedding | |
| 47 | + # Swift Testing crashes the XCTest runner at launch. XCTest only. | |
| 48 | + ENABLE_SWIFT_TESTING: NO | |
| 49 | ||