Vibecode Opinion Stage
track this build4 phases, 12 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Opinion Stage, create a poll, quiz, or survey and embed it with basic response analytics. The hard boundary is templates, hosted delivery, lead features, and engagement reporting, plus integrations, deliverability, and workflow depth.
You are building a lean indie version of Opinion Stage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Opinion Stage · indie build A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server rendering both the builder and the public form, no client framework on the public page | the embedded page has to work on a slow phone on a stranger's website | | Storage | SQLite with the response written in one transaction before any notification | the ordering is the whole guarantee | | Embedding | An iframe plus a tiny script that posts its height to the parent, with an origin allowlist | you cannot control the host page, and you should not want to | | Anti-spam | Honeypot, timing check and rate limiting, no third-party challenge | a challenge service means every respondent is reported to somebody else | | Accessibility | Labels, autofill tokens and a minimum target size, checked in the suite | a form nobody can complete collects nothing, and the criteria are published | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the builder, the public form and the notification worker in one process. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A small server with a domain and a certificate** · a few pounds a month plus the domain - Why: The form is embedded on other people's pages over a secure connection, so it needs a stable public address. - Get it: Any small virtual server with automatic certificates. - Verify: The form loads in an iframe on a test page over a secure connection - [ ] **An email sending path for owner notifications** · free tiers exist; check the current limits before relying on one - Why: Notifications are the one outbound dependency, and it must be able to fail without costing a response. - Get it: Any transactional sending service or an SMTP account you control. - Verify: A test notification arrives and a deliberate failure leaves the response intact - [ ] **A page you can embed into for testing** · free - Why: Height messaging, the framing header and blocked third-party storage are only testable from a genuinely different origin. - Get it: Any static page on a second domain or a local server on another port. - Verify: The embed resizes correctly and an unlisted origin is refused - [ ] **Your retention, minimum count and spam rules** · free - Why: Each becomes a test and a line in the privacy notice, and choosing them afterwards means choosing what the code already did. - Get it: Write down how long responses are kept, the minimum count for showing a percentage, and what makes a submission spam. - Verify: All three are in configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. - templates, hosted delivery, lead features, and engagement reporting - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Opinion Stage, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Opinion Stage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a poll, quiz and survey builder that embeds on sites you do not control and reports results without overclaiming to replace Opinion Stage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules. The first is operational and the second is about honesty. A submitted response is never lost. Somebody typed it once and will not type it again, so the response is written and committed before the thank-you screen is rendered, a retried submission with the same client identifier updates rather than inserts, and an email notification that fails never causes the response to be rolled back. Notification is a side effect; the row is the product. Second, results from an embedded poll are answers from people who chose to answer, which is not a sample of any population. The report says so in words, next to the numbers, always. Do not print a percentage without the count it came from, do not compute a margin of error for a self-selected poll, and if you build a personality-style quiz, the result is a description of the answers given and never a score dressed up as a measurement of the person. ### Stack (fixed, do not substitute) - Runtime: One server rendering both the builder and the public form, no client framework on the public page · the embedded page has to work on a slow phone on a stranger's website - Storage: SQLite with the response written in one transaction before any notification · the ordering is the whole guarantee - Embedding: An iframe plus a tiny script that posts its height to the parent, with an origin allowlist · you cannot control the host page, and you should not want to - Anti-spam: Honeypot, timing check and rate limiting, no third-party challenge · a challenge service means every respondent is reported to somebody else - Accessibility: Labels, autofill tokens and a minimum target size, checked in the suite · a form nobody can complete collects nothing, and the criteria are published ### Data model (create this before Phase 1) - forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect. ### Phase 1 · A form people can actually complete Build: Render questions from the schema with a real label element per control, help text and errors associated with the field, and the right autofill token on contact fields so a browser can fill them. Follow the published criteria: identifying input purpose is a level AA criterion and the autofill field names come from the HTML specification's list, labels or instructions is level A, and the minimum target size criterion added in the 2.2 recommendation asks for 24 by 24 CSS pixels unless the spacing exception applies. Check these in the suite rather than by eye. Trap: Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. Done when: Every control has an associated label and every error message is programmatically associated with its field, checked by an automated accessibility pass in the suite. Contact fields carry autofill tokens from the specification's list and a browser fills them in a manual check. Interactive targets meet the minimum size or satisfy the spacing exception, measured. The whole form can be completed with the keyboard alone, including option groups and the submit action. Do not build yet: No submissions, embedding, results or notifications yet. ### Phase 2 · A submission that cannot be lost or doubled Build: Generate a client key when the form loads and send it with the submission. Write the response and its answers in one transaction, then render the thank-you screen, then queue the notification. A submission arriving twice with the same client key updates the existing row. Limit body size, reject on the honeypot and on an implausibly fast completion, and never write submitted values into a log line. Trap: Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. Done when: A response survives a notification failure, tested by breaking the mail path and checking the row exists. Submitting the same client key twice produces one response, tested with a double click and with a retried request. An oversized body, a filled honeypot and a sub-second completion are each rejected without storing the payload, all three tested. No submitted value appears in any log line, asserted by scanning logs in a test. Do not build yet: No embedding, results or export yet. ### Phase 3 · Living inside someone else's page Build: Embed as an iframe with a small script that measures the document height and posts it to the parent, and a parent snippet that applies it only from your origin. Keep an allowlist of host origins and set the header that restricts who may frame you. Assume no third-party storage: anything you were tempted to keep in a cookie has to live on the server keyed by the client key or not exist. Trap: Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. Done when: The embed resizes to its content on load and after a branch changes the question shown, tested in a host page. Height messages from an origin not on the allowlist are ignored, tested by posting one. The framing header is set from the allowlist and a page not on it cannot embed the form, tested. The form works with all third-party storage blocked, verified in a browser configured that way. Do not build yet: No results, export or retention yet. ### Phase 4 · Results that say what they are Build: Report per question: answers, skips and the drop-off funnel from load to start to submit. Show counts beside every percentage and suppress percentages below a configured minimum count. Label poll results as self-selected respondents in the report itself. For a quiz, score against the stored answer key and show which questions were missed; do not generate a label about the person. Trap: A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. Done when: Every percentage in the report appears with its count, checked across every report view. Percentages below the configured minimum count are suppressed and explained, tested at the boundary. The drop-off funnel distinguishes loads, starts and submissions, and partial responses appear in it. Quiz scoring comes from the stored key and the report shows the missed questions rather than a label about the respondent. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting the answers out, and letting them go Build: Export responses as CSV and JSON including partials, with one column per question and stable identifiers. Set a retention period per form and delete expired responses with a job. Implement deletion of a single response by its identifier, including uploaded files. Publish a short privacy notice on the form itself saying what is collected and how long it is kept. Trap: A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. Done when: Export survives values containing commas, quotes and newlines and opens correctly in a spreadsheet, tested with a crafted response. Partial responses appear in the export marked as incomplete. Retention deletes expired responses and their uploads, tested with a backdated row. Deleting one response removes its answers and uploads and leaves the aggregate counts consistent, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection, which brings a processor, a compliance surface and a refund story bigger than the rest of the app. - A large template library, which is the visible part of what the subscription sells and is content rather than code. - Third-party challenge services for spam, since they report every respondent to somebody else for a problem a honeypot and a rate limit mostly solve. - Cross-form audience tracking and lead scoring, which needs an identity per visitor and is the opposite of what an embedded form should do. ### README must contain - That the response row is committed before any notification, and why that ordering is the guarantee - That an embedded form cannot enforce one response per person once third-party storage is blocked, and what is done instead - Which accessibility criteria are checked in the suite and which were checked by hand - That poll results are self-selected and never come with a margin of error - The retention period, what deletion removes, and where uploads are stored ===== AGENTS.md ===== # Agent instructions · Opinion Stage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server rendering both the builder and the public form, no client framework on the public page, SQLite with the response written in one transaction before any notification, An iframe plus a tiny script that posts its height to the parent, with an origin allowlist, Honeypot, timing check and rate limiting, no third-party challenge, Labels, autofill tokens and a minimum target size, checked in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ===== BUILD_PLAN.md ===== # Build plan · Opinion Stage A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A form people can actually complete Every question is properly labelled, keyboard-operable and sized for a thumb. ### Steps 1. Use a real label for every control A placeholder is an example, not a name. Files: `src/render/question.js`, `tests/a11y.test.js` 2. Put the specification's autofill tokens on contact fields It is one attribute and it satisfies a published criterion. Files: `src/render/question.js` 3. Check target size and spacing in the suite Measured, because a thumb is bigger than a mouse pointer. Files: `tests/a11y.test.js` ### Done when - [ ] Every control has an associated label and error - [ ] Contact fields autofill in a real browser - [ ] Targets meet the size or spacing criterion - [ ] The form completes with the keyboard alone ### Watch out - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. ## Phase 2 · A submission that cannot be lost or doubled A response survives a failed notification, a double click and a flaky connection. ### Steps 1. Commit the response before anything else happens This ordering is the entire guarantee. Files: `src/submit.js`, `tests/submit.test.js` 2. Make the client key the idempotency key Double clicks and retries are the normal case. Files: `src/submit.js` 3. Queue notifications with their own retries A mail outage should not cost you an answer. Files: `src/notify.js`, `tests/notify.test.js` ### Done when - [ ] A response survives a notification failure - [ ] One client key yields one response - [ ] Oversized, honeypot and instant submissions are rejected unstored - [ ] No submitted value reaches the logs ### Watch out - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. ## Phase 3 · Living inside someone else's page The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get. ### Steps 1. Post the height and verify the origin on both sides An unchecked message handler is somebody else's script talking to yours. Files: `src/public/embed.js`, `src/public/host-snippet.js` 2. Set the framing header from the allowlist So your form does not appear on a site you have never heard of. Files: `src/headers.js` 3. Build as if third-party storage does not exist For a large share of visitors it already does not. Files: `src/session.js`, `tests/no-storage.test.js` ### Done when - [ ] The embed resizes on load and after a branch - [ ] Messages from unlisted origins are ignored - [ ] Unlisted pages cannot frame the form - [ ] Everything works with third-party storage blocked ### Watch out - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. ## Phase 4 · Results that say what they are Every figure carries its count, and nothing implies a sample of a population. ### Steps 1. Print the count beside every percentage A percentage without an n is a rumour with a decimal point. Files: `src/report.js` 2. Suppress small-n percentages entirely Below a threshold the number misleads more than it informs. Files: `src/report.js`, `tests/report.test.js` 3. Put self-selection in the report, not the footnotes The report is what gets shared. Files: `src/templates/report.html` ### Done when - [ ] Every percentage shows its count - [ ] Small-n percentages are suppressed with an explanation - [ ] The funnel separates loads, starts and submissions - [ ] Quiz results show missed questions, not a label ### Watch out - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ## Not in this build - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. ## After v1, if you want it - Conditional branching beyond one level, once the funnel makes the drop-off points obvious - A results page you can share publicly with a token, reusing the suppression rules - Scheduled closing of a form at a date, which is one field and a job ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, responses and answers. The responses are the only irreplaceable part. DATABASE_PATH=./data/forms.db # Required. Used in embed snippets and in the framing header. PUBLIC_BASE_URL=https://forms.example.com # Optional · secret. Owner notifications only. A failure here never affects a stored response. MAIL_API_KEY=re_...redacted # Required. Where submission notifications go. The values themselves are not in the log. NOTIFY_TO=you@example.com # Required. Files attached to responses. Deleted with the response and by retention. UPLOAD_DIR=./data/uploads # Required. Stated in the privacy notice on the form and enforced by a job. RESPONSE_RETENTION_DAYS=365 # Required. Below this, the report shows counts only and says why. MIN_COUNT_FOR_PERCENT=20
You are building a lean indie version of Opinion Stage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Opinion Stage · indie build A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server rendering both the builder and the public form, no client framework on the public page | the embedded page has to work on a slow phone on a stranger's website | | Storage | SQLite with the response written in one transaction before any notification | the ordering is the whole guarantee | | Embedding | An iframe plus a tiny script that posts its height to the parent, with an origin allowlist | you cannot control the host page, and you should not want to | | Anti-spam | Honeypot, timing check and rate limiting, no third-party challenge | a challenge service means every respondent is reported to somebody else | | Accessibility | Labels, autofill tokens and a minimum target size, checked in the suite | a form nobody can complete collects nothing, and the criteria are published | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the builder, the public form and the notification worker in one process. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A small server with a domain and a certificate** · a few pounds a month plus the domain - Why: The form is embedded on other people's pages over a secure connection, so it needs a stable public address. - Get it: Any small virtual server with automatic certificates. - Verify: The form loads in an iframe on a test page over a secure connection - [ ] **An email sending path for owner notifications** · free tiers exist; check the current limits before relying on one - Why: Notifications are the one outbound dependency, and it must be able to fail without costing a response. - Get it: Any transactional sending service or an SMTP account you control. - Verify: A test notification arrives and a deliberate failure leaves the response intact - [ ] **A page you can embed into for testing** · free - Why: Height messaging, the framing header and blocked third-party storage are only testable from a genuinely different origin. - Get it: Any static page on a second domain or a local server on another port. - Verify: The embed resizes correctly and an unlisted origin is refused - [ ] **Your retention, minimum count and spam rules** · free - Why: Each becomes a test and a line in the privacy notice, and choosing them afterwards means choosing what the code already did. - Get it: Write down how long responses are kept, the minimum count for showing a percentage, and what makes a submission spam. - Verify: All three are in configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. - templates, hosted delivery, lead features, and engagement reporting - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Opinion Stage, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Opinion Stage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a poll, quiz and survey builder that embeds on sites you do not control and reports results without overclaiming to replace Opinion Stage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules. The first is operational and the second is about honesty. A submitted response is never lost. Somebody typed it once and will not type it again, so the response is written and committed before the thank-you screen is rendered, a retried submission with the same client identifier updates rather than inserts, and an email notification that fails never causes the response to be rolled back. Notification is a side effect; the row is the product. Second, results from an embedded poll are answers from people who chose to answer, which is not a sample of any population. The report says so in words, next to the numbers, always. Do not print a percentage without the count it came from, do not compute a margin of error for a self-selected poll, and if you build a personality-style quiz, the result is a description of the answers given and never a score dressed up as a measurement of the person. ### Stack (fixed, do not substitute) - Runtime: One server rendering both the builder and the public form, no client framework on the public page · the embedded page has to work on a slow phone on a stranger's website - Storage: SQLite with the response written in one transaction before any notification · the ordering is the whole guarantee - Embedding: An iframe plus a tiny script that posts its height to the parent, with an origin allowlist · you cannot control the host page, and you should not want to - Anti-spam: Honeypot, timing check and rate limiting, no third-party challenge · a challenge service means every respondent is reported to somebody else - Accessibility: Labels, autofill tokens and a minimum target size, checked in the suite · a form nobody can complete collects nothing, and the criteria are published ### Data model (create this before Phase 1) - forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect. ### Phase 1 · A form people can actually complete Build: Render questions from the schema with a real label element per control, help text and errors associated with the field, and the right autofill token on contact fields so a browser can fill them. Follow the published criteria: identifying input purpose is a level AA criterion and the autofill field names come from the HTML specification's list, labels or instructions is level A, and the minimum target size criterion added in the 2.2 recommendation asks for 24 by 24 CSS pixels unless the spacing exception applies. Check these in the suite rather than by eye. Trap: Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. Done when: Every control has an associated label and every error message is programmatically associated with its field, checked by an automated accessibility pass in the suite. Contact fields carry autofill tokens from the specification's list and a browser fills them in a manual check. Interactive targets meet the minimum size or satisfy the spacing exception, measured. The whole form can be completed with the keyboard alone, including option groups and the submit action. Do not build yet: No submissions, embedding, results or notifications yet. ### Phase 2 · A submission that cannot be lost or doubled Build: Generate a client key when the form loads and send it with the submission. Write the response and its answers in one transaction, then render the thank-you screen, then queue the notification. A submission arriving twice with the same client key updates the existing row. Limit body size, reject on the honeypot and on an implausibly fast completion, and never write submitted values into a log line. Trap: Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. Done when: A response survives a notification failure, tested by breaking the mail path and checking the row exists. Submitting the same client key twice produces one response, tested with a double click and with a retried request. An oversized body, a filled honeypot and a sub-second completion are each rejected without storing the payload, all three tested. No submitted value appears in any log line, asserted by scanning logs in a test. Do not build yet: No embedding, results or export yet. ### Phase 3 · Living inside someone else's page Build: Embed as an iframe with a small script that measures the document height and posts it to the parent, and a parent snippet that applies it only from your origin. Keep an allowlist of host origins and set the header that restricts who may frame you. Assume no third-party storage: anything you were tempted to keep in a cookie has to live on the server keyed by the client key or not exist. Trap: Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. Done when: The embed resizes to its content on load and after a branch changes the question shown, tested in a host page. Height messages from an origin not on the allowlist are ignored, tested by posting one. The framing header is set from the allowlist and a page not on it cannot embed the form, tested. The form works with all third-party storage blocked, verified in a browser configured that way. Do not build yet: No results, export or retention yet. ### Phase 4 · Results that say what they are Build: Report per question: answers, skips and the drop-off funnel from load to start to submit. Show counts beside every percentage and suppress percentages below a configured minimum count. Label poll results as self-selected respondents in the report itself. For a quiz, score against the stored answer key and show which questions were missed; do not generate a label about the person. Trap: A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. Done when: Every percentage in the report appears with its count, checked across every report view. Percentages below the configured minimum count are suppressed and explained, tested at the boundary. The drop-off funnel distinguishes loads, starts and submissions, and partial responses appear in it. Quiz scoring comes from the stored key and the report shows the missed questions rather than a label about the respondent. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting the answers out, and letting them go Build: Export responses as CSV and JSON including partials, with one column per question and stable identifiers. Set a retention period per form and delete expired responses with a job. Implement deletion of a single response by its identifier, including uploaded files. Publish a short privacy notice on the form itself saying what is collected and how long it is kept. Trap: A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. Done when: Export survives values containing commas, quotes and newlines and opens correctly in a spreadsheet, tested with a crafted response. Partial responses appear in the export marked as incomplete. Retention deletes expired responses and their uploads, tested with a backdated row. Deleting one response removes its answers and uploads and leaves the aggregate counts consistent, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection, which brings a processor, a compliance surface and a refund story bigger than the rest of the app. - A large template library, which is the visible part of what the subscription sells and is content rather than code. - Third-party challenge services for spam, since they report every respondent to somebody else for a problem a honeypot and a rate limit mostly solve. - Cross-form audience tracking and lead scoring, which needs an identity per visitor and is the opposite of what an embedded form should do. ### README must contain - That the response row is committed before any notification, and why that ordering is the guarantee - That an embedded form cannot enforce one response per person once third-party storage is blocked, and what is done instead - Which accessibility criteria are checked in the suite and which were checked by hand - That poll results are self-selected and never come with a margin of error - The retention period, what deletion removes, and where uploads are stored ===== AGENTS.md ===== # Agent instructions · Opinion Stage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server rendering both the builder and the public form, no client framework on the public page, SQLite with the response written in one transaction before any notification, An iframe plus a tiny script that posts its height to the parent, with an origin allowlist, Honeypot, timing check and rate limiting, no third-party challenge, Labels, autofill tokens and a minimum target size, checked in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ===== BUILD_PLAN.md ===== # Build plan · Opinion Stage A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A form people can actually complete Every question is properly labelled, keyboard-operable and sized for a thumb. ### Steps 1. Use a real label for every control A placeholder is an example, not a name. Files: `src/render/question.js`, `tests/a11y.test.js` 2. Put the specification's autofill tokens on contact fields It is one attribute and it satisfies a published criterion. Files: `src/render/question.js` 3. Check target size and spacing in the suite Measured, because a thumb is bigger than a mouse pointer. Files: `tests/a11y.test.js` ### Done when - [ ] Every control has an associated label and error - [ ] Contact fields autofill in a real browser - [ ] Targets meet the size or spacing criterion - [ ] The form completes with the keyboard alone ### Watch out - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. ## Phase 2 · A submission that cannot be lost or doubled A response survives a failed notification, a double click and a flaky connection. ### Steps 1. Commit the response before anything else happens This ordering is the entire guarantee. Files: `src/submit.js`, `tests/submit.test.js` 2. Make the client key the idempotency key Double clicks and retries are the normal case. Files: `src/submit.js` 3. Queue notifications with their own retries A mail outage should not cost you an answer. Files: `src/notify.js`, `tests/notify.test.js` ### Done when - [ ] A response survives a notification failure - [ ] One client key yields one response - [ ] Oversized, honeypot and instant submissions are rejected unstored - [ ] No submitted value reaches the logs ### Watch out - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. ## Phase 3 · Living inside someone else's page The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get. ### Steps 1. Post the height and verify the origin on both sides An unchecked message handler is somebody else's script talking to yours. Files: `src/public/embed.js`, `src/public/host-snippet.js` 2. Set the framing header from the allowlist So your form does not appear on a site you have never heard of. Files: `src/headers.js` 3. Build as if third-party storage does not exist For a large share of visitors it already does not. Files: `src/session.js`, `tests/no-storage.test.js` ### Done when - [ ] The embed resizes on load and after a branch - [ ] Messages from unlisted origins are ignored - [ ] Unlisted pages cannot frame the form - [ ] Everything works with third-party storage blocked ### Watch out - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. ## Phase 4 · Results that say what they are Every figure carries its count, and nothing implies a sample of a population. ### Steps 1. Print the count beside every percentage A percentage without an n is a rumour with a decimal point. Files: `src/report.js` 2. Suppress small-n percentages entirely Below a threshold the number misleads more than it informs. Files: `src/report.js`, `tests/report.test.js` 3. Put self-selection in the report, not the footnotes The report is what gets shared. Files: `src/templates/report.html` ### Done when - [ ] Every percentage shows its count - [ ] Small-n percentages are suppressed with an explanation - [ ] The funnel separates loads, starts and submissions - [ ] Quiz results show missed questions, not a label ### Watch out - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ## Not in this build - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. ## After v1, if you want it - Conditional branching beyond one level, once the funnel makes the drop-off points obvious - A results page you can share publicly with a token, reusing the suppression rules - Scheduled closing of a form at a date, which is one field and a job ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, responses and answers. The responses are the only irreplaceable part. DATABASE_PATH=./data/forms.db # Required. Used in embed snippets and in the framing header. PUBLIC_BASE_URL=https://forms.example.com # Optional · secret. Owner notifications only. A failure here never affects a stored response. MAIL_API_KEY=re_...redacted # Required. Where submission notifications go. The values themselves are not in the log. NOTIFY_TO=you@example.com # Required. Files attached to responses. Deleted with the response and by retention. UPLOAD_DIR=./data/uploads # Required. Stated in the privacy notice on the form and enforced by a job. RESPONSE_RETENTION_DAYS=365 # Required. Below this, the report shows counts only and says why. MIN_COUNT_FOR_PERCENT=20
You are building a production product version of Opinion Stage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Opinion Stage · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Opinion Stage, create a poll, quiz, or survey and embed it with basic response analytics. The hard boundary is templates, hosted delivery, lead features, and engagement reporting, plus integrations, deliverability, and workflow depth. ## Product outcome A form service where a submitted answer is never lost to a downstream failure, the embed behaves on a site you do not control and without third-party storage, and every reported figure carries the count it came from. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - Resend API key or SMTP credentials - self-hosted deploy target ## Explicit non-goals for v1 - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. - templates, hosted delivery, lead features, and engagement reporting - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance ## Success criteria - A response is confirmed to survive a notification failure - One client key is confirmed to produce one response - Unlisted origins are confirmed unable to frame or message the form - Every percentage is confirmed to carry its count - Export is confirmed to survive commas, quotes and newlines ===== BRIEF.md ===== # Build brief · Opinion Stage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a poll, quiz and survey builder that embeds on sites you do not control and reports results without overclaiming to replace Opinion Stage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules. The first is operational and the second is about honesty. A submitted response is never lost. Somebody typed it once and will not type it again, so the response is written and committed before the thank-you screen is rendered, a retried submission with the same client identifier updates rather than inserts, and an email notification that fails never causes the response to be rolled back. Notification is a side effect; the row is the product. Second, results from an embedded poll are answers from people who chose to answer, which is not a sample of any population. The report says so in words, next to the numbers, always. Do not print a percentage without the count it came from, do not compute a margin of error for a self-selected poll, and if you build a personality-style quiz, the result is a description of the answers given and never a score dressed up as a measurement of the person. ### Stack (fixed, do not substitute) - Runtime: One server rendering both the builder and the public form, no client framework on the public page · the embedded page has to work on a slow phone on a stranger's website - Storage: SQLite with the response written in one transaction before any notification · the ordering is the whole guarantee - Embedding: An iframe plus a tiny script that posts its height to the parent, with an origin allowlist · you cannot control the host page, and you should not want to - Anti-spam: Honeypot, timing check and rate limiting, no third-party challenge · a challenge service means every respondent is reported to somebody else - Accessibility: Labels, autofill tokens and a minimum target size, checked in the suite · a form nobody can complete collects nothing, and the criteria are published ### Data model (create this before Phase 1) - forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect. ### Phase 1 · A form people can actually complete Build: Render questions from the schema with a real label element per control, help text and errors associated with the field, and the right autofill token on contact fields so a browser can fill them. Follow the published criteria: identifying input purpose is a level AA criterion and the autofill field names come from the HTML specification's list, labels or instructions is level A, and the minimum target size criterion added in the 2.2 recommendation asks for 24 by 24 CSS pixels unless the spacing exception applies. Check these in the suite rather than by eye. Trap: Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. Done when: Every control has an associated label and every error message is programmatically associated with its field, checked by an automated accessibility pass in the suite. Contact fields carry autofill tokens from the specification's list and a browser fills them in a manual check. Interactive targets meet the minimum size or satisfy the spacing exception, measured. The whole form can be completed with the keyboard alone, including option groups and the submit action. Do not build yet: No submissions, embedding, results or notifications yet. ### Phase 2 · A submission that cannot be lost or doubled Build: Generate a client key when the form loads and send it with the submission. Write the response and its answers in one transaction, then render the thank-you screen, then queue the notification. A submission arriving twice with the same client key updates the existing row. Limit body size, reject on the honeypot and on an implausibly fast completion, and never write submitted values into a log line. Trap: Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. Done when: A response survives a notification failure, tested by breaking the mail path and checking the row exists. Submitting the same client key twice produces one response, tested with a double click and with a retried request. An oversized body, a filled honeypot and a sub-second completion are each rejected without storing the payload, all three tested. No submitted value appears in any log line, asserted by scanning logs in a test. Do not build yet: No embedding, results or export yet. ### Phase 3 · Living inside someone else's page Build: Embed as an iframe with a small script that measures the document height and posts it to the parent, and a parent snippet that applies it only from your origin. Keep an allowlist of host origins and set the header that restricts who may frame you. Assume no third-party storage: anything you were tempted to keep in a cookie has to live on the server keyed by the client key or not exist. Trap: Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. Done when: The embed resizes to its content on load and after a branch changes the question shown, tested in a host page. Height messages from an origin not on the allowlist are ignored, tested by posting one. The framing header is set from the allowlist and a page not on it cannot embed the form, tested. The form works with all third-party storage blocked, verified in a browser configured that way. Do not build yet: No results, export or retention yet. ### Phase 4 · Results that say what they are Build: Report per question: answers, skips and the drop-off funnel from load to start to submit. Show counts beside every percentage and suppress percentages below a configured minimum count. Label poll results as self-selected respondents in the report itself. For a quiz, score against the stored answer key and show which questions were missed; do not generate a label about the person. Trap: A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. Done when: Every percentage in the report appears with its count, checked across every report view. Percentages below the configured minimum count are suppressed and explained, tested at the boundary. The drop-off funnel distinguishes loads, starts and submissions, and partial responses appear in it. Quiz scoring comes from the stored key and the report shows the missed questions rather than a label about the respondent. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting the answers out, and letting them go Build: Export responses as CSV and JSON including partials, with one column per question and stable identifiers. Set a retention period per form and delete expired responses with a job. Implement deletion of a single response by its identifier, including uploaded files. Publish a short privacy notice on the form itself saying what is collected and how long it is kept. Trap: A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. Done when: Export survives values containing commas, quotes and newlines and opens correctly in a spreadsheet, tested with a crafted response. Partial responses appear in the export marked as incomplete. Retention deletes expired responses and their uploads, tested with a backdated row. Deleting one response removes its answers and uploads and leaves the aggregate counts consistent, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection, which brings a processor, a compliance surface and a refund story bigger than the rest of the app. - A large template library, which is the visible part of what the subscription sells and is content rather than code. - Third-party challenge services for spam, since they report every respondent to somebody else for a problem a honeypot and a rate limit mostly solve. - Cross-form audience tracking and lead scoring, which needs an identity per visitor and is the opposite of what an embedded form should do. ### README must contain - That the response row is committed before any notification, and why that ordering is the guarantee - That an embedded form cannot enforce one response per person once third-party storage is blocked, and what is done instead - Which accessibility criteria are checked in the suite and which were checked by hand - That poll results are self-selected and never come with a margin of error - The retention period, what deletion removes, and where uploads are stored ===== ARCHITECTURE.md ===== # Architecture · Opinion Stage ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server rendering both the builder and the public form, no client framework on the public page | the embedded page has to work on a slow phone on a stranger's website | | Storage | SQLite with the response written in one transaction before any notification | the ordering is the whole guarantee | | Embedding | An iframe plus a tiny script that posts its height to the parent, with an origin allowlist | you cannot control the host page, and you should not want to | | Anti-spam | Honeypot, timing check and rate limiting, no third-party challenge | a challenge service means every respondent is reported to somebody else | | Accessibility | Labels, autofill tokens and a minimum target size, checked in the suite | a form nobody can complete collects nothing, and the criteria are published | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Builder | Form and question schemas. | Any editor, provided the public renderer stays schema-driven. | | Submission | Transaction, idempotency and spam checks. | Not swappable for a handler that sends mail before committing. | | Embed | Height messaging, origin allowlist and framing header. | Any transport, provided both sides check the origin. | | Report | Counts, suppression and the self-selection statement. | Any presentation, provided no percentage appears without its count. | | Lifecycle | Export, retention and deletion. | Any format, provided uploads are deleted with their responses. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, responses and answers. The responses are the only irreplaceable part. - `PUBLIC_BASE_URL` · required · Used in embed snippets and in the framing header. - `MAIL_API_KEY` · optional, secret · Owner notifications only. A failure here never affects a stored response. - `NOTIFY_TO` · required · Where submission notifications go. The values themselves are not in the log. - `UPLOAD_DIR` · required · Files attached to responses. Deleted with the response and by retention. - `RESPONSE_RETENTION_DAYS` · required · Stated in the privacy notice on the form and enforced by a job. - `MIN_COUNT_FOR_PERCENT` · required · Below this, the report shows counts only and says why. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Opinion Stage product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One server rendering both the builder and the public form, no client framework on the public page, SQLite with the response written in one transaction before any notification, An iframe plus a tiny script that posts its height to the parent, with an origin allowlist, Honeypot, timing check and rate limiting, no third-party challenge, Labels, autofill tokens and a minimum target size, checked in the suite. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. - A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. ===== MILESTONES.md ===== # Delivery milestones · Opinion Stage Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A form people can actually complete Every question is properly labelled, keyboard-operable and sized for a thumb. ### Steps 1. Use a real label for every control A placeholder is an example, not a name. Files: `src/render/question.js`, `tests/a11y.test.js` 2. Put the specification's autofill tokens on contact fields It is one attribute and it satisfies a published criterion. Files: `src/render/question.js` 3. Check target size and spacing in the suite Measured, because a thumb is bigger than a mouse pointer. Files: `tests/a11y.test.js` ### Done when - [ ] Every control has an associated label and error - [ ] Contact fields autofill in a real browser - [ ] Targets meet the size or spacing criterion - [ ] The form completes with the keyboard alone ### Watch out - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. ## M2 · A submission that cannot be lost or doubled A response survives a failed notification, a double click and a flaky connection. ### Steps 1. Commit the response before anything else happens This ordering is the entire guarantee. Files: `src/submit.js`, `tests/submit.test.js` 2. Make the client key the idempotency key Double clicks and retries are the normal case. Files: `src/submit.js` 3. Queue notifications with their own retries A mail outage should not cost you an answer. Files: `src/notify.js`, `tests/notify.test.js` ### Done when - [ ] A response survives a notification failure - [ ] One client key yields one response - [ ] Oversized, honeypot and instant submissions are rejected unstored - [ ] No submitted value reaches the logs ### Watch out - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. ## M3 · Living inside someone else's page The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get. ### Steps 1. Post the height and verify the origin on both sides An unchecked message handler is somebody else's script talking to yours. Files: `src/public/embed.js`, `src/public/host-snippet.js` 2. Set the framing header from the allowlist So your form does not appear on a site you have never heard of. Files: `src/headers.js` 3. Build as if third-party storage does not exist For a large share of visitors it already does not. Files: `src/session.js`, `tests/no-storage.test.js` ### Done when - [ ] The embed resizes on load and after a branch - [ ] Messages from unlisted origins are ignored - [ ] Unlisted pages cannot frame the form - [ ] Everything works with third-party storage blocked ### Watch out - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. ## M4 · Results that say what they are Every figure carries its count, and nothing implies a sample of a population. ### Steps 1. Print the count beside every percentage A percentage without an n is a rumour with a decimal point. Files: `src/report.js` 2. Suppress small-n percentages entirely Below a threshold the number misleads more than it informs. Files: `src/report.js`, `tests/report.test.js` 3. Put self-selection in the report, not the footnotes The report is what gets shared. Files: `src/templates/report.html` ### Done when - [ ] Every percentage shows its count - [ ] Small-n percentages are suppressed with an explanation - [ ] The funnel separates loads, starts and submissions - [ ] Quiz results show missed questions, not a label ### Watch out - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ## M5 · Getting the answers out, and letting them go (production only) Responses export cleanly, expire on a stated schedule, and one person's data can be removed. ### Steps 1. Use a real CSV writer and test the nasty values Commas and newlines are in every long answer. Files: `src/export.js`, `tests/csv.test.js` 2. Delete uploads along with responses An orphan file is a data protection problem with no owner. Files: `src/retention.js` 3. Show the privacy notice on the form Where the person deciding whether to answer can see it. Files: `src/templates/form.html` ### Done when - [ ] Export handles commas, quotes and newlines - [ ] Partials are exported and marked incomplete - [ ] Retention removes responses and their uploads - [ ] Single-response deletion leaves counts consistent ### Watch out - A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. ===== OPERATIONS.md ===== # Operations · Opinion Stage ## Backup The database and the upload directory together, since a response referencing a missing file is worse than either alone. Responses cannot be regenerated by anyone. ## Restore Restore both, submit a test response, and confirm the notification queue drains without resending old notifications. A restore that replays a week of notifications is its own incident. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Undelivered notifications, submissions rejected as spam, and the gap between starts and submissions per form. A sudden rise in the last one is usually a broken question rather than a bad audience. ## Incident checklist The incident that matters is a missing response. The client key and the notification rows together show whether it was never submitted, submitted and stored, or stored and never announced. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A response is confirmed to survive a notification failure - [ ] One client key is confirmed to produce one response - [ ] Unlisted origins are confirmed unable to frame or message the form - [ ] Every percentage is confirmed to carry its count - [ ] Export is confirmed to survive commas, quotes and newlines ## Launch constraint Do not market omitted Opinion Stage capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, responses and answers. The responses are the only irreplaceable part. DATABASE_PATH=./data/forms.db # Required. Used in embed snippets and in the framing header. PUBLIC_BASE_URL=https://forms.example.com # Optional · secret. Owner notifications only. A failure here never affects a stored response. MAIL_API_KEY=re_...redacted # Required. Where submission notifications go. The values themselves are not in the log. NOTIFY_TO=you@example.com # Required. Files attached to responses. Deleted with the response and by retention. UPLOAD_DIR=./data/uploads # Required. Stated in the privacy notice on the form and enforced by a job. RESPONSE_RETENTION_DAYS=365 # Required. Below this, the report shows counts only and says why. MIN_COUNT_FOR_PERCENT=20
# Opinion Stage · indie build A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server rendering both the builder and the public form, no client framework on the public page | the embedded page has to work on a slow phone on a stranger's website | | Storage | SQLite with the response written in one transaction before any notification | the ordering is the whole guarantee | | Embedding | An iframe plus a tiny script that posts its height to the parent, with an origin allowlist | you cannot control the host page, and you should not want to | | Anti-spam | Honeypot, timing check and rate limiting, no third-party challenge | a challenge service means every respondent is reported to somebody else | | Accessibility | Labels, autofill tokens and a minimum target size, checked in the suite | a form nobody can complete collects nothing, and the criteria are published | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the builder, the public form and the notification worker in one process. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A small server with a domain and a certificate** · a few pounds a month plus the domain - Why: The form is embedded on other people's pages over a secure connection, so it needs a stable public address. - Get it: Any small virtual server with automatic certificates. - Verify: The form loads in an iframe on a test page over a secure connection - [ ] **An email sending path for owner notifications** · free tiers exist; check the current limits before relying on one - Why: Notifications are the one outbound dependency, and it must be able to fail without costing a response. - Get it: Any transactional sending service or an SMTP account you control. - Verify: A test notification arrives and a deliberate failure leaves the response intact - [ ] **A page you can embed into for testing** · free - Why: Height messaging, the framing header and blocked third-party storage are only testable from a genuinely different origin. - Get it: Any static page on a second domain or a local server on another port. - Verify: The embed resizes correctly and an unlisted origin is refused - [ ] **Your retention, minimum count and spam rules** · free - Why: Each becomes a test and a line in the privacy notice, and choosing them afterwards means choosing what the code already did. - Get it: Write down how long responses are kept, the minimum count for showing a percentage, and what makes a submission spam. - Verify: All three are in configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. - templates, hosted delivery, lead features, and engagement reporting - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Opinion Stage, and the README should say so rather than pretend.
# Build brief · Opinion Stage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a poll, quiz and survey builder that embeds on sites you do not control and reports results without overclaiming to replace Opinion Stage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules. The first is operational and the second is about honesty. A submitted response is never lost. Somebody typed it once and will not type it again, so the response is written and committed before the thank-you screen is rendered, a retried submission with the same client identifier updates rather than inserts, and an email notification that fails never causes the response to be rolled back. Notification is a side effect; the row is the product. Second, results from an embedded poll are answers from people who chose to answer, which is not a sample of any population. The report says so in words, next to the numbers, always. Do not print a percentage without the count it came from, do not compute a margin of error for a self-selected poll, and if you build a personality-style quiz, the result is a description of the answers given and never a score dressed up as a measurement of the person. ### Stack (fixed, do not substitute) - Runtime: One server rendering both the builder and the public form, no client framework on the public page · the embedded page has to work on a slow phone on a stranger's website - Storage: SQLite with the response written in one transaction before any notification · the ordering is the whole guarantee - Embedding: An iframe plus a tiny script that posts its height to the parent, with an origin allowlist · you cannot control the host page, and you should not want to - Anti-spam: Honeypot, timing check and rate limiting, no third-party challenge · a challenge service means every respondent is reported to somebody else - Accessibility: Labels, autofill tokens and a minimum target size, checked in the suite · a form nobody can complete collects nothing, and the criteria are published ### Data model (create this before Phase 1) - forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect. ### Phase 1 · A form people can actually complete Build: Render questions from the schema with a real label element per control, help text and errors associated with the field, and the right autofill token on contact fields so a browser can fill them. Follow the published criteria: identifying input purpose is a level AA criterion and the autofill field names come from the HTML specification's list, labels or instructions is level A, and the minimum target size criterion added in the 2.2 recommendation asks for 24 by 24 CSS pixels unless the spacing exception applies. Check these in the suite rather than by eye. Trap: Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. Done when: Every control has an associated label and every error message is programmatically associated with its field, checked by an automated accessibility pass in the suite. Contact fields carry autofill tokens from the specification's list and a browser fills them in a manual check. Interactive targets meet the minimum size or satisfy the spacing exception, measured. The whole form can be completed with the keyboard alone, including option groups and the submit action. Do not build yet: No submissions, embedding, results or notifications yet. ### Phase 2 · A submission that cannot be lost or doubled Build: Generate a client key when the form loads and send it with the submission. Write the response and its answers in one transaction, then render the thank-you screen, then queue the notification. A submission arriving twice with the same client key updates the existing row. Limit body size, reject on the honeypot and on an implausibly fast completion, and never write submitted values into a log line. Trap: Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. Done when: A response survives a notification failure, tested by breaking the mail path and checking the row exists. Submitting the same client key twice produces one response, tested with a double click and with a retried request. An oversized body, a filled honeypot and a sub-second completion are each rejected without storing the payload, all three tested. No submitted value appears in any log line, asserted by scanning logs in a test. Do not build yet: No embedding, results or export yet. ### Phase 3 · Living inside someone else's page Build: Embed as an iframe with a small script that measures the document height and posts it to the parent, and a parent snippet that applies it only from your origin. Keep an allowlist of host origins and set the header that restricts who may frame you. Assume no third-party storage: anything you were tempted to keep in a cookie has to live on the server keyed by the client key or not exist. Trap: Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. Done when: The embed resizes to its content on load and after a branch changes the question shown, tested in a host page. Height messages from an origin not on the allowlist are ignored, tested by posting one. The framing header is set from the allowlist and a page not on it cannot embed the form, tested. The form works with all third-party storage blocked, verified in a browser configured that way. Do not build yet: No results, export or retention yet. ### Phase 4 · Results that say what they are Build: Report per question: answers, skips and the drop-off funnel from load to start to submit. Show counts beside every percentage and suppress percentages below a configured minimum count. Label poll results as self-selected respondents in the report itself. For a quiz, score against the stored answer key and show which questions were missed; do not generate a label about the person. Trap: A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. Done when: Every percentage in the report appears with its count, checked across every report view. Percentages below the configured minimum count are suppressed and explained, tested at the boundary. The drop-off funnel distinguishes loads, starts and submissions, and partial responses appear in it. Quiz scoring comes from the stored key and the report shows the missed questions rather than a label about the respondent. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting the answers out, and letting them go Build: Export responses as CSV and JSON including partials, with one column per question and stable identifiers. Set a retention period per form and delete expired responses with a job. Implement deletion of a single response by its identifier, including uploaded files. Publish a short privacy notice on the form itself saying what is collected and how long it is kept. Trap: A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. Done when: Export survives values containing commas, quotes and newlines and opens correctly in a spreadsheet, tested with a crafted response. Partial responses appear in the export marked as incomplete. Retention deletes expired responses and their uploads, tested with a backdated row. Deleting one response removes its answers and uploads and leaves the aggregate counts consistent, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection, which brings a processor, a compliance surface and a refund story bigger than the rest of the app. - A large template library, which is the visible part of what the subscription sells and is content rather than code. - Third-party challenge services for spam, since they report every respondent to somebody else for a problem a honeypot and a rate limit mostly solve. - Cross-form audience tracking and lead scoring, which needs an identity per visitor and is the opposite of what an embedded form should do. ### README must contain - That the response row is committed before any notification, and why that ordering is the guarantee - That an embedded form cannot enforce one response per person once third-party storage is blocked, and what is done instead - Which accessibility criteria are checked in the suite and which were checked by hand - That poll results are self-selected and never come with a margin of error - The retention period, what deletion removes, and where uploads are stored
# Agent instructions · Opinion Stage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server rendering both the builder and the public form, no client framework on the public page, SQLite with the response written in one transaction before any notification, An iframe plus a tiny script that posts its height to the parent, with an origin allowlist, Honeypot, timing check and rate limiting, no third-party challenge, Labels, autofill tokens and a minimum target size, checked in the suite. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation.
# Build plan · Opinion Stage A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A form people can actually complete Every question is properly labelled, keyboard-operable and sized for a thumb. ### Steps 1. Use a real label for every control A placeholder is an example, not a name. Files: `src/render/question.js`, `tests/a11y.test.js` 2. Put the specification's autofill tokens on contact fields It is one attribute and it satisfies a published criterion. Files: `src/render/question.js` 3. Check target size and spacing in the suite Measured, because a thumb is bigger than a mouse pointer. Files: `tests/a11y.test.js` ### Done when - [ ] Every control has an associated label and error - [ ] Contact fields autofill in a real browser - [ ] Targets meet the size or spacing criterion - [ ] The form completes with the keyboard alone ### Watch out - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. ## Phase 2 · A submission that cannot be lost or doubled A response survives a failed notification, a double click and a flaky connection. ### Steps 1. Commit the response before anything else happens This ordering is the entire guarantee. Files: `src/submit.js`, `tests/submit.test.js` 2. Make the client key the idempotency key Double clicks and retries are the normal case. Files: `src/submit.js` 3. Queue notifications with their own retries A mail outage should not cost you an answer. Files: `src/notify.js`, `tests/notify.test.js` ### Done when - [ ] A response survives a notification failure - [ ] One client key yields one response - [ ] Oversized, honeypot and instant submissions are rejected unstored - [ ] No submitted value reaches the logs ### Watch out - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. ## Phase 3 · Living inside someone else's page The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get. ### Steps 1. Post the height and verify the origin on both sides An unchecked message handler is somebody else's script talking to yours. Files: `src/public/embed.js`, `src/public/host-snippet.js` 2. Set the framing header from the allowlist So your form does not appear on a site you have never heard of. Files: `src/headers.js` 3. Build as if third-party storage does not exist For a large share of visitors it already does not. Files: `src/session.js`, `tests/no-storage.test.js` ### Done when - [ ] The embed resizes on load and after a branch - [ ] Messages from unlisted origins are ignored - [ ] Unlisted pages cannot frame the form - [ ] Everything works with third-party storage blocked ### Watch out - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. ## Phase 4 · Results that say what they are Every figure carries its count, and nothing implies a sample of a population. ### Steps 1. Print the count beside every percentage A percentage without an n is a rumour with a decimal point. Files: `src/report.js` 2. Suppress small-n percentages entirely Below a threshold the number misleads more than it informs. Files: `src/report.js`, `tests/report.test.js` 3. Put self-selection in the report, not the footnotes The report is what gets shared. Files: `src/templates/report.html` ### Done when - [ ] Every percentage shows its count - [ ] Small-n percentages are suppressed with an explanation - [ ] The funnel separates loads, starts and submissions - [ ] Quiz results show missed questions, not a label ### Watch out - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ## Not in this build - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. ## After v1, if you want it - Conditional branching beyond one level, once the funnel makes the drop-off points obvious - A results page you can share publicly with a token, reusing the suppression rules - Scheduled closing of a form at a date, which is one field and a job
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, responses and answers. The responses are the only irreplaceable part. DATABASE_PATH=./data/forms.db # Required. Used in embed snippets and in the framing header. PUBLIC_BASE_URL=https://forms.example.com # Optional · secret. Owner notifications only. A failure here never affects a stored response. MAIL_API_KEY=re_...redacted # Required. Where submission notifications go. The values themselves are not in the log. NOTIFY_TO=you@example.com # Required. Files attached to responses. Deleted with the response and by retention. UPLOAD_DIR=./data/uploads # Required. Stated in the privacy notice on the form and enforced by a job. RESPONSE_RETENTION_DAYS=365 # Required. Below this, the report shows counts only and says why. MIN_COUNT_FOR_PERCENT=20
# Opinion Stage · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Opinion Stage, create a poll, quiz, or survey and embed it with basic response analytics. The hard boundary is templates, hosted delivery, lead features, and engagement reporting, plus integrations, deliverability, and workflow depth. ## Product outcome A form service where a submitted answer is never lost to a downstream failure, the embed behaves on a site you do not control and without third-party storage, and every reported figure carries the count it came from. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - Resend API key or SMTP credentials - self-hosted deploy target ## Explicit non-goals for v1 - Payment collection. - A large template library. - Third-party spam challenge services. - Cross-form visitor tracking and lead scoring. - templates, hosted delivery, lead features, and engagement reporting - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance ## Success criteria - A response is confirmed to survive a notification failure - One client key is confirmed to produce one response - Unlisted origins are confirmed unable to frame or message the form - Every percentage is confirmed to carry its count - Export is confirmed to survive commas, quotes and newlines
# Build brief · Opinion Stage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a poll, quiz and survey builder that embeds on sites you do not control and reports results without overclaiming to replace Opinion Stage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules. The first is operational and the second is about honesty. A submitted response is never lost. Somebody typed it once and will not type it again, so the response is written and committed before the thank-you screen is rendered, a retried submission with the same client identifier updates rather than inserts, and an email notification that fails never causes the response to be rolled back. Notification is a side effect; the row is the product. Second, results from an embedded poll are answers from people who chose to answer, which is not a sample of any population. The report says so in words, next to the numbers, always. Do not print a percentage without the count it came from, do not compute a margin of error for a self-selected poll, and if you build a personality-style quiz, the result is a description of the answers given and never a score dressed up as a measurement of the person. ### Stack (fixed, do not substitute) - Runtime: One server rendering both the builder and the public form, no client framework on the public page · the embedded page has to work on a slow phone on a stranger's website - Storage: SQLite with the response written in one transaction before any notification · the ordering is the whole guarantee - Embedding: An iframe plus a tiny script that posts its height to the parent, with an origin allowlist · you cannot control the host page, and you should not want to - Anti-spam: Honeypot, timing check and rate limiting, no third-party challenge · a challenge service means every respondent is reported to somebody else - Accessibility: Labels, autofill tokens and a minimum target size, checked in the suite · a form nobody can complete collects nothing, and the criteria are published ### Data model (create this before Phase 1) - forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect. ### Phase 1 · A form people can actually complete Build: Render questions from the schema with a real label element per control, help text and errors associated with the field, and the right autofill token on contact fields so a browser can fill them. Follow the published criteria: identifying input purpose is a level AA criterion and the autofill field names come from the HTML specification's list, labels or instructions is level A, and the minimum target size criterion added in the 2.2 recommendation asks for 24 by 24 CSS pixels unless the spacing exception applies. Check these in the suite rather than by eye. Trap: Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. Done when: Every control has an associated label and every error message is programmatically associated with its field, checked by an automated accessibility pass in the suite. Contact fields carry autofill tokens from the specification's list and a browser fills them in a manual check. Interactive targets meet the minimum size or satisfy the spacing exception, measured. The whole form can be completed with the keyboard alone, including option groups and the submit action. Do not build yet: No submissions, embedding, results or notifications yet. ### Phase 2 · A submission that cannot be lost or doubled Build: Generate a client key when the form loads and send it with the submission. Write the response and its answers in one transaction, then render the thank-you screen, then queue the notification. A submission arriving twice with the same client key updates the existing row. Limit body size, reject on the honeypot and on an implausibly fast completion, and never write submitted values into a log line. Trap: Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. Done when: A response survives a notification failure, tested by breaking the mail path and checking the row exists. Submitting the same client key twice produces one response, tested with a double click and with a retried request. An oversized body, a filled honeypot and a sub-second completion are each rejected without storing the payload, all three tested. No submitted value appears in any log line, asserted by scanning logs in a test. Do not build yet: No embedding, results or export yet. ### Phase 3 · Living inside someone else's page Build: Embed as an iframe with a small script that measures the document height and posts it to the parent, and a parent snippet that applies it only from your origin. Keep an allowlist of host origins and set the header that restricts who may frame you. Assume no third-party storage: anything you were tempted to keep in a cookie has to live on the server keyed by the client key or not exist. Trap: Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. Done when: The embed resizes to its content on load and after a branch changes the question shown, tested in a host page. Height messages from an origin not on the allowlist are ignored, tested by posting one. The framing header is set from the allowlist and a page not on it cannot embed the form, tested. The form works with all third-party storage blocked, verified in a browser configured that way. Do not build yet: No results, export or retention yet. ### Phase 4 · Results that say what they are Build: Report per question: answers, skips and the drop-off funnel from load to start to submit. Show counts beside every percentage and suppress percentages below a configured minimum count. Label poll results as self-selected respondents in the report itself. For a quiz, score against the stored answer key and show which questions were missed; do not generate a label about the person. Trap: A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. Done when: Every percentage in the report appears with its count, checked across every report view. Percentages below the configured minimum count are suppressed and explained, tested at the boundary. The drop-off funnel distinguishes loads, starts and submissions, and partial responses appear in it. Quiz scoring comes from the stored key and the report shows the missed questions rather than a label about the respondent. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Getting the answers out, and letting them go Build: Export responses as CSV and JSON including partials, with one column per question and stable identifiers. Set a retention period per form and delete expired responses with a job. Implement deletion of a single response by its identifier, including uploaded files. Publish a short privacy notice on the form itself saying what is collected and how long it is kept. Trap: A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand. Done when: Export survives values containing commas, quotes and newlines and opens correctly in a spreadsheet, tested with a crafted response. Partial responses appear in the export marked as incomplete. Retention deletes expired responses and their uploads, tested with a backdated row. Deleting one response removes its answers and uploads and leaves the aggregate counts consistent, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection, which brings a processor, a compliance surface and a refund story bigger than the rest of the app. - A large template library, which is the visible part of what the subscription sells and is content rather than code. - Third-party challenge services for spam, since they report every respondent to somebody else for a problem a honeypot and a rate limit mostly solve. - Cross-form audience tracking and lead scoring, which needs an identity per visitor and is the opposite of what an embedded form should do. ### README must contain - That the response row is committed before any notification, and why that ordering is the guarantee - That an embedded form cannot enforce one response per person once third-party storage is blocked, and what is done instead - Which accessibility criteria are checked in the suite and which were checked by hand - That poll results are self-selected and never come with a margin of error - The retention period, what deletion removes, and where uploads are stored
# Architecture · Opinion Stage ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server rendering both the builder and the public form, no client framework on the public page | the embedded page has to work on a slow phone on a stranger's website | | Storage | SQLite with the response written in one transaction before any notification | the ordering is the whole guarantee | | Embedding | An iframe plus a tiny script that posts its height to the parent, with an origin allowlist | you cannot control the host page, and you should not want to | | Anti-spam | Honeypot, timing check and rate limiting, no third-party challenge | a challenge service means every respondent is reported to somebody else | | Accessibility | Labels, autofill tokens and a minimum target size, checked in the suite | a form nobody can complete collects nothing, and the criteria are published | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Builder | Form and question schemas. | Any editor, provided the public renderer stays schema-driven. | | Submission | Transaction, idempotency and spam checks. | Not swappable for a handler that sends mail before committing. | | Embed | Height messaging, origin allowlist and framing header. | Any transport, provided both sides check the origin. | | Report | Counts, suppression and the self-selection statement. | Any presentation, provided no percentage appears without its count. | | Lifecycle | Export, retention and deletion. | Any format, provided uploads are deleted with their responses. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, responses and answers. The responses are the only irreplaceable part. - `PUBLIC_BASE_URL` · required · Used in embed snippets and in the framing header. - `MAIL_API_KEY` · optional, secret · Owner notifications only. A failure here never affects a stored response. - `NOTIFY_TO` · required · Where submission notifications go. The values themselves are not in the log. - `UPLOAD_DIR` · required · Files attached to responses. Deleted with the response and by retention. - `RESPONSE_RETENTION_DAYS` · required · Stated in the privacy notice on the form and enforced by a job. - `MIN_COUNT_FOR_PERCENT` · required · Below this, the report shows counts only and says why. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Opinion Stage product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One server rendering both the builder and the public form, no client framework on the public page, SQLite with the response written in one transaction before any notification, An iframe plus a tiny script that posts its height to the parent, with an origin allowlist, Honeypot, timing check and rate limiting, no third-party challenge, Labels, autofill tokens and a minimum target size, checked in the suite. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. - A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand.
# Delivery milestones · Opinion Stage Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A form people can actually complete Every question is properly labelled, keyboard-operable and sized for a thumb. ### Steps 1. Use a real label for every control A placeholder is an example, not a name. Files: `src/render/question.js`, `tests/a11y.test.js` 2. Put the specification's autofill tokens on contact fields It is one attribute and it satisfies a published criterion. Files: `src/render/question.js` 3. Check target size and spacing in the suite Measured, because a thumb is bigger than a mouse pointer. Files: `tests/a11y.test.js` ### Done when - [ ] Every control has an associated label and error - [ ] Contact fields autofill in a real browser - [ ] Targets meet the size or spacing criterion - [ ] The form completes with the keyboard alone ### Watch out - Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name. ## M2 · A submission that cannot be lost or doubled A response survives a failed notification, a double click and a flaky connection. ### Steps 1. Commit the response before anything else happens This ordering is the entire guarantee. Files: `src/submit.js`, `tests/submit.test.js` 2. Make the client key the idempotency key Double clicks and retries are the normal case. Files: `src/submit.js` 3. Queue notifications with their own retries A mail outage should not cost you an answer. Files: `src/notify.js`, `tests/notify.test.js` ### Done when - [ ] A response survives a notification failure - [ ] One client key yields one response - [ ] Oversized, honeypot and instant submissions are rejected unstored - [ ] No submitted value reaches the logs ### Watch out - Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way. ## M3 · Living inside someone else's page The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get. ### Steps 1. Post the height and verify the origin on both sides An unchecked message handler is somebody else's script talking to yours. Files: `src/public/embed.js`, `src/public/host-snippet.js` 2. Set the framing header from the allowlist So your form does not appear on a site you have never heard of. Files: `src/headers.js` 3. Build as if third-party storage does not exist For a large share of visitors it already does not. Files: `src/session.js`, `tests/no-storage.test.js` ### Done when - [ ] The embed resizes on load and after a branch - [ ] Messages from unlisted origins are ignored - [ ] Unlisted pages cannot frame the form - [ ] Everything works with third-party storage blocked ### Watch out - Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it. ## M4 · Results that say what they are Every figure carries its count, and nothing implies a sample of a population. ### Steps 1. Print the count beside every percentage A percentage without an n is a rumour with a decimal point. Files: `src/report.js` 2. Suppress small-n percentages entirely Below a threshold the number misleads more than it informs. Files: `src/report.js`, `tests/report.test.js` 3. Put self-selection in the report, not the footnotes The report is what gets shared. Files: `src/templates/report.html` ### Done when - [ ] Every percentage shows its count - [ ] Small-n percentages are suppressed with an explanation - [ ] The funnel separates loads, starts and submissions - [ ] Quiz results show missed questions, not a label ### Watch out - A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation. ## M5 · Getting the answers out, and letting them go (production only) Responses export cleanly, expire on a stated schedule, and one person's data can be removed. ### Steps 1. Use a real CSV writer and test the nasty values Commas and newlines are in every long answer. Files: `src/export.js`, `tests/csv.test.js` 2. Delete uploads along with responses An orphan file is a data protection problem with no owner. Files: `src/retention.js` 3. Show the privacy notice on the form Where the person deciding whether to answer can see it. Files: `src/templates/form.html` ### Done when - [ ] Export handles commas, quotes and newlines - [ ] Partials are exported and marked incomplete - [ ] Retention removes responses and their uploads - [ ] Single-response deletion leaves counts consistent ### Watch out - A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand.
# Operations · Opinion Stage ## Backup The database and the upload directory together, since a response referencing a missing file is worse than either alone. Responses cannot be regenerated by anyone. ## Restore Restore both, submit a test response, and confirm the notification queue drains without resending old notifications. A restore that replays a week of notifications is its own incident. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Undelivered notifications, submissions rejected as spam, and the gap between starts and submissions per form. A sudden rise in the last one is usually a broken question rather than a bad audience. ## Incident checklist The incident that matters is a missing response. The client key and the notification rows together show whether it was never submitted, submitted and stored, or stored and never announced. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A response is confirmed to survive a notification failure - [ ] One client key is confirmed to produce one response - [ ] Unlisted origins are confirmed unable to frame or message the form - [ ] Every percentage is confirmed to carry its count - [ ] Export is confirmed to survive commas, quotes and newlines ## Launch constraint Do not market omitted Opinion Stage capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, responses and answers. The responses are the only irreplaceable part. DATABASE_PATH=./data/forms.db # Required. Used in embed snippets and in the framing header. PUBLIC_BASE_URL=https://forms.example.com # Optional · secret. Owner notifications only. A failure here never affects a stored response. MAIL_API_KEY=re_...redacted # Required. Where submission notifications go. The values themselves are not in the log. NOTIFY_TO=you@example.com # Required. Files attached to responses. Deleted with the response and by retention. UPLOAD_DIR=./data/uploads # Required. Stated in the privacy notice on the form and enforced by a job. RESPONSE_RETENTION_DAYS=365 # Required. Below this, the report shows counts only and says why. MIN_COUNT_FOR_PERCENT=20
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Opinion Stage because a basic form is trivial; people pay when the form feeds a dependable business workflow and never loses a response. The recurring cost buys spam control, email delivery, storage, exports, accessibility, conditional logic, and integrations, not just the visible interface.
xtemplates, hosted delivery, lead features, and engagement reporting
xadvanced branching
xpayment and CRM integrations
xlarge template libraries
xenterprise compliance
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Opinion Stage pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 25 responses/month; 2 users; unlimited items and questions; 1 workspace. |
| pro, 1,000 responses | $32/workspace | $25/workspace | 1,000 responses/month; 2 users; 1 workspace. |
| pro, 2,500 responses | $49/workspace | $39/workspace | 2,500 responses/month; 2 users; 1 workspace. |
| business, 10,000 responses | $99/workspace | $79/workspace | 10,000 responses/month; 5 users; 1 workspace. |
| business, 25,000 responses | $249/workspace | $199/workspace | 25,000 responses/month; 5 users; 1 workspace. |
| business, 50,000 responses | $500/workspace | $400/workspace | 50,000 responses/month; 5 users; 1 workspace. |
| enterprise | custom | — | Custom or unlimited responses, users and workspaces. |
free tier25 responses/month; 2 users; unlimited items and questions; 1 workspace.
billingMonthly + annual; annual pricing is displayed as approximately 20% lower and is billed yearly.
verified 2026-08-12 · source ↗
Vibecode Opinion Stage
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Opinion Stage replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Opinion Stage cost?
Opinion Stage costs about $25/month (Starter, checked 2026-07-31), which is $300 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Opinion Stage?
Honestly: templates, hosted delivery, lead features, and engagement reporting; advanced branching; payment and CRM integrations; large template libraries; enterprise compliance. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Opinion Stage?
Yes: HeyForm (Polls, quizzes, and surveys with conversational presentation and exportable answers) Forminator (Polls, quizzes, surveys, calculations, and response summaries, free inside WordPress) The prompt is for when you want it exactly your way.