diff --git a/assets/styles/_root.scss b/assets/styles/_root.scss
index dbeafd53b6..c51239d9b6 100644
--- a/assets/styles/_root.scss
+++ b/assets/styles/_root.scss
@@ -154,7 +154,7 @@
--#{$prefix}#{$color}-border-subtle: #{$value};
}
- //--#{$prefix}heading-color: #{$headings-color-dark};
+ --#{$prefix}heading-color: #{$headings-color-dark};
--#{$prefix}code-color: #{$code-color-dark};
//--#{$prefix}highlight-color: #{$mark-color-dark};
diff --git a/assets/styles/_variables.scss b/assets/styles/_variables.scss
index 0a3bc2c897..a7d79985f5 100644
--- a/assets/styles/_variables.scss
+++ b/assets/styles/_variables.scss
@@ -24,6 +24,10 @@ $background-gray-dark: #131315;
$title-size-small: 1.125rem;
// Overrides
+// Badges: fully pill-shaped and a slightly lighter weight, site-wide.
+$badge-border-radius: $border-radius-pill;
+$badge-font-weight: 600;
+
$body-bg-dark: #0E0E11;
$body-color: $gewis-gray-dark;
$body-color-dark: $gewis-gray-light;
@@ -39,6 +43,10 @@ $border-color-translucent: rgba($gray-500, .175);
$display-font-family: "Raleway", sans-serif;
$headings-margin-bottom: 0;
+// Headings are heavier and higher-contrast than body text: full black in light mode, white in gewis-night.
+$headings-font-weight: 600;
+$headings-color: $black;
+$headings-color-dark: $white;
$link-color: $gewis-red;
$link-decoration: none;
diff --git a/assets/styles/components/_activity.scss b/assets/styles/components/_activity.scss
index 880b8d72c1..d28ba46253 100644
--- a/assets/styles/components/_activity.scss
+++ b/assets/styles/components/_activity.scss
@@ -53,3 +53,91 @@
margin-bottom: 0;
}
}
+
+// Activity overview list items reuse the shared .panel, but with a calmer, flatter surface than the default panel: the
+// body background plus a border, so a long dense list reads as separated rows rather than a stack of filled cards. The
+// selector is `.panel.panel-surface` (specificity 0,2,0) on purpose: _activity.scss is imported before _panel.scss, so
+// a single-class modifier would lose the source-order tie to `.panel { --bs-panel-bg }`.
+.panel.panel-surface {
+ --#{$prefix}panel-bg: var(--#{$prefix}body-bg);
+
+ transition: background-color $transition-duration ease;
+
+ // The whole panel is clickable (see activity_item_controller.ts); a subtle background lift signals that on hover.
+ &:hover {
+ --#{$prefix}panel-bg: var(--#{$prefix}tertiary-bg);
+ }
+}
+
+@include color-mode(gewis-night) {
+ .panel.panel-surface {
+ --#{$prefix}panel-bg: var(--#{$prefix}body-bg);
+
+ &:hover {
+ --#{$prefix}panel-bg: var(--#{$prefix}secondary-bg);
+ }
+ }
+}
+
+// Stacked date on the left of each activity list item: red uppercase month on top, bold body-coloured day, small
+// weekday underneath. No background box — it sits directly on the panel surface and is top-aligned with the title.
+.activity-date-badge {
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: column;
+ align-items: center;
+ width: 3.5rem;
+ line-height: 1.2;
+ text-align: center;
+
+ .activity-date-badge-month {
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ color: $gewis-red;
+ }
+
+ .activity-date-badge-day {
+ font-size: 1.25rem;
+ font-weight: 700;
+ white-space: nowrap;
+ color: var(--#{$prefix}body-color);
+ }
+
+ .activity-date-badge-weekday {
+ font-size: 0.75rem;
+ white-space: nowrap;
+ color: var(--#{$prefix}secondary-color);
+ }
+
+ // Multi-day / cross-month ranges carry more text ("31–02", "Jul–Aug"), so shrink the day figure to fit.
+ &.activity-date-badge-range .activity-date-badge-day {
+ font-size: 0.95rem;
+ letter-spacing: -0.02em;
+ }
+}
+
+// Uppercase month divider ("JULY 2026") with a trailing rule, used in both the upcoming and archive overviews.
+.activity-month-divider {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 1.5rem 0 0.75rem;
+ color: var(--#{$prefix}secondary-color);
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &::after {
+ content: "";
+ flex: 1 1 auto;
+ border-top: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color);
+ }
+}
diff --git a/assets/styles/components/_badge.scss b/assets/styles/components/_badge.scss
index 28ae030d16..c5f5aa8048 100644
--- a/assets/styles/components/_badge.scss
+++ b/assets/styles/components/_badge.scss
@@ -1,27 +1,49 @@
-// Badges are like alerts, they use the same CSS custom properties (`--bs-{state}-bg-subtle`,
-// `--bs-{state}-text-emphasis`, `--bs-{state}-border-subtle`).
+// Filled, subtle badge variants (one per theme colour). They use the same CSS custom properties as alerts
+// (`--bs-{state}-bg-subtle`, `--bs-{state}-text-emphasis`). No border: the (default) pill shape carries the visual
+// weight, and a border on a subtle fill just muddies it.
@each $state, $value in $theme-colors {
.badge-#{$state} {
--#{$prefix}badge-color: var(--#{$prefix}#{$state}-text-emphasis);
background-color: var(--#{$prefix}#{$state}-bg-subtle);
- border: var(--#{$prefix}border-width) var(--#{$prefix}border-style)
- var(--#{$prefix}#{$state}-border-subtle);
}
}
-// Neutral badge variant. Built from the surface colours instead of the state colours, so it is subtle in both
-// `gewis-day` and `gewis-night`.
+// GEWIS-red brand badge, keyed to the brand colour (which is not one of Bootstrap's theme colours). Same subtle,
+// borderless treatment as `.badge-{state}`; named `-primary` to match the brand's primary/secondary naming.
+.badge-gewis-primary {
+ --#{$prefix}badge-color: #{shade-color($gewis-red, 20%)};
+
+ background-color: #{tint-color($gewis-red, 80%)};
+}
+
+@include color-mode(gewis-night) {
+ .badge-gewis-primary {
+ --#{$prefix}badge-color: #{tint-color($gewis-red, 40%)};
+
+ background-color: rgba($gewis-red, 0.2);
+ }
+}
+
+// Neutral "tag" variant: transparent fill with a visible border and muted text. Reads as a quiet, secondary chip — use
+// it for labels and similar tags. Subtle in both `gewis-day` and `gewis-night` because it is built from surface tokens.
.badge-tag {
- --#{$prefix}badge-color: var(--#{$prefix}body-color);
- background-color: var(--#{$prefix}secondary-bg);
- border: var(--#{$prefix}border-width) var(--#{$prefix}border-style)
- var(--#{$prefix}border-color);
+ --#{$prefix}badge-color: var(--#{$prefix}secondary-color);
+
+ background-color: transparent;
+ border: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color);
}
-// Outlined: transparent fill, coloured border + text from the paired `.badge-{state}` variant. Use as
-// `class="badge badge-secondary badge-outlined"`.
+// Outlined: transparent fill with a border and text taken from `currentColor`. Pair with a `.badge-{state}` to colour
+// it, e.g. `class="badge badge-secondary badge-outlined"`.
.badge-outlined {
background-color: transparent;
+ border: var(--#{$prefix}border-width) var(--#{$prefix}border-style) currentcolor;
+}
+
+// Less-rounded corners: overrides the app-wide pill radius with the standard border-radius for a calmer, boxier badge
+// (e.g. the sign-up deadline).
+.badge-squared {
+ --#{$prefix}badge-border-radius: var(--#{$prefix}border-radius);
}
// Selectable: hidden input + label.badge pattern, mirroring Bootstrap's `btn-check`.
diff --git a/src/Twig/Extensions/ActivityDateRangeExtension.php b/src/Twig/Extensions/ActivityDateRangeExtension.php
index a531f80b9d..71567529ea 100644
--- a/src/Twig/Extensions/ActivityDateRangeExtension.php
+++ b/src/Twig/Extensions/ActivityDateRangeExtension.php
@@ -34,6 +34,96 @@ public function getFunctions(): array
'activity_date_range',
$this->activityDateRange(...),
),
+ new TwigFunction(
+ 'activity_date_badge',
+ $this->activityDateBadge(...),
+ ),
+ ];
+ }
+
+ /**
+ * Locale-aware split date parts for the left-hand stacked badge on the activity overview. Uppercasing is left to
+ * CSS (text-transform) to stay locale-correct for weekday/month abbreviations. The year is deliberately omitted to
+ * keep the badge compact; activity_date_range() beside the title already shows the year when it differs from today.
+ *
+ * @return array{weekday: string, day: string, month: string, range: bool}
+ */
+ public function activityDateBadge(
+ DateTimeInterface $begin,
+ DateTimeInterface $end,
+ ): array {
+ $locale = Locale::getDefault();
+ $sameDay = $begin->format('Y-m-d') === $end->format('Y-m-d');
+ $sameMonth = $begin->format('Y-m') === $end->format('Y-m');
+
+ if ($sameDay) {
+ return [
+ 'weekday' => $this->format(
+ $begin,
+ 'EEE',
+ $locale,
+ ),
+ 'day' => $this->format(
+ $begin,
+ 'd',
+ $locale,
+ ),
+ 'month' => $this->format(
+ $begin,
+ 'MMM',
+ $locale,
+ ),
+ 'range' => false,
+ ];
+ }
+
+ return [
+ 'weekday' => sprintf(
+ '%s - %s',
+ $this->format(
+ $begin,
+ 'EEE',
+ $locale,
+ ),
+ $this->format(
+ $end,
+ 'EEE',
+ $locale,
+ ),
+ ),
+ 'day' => sprintf(
+ '%s - %s',
+ $this->format(
+ $begin,
+ 'dd',
+ $locale,
+ ),
+ $this->format(
+ $end,
+ 'dd',
+ $locale,
+ ),
+ ),
+ 'month' => $sameMonth
+ ? $this->format(
+ $begin,
+ 'MMM',
+ $locale,
+ )
+ : sprintf(
+ '%s - %s',
+ $this->format(
+ $begin,
+ 'MMM',
+ $locale,
+ ),
+ $this->format(
+ $end,
+ 'MMM',
+ $locale,
+ ),
+ ),
+ 'range' => true,
];
}
diff --git a/templates/activity/admin/approvals/review.html.twig b/templates/activity/admin/approvals/review.html.twig
index 77c233a5cb..687763c93a 100644
--- a/templates/activity/admin/approvals/review.html.twig
+++ b/templates/activity/admin/approvals/review.html.twig
@@ -1,8 +1,5 @@
{% extends 'layout-admin.html.twig' %}
-{# A localised text (a sign-up list's name) shown the same way as the Details panel: two flag-headed columns
- (Dutch | English), each with the inline diff of that language's value. A null `old` shows just the new value (a new
- or removed list, which has nothing to compare against); `struck` strikes both values through (a removed list). #}
{% macro localised_flag_diff(old, new, renderer, struck) %}
@@ -16,8 +13,6 @@
{% endmacro %}
-{# One labelled field of a localised text block: a small label with the inline diff of a single language's value
- below it. Renders nothing when the field is empty on both sides, so an unused language collapses to one note. #}
{% macro field_diff(label, old, new, renderer) %}
{% set rendered = diff(old, new, renderer|default('Combined')) %}
{% if rendered|trim is not empty %}
@@ -28,9 +23,6 @@
{% endif %}
{% endmacro %}
-{# A single boolean list setting. With a `previous` counterpart a newly-enabled flag is green, a newly-disabled one
- is shown struck-through in red, and an unchanged-but-on flag stays neutral; without one (a new/removed list) an
- enabled flag is just shown neutral. An unchanged-off flag renders nothing. #}
{% macro flag(label, old, new) %}
{% if old is not null and new and not old %}
{{ label }}
@@ -41,9 +33,6 @@
{% endif %}
{% endmacro %}
-{# The window, settings and custom fields of one sign-up list. With a lineage-matched `previous` counterpart every
- part is shown as a diff (current value, with the old one struck/annotated when it changed, mirroring the main
- body above); without one (a new or removed list) it just shows the list's own values. #}
{% macro signup_list_body(list, previous) %}
{% import _self as diffmacro %}
@@ -67,8 +56,6 @@
{{ flags }}
{% endif %}
- {# Custom fields, matched to the previous list's fields by position (mirroring the change detection in the
- controller); trailing previous-only fields were removed. #}
{% set previousFields = previous is not null ? previous.fields.values : [] %}
{% if list.fields|length > 0 or previousFields|length > 0 %}
@@ -184,9 +171,9 @@
{{ 'Category'|trans }}
- {{ revision.category|trans }}
+ {{ revision.category|trans }}
{% if previous is not null and previous.category != revision.category %}
- ({{ 'was'|trans }} {{ previous.category|trans }})
+ ({{ 'was'|trans }} {{ previous.category|trans }})
{% endif %}
@@ -329,13 +316,10 @@
{% endif %}
{{ form_start(decisionForm, {'action': path('admin/activities/approvals/decide', {'revision': revision.getId})}) }}
- {# The message field is only present for a decision (reviewer feedback) or a resubmission
- (the organiser's required response); a fresh submit / start review / close gets no field. #}
+ {# The message field is only present for a decision (feedback) or a resubmission (required response); a fresh submit / start review / close has none. #}
{% if decisionForm.message is defined %}
{{ form_row(decisionForm.message) }}
{% endif %}
- {# The progressing action is prominent (full-width, solid); the negative actions are smaller
- outline buttons inline below it. Each tier self-sizes to whatever the workflow enabled. #}
{% if decisionForm.submit is defined %}
{{ form_widget(decisionForm.submit, {'attr': {'class': 'btn btn-success'}}) }}
diff --git a/templates/activity/admin/edit.html.twig b/templates/activity/admin/edit.html.twig
index b8d5e7e772..417200b294 100644
--- a/templates/activity/admin/edit.html.twig
+++ b/templates/activity/admin/edit.html.twig
@@ -45,7 +45,6 @@
{% if hasFeedback %}
- {# A sidebar so the reviewer's feedback stays visible no matter which step the editor is on. #}
- {# Shared confirmation modal for the draw, kept outside the live component so its re-render can't orphan it. #}
{% include 'partials/application/confirm-modal.html.twig' %}
{% endblock %}
diff --git a/templates/activity/archive-my.html.twig b/templates/activity/archive-my.html.twig
index d67a4a55ff..5fcf551030 100644
--- a/templates/activity/archive-my.html.twig
+++ b/templates/activity/archive-my.html.twig
@@ -7,6 +7,8 @@
{% if activity.getCategory.value not in ['other', 'uncategorised'] %}
- {{ activity.getCategory|trans }}
+ {{ activity.getCategory|trans }}
{% endif %}
{% for label in activity.getLabels %}
{{ label.getName|localise_text }}
@@ -126,7 +126,6 @@
{% if activeLists is not empty %}
{{ 'Sign-up lists'|trans }}
- {# Auto-open the first list that is still open for sign-up; leave the rest collapsed. #}
{% set openedFirst = false %}
{% for list in activeLists %}
{% set autoOpen = not openedFirst and list.isOpen %}
@@ -151,8 +150,6 @@
{% endif %}
{% if is_granted('ROLE_USER') %}
- {# Shared confirm-modal driving each list component's unsubscribe live action; rendered once per page,
- OUTSIDE the live components. #}
{% include 'partials/application/confirm-modal.html.twig' %}
{% endif %}
{% endif %}
diff --git a/templates/components/Activity/ActivityOverview.html.twig b/templates/components/Activity/ActivityOverview.html.twig
index d5e093d9c0..9e571d8ea9 100644
--- a/templates/components/Activity/ActivityOverview.html.twig
+++ b/templates/components/Activity/ActivityOverview.html.twig
@@ -1,13 +1,7 @@
-
-
-
+ {# data-live-ignore: keeps the injected label chips intact across re-renders. #}
+
+
@@ -58,10 +52,6 @@
{% if this.labels is not empty %}
- {# Same removable-chip control as the activity form. It only hides and toggles the checkboxes, so
- the data-model="labelFilters" binding keeps driving the LiveProp; it sits inside the
- data-live-ignore panel above, so a re-render never clobbers the injected chips. The field
- label stays outside the controller element so it is not displaced by the injected chips. #}
{% if this.past %}
- {% for year, activities in this.activitiesByAssociationYear %}
-
{{ year }}
-
+
+ {% for activities in this.activitiesByAssociationYear %}
+ {% set currentMonth = null %}
{% for activity in activities %}
+ {% set month = activity.getBeginTime|format_datetime(pattern: 'MMMM yyyy', locale: app.request.locale) %}
+ {% if month != currentMonth %}
+
{{ month }}
+ {% set currentMonth = month %}
+ {% endif %}
{% include 'partials/activity/list-item.html.twig' with {'activity': activity} only %}
{% endfor %}
-
- {% else %}
-
{{ 'No activities match the current filters.'|trans }}
- {% endfor %}
+ {% else %}
+
{{ 'No activities match the current filters.'|trans }}
+ {% endfor %}
+
{% else %}
+ {% set currentMonth = null %}
{% for activity in this.activities %}
+ {% set month = activity.getBeginTime|format_datetime(pattern: 'MMMM yyyy', locale: app.request.locale) %}
+ {% if month != currentMonth %}
+
{{ month }}
+ {% set currentMonth = month %}
+ {% endif %}
{% include 'partials/activity/list-item.html.twig' with {'activity': activity} only %}
{% else %}
{{ 'No activities match the current filters.'|trans }}
diff --git a/templates/components/Activity/Admin/ActivityOverview.html.twig b/templates/components/Activity/Admin/ActivityOverview.html.twig
index 4e8f48cf5f..476a00bcf2 100644
--- a/templates/components/Activity/Admin/ActivityOverview.html.twig
+++ b/templates/components/Activity/Admin/ActivityOverview.html.twig
@@ -105,7 +105,6 @@
{{ 'Submit for review'|trans }}
{% elseif row.status.value == 'rejected' %}
- {# A closed revision is final (the board ended it), so it offers no reopen. #}
- {# Potentially thousands of rows: collapsed by default and paginated. Collapse is driven by the `expanded` live
- prop (not Bootstrap's client-side collapse) so it survives the Ajax re-render that pagination triggers. #}
+ {# Collapse is driven by the `expanded` live prop, not Bootstrap, so it survives the Ajax re-render pagination triggers. #}