{"type":"user_prompt","text":"Fait ce travail majeur sur job ka Prompt — Upgrade visuel majeur de Job-Ka\n\nFais un upgrade majeur du front-end de Job-Ka en conservant les fonctionnalités, l’ADN Groupe KA et la couleur cyan/bleu signature.\n\nLe problème actuel : l’interface est encore trop boxy, trop remplie de champs et de cartes avec bordures, et donne parfois un rendu template / AI-generated. Je veux une version beaucoup plus premium, éditoriale, claire, fluide et distinctive.\n\nObjectifs :\n\n* réduire fortement les rectangles, bordures et cards inutiles;\n* rendre la recherche d’emploi beaucoup plus élégante et centrale;\n* mieux intégrer les filtres pour éviter l’effet “formulaire lourd”;\n* améliorer fortement les cartes d’offres pour leur donner plus de rythme, de hiérarchie et de personnalité;\n* rendre les infos clés ultra lisibles : titre, entreprise, ville, salaire, mode de travail, séniorité, date;\n* créer une expérience mobile haut de gamme, avec transitions fluides et meilleure navigation;\n* conserver l’esprit data-driven, direct source, zéro intermédiaire;\n* donner à Job-Ka un look plus crédible, plus sharp, plus “produit sérieux”, entre job board moderne, média carrière et outil pro.\n\nDirection souhaitée\n\nJob-Ka doit évoquer :\n\n* rapidité\n* clarté\n* marché de l’emploi en direct\n* transparence salariale\n* intelligence de recherche\n* design sobre mais fort\n\nÀ faire\n\nRepenser surtout :\n\n* le hero\n* la barre de recherche\n* les filtres\n* la liste des emplois\n* les tags/badges\n* la navigation mobile\n* la hiérarchie visuelle des résultats\n\nRègle :\n\nSi un composant ressemble à un composant générique qu’on verrait sur 500 sites générés par IA, redesign-le.\n\nJe ne veux pas un simple polish. Je veux une vraie montée en gamme visuelle qui fasse de Job-Ka un produit immédiatement reconnaissable dans l’écosystème KA.","ts":1787681828479} {"type":"status","state":"running"} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/job-ka","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","Monitor","NotebookEdit","PushNotification","Read","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[{"name":"maclustr-cluster","status":"pending"},{"name":"drive","status":"pending"}],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["cluster","cluster-compute","cluster-deploy","cluster-deploy-app","cluster-run","cluster-status","deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","agents","clear","compact","config","context","heapdump","init","__remote-workflow","reload-skills","review","security-review","usage","insights","recap","goal","design","design-consent","design-revoke","team-onboarding"],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"2.1.204","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"55bb7887-ba8e-468e-9ab5-4fffbe02cf39","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-job-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8aeef374-698c-4a71-81a4-9e9ca1abec7e","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"8dd9340b-2028-4ef2-a638-b9c09e75102f","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":106,"estimated_tokens_delta":56,"uuid":"81bfe3bf-1702-4634-ae98-735b3c7d2951","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvcpZrVxjQ7y7qsMRdz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISngMKpwEIERgCKkCMs8AJhrF8DPEebJ4A6jyS2BW+tIk6QxPVpNQUuuMKy5WS8PE3QF7bZ7HJ6Mj5+yLVWZhcJt0pJYjxT5Pgu6aSMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGouLfUBrABAhIMQq7to58/V6FAu3heGgxr56T8hGn6L70aOXgiME6feqiVGGBSQCjN0ipSkmqyND9bRWFFzRB8On4H3pA0Gm03Y3IdNSxtrp9tdYVObCqjAdHRb6s0qxSz5Z4LofXd/ekBi+WqBXIfOg19+QjaVCMi0J43ECWH7LTDQSY/0saCkDKswXGR9SxXtMAXGV/Xal1uvbri8Qxd8kc8VSNHuiR92RO7IFSibHFdBLsRRc5/eD7y5KQVwgrvxm/sy0AZ6wayPfTlO2S1zswdUEI/nb0Q4cV9zbRBSfgsbk5n8Xh5/rsrBJ5xQZTyUcqaJiCU4bZ60WEYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18855,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18855,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"03e44d35-3c5d-47c0-8fcc-453161a5e629","request_id":"req_011CePvcoA2Ah9pd7gCmEp19"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvcpZrVxjQ7y7qsMRdz","type":"message","role":"assistant","content":[{"type":"text","text":"Je démarre par un état des lieux du front-end de Job-Ka pour comprendre la stack et la structure avant de redesigner."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18855,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18855,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"39eca9ec-30ae-4b9d-8b81-3cab6ded63c4","request_id":"req_011CePvcoA2Ah9pd7gCmEp19"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvcpZrVxjQ7y7qsMRdz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0195KoWRJNJYAbqriEPW7QWV","name":"Bash","input":{"command":"ls /Users/simon-pierreboucher/apps/job-ka && cat /Users/simon-pierreboucher/apps/job-ka/package.json 2>/dev/null; ls /Users/simon-pierreboucher/apps/job-ka/frontend 2>/dev/null","description":"List job-ka project structure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18855,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18855,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"feedca07-3ff0-47bd-854e-32d3a95222cf","request_id":"req_011CePvcoA2Ah9pd7gCmEp19"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0195KoWRJNJYAbqriEPW7QWV","type":"tool_result","content":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\njobka\nrequirements.txt\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","is_error":false}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"33adae11-a0c7-47a9-ae99-8e64767c2242","timestamp":"2026-08-25T18:17:15.479Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\njobka\nrequirements.txt\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5314d107-e9e9-4fe7-adb8-04903755ec8d","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvdJpiTxKZ77oqtHqpt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KMs8Vh1s1d6wVHvyXhVSRa","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/job-ka/frontend/package.json && find /Users/simon-pierreboucher/apps/job-ka/frontend/src -type f | head -60 && pm2 list 2>/dev/null | grep -i job","description":"Inspect frontend package.json and src tree"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3524,"cache_read_input_tokens":34054,"cache_creation":{"ephemeral_5m_input_tokens":3524,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"ef054c86-48dc-46b2-96de-18e9c898c1bf","request_id":"req_011CePvdHPeRrWaSZHzMYGx2"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KMs8Vh1s1d6wVHvyXhVSRa","type":"tool_result","content":"{\n \"name\": \"jobka-frontend\",\n \"private\": true,\n \"version\": \"1.0.0\",\n \"description\": \"Frontend Job·Ka — agrégateur d'offres d'emploi québécoises (Groupe KA)\",\n \"author\": \"Simon-Pierre Boucher \",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"tsc -b && vite build\",\n \"preview\": \"vite preview\"\n },\n \"dependencies\": {\n \"maplibre-gl\": \"^4.7.1\",\n \"react\": \"^18.3.1\",\n \"react-dom\": \"^18.3.1\",\n \"react-router-dom\": \"^6.26.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"^18.3.5\",\n \"@types/react-dom\": \"^18.3.0\",\n \"@vitejs/plugin-react\": \"^4.3.1\",\n \"typescript\": \"^5.5.4\",\n \"vite\": \"^5.4.3\"\n }\n}\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/main.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/favorites.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/KaFooter.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/ecosystem.json\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/GroupeKaBadge.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Employeurs.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Contact.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/MapPage.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/kapdf.py\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/kacharts.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/SPEC.md\n│ 33 │ job-ka-ngrok │ default │ N/A │ fork │ 96937 │ 7D │ 0 │ online │ 0% │ 247.1mb │ simon-p… │ disabled │\n│ 32 │ job-ka-sync │ default │ N/A │ fork │ 47907 │ 3h │ 10 │ online │ 0% │ 398.5mb │ simon-p… │ disabled │\n│ 31 │ job-ka-web │ default │ N/A │ fork │ 76934 │ 3h │ 30 │ online │ 0% │ 100.0mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"a8b3773c-1951-4a0d-b9dd-57bc4ace0e14","timestamp":"2026-08-25T18:17:19.570Z","tool_use_result":{"stdout":"{\n \"name\": \"jobka-frontend\",\n \"private\": true,\n \"version\": \"1.0.0\",\n \"description\": \"Frontend Job·Ka — agrégateur d'offres d'emploi québécoises (Groupe KA)\",\n \"author\": \"Simon-Pierre Boucher \",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"tsc -b && vite build\",\n \"preview\": \"vite preview\"\n },\n \"dependencies\": {\n \"maplibre-gl\": \"^4.7.1\",\n \"react\": \"^18.3.1\",\n \"react-dom\": \"^18.3.1\",\n \"react-router-dom\": \"^6.26.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"^18.3.5\",\n \"@types/react-dom\": \"^18.3.0\",\n \"@vitejs/plugin-react\": \"^4.3.1\",\n \"typescript\": \"^5.5.4\",\n \"vite\": \"^5.4.3\"\n }\n}\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/main.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/favorites.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/KaFooter.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/ecosystem.json\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/GroupeKaBadge.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Employeurs.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Contact.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/MapPage.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/kapdf.py\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/kacharts.tsx\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats/SPEC.md\n│ 33 │ job-ka-ngrok │ default │ N/A │ fork │ 96937 │ 7D │ 0 │ online │ 0% │ 247.1mb │ simon-p… │ disabled │\n│ 32 │ job-ka-sync │ default │ N/A │ fork │ 47907 │ 3h │ 10 │ online │ 0% │ 398.5mb │ simon-p… │ disabled │\n│ 31 │ job-ka-web │ default │ N/A │ fork │ 76934 │ 3h │ 30 │ online │ 0% │ 100.0mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0b2645dd-15c8-4b5d-a45a-517179c0732e","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"651ba4aa-ad5e-4796-9308-db1103f183a6","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":108,"estimated_tokens_delta":58,"uuid":"bbe9022e-faf6-4918-acae-e83d2559e81c","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvdcjEfuP2L3je2S3xv","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISpgMKpwEIERgCKkDWwEB//MaxY5RPIXH+5lGlbjUUELOFyCB9AA6MR7/J6BYf6XG9Ok+YYJeBEoJwdNb2Z61IztKgS+zvZhSFPtLNMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGzuLfUBrABAhIM96BiMvXLSWNpgeTNGgz/D4eg8fPSRV/hE98iMBSPNP9WC8/S/e5et6B4WJ8lGsLtzVdUb81XkH0ivI4fn/7lZmN93t5towSWDAfmtiqrAZPjiLUlVmTJ6Cfdxm+lH9+o2tJXt27EnBatRsTVOrgQssU2MopHfIpOIn/d5HwACGswMmRPD8jJiWdixVoCsYIBouzP2OtxcIsQyp1b6P8tVfgEnhxCYir51B0+r0a1n3FwwOa9y/prcUBiOgWHhSAUa8qsp3QUDv2366G6bi+vwnMyWp79aIGxP+ymTek6RC7jbKy56249hbUWGK/a2ItCcluXAGvsG6aWihgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1609,"cache_read_input_tokens":37578,"cache_creation":{"ephemeral_5m_input_tokens":1609,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"ae6e189a-d29b-4838-8bfa-d45df2ec7031","request_id":"req_011CePvdatMw8Wc58Gk2qXPR"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvdcjEfuP2L3je2S3xv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FNmvxneTLBAk6cmguVSuXJ","name":"Bash","input":{"command":"wc -l /Users/simon-pierreboucher/apps/job-ka/frontend/src/*.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/*.css /Users/simon-pierreboucher/apps/job-ka/frontend/src/*.ts /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/*.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/*.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/*.css /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/*.tsx","description":"Count lines of frontend source files"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1609,"cache_read_input_tokens":37578,"cache_creation":{"ephemeral_5m_input_tokens":1609,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"6ea17788-8d7b-4349-b89b-e4ca9c8f567d","request_id":"req_011CePvdatMw8Wc58Gk2qXPR"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FNmvxneTLBAk6cmguVSuXJ","type":"tool_result","content":" 215 /Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx\n 139 /Users/simon-pierreboucher/apps/job-ka/frontend/src/favorites.tsx\n 24 /Users/simon-pierreboucher/apps/job-ka/frontend/src/main.tsx\n 428 /Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css\n 265 /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n 70 /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n 72 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Contact.tsx\n 173 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Employeurs.tsx\n 94 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx\n 160 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n 216 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n 98 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/MapPage.tsx\n 52 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx\n 202 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx\n 149 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx\n 319 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css\n 21 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/GroupeKaBadge.tsx\n 91 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/KaFooter.tsx\n 2788 total","is_error":false}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"c26280a1-df71-4442-ad97-69e7f5e1e189","timestamp":"2026-08-25T18:17:25.651Z","tool_use_result":{"stdout":" 215 /Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx\n 139 /Users/simon-pierreboucher/apps/job-ka/frontend/src/favorites.tsx\n 24 /Users/simon-pierreboucher/apps/job-ka/frontend/src/main.tsx\n 428 /Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css\n 265 /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n 70 /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n 72 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Contact.tsx\n 173 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Employeurs.tsx\n 94 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx\n 160 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n 216 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n 98 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/MapPage.tsx\n 52 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx\n 202 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx\n 149 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx\n 319 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css\n 21 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/GroupeKaBadge.tsx\n 91 /Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/KaFooter.tsx\n 2788 total","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"228c9535-1e10-4b00-949e-1182f9eccafb","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePve4K7vCNpEX3VypxgW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Qxcfcr5muGnUK9bggppYuZ","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1135,"cache_read_input_tokens":39187,"cache_creation":{"ephemeral_5m_input_tokens":1135,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"362f4fdb-de16-488f-9321-6484adbcf6b6","request_id":"req_011CePve2xGN8Hmrj1hWFWRT"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Qxcfcr5muGnUK9bggppYuZ","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/App.tsx\n7\t * Rôle : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,\n8\t * navigation, routes, pied de page commun Groupe KA)\n9\t * Créé : 2026-08-17 Modifié : 2026-08-17\n10\t * =============================================================================\n11\t */\n12\timport { lazy, Suspense, useEffect, useState } from \"react\";\n13\timport { NavLink, Route, Routes, useLocation } from \"react-router-dom\";\n14\timport Home from \"./pages/Home\";\n15\timport JobPage from \"./pages/Job\";\n16\timport Sources from \"./pages/Sources\";\n17\timport Stats from \"./pages/Stats\";\n18\timport Contact from \"./pages/Contact\";\n19\timport Employeurs from \"./pages/Employeurs\";\n20\timport VillePage, { VillesPage } from \"./pages/Ville\";\n21\timport FavorisPage from \"./pages/Favoris\";\n22\timport { FavProvider } from \"./favorites\";\n23\timport GroupeKaBadge from \"./ka/GroupeKaBadge\";\n24\timport KaFooter from \"./ka/KaFooter\";\n25\t\n26\t// MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte\n27\tconst MapPage = lazy(() => import(\"./pages/MapPage\"));\n28\t\n29\tinterface Me { name: string; email: string; picture: string; ka_id: string }\n30\t\n31\t/** Connexion KA ID (hub groupe-ka.com) — bouton ou profil connecté. */\n32\tfunction KaAuth() {\n33\t const [enabled, setEnabled] = useState(false);\n34\t const [me, setMe] = useState(null);\n35\t\n36\t useEffect(() => {\n37\t fetch(\"/api/auth/config\")\n38\t .then((r) => r.json())\n39\t .then((c) => setEnabled(Boolean(c.ka)))\n40\t .catch(() => {});\n41\t fetch(\"/api/me\")\n42\t .then((r) => (r.ok ? r.json() : null))\n43\t .then(setMe)\n44\t .catch(() => {});\n45\t }, []);\n46\t\n47\t const logout = () => {\n48\t fetch(\"/api/auth/logout\", { method: \"POST\" })\n49\t .then(() => setMe(null))\n50\t .catch(() => {});\n51\t };\n52\t\n53\t if (!enabled) return null;\n54\t if (me) {\n55\t return (\n56\t \n57\t {me.picture ? \"\" : null}\n58\t {me.name || me.email}\n59\t \n60\t \n61\t );\n62\t }\n63\t return (\n64\t \n65\t Se connecter avec KA\n66\t \n67\t );\n68\t}\n69\t\n70\t/** Liens de navigation principaux (header desktop + menu mobile). */\n71\tconst NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [\n72\t { to: \"/\", label: \"Offres\", end: true },\n73\t { to: \"/carte\", label: \"Carte\" },\n74\t { to: \"/favoris\", label: \"Favoris ♥\" },\n75\t { to: \"/sources\", label: \"Employeurs\" },\n76\t { to: \"/stats\", label: \"Stats\" },\n77\t { to: \"/employeurs\", label: \"Publier\" },\n78\t { to: \"/contact\", label: \"Contact\" },\n79\t];\n80\t\n81\texport default function App() {\n82\t const [menuOpen, setMenuOpen] = useState(false);\n83\t const location = useLocation();\n84\t\n85\t // Hauteur réelle du header → --header-h sur (la carte s'y cale :\n86\t // le 58 px codé en dur était faux quand le header wrappe sur 2 lignes).\n87\t useEffect(() => {\n88\t const el = document.querySelector(\"header.site\");\n89\t if (!el) return;\n90\t const set = () =>\n91\t document.documentElement.style.setProperty(\"--header-h\", `${el.offsetHeight}px`);\n92\t set();\n93\t const ro = new ResizeObserver(set);\n94\t ro.observe(el);\n95\t return () => ro.disconnect();\n96\t }, []);\n97\t\n98\t // Menu mobile : fermeture au changement de route.\n99\t useEffect(() => {\n100\t setMenuOpen(false);\n101\t }, [location.pathname]);\n102\t\n103\t // Menu ouvert : verrou du scroll d'arrière-plan + fermeture à Escape (avec cleanup).\n104\t useEffect(() => {\n105\t if (!menuOpen) return;\n106\t const prev = document.body.style.overflow;\n107\t document.body.style.overflow = \"hidden\";\n108\t const onKey = (e: KeyboardEvent) => {\n109\t if (e.key === \"Escape\") setMenuOpen(false);\n110\t };\n111\t window.addEventListener(\"keydown\", onKey);\n112\t return () => {\n113\t document.body.style.overflow = prev;\n114\t window.removeEventListener(\"keydown\", onKey);\n115\t };\n116\t }, [menuOpen]);\n117\t\n118\t return (\n119\t \n120\t
\n121\t
\n122\t Job·Ka\n123\t \n124\t \n136\t
\n137\t \n138\t
\n139\t setMenuOpen((o) => !o)}\n145\t >\n146\t \n147\t \n148\t \n149\t \n150\t
\n151\t
\n152\t {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du\n153\t header est masqué à l'ouverture */}\n154\t setMenuOpen(false)}\n159\t tabIndex={menuOpen ? undefined : -1}\n160\t >\n161\t ✕\n162\t \n163\t {NAV_LINKS.map((l) => (\n164\t (isActive ? \"active\" : \"\")}\n169\t onClick={() => setMenuOpen(false)}\n170\t tabIndex={menuOpen ? undefined : -1}\n171\t >\n172\t {l.label}\n173\t \n174\t ))}\n175\t
\n176\t
\n177\t {menuOpen &&
setMenuOpen(false)} />}\n178\t \n179\t } />\n180\t Chargement de la carte…
}>\n182\t \n183\t \n184\t } />\n185\t } />\n186\t } />\n187\t } />\n188\t } />\n189\t } />\n190\t } />\n191\t } />\n192\t } />\n193\t

