diff --git a/CHANGELOG.md b/CHANGELOG.md index 01db81c..3ddb0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak. +## 0.2.1 — 2026-07-21 + +### Toegevoegd + +- Productieklare vertaling van de negen Stitch Intelligence Cockpit-schermen naar de bestaande Django-kern. +- Gedeelde responsive shell, lokale SVG-iconsprite, donkere en lichte thematokens en toegankelijke mobiele navigatie. +- Cockpitdashboard, vacature-intelligentiekaarten/detail, sollicitatiepipeline, gegroepeerde profieleditor, bronkaarten en systeemstatus. +- Integratieplan, permanente Stitch-referentie en browservalidatiescreenshots voor zes doelviewports. + +### Gewijzigd + +- Alle kernroutes gebruiken nu één CSP-veilige lokale CSS/JS-laag zonder externe productie-assets. +- Bronbeheer is teruggebracht onder de gedeelde shell terwijl import, bulkacties, retry, policy en SSRF-beveiliging intact blijven. +- Browseropslag voor themavoorkeur is progressief: opslagbeperkingen veroorzaken geen console- of bedieningsfout. + +### Niet overgenomen + +- Quick/direct apply, fictieve workerstatistieken, onbewezen werkgeversinformatie en kunstmatige activiteitenfeeds. + ## 0.1.1 — 2026-07-21 ### Toegevoegd diff --git a/VERIFICATION_REPORT.md b/VERIFICATION_REPORT.md index 5a16640..723cacd 100644 --- a/VERIFICATION_REPORT.md +++ b/VERIFICATION_REPORT.md @@ -76,3 +76,19 @@ Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, a Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving. Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`. + +## VR-118 — Stitch Intelligence Cockpit (2026-07-21) + +Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd. + +Uitgevoerde browsermatrix: + +- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800; +- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat; +- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem; +- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline; +- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix. + +Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer. + +De volledige afrondingsgate en actuele aantallen worden onderaan dit rapport toegevoegd na `scripts/codex_verify.sh`. diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 6fa8527..f0ff6ce 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -8,5 +8,5 @@ from django.http import HttpRequest def navigation_context(request: HttpRequest) -> dict[str, Any]: return { "app_name": "VacatureRadar", - "app_version": "0.1.0", + "app_version": "0.2.1", } diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 3ebe4a5..73611cd 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -108,6 +108,20 @@ class ApplicationListView(LoginRequiredMixin, ListView): "job", "job__employer" ) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + applications = list(context["applications"]) + context["applications"] = applications + context["pipeline_columns"] = [ + { + "status": status, + "label": label, + "applications": [item for item in applications if item.status == status], + } + for status, label in Application.Status.choices + ] + return context + class ApplicationUpdateView(LoginRequiredMixin, UpdateView): model = Application diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index da09083..84a134c 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -941,6 +941,42 @@ tasks: completed_at: '2026-07-21' note: Release script, smoke-run, backup/restore and artifact bundle for VR-117 toegevoegd; release artefacten en Unraid-runbook geactualiseerd; traceability en projectstate bijgewerkt. +- id: VR-118 + title: Stitch Intelligence Cockpit production-grade integreren + status: done + priority: P1 + requirement_ids: + - NFR-006 + - NFR-008 + - AC-002 + - AC-003 + - AC-051 + depends_on: + - VR-107 + - VR-114 + summary: Vertaal de negen aangeleverde Stitch-schermen naar de bestaande server-rendered Django-interface zonder + domeinlogica, securitygrenzen of gebruikersscope te vervangen en zonder fictieve productdata te introduceren. + acceptance_criteria: + - Een consistente responsive cockpit-shell en dark/light tokens bedienen alle kernroutes. + - Dashboard, vacatures, detail, pipeline, profiel, bronnen, systeem en login gebruiken echte backenddata en acties. + - Externe auto-apply, nepmetrics, onbewezen werkgeverinzichten en onbetrouwbare activiteitenfeeds zijn niet overgenomen. + - Zes doelviewports hebben één h1, geen horizontale overflow en werkende mobiele navigatie. + - Stitch-bronnen, integratieplan, screenshots en verificatierapport zijn traceerbaar maar niet onderdeel van de productiecontainer. + verification: + - uv run pytest tests/e2e tests/integration/test_views.py tests/integration/test_tasks_and_more_views.py + - ./scripts/codex_verify.sh + - Browsercontrole op 1440x900, 1280x800, 1024x768, 768x1024, 390x844 en 360x800 + primary_paths: + - templates/ + - static/css/app.css + - static/js/app.js + - docs/design/STITCH_INTEGRATION_PLAN.md + - docs/design/stitch-reference/ + - docs/design/validation/ + result: + completed_at: '2026-07-21' + note: Stitch-visuele taal geïntegreerd in CSP-veilige Django-templates; dark/light, responsive shell, pipeline, + broninteracties en browservalidatie toegevoegd zonder functionele scope-uitbreiding of fictieve data. - id: VR-201 title: Concrete live-bronnen inventariseren en juridisch/operationeel reviewen status: blocked-external diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index 731b98c..123247c 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -1,7 +1,7 @@ # Projectstatus - Laatst bijgewerkt: 2026-07-21 -- Repositoryversie: 0.1.0 MVP-basis +- Repositoryversie: 0.2.1 Stitch Intelligence Cockpit - Uitvoeringsmodus: autonome backlog - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` @@ -14,7 +14,7 @@ De repository bevat een uitvoerbare Django-MVP met: - JSON-LD-, HTML-, RSS/Atom- en e-mailadapters; - normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies; - deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary; -- dashboard, vacaturelijst/detail, profiel-, bron- en sollicitatiepagina's; +- responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen; - feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox; - Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts; - offline unit-, integratie- en securitytests. @@ -43,6 +43,13 @@ Release-audit op 2026-07-21: ## Laatste uitgevoerde backlogtaak +- 2026-07-21: `VR-118` voltooid. + - Alle kernroutes delen nu één responsive Intelligence Cockpit-shell met lokale SVG-iconen en CSP-veilige assets. + - Stitch-dark en een volwaardige light variant zijn gekoppeld aan echte Django-data; externe quick apply, nepmetrics en onbewezen intelligence zijn bewust geweerd. + - Browserflow valideerde vacature → sollicitatiedossier → pipeline, bron-disclosures, themawissel en viewports 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800 zonder horizontale overflow. + - Stitch-bronnen, integratieplan en screenshots staan onder `docs/design/` en zijn uit de productiecontainer gesloten. + - Volgende taak: `VR-201` (`blocked-external`). + - 2026-07-21: `VR-117` voltooid. - Productiereleasecontrole toegevoegd met `scripts/release_verify.sh`, inclusief smoke, backup/restore-smoke en artifactbundel. - Changelog, traceability en Unraid-runbook samengekoppeld aan het releasepad. @@ -156,7 +163,7 @@ Deze blokkeren onafhankelijke code niet: ## Bekende verificatiebeperking van de aangeleverde basis -De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De Playwright-browservarianten zijn lokaal niet uitgevoerd; hun HTML/a11y-fallback is wel geslaagd. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. +De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-118 is aanvullend handmatig met de geïntegreerde browser op zes viewports uitgevoerd. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. ## Hervatten diff --git a/docs/design/ACCESSIBILITY.md b/docs/design/ACCESSIBILITY.md index 89c191c..863975c 100644 --- a/docs/design/ACCESSIBILITY.md +++ b/docs/design/ACCESSIBILITY.md @@ -62,3 +62,12 @@ Per zichtbare taak: 4. screenreader smoke test voor dashboard, vacaturedetail en profiel; 5. contrastcontrole; 6. test met lange/lege/unknown content. + +## VR-118 browservalidatie + +- Dashboard gecontroleerd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800. +- Op alle zes viewports: exact één `h1`, geen documentbrede horizontale overflow en de juiste desktop/mobile navigatiestatus. +- Mobiel menu opent met `aria-expanded=true`, sluit met Escape/sluitactie en is buiten de focus-/accessibilityflow wanneer gesloten. +- Themawisselaar blijft bruikbaar wanneer browseropslag niet beschikbaar is; de UI geeft dan geen consolefout. +- Dashboard, vacaturelijst/detail, sollicitatiepipeline, profielen/editor, bronnen en systeem zijn met echte seeded data en zonder externe assets gevalideerd. +- Screenshots staan onder `docs/design/validation/` en zijn via `.dockerignore` uitgesloten van de productiecontainer. diff --git a/docs/design/DESIGN_SYSTEM.md b/docs/design/DESIGN_SYSTEM.md index f02058d..bb14ff7 100644 --- a/docs/design/DESIGN_SYSTEM.md +++ b/docs/design/DESIGN_SYSTEM.md @@ -1,37 +1,44 @@ # Design system +> Geactualiseerd voor VR-118. De volledige Stitch-mapping en bewuste afwijkingen staan in +> `docs/design/STITCH_INTEGRATION_PLAN.md`. + ## 1. Merkgevoel -Rustig, betrouwbaar, helder en licht technisch. De interface moet focus geven, niet gamificeren. Geen confetti, eindeloze badges of agressieve notificatiekleuren. +Rustig, betrouwbaar, helder en technisch precies. De interface voelt als een professionele intelligence-cockpit: +donker marineblauw als standaard, signaalcyaan voor primaire interactie en tonale lagen in plaats van zware schaduwen. +De lichte variant behoudt dezelfde hiërarchie. Geen confetti, fictieve grafieken, eindeloze badges of agressieve notificatiekleuren. ## 2. Visuele tokens De implementatie staat in `static/css/app.css`. Gebruik semantische custom properties in plaats van losse kleuren in componenten. -Aanbevolen tokenrollen: +Geïmplementeerde tokenrollen omvatten onder meer: ```css ---surface-page ---surface-card ---surface-raised ---text-primary ---text-secondary ---text-muted ---border-subtle ---accent ---accent-strong ---success +--bg +--bg-deep +--surface +--surface-low +--surface-high +--surface-bright +--text +--text-soft +--muted +--border +--border-strong +--primary +--primary-strong +--positive --warning --danger ---focus-ring ---shadow-card +--signal-glow --radius-sm ---radius-md ---radius-lg ---space-1 ... --space-8 +--radius ``` -Donkere modus volgt `prefers-color-scheme` en behoudt semantische contrastrollen. +Donkere modus is de productstandaard. De gebruiker kan lokaal wisselen via `data-theme`; opslag is progressief en +mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken. ## 3. Typografie @@ -45,10 +52,11 @@ Donkere modus volgt `prefers-color-scheme` en behoudt semantische contrastrollen ## 4. Spacing en layout - 4/8px-gebaseerde schaal; -- contentcontainer ongeveer 1180–1280px; +- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft; - kaarten met consistente padding 20–24px desktop, 16px mobiel; - minimaal 44×44px touch targets voor primaire interactie; -- grids breken onder 900px en 640px logisch af; +- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder; +- grids breken onder 1180px, 900px en 680px logisch af; - vaste/sticky elementen mogen content niet bedekken. ## 5. Componenten diff --git a/docs/design/validation/applications-dark-1280x800.png b/docs/design/validation/applications-dark-1280x800.png new file mode 100644 index 0000000..a0c802a Binary files /dev/null and b/docs/design/validation/applications-dark-1280x800.png differ diff --git a/docs/design/validation/dashboard-1024x768.png b/docs/design/validation/dashboard-1024x768.png new file mode 100644 index 0000000..ede3317 Binary files /dev/null and b/docs/design/validation/dashboard-1024x768.png differ diff --git a/docs/design/validation/dashboard-1280x800.png b/docs/design/validation/dashboard-1280x800.png new file mode 100644 index 0000000..878abc4 Binary files /dev/null and b/docs/design/validation/dashboard-1280x800.png differ diff --git a/docs/design/validation/dashboard-1440x900.png b/docs/design/validation/dashboard-1440x900.png new file mode 100644 index 0000000..ee8f47a Binary files /dev/null and b/docs/design/validation/dashboard-1440x900.png differ diff --git a/docs/design/validation/dashboard-360x800.png b/docs/design/validation/dashboard-360x800.png new file mode 100644 index 0000000..cc3a42d Binary files /dev/null and b/docs/design/validation/dashboard-360x800.png differ diff --git a/docs/design/validation/dashboard-390x844.png b/docs/design/validation/dashboard-390x844.png new file mode 100644 index 0000000..a66adaf Binary files /dev/null and b/docs/design/validation/dashboard-390x844.png differ diff --git a/docs/design/validation/dashboard-768x1024.png b/docs/design/validation/dashboard-768x1024.png new file mode 100644 index 0000000..4c2639b Binary files /dev/null and b/docs/design/validation/dashboard-768x1024.png differ diff --git a/docs/design/validation/dashboard-light-1280x800.png b/docs/design/validation/dashboard-light-1280x800.png new file mode 100644 index 0000000..50c8723 Binary files /dev/null and b/docs/design/validation/dashboard-light-1280x800.png differ diff --git a/docs/design/validation/dashboard-light-1440x900.png b/docs/design/validation/dashboard-light-1440x900.png new file mode 100644 index 0000000..a0449a8 Binary files /dev/null and b/docs/design/validation/dashboard-light-1440x900.png differ diff --git a/docs/design/validation/job-detail-dark-1280x800.png b/docs/design/validation/job-detail-dark-1280x800.png new file mode 100644 index 0000000..1389179 Binary files /dev/null and b/docs/design/validation/job-detail-dark-1280x800.png differ diff --git a/docs/design/validation/jobs-dark-1280x800.png b/docs/design/validation/jobs-dark-1280x800.png new file mode 100644 index 0000000..7e0dc75 Binary files /dev/null and b/docs/design/validation/jobs-dark-1280x800.png differ diff --git a/docs/design/validation/mobile-navigation-390x844.png b/docs/design/validation/mobile-navigation-390x844.png new file mode 100644 index 0000000..ff5fe2d Binary files /dev/null and b/docs/design/validation/mobile-navigation-390x844.png differ diff --git a/docs/design/validation/profile-editor-dark-1280x800.png b/docs/design/validation/profile-editor-dark-1280x800.png new file mode 100644 index 0000000..2bffde8 Binary files /dev/null and b/docs/design/validation/profile-editor-dark-1280x800.png differ diff --git a/docs/design/validation/profiles-dark-1280x800.png b/docs/design/validation/profiles-dark-1280x800.png new file mode 100644 index 0000000..340ddb2 Binary files /dev/null and b/docs/design/validation/profiles-dark-1280x800.png differ diff --git a/docs/design/validation/sources-dark-1280x800.png b/docs/design/validation/sources-dark-1280x800.png new file mode 100644 index 0000000..2fff512 Binary files /dev/null and b/docs/design/validation/sources-dark-1280x800.png differ diff --git a/docs/design/validation/system-dark-1280x800.png b/docs/design/validation/system-dark-1280x800.png new file mode 100644 index 0000000..03659b6 Binary files /dev/null and b/docs/design/validation/system-dark-1280x800.png differ diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md index ed997d8..0fa3337 100644 --- a/docs/quality/THREAT_MODEL.md +++ b/docs/quality/THREAT_MODEL.md @@ -72,6 +72,7 @@ Django/Celery | T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` | | T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` | | T-32 | XML external entity/entity-expansion via sitemap | lokale data-uitlezing of parseruitputting | sitemap-XML wordt met `defusedxml` verwerkt; fetcherlimieten blijven vóór parsing gelden | `tests/unit/test_discovery_rss.py`, Ruff securitycheck | +| T-33 | Externe UI-CDN of niet-vertrouwde Stitch-interactie | supply-chain-XSS, tracking of acties buiten Django-beleid | productie gebruikt alleen lokale CSS/JS en inline SVG-symbolen; Stitch-code is documentatiereferentie, alle mutaties blijven Django POST+CSRF; themavoorkeur bevat geen persoonsgegevens en opslagfouten worden afgevangen | `tests/e2e/test_vr114_browser_and_accessibility.py`, browserconsole- en assetcontrole VR-118 | ## Misbruikscenario's diff --git a/docs/quality/TRACEABILITY_MATRIX.md b/docs/quality/TRACEABILITY_MATRIX.md index cacc977..86a67f7 100644 --- a/docs/quality/TRACEABILITY_MATRIX.md +++ b/docs/quality/TRACEABILITY_MATRIX.md @@ -39,8 +39,8 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel | PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie | | PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 | | PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties | -| PR-050 | I | dashboard view/template | viewtests | VR-114 e2e/a11y | -| PR-051 | I | detailview/template/provenance | viewtests | VR-114 | +| PR-050 | I | dashboard view/template + Stitch-cockpitshell | viewtests, VR-118 browsermatrix | VR-114/118 e2e/a11y | +| PR-051 | I | detailview/template/provenance + intelligence-layout | viewtests, VR-118 screenshots | VR-114/118 | | PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — | | PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier | | PR-054 | I | `notifications/models.py`, outbox/services/tasks | `tests/integration/test_email_import_notifications.py`, `tests/integration/test_reminders.py` | VR-111 | @@ -63,7 +63,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel | NFR-005 | I | domeinapps/services/adapters/ADRs | continue review | | NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e | | NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 | -| NFR-008 | I | semantische templates/designspec | `tests/e2e/test_vr114_browser_and_accessibility.py` | +| NFR-008 | I | semantische templates/designspec, zes viewports, dark/light en toetsenbordmenu | `tests/e2e/test_vr114_browser_and_accessibility.py`, `docs/design/validation/`, VR-118 | | NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond | | NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability | @@ -87,5 +87,6 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma - VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews. - VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests. - VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests. +- VR-118 vertaalt de aangeleverde Stitch Intelligence Cockpit naar de bestaande server-rendered routes. Bewijs staat in `docs/design/STITCH_INTEGRATION_PLAN.md`, `docs/design/validation/`, de view/a11ytests en de gedeelde lokale shell/assets. diff --git a/static/css/app.css b/static/css/app.css index ea9d1a9..fd407c0 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1,303 +1,382 @@ -:root { - --bg: #f5f7fb; - --surface: #ffffff; - --surface-2: #eef2f8; - --text: #142033; - --muted: #667085; - --border: #dce3ee; - --primary: #465bd8; - --primary-strong: #3446b9; - --primary-soft: #edf0ff; - --positive: #087f5b; - --positive-soft: #e7f7f1; - --warning: #9a6700; - --warning-soft: #fff5d6; - --danger: #b42318; - --danger-soft: #ffebe9; - --shadow: 0 16px 40px rgba(23, 36, 64, .08); - --radius: 16px; - --radius-sm: 10px; - --sidebar: 248px; +:root, +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #081120; + --bg-deep: #050c18; + --surface: #111b2e; + --surface-low: #0c1627; + --surface-high: #19253a; + --surface-bright: #243149; + --text: #e7efff; + --text-soft: #c7d3e7; + --muted: #8fa1b8; + --border: #2b3a50; + --border-strong: #42536b; + --primary: #16e5ee; + --primary-strong: #7cf8ff; + --primary-ink: #002f32; + --primary-soft: rgba(22, 229, 238, .1); + --positive: #4edea3; + --positive-soft: rgba(78, 222, 163, .11); + --warning: #ffca69; + --warning-soft: rgba(255, 202, 105, .11); + --danger: #ff8f87; + --danger-soft: rgba(255, 143, 135, .11); + --indigo: #9ea5ff; + --shadow: 0 18px 55px rgba(0, 0, 0, .24); + --signal-glow: 0 0 0 1px rgba(22, 229, 238, .12), 0 0 28px rgba(22, 229, 238, .07); + --radius: 9px; + --radius-sm: 5px; + --sidebar: 272px; + --utility: 66px; } -@media (prefers-color-scheme: dark) { - :root { - --bg: #0f1520; - --surface: #171f2d; - --surface-2: #202a3a; - --text: #edf2fa; - --muted: #a8b2c4; - --border: #303b4e; - --primary: #91a0ff; - --primary-strong: #aab5ff; - --primary-soft: #252f55; - --positive: #5bd3aa; - --positive-soft: #153b31; - --warning: #ffd166; - --warning-soft: #443716; - --danger: #ff8c84; - --danger-soft: #4b2424; - --shadow: 0 18px 42px rgba(0, 0, 0, .28); - } +:root[data-theme="light"] { + color-scheme: light; + --bg: #f3f6fa; + --bg-deep: #e9eef5; + --surface: #ffffff; + --surface-low: #f7f9fc; + --surface-high: #edf2f7; + --surface-bright: #e2e9f2; + --text: #142033; + --text-soft: #2c3c52; + --muted: #617187; + --border: #d7e0eb; + --border-strong: #aebdce; + --primary: #007f87; + --primary-strong: #005e64; + --primary-ink: #f4ffff; + --primary-soft: rgba(0, 127, 135, .09); + --positive: #087f5b; + --positive-soft: rgba(8, 127, 91, .1); + --warning: #8b5b00; + --warning-soft: rgba(183, 119, 0, .11); + --danger: #b42318; + --danger-soft: rgba(180, 35, 24, .09); + --indigo: #4e56c8; + --shadow: 0 16px 44px rgba(27, 43, 65, .08); + --signal-glow: 0 0 0 1px rgba(0, 127, 135, .12), 0 12px 34px rgba(0, 70, 75, .08); } * { box-sizing: border-box; } html { min-height: 100%; scroll-behavior: smooth; } body { + min-width: 320px; min-height: 100vh; margin: 0; - background: var(--bg); + overflow-x: hidden; + background: + linear-gradient(rgba(110, 150, 190, .025) 1px, transparent 1px), + linear-gradient(90deg, rgba(110, 150, 190, .025) 1px, transparent 1px), + var(--bg); + background-size: 48px 48px; color: var(--text); font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; } -a { color: var(--primary); text-decoration: none; } +a { color: var(--primary-strong); text-decoration: none; } a:hover { text-decoration: underline; } button, input, select, textarea { font: inherit; } button { cursor: pointer; } -code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -pre { overflow: auto; border-radius: var(--radius-sm); background: var(--surface-2); padding: 18px; } - -.skip-link { position: fixed; left: 10px; top: -80px; z-index: 1000; padding: 10px 14px; background: var(--surface); color: var(--text); border-radius: 8px; box-shadow: var(--shadow); } -.skip-link:focus { top: 10px; } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -.mobile-only { display: none; } - -.topbar { - position: sticky; - top: 0; - z-index: 30; - min-height: 70px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 10px 24px; - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 92%, transparent); - backdrop-filter: blur(16px); +code, pre, .eyebrow, .pill, .meta-label, .nav-group-label, .job-meta, .utility-title, .kpi-label { + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } -.brand-wrap, .brand, .topbar-actions { display: flex; align-items: center; gap: 12px; } -.brand { color: var(--text); } -.brand:hover { text-decoration: none; } -.brand span:last-child { display: grid; line-height: 1.2; } -.brand small { color: var(--muted); font-size: 12px; } -.brand-mark { - width: 42px; - height: 42px; - display: inline-grid; - place-items: center; - border-radius: 12px; - background: linear-gradient(145deg, var(--primary), #7b4fe8); - color: white; - font-weight: 800; - letter-spacing: -.04em; - box-shadow: 0 8px 18px rgba(70, 91, 216, .25); -} -.app-shell { display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); min-height: calc(100vh - 70px); } +pre { overflow: auto; margin: 0; border-radius: var(--radius-sm); background: var(--bg-deep); padding: 18px; border: 1px solid var(--border); } +svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.icon { width: 20px; height: 20px; flex: 0 0 auto; } +.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; } +.skip-link { position: fixed; left: 12px; top: -90px; z-index: 1000; padding: 11px 15px; background: var(--primary); color: var(--primary-ink); border-radius: var(--radius-sm); font-weight: 800; } +.skip-link:focus { top: 12px; } +.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } +.mobile-only { display: none !important; } + +.app-frame { min-height: 100vh; } .sidebar { - position: sticky; - top: 70px; - height: calc(100vh - 70px); + position: fixed; + inset: 0 auto 0 0; + z-index: 60; + width: var(--sidebar); display: flex; flex-direction: column; - gap: 6px; - padding: 24px 14px; - background: var(--surface); + padding: 18px 14px; + background: color-mix(in srgb, var(--bg-deep) 96%, transparent); border-right: 1px solid var(--border); - overflow-y: auto; + box-shadow: 12px 0 42px rgba(0, 0, 0, .08); } -.nav-link { display: grid; gap: 2px; padding: 11px 14px; border-radius: 11px; color: var(--text); font-weight: 650; } -.nav-link small { color: var(--muted); font-weight: 400; } -.nav-link:hover { background: var(--surface-2); text-decoration: none; } -.nav-link.active { background: var(--primary-soft); color: var(--primary-strong); } -.nav-link.active small { color: var(--primary); } -.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 7px; padding: 12px; color: var(--muted); font-size: 12px; } -.sidebar-foot small { margin-left: auto; } -.status-dot { width: 8px; height: 8px; border-radius: 99px; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); } -.main-content { width: 100%; max-width: 1480px; padding: 34px clamp(20px, 4vw, 56px) 64px; } -.auth-main { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 10%, var(--primary-soft), transparent 36%), var(--bg); } +.sidebar-brand { display: flex; align-items: center; justify-content: space-between; padding: 2px 6px 22px; } +.brand { min-width: 0; display: flex; align-items: center; gap: 12px; color: var(--text); } +.brand:hover { text-decoration: none; } +.brand > span:last-child { min-width: 0; display: grid; line-height: 1.2; } +.brand strong { font-size: 18px; letter-spacing: -.025em; } +.brand small { margin-top: 3px; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; } +.brand-mark { width: 42px; height: 42px; display: inline-grid; place-items: center; border-radius: 7px; background: var(--primary); color: var(--primary-ink); box-shadow: 0 0 24px color-mix(in srgb, var(--primary) 25%, transparent); } +.brand-mark .icon { width: 25px; height: 25px; stroke-width: 1.6; } +.nav-stack { min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 4px; padding: 4px 0; } +.nav-group-label { margin: 18px 13px 5px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .13em; } +.nav-group-label:first-child { margin-top: 3px; } +.nav-link { position: relative; display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 54px; padding: 8px 12px; border: 1px solid transparent; border-radius: 7px; color: var(--muted); } +.nav-link > span { min-width: 0; display: grid; color: var(--text-soft); font-weight: 700; line-height: 1.25; } +.nav-link small { color: var(--muted); font-size: 11px; font-weight: 400; } +.nav-link:hover { background: var(--surface); border-color: var(--border); text-decoration: none; color: var(--primary); } +.nav-link.active { background: linear-gradient(90deg, var(--primary-soft), transparent); border-color: color-mix(in srgb, var(--primary) 25%, var(--border)); color: var(--primary); box-shadow: inset 3px 0 var(--primary); } +.nav-link.active > span, .nav-link.active small { color: var(--primary-strong); } +.sidebar-user { margin-top: auto; min-width: 0; display: grid; grid-template-columns: 38px minmax(0, 1fr) 10px; align-items: center; gap: 10px; padding: 15px 8px 2px; border-top: 1px solid var(--border); } +.sidebar-user > span:nth-child(2) { min-width: 0; display: grid; line-height: 1.25; } +.sidebar-user strong, .sidebar-user small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-user strong { font-size: 13px; } +.sidebar-user small { color: var(--muted); font-size: 11px; } +.avatar { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-high); color: var(--primary-strong); font-weight: 800; } +.status-dot, .signal-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); } +.workspace { min-height: 100vh; margin-left: var(--sidebar); } +.utilitybar { position: sticky; top: 0; z-index: 40; height: var(--utility); display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 clamp(18px, 3vw, 38px); background: color-mix(in srgb, var(--bg) 86%, transparent); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); } +.utilitybar-start, .utility-actions, .utility-title, .utility-link { display: flex; align-items: center; } +.utility-title { gap: 10px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } +.utility-actions { gap: 8px; } +.utility-actions form { margin: 0; } +.utility-link, .icon-button { min-height: 42px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); } +.utility-link { gap: 8px; padding: 8px 10px; font-weight: 650; } +button.utility-link { width: auto; } +.utility-link:hover, .icon-button:hover { border-color: var(--border); background: var(--surface-high); color: var(--text); text-decoration: none; } +.icon-button { width: 42px; display: inline-grid; place-items: center; padding: 0; } +.theme-icon-light { display: none; } +:root[data-theme="light"] .theme-icon-dark { display: none; } +:root[data-theme="light"] .theme-icon-light { display: block; } +.nav-scrim { display: none; } +.main-content { width: 100%; max-width: 1600px; margin: 0 auto; padding: 34px clamp(18px, 3.2vw, 52px) 72px; } -.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; } -.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 4vw, 44px); line-height: 1.08; letter-spacing: -.035em; } -.page-header p { margin: 8px 0 0; color: var(--muted); max-width: 780px; } -.eyebrow { margin: 0 0 8px !important; color: var(--primary) !important; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; } -.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 34px 0 18px; } -.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.02em; } +.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-bottom: 28px; } +.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.7vw, 48px); line-height: 1.06; letter-spacing: -.042em; } +.page-header p { max-width: 760px; margin: 9px 0 0; color: var(--muted); } +.eyebrow { margin: 0 0 9px !important; color: var(--primary) !important; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .13em; } +.page-actions { display: flex; flex-wrap: wrap; gap: 9px; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 34px 0 17px; } +.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; } .section-heading p { margin: 5px 0 0; color: var(--muted); } -.title-row { display: flex; align-items: center; gap: 10px; } +.section-kicker { color: var(--primary); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .11em; } +.title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } -.button { - min-height: 40px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px 14px; - border: 1px solid transparent; - border-radius: 10px; - background: transparent; - color: var(--text); - font-weight: 700; - line-height: 1.2; - text-decoration: none; -} +.button { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 14px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--text); font-weight: 750; line-height: 1.2; text-decoration: none; } .button:hover { text-decoration: none; transform: translateY(-1px); } -.button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 38%, transparent); outline-offset: 2px; } -.button-primary { background: var(--primary); color: white; box-shadow: 0 7px 16px rgba(70,91,216,.2); } +.button:focus-visible, .icon-button:focus-visible, .utility-link:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 40%, transparent); outline-offset: 3px; } +.button-primary { background: var(--primary); color: var(--primary-ink); box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent); } .button-primary:hover { background: var(--primary-strong); } -.button-secondary { background: var(--surface); border-color: var(--border); } -.button-secondary:hover, .button-ghost:hover { background: var(--surface-2); } -.button-ghost { color: var(--muted); } -.button-link { color: var(--primary); background: var(--primary-soft); } +.button-secondary { background: var(--surface-high); border-color: var(--border); } +.button-secondary:hover, .button-ghost:hover { background: var(--surface-bright); border-color: var(--border-strong); } +.button-ghost { border-color: var(--border); color: var(--text-soft); } +.button-link { color: var(--primary-strong); background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); } +.button-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); } .button-block { width: 100%; } -.button-small { min-height: 32px; padding: 5px 9px; font-size: 13px; } -.icon-button { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); } +.button-small { min-height: 34px; padding: 5px 10px; font-size: 13px; } -.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } -.stat-card, .panel, .job-card, .auth-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); -} -.stat-card { min-height: 130px; display: grid; align-content: center; padding: 22px; } -.stat-card span, .stat-card small { color: var(--muted); } -.stat-card strong { margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.04em; } -.panel { padding: clamp(20px, 3vw, 30px); margin-bottom: 20px; } -.panel > h2:first-child { margin-top: 0; } +.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.stat-card, .panel, .job-card, .auth-card, .filter-bar, .table-wrap, .source-card { background: color-mix(in srgb, var(--surface) 95%, transparent); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } +.stat-card { position: relative; min-height: 126px; display: grid; align-content: center; gap: 2px; padding: 20px; overflow: hidden; } +.stat-card::after { content: ""; position: absolute; right: -38px; bottom: -48px; width: 110px; height: 110px; border: 1px solid color-mix(in srgb, var(--primary) 12%, transparent); border-radius: 50%; } +.stat-card span, .stat-card small { position: relative; z-index: 1; color: var(--muted); } +.stat-card strong { position: relative; z-index: 1; margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.045em; } +.stat-card.signal { border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); box-shadow: var(--signal-glow); } +.kpi-label { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; } +.panel { padding: clamp(19px, 2.5vw, 30px); margin-bottom: 18px; } +.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; } +.panel-subtle { background: var(--surface-low); box-shadow: none; } -.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } -.job-card { padding: 22px; display: flex; flex-direction: column; gap: 16px; } -.job-card-head { display: grid; grid-template-columns: auto 1fr; gap: 15px; align-items: flex-start; } -.job-card h3 { margin: 7px 0 2px; font-size: 21px; line-height: 1.25; } -.job-card h3 a { color: var(--text); } +.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; } +.dashboard-rail { position: sticky; top: calc(var(--utility) + 20px); } +.radar-summary { position: relative; overflow: hidden; background: radial-gradient(circle at 88% 10%, var(--primary-soft), transparent 35%), var(--surface); } +.radar-summary::after { content: ""; position: absolute; right: -50px; top: -55px; width: 190px; height: 190px; border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 38px color-mix(in srgb, var(--primary) 2%, transparent), inset 0 0 0 74px color-mix(in srgb, var(--primary) 3%, transparent); pointer-events: none; } +.radar-summary > * { position: relative; z-index: 1; } +.rail-list { display: grid; gap: 10px; margin: 16px 0 0; } +.rail-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-top: 1px solid var(--border); } +.rail-item:first-child { border-top: 0; } +.rail-item span { color: var(--muted); } + +.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } +.job-card { position: relative; display: flex; flex-direction: column; gap: 15px; padding: 20px; transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; } +.job-card:hover { border-color: var(--border-strong); transform: translateY(-2px); } +.job-card.strong-match { border-color: color-mix(in srgb, var(--primary) 38%, var(--border)); box-shadow: var(--signal-glow); background: linear-gradient(145deg, var(--primary-soft), transparent 45%), var(--surface); } +.job-card-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 15px; align-items: start; } +.job-card h2, .job-card h3 { margin: 6px 0 3px; font-size: 20px; line-height: 1.24; letter-spacing: -.02em; } +.job-card h2 a, .job-card h3 a { color: var(--text); } .job-card p { margin: 0; color: var(--muted); } -.score-ring { - width: 58px; - height: 58px; - display: grid; - place-items: center; - border-radius: 50%; - border: 6px solid var(--primary-soft); - box-shadow: inset 0 0 0 2px var(--primary); - color: var(--primary-strong); - font-size: 18px; - font-weight: 850; -} -.job-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--muted); font-size: 13px; } -.job-meta span { padding: 5px 8px; border-radius: 7px; background: var(--surface-2); } +.score-block { min-width: 72px; display: grid; justify-items: end; } +.score-block strong { color: var(--primary); font-size: 27px; line-height: 1; letter-spacing: -.04em; } +.score-block small { margin-top: 5px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; } +.job-meta { display: flex; flex-wrap: wrap; gap: 7px; color: var(--muted); font-size: 11px; } +.job-meta span { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border-radius: 4px; background: var(--surface-high); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); } +.job-meta .icon { width: 13px; height: 13px; } .job-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; } -.pill { display: inline-flex; align-items: center; width: fit-content; padding: 4px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 12px; font-weight: 750; } -.pill-strong, .pill-allow { background: var(--positive-soft); color: var(--positive); } -.pill-possible, .pill-review { background: var(--warning-soft); color: var(--warning); } -.pill-hidden, .pill-deny { background: var(--danger-soft); color: var(--danger); } +.pill { display: inline-flex; align-items: center; gap: 6px; width: fit-content; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-high); color: var(--muted); font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .04em; } +.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.pill-strong, .pill-allow, .pill-active, .pill-success, .pill-offer { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 24%, var(--border)); color: var(--positive); } +.pill-possible, .pill-review, .pill-uncertain, .pill-partial, .pill-interview { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 24%, var(--border)); color: var(--warning); } +.pill-hidden, .pill-deny, .pill-failed, .pill-quarantined, .pill-rejected { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 24%, var(--border)); color: var(--danger); } +.pill-new, .pill-applied, .pill-trial { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); color: var(--primary-strong); } .compact-list { margin: 0; padding-left: 20px; } -.compact-list li + li { margin-top: 5px; } +.compact-list li + li { margin-top: 6px; } .compact-list.positive li::marker { color: var(--positive); } .compact-list.caution li::marker { color: var(--warning); } -.attention { padding: 11px 13px; border-radius: 9px; background: var(--warning-soft); color: var(--warning) !important; font-size: 13px; } +.attention { padding: 10px 12px; border-left: 3px solid var(--warning); border-radius: 4px; background: var(--warning-soft); color: var(--warning) !important; font-size: 13px; } -.filter-bar { display: flex; gap: 10px; padding: 14px; margin-bottom: 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } +.filter-bar { display: flex; align-items: end; gap: 10px; padding: 14px; margin-bottom: 18px; box-shadow: none; } +.filter-bar label { display: grid; gap: 5px; } .filter-bar .grow { flex: 1; } -input, select, textarea { - width: 100%; - min-height: 42px; - padding: 9px 11px; - border: 1px solid var(--border); - border-radius: 9px; - background: var(--surface); - color: var(--text); -} +.field-label { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; } +input, select, textarea { width: 100%; min-height: 43px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); color: var(--text); } +input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); } textarea { resize: vertical; } -.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.table-wrap { overflow-x: auto; } .data-table { width: 100%; border-collapse: collapse; min-width: 760px; } -.data-table th { text-align: left; padding: 12px 15px; background: var(--surface-2); color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } -.data-table td { padding: 15px; border-top: 1px solid var(--border); vertical-align: middle; } -.data-table td small, .table-title + small { display: block; color: var(--muted); margin-top: 3px; } +.data-table th { text-align: left; padding: 12px 15px; background: var(--surface-low); color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; } +.data-table td { padding: 14px 15px; border-top: 1px solid var(--border); vertical-align: middle; } +.data-table tbody tr:hover { background: var(--surface-low); } +.data-table td small, .table-title + small { display: block; margin-top: 3px; color: var(--muted); } .table-title { color: var(--text); font-weight: 760; } -.pagination { display: flex; justify-content: center; gap: 18px; padding: 20px; } +.pagination { display: flex; justify-content: center; align-items: center; gap: 18px; padding: 22px; } -.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; } -.back-link { display: inline-block; margin-bottom: 15px; color: var(--muted); } -.job-hero { display: flex; justify-content: space-between; gap: 24px; padding: clamp(22px, 4vw, 38px); margin-bottom: 20px; border-radius: var(--radius); background: linear-gradient(140deg, var(--surface), var(--primary-soft)); border: 1px solid var(--border); } -.hero-subtitle { margin: 10px 0 14px; color: var(--muted); font-size: 17px; } -.hero-score { min-width: 132px; height: 132px; display: grid; place-items: center; align-content: center; border-radius: 50%; background: var(--surface); border: 8px solid var(--primary); box-shadow: var(--shadow); } -.hero-score strong { font-size: 36px; line-height: 1; } -.hero-score span, .hero-score small { color: var(--muted); font-size: 12px; } -.sticky-card { position: sticky; top: 92px; padding: 22px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } -.sticky-card h2 { margin-top: 0; } +.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; } +.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 15px; color: var(--muted); } +.job-hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; gap: 28px; padding: clamp(24px, 4vw, 40px); margin-bottom: 18px; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: var(--radius); background: radial-gradient(circle at 88% 25%, var(--primary-soft), transparent 32%), linear-gradient(145deg, var(--surface), var(--surface-low)); box-shadow: var(--signal-glow); } +.hero-subtitle { margin: 11px 0 15px; color: var(--text-soft); font-size: 17px; } +.hero-score { min-width: 136px; display: grid; justify-items: end; align-content: start; } +.hero-score strong { color: var(--primary); font-size: 54px; line-height: .95; letter-spacing: -.06em; } +.hero-score span { margin-top: 8px; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; } +.hero-score small { margin-top: 3px; color: var(--muted); } +.sticky-card { position: sticky; top: calc(var(--utility) + 20px); padding: 20px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } +.sticky-card h2 { margin: 0 0 4px; } +.sticky-card > p { margin-top: 0; color: var(--muted); } .stack-actions { display: grid; gap: 9px; } -.hide-form { display: grid; gap: 8px; } +.hide-form { display: grid; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); } .hide-form label { color: var(--muted); font-size: 12px; } -.score-components { display: grid; gap: 12px; margin: 18px 0 24px; } +.score-components { display: grid; gap: 13px; margin: 18px 0 25px; } .score-components > div { display: grid; grid-template-columns: 120px minmax(120px, 1fr) 52px; gap: 12px; align-items: center; } .score-components strong { text-align: right; } -.progress { height: 9px; overflow: hidden; border-radius: 999px; background: var(--surface-2); } -.progress i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: var(--primary); } -.two-columns { display: grid; grid-template-columns: repeat(2, 1fr); gap: 22px; } -.prose { max-width: 82ch; } -.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; } -.prose p, .prose li { color: color-mix(in srgb, var(--text) 88%, var(--muted)); } -.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.definition-grid div { padding: 12px; border-radius: 10px; background: var(--surface-2); } -.definition-grid dt { color: var(--muted); font-size: 12px; } -.definition-grid dd { margin: 3px 0 0; font-weight: 700; } +.progress { height: 7px; overflow: hidden; border-radius: 999px; background: var(--surface-high); } +.progress i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: linear-gradient(90deg, var(--primary), var(--indigo)); } +.two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; } +.prose { max-width: 82ch; color: var(--text-soft); } +.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; color: var(--text); } +.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.definition-grid div { padding: 12px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); } +.definition-grid dt { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; } +.definition-grid dd { margin: 4px 0 0; font-weight: 700; } .source-list { list-style: none; padding: 0; } .source-list li { display: flex; justify-content: space-between; gap: 16px; padding: 11px 0; border-top: 1px solid var(--border); } .source-list span { color: var(--muted); font-size: 12px; } -.card-list { display: grid; gap: 14px; } +.pipeline { display: grid; grid-template-columns: repeat(6, minmax(210px, 1fr)); gap: 12px; overflow-x: auto; padding: 1px 1px 14px; scroll-snap-type: x proximity; } +.pipeline-column { min-height: 320px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-low); scroll-snap-align: start; } +.pipeline-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 3px 12px; } +.pipeline-head h2 { margin: 0; font-size: 14px; } +.pipeline-count { min-width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font-size: 11px; } +.pipeline-cards { display: grid; gap: 9px; } +.pipeline-card { padding: 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); box-shadow: 0 8px 20px rgba(0, 0, 0, .08); } +.pipeline-card h3 { margin: 8px 0 3px; font-size: 15px; line-height: 1.3; } +.pipeline-card p { margin: 0 0 12px; color: var(--muted); font-size: 12px; } +.pipeline-empty { padding: 20px 8px; color: var(--muted); font-size: 12px; text-align: center; border: 1px dashed var(--border); border-radius: 5px; } + +.card-list { display: grid; gap: 12px; } .profile-card, .application-card { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin: 0; } .profile-card h2, .application-card h2 { margin: 5px 0; } -.form-panel { max-width: 980px; } -.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } -.form-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; } +.form-panel { max-width: 1100px; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; } +.form-grid label { display: grid; align-content: start; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; } .form-grid label > input, .form-grid label > select, .form-grid label > textarea { color: var(--text); font-weight: 400; } +.form-section { grid-column: 1 / -1; margin: 12px 0 -2px; padding-top: 18px; border-top: 1px solid var(--border); } +.form-section h2 { margin: 0; font-size: 17px; } +.form-section p { margin: 4px 0 0; color: var(--muted); font-size: 12px; } .span-2 { grid-column: 1 / -1; } .field-error { color: var(--danger); } -.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; } +.form-actions { position: sticky; bottom: 12px; z-index: 5; display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--surface) 92%, transparent); backdrop-filter: blur(12px); } + +.source-overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; } +.source-card { overflow: hidden; box-shadow: none; } +.source-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px; } +.source-identity { min-width: 0; display: flex; gap: 13px; } +.source-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-high); color: var(--primary); } +.source-identity h2 { margin: 0; font-size: 17px; } +.source-identity p { margin: 4px 0 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } +.source-actions { display: flex; align-items: center; gap: 10px; } +.source-details { padding: 18px; border-top: 1px solid var(--border); background: var(--surface-low); } +.source-details-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.technical-log { max-height: 180px; overflow: auto; padding: 14px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-deep); color: var(--muted); font: 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.disclosure-icon { transition: transform .16s ease; } +[aria-expanded="true"] .disclosure-icon { transform: rotate(180deg); } +.import-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .messages { display: grid; gap: 8px; margin-bottom: 18px; } -.message { padding: 12px 14px; border-radius: 10px; background: var(--primary-soft); color: var(--primary-strong); border: 1px solid color-mix(in srgb, var(--primary) 25%, var(--border)); } -.message.success { background: var(--positive-soft); color: var(--positive); } -.message.warning { background: var(--warning-soft); color: var(--warning); } -.message.error { background: var(--danger-soft); color: var(--danger); } -.empty-state { grid-column: 1 / -1; padding: 54px 24px; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); background: var(--surface); } +.message { padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-left: 3px solid var(--primary); border-radius: 5px; background: var(--primary-soft); color: var(--primary-strong); } +.message.success { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 25%, var(--border)); border-left-color: var(--positive); color: var(--positive); } +.message.warning { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 25%, var(--border)); border-left-color: var(--warning); color: var(--warning); } +.message.error { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 25%, var(--border)); border-left-color: var(--danger); color: var(--danger); } +.empty-state { grid-column: 1 / -1; padding: 48px 24px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius); background: var(--surface-low); } .empty-state h2 { margin-bottom: 6px; } -.empty-state p { color: var(--muted); max-width: 600px; margin: 0 auto 16px; } -.empty-icon { font-size: 40px; color: var(--primary); } -.auth-card { width: min(460px, 100%); padding: 32px; } +.empty-state p { max-width: 600px; margin: 0 auto 16px; color: var(--muted); } +.empty-icon { width: 54px; height: 54px; display: grid; place-items: center; margin: 0 auto 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--primary); background: var(--primary-soft); } +.empty-icon .icon { width: 27px; height: 27px; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.auth-main { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 18% 14%, var(--primary-soft), transparent 32%), radial-gradient(circle at 83% 85%, rgba(104, 112, 255, .08), transparent 28%), var(--bg); } +.auth-card { width: min(450px, 100%); padding: 30px; } .auth-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; } .auth-card h1 { margin: 0; } .auth-card p { margin: 4px 0; color: var(--muted); } .stack { display: grid; gap: 16px; } .stack label { display: grid; gap: 6px; font-weight: 650; } -.muted { color: var(--muted); } -.small { font-size: 12px; } -@media (max-width: 1100px) { - .stats-grid { grid-template-columns: repeat(2, 1fr); } - .job-grid { grid-template-columns: 1fr; } +@media (max-width: 1180px) { + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-grid { grid-template-columns: 1fr; } + .dashboard-rail { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } .detail-layout { grid-template-columns: 1fr; } .sticky-card { position: static; } + .source-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 760px) { - .mobile-only { display: inline-grid; place-items: center; } - .topbar { padding: 10px 14px; } - .brand small, .topbar-actions .button:first-child { display: none; } - .app-shell { grid-template-columns: 1fr; } - .sidebar { position: fixed; inset: 70px auto 0 0; z-index: 40; width: min(86vw, 300px); height: calc(100vh - 70px); transform: translateX(-105%); transition: transform .2s ease; box-shadow: var(--shadow); } - .sidebar.open { transform: translateX(0); } - .main-content { padding: 24px 15px 50px; } - .page-header, .profile-card, .application-card, .job-hero { flex-direction: column; } - .page-header .button { width: 100%; } - .stats-grid, .two-columns, .form-grid, .definition-grid { grid-template-columns: 1fr; } - .filter-bar { flex-direction: column; } - .span-2 { grid-column: auto; } - .hero-score { width: 105px; height: 105px; min-width: 105px; } - .score-components > div { grid-template-columns: 94px 1fr 40px; } +@media (max-width: 900px) { + .mobile-only { display: inline-grid !important; } + .sidebar { visibility: hidden; transform: translateX(-105%); transition: transform .18s ease, visibility 0s linear .18s; } + .workspace { margin-left: 0; } + .nav-open .sidebar { visibility: visible; transform: translateX(0); transition-delay: 0s; } + .nav-scrim { position: fixed; inset: 0; z-index: 50; width: 100%; height: 100%; border: 0; background: rgba(1, 8, 17, .62); backdrop-filter: blur(2px); } + .nav-open .nav-scrim { display: block; } + .utilitybar { padding: 0 16px; } + .main-content { padding-top: 26px; } + .job-grid { grid-template-columns: 1fr; } + .dashboard-rail { grid-template-columns: 1fr 1fr; } + .pipeline { grid-template-columns: repeat(6, 245px); } +} + +@media (max-width: 680px) { + .desktop-label { display: none; } + .utility-link { width: 42px; justify-content: center; padding: 0; border: 1px solid transparent; } + .main-content { padding: 22px 14px 54px; } + .page-header, .profile-card, .application-card, .job-hero { align-items: stretch; flex-direction: column; } + .page-header .button, .page-actions { width: 100%; } + .page-actions .button { flex: 1; } + .stats-grid, .two-columns, .form-grid, .definition-grid, .source-overview, .source-details-grid, .import-layout, .dashboard-rail { grid-template-columns: 1fr; } + .filter-bar { align-items: stretch; flex-direction: column; } + .span-2, .form-section { grid-column: auto; } + .job-card-head { grid-template-columns: 1fr; } + .score-block { justify-items: start; } + .job-hero { gap: 20px; } + .hero-score { min-width: 0; justify-items: start; } + .hero-score strong { font-size: 46px; } + .score-components > div { grid-template-columns: 86px minmax(80px, 1fr) 40px; font-size: 12px; } .source-list li { flex-direction: column; gap: 3px; } + .source-card-head { flex-direction: column; } + .source-actions { width: 100%; justify-content: space-between; } + .form-actions { bottom: 8px; } +} + +@media (max-width: 420px) { + .utility-title span:last-child { display: none; } + .stats-grid { gap: 9px; } + .stat-card { min-height: 112px; padding: 16px; } + .stat-card strong { font-size: 29px; } + .page-header h1, .job-hero h1 { font-size: 30px; } + .panel, .job-card { padding: 17px; } + .job-actions form, .job-actions .button { flex: 1 1 auto; } } @media (prefers-reduced-motion: reduce) { diff --git a/static/js/app.js b/static/js/app.js index 55ca1fb..5c7ad48 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,23 +1,54 @@ (() => { - const toggle = document.querySelector('[data-nav-toggle]'); - const sidebar = document.querySelector('[data-sidebar]'); - if (!toggle || !sidebar) return; + const root = document.documentElement; + let storedTheme = null; + try { + storedTheme = localStorage.getItem("vacatureradar-theme"); + } catch { + // Private or embedded browser contexts may disable persistent storage. + } + if (storedTheme === "light" || storedTheme === "dark") root.dataset.theme = storedTheme; - const close = () => { - sidebar.classList.remove('open'); - toggle.setAttribute('aria-expanded', 'false'); - }; - - toggle.addEventListener('click', () => { - const open = sidebar.classList.toggle('open'); - toggle.setAttribute('aria-expanded', String(open)); - }); - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') close(); - }); - document.addEventListener('click', (event) => { - if (sidebar.classList.contains('open') && !sidebar.contains(event.target) && !toggle.contains(event.target)) { - close(); + const themeToggle = document.querySelector("[data-theme-toggle]"); + themeToggle?.addEventListener("click", () => { + const theme = root.dataset.theme === "light" ? "dark" : "light"; + root.dataset.theme = theme; + try { + localStorage.setItem("vacatureradar-theme", theme); + } catch { + // The selected theme still applies for the current page. } + themeToggle.setAttribute("aria-label", theme === "light" ? "Donker thema activeren" : "Licht thema activeren"); + }); + + const frame = document.querySelector("[data-app-frame]"); + const sidebar = document.querySelector("[data-sidebar]"); + const toggle = document.querySelector("[data-nav-toggle]"); + const closeButtons = document.querySelectorAll("[data-nav-close], [data-nav-scrim]"); + let returnFocus = null; + + const closeNavigation = () => { + frame?.classList.remove("nav-open"); + toggle?.setAttribute("aria-expanded", "false"); + if (returnFocus instanceof HTMLElement) returnFocus.focus(); + }; + toggle?.addEventListener("click", () => { + returnFocus = document.activeElement; + frame?.classList.add("nav-open"); + toggle.setAttribute("aria-expanded", "true"); + sidebar?.querySelector("a, button")?.focus(); + }); + closeButtons.forEach((button) => button.addEventListener("click", closeNavigation)); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeNavigation(); + }); + + document.querySelectorAll("[data-disclosure]").forEach((button) => { + button.addEventListener("click", () => { + const target = document.getElementById(button.getAttribute("aria-controls")); + if (!target) return; + const expanded = button.getAttribute("aria-expanded") === "true"; + button.setAttribute("aria-expanded", String(!expanded)); + target.hidden = expanded; + }); }); })(); diff --git a/templates/applications/edit.html b/templates/applications/edit.html index fb6103f..5017a1a 100644 --- a/templates/applications/edit.html +++ b/templates/applications/edit.html @@ -1,3 +1,7 @@ {% extends "base.html" %} {% block title %}Sollicitatiedossier · VacatureRadar{% endblock %} -{% block content %}
{% csrf_token %}
{% for field in form %}{% endfor %}
Terug
{% endblock %} +{% block content %} + +
{% csrf_token %}
{% for field in form %}{% endfor %}
Terug
+

