SPB Git

spb/prisme Public MIT

Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.

Swift 96.7% JavaScript 3.3%

feat: initial commit

Simon-Pierre Boucher committed 3 h ago (Aug 12, 2026)

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 +[![Plateforme](https://img.shields.io/badge/iOS-26.4%2B-0A84FF?logo=apple&logoColor=white)](#-stack-technique)
22 +[![Swift](https://img.shields.io/badge/Swift-6.0-F05138?logo=swift&logoColor=white)](#-stack-technique)
23 +[![UI](https://img.shields.io/badge/SwiftUI-100%25-8B5CF6)](#-stack-technique)
24 +[![IA](https://img.shields.io/badge/IA-100%25%20on--device-34C759)](#-intelligence-locale)
25 +[![Tests](https://img.shields.io/badge/tests%20UI-6%2F6%20✓-success)](#-tests)
26 +[![Dépendances](https://img.shields.io/badge/d%C3%A9pendances%20tierces-0-success)](#-m%C3%A9triques)
27 +[![Concurrence](https://img.shields.io/badge/concurrence-stricte%20(Swift%206)-orange)](#-stack-technique)
28 +[![Licence](https://img.shields.io/badge/licence-MIT-lightgrey)](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