Page introuvable

} />\n194\t \n195\t
\n196\t
\n197\t \n198\t Job-Ka — Tous les emplois des employeurs québécois, directement\n199\t à la source. Chaque offre est traçable à sa source originale, zéro\n200\t boîte noire.\n201\t \n202\t Offres\n203\t Mes favoris\n204\t Villes\n205\t Carte\n206\t Employeurs\n207\t Stats\n208\t Publier une offre\n209\t Contact\n210\t
\n211\t
\n212\t \n213\t
\n214\t );\n215\t}\n216\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"9c44145d-c843-4e4c-959a-babaa9548949","timestamp":"2026-08-25T18:17:28.868Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/App.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/App.tsx\n * Rôle : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,\n * navigation, routes, pied de page commun Groupe KA)\n * Créé : 2026-08-17 Modifié : 2026-08-17\n * =============================================================================\n */\nimport { lazy, Suspense, useEffect, useState } from \"react\";\nimport { NavLink, Route, Routes, useLocation } from \"react-router-dom\";\nimport Home from \"./pages/Home\";\nimport JobPage from \"./pages/Job\";\nimport Sources from \"./pages/Sources\";\nimport Stats from \"./pages/Stats\";\nimport Contact from \"./pages/Contact\";\nimport Employeurs from \"./pages/Employeurs\";\nimport VillePage, { VillesPage } from \"./pages/Ville\";\nimport FavorisPage from \"./pages/Favoris\";\nimport { FavProvider } from \"./favorites\";\nimport GroupeKaBadge from \"./ka/GroupeKaBadge\";\nimport KaFooter from \"./ka/KaFooter\";\n\n// MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte\nconst MapPage = lazy(() => import(\"./pages/MapPage\"));\n\ninterface Me { name: string; email: string; picture: string; ka_id: string }\n\n/** Connexion KA ID (hub groupe-ka.com) — bouton ou profil connecté. */\nfunction KaAuth() {\n const [enabled, setEnabled] = useState(false);\n const [me, setMe] = useState(null);\n\n useEffect(() => {\n fetch(\"/api/auth/config\")\n .then((r) => r.json())\n .then((c) => setEnabled(Boolean(c.ka)))\n .catch(() => {});\n fetch(\"/api/me\")\n .then((r) => (r.ok ? r.json() : null))\n .then(setMe)\n .catch(() => {});\n }, []);\n\n const logout = () => {\n fetch(\"/api/auth/logout\", { method: \"POST\" })\n .then(() => setMe(null))\n .catch(() => {});\n };\n\n if (!enabled) return null;\n if (me) {\n return (\n \n {me.picture ? \"\" : null}\n {me.name || me.email}\n \n \n );\n }\n return (\n \n Se connecter avec KA\n \n );\n}\n\n/** Liens de navigation principaux (header desktop + menu mobile). */\nconst NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [\n { to: \"/\", label: \"Offres\", end: true },\n { to: \"/carte\", label: \"Carte\" },\n { to: \"/favoris\", label: \"Favoris ♥\" },\n { to: \"/sources\", label: \"Employeurs\" },\n { to: \"/stats\", label: \"Stats\" },\n { to: \"/employeurs\", label: \"Publier\" },\n { to: \"/contact\", label: \"Contact\" },\n];\n\nexport default function App() {\n const [menuOpen, setMenuOpen] = useState(false);\n const location = useLocation();\n\n // Hauteur réelle du header → --header-h sur (la carte s'y cale :\n // le 58 px codé en dur était faux quand le header wrappe sur 2 lignes).\n useEffect(() => {\n const el = document.querySelector(\"header.site\");\n if (!el) return;\n const set = () =>\n document.documentElement.style.setProperty(\"--header-h\", `${el.offsetHeight}px`);\n set();\n const ro = new ResizeObserver(set);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Menu mobile : fermeture au changement de route.\n useEffect(() => {\n setMenuOpen(false);\n }, [location.pathname]);\n\n // Menu ouvert : verrou du scroll d'arrière-plan + fermeture à Escape (avec cleanup).\n useEffect(() => {\n if (!menuOpen) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setMenuOpen(false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => {\n document.body.style.overflow = prev;\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [menuOpen]);\n\n return (\n \n
\n
\n Job·Ka\n \n \n
\n \n
\n setMenuOpen((o) => !o)}\n >\n \n \n \n \n
\n
\n {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du\n header est masqué à l'ouverture */}\n setMenuOpen(false)}\n tabIndex={menuOpen ? undefined : -1}\n >\n ✕\n \n {NAV_LINKS.map((l) => (\n (isActive ? \"active\" : \"\")}\n onClick={() => setMenuOpen(false)}\n tabIndex={menuOpen ? undefined : -1}\n >\n {l.label}\n \n ))}\n
\n
\n {menuOpen &&
setMenuOpen(false)} />}\n \n } />\n Chargement de la carte…
}>\n \n \n } />\n } />\n } />\n } />\n } />\n } />\n } />\n } />\n } />\n

