diff --git a/app/assets/images/star-rating.gif b/app/assets/images/star-rating.gif
deleted file mode 100644
index 22edb43a4..000000000
Binary files a/app/assets/images/star-rating.gif and /dev/null differ
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index b12ee650b..5cc0d43c8 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -16,7 +16,7 @@
//= require rails-ujs
//= require activestorage
//= require chosen.jquery
-//= require 'jsimple-star-rating.min.js'
+//= require feedback-rating
//= require pickadate/picker
//= require pickadate/picker.date
//= require pickadate/picker.time
diff --git a/app/assets/javascripts/feedback-rating.js b/app/assets/javascripts/feedback-rating.js
new file mode 100644
index 000000000..232381c46
--- /dev/null
+++ b/app/assets/javascripts/feedback-rating.js
@@ -0,0 +1,62 @@
+// Progressively enhance a .rating container of radio buttons into clickable stars.
+
+(function () {
+ function initRating(container) {
+ const radios = container.querySelectorAll('input[type="radio"]');
+ const labels = container.querySelectorAll('label');
+
+ if (radios.length === 0 || labels.length === 0) return;
+
+ function updateVisuals(checkedValue) {
+ labels.forEach(function (label, index) {
+ label.classList.toggle('selected', index < checkedValue);
+ });
+ }
+
+ function previewVisuals(previewValue) {
+ labels.forEach(function (label, index) {
+ label.classList.toggle('preview', index < previewValue);
+ });
+ }
+
+ function restoreVisuals() {
+ const checked = Array.from(radios).find(function (radio) { return radio.checked; });
+ updateVisuals(checked ? parseInt(checked.value, 10) : 0);
+ previewVisuals(0);
+ }
+
+ labels.forEach(function (label, index) {
+ const radio = radios[index];
+
+ label.addEventListener('click', function (event) {
+ if (radio.checked) {
+ event.preventDefault();
+ radio.checked = false;
+ updateVisuals(0);
+ }
+ });
+
+ label.addEventListener('mouseenter', function () {
+ previewVisuals(index + 1);
+ });
+
+ radio.addEventListener('change', function () {
+ updateVisuals(index + 1);
+ });
+ });
+
+ container.addEventListener('mouseleave', restoreVisuals);
+
+ restoreVisuals();
+ }
+
+ function init() {
+ document.querySelectorAll('.rating').forEach(initRating);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/app/assets/javascripts/jsimple-star-rating.min.js b/app/assets/javascripts/jsimple-star-rating.min.js
deleted file mode 100644
index 9328209ec..000000000
--- a/app/assets/javascripts/jsimple-star-rating.min.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/*
- * ---------------------------------------- *
- * jSimple Star Rating.min *
- * JavaScript *
- * v2.0.0 *
- * Matt O'Neill | www.matt-oneill.co.uk *
- * ---------------------------------------- *
- */
-
-(function (e) { e.fn.starRating = function (t) { return this.each(function () { var n = e(this); n.append("
"); var r = e(this).children("ul"); for (var i = 0; i < n.data("rating-max") ; i++) { r.append("") } $ratingFieldItem = r.children(); var o = 0; $ratingFieldItem.on({ click: function () { if (e(this).index() + 1 != o) { o = e(this).index() + 1; n.attr("data-val", o); e("li:lt(" + (e(this).index() + 1) + ")", r).addClass("active"); e("li:gt(" + e(this).index() + ")", r).removeClass("active") } else { e(this).parent().children("li").removeClass("active"); n.attr("data-val", null); o = 0 } }, mouseenter: function () { e("li:lt(" + (e(this).index() + 1) + ")", r).addClass("hover"); e("li:gt(" + e(this).index() + ")", r).removeClass("hover") }, mouseleave: function () { e(this).parent().children("li:gt(" + e(this).index() + ")").removeClass("hover") } }); r.on({ mouseleave: function () { $ratingFieldItem.removeClass("hover") } }); if (t.minus) { n.prepend(""); e("span.less", n).on("click", function () { e("li.active:last", r).removeClass("active") }) } }) } })(jQuery)
\ No newline at end of file
diff --git a/app/assets/stylesheets/partials/_star-rating.scss b/app/assets/stylesheets/partials/_star-rating.scss
index 92c95c5af..75a8fed08 100644
--- a/app/assets/stylesheets/partials/_star-rating.scss
+++ b/app/assets/stylesheets/partials/_star-rating.scss
@@ -1,40 +1,37 @@
.rating {
- float: left;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
- ul {
- list-style: none;
- float: left;
+ .form-check-inline {
padding: 0;
margin: 0;
- li {
- float: left;
- margin-left: 2px;
- background: image-url("star-rating.gif") no-repeat;
- width: 25px;
- height: 25px;
+ .form-check-label {
cursor: pointer;
- }
+ font-size: 1.5rem;
+ line-height: 1;
+ color: #ffc107;
+ user-select: none;
- li:first-child {
- margin-left: 0;
- }
+ &::before {
+ content: '☆';
+ }
- li.hover {
- background-position: -25px;
+ &.selected::before,
+ &.preview::before {
+ content: '★';
+ }
}
- li.active {
- background-position: -50px;
+ input:checked + .form-check-label::before {
+ content: '★';
}
}
- span.less {
- cursor: pointer;
- background: image-url("star-rating.gif") -75px no-repeat;
- display: block;
- float: left;
- height: 25px;
- width: 25px;
+ input:focus-visible + .form-check-label {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ border-radius: 2px;
}
-}
\ No newline at end of file
+}
diff --git a/app/views/feedback/show.html.haml b/app/views/feedback/show.html.haml
index e46e35322..9ab0c9afc 100644
--- a/app/views/feedback/show.html.haml
+++ b/app/views/feedback/show.html.haml
@@ -17,12 +17,15 @@
= simple_form_for @feedback, url: submit_feedback_path(params[:id]), html: { method: 'patch' } do |f|
.row
= f.hidden_field :token, value: params[:id]
- = f.hidden_field :rating
.mb-3
%label.required
- %abbr(title='required') *
+ %abbr{ title: 'required' } *
= t('feedback_form.rating')
- .rating{:data => {:rating_max => 5 }}
+ .rating{ data: { rating_max: 5 } }
+ - (1..5).each do |value|
+ .form-check.form-check-inline
+ = f.radio_button :rating, value, id: "feedback_rating_#{value}", class: 'form-check-input visually-hidden'
+ %label.form-check-label{ for: "feedback_rating_#{value}" }
.col-lg-6
= f.association :coach, collection: @coaches, label_method: :full_name, value_method: :id, label: t('feedback.coach')
.col-lg-6
@@ -31,10 +34,3 @@
= f.input :suggestions, label: t('feedback_form.suggestions'), input_html: { rows: 3 }
.text-right
= f.button :button, 'Submit feedback', class: 'btn btn-primary'
-
-:javascript
- $('.rating').starRating({ minus: false });
-
- $('button[type="submit"]').on('click', function () {
- $('#feedback_rating').val($('.rating').data('val'))
- });
diff --git a/docs/plans/2026-08-25-2246-refactor-feedback-star-rating-plan.md b/docs/plans/2026-08-25-2246-refactor-feedback-star-rating-plan.md
new file mode 100644
index 000000000..43a841a79
--- /dev/null
+++ b/docs/plans/2026-08-25-2246-refactor-feedback-star-rating-plan.md
@@ -0,0 +1,198 @@
+---
+title: Feedback star rating replacement - Plan
+type: refactor
+date: 2026-08-25
+topic: feedback-star-rating
+artifact_contract: ce-unified-plan/v1
+artifact_readiness: implementation-ready
+product_contract_source: ce-brainstorm
+execution: code
+---
+
+# Feedback star rating replacement - Plan
+
+## Goal Capsule
+
+- **Objective:** Remove the abandoned jSimple Star Rating jQuery plugin and replace the member feedback form's rating control with a maintainable, progressively enhanced, dependency-free star-rating widget that preserves the existing click-to-rate and submit behaviour.
+- **Means:** A vanilla-JavaScript initializer progressively enhances server-rendered radio buttons into CSS-generated stars. (KTD1, KTD2)
+- **Product authority:** The maintainers of codebar planner.
+- **Open blockers:** None.
+
+## Product Contract
+
+### Summary
+
+Replace the hand-vendored jSimple Star Rating plugin used on the member feedback form with a progressively enhanced, vanilla-JavaScript star-rating control.
+The no-JS fallback will be a native form control (radio buttons), which JavaScript then enhances into clickable CSS-generated stars.
+This removes the unmaintained dependency and the inline `:javascript` HAML block while keeping the current star-rating interaction unchanged.
+
+### Problem Frame
+
+The feedback form currently depends on `app/assets/javascripts/jsimple-star-rating.min.js`, an abandoned third-party jQuery plugin (v2.0.0, no active maintenance).
+It is the only consumer of that plugin, yet it is loaded globally through the Sprockets manifest.
+The view also embeds initialization and submit-handling logic inline via the `:javascript` HAML filter, which mixes behaviour with markup and is hard to test.
+
+### Key Decisions
+
+- K1. Use a vanilla JavaScript initializer file in `app/assets/javascripts/` rather than a Stimulus controller or inline script. Governs R1, R2. The project has not yet wired Stimulus/Turbo and the widget appears on a single page, so a small initializer keeps the change focused and avoids introducing a new framework dependency.
+- K2. Preserve the existing visual interaction (up to five stars, click to set, click the same star to clear, hover preview) rather than redesigning the rating UI. Governs R3, R4. This is a maintenance refactor, not a visual redesign.
+- K3. Drive the widget from the existing `data-rating-max` attribute. Governs R2, R3, R5. The number of stars remains data-driven.
+- K4. Use progressive enhancement: server-render a native form control (radio buttons or a `