Skip to content

Commit 667f2df

Browse files
committed
Add star-based scalar rating controls
1 parent 382759b commit 667f2df

8 files changed

Lines changed: 148 additions & 22 deletions

File tree

app/frontend/static/app.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,33 @@ function collectRatings() {
125125
})).filter((entry) => entry.rating > 0);
126126
}
127127

128+
function ratingCaption(value) {
129+
if (!value) {
130+
return "No rating selected yet";
131+
}
132+
if (value === 1) return "1 star selected";
133+
return `${value} stars selected`;
134+
}
135+
136+
function applyStarRating(candidateId, value) {
137+
const numericValue = Number(value || 0);
138+
const hiddenInput = document.querySelector(`.rating-input[data-candidate-id="${candidateId}"]`);
139+
if (hiddenInput) {
140+
hiddenInput.value = String(numericValue);
141+
}
142+
const buttons = Array.from(document.querySelectorAll(`.star-button[data-candidate-id="${candidateId}"]`));
143+
buttons.forEach((button) => {
144+
const buttonValue = Number(button.dataset.ratingValue || 0);
145+
const active = numericValue > 0 && buttonValue <= numericValue;
146+
button.classList.toggle("active", active);
147+
button.setAttribute("aria-pressed", active ? "true" : "false");
148+
});
149+
const caption = document.querySelector(`.star-rating-caption[data-candidate-id="${candidateId}"]`);
150+
if (caption) {
151+
caption.textContent = ratingCaption(numericValue);
152+
}
153+
}
154+
128155
function buildFeedbackPayload(feedbackMode) {
129156
if (feedbackMode === "pairwise") {
130157
const winner = document.querySelector(".pairwise-winner-input:checked")?.dataset.candidateId;
@@ -285,7 +312,7 @@ if (nextRoundButton) {
285312
await pollJob(job.status_url, {
286313
onProgress: (snapshot) => {
287314
setStatus(snapshot.status_message);
288-
setProgress(snapshot.progress, "Generating next round");
315+
setProgress(snapshot.progress, snapshot.status_message || "Generating next round");
289316
},
290317
});
291318
setStatus("Round generated. Refreshing session view...");
@@ -299,6 +326,16 @@ if (nextRoundButton) {
299326
});
300327
}
301328

329+
Array.from(document.querySelectorAll(".star-button")).forEach((button) => {
330+
button.addEventListener("click", () => {
331+
if (button.disabled) return;
332+
const candidateId = button.dataset.candidateId;
333+
const value = Number(button.dataset.ratingValue || 0);
334+
applyStarRating(candidateId, value);
335+
traceFrontend("feedback.rating.selected", { candidate_id: candidateId, rating: value });
336+
});
337+
});
338+
302339
const submitFeedbackButton = document.getElementById("submit-feedback-button");
303340
if (submitFeedbackButton) {
304341
traceFrontend("round.visible", { round_id: submitFeedbackButton.dataset.roundId });
@@ -321,7 +358,7 @@ if (submitFeedbackButton) {
321358
await pollJob(job.status_url, {
322359
onProgress: (snapshot) => {
323360
setStatus(snapshot.status_message);
324-
setProgress(snapshot.progress, "Applying feedback");
361+
setProgress(snapshot.progress, snapshot.status_message || "Applying feedback");
325362
},
326363
});
327364
setStatus("Feedback submitted. Refreshing session view...");

app/frontend/static/styles.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,52 @@ textarea { min-height: 100px; resize: vertical; }
8989
gap: 8px;
9090
margin-top: 10px;
9191
}
92+
.rating-widget {
93+
display: grid;
94+
gap: 8px;
95+
}
96+
.rating-label {
97+
font-weight: 600;
98+
}
99+
.star-rating {
100+
display: flex;
101+
align-items: center;
102+
gap: 6px;
103+
flex-wrap: wrap;
104+
}
105+
.star-button {
106+
width: 40px;
107+
height: 40px;
108+
border-radius: 999px;
109+
border: 1px solid var(--line);
110+
background: #fff8ef;
111+
color: #c7b49b;
112+
font-size: 1.3rem;
113+
line-height: 1;
114+
cursor: pointer;
115+
transition: transform 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease;
116+
}
117+
.star-button:hover:not(:disabled),
118+
.star-button:focus-visible:not(:disabled) {
119+
transform: translateY(-1px);
120+
border-color: var(--accent-2);
121+
color: var(--accent-2);
122+
outline: none;
123+
}
124+
.star-button.active {
125+
background: linear-gradient(180deg, #fff4cc, #ffd98a);
126+
border-color: #d28d2f;
127+
color: #9a5700;
128+
}
129+
.star-button:disabled {
130+
cursor: default;
131+
opacity: 0.75;
132+
}
133+
.star-rating-caption {
134+
margin: 0;
135+
color: var(--muted);
136+
font-size: 0.92rem;
137+
}
92138
.choice-row {
93139
display: flex;
94140
align-items: center;
@@ -165,4 +211,8 @@ code { background: #f2ebdf; padding: 2px 6px; border-radius: 8px; }
165211
@media (max-width: 700px) {
166212
.page { padding: 20px 14px 32px; }
167213
.section-head { display: block; }
214+
.star-button {
215+
width: 36px;
216+
height: 36px;
217+
}
168218
}

app/frontend/templates/session.html

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,24 @@ <h3>{{ candidate.id }}</h3>
5959
<p>z: {{ candidate.z }}</p>
6060
<div class="candidate-controls" data-feedback-mode="{{ session.config.feedback_mode }}">
6161
{% if session.config.feedback_mode == "scalar_rating" %}
62-
<label>Rating
63-
<input type="number" min="1" max="5" class="rating-input" data-candidate-id="{{ candidate.id }}" inputmode="numeric" {% if current_round.feedback_events %}disabled{% endif %}>
64-
</label>
62+
<div class="rating-widget" data-candidate-id="{{ candidate.id }}">
63+
<span class="rating-label">Rating</span>
64+
<div class="star-rating" role="radiogroup" aria-label="Rate candidate {{ candidate.id }}">
65+
{% for star in [1, 2, 3, 4, 5] %}
66+
<button
67+
type="button"
68+
class="star-button"
69+
data-candidate-id="{{ candidate.id }}"
70+
data-rating-value="{{ star }}"
71+
aria-label="{{ star }} star{% if star > 1 %}s{% endif %}"
72+
aria-pressed="false"
73+
{% if current_round.feedback_events %}disabled{% endif %}
74+
></button>
75+
{% endfor %}
76+
</div>
77+
<input type="hidden" class="rating-input" data-candidate-id="{{ candidate.id }}" value="0">
78+
<p class="star-rating-caption" data-candidate-id="{{ candidate.id }}">No rating selected yet</p>
79+
</div>
6580
{% elif session.config.feedback_mode == "pairwise" %}
6681
<label class="choice-row">
6782
<input type="radio" name="pairwise_winner" class="pairwise-winner-input" data-candidate-id="{{ candidate.id }}" {% if current_round.feedback_events %}disabled{% endif %}>
@@ -97,7 +112,7 @@ <h3>{{ candidate.id }}</h3>
97112
{% if not current_round.feedback_events %}
98113
<p class="hint">
99114
{% if session.config.feedback_mode == "scalar_rating" %}
100-
Rate one or more candidates from 1 to 5. The highest-rated candidate becomes the winner.
115+
Click one to five stars on any candidates you want to rate. Higher stars mean stronger preference.
101116
{% elif session.config.feedback_mode == "pairwise" %}
102117
Choose one winner and one loser.
103118
{% elif session.config.feedback_mode == "winner_only" %}

docs/developer_guide.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ During round generation and feedback submission:
239239

240240
This keeps the UI responsive while the real GPU-backed backend works in the background.
241241

242+
Current progress phrases are intentionally operation-specific. For example:
243+
244+
- round generation reports phases such as `Checking session readiness`, `Sampling 5 candidate directions`, `Rendering candidate images on the model backend`, and `Refreshing trace report and replay data`
245+
- feedback submission reports phases such as `Normalizing and validating user preferences`, `Updating the steering model from your feedback`, and `Feedback applied and next round unlocked`
246+
242247
![Session lifecycle diagram](./assets/illustrations/session_lifecycle.svg)
243248

244249
## 6. Core Extension Points
@@ -301,7 +306,7 @@ The current API quality contract is:
301306

302307
The current feedback-mode contract is:
303308

304-
- `scalar_rating` uses explicit rating inputs
309+
- `scalar_rating` uses explicit clickable star ratings
305310
- `pairwise` uses explicit winner and loser selection controls
306311
- `winner_only` uses an explicit winner selection control
307312
- `approve_reject` uses explicit approval checkboxes plus winner selection

docs/user_guide.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ When you generate a round or submit feedback, the session page shows:
8989
- a status label such as queueing, running, completed, or failed
9090
- inline error text if something goes wrong
9191

92+
The status text is phase-aware. You may see messages such as:
93+
94+
- `Checking session readiness`
95+
- `Sampling 5 candidate directions`
96+
- `Rendering candidate images on the model backend`
97+
- `Normalizing and validating user preferences`
98+
- `Updating the steering model from your feedback`
99+
- `Refreshing trace report and replay data`
100+
92101
While work is running:
93102

94103
- the relevant button is disabled
@@ -110,7 +119,7 @@ Each card shows:
110119

111120
The visible controls change with the selected feedback mode:
112121

113-
- `scalar_rating` shows rating inputs
122+
- `scalar_rating` shows clickable one-to-five star controls
114123
- `pairwise` shows explicit winner and loser choices
115124
- `winner_only` shows a single winner choice
116125
- `approve_reject` shows approval choices plus a preferred approved winner

site/docs/developer_guide.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ <h2 id="52-progress-behavior">5.2 Progress Behavior</h2>
221221
<li>impossible async requests are rejected before queueing when the session or round is already in a conflicting state</li>
222222
</ul>
223223
<p>This keeps the UI responsive while the real GPU-backed backend works in the background.</p>
224+
<p>Current progress phrases are intentionally operation-specific. For example:</p>
225+
<ul>
226+
<li>round generation reports phases such as <code>Checking session readiness</code>, <code>Sampling 5 candidate directions</code>, <code>Rendering candidate images on the model backend</code>, and <code>Refreshing trace report and replay data</code></li>
227+
<li>feedback submission reports phases such as <code>Normalizing and validating user preferences</code>, <code>Updating the steering model from your feedback</code>, and <code>Feedback applied and next round unlocked</code></li>
228+
</ul>
224229
<p><img alt="Session lifecycle diagram" src="assets/illustrations/session_lifecycle.svg"></p>
225230
<h2 id="6-core-extension-points">6. Core Extension Points</h2>
226231
<h3 id="61-add-a-sampler">6.1 Add a sampler</h3>
@@ -274,7 +279,7 @@ <h3 id="63-evolve-generation">6.3 Evolve generation</h3>
274279
</ul>
275280
<p>The current feedback-mode contract is:</p>
276281
<ul>
277-
<li><code>scalar_rating</code> uses explicit rating inputs</li>
282+
<li><code>scalar_rating</code> uses explicit clickable star ratings</li>
278283
<li><code>pairwise</code> uses explicit winner and loser selection controls</li>
279284
<li><code>winner_only</code> uses an explicit winner selection control</li>
280285
<li><code>approve_reject</code> uses explicit approval checkboxes plus winner selection</li>

site/docs/user_guide.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ <h2 id="41-progress-and-status">4.1 Progress and Status</h2>
9494
<li>a status label such as queueing, running, completed, or failed</li>
9595
<li>inline error text if something goes wrong</li>
9696
</ul>
97+
<p>The status text is phase-aware. You may see messages such as:</p>
98+
<ul>
99+
<li><code>Checking session readiness</code></li>
100+
<li><code>Sampling 5 candidate directions</code></li>
101+
<li><code>Rendering candidate images on the model backend</code></li>
102+
<li><code>Normalizing and validating user preferences</code></li>
103+
<li><code>Updating the steering model from your feedback</code></li>
104+
<li><code>Refreshing trace report and replay data</code></li>
105+
</ul>
97106
<p>While work is running:</p>
98107
<ul>
99108
<li>the relevant button is disabled</li>
@@ -113,7 +122,7 @@ <h2 id="5-understanding-the-candidate-cards">5. Understanding the Candidate Card
113122
</ul>
114123
<p>The visible controls change with the selected feedback mode:</p>
115124
<ul>
116-
<li><code>scalar_rating</code> shows rating inputs</li>
125+
<li><code>scalar_rating</code> shows clickable one-to-five star controls</li>
117126
<li><code>pairwise</code> shows explicit winner and loser choices</li>
118127
<li><code>winner_only</code> shows a single winner choice</li>
119128
<li><code>approve_reject</code> shows approval choices plus a preferred approved winner</li>

tests/e2e/app.spec.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,10 @@ test.describe("StableSteering browser flow", () => {
4141
await expect(page.locator(".image-card")).toHaveCount(5);
4242
await expect(page.locator(".image-card img").first()).toBeVisible();
4343

44-
const ratings = page.locator(".rating-input");
45-
await ratings.nth(0).fill("2");
46-
await ratings.nth(1).fill("5");
47-
await ratings.nth(2).fill("4");
48-
await ratings.nth(3).fill("3");
49-
await ratings.nth(4).fill("1");
44+
await page.locator('.star-button[data-candidate-id]').nth(1 * 5 + 4).click();
45+
await page.locator('.star-button[data-candidate-id]').nth(2 * 5 + 3).click();
46+
await page.locator('.star-button[data-candidate-id]').nth(3 * 5 + 2).click();
47+
await page.locator('.star-button[data-candidate-id]').nth(4 * 5 + 0).click();
5048
await page.getByRole("button", { name: "Submit feedback" }).click();
5149

5250
await expect(page.getByText("Status:")).toBeVisible();
@@ -70,12 +68,10 @@ test.describe("StableSteering browser flow", () => {
7068
await page.getByRole("button", { name: "Generate next round" }).click();
7169
await expect(page.getByRole("heading", { name: /Round 1/ })).toBeVisible();
7270

73-
const ratings = page.locator(".rating-input");
74-
await ratings.nth(0).fill("1");
75-
await ratings.nth(1).fill("4");
76-
await ratings.nth(2).fill("5");
77-
await ratings.nth(3).fill("3");
78-
await ratings.nth(4).fill("2");
71+
await page.locator('.star-button[data-candidate-id]').nth(1 * 5 + 3).click();
72+
await page.locator('.star-button[data-candidate-id]').nth(2 * 5 + 4).click();
73+
await page.locator('.star-button[data-candidate-id]').nth(3 * 5 + 2).click();
74+
await page.locator('.star-button[data-candidate-id]').nth(4 * 5 + 1).click();
7975
await page.getByRole("button", { name: "Submit feedback" }).click();
8076
await expect(page.getByText("Status:")).toBeVisible();
8177
await expect(page.getByRole("link", { name: "Open replay" })).toBeVisible();

0 commit comments

Comments
 (0)