diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro
index 2fc28c559f..924b009989 100644
--- a/src/layouts/Default.astro
+++ b/src/layouts/Default.astro
@@ -19,7 +19,6 @@ import ArticleNav from '../components/ArticleNav.astro';
import Header from '../components/Header.astro';
import Related from '../components/Related.astro';
import ArticleJourney from '../components/ArticleJourney.astro';
-import Feedback from '../components/Feedback.astro';
import EditOnGithub from '../components/EditOnGithub.astro';
import CopyMarkdown from '../components/CopyMarkdown.astro';
import Plausible from 'src/components/Plausible.astro';
@@ -91,11 +90,6 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
-
diff --git a/src/scripts/modules/headers.js b/src/scripts/modules/headers.js
index dfdcdf7c18..5509b6bafd 100644
--- a/src/scripts/modules/headers.js
+++ b/src/scripts/modules/headers.js
@@ -5,8 +5,8 @@ import { copyOnClick } from './copy-button.js';
const REST = 'Copy URL';
/**
- * Scoped to .page-content headings with an id: the feedback prompt and the
- * navigation render their own headings, and those have nothing to link to.
+ * Scoped to .page-content headings with an id: the navigation renders its own
+ * headings, and those have nothing to link to.
*
* Each heading is given an aria-label of its own text. A heading is named from
* its contents, and those contents include a nested control's name, so without
From b72d88aae7d254b3b9ff5cf343f3013e5bbb9497 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Mon, 10 Aug 2026 15:44:44 +1200
Subject: [PATCH 02/10] Add the feedback widget to the table of contents column
Yes/No vote, an optional comment, and a thank you, built to the Figma
design and sitting under the article navigation.
Submissions go to the existing Google Form. It has no yes/no field, so
Yes maps to 5 and No to 1 on its 1-5 scale, and the comment field is
marked required there, so an empty box sends a space. Google Forms sends
no CORS headers, so the POST goes out as no-cors and the response is
opaque - the thank you is optimistic.
Co-Authored-By: Claude Opus 5 (1M context)
---
src/assets/icons/thumbs-down.svg | 6 +++
src/assets/icons/thumbs-up.svg | 6 +++
src/components/Feedback.astro | 61 +++++++++++++++++++++
src/data/language.json | 20 +++++++
src/layouts/Default.astro | 2 +
src/scripts/main.js | 2 +
src/scripts/modules/feedback.js | 92 ++++++++++++++++++++++++++++++++
src/styles/button.css | 5 ++
src/styles/main.css | 73 +++++++++++++++++++++++++
9 files changed, 267 insertions(+)
create mode 100644 src/assets/icons/thumbs-down.svg
create mode 100644 src/assets/icons/thumbs-up.svg
create mode 100644 src/components/Feedback.astro
create mode 100644 src/scripts/modules/feedback.js
diff --git a/src/assets/icons/thumbs-down.svg b/src/assets/icons/thumbs-down.svg
new file mode 100644
index 0000000000..52aa171be6
--- /dev/null
+++ b/src/assets/icons/thumbs-down.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/src/assets/icons/thumbs-up.svg b/src/assets/icons/thumbs-up.svg
new file mode 100644
index 0000000000..cda73f907a
--- /dev/null
+++ b/src/assets/icons/thumbs-up.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/src/components/Feedback.astro b/src/components/Feedback.astro
new file mode 100644
index 0000000000..d9c1862a1a
--- /dev/null
+++ b/src/components/Feedback.astro
@@ -0,0 +1,61 @@
+---
+import { accelerator } from '@lib/accelerator';
+import { Lang, Translations } from '@util/Languages';
+import Button from './Button.astro';
+
+const stats = new accelerator.statistics('components/Feedback.astro');
+stats.start();
+
+// Properties
+type Props = {
+ lang: string;
+};
+const { lang } = Astro.props satisfies Props;
+
+// Language
+const _ = Lang(lang);
+
+stats.stop();
+---
+
+
+
+
+ {_(Translations.octopus_feedback.question)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {_(Translations.octopus_feedback.thanks)}
+
+
diff --git a/src/data/language.json b/src/data/language.json
index 0d074fd80a..e7934969fc 100644
--- a/src/data/language.json
+++ b/src/data/language.json
@@ -28,6 +28,26 @@
"en": "Legal and support"
}
},
+ "octopus_feedback": {
+ "question": {
+ "en": "Was this page helpful?"
+ },
+ "yes": {
+ "en": "Yes"
+ },
+ "no": {
+ "en": "No"
+ },
+ "comment_label": {
+ "en": "Why did you give this rating? (optional)"
+ },
+ "send": {
+ "en": "Send"
+ },
+ "thanks": {
+ "en": "Thanks for your feedback!"
+ }
+ },
"octopus_github": {
"edit_on_github": {
"en": "Edit on GitHub"
diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro
index 924b009989..c1268ac8b2 100644
--- a/src/layouts/Default.astro
+++ b/src/layouts/Default.astro
@@ -16,6 +16,7 @@ import Taxonomy from '@components/Taxonomy.astro';
// Custom components
import ArticleHeader from '../components/ArticleHeader.astro';
import ArticleNav from '../components/ArticleNav.astro';
+import Feedback from '../components/Feedback.astro';
import Header from '../components/Header.astro';
import Related from '../components/Related.astro';
import ArticleJourney from '../components/ArticleJourney.astro';
@@ -119,6 +120,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
lang={lang}
/> -->
+
diff --git a/src/scripts/main.js b/src/scripts/main.js
index 49c941da5f..6502d07c5c 100644
--- a/src/scripts/main.js
+++ b/src/scripts/main.js
@@ -12,6 +12,7 @@ import { setExternalLinkAttributes } from './modules/external-links.js';
import { monitorInputType } from './modules/input-type.js';
import { enableSharing } from './modules/share.js';
import { highlightCurrentHeading } from './modules/toc.js';
+import { enhanceFeedback } from './modules/feedback.js';
const resizedEventName = addResizedEvent();
@@ -29,6 +30,7 @@ monitorInputType();
enableSharing();
highlightCurrentHeading('.page-toc a');
highlightCurrentHeading('.article-nav a');
+enhanceFeedback();
// @ts-ignore
const f = site_features ?? {};
diff --git a/src/scripts/modules/feedback.js b/src/scripts/modules/feedback.js
new file mode 100644
index 0000000000..e8275f6143
--- /dev/null
+++ b/src/scripts/modules/feedback.js
@@ -0,0 +1,92 @@
+// @ts-check
+import { qs, qsa } from './query.js';
+
+// The Google Form behind the widget. Its three fields, read off the live form:
+// entry.336432709 "Which page did you view?" short text, optional
+// entry.128617088 "How useful was the content?" linear scale 1-5, required
+// entry.434783109 "...what we did well, or what we could improve" required
+// Yes maps to 5 and No to 1 - the form has no yes/no field to send to.
+const FORM_URL =
+ 'https://docs.google.com/forms/d/e/1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg/formResponse';
+const FIELD_PAGE = 'entry.336432709';
+const FIELD_RATING = 'entry.128617088';
+const FIELD_COMMENT = 'entry.434783109';
+
+/**
+ * Google Forms sends no CORS headers, so the POST has to go out as no-cors and
+ * the response comes back opaque. There is no way to read whether the form
+ * accepted it - a rejected submission looks identical to an accepted one. The
+ * widget therefore reports success optimistically.
+ *
+ * @param {string} rating
+ * @param {string} comment
+ * @returns {Promise}
+ */
+async function submit(rating, comment) {
+ const body = new URLSearchParams();
+ body.set(FIELD_PAGE, window.location.href);
+ body.set(FIELD_RATING, rating);
+ // The comment is marked required on the form, so a blank box still has to
+ // send something for the submission to be accepted at all.
+ body.set(FIELD_COMMENT, comment.trim() || ' ');
+
+ await fetch(FORM_URL, { method: 'POST', mode: 'no-cors', body });
+}
+
+class Feedback {
+ /** @param {HTMLElement} root */
+ constructor(root) {
+ this.root = root;
+ this.votes = qsa('[data-feedback-vote]', root);
+ this.comment = qs('[data-feedback-comment]', root);
+ this.textarea = qs('textarea', root);
+ this.send = qs('[data-feedback-send]', root);
+ this.thanks = qs('[data-feedback-thanks]', root);
+ /** @type {string | null} */
+ this.rating = null;
+
+ this.addListeners();
+ }
+
+ addListeners() {
+ this.votes.forEach((button) => {
+ button.addEventListener('click', () => this.vote(button));
+ });
+ this.send.addEventListener('click', () => this.submit());
+ }
+
+ /** @param {HTMLElement} chosen */
+ vote(chosen) {
+ this.rating = chosen.dataset.feedbackVote ?? null;
+ this.votes.forEach((button) => {
+ button.setAttribute('aria-pressed', String(button === chosen));
+ });
+ this.comment.hidden = false;
+ }
+
+ async submit() {
+ if (!this.rating) return;
+
+ // Guards against a second submission while the first is in flight.
+ this.send.setAttribute('disabled', '');
+
+ try {
+ await submit(this.rating, this.textarea.value);
+ } catch (err) {
+ // A network-level failure. Nothing useful to offer the reader here, and
+ // the design has no error state, so the thank you still shows.
+ console.warn('[feedback] submission failed', err);
+ }
+
+ this.root.querySelectorAll('.feedback__vote, .feedback__comment').forEach(
+ /** @param {Element} el */ (el) => {
+ /** @type {HTMLElement} */ (el).hidden = true;
+ }
+ );
+ this.thanks.hidden = false;
+ }
+}
+
+export function enhanceFeedback() {
+ qsa('[data-feedback]').forEach((root) => new Feedback(root));
+}
diff --git a/src/styles/button.css b/src/styles/button.css
index bbefb2c05f..07fc082e05 100644
--- a/src/styles/button.css
+++ b/src/styles/button.css
@@ -80,6 +80,11 @@ img.btn__icon {
background: var(--colorBackgroundPrimaryPressed);
}
+/* A toggle button that is currently on holds the pressed background. */
+.btn[aria-pressed='true']:not(:disabled, [aria-disabled='true']) {
+ background: var(--colorBackgroundPrimaryPressed);
+}
+
.btn:is(:disabled, [aria-disabled='true']) {
background: var(--colorButtonBackgroundDisabled);
border-color: var(--colorButtonBorderDisabled);
diff --git a/src/styles/main.css b/src/styles/main.css
index ab43dcb6d3..201c3f5ca5 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1799,6 +1799,79 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
mask: url('../assets/icons/github.svg') center / contain no-repeat;
}
+/* Feedback */
+
+.feedback {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space16);
+ color: var(--colorTextPrimary);
+ font: var(--textBodyRegularMedium);
+}
+
+.feedback__vote,
+.feedback__comment {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space8);
+ width: 100%;
+}
+
+.feedback__actions {
+ display: flex;
+ gap: var(--space8);
+}
+
+/* The exported icons carry a fixed fill, so they are masked to take a themed
+ color. 20px slot, 16x14 glyph, as the design insets them. */
+.feedback__icon {
+ background-color: var(--colorIconPrimary);
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: 1rem 0.875rem;
+}
+
+.feedback__icon--yes {
+ mask-image: url('../assets/icons/thumbs-up.svg');
+}
+
+.feedback__icon--no {
+ mask-image: url('../assets/icons/thumbs-down.svg');
+}
+
+/* The design calls this out: the label stays regular weight. */
+.feedback__label {
+ font: var(--textBodyRegularMedium);
+}
+
+.feedback__textarea {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 4.375rem;
+ padding: var(--space8);
+ border: var(--borderWidth1) solid var(--colorBorderBold);
+ border-radius: var(--borderRadiusSmall);
+ background: var(--colorBackgroundPrimaryDefault);
+ color: var(--colorTextPrimary);
+ font: var(--textBodyRegularMedium);
+ resize: vertical;
+}
+
+.feedback__textarea:focus-visible {
+ outline: var(--borderWidth2) solid var(--colorBorderSelected);
+ outline-offset: var(--borderWidth1);
+}
+
+/* The table of contents column restacks above the article below this width, so
+ the widget would ask the question before anything had been read. */
+@media (max-width: 1130px) {
+ .feedback {
+ display: none;
+ }
+}
+
/* "Use Octopus docs with AI" dropdown */
.octo-copy-md {
margin-block-start: var(--block-gap);
From f01be3284f3dcb9f0956669e0e654bf50d1a538d Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 11:53:45 +1200
Subject: [PATCH 03/10] Match the feedback widget to the design
Three things were off against the Figma component and its placement in the
table of contents column:
The comment box, the send button and the thank you were all on screen at
once. `[hidden]` is a user agent rule, so the component's own `display: flex`
was winning and no state was ever hidden.
The widget sat flush against the last navigation link and against the left
edge of the column. The design gives it 32px of air and puts it on the same
16px inset the navigation title and links share.
The thumbs down glyph faced the same way as the thumbs up. The design mirrors
it so the pair face each other.
---
src/styles/main.css | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src/styles/main.css b/src/styles/main.css
index 201c3f5ca5..026631e750 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1806,6 +1806,8 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
flex-direction: column;
align-items: flex-start;
gap: var(--space16);
+ margin-block-start: var(--space32);
+ padding-inline-start: var(--space16);
color: var(--colorTextPrimary);
font: var(--textBodyRegularMedium);
}
@@ -1819,6 +1821,11 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
width: 100%;
}
+/* Two classes, so this beats the `display: flex` above. */
+.feedback [hidden] {
+ display: none;
+}
+
.feedback__actions {
display: flex;
gap: var(--space8);
@@ -1837,8 +1844,10 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
mask-image: url('../assets/icons/thumbs-up.svg');
}
+/* Mirrored, as the design pairs the thumbs facing each other. */
.feedback__icon--no {
mask-image: url('../assets/icons/thumbs-down.svg');
+ transform: scaleX(-1);
}
/* The design calls this out: the label stays regular weight. */
From 14ba4e1e375bfd12a6e8ff80cd32fcc2503fe0e5 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 14:52:07 +1200
Subject: [PATCH 04/10] Stop thanking the reader for feedback that never sent
The submission goes out as no-cors, so an HTTP rejection is unreadable and
there is no way to tell an accepted response from a refused one. A request
that never leaves the browser is a different case: fetch rejects, and that
was being caught and discarded on the way to the thank you.
Being offline does it. So does an ad blocker, and `formResponse` is on
several blocklists, so this is not a rare path. The reader was told their
feedback had been sent while it went nowhere.
The thank you now only shows once the request has actually gone out. On a
rejection the form stays up with send live again, which is as far as the
design goes - it has no error state to show.
The rating values move into the module beside the field ids so the component
and the form contract test share one source for them.
---
src/components/Feedback.astro | 5 +++--
src/scripts/modules/feedback.js | 24 +++++++++++++++---------
2 files changed, 18 insertions(+), 11 deletions(-)
diff --git a/src/components/Feedback.astro b/src/components/Feedback.astro
index d9c1862a1a..63562e8aaa 100644
--- a/src/components/Feedback.astro
+++ b/src/components/Feedback.astro
@@ -2,6 +2,7 @@
import { accelerator } from '@lib/accelerator';
import { Lang, Translations } from '@util/Languages';
import Button from './Button.astro';
+import { RATING_NO, RATING_YES } from '../scripts/modules/feedback.js';
const stats = new accelerator.statistics('components/Feedback.astro');
stats.start();
@@ -29,14 +30,14 @@ stats.stop();
icon="feedback__icon feedback__icon--yes"
size="small"
aria-pressed="false"
- data-feedback-vote="5"
+ data-feedback-vote={RATING_YES}
/>
diff --git a/src/scripts/modules/feedback.js b/src/scripts/modules/feedback.js
index e8275f6143..bb2f77543f 100644
--- a/src/scripts/modules/feedback.js
+++ b/src/scripts/modules/feedback.js
@@ -6,17 +6,22 @@ import { qs, qsa } from './query.js';
// entry.128617088 "How useful was the content?" linear scale 1-5, required
// entry.434783109 "...what we did well, or what we could improve" required
// Yes maps to 5 and No to 1 - the form has no yes/no field to send to.
-const FORM_URL =
- 'https://docs.google.com/forms/d/e/1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg/formResponse';
-const FIELD_PAGE = 'entry.336432709';
-const FIELD_RATING = 'entry.128617088';
-const FIELD_COMMENT = 'entry.434783109';
+export const FORM_ID =
+ '1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg';
+export const FIELD_PAGE = 'entry.336432709';
+export const FIELD_RATING = 'entry.128617088';
+export const FIELD_COMMENT = 'entry.434783109';
+export const RATING_YES = '5';
+export const RATING_NO = '1';
+
+const FORM_URL = `https://docs.google.com/forms/d/e/${FORM_ID}/formResponse`;
/**
* Google Forms sends no CORS headers, so the POST has to go out as no-cors and
* the response comes back opaque. There is no way to read whether the form
- * accepted it - a rejected submission looks identical to an accepted one. The
- * widget therefore reports success optimistically.
+ * accepted it - a rejected submission looks identical to an accepted one, so
+ * anything Google answered at all counts as accepted. tests/feedback.spec.ts
+ * checks the form's fields in place of the response nobody can read.
*
* @param {string} rating
* @param {string} comment
@@ -73,9 +78,10 @@ class Feedback {
try {
await submit(this.rating, this.textarea.value);
} catch (err) {
- // A network-level failure. Nothing useful to offer the reader here, and
- // the design has no error state, so the thank you still shows.
+ // Offline, or blocked by an extension - it never left the browser.
console.warn('[feedback] submission failed', err);
+ this.send.removeAttribute('disabled');
+ return;
}
this.root.querySelectorAll('.feedback__vote, .feedback__comment').forEach(
From f69f3c43122c427d2eb190a35407438c804f5082 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 14:52:19 +1200
Subject: [PATCH 05/10] Test the feedback widget and the form contract behind
it
The widget cannot read whether Google accepted a submission, so the failure
it is most exposed to is silent: someone edits the form, an entry id changes
or a new required question appears, and every submission is refused with
nothing on the page to show it.
Three checks read the form definition off the live viewform page and assert
what the widget depends on - that all three fields it posts to still exist,
that the rating scale still accepts the two values it sends, and that no
required question has appeared that it does not answer. The ids come from the
module itself, so the test cannot drift away from the code.
Five more cover the widget: the progressive disclosure the design asks for,
the rating moving between the buttons, the thank you on a submission that
went out, and the form staying up on one that did not.
These need a scheduled run to catch form drift before a reader hits it. No
workflow here runs Playwright yet.
---
tests/feedback.spec.ts | 146 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 146 insertions(+)
create mode 100644 tests/feedback.spec.ts
diff --git a/tests/feedback.spec.ts b/tests/feedback.spec.ts
new file mode 100644
index 0000000000..f4080236c1
--- /dev/null
+++ b/tests/feedback.spec.ts
@@ -0,0 +1,146 @@
+import { test, expect } from '@playwright/test';
+import {
+ FIELD_COMMENT,
+ FIELD_PAGE,
+ FIELD_RATING,
+ FORM_ID,
+ RATING_NO,
+ RATING_YES,
+} from '../src/scripts/modules/feedback.js';
+
+const PAGE = '/docs/kubernetes/steps/kustomize';
+const VIEW_FORM = `https://docs.google.com/forms/d/e/${FORM_ID}/viewform`;
+
+const SENT = [FIELD_PAGE, FIELD_RATING, FIELD_COMMENT];
+
+type Field = { id: string; options: string[]; required: boolean };
+
+/**
+ * Google's form definition is positional and undocumented: a question is
+ * [id, title, description, type, entries], and an entry is
+ * [fieldId, options, required].
+ */
+function fields(html: string): Map {
+ const payload = /FB_PUBLIC_LOAD_DATA_ = ([\s\S]*?);<\/script>/.exec(html);
+ if (!payload) {
+ throw new Error('The form page carries no FB_PUBLIC_LOAD_DATA_');
+ }
+
+ const found = new Map();
+
+ for (const question of JSON.parse(payload[1])?.[1]?.[1] ?? []) {
+ for (const entry of question?.[4] ?? []) {
+ const id = `entry.${entry[0]}`;
+ found.set(id, {
+ id,
+ options: (entry[1] ?? []).map((option: string[]) => option[0]),
+ required: entry[2] === 1,
+ });
+ }
+ }
+
+ return found;
+}
+
+// The widget posts with mode: 'no-cors', so it can never read whether Google
+// accepted a submission. These stand in for the response: they fail on the
+// drift that would make the form start rejecting what the widget sends.
+test.describe('the Google Form behind the feedback widget', () => {
+ let found: Map;
+
+ // `request` is test scoped, so beforeAll builds its own context.
+ test.beforeAll(async ({ playwright }) => {
+ const request = await playwright.request.newContext();
+ const response = await request.get(VIEW_FORM);
+ expect(response.status()).toBe(200);
+ found = fields(await response.text());
+ await request.dispose();
+ });
+
+ test('still has every field the widget posts to', () => {
+ expect([...found.keys()]).toEqual(expect.arrayContaining(SENT));
+ });
+
+ test('still accepts both ratings the widget sends', () => {
+ expect(found.get(FIELD_RATING)?.options).toEqual(
+ expect.arrayContaining([RATING_YES, RATING_NO])
+ );
+ });
+
+ test('has gained no required field the widget leaves out', () => {
+ const unanswered = [...found.values()]
+ .filter((field) => field.required)
+ .map((field) => field.id)
+ .filter((id) => !SENT.includes(id));
+
+ expect(unanswered).toEqual([]);
+ });
+});
+
+test.describe('feedback widget', () => {
+ test.beforeEach(async ({ page }) => {
+ // Blocked for every test, so no run can post to the live form. The success
+ // case below overrides this: Playwright matches the newest route first.
+ await page.route('**/docs.google.com/**', (route) => route.abort());
+ await page.goto(PAGE);
+ });
+
+ test('asks the question alone until a rating is given', async ({ page }) => {
+ await expect(page.locator('.feedback__question')).toBeVisible();
+ await expect(page.locator('.feedback__comment')).toBeHidden();
+ await expect(page.locator('.feedback__thanks')).toBeHidden();
+ });
+
+ test('discloses the comment box on a rating', async ({ page }) => {
+ await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
+
+ await expect(page.locator('.feedback__comment')).toBeVisible();
+ await expect(
+ page.locator(`[data-feedback-vote="${RATING_YES}"]`)
+ ).toHaveAttribute('aria-pressed', 'true');
+ await expect(
+ page.locator(`[data-feedback-vote="${RATING_NO}"]`)
+ ).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ test('moves the rating to the other button', async ({ page }) => {
+ await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
+ await page.locator(`[data-feedback-vote="${RATING_NO}"]`).click();
+
+ await expect(
+ page.locator(`[data-feedback-vote="${RATING_YES}"]`)
+ ).toHaveAttribute('aria-pressed', 'false');
+ await expect(
+ page.locator(`[data-feedback-vote="${RATING_NO}"]`)
+ ).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ test('thanks the reader once the submission has gone out', async ({
+ page,
+ }) => {
+ await page.route('**/docs.google.com/**', (route) =>
+ route.fulfill({ status: 200, body: '' })
+ );
+
+ await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
+ await page.locator('[data-feedback-send]').click();
+
+ await expect(page.locator('.feedback__thanks')).toBeVisible();
+ await expect(page.locator('.feedback__vote')).toBeHidden();
+ await expect(page.locator('.feedback__comment')).toBeHidden();
+ });
+
+ test('keeps the form up when the submission never leaves the browser', async ({
+ page,
+ }) => {
+ await page.locator(`[data-feedback-vote="${RATING_NO}"]`).click();
+ await page.locator('[data-feedback-send]').click();
+
+ // Send is disabled for the attempt and only comes back on the failure, so
+ // this settles after the handler has run. The two checks below would each
+ // pass against a handler that had not reached its catch yet.
+ await expect(page.locator('[data-feedback-send]')).toBeEnabled();
+ await expect(page.locator('.feedback__thanks')).toBeHidden();
+ await expect(page.locator('.feedback__vote')).toBeVisible();
+ });
+});
From 796ea581a66ef21d15e251d9333b61601198a9c6 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 15:07:21 +1200
Subject: [PATCH 06/10] Inset the feedback widget from the rest of the sidebar
column
It read as part of the navigation above it rather than as its own block.
space16 on all four sides of the widget, and a further space16 above to
separate it from the navigation. The two compose to the 32px the design puts
between the last navigation link and the question, and the left inset still
lands the question on the same line as the "On this page" title.
---
src/styles/main.css | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/styles/main.css b/src/styles/main.css
index 026631e750..4b1bf09690 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1806,8 +1806,8 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
flex-direction: column;
align-items: flex-start;
gap: var(--space16);
- margin-block-start: var(--space32);
- padding-inline-start: var(--space16);
+ margin-block-start: var(--space16);
+ padding: var(--space16);
color: var(--colorTextPrimary);
font: var(--textBodyRegularMedium);
}
From 526c07112a92fcf473af04e99ea7db31b03ebf55 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 16:10:18 +1200
Subject: [PATCH 07/10] Move the feedback styles into the component
Astro scopes a component style block to the component, so the widget carries
its own styles rather than spreading them through the global sheet.
The three icon rules need :global. Button.astro renders the span that takes
the mask, so the scope attribute on it belongs to Button, not to this
component - a plain scoped selector would not reach it and the thumbs would
lose their glyphs. Scoping them under .feedback keeps them off every other
button on the page.
---
src/components/Feedback.astro | 83 +++++++++++++++++++++++++++++++++++
src/styles/main.css | 82 ----------------------------------
2 files changed, 83 insertions(+), 82 deletions(-)
diff --git a/src/components/Feedback.astro b/src/components/Feedback.astro
index 63562e8aaa..8961f55c8e 100644
--- a/src/components/Feedback.astro
+++ b/src/components/Feedback.astro
@@ -60,3 +60,86 @@ stats.stop();
{_(Translations.octopus_feedback.thanks)}
+
+
diff --git a/src/styles/main.css b/src/styles/main.css
index 4b1bf09690..ab43dcb6d3 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1799,88 +1799,6 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
mask: url('../assets/icons/github.svg') center / contain no-repeat;
}
-/* Feedback */
-
-.feedback {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: var(--space16);
- margin-block-start: var(--space16);
- padding: var(--space16);
- color: var(--colorTextPrimary);
- font: var(--textBodyRegularMedium);
-}
-
-.feedback__vote,
-.feedback__comment {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: var(--space8);
- width: 100%;
-}
-
-/* Two classes, so this beats the `display: flex` above. */
-.feedback [hidden] {
- display: none;
-}
-
-.feedback__actions {
- display: flex;
- gap: var(--space8);
-}
-
-/* The exported icons carry a fixed fill, so they are masked to take a themed
- color. 20px slot, 16x14 glyph, as the design insets them. */
-.feedback__icon {
- background-color: var(--colorIconPrimary);
- mask-position: center;
- mask-repeat: no-repeat;
- mask-size: 1rem 0.875rem;
-}
-
-.feedback__icon--yes {
- mask-image: url('../assets/icons/thumbs-up.svg');
-}
-
-/* Mirrored, as the design pairs the thumbs facing each other. */
-.feedback__icon--no {
- mask-image: url('../assets/icons/thumbs-down.svg');
- transform: scaleX(-1);
-}
-
-/* The design calls this out: the label stays regular weight. */
-.feedback__label {
- font: var(--textBodyRegularMedium);
-}
-
-.feedback__textarea {
- box-sizing: border-box;
- width: 100%;
- min-height: 4.375rem;
- padding: var(--space8);
- border: var(--borderWidth1) solid var(--colorBorderBold);
- border-radius: var(--borderRadiusSmall);
- background: var(--colorBackgroundPrimaryDefault);
- color: var(--colorTextPrimary);
- font: var(--textBodyRegularMedium);
- resize: vertical;
-}
-
-.feedback__textarea:focus-visible {
- outline: var(--borderWidth2) solid var(--colorBorderSelected);
- outline-offset: var(--borderWidth1);
-}
-
-/* The table of contents column restacks above the article below this width, so
- the widget would ask the question before anything had been read. */
-@media (max-width: 1130px) {
- .feedback {
- display: none;
- }
-}
-
/* "Use Octopus docs with AI" dropdown */
.octo-copy-md {
margin-block-start: var(--block-gap);
From a00b78f24c9b42196f347b113d83b172195db660 Mon Sep 17 00:00:00 2001
From: William Laugesen
Date: Tue, 11 Aug 2026 16:20:02 +1200
Subject: [PATCH 08/10] Start the feedback widget from the component, not the
global script
main.js ran enhanceFeedback() on every page in the site, whether a feedback
widget was on it or not, and bundled the module into the entry point to do it.
Astro already has a place for behaviour that belongs to one component.
The module now wires itself up on import, so the component script is a bare
import with nothing to call. That means it can no longer be imported for its
constants - the import would run it, and Astro frontmatter and the Playwright
spec both evaluate in Node where there is no document. The form id, field ids
and rating values move to feedback-form.js, which is data and safe to import
anywhere; the component and the spec read them from there.
The widget code now ships as its own bundle, requested only by pages that
render it.
---
src/components/Feedback.astro | 6 +++++-
src/scripts/main.js | 2 --
src/scripts/modules/feedback-form.js | 16 ++++++++++++++++
src/scripts/modules/feedback.js | 25 +++++++------------------
tests/feedback.spec.ts | 2 +-
5 files changed, 29 insertions(+), 22 deletions(-)
create mode 100644 src/scripts/modules/feedback-form.js
diff --git a/src/components/Feedback.astro b/src/components/Feedback.astro
index 8961f55c8e..a47972a290 100644
--- a/src/components/Feedback.astro
+++ b/src/components/Feedback.astro
@@ -2,7 +2,7 @@
import { accelerator } from '@lib/accelerator';
import { Lang, Translations } from '@util/Languages';
import Button from './Button.astro';
-import { RATING_NO, RATING_YES } from '../scripts/modules/feedback.js';
+import { RATING_NO, RATING_YES } from '../scripts/modules/feedback-form.js';
const stats = new accelerator.statistics('components/Feedback.astro');
stats.start();
@@ -61,6 +61,10 @@ stats.stop();
+
+