Page introuvable

} />\n \n
\n
\n \n Job-Ka — Tous les emplois des employeurs québécois, directement\n à la source. Chaque offre est traçable à sa source originale, zéro\n boîte noire.\n \n Offres\n Mes favoris\n Villes\n Carte\n Employeurs\n Stats\n Publier une offre\n Contact\n
\n
\n \n
\n );\n}\n","numLines":216,"startLine":1,"totalLines":216}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePve4K7vCNpEX3VypxgW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GBficwmCMpR4rH2mXDqB3i","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1135,"cache_read_input_tokens":39187,"cache_creation":{"ephemeral_5m_input_tokens":1135,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"2088ac3b-f846-4f6c-acad-253c4c832b1e","request_id":"req_011CePve2xGN8Hmrj1hWFWRT"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GBficwmCMpR4rH2mXDqB3i","type":"tool_result","content":"1\t/*\n2\t=============================================================================\n3\tJob·Ka — Groupe KA\n4\tAuteur : Simon-Pierre Boucher\n5\tContact : contact@spboucher.ai\n6\tFichier : frontend/src/styles.css\n7\tRôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA\n8\t (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :\n9\t seulement l'accent de la marque + les classes métier (offres,\n10\t filtres, carte) au style « éditorial sharp » du groupe (bordures\n11\t encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle\n12\t (body, grain, sélection, .container, .btn, .card, .chip,\n13\t ka-footer…) vit dans tokens.css.\n14\tCréé : 2026-08-17 Modifié : 2026-08-25\n15\t=============================================================================\n16\t*/\n17\t\n18\t:root {\n19\t /* ---- ACCENT Job·Ka (cyan officiel — voir ka/ecosystem.json) ---- */\n20\t --accent: #0c8599;\n21\t --accent-soft: #def0f4;\n22\t --accent-deep: #095c6b;\n23\t --on-accent: #ffffff;\n24\t --lime: var(--accent);\n25\t --lime-soft: var(--accent-soft);\n26\t\n27\t /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */\n28\t --brand: var(--ink);\n29\t --border: var(--line);\n30\t --green-soft: #e7f2ec;\n31\t --radius: var(--r-card);\n32\t --shadow: var(--shadow-flat), 0 4px 14px rgb(20 24 20 / 6%);\n33\t}\n34\t\n35\ta { color: var(--green); text-decoration: none; }\n36\ta:hover { text-decoration: underline; }\n37\t\n38\t/* --- en-tête ---------------------------------------------------------------- */\n39\theader.site {\n40\t background: var(--brand);\n41\t color: #fff;\n42\t position: sticky; top: 0; z-index: var(--z-header, 500);\n43\t}\n44\t/* menu mobile ouvert : le header (et son panneau) passe au-dessus du backdrop */\n45\theader.site.menu-open { z-index: var(--z-modal, 900); }\n46\theader.site .container {\n47\t display: flex; align-items: center; flex-wrap: wrap;\n48\t gap: 4px 14px; min-height: 58px; padding-top: 8px; padding-bottom: 8px;\n49\t}\n50\t.logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: #fff; letter-spacing: -0.3px; white-space: nowrap; position: relative; }\n51\t/* extension tactile : le logo (63×31) atteint >=44px de cible sans changer le rendu */\n52\t.logo::after { content: \"\"; position: absolute; inset: -8px; }\n53\t.logo em { color: var(--accent-soft); font-style: normal; }\n54\theader.site .gk-badge { flex-shrink: 0; }\n55\tnav.main { display: flex; gap: 4px; margin-left: auto; }\n56\tnav.main a {\n57\t /* pilules font-display 700 (standard header/menu KA 2026-08-22), thème encre conservé */\n58\t color: #d7ddda; padding: 7px 12px; border-radius: 999px; font-weight: 700;\n59\t font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif); font-size: 13.5px;\n60\t letter-spacing: -0.01em; white-space: nowrap;\n61\t}\n62\tnav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; }\n63\tnav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n64\t\n65\t/* connexion KA ID (droite du header) */\n66\t.auth-box { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n67\t.btn-ka {\n68\t display: inline-flex; align-items: center; gap: 7px; min-height: 34px;\n69\t padding: 4px 13px; border: 1.5px solid rgb(245 243 238 / 45%);\n70\t border-radius: var(--r-pill); background: transparent; color: #f5f3ee;\n71\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n72\t letter-spacing: 0.07em; text-transform: uppercase; cursor: pointer;\n73\t white-space: nowrap; text-decoration: none;\n74\t}\n75\t.btn-ka b {\n76\t font-family: var(--font-display); font-size: 12px; text-transform: none;\n77\t letter-spacing: -0.02em; background: var(--accent); color: var(--on-accent);\n78\t border-radius: 5px; padding: 0 6px 1px;\n79\t}\n80\t.btn-ka:hover { border-color: var(--accent-soft); text-decoration: none; }\n81\t.auth-user { display: inline-flex; align-items: center; gap: 8px; color: #d7ddda; font-size: 13px; white-space: nowrap; }\n82\t.auth-user img { width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid rgb(245 243 238 / 45%); }\n83\t.auth-user button {\n84\t border: 0; background: none; color: #9aa5a0; font: inherit; font-size: 12px;\n85\t cursor: pointer; padding: 6px 0; min-height: 34px;\n86\t}\n87\t.auth-user button:hover { color: #fff; text-decoration: underline; }\n88\t\n89\t/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée\n90\t (défilement horizontal contenu — jamais de débordement de page) */\n91\t@media (max-width: 979px) {\n92\t nav.main {\n93\t order: 10; flex-basis: 100%; margin-left: 0;\n94\t overflow-x: auto; -webkit-overflow-scrolling: touch;\n95\t scrollbar-width: none; padding-bottom: 2px;\n96\t }\n97\t nav.main::-webkit-scrollbar { display: none; }\n98\t nav.main a { padding: 7px 9px; font-size: 13.5px; }\n99\t .auth-box { margin-left: auto; }\n100\t}\n101\t@media (max-width: 560px) {\n102\t header.site .gk-badge { padding: 2px 8px 3px; font-size: 9px; }\n103\t header.site .gk-badge b { font-size: 11px; }\n104\t .auth-user span.name { display: none; }\n105\t}\n106\t\n107\t/* --- menu mobile (hamburger) -------------------------------------------------\n108\t Sous 700 px, la nav en flex-wrap (2-3 lignes) est remplacée par un bouton\n109\t 44×44 + panneau déroulant sous le header ; backdrop fixe rendu HORS du\n110\t header (frère dans App.tsx), verrou de scroll géré côté React. */\n111\t.menu-btn {\n112\t display: none; width: 44px; height: 44px; margin-left: 4px;\n113\t flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n114\t flex-shrink: 0; background: none; border: 0; border-radius: 8px;\n115\t cursor: pointer; padding: 0;\n116\t}\n117\t.menu-btn:hover { background: rgb(255 255 255 / 8%); }\n118\t.menu-btn span {\n119\t display: block; width: 22px; height: 2px; background: #f5f3ee; border-radius: 2px;\n120\t transition: transform 0.2s ease, opacity 0.15s ease;\n121\t}\n122\t.menu-btn[aria-expanded=\"true\"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n123\t.menu-btn[aria-expanded=\"true\"] span:nth-child(2) { opacity: 0; }\n124\t.menu-btn[aria-expanded=\"true\"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n125\t/* KA Nav v2 (2026-08-25) : panneau PLEIN ÉCRAN en position:fixed inset:0 —\n126\t visible peu importe la position de scroll (l'ancien dropdown absolute 480px\n127\t se dessinait sous le header). Le rang du header (marque + burger→✕) reste\n128\t au-dessus du panneau via .menu-open .container. */\n129\t.mobile-menu {\n130\t display: none; position: fixed; inset: 0;\n131\t background: var(--brand);\n132\t padding: calc(var(--header-h, 58px) + 14px) 22px calc(24px + env(safe-area-inset-bottom));\n133\t overflow-y: auto; overscroll-behavior: contain;\n134\t flex-direction: column; counter-reset: mnav;\n135\t}\n136\t.mobile-menu.open { animation: mm-in 0.16s ease; }\n137\t@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }\n138\t/* burger masqué à l'ouverture : la fermeture passe par le ✕ fixe du panneau\n139\t (le burger vit dans le header — hors écran si le header n'est pas épinglé) */\n140\theader.site.menu-open .menu-btn { visibility: hidden; }\n141\t/* bouton de fermeture du panneau : fixe en haut à droite du viewport */\n142\t.mm-close {\n143\t position: fixed; top: 10px; right: 14px; z-index: 2;\n144\t width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;\n145\t border: 1.5px solid #f5f3ee; border-radius: 999px;\n146\t background: #f5f3ee; color: var(--brand);\n147\t font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0;\n148\t}\n149\t.mobile-menu a {\n150\t display: flex; align-items: baseline; gap: 13px; padding: 16px 2px;\n151\t color: #f5f3ee; font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif);\n152\t font-weight: 700; font-size: 21px; letter-spacing: -0.02em; line-height: 1.1;\n153\t border-bottom: 1.5px dashed rgb(255 255 255 / 18%);\n154\t}\n155\t.mobile-menu a::before {\n156\t counter-increment: mnav; content: \"0\" counter(mnav);\n157\t font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px;\n158\t font-weight: 700; color: var(--accent-soft);\n159\t}\n160\t.mobile-menu a:last-child { border-bottom: 0; }\n161\t.mobile-menu a:hover { color: #fff; text-decoration: none; }\n162\t.mobile-menu a.active { color: var(--accent-soft); }\n163\t.mm-backdrop {\n164\t position: fixed; inset: 0; background: rgb(16 18 16 / 45%);\n165\t z-index: var(--z-overlay, 800);\n166\t}\n167\t@media (max-width: 700px) {\n168\t nav.main { display: none; }\n169\t .menu-btn { display: flex; }\n170\t .mobile-menu.open { display: flex; }\n171\t}\n172\t\n173\t/* --- héros (encre — identité Job·Ka) ---------------------------------------- */\n174\t.hero { background: var(--brand); color: #fff; padding: 42px 0 36px; }\n175\t.hero .kicker { color: var(--accent-soft); }\n176\t.hero .kicker::before { background: var(--accent); }\n177\t.hero h1 { margin: 6px 0 8px; font-size: clamp(28px, 2.6vw + 16px, 42px); font-weight: 700; letter-spacing: -0.03em; color: #fff; line-height: 1.08; }\n178\t.hero h1 em {\n179\t /* surligné d'accent incliné — signature du groupe (.hl), déclinaison encre */\n180\t display: inline-block; font-style: normal; background: var(--accent);\n181\t color: var(--on-accent); border-radius: 8px; padding: 0 10px 2px;\n182\t transform: rotate(-1deg);\n183\t}\n184\t.hero p { margin: 0 0 22px; color: #b9c2be; max-width: 640px; }\n185\t\n186\t/* --- filtres (champs sharp : bordure encre + focus accent décalé) ------------ */\n187\t.filters { display: flex; flex-wrap: wrap; gap: 10px; }\n188\t.filters input[type=\"search\"], .filters select {\n189\t height: 44px; padding: 0 12px; border-radius: var(--r-ctl);\n190\t border: 1.5px solid var(--ink);\n191\t background: var(--surface); color: var(--ink); font: inherit; min-width: 130px;\n192\t}\n193\t.filters input[type=\"search\"]:focus, .filters select:focus {\n194\t outline: none; box-shadow: 3px 3px 0 var(--accent);\n195\t}\n196\t.filters input[type=\"search\"] { flex: 1 1 220px; }\n197\t.filters input[type=\"search\"]::placeholder { color: var(--ink-3); }\n198\t.filters label.check {\n199\t display: inline-flex; align-items: center; gap: 7px; background: var(--surface);\n200\t color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */\n201\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 0 14px;\n202\t height: 44px; cursor: pointer; user-select: none; white-space: nowrap;\n203\t}\n204\t.filters label.check:has(input:checked) {\n205\t background: var(--accent-soft); box-shadow: 3px 3px 0 var(--accent);\n206\t}\n207\t/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */\n208\t@media (max-width: 700px) {\n209\t .filters input[type=\"search\"], .filters select, .filters label.check { font-size: 16px; }\n210\t}\n211\t\n212\t/* --- cartes d'offres (sharp : bordure encre + ombre décalée) --------------------- */\n213\t.job-list { display: grid; gap: 14px; margin: 22px 0; }\n214\t.job-card {\n215\t display: block; background: var(--surface); border: 1.5px solid var(--ink);\n216\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n217\t padding: 18px 20px; color: inherit;\n218\t transition: transform 0.15s, box-shadow 0.15s;\n219\t}\n220\t.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n221\t.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n222\t.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n223\t.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n224\t.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n225\t\n226\t/* étiquettes façon .chip du groupe : mono, uppercase, pilule bordée */\n227\t.badge {\n228\t display: inline-flex; align-items: center; gap: 5px;\n229\t padding: 3px 10px; border-radius: var(--r-pill);\n230\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n231\t text-transform: uppercase; letter-spacing: 0.05em;\n232\t background: var(--surface-2); color: var(--ink-2);\n233\t border: 1.5px solid var(--line);\n234\t}\n235\t.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n236\t.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n237\t.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n238\t.badge.new { background: var(--accent); color: var(--on-accent); border-color: var(--accent-deep); }\n239\t.badge.seniority { background: var(--surface-2); color: var(--ink); border-color: var(--ink); }\n240\t.badge.salary .approx { font-style: normal; font-weight: 600; opacity: 0.75; margin-left: 5px; text-transform: none; letter-spacing: 0; }\n241\t\n242\t/* pied de carte : fraîcheur · avantages · plateforme source */\n243\t.jc-foot { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; }\n244\t.jc-foot .ats { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; }\n245\t\n246\t/* --- métriques « En un coup d'œil » (fiche d'offre) -------------------------- */\n247\t.metrics {\n248\t display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));\n249\t gap: 12px; margin: 12px 0 22px;\n250\t}\n251\t.metric {\n252\t background: var(--surface-2); border: 1.5px solid var(--line);\n253\t border-radius: var(--r-card); padding: 13px 15px;\n254\t}\n255\t.metric .m-value { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }\n256\t.metric .m-label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 3px; }\n257\t.metric .m-sub { color: var(--ink-3); font-size: 12px; margin-top: 6px; line-height: 1.45; }\n258\t.delta-up { color: var(--green); }\n259\t.delta-down { color: #a05a22; }\n260\t\n261\t/* position du salaire dans la fenêtre interquartile de sa catégorie */\n262\t.market-bar { position: relative; height: 12px; margin: 2px 0 7px; }\n263\t.market-bar .mb-track { position: absolute; inset: 4px 0; background: rgb(20 24 20 / 8%); border-radius: 4px; }\n264\t.market-bar .mb-window { position: absolute; top: 4px; bottom: 4px; background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 4px; }\n265\t.market-bar .mb-marker { position: absolute; top: 0; bottom: 0; width: 3px; margin-left: -1.5px; background: var(--ink); border-radius: 2px; }\n266\t\n267\t/* historique du salaire affiché */\n268\t.salary-history { margin: 8px 0 0; padding-left: 18px; }\n269\t.salary-history li { margin: 3px 0; font-size: 14px; }\n270\t\n271\t/* --- fiche ------------------------------------------------------------------------- */\n272\t.sheet {\n273\t background: var(--surface); border: 1.5px solid var(--ink);\n274\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n275\t padding: 28px 32px; margin: 24px 0;\n276\t}\n277\t.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }\n278\t.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n279\t.sheet .description { white-space: pre-line; color: var(--ink-2); }\n280\t\n281\t/* --- tuiles de statistiques (hero numbers) --------------------------------------- */\n282\t.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 22px 0; }\n283\t.tile {\n284\t background: var(--surface); border: 1.5px solid var(--ink);\n285\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n286\t padding: 18px 20px; position: relative; overflow: hidden;\n287\t}\n288\t.tile::before { content: \"\"; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--accent); }\n289\t.tile .value { font-family: var(--font-display); font-size: 27px; font-weight: 700; letter-spacing: -0.5px; }\n290\t.tile .label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }\n291\t\n292\t/* --- barres horizontales (magnitude, teinte unique) --------------------------------- */\n293\t.bars { display: grid; gap: 8px; margin: 10px 0 26px; }\n294\t.bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; }\n295\t.bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n296\t.bar-row .track { background: rgb(20 24 20 / 6%); border-radius: 4px; height: 14px; }\n297\t.bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }\n298\t.bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }\n299\t\n300\t/* --- tableaux ----------------------------------------------------------------------- */\n301\ttable.data { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-off-soft); }\n302\ttable.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }\n303\ttable.data th { background: var(--surface-2); color: var(--ink-2); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1.5px solid var(--ink); }\n304\ttable.data tr:last-child td { border-bottom: none; }\n305\t\n306\t/* --- carte -------------------------------------------------------------------------- */\n307\t/* hauteur = viewport - hauteur RÉELLE du header (--header-h posée par App.tsx\n308\t via ResizeObserver) ; dvh évite le saut quand la barre du navigateur se replie */\n309\t.map-wrap {\n310\t height: calc(100vh - var(--header-h, 58px));\n311\t height: calc(100dvh - var(--header-h, 58px));\n312\t position: relative;\n313\t}\n314\t.map-wrap .maplibregl-map { height: 100%; }\n315\t.map-count {\n316\t position: absolute; top: 14px; left: 14px; z-index: 5;\n317\t background: var(--brand); color: #fff; border-radius: var(--r-card);\n318\t border: 1.5px solid var(--ink); box-shadow: var(--shadow-off-mid);\n319\t padding: 8px 14px; font-size: 13px; font-family: var(--font-display); font-weight: 700;\n320\t}\n321\t.map-popup h4 { margin: 0 0 2px; font-size: 14px; }\n322\t.map-popup { font: 13px \"Inter\", sans-serif; }\n323\t\n324\t/* --- pagination / divers ------------------------------------------------------------- */\n325\t.pager { display: flex; gap: 10px; justify-content: center; margin: 26px 0; }\n326\t.pager button {\n327\t background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n328\t padding: 8px 16px; cursor: pointer; font: inherit;\n329\t font-family: var(--font-display); font-weight: 700; font-size: 14px;\n330\t min-height: var(--touch);\n331\t transition: transform 0.15s, box-shadow 0.15s;\n332\t}\n333\t.pager button:hover:not(:disabled) { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n334\t.pager button:active { transform: none; box-shadow: none; }\n335\t.pager button:disabled { opacity: 0.4; cursor: default; }\n336\t.empty { text-align: center; color: var(--ink-3); padding: 60px 0; }\n337\t.section-title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; margin: 30px 0 4px; }\n338\t.muted { color: var(--ink-3); font-size: 13px; }\n339\t\n340\t/* --- formulaire de dépôt direct (page Publier) ------------------------------------------ */\n341\t.depot-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }\n342\t@media (max-width: 700px) { .depot-grid { grid-template-columns: 1fr; } }\n343\t.depot-form label {\n344\t display: flex; flex-direction: column; gap: 6px;\n345\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n346\t text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-2);\n347\t}\n348\t.depot-form input:not([name=\"website\"]), .depot-form select, .depot-form textarea {\n349\t font: 15px/1.4 var(--font-body); color: var(--ink);\n350\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n351\t background: var(--surface); padding: 10px 12px; min-height: var(--touch);\n352\t}\n353\t.depot-form textarea { resize: vertical; }\n354\t.depot-form input:focus, .depot-form select:focus, .depot-form textarea:focus {\n355\t outline: none; box-shadow: 3px 3px 0 var(--accent);\n356\t}\n357\t.depot-form input::placeholder, .depot-form textarea::placeholder { color: var(--ink-3); }\n358\t\n359\t/* --- page Contact (commune Groupe KA) -------------------------------------------------- */\n360\t.contact-page { padding: 44px 0 90px; }\n361\t.contact-page h1 { margin: 10px 0 8px; }\n362\t.contact-page .lede { color: var(--ink-2); max-width: 640px; margin: 0 0 26px; }\n363\t.contact-grid { display: grid; gap: 16px; grid-template-columns: 1fr; margin: 26px 0; }\n364\t@media (min-width: 768px) { .contact-grid { grid-template-columns: repeat(3, 1fr); } }\n365\t.contact-card { padding: 18px 20px; }\n366\t.contact-card .klabel { display: block; margin-bottom: 6px; }\n367\t.contact-card a.mail {\n368\t font-family: var(--font-mono); font-weight: 700; font-size: 13.5px;\n369\t color: var(--accent-deep); word-break: break-all;\n370\t text-decoration: underline; text-underline-offset: 3px;\n371\t}\n372\t.contact-note {\n373\t border-left: 3px solid var(--accent); background: var(--surface);\n374\t border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);\n375\t border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;\n376\t padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;\n377\t}\n378\t.contact-note b { color: var(--ink); }\n379\t.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n380\t.contact-sites a {\n381\t display: inline-flex; align-items: center; min-height: 34px;\n382\t padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n383\t background: var(--surface); font-family: var(--font-mono); font-size: 11px;\n384\t font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;\n385\t color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;\n386\t}\n387\t.contact-sites a:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n388\t.contact-hub { margin-top: 26px; }\n389\t\n390\t/* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */\n391\t.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }\n392\t.prefooter .container {\n393\t display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;\n394\t}\n395\t.prefooter a {\n396\t color: var(--ink-2); text-decoration: none; text-transform: uppercase;\n397\t letter-spacing: 0.08em; font-family: var(--font-mono); font-weight: 700; font-size: 11.5px;\n398\t min-height: var(--touch); display: inline-flex; align-items: center;\n399\t}\n400\t.prefooter a:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n401\t.prefooter .baseline { width: 100%; font-size: 13px; color: var(--ink-2); }\n402\t.ka-footer { margin-top: 0; }\n403\t\n404\t@media (max-width: 640px) {\n405\t .bar-row { grid-template-columns: 120px 1fr 44px; }\n406\t .sheet { padding: 20px; }\n407\t}\n408\t\n409\t/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */\n410\t.job-card { position: relative; }\n411\t.job-card h3 { padding-right: 48px; }\n412\t.fav-btn {\n413\t position: absolute; right: 14px; top: 14px; z-index: 2;\n414\t width: 40px; height: 40px; padding: 0;\n415\t display: inline-flex; align-items: center; justify-content: center;\n416\t border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;\n417\t background: rgba(255, 255, 255, 0.95); color: var(--ink);\n418\t transition: transform 0.12s ease, background 0.12s ease;\n419\t}\n420\t.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }\n421\t.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }\n422\t.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }\n423\t.fav-btn.on svg { fill: currentColor; }\n424\t/* fiche d'offre : le cœur à droite du titre */\n425\t.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }\n426\t.fav-btn.big svg { width: 21px; height: 21px; }\n427\t/* page /favoris */\n428\t.fav-card { padding-right: 64px; }\n429\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"c1d5b17c-1835-4e40-9ef6-56ed49ec5166","timestamp":"2026-08-25T18:17:29.153Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css","content":"/*\n=============================================================================\nJob·Ka — Groupe KA\nAuteur : Simon-Pierre Boucher\nContact : contact@spboucher.ai\nFichier : frontend/src/styles.css\nRôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA\n (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :\n seulement l'accent de la marque + les classes métier (offres,\n filtres, carte) au style « éditorial sharp » du groupe (bordures\n encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle\n (body, grain, sélection, .container, .btn, .card, .chip,\n ka-footer…) vit dans tokens.css.\nCréé : 2026-08-17 Modifié : 2026-08-25\n=============================================================================\n*/\n\n:root {\n /* ---- ACCENT Job·Ka (cyan officiel — voir ka/ecosystem.json) ---- */\n --accent: #0c8599;\n --accent-soft: #def0f4;\n --accent-deep: #095c6b;\n --on-accent: #ffffff;\n --lime: var(--accent);\n --lime-soft: var(--accent-soft);\n\n /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */\n --brand: var(--ink);\n --border: var(--line);\n --green-soft: #e7f2ec;\n --radius: var(--r-card);\n --shadow: var(--shadow-flat), 0 4px 14px rgb(20 24 20 / 6%);\n}\n\na { color: var(--green); text-decoration: none; }\na:hover { text-decoration: underline; }\n\n/* --- en-tête ---------------------------------------------------------------- */\nheader.site {\n background: var(--brand);\n color: #fff;\n position: sticky; top: 0; z-index: var(--z-header, 500);\n}\n/* menu mobile ouvert : le header (et son panneau) passe au-dessus du backdrop */\nheader.site.menu-open { z-index: var(--z-modal, 900); }\nheader.site .container {\n display: flex; align-items: center; flex-wrap: wrap;\n gap: 4px 14px; min-height: 58px; padding-top: 8px; padding-bottom: 8px;\n}\n.logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: #fff; letter-spacing: -0.3px; white-space: nowrap; position: relative; }\n/* extension tactile : le logo (63×31) atteint >=44px de cible sans changer le rendu */\n.logo::after { content: \"\"; position: absolute; inset: -8px; }\n.logo em { color: var(--accent-soft); font-style: normal; }\nheader.site .gk-badge { flex-shrink: 0; }\nnav.main { display: flex; gap: 4px; margin-left: auto; }\nnav.main a {\n /* pilules font-display 700 (standard header/menu KA 2026-08-22), thème encre conservé */\n color: #d7ddda; padding: 7px 12px; border-radius: 999px; font-weight: 700;\n font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif); font-size: 13.5px;\n letter-spacing: -0.01em; white-space: nowrap;\n}\nnav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; }\nnav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n\n/* connexion KA ID (droite du header) */\n.auth-box { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n.btn-ka {\n display: inline-flex; align-items: center; gap: 7px; min-height: 34px;\n padding: 4px 13px; border: 1.5px solid rgb(245 243 238 / 45%);\n border-radius: var(--r-pill); background: transparent; color: #f5f3ee;\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n letter-spacing: 0.07em; text-transform: uppercase; cursor: pointer;\n white-space: nowrap; text-decoration: none;\n}\n.btn-ka b {\n font-family: var(--font-display); font-size: 12px; text-transform: none;\n letter-spacing: -0.02em; background: var(--accent); color: var(--on-accent);\n border-radius: 5px; padding: 0 6px 1px;\n}\n.btn-ka:hover { border-color: var(--accent-soft); text-decoration: none; }\n.auth-user { display: inline-flex; align-items: center; gap: 8px; color: #d7ddda; font-size: 13px; white-space: nowrap; }\n.auth-user img { width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid rgb(245 243 238 / 45%); }\n.auth-user button {\n border: 0; background: none; color: #9aa5a0; font: inherit; font-size: 12px;\n cursor: pointer; padding: 6px 0; min-height: 34px;\n}\n.auth-user button:hover { color: #fff; text-decoration: underline; }\n\n/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée\n (défilement horizontal contenu — jamais de débordement de page) */\n@media (max-width: 979px) {\n nav.main {\n order: 10; flex-basis: 100%; margin-left: 0;\n overflow-x: auto; -webkit-overflow-scrolling: touch;\n scrollbar-width: none; padding-bottom: 2px;\n }\n nav.main::-webkit-scrollbar { display: none; }\n nav.main a { padding: 7px 9px; font-size: 13.5px; }\n .auth-box { margin-left: auto; }\n}\n@media (max-width: 560px) {\n header.site .gk-badge { padding: 2px 8px 3px; font-size: 9px; }\n header.site .gk-badge b { font-size: 11px; }\n .auth-user span.name { display: none; }\n}\n\n/* --- menu mobile (hamburger) -------------------------------------------------\n Sous 700 px, la nav en flex-wrap (2-3 lignes) est remplacée par un bouton\n 44×44 + panneau déroulant sous le header ; backdrop fixe rendu HORS du\n header (frère dans App.tsx), verrou de scroll géré côté React. */\n.menu-btn {\n display: none; width: 44px; height: 44px; margin-left: 4px;\n flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n flex-shrink: 0; background: none; border: 0; border-radius: 8px;\n cursor: pointer; padding: 0;\n}\n.menu-btn:hover { background: rgb(255 255 255 / 8%); }\n.menu-btn span {\n display: block; width: 22px; height: 2px; background: #f5f3ee; border-radius: 2px;\n transition: transform 0.2s ease, opacity 0.15s ease;\n}\n.menu-btn[aria-expanded=\"true\"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n.menu-btn[aria-expanded=\"true\"] span:nth-child(2) { opacity: 0; }\n.menu-btn[aria-expanded=\"true\"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n/* KA Nav v2 (2026-08-25) : panneau PLEIN ÉCRAN en position:fixed inset:0 —\n visible peu importe la position de scroll (l'ancien dropdown absolute 480px\n se dessinait sous le header). Le rang du header (marque + burger→✕) reste\n au-dessus du panneau via .menu-open .container. */\n.mobile-menu {\n display: none; position: fixed; inset: 0;\n background: var(--brand);\n padding: calc(var(--header-h, 58px) + 14px) 22px calc(24px + env(safe-area-inset-bottom));\n overflow-y: auto; overscroll-behavior: contain;\n flex-direction: column; counter-reset: mnav;\n}\n.mobile-menu.open { animation: mm-in 0.16s ease; }\n@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }\n/* burger masqué à l'ouverture : la fermeture passe par le ✕ fixe du panneau\n (le burger vit dans le header — hors écran si le header n'est pas épinglé) */\nheader.site.menu-open .menu-btn { visibility: hidden; }\n/* bouton de fermeture du panneau : fixe en haut à droite du viewport */\n.mm-close {\n position: fixed; top: 10px; right: 14px; z-index: 2;\n width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;\n border: 1.5px solid #f5f3ee; border-radius: 999px;\n background: #f5f3ee; color: var(--brand);\n font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0;\n}\n.mobile-menu a {\n display: flex; align-items: baseline; gap: 13px; padding: 16px 2px;\n color: #f5f3ee; font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif);\n font-weight: 700; font-size: 21px; letter-spacing: -0.02em; line-height: 1.1;\n border-bottom: 1.5px dashed rgb(255 255 255 / 18%);\n}\n.mobile-menu a::before {\n counter-increment: mnav; content: \"0\" counter(mnav);\n font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px;\n font-weight: 700; color: var(--accent-soft);\n}\n.mobile-menu a:last-child { border-bottom: 0; }\n.mobile-menu a:hover { color: #fff; text-decoration: none; }\n.mobile-menu a.active { color: var(--accent-soft); }\n.mm-backdrop {\n position: fixed; inset: 0; background: rgb(16 18 16 / 45%);\n z-index: var(--z-overlay, 800);\n}\n@media (max-width: 700px) {\n nav.main { display: none; }\n .menu-btn { display: flex; }\n .mobile-menu.open { display: flex; }\n}\n\n/* --- héros (encre — identité Job·Ka) ---------------------------------------- */\n.hero { background: var(--brand); color: #fff; padding: 42px 0 36px; }\n.hero .kicker { color: var(--accent-soft); }\n.hero .kicker::before { background: var(--accent); }\n.hero h1 { margin: 6px 0 8px; font-size: clamp(28px, 2.6vw + 16px, 42px); font-weight: 700; letter-spacing: -0.03em; color: #fff; line-height: 1.08; }\n.hero h1 em {\n /* surligné d'accent incliné — signature du groupe (.hl), déclinaison encre */\n display: inline-block; font-style: normal; background: var(--accent);\n color: var(--on-accent); border-radius: 8px; padding: 0 10px 2px;\n transform: rotate(-1deg);\n}\n.hero p { margin: 0 0 22px; color: #b9c2be; max-width: 640px; }\n\n/* --- filtres (champs sharp : bordure encre + focus accent décalé) ------------ */\n.filters { display: flex; flex-wrap: wrap; gap: 10px; }\n.filters input[type=\"search\"], .filters select {\n height: 44px; padding: 0 12px; border-radius: var(--r-ctl);\n border: 1.5px solid var(--ink);\n background: var(--surface); color: var(--ink); font: inherit; min-width: 130px;\n}\n.filters input[type=\"search\"]:focus, .filters select:focus {\n outline: none; box-shadow: 3px 3px 0 var(--accent);\n}\n.filters input[type=\"search\"] { flex: 1 1 220px; }\n.filters input[type=\"search\"]::placeholder { color: var(--ink-3); }\n.filters label.check {\n display: inline-flex; align-items: center; gap: 7px; background: var(--surface);\n color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 0 14px;\n height: 44px; cursor: pointer; user-select: none; white-space: nowrap;\n}\n.filters label.check:has(input:checked) {\n background: var(--accent-soft); box-shadow: 3px 3px 0 var(--accent);\n}\n/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */\n@media (max-width: 700px) {\n .filters input[type=\"search\"], .filters select, .filters label.check { font-size: 16px; }\n}\n\n/* --- cartes d'offres (sharp : bordure encre + ombre décalée) --------------------- */\n.job-list { display: grid; gap: 14px; margin: 22px 0; }\n.job-card {\n display: block; background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 18px 20px; color: inherit;\n transition: transform 0.15s, box-shadow 0.15s;\n}\n.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n\n/* étiquettes façon .chip du groupe : mono, uppercase, pilule bordée */\n.badge {\n display: inline-flex; align-items: center; gap: 5px;\n padding: 3px 10px; border-radius: var(--r-pill);\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.05em;\n background: var(--surface-2); color: var(--ink-2);\n border: 1.5px solid var(--line);\n}\n.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n.badge.new { background: var(--accent); color: var(--on-accent); border-color: var(--accent-deep); }\n.badge.seniority { background: var(--surface-2); color: var(--ink); border-color: var(--ink); }\n.badge.salary .approx { font-style: normal; font-weight: 600; opacity: 0.75; margin-left: 5px; text-transform: none; letter-spacing: 0; }\n\n/* pied de carte : fraîcheur · avantages · plateforme source */\n.jc-foot { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; }\n.jc-foot .ats { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; }\n\n/* --- métriques « En un coup d'œil » (fiche d'offre) -------------------------- */\n.metrics {\n display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));\n gap: 12px; margin: 12px 0 22px;\n}\n.metric {\n background: var(--surface-2); border: 1.5px solid var(--line);\n border-radius: var(--r-card); padding: 13px 15px;\n}\n.metric .m-value { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }\n.metric .m-label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 3px; }\n.metric .m-sub { color: var(--ink-3); font-size: 12px; margin-top: 6px; line-height: 1.45; }\n.delta-up { color: var(--green); }\n.delta-down { color: #a05a22; }\n\n/* position du salaire dans la fenêtre interquartile de sa catégorie */\n.market-bar { position: relative; height: 12px; margin: 2px 0 7px; }\n.market-bar .mb-track { position: absolute; inset: 4px 0; background: rgb(20 24 20 / 8%); border-radius: 4px; }\n.market-bar .mb-window { position: absolute; top: 4px; bottom: 4px; background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 4px; }\n.market-bar .mb-marker { position: absolute; top: 0; bottom: 0; width: 3px; margin-left: -1.5px; background: var(--ink); border-radius: 2px; }\n\n/* historique du salaire affiché */\n.salary-history { margin: 8px 0 0; padding-left: 18px; }\n.salary-history li { margin: 3px 0; font-size: 14px; }\n\n/* --- fiche ------------------------------------------------------------------------- */\n.sheet {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 28px 32px; margin: 24px 0;\n}\n.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }\n.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n.sheet .description { white-space: pre-line; color: var(--ink-2); }\n\n/* --- tuiles de statistiques (hero numbers) --------------------------------------- */\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 22px 0; }\n.tile {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 18px 20px; position: relative; overflow: hidden;\n}\n.tile::before { content: \"\"; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--accent); }\n.tile .value { font-family: var(--font-display); font-size: 27px; font-weight: 700; letter-spacing: -0.5px; }\n.tile .label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }\n\n/* --- barres horizontales (magnitude, teinte unique) --------------------------------- */\n.bars { display: grid; gap: 8px; margin: 10px 0 26px; }\n.bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; }\n.bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.bar-row .track { background: rgb(20 24 20 / 6%); border-radius: 4px; height: 14px; }\n.bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }\n.bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }\n\n/* --- tableaux ----------------------------------------------------------------------- */\ntable.data { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-off-soft); }\ntable.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }\ntable.data th { background: var(--surface-2); color: var(--ink-2); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1.5px solid var(--ink); }\ntable.data tr:last-child td { border-bottom: none; }\n\n/* --- carte -------------------------------------------------------------------------- */\n/* hauteur = viewport - hauteur RÉELLE du header (--header-h posée par App.tsx\n via ResizeObserver) ; dvh évite le saut quand la barre du navigateur se replie */\n.map-wrap {\n height: calc(100vh - var(--header-h, 58px));\n height: calc(100dvh - var(--header-h, 58px));\n position: relative;\n}\n.map-wrap .maplibregl-map { height: 100%; }\n.map-count {\n position: absolute; top: 14px; left: 14px; z-index: 5;\n background: var(--brand); color: #fff; border-radius: var(--r-card);\n border: 1.5px solid var(--ink); box-shadow: var(--shadow-off-mid);\n padding: 8px 14px; font-size: 13px; font-family: var(--font-display); font-weight: 700;\n}\n.map-popup h4 { margin: 0 0 2px; font-size: 14px; }\n.map-popup { font: 13px \"Inter\", sans-serif; }\n\n/* --- pagination / divers ------------------------------------------------------------- */\n.pager { display: flex; gap: 10px; justify-content: center; margin: 26px 0; }\n.pager button {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n padding: 8px 16px; cursor: pointer; font: inherit;\n font-family: var(--font-display); font-weight: 700; font-size: 14px;\n min-height: var(--touch);\n transition: transform 0.15s, box-shadow 0.15s;\n}\n.pager button:hover:not(:disabled) { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n.pager button:active { transform: none; box-shadow: none; }\n.pager button:disabled { opacity: 0.4; cursor: default; }\n.empty { text-align: center; color: var(--ink-3); padding: 60px 0; }\n.section-title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; margin: 30px 0 4px; }\n.muted { color: var(--ink-3); font-size: 13px; }\n\n/* --- formulaire de dépôt direct (page Publier) ------------------------------------------ */\n.depot-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }\n@media (max-width: 700px) { .depot-grid { grid-template-columns: 1fr; } }\n.depot-form label {\n display: flex; flex-direction: column; gap: 6px;\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-2);\n}\n.depot-form input:not([name=\"website\"]), .depot-form select, .depot-form textarea {\n font: 15px/1.4 var(--font-body); color: var(--ink);\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); padding: 10px 12px; min-height: var(--touch);\n}\n.depot-form textarea { resize: vertical; }\n.depot-form input:focus, .depot-form select:focus, .depot-form textarea:focus {\n outline: none; box-shadow: 3px 3px 0 var(--accent);\n}\n.depot-form input::placeholder, .depot-form textarea::placeholder { color: var(--ink-3); }\n\n/* --- page Contact (commune Groupe KA) -------------------------------------------------- */\n.contact-page { padding: 44px 0 90px; }\n.contact-page h1 { margin: 10px 0 8px; }\n.contact-page .lede { color: var(--ink-2); max-width: 640px; margin: 0 0 26px; }\n.contact-grid { display: grid; gap: 16px; grid-template-columns: 1fr; margin: 26px 0; }\n@media (min-width: 768px) { .contact-grid { grid-template-columns: repeat(3, 1fr); } }\n.contact-card { padding: 18px 20px; }\n.contact-card .klabel { display: block; margin-bottom: 6px; }\n.contact-card a.mail {\n font-family: var(--font-mono); font-weight: 700; font-size: 13.5px;\n color: var(--accent-deep); word-break: break-all;\n text-decoration: underline; text-underline-offset: 3px;\n}\n.contact-note {\n border-left: 3px solid var(--accent); background: var(--surface);\n border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);\n border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;\n padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;\n}\n.contact-note b { color: var(--ink); }\n.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.contact-sites a {\n display: inline-flex; align-items: center; min-height: 34px;\n padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n background: var(--surface); font-family: var(--font-mono); font-size: 11px;\n font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;\n color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;\n}\n.contact-sites a:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n.contact-hub { margin-top: 26px; }\n\n/* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */\n.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }\n.prefooter .container {\n display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;\n}\n.prefooter a {\n color: var(--ink-2); text-decoration: none; text-transform: uppercase;\n letter-spacing: 0.08em; font-family: var(--font-mono); font-weight: 700; font-size: 11.5px;\n min-height: var(--touch); display: inline-flex; align-items: center;\n}\n.prefooter a:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n.prefooter .baseline { width: 100%; font-size: 13px; color: var(--ink-2); }\n.ka-footer { margin-top: 0; }\n\n@media (max-width: 640px) {\n .bar-row { grid-template-columns: 120px 1fr 44px; }\n .sheet { padding: 20px; }\n}\n\n/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */\n.job-card { position: relative; }\n.job-card h3 { padding-right: 48px; }\n.fav-btn {\n position: absolute; right: 14px; top: 14px; z-index: 2;\n width: 40px; height: 40px; padding: 0;\n display: inline-flex; align-items: center; justify-content: center;\n border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;\n background: rgba(255, 255, 255, 0.95); color: var(--ink);\n transition: transform 0.12s ease, background 0.12s ease;\n}\n.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }\n.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }\n.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }\n.fav-btn.on svg { fill: currentColor; }\n/* fiche d'offre : le cœur à droite du titre */\n.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }\n.fav-btn.big svg { width: 21px; height: 21px; }\n/* page /favoris */\n.fav-card { padding-right: 64px; }\n","numLines":429,"startLine":1,"totalLines":429}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8db85ff9-519f-4403-ae16-3db4cb825216","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePveKKm3YyLZMfChUijW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MounxAZjwunchHqTzGPaEh","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":14962,"cache_read_input_tokens":40322,"cache_creation":{"ephemeral_5m_input_tokens":14962,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"92f50b59-b297-40df-8b02-38dba3678a49","request_id":"req_011CePveHtSHMZ6x9hvsWGJJ"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePveKKm3YyLZMfChUijW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019pBhQJCKMniKJV3BbnvCZA","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":14962,"cache_read_input_tokens":40322,"cache_creation":{"ephemeral_5m_input_tokens":14962,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"7ee18deb-702d-44bf-b707-49b1f3a2dbbe","request_id":"req_011CePveHtSHMZ6x9hvsWGJJ"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MounxAZjwunchHqTzGPaEh","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Home.tsx\n7\t * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n8\t * Créé : 2026-08-17 Modifié : 2026-08-25\n9\t * =============================================================================\n10\t */\n11\timport { useEffect, useMemo, useState } from \"react\";\n12\timport { useSearchParams } from \"react-router-dom\";\n13\timport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";\n14\timport JobCard from \"../components/JobCard\";\n15\t\n16\tconst PAGE = 30;\n17\t\n18\texport default function Home() {\n19\t const [params, setParams] = useSearchParams();\n20\t const [facets, setFacets] = useState(null);\n21\t const [jobs, setJobs] = useState([]);\n22\t const [total, setTotal] = useState(0);\n23\t const [loading, setLoading] = useState(true);\n24\t const [error, setError] = useState(\"\");\n25\t\n26\t const filters = useMemo(() => ({\n27\t q: params.get(\"q\") ?? \"\",\n28\t city: params.get(\"ville\") ?? \"\",\n29\t region: params.get(\"region\") ?? \"\",\n30\t category: params.get(\"categorie\") ?? \"\",\n31\t work_mode: params.get(\"mode\") ?? \"\",\n32\t employment_type: params.get(\"type\") ?? \"\",\n33\t seniority: params.get(\"seniorite\") ?? \"\",\n34\t language: params.get(\"langue\") ?? \"\",\n35\t with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n36\t sort: params.get(\"tri\") ?? \"recent\",\n37\t }), [params]);\n38\t const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n39\t\n40\t useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n41\t\n42\t useEffect(() => {\n43\t setLoading(true);\n44\t setError(\"\");\n45\t fetchJobs(filters, PAGE, (page - 1) * PAGE)\n46\t .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n47\t .catch((e) => setError(String(e)))\n48\t .finally(() => setLoading(false));\n49\t }, [filters, page]);\n50\t\n51\t const setParam = (key: string, value: string) => {\n52\t const next = new URLSearchParams(params);\n53\t if (value) next.set(key, value); else next.delete(key);\n54\t next.delete(\"page\");\n55\t setParams(next, { replace: true });\n56\t };\n57\t\n58\t const pages = Math.max(1, Math.ceil(total / PAGE));\n59\t\n60\t return (\n61\t <>\n62\t
\n63\t
\n64\t Pages carrières · Zéro intermédiaire\n65\t

L'emploi au Québec, à la source.

\n66\t

\n67\t Job·Ka agrège les offres directement depuis les pages carrières des\n68\t employeurs québécois. Salaires transparents, lien vers l'offre\n69\t originale, aucun intermédiaire.\n70\t

\n71\t
\n72\t {\n77\t if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n78\t }}\n79\t />\n80\t \n84\t \n90\t \n96\t \n100\t \n104\t \n112\t \n118\t \n126\t
\n127\t
\n128\t
\n129\t\n130\t
\n131\t