Privacy en verwijderen

De export bevat de opgeslagen snapshot en tijdlijn. Verwijderen wist alleen jouw dossier; de onderliggende vacature blijft behouden.

{% csrf_token %}
+{% endblock %} diff --git a/templates/applications/list.html b/templates/applications/list.html index ededb74..f47edac 100644 --- a/templates/applications/list.html +++ b/templates/applications/list.html @@ -1,3 +1,18 @@ {% extends "base.html" %} {% block title %}Sollicitaties · VacatureRadar{% endblock %} -{% block content %}
{% for application in applications %}
{{ application.get_status_display }}

{{ application.job.original_title }}

{{ application.job.employer_name }} · {{ application.job.raw_location }}

{% if application.applied_at %}Verzonden {{ application.applied_at|date:"d/m/Y" }}{% endif %}{% if application.follow_up_date %}Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}{% endif %}
Dossier
{% empty %}

Nog geen sollicitatiedossiers

Markeer een vacature als Gesolliciteerd om automatisch een dossier te maken.

{% endfor %}
{% endblock %} +{% block content %} + +
+{% for column in pipeline_columns %} +
+

{{ column.label }}

{{ column.applications|length }}
+
+ {% for application in column.applications %} +
{{ application.get_status_display }}

{{ application.job.original_title }}

