diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3458797..c1fa1bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
+## 0.2.11 — 2026-07-22
+
+### Gewijzigd
+
+- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
+- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
+- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
+- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
+- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
+- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
+
+### Verificatie
+
+- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
+- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
+
## 0.2.10 — 2026-07-22
### Gewijzigd
diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py
index 2f65284..135ee63 100644
--- a/apps/core/context_processors.py
+++ b/apps/core/context_processors.py
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
context = {
"app_name": "VacatureRadar",
- "app_version": "0.2.2",
+ "app_version": "0.2.11",
}
if request.user.is_authenticated:
context["navigation_profile"] = (
diff --git a/apps/core/views.py b/apps/core/views.py
index b3d5275..a74f8c2 100644
--- a/apps/core/views.py
+++ b/apps/core/views.py
@@ -4,13 +4,15 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
-from django.db.models import Count, Q
+from django.db.models import Count, OuterRef, Q, Subquery
from django.http import JsonResponse
from django.utils import timezone
from django.views.generic import TemplateView
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import Application, JobPosting, ScoreRun
+from apps.jobs.services.relevance import it_relevance_query
+from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
@@ -83,9 +85,24 @@ class TodayView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
+ profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
+ latest_score_id = (
+ ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
+ .order_by("-created_at")
+ .values("pk")[:1]
+ )
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
- .filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
+ .filter(
+ profile=profile,
+ job__status=JobPosting.Status.ACTIVE,
+ hard_exclusions=[],
+ recommendation__in=[
+ ScoreRun.Recommendation.STRONG,
+ ScoreRun.Recommendation.POSSIBLE,
+ ],
+ )
+ .filter(it_relevance_query("job__"), pk=Subquery(latest_score_id))
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
@@ -99,6 +116,10 @@ class TodayView(LoginRequiredMixin, TemplateView):
cards.append(score)
if len(cards) >= 20:
break
+ active_it_jobs = JobPosting.objects.filter(
+ Q(status=JobPosting.Status.ACTIVE) & it_relevance_query()
+ )
+ all_active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
context.update(
{
"score_cards": cards,
@@ -106,13 +127,12 @@ class TodayView(LoginRequiredMixin, TemplateView):
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
- "active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
- "new_today": JobPosting.objects.filter(
- first_seen__date=timezone.localdate()
- ).count(),
- "applications_open": Application.objects.exclude(
- status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
- ).count(),
+ "active_jobs": active_it_jobs.count(),
+ "filtered_non_it_jobs": all_active_jobs.exclude(it_relevance_query()).count(),
+ "new_today": active_it_jobs.filter(first_seen__date=timezone.localdate()).count(),
+ "applications_open": Application.objects.filter(user=self.request.user)
+ .exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
+ .count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
diff --git a/apps/jobs/services/relevance.py b/apps/jobs/services/relevance.py
new file mode 100644
index 0000000..fe429f7
--- /dev/null
+++ b/apps/jobs/services/relevance.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from django.db.models import Q
+
+from apps.profiles.models import SearchProfile
+
+from .normalization import normalize_token
+
+# Deliberately title-led: employer pages regularly mention digital systems in otherwise
+# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
+IT_TITLE_TERMS = (
+ "it",
+ "ict",
+ "informatica",
+ "software",
+ "development engineer",
+ "devops",
+ "secops",
+ "cloud",
+ "data engineer",
+ "data scientist",
+ "data steward",
+ "database",
+ "dba",
+ "business intelligence",
+ "bi analyst",
+ "system engineer",
+ "systems engineer",
+ "systeembeheer",
+ "infrastructure engineer",
+ "infrastructuur engineer",
+ "network engineer",
+ "network architect",
+ "netwerkbeheer",
+ "netwerk engineer",
+ "cybersecurity",
+ "cyber security",
+ "security engineer",
+ "security architect",
+ "security analyst",
+ "information security",
+ "informatiebeveiliging",
+ "workplace engineer",
+ "modern workplace",
+ "digital workplace",
+ "service desk",
+ "servicedesk",
+ "helpdesk",
+ "support engineer",
+ "support specialist",
+ "application lead",
+ "application engineer",
+ "application manager",
+ "application specialist",
+ "applicatiebeheer",
+ "platform engineer",
+ "platform expert",
+ "solution architect",
+ "solutions architect",
+ "technical architect",
+ "technisch architect",
+ "functional analyst",
+ "functioneel analist",
+ "business analyst",
+ "product owner",
+ "web developer",
+ "mobile developer",
+ "low-code developer",
+ "low code developer",
+ "api developer",
+ "idm developer",
+ "power platform",
+ "forgerock",
+ "embedded",
+ "c#",
+ "front end",
+ "frontend",
+ "back end",
+ "backend",
+ "full stack",
+ "fullstack",
+ "php",
+ ".net",
+ "java",
+ "python",
+ "powershell",
+ "azure",
+ "aws",
+ "microsoft 365",
+ "linux",
+ "windows server",
+ "kubernetes",
+ "docker",
+ "terraform",
+ "erp",
+ "sap",
+ "machine learning",
+ "ai engineer",
+ "artificial intelligence",
+ "image processing",
+ "computer vision",
+ "qa engineer",
+ "test automation",
+)
+
+# These phrases can contain IT vocabulary while describing commercial, recruitment,
+# or educational-design work. They therefore override positive title signals.
+NON_IT_TITLE_TERMS = (
+ "business developer",
+ "business development",
+ "it recruitment",
+ "it recruiter",
+ "learning designer",
+)
+
+IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
+
+
+def _term_pattern(term: str) -> str:
+ escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
+ return rf"(? ItRelevanceAssessment:
+ normalized_title = normalize_token(title)
+ blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
+ if blocked_signal:
+ return ItRelevanceAssessment(
+ relevant=False,
+ signals=(),
+ reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
+ )
+ signals = tuple(
+ term
+ for term in IT_TITLE_TERMS
+ if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
+ )
+ if signals:
+ return ItRelevanceAssessment(
+ relevant=True,
+ signals=signals[:4],
+ reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
+ )
+ return ItRelevanceAssessment(
+ relevant=False,
+ signals=(),
+ reason="Geen aantoonbaar IT-signaal in de functietitel.",
+ )
+
+
+def profile_requires_it_focus(profile: SearchProfile) -> bool:
+ configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
+ normalized = normalize_token(configured)
+ return any(
+ re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
+ for term in IT_PROFILE_TERMS
+ )
+
+
+def it_relevance_query(prefix: str = "") -> Q:
+ """Return the database equivalent of the conservative title-led classifier."""
+ return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
+ **{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
+ )
diff --git a/apps/jobs/services/scoring.py b/apps/jobs/services/scoring.py
index 07b6a1f..3167668 100644
--- a/apps/jobs/services/scoring.py
+++ b/apps/jobs/services/scoring.py
@@ -14,6 +14,7 @@ from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
+from .relevance import assess_it_relevance, profile_requires_it_focus
@dataclass(frozen=True)
@@ -227,6 +228,9 @@ def _hard_exclusions(
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
+ it_relevance = assess_it_relevance(job.original_title)
+ if profile_requires_it_focus(profile) and not it_relevance.relevant:
+ reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence
@@ -292,6 +296,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
+ it_relevance = assess_it_relevance(job.original_title)
title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {}
@@ -381,6 +386,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns: list[str] = []
if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
+ if it_relevance.relevant:
+ positives.append("IT-focus bevestigd via de functietitel.")
if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter:
@@ -435,6 +442,11 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns=concerns,
hard_exclusions=exclusions,
evidence={
+ "it_relevance": {
+ "relevant": it_relevance.relevant,
+ "signals": list(it_relevance.signals),
+ "reason": it_relevance.reason,
+ },
"distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None,
diff --git a/apps/jobs/views.py b/apps/jobs/views.py
index 2fe2670..717f748 100644
--- a/apps/jobs/views.py
+++ b/apps/jobs/views.py
@@ -3,10 +3,20 @@ from __future__ import annotations
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
-from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
+from django.db.models import (
+ CharField,
+ DateTimeField,
+ DecimalField,
+ F,
+ JSONField,
+ OuterRef,
+ Q,
+ Subquery,
+)
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
+from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView
@@ -21,6 +31,7 @@ from .services.applications import (
track_application_changes,
)
from .services.feedback import record_feedback
+from .services.relevance import it_relevance_query
class JobListView(LoginRequiredMixin, ListView):
@@ -29,6 +40,15 @@ class JobListView(LoginRequiredMixin, ListView):
context_object_name = "jobs"
paginate_by = 30
+ VALID_RECOMMENDATIONS = {
+ ScoreRun.Recommendation.STRONG,
+ ScoreRun.Recommendation.POSSIBLE,
+ ScoreRun.Recommendation.WEAK,
+ ScoreRun.Recommendation.HIDDEN,
+ }
+ VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
+ VALID_SORTS = {"match", "newest", "closing"}
+
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
@@ -48,10 +68,27 @@ class JobListView(LoginRequiredMixin, ListView):
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
+ match_positives=Subquery(
+ latest_score.values("positives")[:1], output_field=JSONField()
+ ),
+ match_concerns=Subquery(
+ latest_score.values("concerns")[:1], output_field=JSONField()
+ ),
+ match_hard_exclusions=Subquery(
+ latest_score.values("hard_exclusions")[:1], output_field=JSONField()
+ ),
+ match_scored_at=Subquery(
+ latest_score.values("created_at")[:1], output_field=DateTimeField()
+ ),
)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
+ focus = self.request.GET.get("focus", "it").strip()
+ recommendation = self.request.GET.get("match", "radar").strip()
+ sort = self.request.GET.get("sort", "match").strip()
+ if recommendation not in self.VALID_MATCH_FILTERS:
+ recommendation = "radar"
if query:
queryset = queryset.filter(
Q(original_title__icontains=query)
@@ -63,7 +100,51 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
- return queryset.order_by("-first_seen")
+ if focus != "all":
+ queryset = queryset.filter(it_relevance_query())
+ if profile and recommendation == "radar":
+ queryset = queryset.filter(
+ Q(
+ match_recommendation__in=[
+ ScoreRun.Recommendation.STRONG,
+ ScoreRun.Recommendation.POSSIBLE,
+ ScoreRun.Recommendation.WEAK,
+ ]
+ )
+ | Q(match_recommendation__isnull=True)
+ )
+ elif profile and recommendation in self.VALID_RECOMMENDATIONS:
+ queryset = queryset.filter(match_recommendation=recommendation)
+ if sort not in self.VALID_SORTS:
+ sort = "match"
+ if sort == "newest":
+ return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
+ if sort == "closing":
+ return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
+ return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
+ it_jobs = active_jobs.filter(it_relevance_query())
+ context.update(
+ {
+ "active_profile": SearchProfile.objects.filter(
+ user=self.request.user, is_active=True
+ ).first(),
+ "active_job_count": active_jobs.count(),
+ "it_job_count": it_jobs.count(),
+ "filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
+ "current_focus": self.request.GET.get("focus", "it"),
+ "current_match": (
+ self.request.GET.get("match", "radar")
+ if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
+ else "radar"
+ ),
+ "current_sort": self.request.GET.get("sort", "match"),
+ }
+ )
+ return context
class JobDetailView(LoginRequiredMixin, DetailView):
@@ -113,7 +194,13 @@ def job_feedback(request, pk):
)
messages.success(request, "Actie opgeslagen.")
next_url = request.POST.get("next")
- return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
+ if next_url and url_has_allowed_host_and_scheme(
+ next_url,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ return redirect(next_url)
+ return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
class ApplicationListView(LoginRequiredMixin, ListView):
diff --git a/config/urls.py b/config/urls.py
index 5999752..6841b25 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -4,11 +4,18 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
+from django.templatetags.static import static as static_url
from django.urls import include, path
+from django.views.generic import RedirectView
from apps.core.views import SecurityAwareLoginView
urlpatterns = [
+ path(
+ "favicon.ico",
+ RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
+ name="favicon",
+ ),
path("admin/", admin.site.urls),
path(
"login/",
diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml
index 9c55277..349747a 100644
--- a/docs/ai/BACKLOG.yaml
+++ b/docs/ai/BACKLOG.yaml
@@ -1444,6 +1444,50 @@ tasks:
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
herberekend.
+- id: VR-206
+ title: Premium Stitch-cockpit en deterministische IT-focus
+ status: done
+ priority: P0
+ requirement_ids:
+ - PR-040
+ - PR-050
+ - PR-051
+ - NFR-004
+ - NFR-008
+ depends_on:
+ - VR-119
+ - VR-120
+ - VR-205
+ summary: Audit de volledige gebruikersflow in code en browser, voorkom dat niet-IT-vacatures de dagelijkse radar
+ vervuilen en benut de Stitch-compositie veel dominanter op mobiel, desktop en widescreen.
+ acceptance_criteria:
+ - Een deterministische en uitlegbare IT-relevantieregel heeft voorrang op scoring en AI; niet-IT-vacatures zijn
+ standaard afwezig uit Vandaag en de vacaturefeed maar blijven via een expliciete archiefkeuze inspecteerbaar.
+ - De vacaturefeed gebruikt echte score-evidence, bruikbare relevantie-/match-/sorteerfilters en toont standaard
+ de meest relevante IT-kansen in plaats van louter de nieuwste bronrecords.
+ - Dashboard, vacaturelijst en vacaturedetail nemen de bruikbare Stitch-panelhiërarchie, informatiedichtheid en
+ actiepatronen over zonder fictieve metrics, quick-apply of externe frontendafhankelijkheden.
+ - De shell en kernlayouts schalen inhoudelijk tussen 320 en 2560 px; widescreen voegt bruikbare kolommen/panelen
+ toe en laat de inhoud niet op een smalle 1280px-strook staan.
+ - Focus, headings, labels, lege toestanden, dark/light contrast, mobiele navigatie en reduced motion blijven toegankelijk;
+ kernroutes hebben geen horizontale paginaoverflow of browserconsolefouten.
+ verification:
+ - uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
+ - ./scripts/codex_verify.sh
+ - Browsermatrix op 2560x1440, 1920x1080, 1440x900, 1024x768, 390x844 en 320x800
+ primary_paths:
+ - apps/jobs/services/relevance.py
+ - apps/jobs/services/scoring.py
+ - apps/jobs/views.py
+ - templates/dashboard/today.html
+ - templates/jobs/list.html
+ - templates/jobs/detail.html
+ - static/css/app.css
+ - docs/design/STITCH_INTEGRATION_PLAN.md
+ result:
+ completed_at: '2026-07-22'
+ note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
+ en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
- id: VR-301
title: Multi-tenant SaaS, teams en billing
status: deferred
diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md
index 22a3d51..5e03a68 100644
--- a/docs/ai/PROJECT_STATE.md
+++ b/docs/ai/PROJECT_STATE.md
@@ -1,7 +1,7 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-22
-- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
+- Repositoryversie: 0.2.11 premium IT-radar en ultrawide Stitch-cockpit
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -131,6 +131,15 @@ VR-201/VR-205-herverificatie op 2026-07-22:
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
+VR-206-herverificatie op 2026-07-22:
+
+- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
+- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
+- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
+- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
+- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
+- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
+
## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
@@ -141,6 +150,13 @@ VR-201/VR-205-herverificatie op 2026-07-22:
## Laatste uitgevoerde backlogtaak
+- 2026-07-22: `VR-206` voltooid.
+ - Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
+ - Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
+ - De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
+ - Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
+ - Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
+
- 2026-07-22: `VR-201` en `VR-205` voltooid.
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
diff --git a/docs/design/DESIGN_SYSTEM.md b/docs/design/DESIGN_SYSTEM.md
index bb14ff7..5d52821 100644
--- a/docs/design/DESIGN_SYSTEM.md
+++ b/docs/design/DESIGN_SYSTEM.md
@@ -52,11 +52,12 @@ mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken.
## 4. Spacing en layout
- 4/8px-gebaseerde schaal;
-- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
+- contentcontainer maximaal 2240px; vanaf 1850px gebruikt de cockpit extra resultaatkolommen en
+ contextpanelen in plaats van alleen langere tekstregels;
- kaarten met consistente padding 20–24px desktop, 16px mobiel;
- minimaal 44×44px touch targets voor primaire interactie;
- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder;
-- grids breken onder 1180px, 900px en 680px logisch af;
+- grids breken rond 1850px, 1350px, 1180px, 900px en 680px logisch af;
- vaste/sticky elementen mogen content niet bedekken.
## 5. Componenten
diff --git a/docs/design/STITCH_INTEGRATION_PLAN.md b/docs/design/STITCH_INTEGRATION_PLAN.md
index c024933..5ca8143 100644
--- a/docs/design/STITCH_INTEGRATION_PLAN.md
+++ b/docs/design/STITCH_INTEGRATION_PLAN.md
@@ -19,6 +19,17 @@ Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit.
+## VR-206: premium radar en ultrawide compositie
+
+De volledige audit maakt de Stitch-compositie ook functioneel dominant. De vacaturefeed start met
+een brede commandofilter, toont beslisbare score-evidence en krijgt op breed desktop een vaste
+radarcontext. Vanaf 1850 px worden matchfeeds tweekoloms; de shell benut maximaal 2240 px in plaats
+van de vroegere 1280 px. Op mobiel worden filters, sectiekoppen en acties lineair gestapeld.
+
+De visuele hiërarchie is gekoppeld aan een deterministische IT-grens: niet-IT en hard uitgesloten
+matches zijn standaard afwezig. Het volledige archief en verborgen scores blijven expliciet
+inspecteerbaar, zodat premium rust niet ten koste gaat van auditbaarheid.
+
## Visuele richting
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht.
@@ -58,7 +69,7 @@ De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuu
## Responsive en toegankelijkheid
-- Validatiebreedtes: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
+- Validatiebreedtes: 2560×1440, 1920×1080, 1440×900, 1024×768, 390×844 en 320×800.
- Eén `h1` per pagina, semantische landmarks, zichtbare focus, toetsenbordbediening, 44px touchdoelen waar praktisch, tekstlabels naast statuskleur en een robuuste skiplink.
- Tabellen schakelen op smalle schermen naar kaartachtige rijen of blijven gecontroleerd horizontaal scrolbaar wanneer kolomvergelijking essentieel is.
- Animatie is subtiel en volledig uitgeschakeld bij `prefers-reduced-motion`.
diff --git a/docs/design/UX_SPEC.md b/docs/design/UX_SPEC.md
index 97ae463..d3417aa 100644
--- a/docs/design/UX_SPEC.md
+++ b/docs/design/UX_SPEC.md
@@ -56,6 +56,8 @@ Regels:
Filters:
- vrije tekst;
+- relevantiefocus: standaard aantoonbare IT-titels, met expliciet volledig bronarchief;
+- radarselectie: standaard geen harde uitsluitingen of verborgen score, met aparte inspectiefilters;
- status;
- recommendation/scoreband;
- werkvorm;
@@ -65,6 +67,10 @@ Filters:
Desktop gebruikt een compacte filterbalk; mobiel een disclosurepanel. Filters blijven in querystring zodat bookmarks en back navigation werken.
+De standaardvolgorde is persoonlijke matchscore, niet importtijd. Niet-IT-inhoud wordt niet
+verwijderd: ze blijft uitsluitend via `Volledig archief` bereikbaar. Een beschrijvingshit alleen
+mag nooit een niet-IT-titel promoveren.
+
Tabel of kaarten kiezen op breedte. Geen horizontale scroll voor kernvelden.
## 5. Vacaturedetail
diff --git a/docs/design/validation/VR-206_AUDIT.md b/docs/design/validation/VR-206_AUDIT.md
new file mode 100644
index 0000000..65cde0d
--- /dev/null
+++ b/docs/design/validation/VR-206_AUDIT.md
@@ -0,0 +1,65 @@
+# VR-206 volledige UX-, logica- en browseraudit
+
+Datum: 2026-07-22
+
+## Uitgangssituatie
+
+De productiecontrole toonde 242 actieve vacatures. De standaardlijst sorteerde hoofdzakelijk op
+`first_seen` en liet elke actieve vacature zien. Daardoor stonden onder meer facilitaire techniek,
+logopedie, vroedkunde en niet-IT-onderzoek tussen de persoonlijke IT-resultaten. Op 2560 px bleef
+de inhoud bovendien op ongeveer 1280 px begrensd, waardoor de Stitch-cockpit een brede lege zone
+overliet.
+
+## Bevindingen en oplossing
+
+| Prioriteit | Bevinding | Oplossing |
+| --- | --- | --- |
+| P0 | Niet-IT-vacatures bereikten Vandaag en de standaardfeed. | Titelgestuurde, deterministische IT-classificatie vóór score/AI; niet-IT blijft alleen in het expliciete volledige archief. |
+| P0 | Vandaag gebruikte niet overal de laatste gebruikersgescopeerde score en open dossiercijfers waren niet volledig gebruikersgescopeerd. | Laatste score per vacature/profiel via subquery; profiel, scores en dossiers zijn aan de aangemelde gebruiker gebonden. |
+| P1 | Verborgen/hard uitgesloten IT-vacatures stonden hoog door hun ruwe score. | Nieuwe standaardkeuze `Radarselectie` laat verborgen resultaten weg; `Verborgen` en `Alle IT-scores` blijven bewuste inspectiekeuzes. |
+| P1 | De lijst sorteerde op bronrecency in plaats van persoonlijke besliswaarde. | Standaard matchscore aflopend, met alternatieven voor nieuwste en sluitingsdatum. |
+| P1 | Stitch werd visueel gebruikt, maar ultrawide bleef een smalle strook. | Adaptieve contentbreedte tot 2240 px, tweekoloms feeds vanaf 1850 px en context-/actierails op breed desktop. |
+| P1 | Vacaturekaarten boden weinig scorebewijs. | Score, datakwaliteit, eerste pluspunt, eerste aandachtspunt, status, werkmodel en directe actie staan in de feed. |
+| P2 | Confidencewaarden wisselden tussen breuken en percentages. | Alle dagelijkse score- en extractiekwaliteit wordt als percentage getoond. |
+| P2 | Inline feedback accepteerde een willekeurige `next`-URL. | Terugkeer alleen naar dezelfde host, met negatieve regressietest. |
+| P2 | De bronpagina liep op 320 px uit door statuschips naast lange sectiekoppen. | Sectiekoppen stapelen onder 680 px; de 320 px matrix heeft geen horizontale overflow. |
+| P2 | Sommige browsers vroegen `/favicon.ico` en kregen een 404. | Lokale permanente fallback naar het bestaande SVG-favicon. |
+
+## Relevantieregel
+
+De classifier is bewust conservatief en titelgestuurd. Een beschrijving die toevallig “digitale
+systemen” vermeldt, maakt een zorg- of facilitaire functie niet relevant. Duidelijke IT-families
+zoals infrastructuur, netwerk, cloud, security, software, data, applicatiebeheer en workplace
+worden toegelaten. Commerciële of recruitmentcontext die IT-woorden bevat, wordt expliciet
+afgewezen. Dezelfde regexcontracten worden in Python en in de databasequery gebruikt.
+
+Op de lokale auditdataset met 336 actieve records bleven 108 titelmatig aantoonbare IT-vacatures
+over en werden 228 niet-IT-records uit de standaardfocus gehouden. Na uitsluiting van verborgen
+matches bevatte de persoonlijke radarselectie 102 kaarten. Het volledige archief blijft 336
+records bevatten: er wordt geen bronhistoriek gewist.
+
+## Stitch-vertaling
+
+- Dashboard: KPI-strip, beslisbare matchfeed en vaste radar-/actierail.
+- Vacaturelijst: brede commandofilter, compacte evidencekaarten, scorehiërarchie en radarcontext.
+- Detail: scorehero, intelligence-strip, bronhistorie en menselijke actierail.
+- Shell: vaste desktopnavigatie, compacte utilitybar, lokale iconen en mobiele drawer.
+- Ultrawide: twee inhoudskolommen plus context; geen kunstmatig uitgerekte tekstregels of fictieve grafieken.
+
+Alle cijfers en signalen komen uit de database. Quick apply, automatische sollicitatie,
+platformloginbots, fictieve trendgrafieken en externe frontendassets blijven bewust afwezig.
+
+## Browsermatrix
+
+Handmatig gecontroleerd in de geïntegreerde browser op 320×800, 390×844, 1024×768, 1440×900,
+1920×1080 en 2560×1440. De zes kernroutes (`/`, `/jobs/`, `/jobs/applications/`, `/profiles/`,
+`/sources/`, `/system/`) hebben één `h1`, gelabelde formuliervelden en geen horizontale
+paginaoverflow. De vacaturefeed schakelt van één kolom naar twee kolommen op ultrawide en gebruikt
+daar 2240 px van de beschikbare ruimte. Mobiele navigatie opent en sluit met correcte
+`aria-expanded`-status.
+
+## Resterend operationeel punt
+
+Na deployment moet de bestaande productie-inventaris opnieuw gescoord worden zodat opgeslagen
+score-evidence eveneens het nieuwe `it_relevance`-blok bevat. De viewfilter werkt onmiddellijk en
+onafhankelijk daarvan; het herscoren verbetert de uitleg en blijvende `hidden`-status.
diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md
index 0c014b1..43cf81a 100644
--- a/docs/quality/THREAT_MODEL.md
+++ b/docs/quality/THREAT_MODEL.md
@@ -80,6 +80,7 @@ Django/Celery
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
+| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
## Misbruikscenario's
diff --git a/docs/quality/TRACEABILITY_MATRIX.md b/docs/quality/TRACEABILITY_MATRIX.md
index ebbe32c..794a931 100644
--- a/docs/quality/TRACEABILITY_MATRIX.md
+++ b/docs/quality/TRACEABILITY_MATRIX.md
@@ -32,14 +32,14 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
| PR-033 | I | canonical constraints/transacties | replaytests | — |
-| PR-040 | I | features/scoring | scoringtests | — |
+| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206 |
| PR-041 | I | scorecomponenten | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 afgerond |
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
| PR-043 | I | `ScoreRun` | pipeline/scoringtests | — |
| 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 + Stitch-dominante cockpitshell/feed/rail | viewtests, VR-119 browsermatrix | VR-114/118/119 e2e/a11y |
+| PR-050 | I | dashboard/list views + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, VR-119/206 browsermatrix | VR-114/118/119/206 e2e/a11y |
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
| 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 |
@@ -98,5 +98,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
-
+- VR-206 voegt een titelgestuurde IT-relevantiegrens vóór scoring/AI toe, maakt de persoonlijke radarselectie de standaard en benut Stitch-panelen dynamisch tot 2560 px. Bewijs staat in `apps/jobs/services/relevance.py`, de score-/viewtests en `docs/design/validation/VR-206_AUDIT.md`.
diff --git a/static/css/app.css b/static/css/app.css
index 2454e65..01e4250 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -7,6 +7,7 @@
--surface-low: #131b2e;
--surface-high: #222a3d;
--surface-bright: #2d3449;
+ --surface-soft: #111a2d;
--text: #dae2fd;
--text-soft: #b9caca;
--muted: #849495;
@@ -16,6 +17,7 @@
--primary-strong: #63f7ff;
--primary-ink: #003739;
--primary-soft: rgba(0, 245, 255, .1);
+ --accent: var(--primary);
--positive: #4edea3;
--positive-soft: rgba(78, 222, 163, .11);
--warning: #ffca69;
@@ -39,6 +41,7 @@
--surface-low: #f7f9fc;
--surface-high: #edf2f7;
--surface-bright: #e2e9f2;
+ --surface-soft: #f6f9fc;
--text: #142033;
--text-soft: #2c3c52;
--muted: #617187;
@@ -62,7 +65,7 @@
* { box-sizing: border-box; }
html { min-height: 100%; scroll-behavior: smooth; }
body {
- min-width: 320px;
+ min-width: 0;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
@@ -72,7 +75,7 @@ body {
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;
+ font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary-strong); text-decoration: none; }
@@ -143,7 +146,7 @@ button.utility-link { width: auto; }
: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: 1280px; margin: 0 auto; padding: 32px 24px 72px; }
+.main-content { width: 100%; max-width: 2240px; margin: 0 auto; padding: clamp(28px, 2.2vw, 48px) clamp(22px, 2.4vw, 54px) 80px; }
.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.2vw, 42px); line-height: 1.08; letter-spacing: -.035em; }
@@ -589,3 +592,92 @@ textarea { resize: vertical; }
.result-body { padding: 17px; }
.result-actions { padding: 0 17px 17px; }
}
+
+/* VR-206: premium, title-led IT radar and genuinely adaptive widescreen composition. */
+.jobs-page-header { align-items: center; }
+.jobs-page-header .radar-status strong { color: var(--primary-strong); }
+.premium-filter-console {
+ grid-template-columns: minmax(220px, 1fr) repeat(5, minmax(112px, 145px)) auto;
+ gap: 12px;
+ padding: 18px;
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--primary) 4%, transparent), transparent 45%),
+ var(--surface);
+ box-shadow: 0 16px 42px rgba(0, 0, 0, .1);
+}
+.premium-filter-console select,
+.premium-filter-console input { background: color-mix(in srgb, var(--surface-low) 94%, transparent); }
+.results-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; align-items: start; }
+.results-column { min-width: 0; }
+.jobs-insight-rail { display: none; }
+.result-card {
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--primary) 2%, transparent), transparent 48%),
+ var(--surface);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .09);
+}
+.result-card:hover { box-shadow: 0 18px 42px rgba(0, 0, 0, .14); }
+.result-heading h2 { font-size: clamp(20px, 1.3vw, 25px); letter-spacing: -.025em; }
+.result-heading > div:first-child { min-width: 0; }
+.score-block strong { font-size: 32px; }
+.score-block small { max-width: 110px; text-align: right; line-height: 1.35; }
+.score-pending strong { color: var(--muted); }
+.pill-it { color: var(--primary-strong); border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); background: var(--primary-soft); }
+.evidence-line { display: flex; align-items: flex-start; gap: 9px; margin: 0; padding: 10px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.45; }
+.evidence-line .icon { width: 17px; height: 17px; margin-top: 1px; }
+.positive-evidence { border-left: 2px solid var(--positive); background: var(--positive-soft); color: var(--text-soft); }
+.positive-evidence .icon { color: var(--positive); }
+.concern-evidence { border-left: 2px solid var(--warning); background: var(--warning-soft); color: var(--text-soft); }
+.concern-evidence > span:first-child { color: var(--warning); font-weight: 900; }
+.result-actions { align-content: center; gap: 9px; }
+.result-actions form { margin: 0; }
+.result-actions .button { width: 100%; }
+.profile-signal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin: 16px 0; }
+.profile-signal-grid > div { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); }
+.profile-signal-grid span { color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
+.profile-signal-grid strong { font-size: 18px; }
+.radar-steps { display: grid; gap: 0; margin: 14px 0 0; padding: 0; list-style: none; counter-reset: radar-step; }
+.radar-steps li { position: relative; display: grid; gap: 2px; padding: 12px 0 12px 34px; border-top: 1px solid var(--border); counter-increment: radar-step; }
+.radar-steps li::before { content: counter(radar-step); position: absolute; left: 0; top: 12px; width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: 50%; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
+.radar-steps span { color: var(--muted); font-size: 12px; }
+.job-hero .title-row { margin-bottom: 12px; }
+.detail-main .panel { box-shadow: 0 12px 38px rgba(0, 0, 0, .08); }
+
+@media (min-width: 1350px) {
+ .results-layout { grid-template-columns: minmax(0, 1fr) 320px; }
+ .jobs-insight-rail { position: sticky; top: calc(var(--utility) + 20px); display: block; }
+}
+
+@media (min-width: 1850px) {
+ .utilitybar { padding-inline: clamp(30px, 2.5vw, 60px); }
+ .global-search { width: min(620px, 42vw); }
+ .dashboard-grid { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
+ .dashboard-grid .job-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
+ .dashboard-grid .section-heading { grid-column: 1 / -1; }
+ .results-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
+ .results-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
+ .result-card { height: 100%; grid-template-columns: 4px minmax(0, 1fr); }
+ .result-actions { grid-column: 2; grid-template-columns: minmax(0, 1fr) auto; padding: 0 22px 20px; border: 0; background: transparent; }
+ .result-actions .button-ghost { width: auto; }
+ .detail-layout { grid-template-columns: minmax(0, 1fr) 380px; gap: 34px; }
+ .health-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .editor-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
+}
+
+@media (max-width: 1180px) {
+ .premium-filter-console { grid-template-columns: minmax(0, 1fr) repeat(2, minmax(145px, .35fr)); }
+ .premium-filter-console .button { grid-column: 1 / -1; }
+}
+
+@media (max-width: 680px) {
+ body { font-size: 15px; }
+ .section-heading { align-items: flex-start; flex-direction: column; gap: 10px; }
+ .section-heading > .pill { align-self: flex-start; }
+ .jobs-page-header { align-items: stretch; }
+ .premium-filter-console { grid-template-columns: 1fr; padding: 14px; }
+ .premium-filter-console .button { grid-column: auto; }
+ .result-actions { gap: 8px; }
+ .result-actions form { width: 100%; }
+ .result-heading h2 { font-size: 20px; }
+ .score-block small { max-width: none; text-align: left; }
+}
diff --git a/templates/dashboard/today.html b/templates/dashboard/today.html
index 75fa9c3..c1cb2a5 100644
--- a/templates/dashboard/today.html
+++ b/templates/dashboard/today.html
@@ -17,7 +17,7 @@
{{ score.job.employer_name }}{% if score.job.raw_location %} · {{ score.job.raw_location }}{% endif %}
{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}
{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}
{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}
{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}
De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.
Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.
{{ job.description_text|linebreaksbr }}
Geen vacatureomschrijving beschikbaar.
{% endif %}Nieuwe matches & archief
Doorzoek actuele en gearchiveerde vacatures met score, herkomst en status in één scanbare feed.
Nieuwe matches & archief
+Een rustige IT-radar: eerst de aantoonbaar relevante functies, gerangschikt op persoonlijke match.
+{{ job.employer_name }}
-Herkenbare skills: {{ job.skills_required|slice:":4"|join:", " }}
{% elif job.description_text %}{{ job.description_text|truncatewords:24 }}
{% endif %} +Pas de zoekterm of filters aan. Gearchiveerde resultaten verschijnen wanneer je de statusfilter leegmaakt.
{{ job.employer_name }}
+{{ job.match_positives.0 }}
{% elif job.skills_required %}Herkenbare skills: {{ job.skills_required|slice:":4"|join:", " }}
{% elif job.description_text %}{{ job.description_text|truncatewords:24 }}
{% endif %} + {% if job.match_concerns.0 %}{{ job.match_concerns.0 }}
{% endif %} +Pas de filters aan of open het volledige bronarchief. Niet-IT-vacatures en harde uitsluitingen blijven standaard bewust uit je radar.
Volledig archief bekijken