\n132\t {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n133\t {\" · \"}\n134\t setParam(\"tri\", e.target.value)}\n137\t style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n138\t >\n139\t \n140\t \n141\t \n142\t

\n143\t {error &&
Erreur de chargement : {error}
}\n144\t
\n145\t {jobs.map((j) => )}\n146\t
\n147\t {!loading && jobs.length === 0 && !error && (\n148\t
Aucune offre ne correspond à ces filtres.
\n149\t )}\n150\t {pages > 1 && (\n151\t
\n152\t \n153\t \n154\t \n155\t
\n156\t )}\n157\t
\n158\t \n159\t );\n160\t}\n161\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"2ef17da8-c4d8-4fab-98af-653047ce9f93","timestamp":"2026-08-25T18:17:33.580Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n seniority: params.get(\"seniorite\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","numLines":161,"startLine":1,"totalLines":161}}}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019pBhQJCKMniKJV3BbnvCZA","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/components/JobCard.tsx\n7\t * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,\n8\t * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris\n9\t * Créé : 2026-08-17 Modifié : 2026-08-25\n10\t * =============================================================================\n11\t */\n12\timport { Link } from \"react-router-dom\";\n13\timport {\n14\t agoLabel, daysSince, displayTitle, formatSalary, Job,\n15\t MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n16\t} from \"../api\";\n17\timport { FavButton } from \"../favorites\";\n18\t\n19\tconst NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins\n20\t\n21\texport default function JobCard({ job }: { job: Job }) {\n22\t const salary = formatSalary(job);\n23\t const yearly = yearlyEquiv(job);\n24\t const days = daysSince(job.date_posted);\n25\t const isNew = days !== null && days <= NEW_DAYS;\n26\t const exp = job.requirements?.experience_years;\n27\t const langs = job.requirements?.languages;\n28\t\n29\t return (\n30\t \n31\t \n32\t

{displayTitle(job)}