{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}

{% if application.applied_at %}Verzonden {{ application.applied_at|date:"d/m/Y" }}{% endif %}{% if application.follow_up_date %}Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}{% endif %}
+ {% empty %}
Geen dossiers in deze fase.
{% endfor %} +
+
+{% endfor %} +
+{% if not applications %}

Nog geen sollicitatiedossiers

Markeer een vacature als Gesolliciteerd om automatisch een persoonlijk dossier te maken.

Vacatures bekijken
{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 95aeb41..af41d6b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,50 +1,87 @@ {% load static %} - + - + + {% block title %}{{ app_name }}{% endblock %} - - + + - + {% include "components/icon_sprite.html" %} + {% if user.is_authenticated %} -
-
- - - - VacatureRadarpersoonlijke vacature-assistent - -
-
- Systeem -
{% csrf_token %}
-
-
-
- -
+
+ + +
+
+
+ +
Persoonlijke radar
+
+
+ Zoeken + +
{% csrf_token %}
+
+
+
{% else %}
{% endif %} {% if messages %} -
+
{% for message in messages %}
{{ message }}
{% endfor %}
{% endif %} {% block content %}{% endblock %}
- {% if user.is_authenticated %}
{% endif %} + {% if user.is_authenticated %}
{% endif %} diff --git a/templates/components/icon_sprite.html b/templates/components/icon_sprite.html new file mode 100644 index 0000000..bb197e1 --- /dev/null +++ b/templates/components/icon_sprite.html @@ -0,0 +1,24 @@ + diff --git a/templates/dashboard/today.html b/templates/dashboard/today.html index 9a9a75c..ec8724b 100644 --- a/templates/dashboard/today.html +++ b/templates/dashboard/today.html @@ -2,31 +2,43 @@ {% block title %}Vandaag · VacatureRadar{% endblock %} {% block content %}
-
Nieuwe vacatures{{ new_today }}vandaag gevonden
-
Actief{{ active_jobs }}actuele vacatures
-
Sollicitaties{{ applications_open }}open dossiers
-
Bronnen{{ source_counts.total|default:0 }}{{ source_counts.unhealthy|default:0 }} vragen aandacht
+
Nieuwe signalen{{ new_today }}vandaag gevonden
+
Actieve vacatures{{ active_jobs }}actueel in de radar
+
Open dossiers{{ applications_open }}sollicitaties in opvolging
+
Bronstatus{{ source_counts.total|default:0 }}{{ source_counts.unhealthy|default:0 }} vragen aandacht
-

Beste matches

Gerangschikt op persoonlijke matchscore; confidence blijft afzonderlijk zichtbaar.

-
-{% for score in score_cards %} -
-
{{ score.score|floatformat:0 }}
{{ score.get_recommendation_display }}

{{ score.job.original_title }}

{{ score.job.employer_name }}

-
{{ score.job.raw_location|default:"Locatie onbekend" }}{{ score.job.get_workplace_type_display }}confidence {{ score.confidence|floatformat:2 }}
- {% if score.positives %}{% endif %} - {% if score.concerns %}

Aandachtspunt: {{ score.concerns.0 }}

{% endif %} -
-
{% csrf_token %}
-
{% csrf_token %}
- Details + +
+
+
Gerangschikt voor jou

Beste matches

Matchscore en dataconfidence blijven bewust afzonderlijk zichtbaar.

Alles bekijken →
+
+ {% for score in score_cards %} +
+
+
{{ score.get_recommendation_display }}

{{ score.job.original_title }}

{{ score.job.employer_name }}

+
{{ score.score|floatformat:0 }}%confidence {{ score.confidence|floatformat:2 }}
+
+
{{ score.job.raw_location|default:"Locatie onbekend" }}{{ score.job.get_workplace_type_display }}
+ {% if score.positives %}
    {% for item in score.positives|slice:":2" %}
  • {{ item }}
  • {% endfor %}
{% endif %} + {% if score.concerns %}

Aandachtspunt: {{ score.concerns.0 }}

{% endif %} +
+
{% csrf_token %}
+
{% csrf_token %}
+ Analyse +
+
+ {% empty %} +

Nog geen beoordeelde vacatures

Importeer vacaturedata of activeer een toegestane bron. De eerste betrouwbare resultaten verschijnen hier automatisch.

Bronnen beheren
+ {% endfor %}
-
-{% empty %} -

Nog geen beoordeelde vacatures

Importeer de meegeleverde fixture of activeer een toegestane bron. De eerste resultaten verschijnen hier automatisch.

make demo
-{% endfor %} + +
{% endblock %} diff --git a/templates/jobs/detail.html b/templates/jobs/detail.html index 7911064..da5215d 100644 --- a/templates/jobs/detail.html +++ b/templates/jobs/detail.html @@ -1,14 +1,19 @@ {% extends "base.html" %} {% block title %}{{ job.original_title }} · VacatureRadar{% endblock %} {% block content %} +← Terug naar vacatures
- ← Terug naar vacatures -

{{ job.get_status_display }}

{{ job.original_title }}

{{ job.employer_name }} · {{ job.raw_location|default:"Locatie onbekend" }}

{{ job.get_workplace_type_display }}{% for type in job.employment_types %}{{ type }}{% endfor %}{% if job.valid_through %}Sluit {{ job.valid_through|date:"d/m/Y" }}{% endif %}
{% if score %}
{{ score.score|floatformat:0 }}matchscoreconfidence {{ score.confidence|floatformat:2 }}
{% endif %}
- {% if score %}

Waarom deze score?

De inhoudelijke score en de betrouwbaarheid worden apart behandeld.

{{ score.get_recommendation_display }}
{% for name,value in score.components.items %}
{{ name|capfirst }}
{{ value|floatformat:1 }}
{% endfor %}

Waarom passend

    {% for item in score.positives %}
  • {{ item }}
  • {% empty %}
  • Nog onvoldoende positieve signalen.
  • {% endfor %}

Aandachtspunten

    {% for item in score.concerns %}
  • {{ item }}
  • {% empty %}
  • Geen specifieke aandachtspunten gedetecteerd.
  • {% endfor %}
{% if score.hard_exclusions %}
Harde uitsluitingen: {{ score.hard_exclusions|join:", " }}
{% endif %}
{% endif %} -

Vacatureomschrijving

{% if job.description_html_sanitized %}
{{ job.description_html_sanitized|safe }}
{% else %}

{{ job.description_text|linebreaksbr }}

{% endif %}
-

Bron en historie

Eerst gezien
{{ job.first_seen|date:"d/m/Y H:i" }}
Laatst gezien
{{ job.last_seen|date:"d/m/Y H:i" }}
Laatst gewijzigd
{{ job.last_changed|date:"d/m/Y H:i" }}
Extractieconfidence
{{ job.extraction_confidence|floatformat:2 }}

Ook gevonden via

+
+

{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}

{{ job.original_title }}

{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}

{{ job.get_workplace_type_display }}{% for type in job.employment_types %}{{ type }}{% endfor %}{% if job.hours_text %}{{ job.hours_text }}{% endif %}{% if job.valid_through %}sluit {{ job.valid_through|date:"d/m/Y" }}{% endif %}
+ {% if score %}
{{ score.score|floatformat:0 }}%{{ score.get_recommendation_display }}confidence {{ score.confidence|floatformat:2 }}
{% endif %} +
+ {% if score %} +
Deterministische analyse

Waarom deze score?

De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.

{{ score.get_recommendation_display }}
{% for name,value in score.components.items %}
{{ name|capfirst }}
{{ value|floatformat:1 }}
{% endfor %}

Waarom passend

    {% for item in score.positives %}
  • {{ item }}
  • {% empty %}
  • Nog onvoldoende positieve signalen.
  • {% endfor %}

Aandachtspunten

    {% for item in score.concerns %}
  • {{ item }}
  • {% empty %}
  • Geen specifieke aandachtspunten gedetecteerd.
  • {% endfor %}
{% if score.hard_exclusions %}
Harde uitsluitingen: {{ score.hard_exclusions|join:", " }}
{% endif %}
+ {% else %}

Nog geen persoonlijke score

Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.

{% endif %} +
Vacaturetekst

Over de functie

{% if job.description_html_sanitized %}
{{ job.description_html_sanitized|safe }}
{% elif job.description_text %}

{{ job.description_text|linebreaksbr }}

{% else %}

Geen vacatureomschrijving beschikbaar.

{% endif %}
+
Transparantie

Bron en historie

Eerst gezien
{{ job.first_seen|date:"d/m/Y H:i" }}
Laatst gezien
{{ job.last_seen|date:"d/m/Y H:i" }}
Laatst gewijzigd
{{ job.last_changed|date:"d/m/Y H:i" }}
Extractieconfidence
{{ job.extraction_confidence|floatformat:2 }}

Ook gevonden via

- +
{% endblock %} diff --git a/templates/jobs/list.html b/templates/jobs/list.html index 1e70ce6..f21c25d 100644 --- a/templates/jobs/list.html +++ b/templates/jobs/list.html @@ -1,16 +1,23 @@ {% extends "base.html" %} {% block title %}Vacatures · VacatureRadar{% endblock %} {% block content %} - +
- - - - + + + +
-
-{% for job in jobs %} -{% empty %}{% endfor %} -
VacatureLocatieWerkmodelStatusGevonden
{{ job.original_title }}{{ job.employer_name }}{{ job.raw_location|default:"—" }}{{ job.get_workplace_type_display }}{{ job.get_status_display }}{{ job.first_seen|date:"d/m/Y" }}
Geen vacatures gevonden voor deze filters.
-{% if is_paginated %}{% endif %} +
Resultaten

{{ page_obj.paginator.count|default:jobs|length }} vacatures

+
+{% for job in jobs %} +
+
{{ job.get_status_display }}

{{ job.original_title }}

{{ job.employer_name }}

+
{{ job.raw_location|default:"Locatie onbekend" }}{{ job.get_workplace_type_display }}gevonden {{ job.first_seen|date:"d/m/Y" }}
+ {% if job.skills_required %}

Vereiste skills: {{ job.skills_required|slice:":3"|join:", " }}

{% endif %} + +
+{% empty %}

Geen vacatures gevonden

Pas de zoekterm of filters aan. Gearchiveerde resultaten verschijnen wanneer je de statusfilter leegmaakt.

{% endfor %} +
+{% if is_paginated %}{% endif %} {% endblock %} diff --git a/templates/profiles/edit.html b/templates/profiles/edit.html index c9a518d..0ead213 100644 --- a/templates/profiles/edit.html +++ b/templates/profiles/edit.html @@ -1,3 +1,11 @@ {% extends "base.html" %} {% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %} -{% block content %}
{% csrf_token %}{% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
{% for field in form %}{% endfor %}
Annuleren
{% endblock %} +{% block content %} + +
{% csrf_token %}{% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
+{% for field in form %} + {% if field.name == 'name' %}

Profielbasis

Naam, status en thuislocatie voor afstandsbepaling.

{% elif field.name == 'desired_titles_text' %}

Functies en vaardigheden

Eén titel of skill per regel; uitsluitingen hebben voorrang op voorkeuren.

{% elif field.name == 'allowed_employment_types' %}

Werkvorm en regio

Lijstvelden gebruiken JSON-notatie, bijvoorbeeld ["hybrid", "on_site"].

{% elif field.name == 'recommendation_threshold' %}

Scoring en automatisering

Topmatch moet minstens gelijk zijn aan de aanbevelingsdrempel.

{% endif %} + +{% endfor %} +
Annuleren
+{% endblock %} diff --git a/templates/profiles/list.html b/templates/profiles/list.html index 035ad24..b28de07 100644 --- a/templates/profiles/list.html +++ b/templates/profiles/list.html @@ -1,3 +1,6 @@ {% extends "base.html" %} {% block title %}Zoekprofielen · VacatureRadar{% endblock %} -{% block content %}
{% for profile in profiles %}

{{ profile.name }}

{% if profile.is_active %}Actief{% endif %}

{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}

max. {{ profile.max_distance_km }} kmdigest {{ profile.digest_time|time:"H:i" }}versie {{ profile.version }}
Bewerken{% if not profile.is_active %}
{% csrf_token %}
{% endif %}
{% empty %}

Geen profiel

Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.

{% endfor %}
{% endblock %} +{% block content %} + +
{% for profile in profiles %}
{% if profile.is_active %}Actief{% else %}Inactief{% endif %}

{{ profile.name }}

{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}

max. {{ profile.max_distance_km }} kmdigest {{ profile.digest_time|time:"H:i" }}versie {{ profile.version }}leren {% if profile.learning_enabled %}aan{% else %}uit{% endif %}
Profiel bewerken{% if not profile.is_active %}
{% csrf_token %}
{% endif %}
{% empty %}

Geen profiel beschikbaar

Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.

{% endfor %}
+{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html index 59e92a2..5b5100e 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -2,13 +2,9 @@ {% block title %}Aanmelden · VacatureRadar{% endblock %} {% block content %}
-
VR

Welkom terug

Open je persoonlijke vacatureselectie.

+

Intelligence cockpit

Welkom terug

Open je persoonlijke vacatureselectie.

{% if form.errors %}
Gebruikersnaam of wachtwoord klopt niet.
{% endif %} -
{% csrf_token %} - - - -
-

De applicatie is ontworpen voor lokaal gebruik via LAN of VPN.

+
{% csrf_token %}
+

Beveiligde lokale toegang via je eigen netwerk, VPN of reverse proxy.

{% endblock %} diff --git a/templates/sources/list.html b/templates/sources/list.html index c70708d..a0d1ba0 100644 --- a/templates/sources/list.html +++ b/templates/sources/list.html @@ -1,499 +1,44 @@ +{% extends "base.html" %} {% load static %} - - - - - - VacatureRadar - Bronnen & Source Health - - - - - - - - - - -
- {% if messages %} -
- {% for message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} - -
-
-

Bronbeheer

-

Beheer en monitor de actieve scan-endpoints van jouw radarprofiel.

-
-
-
- Actieve Bronnen - {{ active_source_count }} -
-
- Systeemstatus - - Gezond - - -
-
-
- -
-

Handmatige import

-

Importeer een URL of plak vacaturetekst met dezelfde policy- en SSRF-controle als de backend.

- -
- {% csrf_token %} - - -
- - -
- - Bookmarklet: sleep naar bladwijzers - - {% for error in manual_import_form.non_field_errors %} - {{ error }} - {% endfor %} -
- -
- - {% if manual_import_result %} -
-

Importresultaat

-

Bron: {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})

- - {% if manual_import_result.warnings %} -

Waarschuwingen

- - {% endif %} - {% if manual_import_result.jobs %} -

Geïmporteerde vacatures

- - {% endif %} -
- {% endif %} - -
- {% csrf_token %} - - - -
- -
- {% for source in sources %} - {% with latest_run=source.runs.all|first %} -
-
-
-
- - {% if source.source_type == 'email' %}mail{% elif source.source_type == 'rss' or source.source_type == 'sitemap' %}rss_feed{% elif source.source_type == 'ats' %}hub{% else %}web{% endif %} - -
-
-
- {% if source.policy != "deny" %} - - {% endif %} -

{{ source.name }}

-
-
- {{ source.get_source_type_display }} - - {{ source.domain|default:'Geen domein' }} - {% if latest_run %} - Laatste scan: {{ latest_run.started_at|date:'d/m/Y H:i' }} - {% else %} - Nog niet gestart - {% endif %} -
-
-
-
-
- Vacatures gevonden - {% if latest_run %}{{ latest_run.extracted_count }}{% else %}0{% endif %} -
-
- - - {% if source.policy == "deny" %} - Geblokkeerd - {% elif latest_run and latest_run.status == 'failed' %} - Mislukt - {% elif latest_run and latest_run.status == 'partial' %} - Beperkt - {% elif source.status == 'quarantined' %} - Quarantaine - {% else %} - Gezond - {% endif %} - -
- -
-
- {% if source.discovery_evidence %} -
-

Ontdekkingsevidence

-
    - {% for evidence in source.discovery_evidence %} -
  • {{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}
  • - {% endfor %} -
-
- {% endif %} - -
- {% endwith %} - {% empty %} -
-

Nog geen bronnen geregistreerd.

-
- {% endfor %} -
- -
-

Bronstatus op overzicht

-
-
-

Bronnen geregistreerd

-
- {{ source_total_count }} - Totaal in beheer -
-
-
-

Te reviewen

-
- {{ review_pending_count }} - Policy review -
-
-
-

Systeemlogboek

-

Brongezondheid en technische events zijn zichtbaar op source-level kaarten.

-
-
-
-
- - - - - - - - + {% if source.discovery_evidence %}
Ontdekkingsevidence
{% endif %} + + +{% endwith %}{% empty %}

Nog geen bronnen

Registreer een bron via de beheeropdrachten of gebruik handmatige import.

{% endfor %} + + +{% endblock %} diff --git a/templates/system/status.html b/templates/system/status.html index 20fe7c9..c033f42 100644 --- a/templates/system/status.html +++ b/templates/system/status.html @@ -1,6 +1,11 @@ {% extends "base.html" %} {% block title %}Systeemstatus · VacatureRadar{% endblock %} -{% block content %}
Readiness{% if health.ok %}OK{% else %}FOUT{% endif %}{{ health.checks.database }}
{% for row in source_summary %}
Bronnen {{ row.status }}{{ row.total }}
{% endfor %}{% for row in job_summary %}
Vacatures {{ row.status }}{{ row.total }}
{% endfor %}

Veilige beheeropdrachten

make verify
+{% block content %}
+
+
Readiness{% if health.ok %}OK{% else %}FOUT{% endif %}database: {{ health.checks.database }}
{% for row in source_summary %}
Bronnen · {{ row.status }}{{ row.total }}geregistreerde bronnen
{% endfor %}{% for row in job_summary %}
Vacatures · {{ row.status }}{{ row.total }}vacatures in database
{% endfor %}
+
Operaties

Veilige beheeropdrachten

Voer deze uitsluitend uit vanaf een vertrouwde shell op de server.

+
make verify
 ./scripts/backup.sh
 ./scripts/restore.sh /pad/naar/backup.sql.gz
-uv run python manage.py seed_sources
{% endblock %} +uv run python manage.py seed_sources
+{% endblock %} diff --git a/tests/e2e/test_vr114_browser_and_accessibility.py b/tests/e2e/test_vr114_browser_and_accessibility.py index 809b186..1855878 100644 --- a/tests/e2e/test_vr114_browser_and_accessibility.py +++ b/tests/e2e/test_vr114_browser_and_accessibility.py @@ -66,6 +66,12 @@ def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) -> label = button.get_text(" ", strip=True) or button.get("aria-label") assert label, f"{page_name}: knop zonder tekst of aria-label" + for element in soup.select("script[src], link[rel='stylesheet'][href]"): + asset_url = element.get("src") or element.get("href") or "" + assert not asset_url.startswith(("http://", "https://")), ( + f"{page_name}: externe productie-asset gevonden: {asset_url}" + ) + def _run_html_accessibility_probe(client, user, profile, job) -> None: _seed_dashboard_score(profile=profile, job=job) @@ -99,11 +105,16 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None: response = client.get(reverse("jobs:applications")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="applications") + assert "Sollicitatiepipeline" in response.content.decode("utf-8") response = client.get(reverse("sources:list")) assert response.status_code == 200 _assert_basic_html_accessibility(response.content, page_name="sources") + response = client.get(reverse("dashboard:system")) + assert response.status_code == 200 + _assert_basic_html_accessibility(response.content, page_name="system") + def _assert_playwright_accessibility(page, *, expected_h1: str) -> None: page.wait_for_load_state("networkidle") diff --git a/tests/integration/test_tasks_and_more_views.py b/tests/integration/test_tasks_and_more_views.py index 3cb9a1d..0d232ad 100644 --- a/tests/integration/test_tasks_and_more_views.py +++ b/tests/integration/test_tasks_and_more_views.py @@ -4,7 +4,7 @@ import pytest from django.urls import reverse from django.utils import timezone -from apps.jobs.models import Feedback, ScoreRun +from apps.jobs.models import Application, Feedback, ScoreRun from apps.jobs.tasks import rescore_active_jobs, update_job_lifecycle from apps.notifications.tasks import build_due_digests from apps.sources.models import RawDocument, Source, SourceLease, SourcePolicyReview, SourceRun @@ -163,7 +163,11 @@ def test_job_profile_source_and_application_views(client, user, profile, job, so assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200 assert client.get(reverse("sources:list")).status_code == 200 assert client.get(reverse("dashboard:system")).status_code == 200 - assert client.get(reverse("jobs:applications")).status_code == 200 + applications_response = client.get(reverse("jobs:applications")) + assert applications_response.status_code == 200 + assert [column["status"] for column in applications_response.context["pipeline_columns"]] == [ + status for status, _label in Application.Status.choices + ] source.policy = Source.Policy.DENY source.save(update_fields=["policy"])