\n33\t {job.employer}\n34\t {job.city ? — {job.city} : null}\n35\t
\n36\t {isNew && Nouveau}\n37\t {job.is_direct !== false && (\n38\t \n42\t Offre directe\n43\t \n44\t )}\n45\t {salary && (\n46\t \n47\t {salary}{yearly ? {yearly} : null}\n48\t \n49\t )}\n50\t {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n51\t {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n52\t {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n53\t {typeof exp === \"number\" && (\n54\t \n55\t {exp} an{exp > 1 ? \"s\" : \"\"} d'exp. min.\n56\t \n57\t )}\n58\t {langs && langs.length === 2 && Bilingue exigé}\n59\t {job.category && {job.category}}\n60\t
\n61\t
\n62\t {days !== null && Publiée {agoLabel(days)}}\n63\t {job.benefits && job.benefits.length > 0 && (\n64\t {job.benefits.length} avantage{job.benefits.length > 1 ? \"s\" : \"\"} listé{job.benefits.length > 1 ? \"s\" : \"\"}\n65\t )}\n66\t {job.ats && {job.ats}}\n67\t
\n68\t \n69\t );\n70\t}\n71\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"297eeab6-b4f8-4af1-8a59-1897e00f05d6","timestamp":"2026-08-25T18:17:33.608Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/components/JobCard.tsx\n * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,\n * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport {\n agoLabel, daysSince, displayTitle, formatSalary, Job,\n MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nconst NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins\n\nexport default function JobCard({ job }: { job: Job }) {\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const isNew = days !== null && days <= NEW_DAYS;\n const exp = job.requirements?.experience_years;\n const langs = job.requirements?.languages;\n\n return (\n \n \n

{displayTitle(job)}

\n {job.employer}\n {job.city ? — {job.city} : null}\n
\n {isNew && Nouveau}\n {job.is_direct !== false && (\n \n Offre directe\n \n )}\n {salary && (\n \n {salary}{yearly ? {yearly} : null}\n \n )}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {typeof exp === \"number\" && (\n \n {exp} an{exp > 1 ? \"s\" : \"\"} d'exp. min.\n \n )}\n {langs && langs.length === 2 && Bilingue exigé}\n {job.category && {job.category}}\n
\n
\n {days !== null && Publiée {agoLabel(days)}}\n {job.benefits && job.benefits.length > 0 && (\n {job.benefits.length} avantage{job.benefits.length > 1 ? \"s\" : \"\"} listé{job.benefits.length > 1 ? \"s\" : \"\"}\n )}\n {job.ats && {job.ats}}\n
\n \n );\n}\n","numLines":71,"startLine":1,"totalLines":71}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8f2494db-2c53-4bdc-93c5-16e9e7ef959a","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvefDqEGkbn5UshU5ce","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Vb2Z2nxnuatTRQQacpRq2s","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4523,"cache_read_input_tokens":55284,"cache_creation":{"ephemeral_5m_input_tokens":4523,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"0f25fd07-9588-4f2c-886f-b82ed97798e7","request_id":"req_011CePvecwuymQgYhXFqruu6"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvefDqEGkbn5UshU5ce","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016D5BoQaBQE3YTNVfWsqsqu","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4523,"cache_read_input_tokens":55284,"cache_creation":{"ephemeral_5m_input_tokens":4523,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"91236653-0baf-44b9-a4a3-00d2ddc3abed","request_id":"req_011CePvecwuymQgYhXFqruu6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Vb2Z2nxnuatTRQQacpRq2s","type":"tool_result","content":"1\t/* -----------------------------------------------------------------------------\n2\t Auteur : Simon-Pierre Boucher — contact@spboucher.ai\n3\t Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)\n4\t Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle\n5\t de composants partagés par les 12 plateformes. Chaque site importe ce\n6\t fichier PUIS surcharge uniquement son accent (voir accents.css).\n7\t\n8\t · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono\n9\t · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site\n10\t · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,\n11\t surlignés d'accent inclinés (néo-brutalisme raffiné)\n12\t · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)\n13\t · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)\n14\t----------------------------------------------------------------------------- */\n15\t\n16\t:root {\n17\t /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */\n18\t --paper: #f5f3ee;\n19\t --surface: #ffffff;\n20\t --surface-2: #faf9f5;\n21\t --ink: #141814;\n22\t --ink-2: #4d5551;\n23\t --ink-3: #8b928c;\n24\t --line: rgba(20, 24, 20, 0.14);\n25\t --line-strong: rgba(20, 24, 20, 0.85);\n26\t --green: #1c5c41;\n27\t --green-deep: #123f2e;\n28\t --amber: #e8a33d;\n29\t --amber-soft: #fdf3e2;\n30\t --danger: #b3423a;\n31\t --danger-soft: #fbe9e7;\n32\t\n33\t /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */\n34\t --accent: #d9f26b; /* défaut : lime Groupe KA */\n35\t --accent-soft: #f0f9d2;\n36\t --accent-deep: #123f2e;\n37\t --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */\n38\t\n39\t /* alias rétro-compatibles (les satellites historiques utilisent --lime) */\n40\t --lime: var(--accent);\n41\t --lime-soft: var(--accent-soft);\n42\t\n43\t /* ---- Géométrie sharp ---- */\n44\t --r-card: 10px;\n45\t --r-ctl: 6px;\n46\t --r-pill: 999px;\n47\t\n48\t /* ---- Ombres décalées — la signature du groupe ---- */\n49\t --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);\n50\t --shadow-off: 6px 6px 0 var(--ink);\n51\t --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);\n52\t --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);\n53\t\n54\t /* ---- Typographie ---- */\n55\t --font-display: \"Space Grotesk\", system-ui, sans-serif;\n56\t --font-body: \"Inter\", system-ui, sans-serif;\n57\t --font-mono: \"JetBrains Mono\", ui-monospace, monospace;\n58\t\n59\t /* Échelle fluide (mobile-first, clamp) */\n60\t --fs-h1: clamp(30px, 3.2vw + 18px, 54px);\n61\t --fs-h2: clamp(23px, 1.8vw + 14px, 34px);\n62\t --fs-h3: clamp(17px, 0.9vw + 12px, 22px);\n63\t --fs-body: 15px;\n64\t --fs-small: 13px;\n65\t\n66\t /* Espacements */\n67\t --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;\n68\t --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;\n69\t\n70\t /* Cible tactile minimale */\n71\t --touch: 44px;\n72\t\n73\t /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----\n74\t grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,\n75\t purement visuel). Tout composant interactif se place sous 9999 : */\n76\t --z-content: 1; /* contenu positionné ordinaire */\n77\t --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */\n78\t --z-header: 500; /* header du site */\n79\t --z-bottombar: 600; /* tab bar / barre d'action basse */\n80\t --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */\n81\t --z-overlay: 800; /* voile sous modale/panneau */\n82\t --z-modal: 900; /* modales, panneaux de filtres, galeries */\n83\t --z-toast: 950; /* notifications */\n84\t\n85\t /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se\n86\t replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */\n87\t --vh100: 100vh;\n88\t}\n89\t@supports (height: 100dvh) { :root { --vh100: 100dvh; } }\n90\t\n91\t/* ---- Socle ---- */\n92\t* { box-sizing: border-box; }\n93\t/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul\n94\t ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;\n95\t clip (≠ hidden) ne casse pas position:sticky. */\n96\thtml { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }\n97\tbody {\n98\t margin: 0;\n99\t background: var(--paper);\n100\t color: var(--ink);\n101\t font-family: var(--font-body);\n102\t font-size: var(--fs-body);\n103\t line-height: 1.55;\n104\t -webkit-font-smoothing: antialiased;\n105\t padding-bottom: env(safe-area-inset-bottom);\n106\t overflow-x: clip; /* jamais de débordement horizontal */\n107\t}\n108\timg, svg, video { max-width: 100%; height: auto; display: block; }\n109\th1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }\n110\th1 { font-size: var(--fs-h1); line-height: 1.06; }\n111\th2 { font-size: var(--fs-h2); line-height: 1.15; }\n112\th3 { font-size: var(--fs-h3); line-height: 1.25; }\n113\ta { color: inherit; }\n114\t::selection { background: var(--accent); color: var(--on-accent); }\n115\t:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }\n116\t\n117\t/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:\n118\t relative seulement), sinon les utilitaires z-* sont écrasés. */\n119\tbody::before {\n120\t content: \"\";\n121\t position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;\n122\t background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\");\n123\t}\n124\tbody > * { position: relative; }\n125\t\n126\t/* ---- Conteneur commun ---- */\n127\t.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }\n128\t@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }\n129\t\n130\t/* ---- Micro-typographie signature ---- */\n131\t.kicker {\n132\t display: inline-flex; align-items: center; gap: 10px;\n133\t font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;\n134\t text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);\n135\t}\n136\t.kicker::before { content: \"\"; width: 22px; height: 2px; background: var(--green); }\n137\t.klabel {\n138\t font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n139\t text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n140\t}\n141\t.hl {\n142\t display: inline-block; background: var(--accent); color: var(--on-accent);\n143\t border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);\n144\t}\n145\t.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }\n146\t\n147\t/* ---- Boutons (cible ≥ 44 px) ---- */\n148\t.btn {\n149\t display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n150\t min-height: var(--touch); padding: 10px 18px;\n151\t font-family: var(--font-display); font-weight: 700; font-size: 14px;\n152\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n153\t background: var(--surface); color: var(--ink);\n154\t cursor: pointer; text-decoration: none;\n155\t transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;\n156\t}\n157\t.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n158\t.btn:active { transform: translate(0, 0); box-shadow: none; }\n159\t.btn:disabled { opacity: 0.45; pointer-events: none; }\n160\t.btn-primary { background: var(--ink); color: var(--accent); }\n161\t.btn-primary:hover { background: var(--accent-deep); }\n162\t.btn-accent { background: var(--accent); color: var(--on-accent); }\n163\t.btn-ghost { background: transparent; border-color: var(--line); }\n164\t.btn-ghost:hover { border-color: var(--ink); }\n165\t\n166\t/* ---- Cartes ---- */\n167\t.card {\n168\t background: var(--surface); border: 1.5px solid var(--ink);\n169\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n170\t overflow: hidden;\n171\t}\n172\t.card-hover { transition: transform 0.15s, box-shadow 0.15s; }\n173\t.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }\n174\t\n175\t/* ---- Chips / badges ---- */\n176\t.chip {\n177\t display: inline-flex; align-items: center; gap: 6px;\n178\t padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;\n179\t text-transform: uppercase; letter-spacing: 0.06em;\n180\t border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n181\t background: var(--surface); color: var(--ink);\n182\t}\n183\t.chip-accent { background: var(--accent); color: var(--on-accent); }\n184\t.chip-soft { background: var(--accent-soft); border-color: var(--line); }\n185\t\n186\t/* ---- Champs ---- */\n187\t.input, .select, .textarea {\n188\t width: 100%; min-height: var(--touch); padding: 10px 14px;\n189\t font: inherit; color: var(--ink); background: var(--surface);\n190\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n191\t}\n192\t.input:focus, .select:focus, .textarea:focus {\n193\t outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);\n194\t}\n195\t.input::placeholder { color: var(--ink-3); }\n196\t\n197\t/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */\n198\t.gk-badge {\n199\t display: inline-flex; align-items: center; gap: 7px;\n200\t min-height: 30px; padding: 3px 10px 4px;\n201\t font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n202\t letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;\n203\t border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n204\t background: var(--surface); color: var(--ink-2); white-space: nowrap;\n205\t}\n206\t.gk-badge b {\n207\t font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;\n208\t text-transform: none; color: var(--ink);\n209\t}\n210\t.gk-badge b .ka {\n211\t display: inline-block; background: var(--ink); color: var(--accent);\n212\t border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);\n213\t}\n214\t.gk-badge:hover .ka { transform: rotate(0); }\n215\t.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }\n216\t.gk-badge--dark b { color: var(--paper); }\n217\t\n218\t/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */\n219\t.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }\n220\t.ka-footer a { text-decoration: none; }\n221\t.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }\n222\t.ka-footer .wordmark .ka { color: var(--accent); }\n223\t.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }\n224\t.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }\n225\t.ka-footer .notice b { color: var(--paper); }\n226\t.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }\n227\t.ka-footer .sites a { color: rgba(245,243,238,0.65); }\n228\t.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n229\t.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }\n230\t@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }\n231\t.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }\n232\t.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n233\t.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }\n234\t.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }\n235\t.ka-footer .legal a { color: inherit; }\n236\t.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n237\t\n238\t/* ---- Tableaux → cartes empilées sous 768 px ---- */\n239\t.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }\n240\t@media (max-width: 767px) {\n241\t .tbl-stack thead { display: none; }\n242\t .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }\n243\t .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }\n244\t .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }\n245\t}\n246\t\n247\t/* ---- Utilitaires responsive ---- */\n248\t.only-mobile { display: initial; } .only-desktop { display: none; }\n249\t@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }\n250\t\n251\t/* =============================================================================\n252\t SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.\n253\t RÈGLES pour tout composant futur :\n254\t · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;\n255\t le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).\n256\t · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.\n257\t · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/\n258\t will-change/backdrop-filter — sinon monter l'élément à la racine (body).\n259\t · Barre basse fixe = .ka-bottombar + classe has-bottombar sur .\n260\t · Menu/panneau ouvert = .ka-scroll-lock sur (scroll arrière-plan gelé).\n261\t · z-index : uniquement l'échelle --z-* ci-dessus.\n262\t · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).\n263\t ========================================================================== */\n264\t\n265\t/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px\n266\t déclenche un zoom automatique de page sur Safari iOS). !important assumé :\n267\t c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi\n268\t qu'il arrive — ne s'applique qu'aux écrans tactiles. */\n269\t@media (hover: none) and (pointer: coarse) {\n270\t input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]),\n271\t select, textarea { font-size: max(16px, 1em) !important; }\n272\t}\n273\t\n274\t/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer\n275\t son rendu (débord de la zone cliquable via pseudo-élément). */\n276\t@media (pointer: coarse) {\n277\t .gk-badge { position: relative; }\n278\t .gk-badge::after { content: \"\"; position: absolute; inset: -8px; }\n279\t}\n280\t\n281\t/* Hauteurs viewport dynamiques */\n282\t.h-viewport { height: 100vh; height: 100dvh; }\n283\t.min-h-viewport { min-height: 100vh; min-height: 100dvh; }\n284\t\n285\t/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les\n286\t transforms accidentels, et compensation d'espace via body.has-bottombar. */\n287\t.ka-bottombar {\n288\t position: fixed; left: 0; right: 0; bottom: 0;\n289\t z-index: var(--z-bottombar);\n290\t padding-bottom: env(safe-area-inset-bottom, 0px);\n291\t background: var(--surface);\n292\t border-top: 1.5px solid var(--ink);\n293\t transform: none !important; filter: none !important;\n294\t}\n295\tbody.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }\n296\t\n297\t/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres\n298\t ouverts) : ajouter/retirer .ka-scroll-lock sur . */\n299\thtml.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }\n300\t\n301\t/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox\n302\t incluses), défilement interne avec élan, jamais plus haut que l'écran. */\n303\t.ka-menu {\n304\t position: absolute; z-index: var(--z-dropdown);\n305\t min-width: 180px;\n306\t max-height: min(60vh, 420px); max-height: min(60dvh, 420px);\n307\t overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;\n308\t background: var(--surface); border: 1.5px solid var(--ink);\n309\t border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);\n310\t}\n311\t.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role=\"menuitem\"], .ka-menu [role=\"option\"] {\n312\t display: flex; align-items: center; min-height: var(--touch);\n313\t padding: 10px 14px; width: 100%; text-decoration: none;\n314\t}\n315\t/* Voile plein écran sous une modale / un panneau */\n316\t.ka-overlay {\n317\t position: fixed; inset: 0; z-index: var(--z-overlay);\n318\t background: rgba(20, 24, 20, 0.45);\n319\t}\n320\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"f26ff4f4-046f-42c1-8b23-a973ad3a4053","timestamp":"2026-08-25T18:17:38.908Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/tokens.css","content":"/* -----------------------------------------------------------------------------\n Auteur : Simon-Pierre Boucher — contact@spboucher.ai\n Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)\n Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle\n de composants partagés par les 12 plateformes. Chaque site importe ce\n fichier PUIS surcharge uniquement son accent (voir accents.css).\n\n · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono\n · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site\n · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,\n surlignés d'accent inclinés (néo-brutalisme raffiné)\n · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)\n · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)\n----------------------------------------------------------------------------- */\n\n:root {\n /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */\n --paper: #f5f3ee;\n --surface: #ffffff;\n --surface-2: #faf9f5;\n --ink: #141814;\n --ink-2: #4d5551;\n --ink-3: #8b928c;\n --line: rgba(20, 24, 20, 0.14);\n --line-strong: rgba(20, 24, 20, 0.85);\n --green: #1c5c41;\n --green-deep: #123f2e;\n --amber: #e8a33d;\n --amber-soft: #fdf3e2;\n --danger: #b3423a;\n --danger-soft: #fbe9e7;\n\n /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */\n --accent: #d9f26b; /* défaut : lime Groupe KA */\n --accent-soft: #f0f9d2;\n --accent-deep: #123f2e;\n --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */\n\n /* alias rétro-compatibles (les satellites historiques utilisent --lime) */\n --lime: var(--accent);\n --lime-soft: var(--accent-soft);\n\n /* ---- Géométrie sharp ---- */\n --r-card: 10px;\n --r-ctl: 6px;\n --r-pill: 999px;\n\n /* ---- Ombres décalées — la signature du groupe ---- */\n --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);\n --shadow-off: 6px 6px 0 var(--ink);\n --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);\n --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);\n\n /* ---- Typographie ---- */\n --font-display: \"Space Grotesk\", system-ui, sans-serif;\n --font-body: \"Inter\", system-ui, sans-serif;\n --font-mono: \"JetBrains Mono\", ui-monospace, monospace;\n\n /* Échelle fluide (mobile-first, clamp) */\n --fs-h1: clamp(30px, 3.2vw + 18px, 54px);\n --fs-h2: clamp(23px, 1.8vw + 14px, 34px);\n --fs-h3: clamp(17px, 0.9vw + 12px, 22px);\n --fs-body: 15px;\n --fs-small: 13px;\n\n /* Espacements */\n --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;\n --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;\n\n /* Cible tactile minimale */\n --touch: 44px;\n\n /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----\n grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,\n purement visuel). Tout composant interactif se place sous 9999 : */\n --z-content: 1; /* contenu positionné ordinaire */\n --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */\n --z-header: 500; /* header du site */\n --z-bottombar: 600; /* tab bar / barre d'action basse */\n --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */\n --z-overlay: 800; /* voile sous modale/panneau */\n --z-modal: 900; /* modales, panneaux de filtres, galeries */\n --z-toast: 950; /* notifications */\n\n /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se\n replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */\n --vh100: 100vh;\n}\n@supports (height: 100dvh) { :root { --vh100: 100dvh; } }\n\n/* ---- Socle ---- */\n* { box-sizing: border-box; }\n/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul\n ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;\n clip (≠ hidden) ne casse pas position:sticky. */\nhtml { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }\nbody {\n margin: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: var(--font-body);\n font-size: var(--fs-body);\n line-height: 1.55;\n -webkit-font-smoothing: antialiased;\n padding-bottom: env(safe-area-inset-bottom);\n overflow-x: clip; /* jamais de débordement horizontal */\n}\nimg, svg, video { max-width: 100%; height: auto; display: block; }\nh1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }\nh1 { font-size: var(--fs-h1); line-height: 1.06; }\nh2 { font-size: var(--fs-h2); line-height: 1.15; }\nh3 { font-size: var(--fs-h3); line-height: 1.25; }\na { color: inherit; }\n::selection { background: var(--accent); color: var(--on-accent); }\n:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }\n\n/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:\n relative seulement), sinon les utilitaires z-* sont écrasés. */\nbody::before {\n content: \"\";\n position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\");\n}\nbody > * { position: relative; }\n\n/* ---- Conteneur commun ---- */\n.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }\n@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }\n\n/* ---- Micro-typographie signature ---- */\n.kicker {\n display: inline-flex; align-items: center; gap: 10px;\n font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;\n text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);\n}\n.kicker::before { content: \"\"; width: 22px; height: 2px; background: var(--green); }\n.klabel {\n font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n}\n.hl {\n display: inline-block; background: var(--accent); color: var(--on-accent);\n border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);\n}\n.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }\n\n/* ---- Boutons (cible ≥ 44 px) ---- */\n.btn {\n display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n min-height: var(--touch); padding: 10px 18px;\n font-family: var(--font-display); font-weight: 700; font-size: 14px;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); color: var(--ink);\n cursor: pointer; text-decoration: none;\n transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;\n}\n.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n.btn:active { transform: translate(0, 0); box-shadow: none; }\n.btn:disabled { opacity: 0.45; pointer-events: none; }\n.btn-primary { background: var(--ink); color: var(--accent); }\n.btn-primary:hover { background: var(--accent-deep); }\n.btn-accent { background: var(--accent); color: var(--on-accent); }\n.btn-ghost { background: transparent; border-color: var(--line); }\n.btn-ghost:hover { border-color: var(--ink); }\n\n/* ---- Cartes ---- */\n.card {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n overflow: hidden;\n}\n.card-hover { transition: transform 0.15s, box-shadow 0.15s; }\n.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }\n\n/* ---- Chips / badges ---- */\n.chip {\n display: inline-flex; align-items: center; gap: 6px;\n padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.06em;\n border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n background: var(--surface); color: var(--ink);\n}\n.chip-accent { background: var(--accent); color: var(--on-accent); }\n.chip-soft { background: var(--accent-soft); border-color: var(--line); }\n\n/* ---- Champs ---- */\n.input, .select, .textarea {\n width: 100%; min-height: var(--touch); padding: 10px 14px;\n font: inherit; color: var(--ink); background: var(--surface);\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n}\n.input:focus, .select:focus, .textarea:focus {\n outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);\n}\n.input::placeholder { color: var(--ink-3); }\n\n/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */\n.gk-badge {\n display: inline-flex; align-items: center; gap: 7px;\n min-height: 30px; padding: 3px 10px 4px;\n font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;\n border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n background: var(--surface); color: var(--ink-2); white-space: nowrap;\n}\n.gk-badge b {\n font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;\n text-transform: none; color: var(--ink);\n}\n.gk-badge b .ka {\n display: inline-block; background: var(--ink); color: var(--accent);\n border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);\n}\n.gk-badge:hover .ka { transform: rotate(0); }\n.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }\n.gk-badge--dark b { color: var(--paper); }\n\n/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */\n.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }\n.ka-footer a { text-decoration: none; }\n.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }\n.ka-footer .wordmark .ka { color: var(--accent); }\n.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }\n.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }\n.ka-footer .notice b { color: var(--paper); }\n.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }\n.ka-footer .sites a { color: rgba(245,243,238,0.65); }\n.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }\n@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }\n.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }\n.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }\n.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }\n.ka-footer .legal a { color: inherit; }\n.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }\n\n/* ---- Tableaux → cartes empilées sous 768 px ---- */\n.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }\n@media (max-width: 767px) {\n .tbl-stack thead { display: none; }\n .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }\n .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }\n .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }\n}\n\n/* ---- Utilitaires responsive ---- */\n.only-mobile { display: initial; } .only-desktop { display: none; }\n@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }\n\n/* =============================================================================\n SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.\n RÈGLES pour tout composant futur :\n · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;\n le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).\n · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.\n · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/\n will-change/backdrop-filter — sinon monter l'élément à la racine (body).\n · Barre basse fixe = .ka-bottombar + classe has-bottombar sur .\n · Menu/panneau ouvert = .ka-scroll-lock sur (scroll arrière-plan gelé).\n · z-index : uniquement l'échelle --z-* ci-dessus.\n · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).\n ========================================================================== */\n\n/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px\n déclenche un zoom automatique de page sur Safari iOS). !important assumé :\n c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi\n qu'il arrive — ne s'applique qu'aux écrans tactiles. */\n@media (hover: none) and (pointer: coarse) {\n input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]),\n select, textarea { font-size: max(16px, 1em) !important; }\n}\n\n/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer\n son rendu (débord de la zone cliquable via pseudo-élément). */\n@media (pointer: coarse) {\n .gk-badge { position: relative; }\n .gk-badge::after { content: \"\"; position: absolute; inset: -8px; }\n}\n\n/* Hauteurs viewport dynamiques */\n.h-viewport { height: 100vh; height: 100dvh; }\n.min-h-viewport { min-height: 100vh; min-height: 100dvh; }\n\n/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les\n transforms accidentels, et compensation d'espace via body.has-bottombar. */\n.ka-bottombar {\n position: fixed; left: 0; right: 0; bottom: 0;\n z-index: var(--z-bottombar);\n padding-bottom: env(safe-area-inset-bottom, 0px);\n background: var(--surface);\n border-top: 1.5px solid var(--ink);\n transform: none !important; filter: none !important;\n}\nbody.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }\n\n/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres\n ouverts) : ajouter/retirer .ka-scroll-lock sur . */\nhtml.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }\n\n/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox\n incluses), défilement interne avec élan, jamais plus haut que l'écran. */\n.ka-menu {\n position: absolute; z-index: var(--z-dropdown);\n min-width: 180px;\n max-height: min(60vh, 420px); max-height: min(60dvh, 420px);\n overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);\n}\n.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role=\"menuitem\"], .ka-menu [role=\"option\"] {\n display: flex; align-items: center; min-height: var(--touch);\n padding: 10px 14px; width: 100%; text-decoration: none;\n}\n/* Voile plein écran sous une modale / un panneau */\n.ka-overlay {\n position: fixed; inset: 0; z-index: var(--z-overlay);\n background: rgba(20, 24, 20, 0.45);\n}\n","numLines":320,"startLine":1,"totalLines":320}}}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016D5BoQaBQE3YTNVfWsqsqu","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/api.ts\n7\t * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n8\t * Créé : 2026-08-17 Modifié : 2026-08-25\n9\t * =============================================================================\n10\t */\n11\t\n12\texport interface Job {\n13\t uid: string;\n14\t source: string;\n15\t external_id: string;\n16\t url: string;\n17\t employer: string;\n18\t title: string;\n19\t description?: string;\n20\t address: string;\n21\t city: string;\n22\t region: string;\n23\t postal_code: string;\n24\t location_label: string;\n25\t work_mode: string | null;\n26\t employment_type: string | null;\n27\t salary_min: number | null;\n28\t salary_max: number | null;\n29\t salary_unit: string | null;\n30\t salary_label: string;\n31\t salary_year_min: number | null;\n32\t salary_year_max: number | null;\n33\t salary_hour_min: number | null;\n34\t salary_hour_max: number | null;\n35\t benefits: string[];\n36\t requirements: JobRequirements;\n37\t date_posted: string | null;\n38\t date_deadline: string | null;\n39\t category: string;\n40\t ats: string;\n41\t details: Record;\n42\t lat: number | null;\n43\t lng: number | null;\n44\t active: number;\n45\t dup_sources?: string[];\n46\t is_direct?: boolean;\n47\t company_logo?: string | null;\n48\t language?: string | null; // fr | en | bilingue\n49\t apply_url?: string | null; // candidature directe (≠ url de la fiche)\n50\t title_clean?: string | null; // titre d'affichage normalisé\n51\t seniority?: string | null; // stage | junior | intermediaire | senior | direction\n52\t first_seen?: number | null; // epoch : première collecte par Job·Ka\n53\t // fiche seulement (GET /api/jobs/{uid}) :\n54\t salary_context?: SalaryContext | null;\n55\t employer_jobs?: number; // offres actives du même employeur\n56\t salary_history?: SalaryPoint[];\n57\t}\n58\t\n59\t/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\n60\texport interface JobRequirements {\n61\t experience_years?: number; // années d'expérience minimales exigées\n62\t education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n63\t languages?: string[]; // langues exigées\n64\t [k: string]: unknown; // champs bruts hérités des ATS\n65\t}\n66\t\n67\t/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\n68\texport interface SalaryContext {\n69\t category: string;\n70\t n: number; // offres avec salaire dans la catégorie\n71\t p25: number;\n72\t median: number;\n73\t p75: number;\n74\t job_mid?: number; // point médian de la fourchette de l'offre\n75\t delta_pct?: number; // écart % vs médiane de la catégorie\n76\t}\n77\t\n78\texport interface SalaryPoint {\n79\t ts: number;\n80\t s_min: number | null;\n81\t s_max: number | null;\n82\t unit: string | null;\n83\t}\n84\t\n85\texport interface JobList {\n86\t total: number;\n87\t count: number;\n88\t jobs: Job[];\n89\t}\n90\t\n91\texport interface Facets {\n92\t cities: string[];\n93\t regions: { region: string; n: number }[];\n94\t languages: { language: string; n: number }[];\n95\t categories: { category: string; n: number }[];\n96\t employers: { employer: string; n: number }[];\n97\t work_modes: string[];\n98\t employment_types: string[];\n99\t seniorities: { seniority: string; n: number }[];\n100\t sources: { source: string; n: number }[];\n101\t}\n102\t\n103\texport interface Source {\n104\t id: string;\n105\t name: string;\n106\t url: string;\n107\t careers_url: string;\n108\t sectors: string[];\n109\t connector: string;\n110\t status: string;\n111\t region: string;\n112\t active_jobs: number;\n113\t last_sync: number | null;\n114\t}\n115\t\n116\texport interface Stats {\n117\t total: number;\n118\t employers: number;\n119\t sources: number;\n120\t with_salary: number;\n121\t avg_salary_year: number | null;\n122\t remote: number;\n123\t geocoded: number;\n124\t direct_jobs?: number;\n125\t direct_employers?: number;\n126\t direct_sources?: number;\n127\t portal_jobs?: number;\n128\t duplicates_hidden?: number;\n129\t top_cities: { city: string; n: number }[];\n130\t categories: { category: string; n: number }[];\n131\t recent_syncs: {\n132\t source: string; ts: number; found: number; added: number;\n133\t updated: number; removed: number; ok: number; message: string;\n134\t }[];\n135\t}\n136\t\n137\tasync function get(path: string, params?: Record): Promise {\n138\t const url = new URL(path, window.location.origin);\n139\t if (params) {\n140\t for (const [k, v] of Object.entries(params)) {\n141\t if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n142\t }\n143\t }\n144\t const ctl = new AbortController();\n145\t const timer = setTimeout(() => ctl.abort(), 20000);\n146\t try {\n147\t const resp = await fetch(url.toString(), { signal: ctl.signal });\n148\t if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n149\t return (await resp.json()) as T;\n150\t } finally {\n151\t clearTimeout(timer);\n152\t }\n153\t}\n154\t\n155\texport interface JobFilters {\n156\t q?: string;\n157\t city?: string;\n158\t region?: string;\n159\t category?: string;\n160\t work_mode?: string;\n161\t employment_type?: string;\n162\t seniority?: string;\n163\t language?: string;\n164\t salary_min?: number;\n165\t with_salary?: number;\n166\t sort?: string;\n167\t}\n168\t\n169\texport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n170\t get(\"/api/jobs\", { ...filters, limit, offset });\n171\t\n172\texport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n173\t\n174\texport const fetchFacets = () => get(\"/api/facets\");\n175\t\n176\texport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n177\t\n178\texport const fetchStats = () => get(\"/api/stats\");\n179\t\n180\texport const geojsonUrl = (filters: JobFilters) => {\n181\t const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n182\t for (const [k, v] of Object.entries(filters)) {\n183\t if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n184\t }\n185\t return url.toString();\n186\t};\n187\t\n188\t// --- présentation ------------------------------------------------------------\n189\t\n190\tconst UNIT_FR: Record = {\n191\t hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n192\t};\n193\t\n194\texport function formatSalary(job: Pick): string | null {\n195\t if (job.salary_min == null || !job.salary_unit) return null;\n196\t const fmt = (n: number) =>\n197\t n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n198\t : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n199\t const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n200\t if (job.salary_max != null && job.salary_max > job.salary_min) {\n201\t return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n202\t }\n203\t return `${fmt(job.salary_min)} $ / ${unit}`;\n204\t}\n205\t\n206\texport const MODE_FR: Record = {\n207\t presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n208\t};\n209\t\n210\texport const TYPE_FR: Record = {\n211\t temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n212\t contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n213\t};\n214\t\n215\texport const LANG_FR: Record = {\n216\t fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n217\t};\n218\t\n219\texport const SENIORITY_FR: Record = {\n220\t stage: \"Stage\", junior: \"Junior\", intermediaire: \"Intermédiaire\",\n221\t senior: \"Senior\", direction: \"Direction\",\n222\t};\n223\t\n224\t/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */\n225\texport function compactMoney(n: number): string {\n226\t if (n >= 1000) {\n227\t const k = n / 1000;\n228\t return `${k.toLocaleString(\"fr-CA\", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;\n229\t }\n230\t return `${n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })} $`;\n231\t}\n232\t\n233\t/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */\n234\texport function yearlyEquiv(job: Pick): string | null {\n235\t if (job.salary_year_min == null || job.salary_unit === \"year\") return null;\n236\t const lo = compactMoney(job.salary_year_min);\n237\t const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min\n238\t ? ` – ${compactMoney(job.salary_year_max)}` : \"\";\n239\t return `≈ ${lo}${hi}/an`;\n240\t}\n241\t\n242\t/** Jours écoulés depuis une date ISO (null si absente ou invalide). */\n243\texport function daysSince(iso: string | null | undefined): number | null {\n244\t if (!iso) return null;\n245\t const d = new Date(`${iso}T12:00:00`).getTime();\n246\t if (Number.isNaN(d)) return null;\n247\t return Math.max(0, Math.round((Date.now() - d) / 86400000));\n248\t}\n249\t\n250\t/** « aujourd'hui », « hier », « il y a N jours ». */\n251\texport function agoLabel(days: number): string {\n252\t if (days === 0) return \"aujourd'hui\";\n253\t if (days === 1) return \"hier\";\n254\t return `il y a ${days} jours`;\n255\t}\n256\t\n257\t/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\n258\texport const displayTitle = (job: Pick) =>\n259\t job.title_clean || job.title;\n260\t\n261\texport function formatDate(iso: string | null): string {\n262\t if (!iso) return \"\";\n263\t const d = new Date(`${iso}T12:00:00`);\n264\t return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n265\t}\n266\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"e998b112-5cc8-4208-8764-0cd97d6e3f97","timestamp":"2026-08-25T18:17:38.911Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n seniorities: { seniority: string; n: number }[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n seniority?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\nexport const SENIORITY_FR: Record = {\n stage: \"Stage\", junior: \"Junior\", intermediaire: \"Intermédiaire\",\n senior: \"Senior\", direction: \"Direction\",\n};\n\n/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */\nexport function compactMoney(n: number): string {\n if (n >= 1000) {\n const k = n / 1000;\n return `${k.toLocaleString(\"fr-CA\", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;\n }\n return `${n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })} $`;\n}\n\n/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */\nexport function yearlyEquiv(job: Pick): string | null {\n if (job.salary_year_min == null || job.salary_unit === \"year\") return null;\n const lo = compactMoney(job.salary_year_min);\n const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min\n ? ` – ${compactMoney(job.salary_year_max)}` : \"\";\n return `≈ ${lo}${hi}/an`;\n}\n\n/** Jours écoulés depuis une date ISO (null si absente ou invalide). */\nexport function daysSince(iso: string | null | undefined): number | null {\n if (!iso) return null;\n const d = new Date(`${iso}T12:00:00`).getTime();\n if (Number.isNaN(d)) return null;\n return Math.max(0, Math.round((Date.now() - d) / 86400000));\n}\n\n/** « aujourd'hui », « hier », « il y a N jours ». */\nexport function agoLabel(days: number): string {\n if (days === 0) return \"aujourd'hui\";\n if (days === 1) return \"hier\";\n return `il y a ${days} jours`;\n}\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","numLines":266,"startLine":1,"totalLines":266}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a5dc1468-3e2b-44e0-a06a-38f065678326","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"16ee7f97-b27f-4ecb-85a1-61b104457bfd","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":115,"estimated_tokens_delta":65,"uuid":"f49027c0-38d8-44a2-a39b-2801bc630aae","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvf3HdneVWwmBVYvcLz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISxAMKpwEIERgCKkD34lXcg72WRvGZSbwjAODzNu8Y6pZSTMfvirRZ6TKXYiTHEtu35br0g1ISSrK2lGu2ZuHTa+j/IMsl0Uwv4MhsMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHHuLfUBrABAhIMr/OvsH3AkLttggvrGgzWvNrtA8/NQ61WMPgiMG79QlEqFqU7FuNb/vAJ/XByd5BiYbXcKLAmAYXE9VqWvraY9VbxhUQuYjaEicH2HSrJAVLBzwho4Z5gVQiwlUb0txaIUmmVu/ewbnxVaB32lO6HoKTfK9P30rNopyigQearmdMgqT6KUbXT8IiyRTjef0jGD/+fjQt0nj9jkoAfVOQdi1ZB9FRYSX3uK4WwI5M7vBc2nr0rFFFfBmntfL5NDflCkfS4RPjhL0zCY6ueSH2jHNNRrFZZnub4EcNKTvyY2j1/xS9iOPIqktnL/yl9GweJOqjDC1Sn6tdy0rknSivUbHsA/J58QsQAs5xmFbJaaQ8Ry7TmHMUK0xgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":12617,"cache_read_input_tokens":59807,"cache_creation":{"ephemeral_5m_input_tokens":12617,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"cc0fd94b-d2b7-4083-b930-c66411cdd24f","request_id":"req_011CePvf1eubRCmsuqStJwvU"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvf3HdneVWwmBVYvcLz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0198cT3qKMgDfPnbwJAmGyFe","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":12617,"cache_read_input_tokens":59807,"cache_creation":{"ephemeral_5m_input_tokens":12617,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"0ec50c9c-7404-4d55-ae98-39f48d1d1212","request_id":"req_011CePvf1eubRCmsuqStJwvU"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvf3HdneVWwmBVYvcLz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M6dVHZjHV6QnPZNGu6YuUw","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":12617,"cache_read_input_tokens":59807,"cache_creation":{"ephemeral_5m_input_tokens":12617,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"8f5774a9-9061-4497-9a3e-c2c608c1bdb3","request_id":"req_011CePvf1eubRCmsuqStJwvU"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M6dVHZjHV6QnPZNGu6YuUw","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Stats.tsx\n7\t * Rôle : Tableau de bord analytique — module Stats commun Groupe KA v2\n8\t * (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).\n9\t * PDF (5 rapports) + fraîcheur, KPI + sparklines, jauges, courbes\n10\t * (N-1) + stats de séries, multi-courbes, barres empilées,\n11\t * répartitions, distributions, géographie, calendrier + heatmap\n12\t * horaire, tableaux, records.\n13\t * Créé : 2026-08-17 Modifié : 2026-08-19\n14\t * =============================================================================\n15\t */\n16\timport { useCallback, useEffect, useState } from \"react\";\n17\timport {\n18\t BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,\n19\t EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,\n20\t Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,\n21\t PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,\n22\t StackedSerie, StatSummary, TableSpec,\n23\t} from \"../ka/stats/kacharts\";\n24\t\n25\tinterface Dashboard {\n26\t updated: string;\n27\t period: { from: string | null; to: string | null; label: string };\n28\t kpis: Kpi[];\n29\t gauges?: Gauge[];\n30\t series: Serie[];\n31\t multiseries?: MultiSerie[];\n32\t stacked?: StackedSerie[];\n33\t breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];\n34\t distributions?: Distribution[];\n35\t geo?: { title: string; items: BreakItem[] };\n36\t heatmap?: { title: string; cells: { date: string; value: number }[] };\n37\t hourly?: { title: string; cells: HourCell[] };\n38\t tables: TableSpec[];\n39\t records: RecordFact[];\n40\t}\n41\t\n42\tfunction SectionTitle({ children }: { children: string }) {\n43\t return (\n44\t

\n45\t {children}\n46\t

\n47\t );\n48\t}\n49\t\n50\texport default function StatsPage() {\n51\t const [period, setPeriod] = useState(\"30j\");\n52\t const [custom, setCustom] = useState<{ from: string; to: string }>({ from: \"\", to: \"\" });\n53\t const [dash, setDash] = useState(null);\n54\t const [loading, setLoading] = useState(true);\n55\t const [err, setErr] = useState(null);\n56\t\n57\t const customOk = Boolean(custom.from && custom.to);\n58\t\n59\t const load = useCallback(() => {\n60\t setLoading(true);\n61\t setErr(null);\n62\t const p = new URLSearchParams({ period });\n63\t if (customOk) {\n64\t p.set(\"from\", custom.from);\n65\t p.set(\"to\", custom.to);\n66\t }\n67\t fetch(`/api/stats/dashboard?${p}`)\n68\t .then((r) => {\n69\t if (!r.ok) throw new Error(`API ${r.status}`);\n70\t return r.json();\n71\t })\n72\t .then((d: Dashboard) => setDash(d))\n73\t .catch((e) => setErr(String(e?.message ?? e)))\n74\t .finally(() => setLoading(false));\n75\t }, [period, custom.from, custom.to, customOk]);\n76\t\n77\t useEffect(() => {\n78\t document.title = \"Statistiques de l'emploi — Job-Ka · Un service Groupe KA\";\n79\t load();\n80\t }, [load]);\n81\t\n82\t const donuts = dash?.breakdowns?.filter((b) => b.kind === \"donut\") ?? [];\n83\t const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== \"donut\") ?? [];\n84\t\n85\t return (\n86\t
\n87\t {/* --- en-tête : titre + PDF (5 rapports) + fraîcheur ------------------ */}\n88\t
\n89\t
\n90\t

Groupe KA · Job·Ka

\n91\t

\n92\t Statistiques du marché de l'emploi\n93\t

\n94\t {dash && (\n95\t

\n96\t Période : {dash.period.label}\n97\t {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : \"\"}\n98\t

\n99\t )}\n100\t
\n101\t
\n102\t \n103\t {dash && }\n104\t
\n105\t
\n106\t\n107\t {err && (\n108\t
\n109\t Impossible de charger les statistiques.\n110\t

{err}

\n111\t \n112\t
\n113\t )}\n114\t\n115\t {/* --- bandeau KPI (sparklines) ---------------------------------------- */}\n116\t {dash && dash.kpis.length > 0 && (\n117\t
\n118\t {dash.kpis.map((k) => )}\n119\t
\n120\t )}\n121\t {!dash && loading && (\n122\t
\n123\t Chargement des statistiques…\n124\t
\n125\t )}\n126\t\n127\t {/* --- sélecteur de période --------------------------------------------- */}\n128\t
\n129\t { setPeriod(p); setCustom({ from: \"\", to: \"\" }); }}\n132\t custom={custom}\n133\t onCustom={(from, to) => setCustom({ from, to })}\n134\t />\n135\t
\n136\t\n137\t {dash && (\n138\t
\n139\t {/* --- jauges (complétude des fiches) ------------------------------ */}\n140\t {dash.gauges && dash.gauges.length > 0 && (\n141\t
\n142\t {dash.gauges.map((g) => )}\n143\t
\n144\t )}\n145\t\n146\t {/* --- évolutions : courbes / barres + stats de séries -------------- */}\n147\t {dash.series?.length ? (\n148\t dash.series.map((s) => (\n149\t
\n150\t \n151\t \n152\t
\n153\t ))\n154\t ) : (\n155\t \n156\t )}\n157\t\n158\t {/* --- multi-courbes (top secteurs) ---------------------------------- */}\n159\t {dash.multiseries?.map((ms) => )}\n160\t\n161\t {/* --- barres empilées (ajouts par source) --------------------------- */}\n162\t {dash.stacked?.map((st) => )}\n163\t\n164\t {/* --- répartitions (anneaux + barres avec deltas) ------------------- */}\n165\t {(donuts.length > 0 || barBreaks.length > 0) && (\n166\t
\n167\t {donuts.map((b) => )}\n168\t {barBreaks.map((b) => )}\n169\t
\n170\t )}\n171\t\n172\t {/* --- distributions (histogrammes) ---------------------------------- */}\n173\t {dash.distributions && dash.distributions.length > 0 && (\n174\t
\n175\t {dash.distributions.map((d) => )}\n176\t
\n177\t )}\n178\t\n179\t {/* --- géographie ------------------------------------------------------ */}\n180\t {dash.geo && }\n181\t\n182\t {/* --- calendrier de chaleur + activité horaire ------------------------ */}\n183\t {dash.heatmap && }\n184\t {dash.hourly && }\n185\t\n186\t {/* --- tableaux détaillés ---------------------------------------------- */}\n187\t {dash.tables?.map((t) => )}\n188\t\n189\t {/* --- records & faits marquants ---------------------------------------- */}\n190\t {dash.records?.length > 0 && (\n191\t
\n192\t Records & faits marquants\n193\t
\n194\t {dash.records.map((r) => )}\n195\t
\n196\t
\n197\t )}\n198\t
\n199\t )}\n200\t
\n201\t );\n202\t}\n203\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"dd7466c4-714e-4315-8dbc-893fbde13d55","timestamp":"2026-08-25T18:17:44.477Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Stats.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Stats.tsx\n * Rôle : Tableau de bord analytique — module Stats commun Groupe KA v2\n * (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).\n * PDF (5 rapports) + fraîcheur, KPI + sparklines, jauges, courbes\n * (N-1) + stats de séries, multi-courbes, barres empilées,\n * répartitions, distributions, géographie, calendrier + heatmap\n * horaire, tableaux, records.\n * Créé : 2026-08-17 Modifié : 2026-08-19\n * =============================================================================\n */\nimport { useCallback, useEffect, useState } from \"react\";\nimport {\n BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,\n EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,\n Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,\n PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,\n StackedSerie, StatSummary, TableSpec,\n} from \"../ka/stats/kacharts\";\n\ninterface Dashboard {\n updated: string;\n period: { from: string | null; to: string | null; label: string };\n kpis: Kpi[];\n gauges?: Gauge[];\n series: Serie[];\n multiseries?: MultiSerie[];\n stacked?: StackedSerie[];\n breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];\n distributions?: Distribution[];\n geo?: { title: string; items: BreakItem[] };\n heatmap?: { title: string; cells: { date: string; value: number }[] };\n hourly?: { title: string; cells: HourCell[] };\n tables: TableSpec[];\n records: RecordFact[];\n}\n\nfunction SectionTitle({ children }: { children: string }) {\n return (\n

\n {children}\n

\n );\n}\n\nexport default function StatsPage() {\n const [period, setPeriod] = useState(\"30j\");\n const [custom, setCustom] = useState<{ from: string; to: string }>({ from: \"\", to: \"\" });\n const [dash, setDash] = useState(null);\n const [loading, setLoading] = useState(true);\n const [err, setErr] = useState(null);\n\n const customOk = Boolean(custom.from && custom.to);\n\n const load = useCallback(() => {\n setLoading(true);\n setErr(null);\n const p = new URLSearchParams({ period });\n if (customOk) {\n p.set(\"from\", custom.from);\n p.set(\"to\", custom.to);\n }\n fetch(`/api/stats/dashboard?${p}`)\n .then((r) => {\n if (!r.ok) throw new Error(`API ${r.status}`);\n return r.json();\n })\n .then((d: Dashboard) => setDash(d))\n .catch((e) => setErr(String(e?.message ?? e)))\n .finally(() => setLoading(false));\n }, [period, custom.from, custom.to, customOk]);\n\n useEffect(() => {\n document.title = \"Statistiques de l'emploi — Job-Ka · Un service Groupe KA\";\n load();\n }, [load]);\n\n const donuts = dash?.breakdowns?.filter((b) => b.kind === \"donut\") ?? [];\n const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== \"donut\") ?? [];\n\n return (\n
\n {/* --- en-tête : titre + PDF (5 rapports) + fraîcheur ------------------ */}\n
\n
\n

Groupe KA · Job·Ka

\n

\n Statistiques du marché de l'emploi\n

\n {dash && (\n

\n Période : {dash.period.label}\n {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : \"\"}\n

\n )}\n
\n
\n \n {dash && }\n
\n
\n\n {err && (\n
\n Impossible de charger les statistiques.\n

{err}

\n \n
\n )}\n\n {/* --- bandeau KPI (sparklines) ---------------------------------------- */}\n {dash && dash.kpis.length > 0 && (\n
\n {dash.kpis.map((k) => )}\n
\n )}\n {!dash && loading && (\n
\n Chargement des statistiques…\n
\n )}\n\n {/* --- sélecteur de période --------------------------------------------- */}\n
\n { setPeriod(p); setCustom({ from: \"\", to: \"\" }); }}\n custom={custom}\n onCustom={(from, to) => setCustom({ from, to })}\n />\n
\n\n {dash && (\n
\n {/* --- jauges (complétude des fiches) ------------------------------ */}\n {dash.gauges && dash.gauges.length > 0 && (\n
\n {dash.gauges.map((g) => )}\n
\n )}\n\n {/* --- évolutions : courbes / barres + stats de séries -------------- */}\n {dash.series?.length ? (\n dash.series.map((s) => (\n
\n \n \n
\n ))\n ) : (\n \n )}\n\n {/* --- multi-courbes (top secteurs) ---------------------------------- */}\n {dash.multiseries?.map((ms) => )}\n\n {/* --- barres empilées (ajouts par source) --------------------------- */}\n {dash.stacked?.map((st) => )}\n\n {/* --- répartitions (anneaux + barres avec deltas) ------------------- */}\n {(donuts.length > 0 || barBreaks.length > 0) && (\n
\n {donuts.map((b) => )}\n {barBreaks.map((b) => )}\n
\n )}\n\n {/* --- distributions (histogrammes) ---------------------------------- */}\n {dash.distributions && dash.distributions.length > 0 && (\n
\n {dash.distributions.map((d) => )}\n
\n )}\n\n {/* --- géographie ------------------------------------------------------ */}\n {dash.geo && }\n\n {/* --- calendrier de chaleur + activité horaire ------------------------ */}\n {dash.heatmap && }\n {dash.hourly && }\n\n {/* --- tableaux détaillés ---------------------------------------------- */}\n {dash.tables?.map((t) => )}\n\n {/* --- records & faits marquants ---------------------------------------- */}\n {dash.records?.length > 0 && (\n
\n Records & faits marquants\n
\n {dash.records.map((r) => )}\n
\n
\n )}\n
\n )}\n
\n );\n}\n","numLines":203,"startLine":1,"totalLines":203}}}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0198cT3qKMgDfPnbwJAmGyFe","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Job.tsx\n7\t * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n8\t * (salaire vs marché, exigences, fraîcheur), description, lien vers\n9\t * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n10\t * Créé : 2026-08-17 Modifié : 2026-08-25\n11\t * =============================================================================\n12\t */\n13\timport { ReactNode, useEffect, useState } from \"react\";\n14\timport { Link, useParams } from \"react-router-dom\";\n15\timport {\n16\t agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n17\t formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n18\t} from \"../api\";\n19\timport { FavButton } from \"../favorites\";\n20\t\n21\t/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\n22\tfunction Metric({ label, value, sub, title }: {\n23\t label: string; value: ReactNode; sub?: ReactNode; title?: string;\n24\t}) {\n25\t return (\n26\t
\n27\t
{value}
\n28\t
{label}
\n29\t {sub &&
{sub}
}\n30\t
\n31\t );\n32\t}\n33\t\n34\t/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n35\t * catégorie (fenêtre interquartile = zone « normale » du marché). */\n36\tfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n37\t const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n38\t const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n39\t return (\n40\t
\n41\t
\n42\t
\n43\t
\n44\t
\n45\t );\n46\t}\n47\t\n48\texport default function JobPage() {\n49\t const { uid = \"\" } = useParams();\n50\t const [job, setJob] = useState(null);\n51\t const [error, setError] = useState(\"\");\n52\t\n53\t useEffect(() => {\n54\t setJob(null);\n55\t fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n56\t window.scrollTo(0, 0);\n57\t }, [uid]);\n58\t\n59\t if (error) return

Offre introuvable

← Retour aux offres
;\n60\t if (!job) return
Chargement…
;\n61\t\n62\t const salary = formatSalary(job);\n63\t const yearly = yearlyEquiv(job);\n64\t const days = daysSince(job.date_posted);\n65\t const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n66\t const deadlineIn = job.date_deadline\n67\t ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n68\t : null;\n69\t const ctx = job.salary_context;\n70\t const reqs = job.requirements ?? {};\n71\t const langsReq = reqs.languages;\n72\t const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n73\t\n74\t return (\n75\t
\n76\t
\n77\t

← Toutes les offres

\n78\t
\n79\t {job.company_logo && (\n80\t { (e.target as HTMLImageElement).style.display = \"none\"; }}\n85\t />\n86\t )}\n87\t

{displayTitle(job)}

\n88\t \n89\t
\n90\t

\n91\t {job.employer}\n92\t {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n93\t · {job.employer_jobs} offres actives\n94\t )}\n95\t {job.location_label && — {job.location_label}}\n96\t {job.region && job.region !== \"Québec\" && ({job.region})}\n97\t

\n98\t\n99\t
\n100\t {days !== null && days <= 3 && Nouveau}\n101\t {job.is_direct !== false && Offre directe}\n102\t {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n103\t {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n104\t {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n105\t {job.language && {LANG_FR[job.language] ?? job.language}}\n106\t {job.category && {job.category}}\n107\t {job.active === 0 && Offre retirée}\n108\t
\n109\t\n110\t {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n111\t

En un coup d'œil

\n112\t
\n113\t \n123\t {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n124\t = 0 ? \"delta-up\" : \"delta-down\"}>\n128\t {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n129\t \n130\t }\n131\t sub={\n132\t <>\n133\t \n134\t médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n135\t \n136\t }\n137\t title=\"Écart entre le point médian de la fourchette de l'offre et la médiane des salaires affichés de la même catégorie sur Job·Ka\"\n138\t />\n139\t )}\n140\t {typeof reqs.experience_years === \"number\" && (\n141\t 1 ? \"s\" : \"\"} min.`}\n144\t sub=\"Extrait du texte de l'offre\"\n145\t />\n146\t )}\n147\t {typeof reqs.education === \"string\" && (\n148\t \n149\t )}\n150\t {Array.isArray(langsReq) && langsReq.length > 0 && (\n151\t \n156\t )}\n157\t {days !== null && (\n158\t \n163\t )}\n164\t {deadlineIn !== null && deadlineDays !== null && (\n165\t = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n168\t sub={formatDate(job.date_deadline)}\n169\t title=\"Date limite pour postuler, publiée par l'employeur\"\n170\t />\n171\t )}\n172\t
\n173\t\n174\t

\n175\t \n176\t Postuler chez {job.employer} ↗\n177\t \n178\t

\n179\t

\n180\t Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n181\t n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n182\t

\n183\t\n184\t {history.length > 1 && (\n185\t <>\n186\t

Évolution du salaire affiché

\n187\t
    \n188\t {history.map((h, i) => (\n189\t
  • \n190\t {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n191\t {\" — \"}\n192\t {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n193\t
  • \n194\t ))}\n195\t
\n196\t \n197\t )}\n198\t\n199\t {job.benefits && job.benefits.length > 0 && (\n200\t <>\n201\t

Avantages

\n202\t
    \n203\t {job.benefits.map((b, i) =>
  • {b}
  • )}\n204\t
\n205\t \n206\t )}\n207\t {job.description && (\n208\t <>\n209\t

Description du poste

\n210\t
{job.description}
\n211\t \n212\t )}\n213\t
\n214\t
\n215\t );\n216\t}\n217\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"e0674c59-8978-4050-bb60-9656df2ec51f","timestamp":"2026-08-25T18:17:44.480Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n * (salaire vs marché, exigences, fraîcheur), description, lien vers\n * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\n/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\nfunction Metric({ label, value, sub, title }: {\n label: string; value: ReactNode; sub?: ReactNode; title?: string;\n}) {\n return (\n
\n
{value}
\n
{label}
\n {sub &&
{sub}
}\n
\n );\n}\n\n/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n * catégorie (fenêtre interquartile = zone « normale » du marché). */\nfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n return (\n
\n
\n
\n
\n
\n );\n}\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n const deadlineIn = job.date_deadline\n ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n : null;\n const ctx = job.salary_context;\n const reqs = job.requirements ?? {};\n const langsReq = reqs.languages;\n const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n · {job.employer_jobs} offres actives\n )}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n\n
\n {days !== null && days <= 3 && Nouveau}\n {job.is_direct !== false && Offre directe}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.active === 0 && Offre retirée}\n
\n\n {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n

En un coup d'œil

\n
\n \n {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n = 0 ? \"delta-up\" : \"delta-down\"}>\n {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n \n }\n sub={\n <>\n \n médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n \n }\n title=\"Écart entre le point médian de la fourchette de l'offre et la médiane des salaires affichés de la même catégorie sur Job·Ka\"\n />\n )}\n {typeof reqs.experience_years === \"number\" && (\n 1 ? \"s\" : \"\"} min.`}\n sub=\"Extrait du texte de l'offre\"\n />\n )}\n {typeof reqs.education === \"string\" && (\n \n )}\n {Array.isArray(langsReq) && langsReq.length > 0 && (\n \n )}\n {days !== null && (\n \n )}\n {deadlineIn !== null && deadlineDays !== null && (\n = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n sub={formatDate(job.date_deadline)}\n title=\"Date limite pour postuler, publiée par l'employeur\"\n />\n )}\n
\n\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n\n {history.length > 1 && (\n <>\n

Évolution du salaire affiché

\n
    \n {history.map((h, i) => (\n
  • \n {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n {\" — \"}\n {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n
  • \n ))}\n
\n \n )}\n\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","numLines":217,"startLine":1,"totalLines":217}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvf3HdneVWwmBVYvcLz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011sk55sPwkfQMT2y1KD339D","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":12617,"cache_read_input_tokens":59807,"cache_creation":{"ephemeral_5m_input_tokens":12617,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"d5d5b026-82a2-4cd6-952a-23ca553980e6","request_id":"req_011CePvf1eubRCmsuqStJwvU"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011sk55sPwkfQMT2y1KD339D","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Sources.tsx\n7\t * Rôle : Registre public des employeurs connectés (traçabilité des sources)\n8\t * Créé : 2026-08-17 Modifié : 2026-08-17\n9\t * =============================================================================\n10\t */\n11\timport { useEffect, useState } from \"react\";\n12\timport { fetchSources, Source } from \"../api\";\n13\t\n14\texport default function Sources() {\n15\t const [sources, setSources] = useState([]);\n16\t\n17\t useEffect(() => { fetchSources().then((r) => setSources(r.sources)).catch(() => {}); }, []);\n18\t\n19\t return (\n20\t
\n21\t

Employeurs connectés

\n22\t

\n23\t Chaque source est un connecteur automatisé vers la page carrière officielle\n24\t de l'employeur, resynchronisé jour et nuit. Zéro saisie manuelle.\n25\t

\n26\t \n27\t \n28\t \n29\t \n30\t \n31\t \n32\t \n33\t \n34\t {sources.map((s) => (\n35\t \n36\t \n37\t \n38\t \n39\t \n40\t \n41\t \n46\t \n47\t ))}\n48\t \n49\t
EmployeurSecteursRégionATSOffres activesDernière synchro
{s.name}{s.sectors.join(\", \")}{s.region}{s.connector}{s.active_jobs}\n42\t {s.last_sync\n43\t ? new Date(s.last_sync * 1000).toLocaleString(\"fr-CA\", { dateStyle: \"medium\", timeStyle: \"short\" })\n44\t : \"—\"}\n45\t
\n50\t
\n51\t );\n52\t}\n53\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"56a5b5fd-cd1b-474d-b056-7c106ad76076","timestamp":"2026-08-25T18:17:44.962Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Sources.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Sources.tsx\n * Rôle : Registre public des employeurs connectés (traçabilité des sources)\n * Créé : 2026-08-17 Modifié : 2026-08-17\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { fetchSources, Source } from \"../api\";\n\nexport default function Sources() {\n const [sources, setSources] = useState([]);\n\n useEffect(() => { fetchSources().then((r) => setSources(r.sources)).catch(() => {}); }, []);\n\n return (\n
\n

Employeurs connectés

\n

\n Chaque source est un connecteur automatisé vers la page carrière officielle\n de l'employeur, resynchronisé jour et nuit. Zéro saisie manuelle.\n

\n \n \n \n \n \n \n \n \n {sources.map((s) => (\n \n \n \n \n \n \n \n \n ))}\n \n
EmployeurSecteursRégionATSOffres activesDernière synchro
{s.name}{s.sectors.join(\", \")}{s.region}{s.connector}{s.active_jobs}\n {s.last_sync\n ? new Date(s.last_sync * 1000).toLocaleString(\"fr-CA\", { dateStyle: \"medium\", timeStyle: \"short\" })\n : \"—\"}\n
\n
\n );\n}\n","numLines":53,"startLine":1,"totalLines":53}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e15c2930-178d-4694-9fc2-f177f66a7303","session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvfVk6fgDwcdJM7ZQT9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YB7tBd7UtHz7VJPPuvpXhJ","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9917,"cache_read_input_tokens":72424,"cache_creation":{"ephemeral_5m_input_tokens":9917,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"548ba604-78a2-46ab-b5ce-8f3009575ddb","request_id":"req_011CePvfTcMnNQWYhcyTjHju"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YB7tBd7UtHz7VJPPuvpXhJ","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Ville.tsx\n7\t * Rôle : Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.\n8\t * Le serveur rend le même contenu en HTML (jobka/seo.py) ; ici, la\n9\t * version interactive avec les cartes d'offres de l'app.\n10\t * Créé : 2026-08-23 Modifié : 2026-08-23\n11\t * =============================================================================\n12\t */\n13\timport { useEffect, useState } from \"react\";\n14\timport { Link, useParams } from \"react-router-dom\";\n15\timport { fetchJobs, Job } from \"../api\";\n16\timport JobCard from \"../components/JobCard\";\n17\t\n18\tinterface VilleRow {\n19\t city: string;\n20\t slug: string;\n21\t n: number;\n22\t employers: number;\n23\t with_salary: number;\n24\t}\n25\t\n26\tinterface VilleData {\n27\t city: string;\n28\t slug: string;\n29\t n: number;\n30\t employers: number;\n31\t with_salary: number;\n32\t neighbors: VilleRow[];\n33\t}\n34\t\n35\tasync function get(path: string): Promise {\n36\t const res = await fetch(path);\n37\t if (!res.ok) throw new Error(`${res.status}`);\n38\t return res.json();\n39\t}\n40\t\n41\texport function VillesPage() {\n42\t const [villes, setVilles] = useState(null);\n43\t const [error, setError] = useState(null);\n44\t\n45\t useEffect(() => {\n46\t get<{ villes: VilleRow[] }>(\"/api/seo/villes\")\n47\t .then((r) => setVilles(r.villes))\n48\t .catch((e) => setError(String(e)));\n49\t window.scrollTo(0, 0);\n50\t }, []);\n51\t\n52\t return (\n53\t
\n54\t

Offres d'emploi par ville

\n55\t

\n56\t Toutes les villes du Québec où Job-Ka recense des offres d'emploi,\n57\t avec le nombre de postes ouverts et d'employeurs qui recrutent.\n58\t

\n59\t {error &&
⚠️ {error}
}\n60\t {!villes && !error &&
Chargement…
}\n61\t {villes && (\n62\t
    \n63\t {villes.map((v) => (\n64\t
  • \n65\t {v.city}\n66\t {\" — \"}\n67\t {v.n} offre{v.n > 1 ? \"s\" : \"\"} · {v.employers} employeur\n68\t {v.employers > 1 ? \"s\" : \"\"}\n69\t
  • \n70\t ))}\n71\t
\n72\t )}\n73\t
\n74\t );\n75\t}\n76\t\n77\texport default function VillePage() {\n78\t const { ville = \"\" } = useParams();\n79\t const [data, setData] = useState(null);\n80\t const [jobs, setJobs] = useState(null);\n81\t const [error, setError] = useState(null);\n82\t\n83\t useEffect(() => {\n84\t setData(null);\n85\t setJobs(null);\n86\t setError(null);\n87\t get(`/api/seo/ville/${encodeURIComponent(ville)}`)\n88\t .then((d) => {\n89\t setData(d);\n90\t return fetchJobs({ city: d.city }, 60).then((r) => setJobs(r.jobs));\n91\t })\n92\t .catch((e) =>\n93\t setError(e.message === \"404\" ? \"Ville inconnue.\" : String(e)));\n94\t window.scrollTo(0, 0);\n95\t }, [ville]);\n96\t\n97\t if (error)\n98\t return (\n99\t
\n100\t

Page introuvable

\n101\t
\n102\t ⚠️ {error} Voir toutes les villes\n103\t
\n104\t
\n105\t );\n106\t if (!data)\n107\t return (\n108\t
\n109\t
Chargement…
\n110\t
\n111\t );\n112\t\n113\t return (\n114\t
\n115\t

\n116\t Villes — {data.city}\n117\t

\n118\t

Offres d'emploi à {data.city}

\n119\t

\n120\t {data.n} poste{data.n > 1 ? \"s\" : \"\"} chez {data.employers} employeur\n121\t {data.employers > 1 ? \"s\" : \"\"}\n122\t {data.with_salary > 0 && (\n123\t <> · {data.with_salary} avec salaire affiché\n124\t )}\n125\t

\n126\t\n127\t
\n128\t {!jobs &&
Chargement des offres…
}\n129\t {jobs && jobs.map((j) => )}\n130\t
\n131\t\n132\t {data.neighbors.length > 0 && (\n133\t <>\n134\t

Autres villes

\n135\t

\n136\t {data.neighbors.map((v) => (\n137\t \n138\t {v.city} ({v.n})\n139\t \n140\t ))}\n141\t

\n142\t \n143\t )}\n144\t

\n145\t ← Toutes les offres d'emploi au Québec\n146\t

\n147\t
\n148\t );\n149\t}\n150\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"8fe2b40d-ea42-4be2-b4aa-65bf9db2dbca","timestamp":"2026-08-25T18:17:50.614Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Ville.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Ville.tsx\n * Rôle : Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.\n * Le serveur rend le même contenu en HTML (jobka/seo.py) ; ici, la\n * version interactive avec les cartes d'offres de l'app.\n * Créé : 2026-08-23 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport { fetchJobs, Job } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\ninterface VilleRow {\n city: string;\n slug: string;\n n: number;\n employers: number;\n with_salary: number;\n}\n\ninterface VilleData {\n city: string;\n slug: string;\n n: number;\n employers: number;\n with_salary: number;\n neighbors: VilleRow[];\n}\n\nasync function get(path: string): Promise {\n const res = await fetch(path);\n if (!res.ok) throw new Error(`${res.status}`);\n return res.json();\n}\n\nexport function VillesPage() {\n const [villes, setVilles] = useState(null);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n get<{ villes: VilleRow[] }>(\"/api/seo/villes\")\n .then((r) => setVilles(r.villes))\n .catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, []);\n\n return (\n
\n

Offres d'emploi par ville

\n

\n Toutes les villes du Québec où Job-Ka recense des offres d'emploi,\n avec le nombre de postes ouverts et d'employeurs qui recrutent.\n

\n {error &&
⚠️ {error}
}\n {!villes && !error &&
Chargement…
}\n {villes && (\n
    \n {villes.map((v) => (\n
  • \n {v.city}\n {\" — \"}\n {v.n} offre{v.n > 1 ? \"s\" : \"\"} · {v.employers} employeur\n {v.employers > 1 ? \"s\" : \"\"}\n
  • \n ))}\n
\n )}\n
\n );\n}\n\nexport default function VillePage() {\n const { ville = \"\" } = useParams();\n const [data, setData] = useState(null);\n const [jobs, setJobs] = useState(null);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n setData(null);\n setJobs(null);\n setError(null);\n get(`/api/seo/ville/${encodeURIComponent(ville)}`)\n .then((d) => {\n setData(d);\n return fetchJobs({ city: d.city }, 60).then((r) => setJobs(r.jobs));\n })\n .catch((e) =>\n setError(e.message === \"404\" ? \"Ville inconnue.\" : String(e)));\n window.scrollTo(0, 0);\n }, [ville]);\n\n if (error)\n return (\n
\n

Page introuvable

\n
\n ⚠️ {error} Voir toutes les villes\n
\n
\n );\n if (!data)\n return (\n
\n
Chargement…
\n
\n );\n\n return (\n
\n

\n Villes — {data.city}\n

\n

Offres d'emploi à {data.city}

\n

\n {data.n} poste{data.n > 1 ? \"s\" : \"\"} chez {data.employers} employeur\n {data.employers > 1 ? \"s\" : \"\"}\n {data.with_salary > 0 && (\n <> · {data.with_salary} avec salaire affiché\n )}\n

\n\n
\n {!jobs &&
Chargement des offres…
}\n {jobs && jobs.map((j) => )}\n
\n\n {data.neighbors.length > 0 && (\n <>\n

Autres villes

\n

\n {data.neighbors.map((v) => (\n \n {v.city} ({v.n})\n \n ))}\n

\n \n )}\n

\n ← Toutes les offres d'emploi au Québec\n

\n
\n );\n}\n","numLines":150,"startLine":1,"totalLines":150}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvfVk6fgDwcdJM7ZQT9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wu1WNT7rEXyisNMZmBRyZJ","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9917,"cache_read_input_tokens":72424,"cache_creation":{"ephemeral_5m_input_tokens":9917,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"3164e0cb-99f1-4097-aeb9-ac3d58ed568f","request_id":"req_011CePvfTcMnNQWYhcyTjHju"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wu1WNT7rEXyisNMZmBRyZJ","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Favoris.tsx\n7\t * Rôle : Mes favoris ♥ « Mon univers Ka » — offres mises en favoris,\n8\t * lues au hub Groupe KA (magasin central, aucun stockage local).\n9\t * Créé : 2026-08-23 Modifié : 2026-08-23\n10\t * =============================================================================\n11\t */\n12\timport { Link } from \"react-router-dom\";\n13\timport { FavItem, goLogin, useFav } from \"../favorites\";\n14\t\n15\t/** /emploi/ local si l'url du favori pointe vers job-ka.com. */\n16\tfunction localPath(f: FavItem): string | null {\n17\t if (!f.url) return `/emploi/${encodeURIComponent(f.item_id)}`;\n18\t try {\n19\t const u = new URL(f.url);\n20\t if (u.hostname.endsWith(\"job-ka.com\")) return u.pathname + u.search;\n21\t } catch { /* url invalide : lien externe ignoré */ }\n22\t return null;\n23\t}\n24\t\n25\tfunction FavCard({ f }: { f: FavItem }) {\n26\t const { toggle } = useFav();\n27\t const path = localPath(f);\n28\t const inner = (\n29\t <>\n30\t

{f.title}

\n31\t {f.subtitle && {f.subtitle}}\n32\t
\n33\t {f.price_label && {f.price_label}}\n34\t
\n35\t { e.preventDefault(); e.stopPropagation(); toggle(f); }}\n41\t >\n42\t \n43\t \n44\t \n45\t \n46\t \n47\t );\n48\t return path\n49\t ? {inner}\n50\t : {inner};\n51\t}\n52\t\n53\texport default function FavorisPage() {\n54\t const { ready, connected, items } = useFav();\n55\t\n56\t return (\n57\t
\n58\t

Mes favoris

\n59\t

\n60\t Vos offres d'emploi mises en favoris ♥ — synchronisées avec votre\n61\t compte Mon univers Ka sur toutes les plateformes du Groupe KA.\n62\t

\n63\t {!ready &&
Chargement…
}\n64\t {ready && !connected && (\n65\t
\n66\t

Connectez-vous avec votre KA ID pour retrouver vos favoris.

\n67\t \n70\t
\n71\t )}\n72\t {ready && connected && items.length === 0 && (\n73\t
\n74\t

Aucun favori pour l'instant — touchez le cœur ♥ sur une offre\n75\t pour la retrouver ici.

\n76\t Parcourir les offres\n77\t
\n78\t )}\n79\t {ready && connected && items.length > 0 && (\n80\t
\n81\t {items.map((f) => )}\n82\t
\n83\t )}\n84\t {ready && connected && (\n85\t

\n86\t Tous vos favoris Groupe KA (logements, propriétés, produits…) :{\" \"}\n87\t \n88\t Mon univers Ka ↗\n89\t \n90\t

\n91\t )}\n92\t
\n93\t );\n94\t}\n95\t"}]},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"6f5af59a-da9c-42a3-a6bf-181d6be869ce","timestamp":"2026-08-25T18:17:51.223Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Favoris.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Favoris.tsx\n * Rôle : Mes favoris ♥ « Mon univers Ka » — offres mises en favoris,\n * lues au hub Groupe KA (magasin central, aucun stockage local).\n * Créé : 2026-08-23 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport { FavItem, goLogin, useFav } from \"../favorites\";\n\n/** /emploi/ local si l'url du favori pointe vers job-ka.com. */\nfunction localPath(f: FavItem): string | null {\n if (!f.url) return `/emploi/${encodeURIComponent(f.item_id)}`;\n try {\n const u = new URL(f.url);\n if (u.hostname.endsWith(\"job-ka.com\")) return u.pathname + u.search;\n } catch { /* url invalide : lien externe ignoré */ }\n return null;\n}\n\nfunction FavCard({ f }: { f: FavItem }) {\n const { toggle } = useFav();\n const path = localPath(f);\n const inner = (\n <>\n

{f.title}

\n {f.subtitle && {f.subtitle}}\n
\n {f.price_label && {f.price_label}}\n
\n { e.preventDefault(); e.stopPropagation(); toggle(f); }}\n >\n \n \n \n \n \n );\n return path\n ? {inner}\n : {inner};\n}\n\nexport default function FavorisPage() {\n const { ready, connected, items } = useFav();\n\n return (\n
\n

Mes favoris

\n

\n Vos offres d'emploi mises en favoris ♥ — synchronisées avec votre\n compte Mon univers Ka sur toutes les plateformes du Groupe KA.\n

\n {!ready &&
Chargement…
}\n {ready && !connected && (\n
\n

Connectez-vous avec votre KA ID pour retrouver vos favoris.

\n \n
\n )}\n {ready && connected && items.length === 0 && (\n
\n

Aucun favori pour l'instant — touchez le cœur ♥ sur une offre\n pour la retrouver ici.

\n Parcourir les offres\n
\n )}\n {ready && connected && items.length > 0 && (\n
\n {items.map((f) => )}\n
\n )}\n {ready && connected && (\n

\n Tous vos favoris Groupe KA (logements, propriétés, produits…) :{\" \"}\n \n Mon univers Ka ↗\n \n

\n )}\n
\n );\n}\n","numLines":95,"startLine":1,"totalLines":95}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvfVk6fgDwcdJM7ZQT9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017YpPHfpBNxLF73P9CwKEAv","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Employeurs.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9917,"cache_read_input_tokens":72424,"cache_creation":{"ephemeral_5m_input_tokens":9917,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"fd9caea6-9961-4f1e-a3fc-c98a6a18832f","request_id":"req_011CePvfTcMnNQWYhcyTjHju"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePvfVk6fgDwcdJM7ZQT9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VvWHi6aCJKHEijxu2VmU4W","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/index.html"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9917,"cache_read_input_tokens":72424,"cache_creation":{"ephemeral_5m_input_tokens":9917,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e3b71be3-35a2-4a1e-a1f4-46d26806e3bc","uuid":"84b6e91a-b072-4860-9546-64600c66140b","request_id":"req_011CePvfTcMnNQWYhcyTjHju"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017YpPHfpBNxLF73P9CwKEAv","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Employeurs.tsx\n7\t * Rôle : Page « Publier une offre » — dépôt direct par les employeurs\n8\t * (POST /api/employeurs/offres, modération avant publication)\n9\t * Créé : 2026-08-18 Modifié : 2026-08-22\n10\t * =============================================================================\n11\t */\n12\timport { FormEvent, useEffect, useState } from \"react\";\n13\t\n14\tinterface FormState {\n15\t employer: string;\n16\t contact_email: string;\n17\t title: string;\n18\t city: string;\n19\t url: string;\n20\t salary_label: string;\n21\t employment_type: string;\n22\t work_mode: string;\n23\t description: string;\n24\t website: string; // pot de miel anti-robots (doit rester vide)\n25\t}\n26\t\n27\tconst EMPTY: FormState = {\n28\t employer: \"\", contact_email: \"\", title: \"\", city: \"\", url: \"\",\n29\t salary_label: \"\", employment_type: \"\", work_mode: \"\", description: \"\",\n30\t website: \"\",\n31\t};\n32\t\n33\texport default function EmployeursPage() {\n34\t const [form, setForm] = useState(EMPTY);\n35\t const [sending, setSending] = useState(false);\n36\t const [done, setDone] = useState(null);\n37\t const [err, setErr] = useState(null);\n38\t\n39\t useEffect(() => {\n40\t document.title = \"Publier une offre | Job-Ka — Un service Groupe KA\";\n41\t window.scrollTo(0, 0);\n42\t }, []);\n43\t\n44\t const set = (k: keyof FormState) =>\n45\t (e: React.ChangeEvent) =>\n46\t setForm((f) => ({ ...f, [k]: e.target.value }));\n47\t\n48\t const submit = (e: FormEvent) => {\n49\t e.preventDefault();\n50\t setSending(true);\n51\t setErr(null);\n52\t fetch(\"/api/employeurs/offres\", {\n53\t method: \"POST\",\n54\t headers: { \"Content-Type\": \"application/json\" },\n55\t body: JSON.stringify({\n56\t ...form,\n57\t employment_type: form.employment_type || null,\n58\t work_mode: form.work_mode || null,\n59\t }),\n60\t })\n61\t .then(async (r) => {\n62\t if (!r.ok) {\n63\t const body = await r.json().catch(() => null);\n64\t throw new Error(body?.detail?.[0]?.msg ?? body?.detail ?? `Erreur ${r.status}`);\n65\t }\n66\t return r.json();\n67\t })\n68\t .then((d) => {\n69\t setDone(d.message ?? \"Offre reçue — merci !\");\n70\t setForm(EMPTY);\n71\t })\n72\t .catch((ex) => setErr(String(ex?.message ?? ex)))\n73\t .finally(() => setSending(false));\n74\t };\n75\t\n76\t return (\n77\t
\n78\t Employeurs\n79\t

\n80\t Publiez votre offre directement\n81\t

\n82\t

\n83\t Job-Ka référence les offres à la source : pages carrières des\n84\t employeurs québécois d'abord, portails ensuite. Vous êtes un employeur\n85\t sans page carrière connectée ? Déposez votre offre ici — elle est\n86\t vérifiée par notre équipe avant publication, puis affichée avec le\n87\t badge « Offre directe employeur ». C'est gratuit.\n88\t

\n89\t\n90\t
    \n91\t
  • Votre offre reste traçable : elle renvoie vers votre site si vous fournissez un lien.
  • \n92\t
  • Si votre entreprise utilise un ATS (Workday, Lever, Njoyn, Taleo…), écrivez-nous :\n93\t nous pouvons connecter votre page carrière et synchroniser toutes vos offres automatiquement.
  • \n94\t
  • Le courriel de contact sert uniquement à la modération — il n'est jamais affiché.
  • \n95\t
\n96\t\n97\t {done ? (\n98\t
\n99\t

Offre reçue ✓

\n100\t

{done}

\n101\t \n104\t
\n105\t ) : (\n106\t
\n108\t
\n109\t \n113\t \n117\t \n121\t \n124\t \n128\t \n132\t \n142\t \n150\t
\n151\t